diff --git a/README.md b/README.md index 84311b7..f1aaf15 100644 --- a/README.md +++ b/README.md @@ -898,6 +898,7 @@ Future `ocp update` invocations sync automatically. | `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `/.claude/projects//`. Created automatically. | | `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. | | `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. | +| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. | ### Streaming heartbeat diff --git a/lib/tui/session.mjs b/lib/tui/session.mjs index 00c1fa6..9f04a2d 100644 --- a/lib/tui/session.mjs +++ b/lib/tui/session.mjs @@ -212,13 +212,42 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli"); else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" "); + + // Tool surface. + // DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*); + // built-in tools stay on, acceptable for single-user A-path. + // OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path + // (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a + // SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw + // assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors + // buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible + // with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's + // surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG. + let toolArgs; + if (process.env.OCP_TUI_FULL_TOOLS === "1") { + toolArgs = []; + if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") { + toolArgs.push("--dangerously-skip-permissions"); + } else { + const allowed = (process.env.CLAUDE_ALLOWED_TOOLS || + "Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent") + .split(",").map((s) => s.trim()).filter(Boolean); + // shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike + // buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers + // like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell + // command if pasted bare. (operator-self-injection only — guests can't reach TUI.) + if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq)); + } + if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG)); + } else { + toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")]; + } return [ envPrefix, shq(claudeBin), "--model", shq(model), "--session-id", sessionId, - "--strict-mcp-config", - "--disallowedTools", shq("mcp__*"), + ...toolArgs, ].join(" "); } diff --git a/test-features.mjs b/test-features.mjs index 0afb1a5..d0d0d88 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1506,6 +1506,50 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => { assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint"); }); +test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => { + const save = { ...process.env }; + const restore = () => { + for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_SKIP_PERMISSIONS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) { + if (k in save) process.env[k] = save[k]; else delete process.env[k]; + } + }; + try { + // default (gate off) keeps the MCP wall, no --allowedTools + delete process.env.OCP_TUI_FULL_TOOLS; + const off = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli"); + assert.ok(off.includes("--strict-mcp-config") && !off.includes("--allowedTools"), "gate off = MCP wall"); + + // gate on: --allowedTools (default set incl Bash), MCP wall dropped + process.env.OCP_TUI_FULL_TOOLS = "1"; + delete process.env.CLAUDE_SKIP_PERMISSIONS; + delete process.env.CLAUDE_MCP_CONFIG; + delete process.env.CLAUDE_ALLOWED_TOOLS; + const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli"); + assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash"); + assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall"); + + // skip-permissions supersedes --allowedTools + process.env.CLAUDE_SKIP_PERMISSIONS = "true"; + const skip = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli"); + assert.ok(skip.includes("--dangerously-skip-permissions") && !skip.includes("--allowedTools"), "skip-permissions honored"); + + // mcp-config threaded through + delete process.env.CLAUDE_SKIP_PERMISSIONS; + process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json"; + const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli"); + assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)"); + + // operator-supplied scoped tool specifiers must be shell-quoted (no injection via ()*~) + delete process.env.CLAUDE_MCP_CONFIG; + process.env.CLAUDE_ALLOWED_TOOLS = "Bash(npm run test:*),Read"; + const scoped = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli"); + assert.ok(scoped.includes("'Bash(npm run test:*)'"), "scoped tool tokens are shq'd in the shell string"); + assert.ok(!/--allowedTools Bash\(npm/.test(scoped), "scoped token must NOT appear unquoted"); + } finally { + restore(); + } +}); + test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => { const killed = []; const fakeTmux = (args) => {