Never put the system prompt on the command line - #291
Open
pufit wants to merge 1 commit into
Open
Conversation
The assembled system prompt is the instance's private context — identity and memory files plus TOOLS.md, which indexes where the host's credentials live. It was passed to the CLI as `--system-prompt <text>` whenever it fit under a 100 KB threshold, which put the whole bundle in argv: readable by any local process, for every concurrent session, with one unprivileged `ps` and no trace. Over the threshold it spilled to a file instead — but at 0644 in a 0755 dir, so that branch only moved the exposure to disk. Which side a session landed on was incidental drift. Now it always travels by file, at every size, written 0600 in a 0700 directory via the atomic writer (mode set before the content lands). The sweep that GCs stale spills also clamps the ones earlier versions left at 0644, so existing installs are remediated on next session start, and it lstats rather than stats so a planted symlink can't redirect it. Prompt-cache behavior is keyed on prompt content, not transport: over the last 7 days on a live instance the file path showed a 96.1% cache-read share against 91.9% for inline, so there is nothing to trade off and no reason to keep a knob whose only other setting is "leak". Side effects: the E2BIG failure the threshold was originally guarding against can no longer happen, and workspace text is no longer a `pkill -f` pattern matching every session on the box. Measured: 101,683 bytes of argv -> 428.
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.
The problem
The Claude backend passed the assembled system prompt to the CLI as
--system-prompt <text>whenever it fit under a 100 KB threshold — which put the entire prompt in argv.argv is world-readable. Any process running as the same user — including the MCP servers Nerve spawns as subprocesses, and any command an agent shells out to — can read it with one unprivileged
ps -ww, for every concurrent session at once, leaving no trace.What's in that prompt is not incidental: SOUL.md, IDENTITY.md, USER.md, AGENTS.md, TOOLS.md, MEMORY.md and the recalled-memory block. On a configured instance that means the operator's personal details and an index of where the host's credentials live.
Over the threshold the prompt already spilled to a file — but at mode
0644in a0755directory, so that branch just moved the same bundle to a world-readable file. There was no safe side; the threshold only chose which exposure you got. And which side a session landed on was incidental drift: how many skills were listed, how long the recall block came back, the length of the session id.The threshold was never a confidentiality control. It was added as a crash guard (
117ecf7) to stay under Linux'sMAX_ARG_STRLENand avoidE2BIG, and its own comment says so.The change
The prompt always travels by file, at every size.
_build_optionsunconditionally writes it and passes{"type": "file", "path": ...}, which the SDK turns into--system-prompt-file <PATH>— only the path reaches the process table.The file is now written like the secret it is:
0600file in a0700directory, via the existing atomic writer, so the mode is in place before the content is — no window at the process umask.mkdir(exist_ok=True)does not touch an existing directory's mode, which is exactly why the current one is still0755on installs that have been running a while.0644. Existing installs are remediated on the next session start rather than waiting for the files to age out.lstats rather thanstats, so a symlink planted in the directory can't aim the unlink or the chmod at a file outside it.No config knob. A knob here would have one non-default setting, and that setting is "leak".
Prompt cache
The inline branch's stated benefit was preserving prompt-cache hits for small, stable prompts. Measured rather than assumed, over 7 days of real traffic on a live instance:
The cache key is the rendered prompt's content, not its transport. Nothing to trade off.
Verification
Built the real argv the SDK would exec, using a full-size production prompt, then put that exact vector in the process table and read it back with
ps:psoutput contains the path and nothing else — no identity content, no credential-file names.tests/test_system_prompt_transport.pypins this end-to-end through the SDK's own argv builder (not a shape assertion on our options object), plus the permissions, the umask independence, the upgrade path, symlink safety, filename determinism for resume, and the GC.Full suite: 3040 passed.
Side effects, both good
E2BIGcan no longer happen. The failure the threshold was guarding against is gone rather than avoided.pkill -fmagnet. With the bundle in argv, every string in any workspace file matched every session's process, so an ordinarypkill -f "<some common word>"during cleanup was a fleet-wide kill switch. Off argv, it isn't.Backends now agree
Codex was already unaffected — it sends the prompt as
developerInstructionsinside its app-server JSON payload, never argv. The invariant is now written down onSessionSpec.system_promptand guarded by a test covering both backends, so a future backend can't quietly reintroduce it.