feat(security): deny ssh/scp/sftp/rsync targeting this same host - #8283
feat(security): deny ssh/scp/sftp/rsync targeting this same host#8283patrigao wants to merge 1 commit into
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of 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
Suggestions
[DESIGN-REVIEWED] cf5c2be |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of 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 Not justified as shipped
What this change shipsInventory (9 items) — 7 justifiedIntent: 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.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] cf5c2be |
Opus 4.8 Review — 🔴 changes requested (blocking)Reviewed BLOCKING — src/kiro_crew/security/argv_floor.py:2862 — an ANSI-C-quoted ssh-family verb bypasses the entire Input [BLOCK-MERGE] cf5c2be Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/security/argv_floor.py:2596 -- Renamed SSH binaries bypass the floor 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 — F2 — at argv_floor.py:3077 a bare Torn on unbounded-security fenced findings resolves to UPHOLD-FENCED. [ADJUDICATION] cf5c2be total=0 uphold=0 downgrade=0 False positive or not applicable? A repository writer can comment: |
ad456d0 to
53378ae
Compare
|
|
|
|
f1e84cb to
596fd06
Compare
|
596fd06 to
a5dcd29
Compare
|
|
|
|
a5dcd29 to
1e56ac8
Compare
|
self-added: yes
Fixed in 0fd83d4.
|
|
self-added: yes
Fixed in 0fd83d4.
|
|
Advisory (gpt): function-local |
0fd83d4 to
d71c6d1
Compare
|
self-added: yes
Fixed in d71c6d1.
|
|
self-added: yes
Fixed in d71c6d1.
|
d71c6d1 to
6915379
Compare
|
self-added: yes
Fixed in 6915379.
|
|
self-added: yes
Fixed in 6915379.
|
|
Advisory (gpt): |
6915379 to
d012b0a
Compare
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: no
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: no
|
d012b0a to
fbb4959
Compare
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
|
self-added: yes
|
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).
|
self-added: yes
|
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 insecurity.py. Observed live on a dev desktop:sudois broken inside the sandbox (uid remap strips the setuid bit), butssh 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 localhostinherits 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):
sandbox-escape-ssh-self(new categorysandbox-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 namedlocalhost.example.comnever matches. It is deliberately a human-auditable SUBSET of the floor: the star-of-options shape that could skip option/value pairs failsis_safe_user_regex, which would silently disable the rule (_DenyMatcherrefuses unsafe patterns)._is_ssh_to_self, registered in_SELF_PROTECTION_FLOOR_RULE_IDSand theis_deniedfloor loop with its own_SELF_PROTECTION_FLOOR_NOTESentry. The walk is FAIL-CLOSED about option grammar: instead of a per-verb table of which options take values (a table mis-consumedscp -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, sossh 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._operand_targets_self/_host_is_self): loopback names,127.*, bracketed and bare IPv6 (::1,::ffff:127.0.0.1), numeric IPv4 spellingsinet_atonaccepts (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`/$HOSTNAMEspellings, Windowsssh.exe, and ssh's routing options in both spellings (-o HostName=localhostand 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.ioctl, WindowsGetAdaptersAddresses, macOSgetifaddrs— the macOS walk is exercised by the macOS CI leg). DNS-derived names resolve in a background daemon thread with retry backoff:getfqdn/getaddrinfoare synchronous DNS andis_deniedruns inline on the gateway event loop (the AUTOSDE no-blocking-call rule names these calls). The hard-coded loopback half never depends on either.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.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):
-Fconfig files, ssh_config Host aliases, theGIT_SSH_COMMANDenv 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):bash -cpayload, URI, IPv6/numeric literals, redirects, option-shadow shapes likescp -r localhost:/dir .andssh -vp 22 localhost, routing options in both spellings,ssh.exe).localhost(quoted and unquoted),localhost.example.com,::10,notssh,$HOSTNAME_BACKUP,rsync --exclude=localhost, non-connection mentions.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_deniedin 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 thatis_deniedreturns in ~64ms with DNS artificially blocked (background-thread resolution verified). The config-style-o "Hostname localhost"routing was confirmed equivalent to the=spelling against realssh -Goutput.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.