diff --git a/src/lib/actions/sandbox/agent/passthrough-json.test.ts b/src/lib/actions/sandbox/agent/passthrough-json.test.ts index be9e4931d40..a361720c181 100644 --- a/src/lib/actions/sandbox/agent/passthrough-json.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough-json.test.ts @@ -149,7 +149,7 @@ describe("runAgentJsonPassthrough", () => { runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, { getOpenshellBinary: () => "/usr/local/bin/openshell", provenanceLines: () => { - throw new RangeError("Maximum call stack size exceeded"); + throw new SyntaxError("Unexpected token in OpenClaw JSON output"); }, spawnSync, }), diff --git a/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts b/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts new file mode 100644 index 00000000000..397c133d6c7 --- /dev/null +++ b/src/lib/actions/sandbox/agent/passthrough-shields-warning.test.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; + +const execMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureLiveMock = vi.hoisted(() => + vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }), +); +const getSandboxMock = vi.hoisted(() => vi.fn(() => ({ agent: "openclaw" }))); +const listAgentsMock = vi.hoisted(() => vi.fn(() => ["langchain-deepagents-code", "openclaw"])); +const loadAgentMock = vi.hoisted(() => + vi.fn((name: string) => ({ + name, + runtime: + name === "langchain-deepagents-code" + ? { kind: "terminal", interactive_command: "dcode", headless_command: "dcode -n" } + : undefined, + })), +); +const isTerminalAgentMock = vi.hoisted(() => + vi.fn((agent: { runtime?: { kind?: string } }) => agent.runtime?.kind === "terminal"), +); + +vi.mock("../exec", () => ({ execSandbox: execMock })); +vi.mock("../gateway-state", () => ({ ensureLiveSandboxOrExit: ensureLiveMock })); +vi.mock("../../../state/registry", () => ({ getSandbox: getSandboxMock })); +vi.mock("../../../agent/defs", () => ({ + isTerminalAgent: isTerminalAgentMock, + listAgents: listAgentsMock, + loadAgent: loadAgentMock, +})); +vi.mock("../../../shields/audit", () => ({ + readRecentShieldsAutoRestore: vi.fn(() => ({ kind: "none" })), +})); + +import { type AgentPassthroughDeps, runAgentPassthrough } from "./passthrough"; + +function makeProcMock() { + const writes: string[] = []; + return { + writes, + proc: { + exit: ((code: number): never => { + throw new Error(`__exit:${code}`); + }) as (code: number) => never, + stderr: { write: (value: string) => writes.push(value) }, + }, + }; +} + +describe("runAgentPassthrough shields-relock warning", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function runWarning( + result: ShieldsAutoRestoreReadResult, + sandboxName = "alpha", + ): Promise { + getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); + const { writes, proc } = makeProcMock(); + await runAgentPassthrough( + sandboxName, + { extraArgs: ["--agent", "main", "-m", "hi"] }, + { process: proc, getRecentShieldsAutoRestore: () => result }, + ); + return writes.join(""); + } + + it("emits the original timeout after a recent auto-relock (#5922)", async () => { + const output = await runWarning({ + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, + }); + expect(execMock).toHaveBeenCalled(); + expect(output).toMatch(/[Ss]hields auto-relocked after 20s/); + expect(output).toMatch(/shields down --timeout 20s/); + }); + + it("uses the safe fallback timeout when the original timeout is unavailable (#5922)", async () => { + const output = await runWarning({ + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: null }, + }); + expect(execMock).toHaveBeenCalled(); + expect(output).toMatch(/[Ss]hields auto-relocked/); + expect(output).toMatch(/shields down --timeout 60s/); + }); + + it("defensively rejects an invalid injected timeout from the command suggestion (#5922)", async () => { + const output = await runWarning({ + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 9999 }, + }); + expect(output).not.toContain("9999s"); + expect(output).toMatch(/shields down --timeout 60s/); + }); + + it("shell-quotes sandbox names in recovery command suggestions (#5922)", async () => { + const output = await runWarning( + { + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, + }, + "alpha; touch /tmp/pwn", + ); + expect(output).toContain("nemoclaw 'alpha; touch /tmp/pwn' shields down --timeout 20s"); + expect(output).not.toContain("nemoclaw alpha; touch /tmp/pwn"); + }); + + it("escapes embedded single quotes in recovery command suggestions (#5922)", async () => { + const output = await runWarning( + { + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, + }, + "alpha'beta", + ); + expect(output).toContain("nemoclaw 'alpha'\\''beta' shields down --timeout 20s"); + }); + + it("keeps JSON stdout parseable while warning from a real audit file on stderr (#5922)", async () => { + const actualAudit = + await vi.importActual("../../../shields/audit"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-warning-")); + const auditPath = path.join(tempDir, "shields-audit.jsonl"); + const restoreTimestamp = new Date().toISOString(); + const stdoutWrites: string[] = []; + const { writes, proc } = makeProcMock(); + const processWithStdout = { + ...proc, + stdout: { write: (value: string) => stdoutWrites.push(value) }, + }; + const execJson = vi.fn(((_sandboxName, _command, jsonProc): never => { + jsonProc?.stdout.write('{"ok":true}\n'); + throw new Error("__json-exit:0"); + }) as NonNullable); + + try { + fs.writeFileSync( + auditPath, + [ + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 20 * 1000).toISOString(), + timeout_seconds: 20, + }), + JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: restoreTimestamp, + }), + ].join("\n") + "\n", + ); + + await expect( + runAgentPassthrough( + "alpha", + { extraArgs: ["--agent", "main", "-m", "hi", "--json"] }, + { + process: processWithStdout, + execJson, + getRecentShieldsAutoRestore: (sandboxName) => + actualAudit.readRecentShieldsAutoRestore(sandboxName, 10 * 60 * 1000, auditPath), + }, + ), + ).rejects.toThrow("__json-exit:0"); + + expect(JSON.parse(stdoutWrites.join(""))).toEqual({ ok: true }); + expect(writes.join("")).toMatch(/Shields auto-relocked after 20s/); + expect(execJson).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("emits no relock warning when the audit has no recent event (#5922)", async () => { + const output = await runWarning({ kind: "none" }); + expect(execMock).toHaveBeenCalled(); + expect(output).not.toMatch(/[Ss]hields auto-relocked/); + }); + + it("reports unreadable audit history without blocking agent dispatch (#5922)", async () => { + const output = await runWarning({ kind: "unreadable" }); + expect(execMock).toHaveBeenCalled(); + expect(output).toMatch(/Could not read shields audit history/); + expect(output).toMatch(/shields status/); + }); + + it("does not consult OpenClaw relock history for terminal-runtime passthroughs (#5922)", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" }); + const getRecentShieldsAutoRestore = vi.fn( + (): ShieldsAutoRestoreReadResult => ({ + kind: "event", + event: { timestamp: new Date().toISOString(), timeoutSeconds: 20 }, + }), + ); + const { writes, proc } = makeProcMock(); + + await runAgentPassthrough( + "alpha", + { extraArgs: ["--help"] }, + { process: proc, getRecentShieldsAutoRestore }, + ); + + expect(execMock).toHaveBeenCalledWith("alpha", ["dcode", "--help"], { tty: false }); + expect(getRecentShieldsAutoRestore).not.toHaveBeenCalled(); + expect(writes.join("")).not.toMatch(/[Ss]hields auto-relocked/); + }); +}); diff --git a/src/lib/actions/sandbox/agent/passthrough-shields-warning.ts b/src/lib/actions/sandbox/agent/passthrough-shields-warning.ts new file mode 100644 index 00000000000..d16705ae5b6 --- /dev/null +++ b/src/lib/actions/sandbox/agent/passthrough-shields-warning.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CLI_NAME } from "../../../cli/branding"; +import { shellQuote } from "../../../core/shell-quote"; +import { + readRecentShieldsAutoRestore, + type ShieldsAutoRestoreEvent, + type ShieldsAutoRestoreReadResult, +} from "../../../shields/audit"; + +// Source-of-truth boundary for the host CLI relock diagnostic: +// +// - Invalid state: after shields auto-relock, OpenClaw can report only +// `missing scope: operator.write`; an older relock warning also becomes stale +// after the user lowers shields again. +// - Source boundary: OpenShell/OpenClaw own current scope state. NemoClaw audit +// JSONL is non-authoritative context. Validated chronology may suppress stale +// context but never establishes current policy state, and unreadable history +// never blocks dispatch. The audit writers are the shields timer and inline +// expired-timer recovery paths. +// - Presentation boundary: sandbox names are user-controlled command text and +// must remain shell-quoted. Direct stderr output is deliberate so the warning +// is visible in a one-shot CLI while machine-readable stdout stays clean. +// - Source-fix constraint: an already-running in-sandbox TUI has no host CLI +// interception point. That surface needs an upstream structured relock error +// or a separate extend-on-activity design; this helper covers only host +// `nemoclaw agent` dispatches. +// - Regression tests cover validated/fallback timeouts, shell metacharacters +// and embedded quotes, real-file JSON stdout separation, unreadable/absent +// history, newer-down suppression, and terminal-runtime exclusion. +// - Removal condition: drop this diagnostic when OpenClaw exposes the relock +// cause directly or NemoClaw prevents mid-session relock by extending on +// activity. + +// A relock remains useful context briefly after it happens. This is a +// relevance window measured from the restore event, independent of the +// original shields-down timeout; a longer window risks stale-session warnings. +const SHIELDS_RELOCK_WARNING_WINDOW_MS = 10 * 60 * 1000; + +type ShieldsWarningProcess = { + stderr: { write(value: string): unknown }; +}; + +type RecentShieldsAutoRestoreReader = (sandboxName: string) => ShieldsAutoRestoreReadResult; + +function emitShieldsRelockWarning( + proc: ShieldsWarningProcess, + relock: ShieldsAutoRestoreEvent, + sandboxName: string, +): void { + // Defend the user-facing command suggestion even when tests or future + // callers inject an event without going through the audit reader. + const timeoutSeconds = + relock.timeoutSeconds !== null && + Number.isInteger(relock.timeoutSeconds) && + relock.timeoutSeconds >= 1 && + relock.timeoutSeconds <= 1800 + ? relock.timeoutSeconds + : null; + const afterPart = timeoutSeconds !== null ? ` after ${String(timeoutSeconds)}s` : ""; + const timeoutSuggestion = + timeoutSeconds !== null ? `--timeout ${String(timeoutSeconds)}s` : "--timeout 60s"; + proc.stderr.write( + ` ⚠ Shields auto-relocked${afterPart} — run \`${CLI_NAME} ${shellQuote(sandboxName)} shields down ${timeoutSuggestion}\` to extend.\n`, + ); +} + +function emitShieldsAuditUnreadableWarning(proc: ShieldsWarningProcess, sandboxName: string): void { + proc.stderr.write( + ` ⚠ Could not read shields audit history; continuing without relock context. Run \`${CLI_NAME} ${shellQuote(sandboxName)} shields status\` to verify current state.\n`, + ); +} + +export function maybeEmitShieldsRelockWarning( + proc: ShieldsWarningProcess, + sandboxName: string, + getRecentShieldsAutoRestore: RecentShieldsAutoRestoreReader = (name) => + readRecentShieldsAutoRestore(name, SHIELDS_RELOCK_WARNING_WINDOW_MS), +): void { + const relock = getRecentShieldsAutoRestore(sandboxName); + if (relock.kind === "event") { + emitShieldsRelockWarning(proc, relock.event, sandboxName); + } else if (relock.kind === "unreadable") { + emitShieldsAuditUnreadableWarning(proc, sandboxName); + } +} diff --git a/src/lib/actions/sandbox/agent/passthrough.test.ts b/src/lib/actions/sandbox/agent/passthrough.test.ts index 792bf71da96..de1bf2d267e 100644 --- a/src/lib/actions/sandbox/agent/passthrough.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough.test.ts @@ -32,6 +32,11 @@ vi.mock("../../../agent/defs", () => ({ listAgents: listAgentsMock, loadAgent: loadAgentMock, })); +// Default to no recent shields auto-restore so tests that don't inject +// getRecentShieldsAutoRestore don't read ~/.nemoclaw/state/shields-audit.jsonl. +vi.mock("../../../shields/audit", () => ({ + readRecentShieldsAutoRestore: vi.fn(() => ({ kind: "none" })), +})); import { type AgentPassthroughDeps, runAgentPassthrough } from "./passthrough"; @@ -147,22 +152,23 @@ describe("runAgentPassthrough", () => { ); }); - it("keeps --json after an unknown future value flag on the normal passthrough path", async () => { + it("keeps --json-something --json on the normal passthrough path", async () => { const execJson = vi.fn(((): never => { throw new Error("__unexpected-json"); }) as NonNullable); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); + // The first unknown flag selects conservative passthrough before the later --json token. await runAgentPassthrough( "alpha", - { extraArgs: ["--agent", "work", "--some-future-value-flag", "--json"] }, + { extraArgs: ["--agent", "work", "--json-something", "--json"] }, { execJson }, ); expect(execJson).not.toHaveBeenCalled(); expect(execMock).toHaveBeenCalledWith( "alpha", - ["openclaw", "agent", "--agent", "work", "--some-future-value-flag", "--json"], + ["openclaw", "agent", "--agent", "work", "--json-something", "--json"], { tty: false }, ); }); diff --git a/src/lib/actions/sandbox/agent/passthrough.ts b/src/lib/actions/sandbox/agent/passthrough.ts index e2d99c6b4ac..a9d77d9f963 100644 --- a/src/lib/actions/sandbox/agent/passthrough.ts +++ b/src/lib/actions/sandbox/agent/passthrough.ts @@ -3,7 +3,8 @@ // Source-of-truth boundary for the `nemoclaw agent` passthrough. // -// The wrapper enforces three host-side mirrors of upstream contracts: +// The wrapper enforces three host-side mirrors of upstream contracts and one +// advisory diagnostic: // // 1. Agent-kind guard (registry mirror). // @@ -67,6 +68,10 @@ // selector case is intercepted; everything else still flows through to // the in-sandbox binary. // +// 4. Recent shields-relock diagnostic (advisory audit mirror). Its complete +// source-boundary analysis lives with the focused implementation in +// `passthrough-shields-warning.ts`. +// // Regression tests: `passthrough.test.ts` covers the Hermes redirect, the // forwarded argv, the registry-miss fallback to OpenClaw, registry and // manifest-resolution fail-closed paths, quoted manifest command rejection, @@ -74,7 +79,7 @@ // unparseable phase fail-closed path, the OpenClaw no-selector rejection, and // the `--flag=value` selector-acceptance branch, plus the OpenClaw JSON // captured transport path used to append failure provenance without polluting -// machine-readable stdout. +// machine-readable stdout. The focused shields diagnostic owns its tests. // // Removal conditions: // @@ -89,12 +94,14 @@ import { type AgentDefinition, isTerminalAgent, listAgents, loadAgent } from "../../../agent/defs"; import { CLI_NAME } from "../../../cli/branding"; +import type { ShieldsAutoRestoreReadResult } from "../../../shields/audit"; import { parseSandboxPhase } from "../../../state/gateway"; import * as registry from "../../../state/registry"; import { execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help"; import { type AgentJsonPassthroughProcess, runAgentJsonPassthrough } from "./passthrough-json"; +import { maybeEmitShieldsRelockWarning } from "./passthrough-shields-warning"; export { hasAgentPassthroughHelpToken, @@ -127,6 +134,7 @@ export interface AgentPassthroughDeps { ensureLive?: typeof ensureLiveSandboxOrExit; exec?: typeof execSandbox; execJson?: typeof runAgentJsonPassthrough; + getRecentShieldsAutoRestore?: (sandboxName: string) => ShieldsAutoRestoreReadResult; process?: { exit(code: number): never; stdout?: { write(s: string): unknown }; @@ -299,9 +307,11 @@ function requestsOpenClawJsonOutput(extraArgs: readonly string[]): boolean { // a value by another OpenClaw option. Source boundary: upstream OpenClaw owns // the complete argv grammar; NemoClaw mirrors documented flags only to choose // the host transport path. Unknown options fail conservative to normal - // passthrough, where OpenClaw parses argv itself. Regression tests cover each - // documented value flag, documented equals-form value flags, documented - // boolean flags, unknown flag fallback, and the `--` terminator. Removal + // passthrough, where OpenClaw parses argv itself. Any newly documented value + // flag, including a `--json-*` name, must be added to the value-flag set and + // its tests together. Regression tests cover each documented value flag, + // documented equals-form value flags, documented boolean flags, unknown flag + // fallback, and the `--` terminator. Removal // condition: OpenClaw exposes a machine-readable argv schema or NemoClaw stops // special-casing the JSON transport path. let skipNextValue = false; @@ -414,6 +424,9 @@ export async function runAgentPassthrough( if (isOpenClawPassthroughCommand(command) && !hasTargetSelector(extraArgs)) { rejectNoTargetSelector(proc); } + if (isOpenClawPassthroughCommand(command)) { + maybeEmitShieldsRelockWarning(proc, sandboxName, deps.getRecentShieldsAutoRestore); + } if (isOpenClawPassthroughCommand(command) && requestsOpenClawJsonOutput(extraArgs)) { const execJson = deps.execJson ?? runAgentJsonPassthrough; execJson(sandboxName, command, { diff --git a/src/lib/shields/audit.test.ts b/src/lib/shields/audit-format.test.ts similarity index 97% rename from src/lib/shields/audit.test.ts rename to src/lib/shields/audit-format.test.ts index 7e86abc5c62..b0f3864a88c 100644 --- a/src/lib/shields/audit.test.ts +++ b/src/lib/shields/audit-format.test.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect, beforeEach, afterEach } from "vitest"; import fs from "node:fs"; -import path from "node:path"; import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Test the audit entry format and JSONL structure using the same logic // as the production module but with a controllable output path. @@ -33,7 +33,7 @@ function appendAuditEntry(entry: AuditRecord) { fs.appendFileSync(auditPath, JSON.stringify(entry) + "\n", { mode: 0o600 }); } -describe("shields-audit", () => { +describe("shields-audit format", () => { it("creates file on first write and writes valid JSONL", () => { expect(fs.existsSync(auditPath)).toBe(false); diff --git a/src/lib/shields/audit-reader.test.ts b/src/lib/shields/audit-reader.test.ts new file mode 100644 index 00000000000..5f9acf26cea --- /dev/null +++ b/src/lib/shields/audit-reader.test.ts @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + readRecentShieldsAutoRestore, + type ShieldsAutoRestoreEvent, + type ShieldsAutoRestoreReadResult, +} from "./audit"; + +let tmpDir: string; +let auditPath: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-audit-test-")); + auditPath = path.join(tmpDir, "shields-audit.jsonl"); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function requireEvent(result: ShieldsAutoRestoreReadResult): ShieldsAutoRestoreEvent { + expect(result.kind).toBe("event"); + return (result as Extract).event; +} + +describe("readRecentShieldsAutoRestore", () => { + it("returns timestamp and timeoutSeconds when shields_down precedes shields_auto_restore (#5922)", () => { + const now = new Date().toISOString(); + fs.appendFileSync( + auditPath, + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 25 * 1000).toISOString(), + timeout_seconds: 20, + }) + + "\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: now }) + + "\n", + ); + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(now); + expect(event.timeoutSeconds).toBe(20); + }); + + it("returns timestamp with null timeoutSeconds when no shields_down entry exists (#5922)", () => { + const now = new Date().toISOString(); + fs.appendFileSync( + auditPath, + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: now }) + "\n", + ); + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(now); + expect(event.timeoutSeconds).toBeNull(); + }); + + it("returns no event when the shields_auto_restore entry is future-dated (#5922)", () => { + const future = new Date(Date.now() + 60 * 1000).toISOString(); + fs.appendFileSync( + auditPath, + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: future }) + + "\n", + ); + const result = readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns no event when the most recent shields_auto_restore entry is older than the window (#5922)", () => { + const old = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + fs.appendFileSync( + auditPath, + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: old }) + "\n", + ); + const result = readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns no event when the recent shields_auto_restore entry is for a different sandbox (#5922)", () => { + const now = new Date().toISOString(); + fs.appendFileSync( + auditPath, + JSON.stringify({ action: "shields_auto_restore", sandbox: "other-sb", timestamp: now }) + + "\n", + ); + const result = readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns no event when the audit file does not exist (#5922)", () => { + const result = readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath); + expect(result).toEqual({ kind: "none" }); + }); + + it("returns null timeoutSeconds for out-of-bounds timeout values in shields_down entry (#5922)", () => { + const now = new Date().toISOString(); + // JSON.stringify serializes these correctly (finite numbers) + for (const bad of [0, -1, 1801, 9999, 1.5]) { + fs.writeFileSync( + auditPath, + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 25 * 1000).toISOString(), + timeout_seconds: bad, + }) + + "\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: now }) + + "\n", + ); + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp, `bad value ${String(bad)}`).toBe(now); + expect(event.timeoutSeconds, `bad value ${String(bad)}`).toBeNull(); + } + }); + + it("returns correct timeoutSeconds when a malformed JSONL line sits between shields_down and shields_auto_restore (#5922)", () => { + const now = new Date().toISOString(); + const downLine = JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 25 * 1000).toISOString(), + timeout_seconds: 30, + }); + // Malformed line between the two valid entries; parseEntry must skip it and + // continue to find the preceding shields_down. + fs.writeFileSync( + auditPath, + downLine + + "\n{not valid json\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: now }) + + "\n", + ); + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(now); + expect(event.timeoutSeconds).toBe(30); + }); + + it("uses the immediately-preceding shields_down when multiple exist (#5922)", () => { + const now = new Date().toISOString(); + // Two shields_down entries with different timeout_seconds. The second (most + // recent) should be used because it immediately precedes the auto-restore. + fs.writeFileSync( + auditPath, + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 60 * 1000).toISOString(), + timeout_seconds: 120, + }) + + "\n" + + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 25 * 1000).toISOString(), + timeout_seconds: 45, + }) + + "\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp: now }) + + "\n", + ); + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(now); + expect(event.timeoutSeconds).toBe(45); + }); + + it("rejects a shields_down timeout timestamped after its auto-restore (#5922)", () => { + const restoreTimestamp = new Date().toISOString(); + fs.writeFileSync( + auditPath, + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() + 60 * 1000).toISOString(), + timeout_seconds: 45, + }) + + "\n" + + JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: restoreTimestamp, + }) + + "\n", + ); + + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(restoreTimestamp); + expect(event.timeoutSeconds).toBeNull(); + }); + + it("suppresses stale relock context after a newer shields_down (#5922)", () => { + const now = Date.now(); + fs.writeFileSync( + auditPath, + [ + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(now - 30 * 1000).toISOString(), + timeout_seconds: 20, + }), + JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: new Date(now - 20 * 1000).toISOString(), + }), + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(now - 10 * 1000).toISOString(), + timeout_seconds: 60, + }), + ].join("\n") + "\n", + ); + + expect(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)).toEqual({ + kind: "none", + }); + }); + + it("returns null timeoutSeconds when shields_down has NaN or Infinity as a raw string payload (#5922)", () => { + // JSON.stringify(NaN) and JSON.stringify(Infinity) both produce "null", + // so write the JSONL line manually to exercise the non-finite path. + const now = new Date().toISOString(); + for (const rawValue of ["NaN", "Infinity", "-Infinity"]) { + const downLine = `{"action":"shields_down","sandbox":"alpha","timestamp":"${new Date(Date.now() - 25 * 1000).toISOString()}","timeout_seconds":${rawValue}}`; + const restoreLine = JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: now, + }); + fs.writeFileSync(auditPath, downLine + "\n" + restoreLine + "\n"); + // Malformed JSON (NaN/Infinity are not valid JSON) → parseEntry returns null → timeoutSeconds stays null + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp, `raw value ${rawValue}`).toBe(now); + expect(event.timeoutSeconds, `raw value ${rawValue}`).toBeNull(); + } + }); + + it("distinguishes unreadable audit history from an absent audit file (#5922)", () => { + fs.mkdirSync(auditPath); + expect(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)).toEqual({ + kind: "unreadable", + }); + }); + + it("skips invalid restore timestamps and finds the next valid recent event (#5922)", () => { + const validTimestamp = new Date(Date.now() - 1000).toISOString(); + fs.writeFileSync( + auditPath, + [ + JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: validTimestamp, + }), + JSON.stringify({ + action: "shields_auto_restore", + sandbox: "alpha", + timestamp: "not-a-timestamp", + }), + ].join("\n") + "\n", + ); + + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(validTimestamp); + }); + + it("skips non-object JSONL tail rows while finding a valid restore event (#5922)", () => { + const timestamp = new Date().toISOString(); + fs.writeFileSync( + auditPath, + [ + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp }), + "null", + "[]", + '"text"', + "42", + ].join("\n") + "\n", + ); + + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(timestamp); + }); + + it("reads only the bounded audit tail and discards its partial first line (#5922)", () => { + const timestamp = new Date().toISOString(); + fs.writeFileSync( + auditPath, + "x".repeat(1024 * 1024 + 100) + + "\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp }) + + "\n", + ); + + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(timestamp); + }); + + it("uses a null timeout when shields_down falls outside the bounded tail (#5922)", () => { + const timestamp = new Date().toISOString(); + fs.writeFileSync( + auditPath, + JSON.stringify({ + action: "shields_down", + sandbox: "alpha", + timestamp: new Date(Date.now() - 25 * 1000).toISOString(), + timeout_seconds: 20, + }) + + "\n" + + "x".repeat(1024 * 1024 + 100) + + "\n" + + JSON.stringify({ action: "shields_auto_restore", sandbox: "alpha", timestamp }) + + "\n", + ); + + const event = requireEvent(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)); + expect(event.timestamp).toBe(timestamp); + expect(event.timeoutSeconds).toBeNull(); + }); + + it("reports an oversized unterminated JSONL entry as unreadable (#5922)", () => { + fs.writeFileSync(auditPath, "x".repeat(1024 * 1024 + 100)); + + expect(readRecentShieldsAutoRestore("alpha", 10 * 60 * 1000, auditPath)).toEqual({ + kind: "unreadable", + }); + }); +}); diff --git a/src/lib/shields/audit.ts b/src/lib/shields/audit.ts index 5d25e0a6277..74836a827c8 100644 --- a/src/lib/shields/audit.ts +++ b/src/lib/shields/audit.ts @@ -10,7 +10,7 @@ * Entries never contain credential values — only key names and policy labels. */ -import { appendFileSync } from "node:fs"; +import { appendFileSync, closeSync, fstatSync, openSync, readSync } from "node:fs"; import { join } from "node:path"; import { redactFull } from "../security/redact"; import { ensureConfigDir } from "../state/config-io"; @@ -56,4 +56,167 @@ export function appendAuditEntry(entry: ShieldsAuditEntry): void { appendFileSync(AUDIT_FILE, JSON.stringify(safe) + "\n", { mode: 0o600 }); } -export { AUDIT_FILE, AUDIT_DIR }; +export interface ShieldsAutoRestoreEvent { + /** ISO timestamp written by the auto-restore timer. */ + timestamp: string; + /** + * Original timeout in seconds from the preceding `shields_down` entry, or + * null when that entry is not found in the audit log. + */ + timeoutSeconds: number | null; +} + +export type ShieldsAutoRestoreReadResult = + | { kind: "event"; event: ShieldsAutoRestoreEvent } + | { kind: "none" } + | { kind: "unreadable" }; + +const MAX_RECENT_AUDIT_BYTES = 1024 * 1024; + +function readAuditTail(auditFile: string): string { + const fd = openSync(auditFile, "r"); + try { + const size = fstatSync(fd).size; + const bytesToRead = Math.min(size, MAX_RECENT_AUDIT_BYTES); + if (bytesToRead === 0) return ""; + + const offset = size - bytesToRead; + const buffer = Buffer.alloc(bytesToRead); + let bytesRead = 0; + while (bytesRead < bytesToRead) { + const count = readSync(fd, buffer, bytesRead, bytesToRead - bytesRead, offset + bytesRead); + if (count === 0) break; + bytesRead += count; + } + const content = buffer.subarray(0, bytesRead).toString("utf8"); + if (offset === 0) return content; + + // The bounded read can begin in the middle of a JSONL entry. Drop that + // partial first line and retain only complete entries from the tail. If + // there is no newline, an oversized unterminated entry has consumed the + // entire tail; report degraded visibility instead of treating it as an + // empty audit log. + const firstNewline = content.indexOf("\n"); + if (firstNewline === -1) throw new Error("audit JSONL entry exceeds bounded tail"); + return content.slice(firstNewline + 1); + } finally { + closeSync(fd); + } +} + +/** + * Scan the audit log in reverse and return details about the most recent + * `shields_auto_restore` event for the given sandbox that falls within + * `withinMs` milliseconds of now. Also reads the preceding `shields_down` + * entry to recover the original timeout so callers can echo it back. + * + * This log is non-authoritative UX input. Missing, corrupt, or locally tampered + * rows must never make policy or current shield-state decisions; callers use an + * `event` result only to explain a likely relock. Current state is queried by + * the shields commands themselves. + * + * Missing files return `none`. Other read failures return `unreadable` so the + * caller can surface degraded audit visibility while still dispatching the + * agent. Fail-open is intentional: blocking dispatch on EACCES/EIO would turn + * this advisory check into a denial-of-service boundary. Future-dated entries + * are rejected strictly so a crafted row cannot pin the warning permanently; + * the same host clock writes and reads this local log, and a rare false + * negative after a backward clock adjustment is safer than stale guidance. + * + * Only the last 1 MiB is read. This intentionally keeps the one-shot CLI read + * synchronous so the warning is ordered before dispatch while bounding the + * work to thousands of normal audit entries. If the matching `shields_down` + * row falls outside that tail, the event remains useful with a null timeout + * and the caller uses its safe fallback suggestion. Revisit the synchronous + * API if audit storage moves off the local filesystem or into a long-lived + * process. + * + * Remove this reader when OpenClaw exposes a structured relock cause or when + * extend-on-activity removes the mid-session relock condition. + * + * The optional `auditFile` parameter overrides the default path; used in tests. + */ +export function readRecentShieldsAutoRestore( + sandboxName: string, + withinMs: number, + auditFile: string = AUDIT_FILE, +): ShieldsAutoRestoreReadResult { + if (!Number.isFinite(withinMs) || withinMs <= 0) return { kind: "none" }; + + let content: string; + try { + content = readAuditTail(auditFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "none" }; + return { kind: "unreadable" }; + } + const cutoff = Date.now() - withinMs; + const lines = content.split("\n"); + + function parseEntry(line: string): Record | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Intentional resilient skip: a malformed or truncated JSONL line (e.g. + // from a partial write or manual edit) must not prevent finding valid + // surrounding entries. The reverse scan continues past it. + } + return null; + } + + const now = Date.now(); + // Scan backwards for the most recent shields_auto_restore within the window. + // A later shields_down for the same sandbox makes older relock context stale, + // but remains advisory: it suppresses only this warning and never establishes + // current shield state. + let newerShieldsDownMs: number | null = null; + for (let i = lines.length - 1; i >= 0; i--) { + const entry = parseEntry(lines[i]); + if (entry?.sandbox !== sandboxName) continue; + if (entry.action === "shields_down") { + const downMs = + typeof entry.timestamp === "string" ? new Date(entry.timestamp).getTime() : Number.NaN; + if (Number.isFinite(downMs) && downMs <= now) { + newerShieldsDownMs = Math.max(newerShieldsDownMs ?? downMs, downMs); + } + continue; + } + if (entry?.action !== "shields_auto_restore" || typeof entry.timestamp !== "string") continue; + const restoreMs = new Date(entry.timestamp).getTime(); + if (!Number.isFinite(restoreMs) || restoreMs < cutoff || restoreMs > now) continue; + if (newerShieldsDownMs !== null && newerShieldsDownMs >= restoreMs) { + return { kind: "none" }; + } + const restoreTs = entry.timestamp; + // Continue backwards to find the preceding shields_down to get timeout_seconds. + let timeoutSeconds: number | null = null; + for (let j = i - 1; j >= 0; j--) { + const prev = parseEntry(lines[j]); + if (prev?.action === "shields_down" && prev.sandbox === sandboxName) { + const downMs = + typeof prev.timestamp === "string" ? new Date(prev.timestamp).getTime() : Number.NaN; + if ( + Number.isFinite(downMs) && + downMs <= restoreMs && + typeof prev.timeout_seconds === "number" && + Number.isFinite(prev.timeout_seconds) && + Number.isInteger(prev.timeout_seconds) && + prev.timeout_seconds >= 1 && + prev.timeout_seconds <= 1800 + ) { + timeoutSeconds = prev.timeout_seconds; + } + break; + } + } + return { kind: "event", event: { timestamp: restoreTs, timeoutSeconds } }; + } + return { kind: "none" }; +} + +export { AUDIT_DIR, AUDIT_FILE };