fix(daemon): verify Claude resume lineage when pane identity is opaque (#335)

* fix(daemon): trust exact Claude resume lineage

* fix(daemon): verify wrapped Claude composer identity

* fix(restore): require strict foreground resume proof

* fix(daemon): reject failed headerless reconciliation proof

* test(daemon): retain inconclusive resume after main update
This commit is contained in:
Wu Shuwen authored and GitHub committed 2026-10-02 01:17:14 -07:00
1 parent 0a108305f4
commit e6a391b66c
6 files changed
+190 -5

No files matched your search

+27 -2
View File
@@ -3,6 +3,7 @@ import type { TmuxAdapter } from "./tmux.js";
import { shellQuote } from "./shell-quote.js";
import { claudePostureFlag, claudeClassicRendererEnvPrefix } from "./yolo-mode.js";
import { assessNativeResumeProbe } from "../domain/native-resume-probe.js";
import { verifyClaudePaneProcess, type NativeProcessLister } from "../domain/native-process-lineage.js";
import { observeClaudePermission, type AppliedLaunchObservation } from "../domain/permission-drift.js";
import { unresolvedClaudePermissionModes } from "../domain/native-permission-selection.js";
import type { ClaudeManagedLaunch } from "../domain/claude-managed-launch.js";
@@ -18,6 +19,7 @@ const CLAUDE_TYPES = new Set(["claude_name", "claude_id"]);
interface ClaudeResumeOptions {
claudeManagedLaunch?: ClaudeManagedLaunch;
listProcesses?: NativeProcessLister;
pollMs?: number;
maxWaitMs?: number;
sleep?: (ms: number) => Promise<void>;
@@ -85,7 +87,7 @@ export class ClaudeResumeAdapter {
return { ok: false, code: "resume_failed", message: keyResult.message };
}
const result = await this.verifyResume(tmuxSessionName).catch((error): ResumeResult => ({
const result = await this.verifyResume(tmuxSessionName, resumeToken!).catch((error): ResumeResult => ({
ok: false,
code: "attention_required",
message: `Claude resume observation unavailable; launch retained: ${error instanceof Error ? error.message : String(error)}`,
@@ -93,7 +95,7 @@ export class ClaudeResumeAdapter {
return result.ok ? { ...result, appliedLaunch } : result;
}
private async verifyResume(tmuxSessionName: string): Promise<ResumeResult> {
private async verifyResume(tmuxSessionName: string, resumeToken: string): Promise<ResumeResult> {
const pollMs = this.options.pollMs ?? 200;
const maxWaitMs = this.options.maxWaitMs ?? 5_000;
const sleepFn = this.options.sleep ?? sleep;
@@ -157,6 +159,29 @@ export class ClaudeResumeAdapter {
return { ok: true };
}
// The exact --resume identity is stronger evidence than a pane command or
// a version/footer heuristic. Use it only with Claude's interactive prompt
// visible, and after the untrusted screen classifiers have had their say.
const mayBeWrappedComposer = finalProbe.status === "inconclusive"
|| (finalProbe.status === "failed" && finalProbe.code === "returned_to_shell");
if (/(^|\n)\s*❯/.test(finalContent) && mayBeWrappedComposer) {
const identity = await verifyClaudePaneProcess({
target: tmuxSessionName,
tmux: this.tmux,
...(this.options.listProcesses ? { listProcesses: this.options.listProcesses } : {}),
expectedToken: resumeToken,
});
if (identity) {
const verifiedProbe = assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: finalCommand,
paneContent: finalContent,
claudeResumeIdentityVerified: true,
});
if (verifiedProbe.status === "resumed") return { ok: true };
}
}
if (finalProbe.code === "returned_to_shell") {
return {
ok: false,
@@ -12,6 +12,8 @@ export interface NativeResumeProbeInput {
paneContent: string | null;
/** Only the managed adapter supplies this after exact, stable native-process proof. */
claudeAutoIdentityVerified?: boolean;
/** Only an exact --resume process-lineage proof may use the visible composer as readiness. */
claudeResumeIdentityVerified?: boolean;
}
export interface NativeResumeProbeResult {
@@ -126,7 +128,14 @@ export function assessNativeResumeProbe(
detail: "Claude is running with an active interactive TUI in the probe pane.",
};
}
if (paneCommand === "claude") {
if (input.claudeResumeIdentityVerified && hasClaudeComposerPrompt(paneContent)) {
return {
status: "resumed",
code: "verified_native_identity",
detail: "Claude is at its interactive prompt and the exact managed resume process was verified.",
};
}
if (paneCommand === "claude" && input.claudeResumeIdentityVerified !== false) {
return {
status: "resumed",
code: "active_runtime",
@@ -239,7 +248,7 @@ export function isProbeShellReady(input: ProbeShellReadyInput): boolean {
}
function looksLikeClaudeTui(paneContent: string): boolean {
const hasPrompt = /(^|\n)\s*❯/.test(paneContent);
const hasPrompt = hasClaudeComposerPrompt(paneContent);
if (!hasPrompt) return false;
return (
@@ -248,6 +257,10 @@ function looksLikeClaudeTui(paneContent: string): boolean {
);
}
function hasClaudeComposerPrompt(paneContent: string): boolean {
return /(^|\n)\s*❯/.test(paneContent);
}
function looksLikeClaudeTrustPrompt(paneContent: string): boolean {
return paneContent.includes("Accessing workspace:")
&& paneContent.includes("Yes, I trust this folder");
@@ -18,6 +18,7 @@ import type { PiResumeAdapter } from "../adapters/pi-resume.js";
import type { OmpResumeAdapter } from "../adapters/omp-resume.js";
import type { TranscriptStore } from "./transcript-store.js";
import { assessNativeResumeProbe } from "./native-resume-probe.js";
import { verifyClaudePaneProcess } from "./native-process-lineage.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
import type {
RestoreOutcome,
@@ -1844,7 +1845,21 @@ export class RestoreOrchestrator {
}
const paneCommand = await this.tmuxAdapter.getPaneCommand(identity.pane);
const paneContent = (await this.tmuxAdapter.capturePaneContent(identity.pane, 40)) ?? "";
const probe = assessNativeResumeProbe({ runtime, paneCommand, paneContent });
const claudeResumeIdentityVerified = runtime === "claude-code" && !!await verifyClaudePaneProcess({
target: identity.pane,
tmux: this.tmuxAdapter,
expectedToken: expectedResumeToken,
requireResume: true,
...(this.listProcesses ? { listProcesses: this.listProcesses } : {}),
});
const probe = assessNativeResumeProbe({
runtime,
paneCommand,
paneContent,
// Headerless readiness requires the same stable foreground/argv proof
// as the resume adapter; a token-bearing descendant alone is insufficient.
...(runtime === "claude-code" ? { claudeResumeIdentityVerified } : {}),
});
const fgProcess = runtime === "claude-code" ? "claude" as const : runtime === "codex" ? "codex" as const : null;
if (!fgProcess) {
return { ok: false, code: "fg_process_not_runtime", detail: `Node runtime is ${runtime ?? "unknown"}, not claude/codex.` };
@@ -15,12 +15,14 @@ function mockTmux(overrides?: {
sendKeys?: (target: string, keys: string[]) => Promise<TmuxResult>;
getPaneCommand?: (target: string) => Promise<string | null>;
capturePaneContent?: (target: string, lines?: number) => Promise<string | null>;
getPanePid?: (target: string) => Promise<number | null>;
}) {
return {
sendText: overrides?.sendText ?? vi.fn(async () => ({ ok: true as const })),
sendKeys: overrides?.sendKeys ?? vi.fn(async () => ({ ok: true as const })),
getPaneCommand: overrides?.getPaneCommand ?? vi.fn(async () => "claude"),
capturePaneContent: overrides?.capturePaneContent ?? vi.fn(async () => ""),
getPanePid: overrides?.getPanePid ?? vi.fn(async () => null),
createSession: async () => ({ ok: true as const }),
killSession: async () => ({ ok: true as const }),
listSessions: async () => [],
@@ -376,5 +378,54 @@ describe("ClaudeResumeAdapter", () => {
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
});
it.each(["2.1.283", "sh"])("accepts a headerless bypass-mode prompt with exact resume lineage behind %s", async (paneCommand) => {
const tmux = mockTmux({
getPanePid: vi.fn(async () => 1234),
getPaneCommand: vi.fn(async () => paneCommand),
capturePaneContent: vi.fn(async () => [
"Restored conversation",
"❯",
"⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n")),
});
const adapter = new ClaudeResumeAdapter(tmux, {
pollMs: 0,
maxWaitMs: 0,
sleep: async () => {},
listProcesses: async () => [
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, command: "bash", executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, command: "claude --permission-mode acceptEdits --resume resume-id", executableName: "claude", startedAt: "Sat Jan 1 12:00:00 2000" },
],
});
const result = await adapter.resume("r99-demo1-lead", "claude_id", "resume-id", "/repo");
expect(result).toEqual({ ok: true, appliedLaunch: CLAUDE_FLOOR_EFFECT });
});
it.each([
["2.1.283", "attention_required"],
["sh", "retry_fresh"],
] as const)("does not accept the prompt behind %s when the native process carries another resume token", async (paneCommand, expectedCode) => {
const tmux = mockTmux({
getPanePid: vi.fn(async () => 1234),
getPaneCommand: vi.fn(async () => paneCommand),
capturePaneContent: vi.fn(async () => "Restored conversation\n❯\n⏵⏵ bypass permissions on"),
});
const adapter = new ClaudeResumeAdapter(tmux, {
pollMs: 0,
maxWaitMs: 0,
sleep: async () => {},
listProcesses: async () => [
{ pid: 1234, ppid: 1, pgid: 1234, tpgid: 1235, command: "bash", executableName: "bash", startedAt: "Sat Jan 1 12:00:00 2000" },
{ pid: 1235, ppid: 1234, pgid: 1235, tpgid: 1235, command: "claude --permission-mode acceptEdits --resume another-session", executableName: "claude", startedAt: "Sat Jan 1 12:00:00 2000" },
],
});
const result = await adapter.resume("r99-demo1-lead", "claude_id", "resume-id", "/repo");
expect(result).toMatchObject({ ok: false, code: expectedCode });
});
});
});
@@ -32,6 +32,35 @@ describe("native resume probe", () => {
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "sh", paneContent: `${screen}\n${panel}`, claudeAutoIdentityVerified: true }).code).toBe(code);
});
});
describe("Claude's exact resume identity can validate the visible composer", () => {
const screen = "Restored conversation\n❯\n ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents\n";
it("accepts a headerless prompt only after exact resume-lineage proof", () => {
expect(assessNativeResumeProbe({ runtime: "claude-code", paneCommand: "2.1.283", paneContent: screen }))
.toMatchObject({ status: "inconclusive", code: "awaiting_runtime" });
expect(assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: "2.1.283",
paneContent: screen,
claudeResumeIdentityVerified: true,
})).toMatchObject({ status: "resumed", code: "verified_native_identity" });
});
it.each([
["Accessing workspace:\nYes, I trust this folder", "inconclusive", "trust_gate"],
["new MCP servers found in .mcp.json\nSelect any you wish to enable\nEnter to confirm", "inconclusive", "mcp_gate"],
["Not logged in · Run /login", "failed", "login_required"],
["How would you like to resume?\n❯ Resume from summary\n Resume full session as-is", "attention_required", "claude_resume_selection_prompt"],
["No conversation found", "failed", "no_conversation_found"],
])("keeps known blocking prompt authoritative despite resume identity proof: %s", (panel, status, code) => {
expect(assessNativeResumeProbe({
runtime: "claude-code",
paneCommand: "2.1.283",
paneContent: `${screen}\n${panel}`,
claudeResumeIdentityVerified: true,
})).toMatchObject({ status, code });
});
});
describe("issue116 headerless custom status lines", () => {
const reportedFooter = " 5h 71% left · weekly 24% left · GPT-6-Astra high · Context 81% left";
it.each(["›", "»"])("recognizes the reported footer below a %s conversation prompt", (prompt) => {
@@ -3062,6 +3062,58 @@ describe("RestoreOrchestrator", () => {
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("reconciles a headerless Claude prompt when exact resume-token lineage is verified", async () => {
const tmux = mockTmuxForReconciler();
vi.mocked(tmux.hasSession).mockResolvedValue(true);
vi.mocked(tmux.getPaneCommand).mockResolvedValue("2.1.283");
vi.mocked(tmux.capturePaneContent).mockResolvedValue([
"Restored conversation",
"❯",
"⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n"));
const seeded = seedFailedAttempt({ restoreOutcome: "failed", withResumeToken: true });
const result = await createOrchestrator({
tmux,
listProcesses: async () => managedClaudeRows("tok-abc-123"),
}).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
expect(result).toMatchObject({ ok: true, to: "operator_recovered" });
expect(db.prepare("SELECT * FROM events WHERE type = 'restore.outcome_reconciled'").all()).toHaveLength(1);
expect(tmux.sendKeys).not.toHaveBeenCalled();
expect(tmux.sendText).not.toHaveBeenCalled();
});
it.each(["background", "ambiguous", "argv-lookalike", "missing-metadata"].flatMap((failure) =>
["2.1.283", "claude"].map((paneCommand) => [failure, paneCommand])
))("does not reconcile headerless %s evidence under %s", async (failure, paneCommand) => {
const tmux = mockTmuxForReconciler();
vi.mocked(tmux.hasSession).mockResolvedValue(true);
vi.mocked(tmux.getPaneCommand).mockResolvedValue(paneCommand);
vi.mocked(tmux.capturePaneContent).mockResolvedValue("Restored conversation\n❯\n⏵⏵ bypass permissions on");
const seeded = seedFailedAttempt({ restoreOutcome: "failed", withResumeToken: true });
const originalOutcome = db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get();
const listProcesses = async () => {
const rows = managedClaudeRows("tok-abc-123");
if (failure === "background") {
rows[2]!.pgid = 9000;
rows.push({ ...rows[2]!, pid: 1237, pgid: 1235, command: "claude --resume another-session" });
}
if (failure === "ambiguous") rows.push({ ...rows[2]!, pid: 1237 });
if (failure === "argv-lookalike") rows[2]!.command = "claude --model --resume tok-abc-123";
if (failure === "missing-metadata") rows[2]!.startedAt = "";
return rows;
};
const result = await createOrchestrator({ tmux, listProcesses }).reconcileNodeRuntimeTruth(seeded.rig.id, seeded.nodeId);
expect(result.ok).toBe(false);
expect(db.prepare("SELECT * FROM events WHERE type = 'restore.outcome_reconciled'").all()).toHaveLength(0);
expect(db.prepare("SELECT payload FROM events WHERE type = 'restore.completed'").get()).toEqual(originalOutcome);
expect(tmux.sendKeys).not.toHaveBeenCalled();
expect(tmux.sendText).not.toHaveBeenCalled();
});
it("upgrades failed -> operator_recovered when ALL four preconditions hold; emits audit event", async () => {
const tmux = mockTmuxForReconciler();
(tmux.hasSession as ReturnType<typeof vi.fn>).mockResolvedValue(true);