mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-24 14:35:08 +00:00
fix(tui): pass --safe-mode so the host CLAUDE.md cannot leak into proxied turns (#187)
OCP is a proxy: the operator's private ~/.claude/CLAUDE.md and auto-memory must never enter a proxied turn's context. The pane already sets CLAUDE_CODE_DISABLE_CLAUDE_MDS + CLAUDE_CODE_DISABLE_AUTO_MEMORY, but on newer claude (2.1.216) those env vars no longer suppress the injection, so a proxied turn can obey the operator's private CLAUDE.md instead of the caller's prompt — a leak of private context into API responses. Add --safe-mode to the interactive spawn argv (gated off on the streaming and OCP_TUI_FULL_TOOLS paths). Docs + tests updated. Co-authored-by: sumlin <3495838+sumlin@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -65,7 +65,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
|
|||||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
|
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode runs `claude` with `--safe-mode`, which disables all host customizations (CLAUDE.md, skills, plugins, hooks, MCP servers) while leaving auth, model selection, built-in tools, and permissions untouched — so a `CLAUDE.md` on the OCP host can never leak into a proxied turn and steer the answer, and the session still bills the subscription pool (`cc_entrypoint=cli`, unlike `--bare`). The `CLAUDE_CODE_DISABLE_CLAUDE_MDS` / `CLAUDE_CODE_DISABLE_AUTO_MEMORY` env vars are kept as a fallback (see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled. **Exception:** the two opt-in modes that deliberately need a customization `--safe-mode` would strip — real streaming (`OCP_TUI_STREAM`, which registers a `MessageDisplay` **hook** via `--settings`) and `OCP_TUI_FULL_TOOLS` (which grants an **MCP** / skills surface) — run without `--safe-mode` and rely on the env-var suppression alone.
|
||||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||||
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
|||||||
+32
-3
@@ -389,9 +389,15 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
|||||||
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||||
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||||
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||||
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
// obeyed by the proxied turn until these flags were set, after which it was not. Mirrors the
|
||||||
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
// -p path's CLAUDE_NO_CONTEXT vars. Harmless on hosts with no CLAUDE.md (they suppress nothing).
|
||||||
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
//
|
||||||
|
// These env vars stopped being sufficient on newer claude: they are present in the pane's
|
||||||
|
// environment yet the host CLAUDE.md is still injected into the turn's context, so a proxied
|
||||||
|
// turn again obeys the operator's private CLAUDE.md instead of the caller's prompt — a leak of
|
||||||
|
// the operator's private context into API responses. The robust suppression is the --safe-mode
|
||||||
|
// flag added to the argv below; these env vars are kept as belt-and-braces for the two argv
|
||||||
|
// paths that cannot take --safe-mode (see the safeModeArgs note near the return).
|
||||||
const sets = [
|
const sets = [
|
||||||
`HOME=${shq(ehome)}`,
|
`HOME=${shq(ehome)}`,
|
||||||
"DISABLE_AUTOUPDATER=1",
|
"DISABLE_AUTOUPDATER=1",
|
||||||
@@ -482,9 +488,32 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
|||||||
// so the OFF argv is byte-for-byte the pre-streaming argv.
|
// so the OFF argv is byte-for-byte the pre-streaming argv.
|
||||||
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
|
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
|
||||||
|
|
||||||
|
// --safe-mode: disable ALL customizations (host CLAUDE.md, skills, plugins, hooks, MCP
|
||||||
|
// servers, custom commands/agents, output styles, ...) while leaving auth, model selection,
|
||||||
|
// built-in tools, and permissions untouched (claude 2.1.216 --help). This is the robust
|
||||||
|
// replacement for the CLAUDE_CODE_DISABLE_CLAUDE_MDS / _AUTO_MEMORY env vars above, which no
|
||||||
|
// longer suppress the host CLAUDE.md on newer claude — so the operator's private CLAUDE.md
|
||||||
|
// could otherwise leak into a proxied API response. Unlike --bare, --safe-mode does NOT drop
|
||||||
|
// the interactive session off the subscription pool (it keeps auth + model selection), so the
|
||||||
|
// billing classifier stays cc_entrypoint=cli.
|
||||||
|
//
|
||||||
|
// NOT applied when the pane deliberately relies on a customization --safe-mode would strip,
|
||||||
|
// because those would break SILENTLY:
|
||||||
|
// - streaming (settingsArgs present): the MessageDisplay hook is a HOOK, which --safe-mode
|
||||||
|
// disables — every streamed turn would then fire zero deltas (tui.streamZeroDeltaTurns)
|
||||||
|
// and fall back to buffered, defeating OCP_TUI_STREAM.
|
||||||
|
// - OCP_TUI_FULL_TOOLS=1: grants an MCP / skills / agent surface (--mcp-config, ...) that
|
||||||
|
// --safe-mode disables wholesale.
|
||||||
|
// Those two opt-in paths keep the env-var suppression above as their best-effort fallback.
|
||||||
|
const safeModeArgs =
|
||||||
|
settingsArgs.length === 0 && process.env.OCP_TUI_FULL_TOOLS !== "1"
|
||||||
|
? ["--safe-mode"]
|
||||||
|
: [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
envPrefix,
|
envPrefix,
|
||||||
shq(claudeBin),
|
shq(claudeBin),
|
||||||
|
...safeModeArgs,
|
||||||
"--model", shq(model),
|
"--model", shq(model),
|
||||||
"--session-id", sessionId,
|
"--session-id", sessionId,
|
||||||
...toolArgs,
|
...toolArgs,
|
||||||
|
|||||||
+27
-1
@@ -2209,10 +2209,32 @@ console.log("\nTUI command construction (proxy-purity / #4):");
|
|||||||
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||||
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||||
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||||
|
// Primary mechanism is --safe-mode (env vars alone stopped suppressing on newer claude);
|
||||||
|
// the env vars remain as belt-and-braces.
|
||||||
|
assert.ok(/(^| )--safe-mode( |$)/.test(cmd), "default pane must pass --safe-mode (disables host CLAUDE.md/skills/plugins/hooks)");
|
||||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd omits --safe-mode when a customization it would strip is in use", () => {
|
||||||
|
const save = process.env.OCP_TUI_FULL_TOOLS;
|
||||||
|
try {
|
||||||
|
delete process.env.OCP_TUI_FULL_TOOLS;
|
||||||
|
// streaming registers a MessageDisplay HOOK via --settings; --safe-mode would kill the hook
|
||||||
|
// (zero deltas), so it must be omitted on the streaming pane.
|
||||||
|
const streaming = buildTuiCmd("/usr/bin/claude", "m", "sid-s", "/home/u", "cli", { file: "/d/sid-s.jsonl", settings: "/d/s.json" });
|
||||||
|
assert.ok(!/--safe-mode/.test(streaming), "streaming pane must NOT pass --safe-mode (would disable the MessageDisplay hook)");
|
||||||
|
assert.ok(streaming.includes("--settings '/d/s.json'"), "streaming pane keeps its --settings hook");
|
||||||
|
|
||||||
|
// OCP_TUI_FULL_TOOLS grants an MCP/skills surface --safe-mode disables wholesale.
|
||||||
|
process.env.OCP_TUI_FULL_TOOLS = "1";
|
||||||
|
const full = buildTuiCmd("/usr/bin/claude", "m", "sid-f", "/home/u", "cli");
|
||||||
|
assert.ok(!/--safe-mode/.test(full), "full-tools pane must NOT pass --safe-mode (would disable MCP/skills)");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.OCP_TUI_FULL_TOOLS; else process.env.OCP_TUI_FULL_TOOLS = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||||
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||||
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||||
@@ -4531,13 +4553,17 @@ test("stream: sink path is keyed by session_id (concurrent panes cannot interlea
|
|||||||
assert.ok(A.endsWith("/aaaa-1111.jsonl"));
|
assert.ok(A.endsWith("/aaaa-1111.jsonl"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stream: buildTuiCmd — OFF is byte-for-byte the pre-streaming argv; ON adds only env + --settings", () => {
|
test("stream: buildTuiCmd — streaming ON adds env + --settings and drops --safe-mode (hook survives)", () => {
|
||||||
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
|
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
|
||||||
assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
|
assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
|
||||||
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
|
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
|
||||||
|
assert.ok(off.includes("--safe-mode"), "the non-streaming pane carries --safe-mode");
|
||||||
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
|
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
|
||||||
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
|
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
|
||||||
assert.ok(on.includes("--settings '/d/s.json'"));
|
assert.ok(on.includes("--settings '/d/s.json'"));
|
||||||
|
// --safe-mode would disable the MessageDisplay hook registered by --settings, so the
|
||||||
|
// streaming pane must NOT carry it (it keeps the env-var suppression instead).
|
||||||
|
assert.ok(!on.includes("--safe-mode"), "streaming pane omits --safe-mode so the hook fires");
|
||||||
// must not regress the MCP wall or the pinned effort (#156)
|
// must not regress the MCP wall or the pinned effort (#156)
|
||||||
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
|
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
|
||||||
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");
|
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");
|
||||||
|
|||||||
Reference in New Issue
Block a user