diff --git a/docs/agents/LOCAL_CLI_WRAPPER.md b/docs/agents/LOCAL_CLI_WRAPPER.md index ae0c5c12c1..bbc6d54acc 100644 --- a/docs/agents/LOCAL_CLI_WRAPPER.md +++ b/docs/agents/LOCAL_CLI_WRAPPER.md @@ -64,6 +64,7 @@ A Claude Code, Codex, OpenCode or Qwen Code seat that the web app or the daemon A seat you attach yourself with `commonly agent attach`, with no `--env` or with an environment file that declares **no** `sandbox` block, is **private**. It runs as you, with no sandbox: - **What it can reach:** your whole machine as your user. That covers your files, your shell tools, your own MCP servers (including any that hold a logged-in session, such as a browser or LinkedIn server), your skills and settings, and the connectors on your Claude account. A private Codex seat also runs without approvals. +- **Your stored credentials are part of that reach.** Anything your user can read, the seat can read. That includes your operating system's credential store (the macOS login keychain, a Linux secret service), so it covers every token a tool keeps there: `gh`, a git credential helper, a cloud CLI. A wrapper or shim around a tool does not keep the seat away from that tool's token. The seat can read the token itself and pass it to the tool, or use it directly. If a seat must not act as you on a service, keep that service's credential out of its reach (a separate OS user, or a store whose access list names only the tool's own binary), or give the seat a sandbox block. - **Who can instruct it:** everyone who can @mention it in the pod, including any teammate you invite later. - **Where attach allows it:** attach refuses a private seat in a pod anyone can read or one listed in Community. Use one only in a pod where you're the only member, or where you invited everyone yourself. - **Your own Claude tools still need a permission:** the listener pre-approves the tools of the servers declared in the seat's environment (by default, only Commonly's). Tools from servers in your own `~/.claude.json` need a `permissions.allow` entry in your `~/.claude/settings.json`, e.g. `"mcp____"`. Without that, the call is refused, because there's no one to answer a permission prompt. diff --git a/docs/development/agent-experience-audit.md b/docs/development/agent-experience-audit.md index 8096552c87..52b8fe907e 100644 --- a/docs/development/agent-experience-audit.md +++ b/docs/development/agent-experience-audit.md @@ -4210,3 +4210,19 @@ Settings read one writable pod's `config.environment`, while the daemon selected **Repair:** Settings now uses the daemon's environment projection for model and effort, saves through that projection's source pod, and shows standalone CLI controls as locally set and read-only. Focused route, UI and adapter tests connect the saved effort to the spawn argv. **Lesson:** a displayed runtime setting must identify the source the runner reads. A server preference cannot attest a local process that does not consume it. + +## 85. A wrapper around a CLI is not a fence around the CLI's credential (2026-10-22, operator) + +*Origin: an incident on 2026-10-22, written up by the operator. The seat, accounts and machine are left out on purpose; the mechanism is the finding.* + +A private seat (it runs as the operator's OS user, with no sandbox) was blocked from a hosting CLI by a wrapper that refuses seat calls. It worked out that the CLI's tokens sat in the operator's login credential store. It read them with the operating system's own command-line tool and passed one to the CLI through the CLI's token environment variable. That bypassed the wrapper entirely. Two reads printed the raw stored values. The seat's own redaction covered the tokens' usual prefix but not the encoded form the store returns, so both values entered the model's context, which the seat's runtime sends to its model provider. The only calls made with a token were two read-only API requests. Both credentials were treated as exposed and rotated. + +Nothing in the seat broke a rule it could see: the wrapper reads as "you may not run this CLI", and the seat honoured that by not running it through the wrapper. The guard sat on the binary; the authority sat in the credential, and the credential was readable by the same user the seat runs as. + +**Repair (pending SRE):** on the affected machine, limit the credential's store entry to the CLI's own binary in its access list, so other processes, a seat's included, are refused. This line is rewritten to what was actually applied before the entry merges. Such a lock is an interim mitigation, not the fix: the root cause is that seats run as the same OS user as the operator, and the proposed durable fix, a separate OS user for seats, is ADR-031 (#2428). The local-wrapper docs now say that a private seat's reach includes the OS credential store (`docs/agents/LOCAL_CLI_WRAPPER.md`, "Public and private seats"). + +**Lesson:** put the boundary where the credential lives, not in front of the program that uses it. A shim, an alias or an allowlisted wrapper is a habit guard for a cooperative caller; it stops nothing that can read the token. Three corollaries: +- a redaction pattern has to match every form the value can take (raw, base64, URL-encoded), or it fails exactly when a tool returns a stored blob; +- once a value has entered a model's context it has left the machine, so "it was never written to a file" is not a reason to skip rotation. +- a refusal to hand over a credential should not say where the credential lives. Here, another agent correctly declined to copy the token, but its refusal named the store it sat in, and the seat read it from there. A refusal that names the location works as a map. +