Skip to content

Never put the system prompt on the command line - #291

Open
pufit wants to merge 1 commit into
mainfrom
pufit/system-prompt-never-in-argv
Open

Never put the system prompt on the command line#291
pufit wants to merge 1 commit into
mainfrom
pufit/system-prompt-never-in-argv

Conversation

@pufit

@pufit pufit commented Aug 9, 2026

Copy link
Copy Markdown
Member

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 0644 in a 0755 directory, 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's MAX_ARG_STRLEN and avoid E2BIG, and its own comment says so.

The change

The prompt always travels by file, at every size. _build_options unconditionally 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:

  • 0600 file in a 0700 directory, via the existing atomic writer, so the mode is in place before the content is — no window at the process umask.
  • The chmod on the directory is unconditional. mkdir(exist_ok=True) does not touch an existing directory's mode, which is exactly why the current one is still 0755 on installs that have been running a while.
  • The sweep that GCs stale spills (>7d) now also clamps files earlier versions left at 0644. Existing installs are remediated on the next session start rather than waiting for the files to age out.
  • It lstats rather than stats, 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:

transport turns cache-read share
file 46 96.1%
inline 736 91.9%

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:

system prompt: 101,683 bytes
argv:              428 bytes

ps output contains the path and nothing else — no identity content, no credential-file names. tests/test_system_prompt_transport.py pins 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

  • E2BIG can no longer happen. The failure the threshold was guarding against is gone rather than avoided.
  • Workspace text stops being a pkill -f magnet. With the bundle in argv, every string in any workspace file matched every session's process, so an ordinary pkill -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 developerInstructions inside its app-server JSON payload, never argv. The invariant is now written down on SessionSpec.system_prompt and guarded by a test covering both backends, so a future backend can't quietly reintroduce it.

Generated by Nerve

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.
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