feat(security): invariant G7 — no unprivileged command weaker than the Bash tool - #55
Merged
Merged
Conversation
…n the Bash tool sudo-proxy verified one invariant (G1: nothing privileged runs without a human approving the exact command). The unprivileged surface was weaker than the agent's own Bash tool in two ways; this adds a second invariant (G7) and closes both gaps. Gap 1 (F2): pressing `a` at an unprivileged prompt persisted a global `confirm_unprivileged=false`, after which unprivileged commands ran unattended with only a best-effort banner. Replaced with a two-barrier, session-scoped model: - `unattended_eligible` (per-daemon policy, default false, read once at startup and immutable at runtime — no wire field/MCP flag/keypress sets it); and - an in-memory session grant flipped only by `a` when eligible, never persisted, reset when the daemon/SSH tunnel ends. Non-eligible daemons prompt every command; a stale `confirm_unprivileged` key is inert on load. The granted window logs unconditionally (reliable audit). Gap 2 (F5, new): host routing keyed on the literal string "localhost", so `execute(host="127.0.0.1")` opened an SSH-to-self tunnel that bypassed local policy. Added `is_local_host` normalization (loopback /8, ::1, own hostname, user@/trailing-dot forms) at every routing site. Delegate-to-Bash: a local unprivileged `execute()` is refused and redirected to the Bash tool (which already applies the client's rules); sudo-proxy handles privilege escalation and remote hosts. Soundness for undetected self-aliases rests on the composition (C10): every remote daemon still gates unattended execution. Verification: - Rung 2 property/dispatch tests (is_local_host truth table, two barriers, non-persistence, migration inertness, granted-session bypass); rewrote the tests that encoded the old ApprovedAlways behavior. - Rung 4 TLA+ (ApprovalStateMachine): `eligible` immutable input + session `grant` with boundary reset; new properties GrantOnlyByKeypressWhenEligible, NoUnattendedUnprivilegedExec, EligibilityImmutable; TLC-verified with new negative controls NC5/NC6/NC7. - Docs threaded: F2 closed + new F5 in security-audit; REVIEWING C8/C9/C10; assurance-case G7; threat-model, roadmap, README, usage, mcp, architecture. BREAKING: removes `--no-confirm-unprivileged` / `--confirm-unprivileged` (replaced by `--unattended-eligible`); renames the hosts.json policy field `confirm_unprivileged` -> `unattended_eligible` (old value ignored, fail-closed); local unprivileged `execute()` now refused (use the Bash tool). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Oct 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a second security invariant (G7) — no unprivileged command runs with less scrutiny than the agent's Bash tool — and closes the two gaps that made sudo-proxy's unprivileged surface weaker than Bash. G1 (nothing privileged runs without deliberate human approval) is unchanged.
Gap 1 — unattended execution (finding F2, closed)
The old
a("approve always") persisted a globalconfirm_unprivileged=false, after which unprivileged commands ran unattended with only a best-effort banner. Replaced with a two-barrier, session-scoped model:unattended_eligible: per-daemon policy, defaultfalse, read once at startup and immutable at runtime (no wire field, MCP flag, or keypress sets it — only an out-of-bandhosts.jsonedit or--unattended-eligible).awhen eligible, in-memory, never persisted, reset when the daemon/SSH tunnel ends. Non-eligible daemons prompt every command; a staleconfirm_unprivilegedkey is inert on load; the granted window logs unconditionally.Gap 2 — loopback routing bypass (finding F5, new, closed)
Routing keyed on the literal
"localhost", soexecute(host="127.0.0.1")opened an SSH-to-self tunnel around local policy. Addedis_local_hostnormalization (127/8,::1, own hostname,user@/trailing-dot) at every routing site.Delegate-to-Bash
A local unprivileged
execute()is now refused and redirected to the Bash tool (which already applies the client's permission rules). sudo-proxy handles privilege escalation and remote hosts. Undetected self-aliases are covered by the C10 composition — every remote daemon still gates unattended execution.Verification
is_local_hosttruth table, the two barriers, non-persistence, migration inertness, granted-session bypass. Rewrote the tests that encoded the oldApprovedAlwaysbehavior.cargo testgreen (minus the pre-existing flakyburst_connections_above_cap, which CI skips); clippy-D warningsclean; both release builds pass.ApprovalStateMachine):eligibleimmutable input + sessiongrantwith boundary reset; new propertiesGrantOnlyByKeypressWhenEligible,NoUnattendedUnprivilegedExec,EligibilityImmutable— TLC-verified (no error, 11,608 states) with new negative controls NC5/NC6/NC7.security-audit); C8/C9/C10 (REVIEWING); G7 goal (assurance-case); plusthreat-model,formalisation-roadmap,README,usage,mcp,architecture.Versioning — 1.1.0 → 1.2.0
New security feature with graceful migration (stale config fail-closes to prompting).⚠️ Contains arguably-breaking changes a strict reading might call major:
--no-confirm-unprivileged/--confirm-unprivileged(→--unattended-eligible);hosts.jsonpolicy fieldconfirm_unprivileged→unattended_eligible(old value ignored, fail-closed);execute()now refused.The MCP wire protocol and the
privilegedfield are unchanged. Tagv1.2.0on merge (not on this branch).🤖 Generated with Claude Code