fix: TUI hardening — non-loopback LAN gate + assert cc_entrypoint (#115) (#122)

Two TUI-mode findings from the 2026-05-31 audit (ADR 0007):

1. The fail-loud LAN gate refused TUI boot only when CLAUDE_BIND === "0.0.0.0",
   but binding to a concrete LAN IP, a Tailscale 100.x address, or IPv6 :: is
   equally network-exposed and slipped through — letting any reachable peer drive
   the operator's full-filesystem claude session. New isLoopbackBind() treats only
   127.0.0.0/8 / ::1 / localhost / ::ffff:127.0.0.1 as safe; the gate now trips on
   any non-loopback bind (OCP_TUI_ALLOW_LAN=1 escape hatch retained).

2. verifyEntrypoint() was implemented and unit-tested but never wired: readTuiTranscript
   discarded the events and returned only text, so a silent degrade to the metered
   sdk-cli (Agent SDK) billing pool — which still returns text but costs money — went
   undetected. readTuiTranscript now returns { text, entrypoint }; runTuiTurn passes it
   through; callClaudeTui logs a "tui_entrypoint_mismatch" warning when configured cli
   mode yields a non-cli entrypoint (including null/unverified). callClaudeTui still
   returns Promise<string>, so singleflight/cache/completion downstream is unchanged.

ALIGNMENT.md: TUI is a proxy-side execution-mode bridge (ADR 0007); this adds a local
bind classifier (startup gate) + a non-fatal log assertion of an existing classification
— no Anthropic operation forwarded, so a cli.js citation is N/A under Rule 2. No
blacklisted tokens or port literals introduced; alignment.yml passes.

Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
isLoopbackBind via truth table (no exposed address misclassified as loopback — the
dangerous direction), the full string→{text,entrypoint} contract ripple across all
callers (Promise<string> preserved), the mismatch logic (warns on sdk-cli AND null in
cli mode, silent for auto/off, non-fatal), and ALIGNMENT N/A via a fetch/header grep.
Minors are process-only (this commit body; a future lib/ extraction of the test-mirrored
helper). npm test → 181 passed, 0 failed.

Closes #115.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-31 23:13:47 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.8
parent 879b40fe93
commit aa1c65beb1
4 changed files with 80 additions and 16 deletions
+3
View File
@@ -168,6 +168,8 @@ function buildTuiCmd(claudeBin, model, sessionId) {
// 5. Block on the native JSONL transcript (located by session-id) until terminal
// marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw).
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
export async function runTuiTurn({
prompt,
model,
@@ -238,6 +240,7 @@ export async function runTuiTurn({
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 3. Block on the native transcript (resolved by session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally {
// 4. Teardown — always, even on throw.
+9 -4
View File
@@ -104,8 +104,10 @@ export function verifyEntrypoint(events) {
// Block until the session transcript is terminal (turn_duration) or
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
// editors). Returns the latest assistant text. On cap with text, returns the
// partial text; on cap with no text at all, throws.
// editors). Returns { text, entrypoint } where text is the latest assistant text
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
// "cli"), or null if not yet present. On cap with text, returns the partial result;
// on cap with no text at all, throws.
//
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
@@ -115,15 +117,18 @@ export function verifyEntrypoint(events) {
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
let lastEntrypoint = null;
while (Date.now() < deadline) {
const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
lastText = extractLatestAssistantText(events) || lastText;
if (events.some(isTerminalLine)) return lastText;
const ep = verifyEntrypoint(events);
if (ep != null) lastEntrypoint = ep;
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
}
await sleep(pollMs);
}
if (lastText) return lastText;
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
}