fix: deny mutating tailscale subcommands so the agent cannot widen its own network exposure - #8262
fix: deny mutating tailscale subcommands so the agent cannot widen its own network exposure#8262GoZippy wants to merge 1 commit into
Conversation
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
a95b72e to
00b0360
Compare
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Deny-list enumerates today's mutating verbs on a third-party CLI, so the invariant "agent can never widen exposure" is only as current as the verb list. Watch
Suggestions
[DESIGN-REVIEWED] b79ea3b |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All verification is complete. The governed First-Principles-Verdict: CONCERNS The invariant claimed — "the agent can never widen its own network exposure" — stays open via ngrok/cloudflared/ssh -R; this closes only the tailscale spelling. And the description's counts contradict the diff. Not justified as shipped
What this change shipsIntent: stop the agent's bash from bypassing the governed tailnet-publish seam by calling the raw
Watch
[FIRST-PRINCIPLES-REVIEWED] b79ea3b |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/security/denied_rules.py:1456 -- shell expansion bypasses all new Tailscale denials Adjudication (Opus 4.8) — is blocking on each finding proportionate?Fenced finding F1: the deny tier matches text via Harm rung: UNBOUNDED (widening the agent's own network exposure past a governed, owner-only ceiling — exposing a service to the public internet). No recovery path confirmed. FLAG's rarity argument fails: the bypass is trivially reachable. [ADJUDICATION] b79ea3b total=0 uphold=0 downgrade=0 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate (node-mutate rule over-blocking incidental "tailscale up" prose) describes real behavior — [OPUS-REVIEWED] b79ea3b |
…s own network exposure No DeniedCommandRule named `tailscale`, so from bash the agent could run `tailscale serve` to publish the dashboard, `tailscale funnel` to expose a local service to the public internet, or `tailscale up`/`set` to enable an SSH server or advertise routes -- all below the governed, owner-only publish path (`tailnet_serve.publish`, which honours `capabilities.tailnet_origin`). Add a `network-exposure` denied-command family (serve mutations, funnel, and up/set/login/logout/switch/cert) enforced at the same hooks.py PreToolUse gate, reusing the linearized flag-run idiom so `_linearize_deny_pattern` keeps them ReDoS-safe. A `(?!\s+status)` carve-out keeps `serve status`/`funnel status` and the read subcommands allowed. Kiro Crew's own publish path is unaffected: `tailnet_serve._run` spawns tailscale via subprocess.run, not the hooks gate.
00b0360 to
b79ea3b
Compare
|
Rebased onto main Four files conflicted:
Gates run locally on the changed files: black, isort, flake8, and Please review the resolution. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong. |
Problem / Motivation
No
DeniedCommandRuleinBUILTIN_DENIED_RULESnamestailscale. Putting thedashboard on the tailnet is a governed action —
tailnet_serve.publish()isowner-only, SEL-audited, and honours
capabilities.tailnet_origin— but thatgovernance lives at the dashboard/CLI seam. The raw
tailscaleCLI sits belowit, and the agent has a bash tool. So from bash the agent can run
tailscale serveto publish the dashboard,tailscale funnelto expose a localservice to the public internet, or
tailscale up/setto enable an SSHserver or advertise this node as an exit/subnet router — every one of them
bypassing
publish()and its governance pin.Why it matters
"The agent can never widen its own network exposure" is a security invariant the
governed publish path is built to uphold, and today a single bash call defeats
it.
tailscale funnelis the sharpest case: it makes a local service reachablefrom the open internet, with no governance gate in the path. On Windows there is
no OS sandbox beneath the bash tool to catch it either.
What changed (motivation → approach → change)
Root cause. Governance for tailnet exposure is enforced at
publish(), not atthe command layer, and the denied-command gate — the one place that does sit on
the agent's bash — had no rule for
tailscale.Approach. Add a
network-exposuredenied-command family that blocks themutating
tailscalesubcommands at the samehooks.pyPreToolUse gate as everyother denied command, while keeping the read subcommands the status card and any
diagnostic tooling rely on. Reuse the existing linearized flag-run idiom
(
tailscale(?:\s+--?…)*\s+<verb>) so_linearize_deny_patternkeeps thepatterns ReDoS-safe, exactly like the AWS and self-protection rules. A
(?!\s+status)carve-out onserve/funnelpreservesserve status/funnel status.Change. Three rules (category
network-exposure):network-exposure-tailscale-serve—tailscale servemutations (publish/withdraw);
serve statusstays allowed.network-exposure-tailscale-funnel—tailscale funnel(public-internetexposure);
funnel statusstays allowed.network-exposure-tailscale-node-mutate—up/set/login/logout/switch/cert.Kiro Crew's own publish path is unaffected:
tailnet_serve._runspawnstailscaleviasubprocess.rundirectly, never through thehooks.pygate, sokirocrew tailnet upand the Phone-access card keep working. Reads (status,serve status,netcheck,ping,whois) stay allowed.Tests
test/test_denied_commands_security.py::TestCatalog::test_tailscale_network_exposure_familyasserts the deny/allow matrix: 15 mutation forms are blocked (incl. an interposed
top-level flag and a
sudoprefix) and 11 read/incidental forms stay allowed(incl.
serve status --json,funnel status, and the word inside anecho/grep). The catalog count pins move 148 → 151 and the golden manifest gains thethree entries (append-only, verified byte-identical for the existing 148).
Manual verification
N/A — unit coverage is sufficient: the rules are pure
DeniedCommandRuledataevaluated by the existing gate, and the deny/allow matrix exercises the regex
tier directly. The linearization/ReDoS suite in the same file already covers the
flag-run idiom these patterns reuse.
Related Issues
N/A — no existing issue; hardening found while wiring the Phone-access flow. The
invariant it upholds ("the agent cannot widen its own network exposure") is the
same one the governed
publish()path andcapabilities.tailnet_originalreadyserve at the dashboard seam.
Pattern harvest
Rule candidate: a checklist item rather than a mechanical lint — a capability that is
governed at a high-level seam must also be denied at the command layer whenever a
raw CLI reaches the same effect below that seam. Tailnet exposure is governed at
tailnet_serve.publish()(owner-only, audited,capabilities.tailnet_origin),but the
tailscaleCLI sits under it and the agent has a bash tool — so thegovernance was reachable-around until these deny rules closed it. The
generalizable audit: for each governed capability, ask "is there a CLI/binary
that produces the same effect, and does a
DeniedCommandRulecover it?" Notexpressible as a syntactic pattern (it is a semantic mapping between a governance
seam and a command surface), so it is guidance for the next capability review
rather than a semgrep rule.
Checklist
docs/system-specs/modules/security.md)