Skip to content

feat(security): invariant G7 — no unprivileged command weaker than the Bash tool - #55

Merged
cuihtlauac merged 1 commit into
mainfrom
feat/g7-unprivileged-invariant
Oct 2, 2026
Merged

cuihtlauac merged 1 commit into
mainfrom
feat/g7-unprivileged-invariant

Conversation

@cuihtlauac

Copy link
Copy Markdown
Member

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 global confirm_unprivileged=false, after which unprivileged commands ran unattended with only a best-effort banner. Replaced with a two-barrier, session-scoped model:

  • Barrier 1 — unattended_eligible: per-daemon policy, default false, read once at startup and immutable at runtime (no wire field, MCP flag, or keypress sets it — only an out-of-band hosts.json edit or --unattended-eligible).
  • Barrier 2 — session grant: flipped only by a when eligible, in-memory, 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.

Gap 2 — loopback routing bypass (finding F5, new, closed)

Routing keyed on the literal "localhost", so execute(host="127.0.0.1") opened an SSH-to-self tunnel around local policy. Added is_local_host normalization (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

  • Rung 2 property/dispatch tests: is_local_host truth table, the two barriers, non-persistence, migration inertness, granted-session bypass. Rewrote the tests that encoded the old ApprovedAlways behavior. cargo test green (minus the pre-existing flaky burst_connections_above_cap, which CI skips); clippy -D warnings clean; both release builds pass.
  • Rung 4 TLA+ (ApprovalStateMachine): eligible immutable input + session grant with boundary reset; new properties GrantOnlyByKeypressWhenEligible, NoUnattendedUnprivilegedExec, EligibilityImmutable — TLC-verified (no error, 11,608 states) with new negative controls NC5/NC6/NC7.
  • Docs threaded: F2 closed + new F5 (security-audit); C8/C9/C10 (REVIEWING); G7 goal (assurance-case); plus threat-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:

  • removes --no-confirm-unprivileged / --confirm-unprivileged (→ --unattended-eligible);
  • renames the hosts.json policy field confirm_unprivileged → unattended_eligible (old value ignored, fail-closed);
  • local unprivileged execute() now refused.

The MCP wire protocol and the privileged field are unchanged. Tag v1.2.0 on merge (not on this branch).

🤖 Generated with Claude Code

…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>
@cuihtlauac
cuihtlauac merged commit e648fc7 into main Oct 2, 2026
16 checks passed
@cuihtlauac
cuihtlauac deleted the feat/g7-unprivileged-invariant branch October 2, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant