Skip to content

feat(security): deny ssh/scp/sftp/rsync targeting this same host - #8283

Open
patrigao wants to merge 1 commit into
mainfrom
fix/sandbox-escape-ssh-self
Open

feat(security): deny ssh/scp/sftp/rsync targeting this same host#8283
patrigao wants to merge 1 commit into
mainfrom
fix/sandbox-escape-ssh-self

Conversation

@patrigao

@patrigao patrigao commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

An agent shell running inside the KiroCrew sandbox can escape it with one command: ssh localhost <cmd>. sshd is not sandboxed, so the far side of a loopback connection is a fresh unsandboxed login shell on the same machine — outside every deny rule, sensitive-path guard, and audit hook in security.py. Observed live on a dev desktop: sudo is broken inside the sandbox (uid remap strips the setuid bit), but ssh localhost 'sudo -n true' succeeds, and dev-desktop sudoers grants (root) NOPASSWD: ALL — a complete root escape in one line. Nothing in the catalog or the floors recognizes the pattern today.

Why it matters

Every other control in the module (credential-exfil rules, git-publish floor, self-protection floors, sensitive-file guards) is bypassable through this one hole: a prompt-injected or misbehaving agent that reaches ssh localhost inherits the user's full unsandboxed authority, including passwordless root on standard dev desktops. The controls only mean something if re-entering the host is also gated.

What changed (motivation → approach → change)

Symptom → root cause: the deny system reasons about the command's own text and argv, and an ssh family verb with a self-target smuggles execution to an unguarded context. The fix follows the module's established two-tier self-protection shape (regex catalog row + argv-structural floor, union not replacement):

  • New catalog rule sandbox-escape-ssh-self (new category sandbox-escape, default-ON, operator-disableable like its floor siblings). The pattern is a lint-safe positional regex — verb in command position, self host as the operand directly after it, end-delimited so a remote host merely named localhost.example.com never matches. It is deliberately a human-auditable SUBSET of the floor: the star-of-options shape that could skip option/value pairs fails is_safe_user_regex, which would silently disable the rule (_DenyMatcher refuses unsafe patterns).
  • New argv floor _is_ssh_to_self, registered in _SELF_PROTECTION_FLOOR_RULE_IDS and the is_denied floor loop with its own _SELF_PROTECTION_FLOOR_NOTES entry. The walk is FAIL-CLOSED about option grammar: instead of a per-verb table of which options take values (a table mis-consumed scp -r localhost:… in review), every token that could be an operand — including one in an option's value slot — is checked against the self-host set; a real option value (a port, a cipher) never names this host. ssh/sftp read only the FIRST unshadowed operand as the host, so ssh far-host 'curl localhost:8080' stays allowed — "localhost" in a remote command is data, not a destination. scp/rsync accept a target in any operand position. Redirections are stepped over the way bash removes them from argv.
  • Target resolution (_operand_targets_self / _host_is_self): loopback names, 127.*, bracketed and bare IPv6 (::1, ::ffff:127.0.0.1), numeric IPv4 spellings inet_aton accepts (2130706433, 0x7f000001, 0177.0.0.1), user@ stripped from the host part only (an @ in a remote path is not userinfo), URI authorities isolated before paths, $(hostname)/`hostname`/$HOSTNAME spellings, Windows ssh.exe, and ssh's routing options in both spellings (-o HostName=localhost and the config-style -o "Hostname localhost", plus attached -Jlocalhost) — the whitespace form only in a value slot, so a quoted remote payload 'hostname localhost' stays data.
  • This machine's own hostname/FQDN/addresses extend the self set. Interface addresses join a synchronous, packet-less seed (UDP-connect probes plus per-OS adapter sweeps: Linux ioctl, Windows GetAdaptersAddresses, macOS getifaddrs — the macOS walk is exercised by the macOS CI leg). DNS-derived names resolve in a background daemon thread with retry backoff: getfqdn/getaddrinfo are synchronous DNS and is_denied runs inline on the gateway event loop (the AUTOSDE no-blocking-call rule names these calls). The hard-coded loopback half never depends on either.
  • Declared exception for first contact: a dotted hostname in HOST position that no textual layer can classify is refused ONCE, per gateway process, while an off-loop DNS check rules out a loopback alias (self.attacker.example → 127.0.0.1 stays denied; a public name allows on the next decision). File operands and option values never trigger this — only genuine connection targets. The catalog rule description declares the same behavior.
  • Golden manifest gains the new row (catalog count pin 111 → 112 after the security-package split); the module spec (docs/system-specs/modules/security.md) gains the new floor and category.

Named fail-open residuals (denying these would break every legitimate remote ssh this box's workflows depend on): -F config files, ssh_config Host aliases, the GIT_SSH_COMMAND env family, values that exist only at run time (unassigned $VAR, dynamic substitution output), and nested function indirection. These are documented in the rule comment and the floor docstring. Statically-derivable forms are resolved and checked: parameter defaults, brace expansion, single-literal arithmetic, literal same-line assignments, one level of function-call binding, glob patterns, and flat static command-substitution output; arithmetic EXPRESSIONS in a target position fail closed.

Tests

test/test_denied_commands_security.py::TestSandboxEscapeSshSelf (new; ~190 tests by round 17):

  • Deny corpus: regex-tier spellings, floor-only spellings (options, quoting, bash -c payload, URI, IPv6/numeric literals, redirects, option-shadow shapes like scp -r localhost:/dir . and ssh -vp 22 localhost, routing options in both spellings, ssh.exe).
  • Allow corpus: remote hosts, remote commands mentioning localhost (quoted and unquoted), localhost.example.com, ::10, notssh, $HOSTNAME_BACKUP, rsync --exclude=localhost, non-connection mentions.
  • Structural: registered on both tiers, pattern passes is_safe_user_regex (a failing pattern silently disables the rule), pattern-subset-of-predicate invariant (mirror of the existing floor test), opt-out disables both tiers, tokenizer-failure still denies via the regex half, own-name cache semantics and best-effort resolver failure paths.

The catalog count pin assert is updated 111 → 112; the golden manifest entry is kept in lockstep with the rule (pattern and description).

Manual verification

Behavioral corpus run against the real is_denied in this worktree (all deny/allow/subset checks green), plus an independent focused verifier pass that re-probed every review blocker against the built module — including timing evidence that is_denied returns in ~64ms with DNS artificially blocked (background-thread resolution verified). The config-style -o "Hostname localhost" routing was confirmed equivalent to the = spelling against real ssh -G output.

Screenshots / video

N/A — no user-visible UI change (backend security rule; the Settings catalog renders the new row through the existing generic rule list).

no linked issue: filed directly from an operator request after the live escape was demonstrated on this machine; no tracking issue exists for it.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of cf5c2bedd3cff1b581d2abb52e7be08d75623aa1 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound placement per the module's own doctrine (the mount-namespace sandbox can't hold a network path), but the control mitigates the escape rather than closing it, while its naming claims closure.

Watch

  • The floor is verb-anchored on four binaries (_SSH_FAMILY_VERBS) with documented fail-open residuals (-F configs, ssh_config aliases, GIT_SSH_COMMAND, run-time values) — yet the PR's own adversary is "a prompt-injected or misbehaving agent," who also has plink/mosh/a paramiko one-liner, none of which carry an ssh-family verb. The category sandbox-escape and the UI text "Blocks ssh/scp/sftp/rsync targeting this same machine" invite an operator to believe the loopback re-entry hole is closed and leave passwordless sudo configured, when the true boundary would be an OS-level connect fence or host hardening.
    Clears when: the rule description/spec frames this as harm reduction with the residual class named user-visibly, and a follow-up issue owns the OS-level or host-hardening close.
  • The first-contact DNS refusal means every fresh hostname's first ssh per gateway process fails once. Interactive users retry; an unattended cron/task run doing a one-shot remote job to a new host fails outright, and the agent may route around the refusal rather than retry.
    Clears when: the refusal text is shown to instruct an immediate retry (or the agent loop demonstrably retries), verified by a test on the unattended path.

Suggestions

  • The floor is now a partial bash evaluator grown over 19 rounds; extract the expansion/resolution layers (params, braces, arithmetic, substitutions, assignments) into a shared normalizer other floors reuse, so the next spelling report lands in one place instead of adding another rule-local layer.

[DESIGN-REVIEWED] cf5c2be

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of cf5c2bedd3cff1b581d2abb52e7be08d75623aa1 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All checks are done — the facade export list is a mandated frozen inventory, the sandbox has no network namespace (so an OS-level fix is genuinely larger), and I've confirmed the sibling paths and the diff's own admissions about residuals and gate crashes. Emitting the review.

First-Principles-Verdict: CONCERNS

The shell-emulator half (brace ranges, arithmetic, function binding) closes spellings an adversary sidesteps via the documented $VAR residual — and it already crashed the gate twice in review.

Not justified as shipped

  • Item 4 — one-member category: grep category="sandbox-escape" finds 1 rule, and the rule already lives in _SELF_PROTECTION_FLOOR_RULE_IDS; a second grouping concept for the same floor is a second spelling of self-protection.
  • Item 5 — oversized: the PR's own residual list makes h=$(uname -n); ssh $h a one-line fail-open bypass, so _brace_expansions, _arith_int_literal_repl and _function_call_binds_self each remove no harm the attacker can't dodge for free — while the diff's own test comments record that the arith and brace evaluators each shipped an uncaught ValueError "THROUGH is_denied, aborting the tool-call evaluation" (fixed round-18): measured availability cost on the every-tool-call path against an unnameable marginal harm.

What this change ships

Inventory (9 items) — 7 justified

Intent: stop the agent escaping its sandbox by ssh-ing back into this same machine (demonstrated live root escape). ADDITION (new guardrail) with derived provenance — a reproduction is written in the description.

  1. ssh/scp/sftp/rsync targeting this machine is now refused, on both tiers, operator-disableable — justified
  2. The first ssh to any never-seen hostname is refused once per process while background DNS rules out a loopback alias — justified
  3. The gateway now runs background DNS daemon threads with process-lifetime host caches — justified
  4. New Settings category sandbox-escape — one consumer, generalized: single member, rule already rides the self-protection floor set
  5. Static bash emulation beyond literals (brace ranges, arithmetic, function-arg binding, glob, sentinel masking) — oversized: closes single spellings of an admitted-unbounded set; introduced two gate crashes during review
  6. Catalog count pin 111→112 plus golden-manifest row — justified
  7. security.md floor/category text updated in the same commit — justified
  8. ~35 helper names added to the frozen facade export inventory — justified
  9. Two interface-address tests pinned into a CI job list — justified

Watch

  • Point patch with 2 counted unfixed siblings reaching the same local sshd with the same authority: git's own ssh transport (git clone ssh://localhost/x, scp-syntax localhost:path — grep of _SSH_FAMILY_VERBS and the residual comments covers only the GIT_SSH_COMMAND env family) and interpreter payloads (python -c spawning ssh — the sibling self-kill floor gates that class via a catalog rule; this one doesn't). The cause-level fix (a network boundary in sandbox.py, which today unshares only user+mount) is genuinely larger — accepted and deferred, but these two are absent even from the named-residual set. Clears when: a linked issue tracks the git-transport and interpreter-payload paths, or either is gated.

Subtractions

  • Delete _brace_expansions/_brace_alternatives/_BRACE_RANGE_RE, _ARITH_INT_LITERAL_RE/_arith_int_literal_repl, and _function_call_binds_self in argv_floor.py, folding those spellings into the same run-time residual class as bare $VAR — the floor's deny corpus minus those cases still blocks every literal, option, routing, and quoting spelling, without a bash evaluator on the PreToolUse path.
  • Drop the one-member sandbox-escape category — set category="self-protection" on the row (1 member counted; the floor registration already names it self-protection) and remove the category from the spec list.

[FIRST-PRINCIPLES-REVIEWED] cf5c2be

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — 🔴 changes requested (blocking)

Reviewed cf5c2bedd3cff1b581d2abb52e7be08d75623aa1 — this comment is updated in place on each push.

BLOCKING — src/kiro_crew/security/argv_floor.py:2862 — an ANSI-C-quoted ssh-family verb bypasses the entire _is_ssh_to_self floor, and the own-host target class it alone enforces has no regex backstop.

probe = text_lower.replace('"', "").replace("'", "").replace("\\", "")
...
if not any(verb in probe for verb in _SSH_FAMILY_VERBS) and not _glob_can_name_ssh_verb(probe):
    return False

Input $'\x73\x73\x68' myhost.internal id (own resolved hostname/FQDN/interface IP): the verb gate builds probe by stripping only quotes/backslashes ($'\x73\x73\x68'$x73x73x68) and resolving ${…} defaults — it never ANSI-C-decodes — so probe holds no ssh substring and the gate returns False before the operand walk, which via _self_token_framesnormalize_shell_command_decode_shell_quoted_literals would have decoded the verb to ssh and matched _own_host_names(); the regex tier's normalized view decodes the ANSI-C but its target alternation (localhost|127.x|::1|$(hostname|${hostname}) excludes the own-name set the rule delegates to the floor → ssh <own-host> runs, re-entering the machine as an unsandboxed login shell (passwordless sudo there completes a full escape).
Fix: derive the verb-gate probe from ANSI-C-decoded text (run _decode_shell_quoted_literals(text_lower) before the quote/backslash strips) so a verb spelling the operand-walk tokenizer resolves cannot slip the gate guarding it.

[BLOCK-MERGE] cf5c2be
[OPUS-REVIEWED] cf5c2be

Verdict parsed from the review's SHA-scoped output markers for commit cf5c2bedd3cff1b581d2abb52e7be08d75623aa1.

False positive or not applicable? A repository writer can comment:
/ai-review override fable cf5c2bedd3cff1b581d2abb52e7be08d75623aa1: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging cf5c2bedd3cff1b581d2abb52e7be08d75623aa1. 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands.

This comment is updated in place on each push.

BLOCKING -- src/kiro_crew/security/argv_floor.py:2596 -- Renamed SSH binaries bypass the floor
base = _program_basename(_strip_redirect(token.strip("\"'")))
cp /usr/bin/ssh /tmp/x && /tmp/x localhost id -> _ssh_family_verb returns None -> unsandboxed local login executes.
Anchor: residual/security
Fix: block local-address connections at the sandbox’s OS/network boundary, independent of executable basename.
BLOCKING -- src/kiro_crew/security/argv_floor.py:3077 -- Option terminator hides DNS-classified self targets
value_shadow = stripped.startswith("--") or (
ssh -- localalias with a loopback hosts/DNS mapping -> -- shadows the host operand -> local unsandboxed login executes.
Anchor: residual/security
Fix: treat exact -- as an option terminator that preserves host position.
FINDING -- src/kiro_crew/security/argv_floor.py:2791 -- "instead of a per-verb table" contradicts _VERB_VALUE_TAKING_OPT_LETTERS -> Fix: update the docstring to describe the per-verb table.
[BLOCK-MERGE] cf5c2be
[GPT-REVIEWED] cf5c2be

Adjudication (Opus 4.8) — is blocking on each finding proportionate?

Both fenced findings target the ssh-to-self argv floor, whose bypass yields unsandboxed local login — a governance-ceiling / privilege-escalation harm, unbounded by definition.

F1_ssh_family_verb (argv_floor.py:2596) keys ssh detection off the program basename; a renamed binary (cp /usr/bin/ssh /tmp/x && /tmp/x localhost id) returns None and skips the floor. The required condition is a trivial cp — the canonical, low-effort bypass, not an extreme or self-contradicting combination. No rarity argument survives; recovery path: none. Cannot complete a FLAG record.

F2 — at argv_floor.py:3077 a bare -- sets value_shadow, so the following operand is checked with host_position=False, which drops dns_fallback in _host_is_self (argv_floor.py:2337). Textual loopback names/IP literals are still caught regardless (argv_floor.py:2306-2330), so the residual is only a custom DNS/hosts alias resolving to loopback behind --. That alias mapping is plausible in ordinary operator environments, and -- is common ssh usage — not extreme enough to argue a human accepts the risk. Recovery path: none. Cannot complete a FLAG record.

Torn on unbounded-security fenced findings resolves to UPHOLD-FENCED.

[ADJUDICATION] cf5c2be total=0 uphold=0 downgrade=0
[GPT-ADJUDICATED] cf5c2be
[ADJUDICATION-FENCED] cf5c2be fenced=2 flagged=0
UPHOLD-FENCED F1 src/kiro_crew/security/argv_floor.py:2596 -- Basename-keyed detection is bypassed by a trivial binary rename, a low-effort common condition, so no extremity argument justifies a FLAG.
UPHOLD-FENCED F2 src/kiro_crew/security/argv_floor.py:3077 -- A -- terminator plus a loopback-aliased hostname is a plausible operator environment, not an extreme combination, so it keeps blocking without a drafted override.
[GPT-ADJUDICATED-FENCED] cf5c2be

False positive or not applicable? A repository writer can comment:
/ai-review override gpt cf5c2bedd3cff1b581d2abb52e7be08d75623aa1: <one-sentence reason>

@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from ad456d0 to 53378ae Compare September 3, 2026 23:06
@patrigao

patrigao commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author
  • Runtime-expanded self targets bypass the floor (src/kiro_crew/security.py:5645) — span=3c5b15a2d41e — disposition: fixed (partial by design), commit 53378ae.

_operand_targets_self now checks the embedded word of ${VAR:-word} / ${VAR:=word} / ${VAR:+word} expansions (_EXPANSION_DEFAULT_RE, recursive so nested defaults resolve): ssh "${TARGET:-localhost}" id and ssh ${H:=127.0.0.1} id are denied, locked by tests. The wider demand — deny ALL unresolved expansions in destination position — is deliberately not taken: a bare $h destination is the daily fleet pattern here (for h in $(expand-hostclass …); do ssh $h …), so failing closed on it denies every legitimate remote ssh. That run-time-only residual is documented in the section header and the PR body as fail-open by design; the statically-visible half is now closed.

@patrigao

patrigao commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author
  • First own-host connection races hostname resolution (src/kiro_crew/security.py:5592) — span=3c5b15a2d41e — disposition: fixed, commit 53378ae.

_own_host_names() now publishes a synchronous seed from socket.gethostname() (a local uname syscall, not DNS — safe on the event loop) before kicking the DNS enrichment thread, exactly as the finding proposed. The very first ssh <own-hostname> is denied with no resolution window; test_first_own_host_command_is_denied_without_waiting_for_dns locks it with the enrichment thread suppressed. getfqdn/getaddrinfo stay off-loop per the AUTOSDE no-blocking-call rule.

@patrigao

patrigao commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author
  • Nested ssh far-host ssh localhost treated as a local connection (src/kiro_crew/security.py:5751) — span=732b722c70fd — disposition: rebutted (conservative over-block, kept deliberately).

The whole-frame scan is the same convention the sibling self-kill floor ships with, and unwinding it is not safe: excluding "ssh's remote payload" from scanning would also exclude ssh far "$(ssh localhost exfil)", whose command substitution runs LOCALLY before ssh ever connects — the tokenizer cannot tell the two apart without a shell-accurate evaluation model. The blocked shape connects to the REMOTE machine's loopback (rare; the common two-hop form is -J, and quoted nested payloads targeting other hosts stay allowed), so the failure direction is a rare over-block rather than an escape. Documented as the accepted trade-off in the floor's design comments.

@patrigao

patrigao commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author
  • Forward/bind option values denied as positional hosts (src/kiro_crew/security.py:5803) — span=c042398cee96 — disposition: fixed, commit 53378ae.

Added _SSH_FORWARD_OPT_LETTERS (-L/-R/-D/-W/-b, plus -l login under lowering): an ssh option in that set marks its value token exempt from the fail-closed operand check, so ssh -L 127.0.0.1:8080:db:5432 far-host, -D localhost:1080, -R localhost:2222:localhost:22, -W localhost:22, and -b localhost all stay allowed (locked by tests). The exemption is ssh-ONLY — for scp/rsync -r/-l are the valueless flags whose mis-classification hid scp -r localhost:… in round one, and the regression tests for that case still pass. -J deliberately stays checked (the jump connection originates locally), and the docstring premise the finding quoted is corrected to name the carve-out.

@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch 2 times, most recently from f1e84cb to 596fd06 Compare September 4, 2026 00:35
@patrigao

patrigao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author
  • IPv4-mapped loopback bypasses _host_is_self on Python < 3.12.4 (src/kiro_crew/security.py:5638) — span=c9886dc1579f — disposition: fixed, commit 596fd06.

Legitimate: IPv6Address("::ffff:127.0.0.1").is_loopback only delegates to ipv4_mapped from 3.12.4 (gh-113171); requires-python >= 3.12 admits 3.12.0–3.12.3 (Ubuntu 24.04 ships 3.12.3), where the mapped form fell through every branch and was allowed. _host_is_self now unwraps ip.ipv4_mapped explicitly before the loopback/unspecified/own-name checks, so the deny no longer depends on interpreter delegation. Locked by test_mapped_loopback_denial_does_not_rely_on_is_loopback_delegation, which pins the IPv6 properties to the pre-3.12.4 semantics and asserts the deny still holds — verified to FAIL on the previous code and pass on this commit (the shipped ssh ::ffff:127.0.0.1 id deny case alone could not catch this on ≥ 3.12.4 interpreters, where delegation masks the bug).

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from 596fd06 to a5dcd29 Compare September 4, 2026 01:55
@patrigao

patrigao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author
  • Empty substitutions bypass verb and host detection (src/kiro_crew/security.py:5794) span=3c5b15a2d41e — fixed in a5dcd29

_EMPTY_EXPANSION_RE now collapses $()/empty backticks out of the source text before BOTH the verb gate and tokenization, so s$()sh localhost and ssh local$()host resolve to their spliced-out spellings. The gate additionally probes a quote/backslash-splice-stripped copy, closing the sibling ss""h / s\sh gate evasions (shlex already rejoined those in tokens; only the raw-substring gate missed them). Locked by new deny tests for all four spellings; proven to fail on the pre-fix code via stash run.

@patrigao

patrigao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author
  • ProxyJump chains are not inspected per hop (src/kiro_crew/security.py:5753) span=3c5b15a2d41e — fixed in a5dcd29

_proxyjump_value_targets_self now splits the value on commas and checks every hop with _operand_targets_self — ssh dials the FIRST hop from this machine, so a self host anywhere in the chain is a self dial. Applied to both -o proxyjump=… spellings and the attached -J… form. Deny tests: proxyjump=localhost,far and -Jlocalhost,far; allow regression: all-remote chains in both spellings.

@patrigao

patrigao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author
  • ProxyCommand payloads bypass self-target scanning (src/kiro_crew/security.py:5734) span=3c5b15a2d41e — fixed in a5dcd29

New _SSH_COMMAND_OPTION_KEYS = ("proxycommand", "localcommand"): these option values are command lines ssh executes ON THIS HOST, so _routing_option_key_value_targets_self now recurses the whole floor on the value (strictly shorter than its containing token, so the recursion terminates). LocalCommand is covered as the same locally-executed class, and scp/sftp inherit the fix because they forward -o to ssh. Deny tests: ssh -o proxycommand="ssh localhost sh" far and the scp spelling.

@patrigao

patrigao commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author
  • Rsync remote-shell payloads bypass self-target scanning (src/kiro_crew/security.py:5845) span=3c5b15a2d41e — fixed in a5dcd29

rsync's -e/--rsh value is a command line rsync execs FROM HERE, so the walk now recurses the floor on it in every spelling: --rsh=VALUE attached, -eVALUE attached (single-dash, including mid-bundle), and the detached next-token form via a pending flag (also set by a bundle ENDING in e, e.g. -ave). Only exactly rsh among double-dash options consumes a value, so --exclude=localhost stays data. Deny tests cover detached/attached/bundle spellings; allow regressions keep --rsh=ssh and --exclude=localhost. (Note: the plain detached -e ssh allow-case is asserted at floor level — end-to-end that string was already denied BEFORE this PR by the unrelated reverse-shell-nc catalog rule, whose unanchored pattern substring-matches inside the rsync flag text; fixing that older rule is out of scope here.)

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from a5dcd29 to 1e56ac8 Compare September 4, 2026 03:48
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: brace-range integer conversion failures land on the existing overflow sentinel

  • Oversized brace integers crash the permission gate (argv_floor.py:2167)

Fixed in 0fd83d4.

_brace_alternatives wraps its int() conversions (endpoints and step) in try/except ValueError and returns the existing over-cap sentinel, which the caller converts to the fail-closed deny -- the same path an in-range but over-cap fan-out takes. A digit string past the interpreter's conversion cap cannot name a single legitimate target, so deny is the correct terminal, and no conversion of attacker-sized integers remains outside the guard in this function.
Red-first tests: ssh h{1..<4301 digits>} and ssh h{1..2..<4301 digits>} both denied, no exception.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: resolver thread-start failure answers from the synchronous seed

  • Resolver thread-start failure aborts permission decisions (argv_floor.py:2067)

Fixed in 0fd83d4.

The Thread.start() except-path clears the in-flight latch and falls through to return the synchronous seed already cached under the lock, so a thread-exhausted host still gets a permission decision from the seed (deny floor intact) and a later call retries the enrichment worker after backoff. No exception escapes _own_host_names from the spawn path; this ruling covers resolver-worker spawn failures as a class.
Red-first test: stubbed Thread.start raising RuntimeError -- _own_host_names() returns the seed and the latch reads clear.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Advisory (gpt): function-local import ctypes (argv_floor.py:1923) -- fixed in 0fd83d4: ctypes sits in the module import block; _windows_interface_addresses uses the module-level name.

@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from 0fd83d4 to d71c6d1 Compare September 9, 2026 06:21
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: flat static-substitution outputs splice into the source before probe and walk

  • Static command substitution bypasses self-host detection (argv_floor.py:2268)

Fixed in d71c6d1.

Flat command substitutions whose output is statically decidable ($(printf localhost), backtick echo localhost, glued local$(printf host)) splice their output into the source text before the verb probe and the operand walk, reusing _static_substitution_output -- the resolved self-host is then an ordinary operand and denies. Dynamic bodies keep their original text (the $(hostname hints still read them) and remain the documented run-time residual: emulating a general generator needs an in-floor evaluator that is itself attack surface. This ruling covers the statically-decidable substitution class wherever it appears in the command line.
Red-first tests: ssh $(printf localhost), backtick echo, and the glued spelling all denied; ssh $(printf remote.example.com) stays allowed.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: darwin getifaddrs sweep feeds the synchronous seed

  • macOS secondary interface addresses bypass detection (argv_floor.py:1872)

Fixed in d71c6d1.

_darwin_interface_addresses reads the interface table via libc getifaddrs (ctypes) -- a pure local-table read, no resolver, no packet, safe on the event-loop seed path -- and feeds _own_interface_addresses as its own sweep layer, the sibling of the Linux ioctl and Windows GetAdaptersAddresses sweeps. macOS secondary/VPN addresses without DNS records reach the seed before the first ssh-family command is judged; the helper self-gates off macOS and contributes an empty set on any failure. Each first-class platform carries a packet-less per-interface sweep, so this ruling covers platform-sweep-omission findings as a class.
Red-first tests: stubbed sweep feeds the seed (first ssh <secondary-IP> denied); off-darwin the helper returns empty.

@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from d71c6d1 to 6915379 Compare September 9, 2026 07:29
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: fnmatch glob classification for command words and self-target operands

  • Pathname expansion bypasses SSH verb and target detection (argv_floor.py:2588)

Fixed in 6915379.

Glob words classify by what the pattern CAN match, with no filesystem read: a command word whose glob can name an ssh-family program (/usr/bin/s?h) passes the verb gate and resolves to that verb in the walk, and an operand whose glob can match a loopback name, loopback literal, or own-host entry (localho?t, 127.0.0.?) denies. A pattern that cannot match any self name stays allowed, so remote globs are untouched. Treating can-match as match is the deny-floor over-approximation; this ruling covers pathname-expansion spellings of both the program word and the destination as a class.
Red-first tests: /usr/bin/s?h localhost and ssh localho?t denied; /usr/bin/s?h remote.example.com and ssh remo?e.example.com stay allowed.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: off-loop DNS verdict worker with per-host cache, fail-closed while unresolved

  • DNS aliases to local addresses bypass detection (argv_floor.py:2191)

Fixed in 6915379.

A dotted hostname the textual layers cannot classify goes to _resolved_host_verdict: the decision fails closed (denied) until a single-flight daemon worker resolves the name off the event loop and caches whether any resolved address is loopback, unspecified, or a member of the own-address set -- self.attacker.example pointing at 127.0.0.1 stays denied, a public address allows on the next decision, and a name that does not resolve is not self because a connection cannot reach this machine through it either. No resolver call runs on the is_denied path; this ruling covers resolver-reachable alias spellings as a class.
Red-first tests: unknown host fails closed with single-flight scheduling; the worker classifies loopback, IPv6 loopback, public, and unresolvable addresses.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Advisory (gpt): host.startswith('127.') rejects unrelated remote names (argv_floor.py:2166) -- fixed in 6915379: the prefix check is deleted; numeric loopback is decided by the existing ip_address/inet_aton parsers (abbreviated 127.1 stays denied), and a remote domain with a numeric first label such as 127.example.com classifies through the DNS verdict layer like any other hostname.

@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from 6915379 to d012b0a Compare September 9, 2026 08:39
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: same-line literal assignment resolution (_resolve_line_assignments) before the verb gate and walk

  • fixed (d012b0ac4): span=7670520e57b3 — literal variable splicing bypassed the verb gate (a=s; ${a}sh localhost id).

_resolve_line_assignments substitutes $a/${a} from literal, separator-free same-line assignments, position-aware like bash, into the SOURCE before the probe and tokenization — so a verb or operand spliced from statically-known assigned text is seen assembled. Values carrying $, backticks, spaces, or separators stay unresolved: that run-time class remains the documented residual, and this ruling covers findings that require resolving run-time-only variable values.
Deny tests: a=s; ${a}sh localhost id, h=localhost; ssh $h id; allow pinned: a=far.example.com; ssh $a uptime. Red-first at 6915379.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: one-level function-call argument binding (_function_call_binds_self)

  • fixed (d012b0ac4): span=7670520e57b3 — function parameters bypassed target detection (f(){ ssh "$1" id; }; f localhost).

_function_call_binds_self binds each same-line (definition, later call) pair — $1..$9, ${N}, $@, $*, quoted forms — and recurses the floor on the bound body, so the literal call argument is checked as the destination. One level only, both fan-outs capped at 8: nested function indirection (a function calling a function) is the documented residual of this mechanism, and this ruling covers findings that require multi-level call-graph emulation.
Deny test: f(){ ssh "$1" id; }; f localhost; allow pinned: f(){ ssh "$1" uptime; }; f far.example.com. Red-first at 6915379.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: fail-closed rule for unresolved arithmetic in connection-target position

  • fixed (d012b0ac4): span=7670520e57b3 — constant arithmetic expansion bypassed loopback detection (ssh 127.0.0.$((0+1)) id).

An arithmetic expansion that survives _ARITH_INT_LITERAL_RE normalization is an EXPRESSION whose produced text only the shell knows; in a connection-target position (host slot, or an operand carrying the host:path colon) it now fails closed rather than parsing around the unknown. No in-floor expression evaluator is added — that would be attack surface — so this ruling covers every arithmetic-expression spelling in target position, $((0+1)) and successors alike. The earlier ssh host$((i)) allow pin is reversed to deny accordingly; arithmetic in a non-target operand (scp release$((2*3)).tar far:/dst) stays allowed.
Deny tests: ssh 127.0.0.$((0+1)) id, scp 127.0.0.$((0+1)):/etc/passwd /tmp/x, ssh host$((i)) id. Red-first at 6915379.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: no
mechanism: proxyjump_value_pending latch in the option walk

  • fixed (d012b0ac4): span=4f0f18c795c6 — detached ssh -J localhost,far-host first-hop dial was never comma-split.

The option walk now latches proxyjump_value_pending when a single-dash option token ends with the jump letter (-J, and flag bundles like -4J), and the NEXT token runs _proxyjump_value_targets_self — the same comma-splitting check the attached -Jvalue and -o proxyjump= spellings already used, restoring the "self anywhere in the hop chain" invariant for the standard detached spelling. This ruling covers the detached-value siblings of routing options the attached form already denies.
Deny tests: ssh -J localhost,far.example.com far.example.com, ssh -4J localhost,far.example.com far.example.com; allow pinned: ssh -J far1.example.com,far2.example.com target.example.com. Red-first at 6915379.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: host_position gating of the DNS-alias verdict layer

  • fixed (d012b0ac4): span=71f170d22fd9 — the DNS verdict layer fail-closed on dotted scp/rsync FILE operands (scp backup.tar.gz far-host:/dst).

_operand_targets_self now threads host_position and _host_is_self consults the fail-closed DNS verdict only for tokens that ARE hosts: the unshadowed ssh/sftp positional, a host:path colon prefix, a scheme authority, a bracketed literal, a userinfo remainder, or a routing option value. A dotted local filename or an ssh option value (-i id_rsa.pub) never reaches the DNS layer, so no first-contact refusal recurs per new filename; all textual layers still apply everywhere. This ruling covers DNS-layer false positives on non-host operands as a class.
Recorder test proves the discrimination: with a deny-all verdict stub, scp backup.tar.gz far.example.com:/dst consults DNS only for far.example.com; ssh unseen.example.com still consults (and fail-closes) in host position. Red-first at 6915379.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: macOS CI leg now runs the interface-sweep tests

  • fixed (d012b0ac4): Blocker 1 — the macOS getifaddrs sweep was an unverified platform claim inside the deny gate.

Per the stated clears-when, the macOS CI job's "Run gateway + platform suites" step now runs test_own_interface_addresses_returns_parseable_addresses (exercising the real _darwin_interface_addresses ctypes walk on a macOS runner) plus test_first_command_knows_darwin_interface_addresses, so this PR's own CI produces the named green macOS run. The sweep is kept rather than deleted because deleting it re-opens the round-16 GPT blocking (macOS secondary/VPN interfaces missing from the first-command seed) — the two lanes' demands are only jointly satisfiable by keep + verify.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: corrected catalog rule description + host_position DNS gating

  • fixed (d012b0ac4): Blocker 2 — the rule text claimed "Connections to OTHER hosts are unaffected" while the DNS layer fail-closed first contact.

Both halves of the clears-when are taken. The description in denied_rules.py (and the golden manifest) now declares the exception: "a first-seen dotted hostname in HOST position is refused once, per process, while an off-loop DNS check rules out a loopback alias, then cached." And the miss path is narrowed: with host_position gating (this round's Opus fix), the one-time refusal applies only to genuine host-position tokens, never to file operands, so the declared behavior is also the smallest honest version of the layer. The PR description now declares the same one-time refusal.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: no

  • fixed (d012b0ac4): Watch — the PR description was written against a different tree (stale "148→149" count-pin and test_deny_guidance.py claims).

The description is rewritten against the actual diff: the catalog count pin is 111→112 in one assert, there is no test_deny_guidance.py in this patch, and the DNS-layer one-time refusal plus the macOS CI verification are declared. Reconciled in the same push (d012b0ac4).

Comment thread test/test_denied_commands_security.py Fixed
@patrigao
patrigao force-pushed the fix/sandbox-escape-ssh-self branch from d012b0a to fbb4959 Compare September 9, 2026 09:25
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: none -- one-line containment move in the round-17 normalizer

  • fixed (fbb495992): span=4f0f18c795c6 — huge hex/octal arithmetic literal raised uncaught ValueError through is_denied.

_arith_int_literal_repl keeps the decimal str(value) INSIDE its try: hex/octal int() uses a power-of-two base exempt from the interpreter's digit cap, so a ~3600-digit literal converts, but its decimal spelling is capped -- on overflow the original spelling is kept and the round-17 target-position rule fails closed on the unresolved $((. Every int<->str conversion in this floor's expansion helpers is now inside a ValueError guard whose fallback composes into a deny (_brace_alternatives already was); this ruling covers conversion-cap crashes in the expansion helpers as a class.
Deny tests: ssh 127.0.0.$((0x<3700 f's>)) and the 5000-digit octal sibling; both raised pre-fix, both deny post-fix. Red-first at d012b0a.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: case-folded value-taking option letter set (_SSH_VALUE_TAKING_OPT_LETTERS) + value_shadow

  • fixed (fbb495992): span=7670520e57b3 — valueless SSH flags suppressed alias validation (ssh -v self.example).

The walk now distinguishes value-CONSUMING options from valueless flags by the bundle-final letter: after -v/-4/... the next token IS the destination and keeps host position (DNS-checked), after -i/-p/-o/... it is the option's value (exempt, per the round-17 Opus ruling on file operands). Case-folded collisions (C/c, F/f, M/m, Q/q, S/s) resolve toward VALUELESS -- the safe direction: the DNS layer then still covers a host after them, and a dotted value there at worst takes the declared one-time refusal. A consumed positional also ends host position, so a dotted remote-command argument after the host stays data.
Recorder tests pin both directions: ssh -v self.example consults DNS for self.example; ssh -i id_rsa.pub far.example.com consults only far.example.com; ssh -v far.example.com hostname.txt never consults hostname.txt. Red-first at d012b0a.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: none -- one-character regex widening in the round-16 DNS layer

  • fixed (fbb495992): span=7670520e57b3 — dotless local aliases skipped resolution (ssh localalias with an /etc/hosts loopback mapping).

_DNS_CANDIDATE_RE's dotted part is now optional: a dotless lettered name in a CONFIRMED host position resolves like any hostname, so an /etc/hosts loopback alias is classified instead of skipped. The dotted-only filter existed to keep junk words out of the resolver before round-17's host-position gating; position now does that job, so punctuation no longer decides coverage. The catalog rule description drops the word "dotted" accordingly (golden manifest in lockstep).
Recorder test: ssh localalias uptime consults the verdict layer for localalias. Red-first at d012b0a.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: TTL'd allow verdicts (_HOST_VERDICT_ALLOW_TTL + _HOST_VERDICT_STAMP, stale-while-revalidate)

  • fixed (fbb495992): span=7670520e57b3 — negative DNS verdicts were reused unbounded, permitting rebinding.

An ALLOW verdict now carries a timestamp and is revalidated after 300s: the aged entry is served stale exactly while one single-flight worker re-resolves (no recurring first-contact refusal), so a name that rebinds to loopback is caught at the next publish and the rebinding window is bounded by the TTL. DENY verdicts stay permanent -- over-blocking is the floor's safe direction. Address pinning to the eventual connection is not reachable from a textual pre-exec floor (ssh re-resolves at connect time); the bounded-reuse ruling covers TOCTOU-at-connect findings against this layer, whose residual is the TTL window and is documented at the constant.
Unit test pins all three behaviors: stale allow schedules exactly one revalidation, fresh allow schedules none, deny never revalidates. Red-first at d012b0a.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes

  • rebutted (fbb495992): span=7670520e57b3 — blocking-socket-on-event-loop for the UDP-connect probe (adjudicator: DOWNGRADE, disproportionate remedy).

Not a defect in the blocking sense the rule targets: the probe is a SOCK_DGRAM connect to a documentation IP -- a local route lookup that sends no packet and does no name resolution -- runs at most once per process, and its result is cached permanently in the own-host seed. The prescribed remedy (move it off-loop) would reintroduce the deterministic first-command interface-IP escape the synchronous seed exists to close (the round-12 converged finding). The adjudication ledger for this head records the DOWNGRADE with reason=disproportionate-remedy; this ruling covers event-loop findings against the packet-less seed probes as a class.

@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: none -- regex alternative in the round-17 function binder

  • fixed (fbb495992): span=ed2b4cb75a46 — the binder missed bash's parenthesis-free function f { ssh "$1"; } keyword form.

_FUNCTION_DEF_RE accepts both spellings -- name() { ... } and function name { ... } (parentheses optional after the keyword) -- binding identically. This ruling covers definition-syntax variants of the one-level binder; nested function indirection remains its documented residual.
Deny test: function f { ssh "$1" id; }; f localhost. Red-first at d012b0a.

Comment thread test/test_denied_commands_security.py Fixed
ssh to localhost (or this host's own name) re-enters the machine OUTSIDE
the agent sandbox: the far side is a fresh unsandboxed login shell, and
passwordless sudo there completes a full escape (observed live on a dev
desktop where sudo is broken inside the sandbox but 'ssh localhost sudo'
grants root).

Two-tier build, same shape as the existing self-protection floors:

- Catalog rule sandbox-escape-ssh-self (category sandbox-escape): a
  lint-safe positional regex — verb in command position, self host as
  the operand directly after it, end-delimited so a remote host merely
  named localhost.example.com never matches — kept a human-auditable
  SUBSET of the floor. No option-skipping group on purpose: that shape
  fails is_safe_user_regex, which would silently disable the rule.
- Argv-structural floor _is_ssh_to_self: FAIL-CLOSED about option
  grammar — every token that could be an operand, including one in an
  option's value slot, is checked against the self-host set, so no
  per-verb option table exists to get wrong (scp -r localhost:… was the
  shape that defeated a table). Resolves redirections the way bash
  removes them from argv, user@/URI-authority/bracketed-IPv6 prefixes,
  bare IPv6 and numeric IPv4 literals (::ffff:127.0.0.1, 2130706433,
  0x7f000001), hostname substitutions, -o hostname=/-o proxyjump=
  routing values, Windows .exe spellings, and this machine's own
  hostname/FQDN/addresses. ssh/sftp read only the FIRST unshadowed
  operand as the host, so a word like localhost inside a remote command
  run on another host stays data.
- Own-name resolution runs in a background daemon thread with retry
  backoff: getfqdn/getaddrinfo are synchronous DNS and is_denied runs
  inline on the gateway event loop, so the gate never blocks on DNS.
  Until the first resolve lands the own-name half is empty; the
  hard-coded loopback half never depends on it.

Named fail-open residuals (denying these would break every legitimate
remote ssh): -F config files, ProxyCommand, ssh_config Host aliases,
and substitutions that only resolve to this host at run time.

Remote hosts are unaffected. The rule is operator-disableable like its
floor siblings; the pattern-subset-of-predicate invariant, deny/allow
corpora, opt-out, tokenizer-failure union, and resolver failure paths
are locked by tests. Golden manifest 148->149; module spec and the
deny-guidance docstring updated alongside.

Round 8 (reviewer-verified escape gaps, maintainer-ruled fixes):
- Nested-frame RSYNC_RSH inheritance: a self-targeting RSYNC_RSH seen in an
  outer frame (export or env-prefix) now latches for the command's remaining
  frames, so RSYNC_RSH='ssh localhost' sh -c 'rsync host:/x .' is denied.
- IPv6 zone-ID bypass: %zone suffixes are stripped at both the resolver cache
  site and the _host_is_self operand, so fe80::1%eth0 matches the interface
  address fe80::1.
- Tests: 2 deny + 2 allow RSYNC_RSH frame cases, plus 2 zone-ID tests
  (resolver strip + match-time strip). Proof-run: all 4 new assertions fail
  on the pre-fix tree.

Round 9 (new reviewer-verified escape gap + import hygiene):
- Escaped-quote separator-mask bypass: inside double quotes a backslash-escaped
  quote is a literal, not a close. _mask_quoted_separators now honors bash's
  \" \\ \$ \` escapes inside double quotes, so scp "a\";b" localhost:/x no
  longer exits the quote at \" and reads the ; as a real separator that ends
  the operand walk before the self-host target. Deny test added; proof-run
  confirms it fails on the pre-fix tree.
- top-level-imports: fcntl/struct moved from a function-local import to a
  guarded module-level optional import; the Linux interface sweep now gates on
  their presence as well as sys.platform.

Round 10 (new reviewer-verified escape gap, maintainer-ruled fix + sweep):
- Parameter-default gluing bypass: _operand_targets_self checked each
  ${VAR:-word} default in ISOLATION, so bash's substitution INTO the
  surrounding word escaped it: ssh local${KC_UNSET:-host} id resolves to
  ssh localhost id. The whole operand is now resolved via
  _resolve_param_defaults (fixpoint: all operator spellings, colon-less
  and nested included) and the resolved spelling re-checked.
- Sibling sweep, one invariant at the operand chokepoint: every
  statically-derivable expansion/gluing form is resolved before host
  parsing. Brace expansion (local{h,}ost, ::{1,2}, 0x7f00000{1..2};
  alternations and numeric/char ranges, capped at 256 products with
  fail-closed deny on overflow) and single-integer arithmetic literals
  ($((0x7f)).0.0.1 -> 127.0.0.1; hex/bash-octal/decimal normalized, no
  expression evaluator) are expanded and each choice re-checked; the
  ProxyJump chain also checks its unsplit value so a brace comma is not
  torn by the hop split. Run-time-dependent forms (bare $VAR, command
  substitution with output, arithmetic beyond one literal) remain the
  documented residual class. Each resolution only widens the deny.
- Tests: 13 deny cases (6 parameter-operator/nesting forms, 5 brace
  forms incl. proxyjump, 2 arithmetic) + 4 allow cases locking remote
  hosts and plain file operands. Proof-run: exactly the 13 new deny
  assertions fail on the pre-fix tree.
- argv_floor.py black-formatted at the pinned version (clears the
  Backend Lint black-gate offender from round 9).

Review round 11: the quoted-separator sentinel table now covers every
_ends_argv boundary character (& newline # ( {), so a quoted boundary
token is operand data instead of a fake command boundary that hides a
later self-host target (scp '&' localhost:/tmp/x); backslash-newline
stays untouched as a line continuation. Interface addresses join the
synchronous own-host seed, so the first ssh-family command already
knows them -- the async worker only adds DNS-derived names, closing the
first-command window for ssh <own-interface-IP>. Comment wording in
argv_floor.py reworded to satisfy the comment-history lint gate.

The synchronous own-identity seed is packet-less: interface enumeration
keeps the UDP-connect probes and the Linux per-interface sweep, and every
resolver-backed form (getfqdn/getaddrinfo, plus Windows' resolver-backed
interface list) belongs to the async enrichment worker, so is_denied never
does DNS on the event loop. The operand walk reads userinfo before the
colon split when the @ precedes the first colon, so a bare IPv6 loopback
behind userinfo (user@::1) resolves to the whole remainder instead of an
empty host. The RSYNC_RSH walk remembers the last plain assignment across
separators and promotes it into the exported slot when a later bare
"export RSYNC_RSH" names it, covering the POSIX VAR=value; export VAR
two-step.

Windows adapters get their own packet-less sweep in the synchronous seed:
GetAdaptersAddresses (iphlpapi, via ctypes) reads the local adapter table
with the anycast/multicast/dns-server lists skipped, so a secondary or VPN
address with no DNS record is known before the first ssh-family command is
judged, matching the Linux per-interface sweep. The helper self-gates and
returns an empty set off Windows or on any failure.

The raw-substring verb gate resolves static parameter defaults before
probing (s${U:-s}h -> ssh), matching the operand walk; brace-range
integer conversion failures past the interpreter's digit cap land on
the existing fail-closed overflow deny instead of an uncaught
ValueError; a resolver Thread.start failure clears the in-flight latch
and answers from the synchronous seed instead of aborting the
permission decision. ctypes moves to the module import block.

Flat command substitutions with statically-decidable output (echo/printf
literals) splice into the source before the verb probe and the operand
walk, so ssh $(printf localhost) resolves to its output; dynamic bodies
keep their text and stay the documented run-time residual. macOS gains
a packet-less getifaddrs interface sweep feeding the synchronous seed,
the sibling of the Linux ioctl and Windows GetAdaptersAddresses sweeps.

Glob operands and command words resolve against what the pattern CAN
match: a word whose glob can name an ssh-family program passes the verb
gate and the walk, and an operand whose glob can match a loopback or
own-host name denies, both via fnmatch with no filesystem read. A
hostname the textual layers cannot classify goes to an off-loop DNS
verdict worker; the decision fails closed until the cached verdict says
whether any resolved address is loopback or local. The numeric-loopback
check accepts only spellings inet_aton or ip_address accept, so remote
domains with a numeric first label stay allowed.

Round 17 (three lanes): detached "-J value" (and flag bundles ending in
the jump letter) now latch the next token as a ProxyJump hop chain,
comma-split like the attached form. The fail-closed DNS-alias verdict is
consulted only for tokens in HOST position (ssh/sftp positional, a
host:path prefix, a scheme authority, a userinfo remainder, or a routing
option value) so dotted local filenames like backup.tar.gz are never
refused as first-contact hostnames. Literal same-line assignments
resolve before the verb gate and the walk (a=s; ${a}sh localhost
denies); one level of function-call argument binding recurses the floor
on the bound body (f(){ ssh "$1" id; }; f localhost denies); an
arithmetic expression that survives normalization fails closed in a
connection-target position (ssh 127.0.0.$((0+1)) denies), reversing the
earlier allow ruling for target-position expressions. The catalog rule
description now declares the one-time first-contact refusal instead of
claiming other hosts are unaffected, and the macOS CI leg runs the
interface-sweep tests so the darwin getifaddrs walk is CI-verified.

Round 18: the arithmetic literal normalizer keeps its decimal str()
inside the try -- a huge hex/octal literal whose int() succeeds (power-
of-two bases are exempt from the digit cap) but whose decimal spelling
exceeds it now fails closed through the target-position rule instead of
raising through is_denied. Host position survives valueless flags: a
new case-folded value-taking option letter set decides whether the
token after an option is the destination (ssh -v self.example is DNS-
checked) or the option's value (ssh -i id_rsa.pub is not), and a
consumed positional ends host position so later dotted remote-command
arguments stay data. The DNS-alias layer covers dotless names (an
/etc/hosts loopback alias resolves like any hostname; position, not
punctuation, keeps junk out) and allow verdicts are revalidated after a
TTL (stale-while-revalidate, one single-flight worker; deny verdicts
stay permanent) so a rebinding name is caught at the next publish. The
function binder accepts bash's parenthesis-free keyword form. The rule
description drops the word dotted.

Round 19: the value-taking option letters are per verb, and a
case-folded collision now reads as VALUE-TAKING -- sftp's uppercase -R
takes a value, so treating the folded "r" as valueless let the value
consume the positional slot and the real host went unchecked (sftp -R
64 localhost). A host mistaken for a value leaves the slot pending and
every later token still gets the full checks, so over-checking is the
safe side of that fold. ssh -c cipher is the same class and is covered
by the same table. Test assertions on the DNS recorder use exact
equality comprehensions (also clears the CodeQL substring-sanitization
false positive on the membership spelling).
@patrigao

patrigao commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

self-added: yes
mechanism: per-verb value-taking option tables (_VERB_VALUE_TAKING_OPT_LETTERS), collisions -> value-taking

  • fixed (cf5c2bedd): span=7670520e57b3 — sftp's uppercase -R bypassed self-target detection (sftp -R 64 localhost).

The value-taking classification is now per verb, and a case-folded collision reads as VALUE-TAKING — the reverse of the round-18 rule, whose "the DNS layer still covers a host after it" bet failed on the consumption path the adjudicator traced: a value mistaken for the positional CONSUMES the slot and the real host is never checked at all, while a host mistaken for a value leaves the slot pending so every later token still gets the full checks. Over-checking is the floor's safe direction, so this ruling covers case-fold collisions in the option tables as a class (ssh -c cipher host was the same latent instance and is covered by the same table).
Deny tests: sftp -R 64 localhost, ssh -c aes128-ctr localhost id; allow pinned: sftp -R 64 far.example.com. Red-first at fbb4959 (both allowed pre-fix); class 197/197, suite 1138 post-fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants