From 6d4751f9837ef7a51a25b52f2aebc4a92e667c40 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Tue, 2 Jun 2026 11:03:20 +1000 Subject: [PATCH] =?UTF-8?q?feat(tui):=20OCP=5FTUI=5FFULL=5FTOOLS=20gate=20?= =?UTF-8?q?=E2=80=94=20-p-equivalent=20tools=20for=20single-user=20TUI=20(?= =?UTF-8?q?#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): OCP_TUI_FULL_TOOLS gate — -p-equivalent tool surface for single-user TUI Lets a SINGLE-USER / trusted TUI deployment run a tool-using / MCP agent (e.g. an OpenClaw assistant) on the subscription pool. When OCP_TUI_FULL_TOOLS=1, buildTuiCmd grants the interactive session the SAME tool surface as the -p A-path — --allowedTools (+ optional --mcp-config / --dangerously-skip-permissions, read from the same CLAUDE_ALLOWED_TOOLS / CLAUDE_MCP_CONFIG / CLAUDE_SKIP_PERMISSIONS env as buildCliArgs) — instead of the default MCP-walled, built-in-tools-only set. Motivation: the default TUI tool wall (--strict-mcp-config --disallowedTools mcp__*) exists for multi-tenant safety, but it also blocks a trusted single-operator agent from its MCP tools — forcing tool-using agents onto the metered -p pool after 2026-06-15. This gate resolves that for the single-user case. Safe to gate ON only because TUI is hard-incompatible with AUTH_MODE=multi (server.mjs refuses to boot, see existing guard), so this can NEVER widen a guest's tool surface. Default (gate unset) is unchanged: MCP wall + built-in tools only. Verified live on PI231 through the real OCP TUI path (spike 2026-06-02): - built-in tools: claude created + read a file on the host (2 tool_use entries); - MCP: claude invoked a custom MCP server's tool (mcp__spike__echo_marker) and returned its output; - billing: entrypoint=cli (subscription pool) WITH full tools + MCP; - completion: end_turn, no permission stall, no hang. Tests: +1 (full-tools branch: --allowedTools present + MCP wall dropped; skip-permissions supersedes; mcp-config threaded). 195 pass. README: new OCP_TUI_FULL_TOOLS env var entry. ALIGNMENT.md: changes lib/tui/session.mjs, not server.mjs — server.mjs hard requirements do not trigger. Mirrors buildCliArgs() permissions logic; the flags are documented Claude Code CLI flags, not invented endpoints. cli.js citation N/A under Rule 2. ADR 0007 (A-path). Co-Authored-By: Claude Opus 4.8 * fix(tui): shq operator tool tokens in full-tools shell string (reviewer Finding 2) Independent reviewer (APPROVE WITH CHANGES) caught that buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike buildCliArgs which returns an argv array to spawn(). Operator-supplied CLAUDE_ALLOWED_TOOLS can be a scoped specifier such as "Bash(npm run test:*)" or "Read(~/**)", whose ( ) * ~ would break / inject the shell command if pasted bare. Now shq() each allowed-tool token (operator-self-injection only — guests cannot reach TUI per the multi-mode boot guard, but it's a real correctness bug). Also: tightened the README OCP_TUI_FULL_TOOLS wording per reviewer Finding 1 — the precise safety property is "no guest key can reach the TUI path (multi-mode boot is a hard exit)", and noted the AUTH_MODE=shared + OCP_TUI_ALLOW_LAN trust model is unchanged. Test: +scoped-specifier assertions (token shq'd; never appears unquoted). 195 pass. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: dtzp555 Co-authored-by: Claude Opus 4.8 --- README.md | 1 + lib/tui/session.mjs | 33 +++++++++++++++++++++++++++++++-- test-features.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) 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) => {