From cbcc73fbb79ab431ce37194d559cf1b2c0b34d3e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 5 Jun 2026 17:23:43 -0700 Subject: [PATCH 1/2] fix(security): close P1 code scanning findings Signed-off-by: Carlos Villela --- agents/hermes/start.sh | 4 +++- scripts/nemoclaw-start.sh | 6 +++++- src/lib/actions/dev/npm-link-or-shim.test.ts | 12 +++++++++--- src/lib/actions/dev/npm-link-or-shim.ts | 15 +++++++++------ src/lib/actions/sandbox/snapshot.ts | 13 +++++++++++-- src/lib/shields/timer.ts | 13 ++++++++++++- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index f96f34ab74..c6a38c947e 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -333,7 +333,9 @@ cleanup_orphan_socat_forwarders() { kill "$pid" 2>/dev/null || true ;; *socat*"TCP-LISTEN:${dashboard_public_port}"*"TCP:127.0.0.1:${dashboard_internal_port}"*) - [ -n "$dashboard_public_port" ] && [ -n "$dashboard_internal_port" ] || continue + if [ -z "$dashboard_public_port" ] || [ -z "$dashboard_internal_port" ]; then + continue + fi echo "[gateway] Removing orphaned dashboard socat forwarder for ${dashboard_public_port}->${dashboard_internal_port} (pid ${pid})" >&2 kill "$pid" 2>/dev/null || true ;; diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 57837b497c..879344d8ba 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2789,7 +2789,11 @@ NODE openclaw_pkg_roots+=("/usr/local/lib/node_modules/openclaw") if openclaw_bin="$(command -v openclaw 2>/dev/null)"; then openclaw_real="$(readlink -f "$openclaw_bin" 2>/dev/null || printf '%s\n' "$openclaw_bin")" - openclaw_pkg="$(cd "$(dirname "$openclaw_real")/.." 2>/dev/null && pwd -P || true)" + openclaw_pkg="$( + if cd "$(dirname "$openclaw_real")/.." 2>/dev/null; then + pwd -P + fi + )" if [ -n "$openclaw_pkg" ]; then openclaw_pkg_roots+=("$openclaw_pkg") fi diff --git a/src/lib/actions/dev/npm-link-or-shim.test.ts b/src/lib/actions/dev/npm-link-or-shim.test.ts index ddd72412f8..071000a4dc 100644 --- a/src/lib/actions/dev/npm-link-or-shim.test.ts +++ b/src/lib/actions/dev/npm-link-or-shim.test.ts @@ -66,8 +66,11 @@ describe("runNpmLinkOrShim", () => { const shim = fs.readFileSync(shimPath, "utf-8"); expect(shim).toContain(DEV_SHIM_MARKER); expect(shim).toContain(`exec "${binPath}" "$@"`); - expect(errors.join("\n")).toContain("npm link failed"); - expect(errors.join("\n")).toContain("Created user-local shim"); + const logOutput = errors.join("\n"); + expect(logOutput).toContain("npm link failed"); + expect(logOutput).toContain("Created user-local shim at ~/.local/bin/nemoclaw"); + expect(logOutput).not.toContain(homeDir); + expect(logOutput).not.toContain(repoDir); }); it("refuses to overwrite a foreign shim", () => { @@ -117,7 +120,10 @@ describe("runNpmLinkOrShim", () => { ); expect(result.status).toBe(1); - expect(errors.join("\n")).toContain("shim creation failed"); + const logOutput = errors.join("\n"); + expect(logOutput).toContain("shim creation failed"); + expect(logOutput).toContain("~/.local/bin"); + expect(logOutput).not.toContain(homeDir); expect(fs.readFileSync(path.join(homeDir, ".local"), "utf-8")).toBe("not-a-directory\n"); }); }); diff --git a/src/lib/actions/dev/npm-link-or-shim.ts b/src/lib/actions/dev/npm-link-or-shim.ts index 90ce993e91..775cd108f4 100644 --- a/src/lib/actions/dev/npm-link-or-shim.ts +++ b/src/lib/actions/dev/npm-link-or-shim.ts @@ -95,6 +95,8 @@ export function runNpmLinkOrShim( const home = env.HOME || os.homedir(); const shimDir = path.join(home, ".local", "bin"); const shimPath = path.join(shimDir, "nemoclaw"); + const shimDirDisplay = "~/.local/bin"; + const shimPathDisplay = "~/.local/bin/nemoclaw"; const logError = deps.logError ?? ((message: string) => console.error(message)); const exists = deps.exists ?? ((filePath: string) => fs.existsSync(filePath)); @@ -145,7 +147,7 @@ export function runNpmLinkOrShim( if (exists(shimPath)) { const classification = classifyDevShim(safeRead(readFile, shimPath)); if (classification === "foreign") { - logError(`[nemoclaw] ${shimPath} already exists and is not managed by NemoClaw; not overwriting.`); + logError(`[nemoclaw] ${shimPathDisplay} already exists and is not managed by NemoClaw; not overwriting.`); logError("[nemoclaw] Move it aside and re-run 'npm install' to install the dev shim."); return { shimPath, status: 1 }; } @@ -160,21 +162,22 @@ export function runNpmLinkOrShim( rename(shimTmp, shimPath); shimTmp = null; } catch (error) { - logError(`[nemoclaw] shim creation failed: ${error instanceof Error ? error.message : String(error)}`); + const reason = error instanceof Error && "code" in error && typeof error.code === "string" ? ` (${error.code})` : ""; + logError(`[nemoclaw] shim creation failed${reason}; check permissions for ${shimDirDisplay}`); return { shimPath, status: 1 }; } finally { if (shimTmp) unlink(shimTmp); } if (!isExecutable(shimPath)) { - logError(`[nemoclaw] shim creation failed: ${shimPath} is not executable after write`); + logError(`[nemoclaw] shim creation failed: ${shimPathDisplay} is not executable after write`); return { shimPath, status: 1 }; } - logError(`[nemoclaw] Created user-local shim at ${shimPath} -> ${binPath}`); + logError(`[nemoclaw] Created user-local shim at ${shimPathDisplay}`); if (!pathContainsDirectory(env.PATH, shimDir)) { - logError(`[nemoclaw] ${shimDir} is not on PATH. Add it to your shell profile, e.g.:`); - logError(`[nemoclaw] echo 'export PATH="${shimDir}:$PATH"' >> ~/.bashrc`); + logError(`[nemoclaw] ${shimDirDisplay} is not on PATH. Add it to your shell profile, e.g.:`); + logError('[nemoclaw] echo \'export PATH="$HOME/.local/bin:$PATH"\' >> ~/.bashrc'); } return { shimPath, status: 0 }; } diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 0c94d8e54d..d8a72b66b7 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -12,7 +12,7 @@ import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import * as policies from "../../policy"; import { ROOT, run, shellQuote, validateName } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; -import { isShieldsDown } from "../../shields"; +import * as shields from "../../shields"; import { isGatewayHealthy } from "../../state/gateway"; import type { SandboxEntry } from "../../state/registry"; import * as registry from "../../state/registry"; @@ -323,6 +323,15 @@ function probeGatewayRunning(sandboxName?: string): boolean { return result.status === 0 && String(result.stdout || "").trim() === "true"; } +function isSnapshotCreationAllowedByShields(sandboxName: string): boolean { + const isShieldsDown = shields.isShieldsDown; + if (typeof isShieldsDown !== "function") { + console.error(" Cannot verify shields state. Refusing to create snapshot."); + return false; + } + return isShieldsDown(sandboxName); +} + export async function runSandboxSnapshot( sandboxName: string, request: SnapshotRequest = { kind: "help" }, @@ -339,7 +348,7 @@ export async function runSandboxSnapshot( console.error(` Sandbox '${sandboxName}' is not running. Cannot create snapshot.`); snapshotExit(1); } - if (!isShieldsDown(sandboxName)) { + if (!isSnapshotCreationAllowedByShields(sandboxName)) { console.error(" Cannot create snapshot while shields are up."); console.error(` Run \`${CLI_NAME} ${sandboxName} shields down\` first, then retry.`); snapshotExit(1); diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index ff82be4b5d..02a4011ad9 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -16,7 +16,7 @@ import { run } from "../runner"; import { resolveAgentConfig } from "../sandbox/config"; import { resolveNemoclawStateDir } from "../state/paths"; import { appendAuditEntry, type ShieldsAuditEntry } from "./audit"; -import { lockAgentConfig } from "./index"; +import * as shields from "./index"; interface ShieldsStatePatch { shieldsDown?: boolean; @@ -41,6 +41,8 @@ interface TimerArgs { processToken?: string; } +type LockAgentConfig = typeof shields.lockAgentConfig; + const STATE_DIR = resolveNemoclawStateDir(); function parseTimerArgs(argv: string[]): TimerArgs | null { @@ -117,6 +119,14 @@ function readTimerMarker(markerPath: string): UnknownRecord | null { } } +function resolveLockAgentConfig(): LockAgentConfig { + const lockAgentConfig = shields.lockAgentConfig; + if (typeof lockAgentConfig !== "function") { + throw new Error("Shields lock helper is unavailable; cannot verify auto-restore lock state"); + } + return lockAgentConfig; +} + function markerMatchesCurrentTimer(args: TimerArgs): boolean { const marker = readTimerMarker(args.markerPath); if (!marker) return false; @@ -229,6 +239,7 @@ function runRestoreTimer(args: TimerArgs): void { } if (lockTarget) { try { + const lockAgentConfig = resolveLockAgentConfig(); const lockResult = lockAgentConfig(args.sandboxName, lockTarget); lockedChattr = lockResult.chattrApplied; lockedHashes = lockResult.fileHashes; From 5ba38958a2f326e089b3d958abcfa545b4b2b7cb Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 5 Jun 2026 17:54:33 -0700 Subject: [PATCH 2/2] test(security): cover P1 feedback paths Signed-off-by: Carlos Villela --- src/lib/actions/dev/npm-link-or-shim.test.ts | 30 +++++- src/lib/actions/dev/npm-link-or-shim.ts | 27 +++++- src/lib/actions/sandbox/snapshot.test.ts | 97 ++++++++++++++++++++ src/lib/actions/sandbox/snapshot.ts | 3 + src/lib/shields/timer.test.ts | 80 +++++++++++++++- src/lib/shields/timer.ts | 3 + 6 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 src/lib/actions/sandbox/snapshot.test.ts diff --git a/src/lib/actions/dev/npm-link-or-shim.test.ts b/src/lib/actions/dev/npm-link-or-shim.test.ts index 071000a4dc..834cf9c02d 100644 --- a/src/lib/actions/dev/npm-link-or-shim.test.ts +++ b/src/lib/actions/dev/npm-link-or-shim.test.ts @@ -20,8 +20,8 @@ function setupRepo(): { binPath: string; homeDir: string; repoDir: string; tmpDi return { binPath, homeDir, repoDir, tmpDir }; } -function failingNpm() { - return vi.fn(() => ({ status: 1, stdout: "", stderr: "npm error code EACCES\n" })); +function failingNpm(output = "npm error code EACCES\n") { + return vi.fn(() => ({ status: 1, stdout: "", stderr: output })); } describe("runNpmLinkOrShim", () => { @@ -73,6 +73,32 @@ describe("runNpmLinkOrShim", () => { expect(logOutput).not.toContain(repoDir); }); + it("redacts fallback npm output before logging", () => { + const { homeDir, repoDir } = setupRepo(); + const errors: string[] = []; + const token = "nvapi-secret-value"; + + const result = runNpmLinkOrShim( + { env: { HOME: homeDir }, repoRoot: repoDir }, + { + commandPath: () => process.execPath, + logError: (message) => errors.push(message), + run: failingNpm(`npm failed in ${repoDir} under ${homeDir}\nNVIDIA_API_KEY=${token}\nAuthorization: Bearer ${token}\n`), + }, + ); + + const logOutput = errors.join("\n"); + expect(result.status).toBe(0); + expect(logOutput).toContain("npm link failed"); + expect(logOutput).toContain(""); + expect(logOutput).toContain("~"); + expect(logOutput).toContain("NVIDIA_API_KEY=[REDACTED]"); + expect(logOutput).toContain("Bearer [REDACTED]"); + expect(logOutput).not.toContain(homeDir); + expect(logOutput).not.toContain(repoDir); + expect(logOutput).not.toContain(token); + }); + it("refuses to overwrite a foreign shim", () => { const { homeDir, repoDir } = setupRepo(); const shimPath = path.join(homeDir, ".local", "bin", "nemoclaw"); diff --git a/src/lib/actions/dev/npm-link-or-shim.ts b/src/lib/actions/dev/npm-link-or-shim.ts index 775cd108f4..83558d833a 100644 --- a/src/lib/actions/dev/npm-link-or-shim.ts +++ b/src/lib/actions/dev/npm-link-or-shim.ts @@ -61,9 +61,30 @@ function defaultCommandPath(command: string, env: NodeJS.ProcessEnv): string | n return result.status === 0 && resolved ? resolved : null; } -function formatNpmLinkFailure(output: string): string[] { +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function redactKnownPath(value: string, rawPath: string, replacement: string): string { + if (!rawPath) return value; + return value.replace(new RegExp(escapeRegExp(rawPath), "g"), replacement); +} + +function sanitizeNpmLinkOutput(output: string, paths: { home: string; repoRoot: string }): string { + let sanitized = output; + sanitized = redactKnownPath(sanitized, paths.repoRoot, ""); + sanitized = redactKnownPath(sanitized, paths.home, "~"); + sanitized = sanitized.replace( + /\b(([A-Z0-9_-]*(?:api[_-]?key|token|secret|password)[A-Z0-9_-]*)\s*[:=]\s*)([^\s'"`]+)/gi, + "$1[REDACTED]", + ); + sanitized = sanitized.replace(/\b(Bearer\s+)([^\s'"`]+)/gi, "$1[REDACTED]"); + return sanitized; +} + +function formatNpmLinkFailure(output: string, paths: { home: string; repoRoot: string }): string[] { const lines = ["[nemoclaw] npm link failed; falling back to user-local shim."]; - for (const line of output.split(/\r?\n/).filter(Boolean)) { + for (const line of sanitizeNpmLinkOutput(output, paths).split(/\r?\n/).filter(Boolean)) { lines.push(`[nemoclaw] ${line}`); } return lines; @@ -135,7 +156,7 @@ export function runNpmLinkOrShim( const linkResult = run("npm", ["link"], { cwd: repoRoot, env: installEnv }); if (linkResult.status === 0) return { status: 0 }; - for (const line of formatNpmLinkFailure(`${linkResult.stdout}${linkResult.stderr}`)) logError(line); + for (const line of formatNpmLinkFailure(`${linkResult.stdout}${linkResult.stderr}`, { home, repoRoot })) logError(line); const nodePath = findNodePath(installEnv, { commandPath, isExecutable }); if (!nodePath) { diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts new file mode 100644 index 0000000000..2dd22b61e1 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const backupSandboxStateMock = vi.fn(); +const captureOpenshellMock = vi.fn(() => ({ status: 0, output: "alpha Ready\n" })); +const dockerInspectMock = vi.fn(() => ({ status: 0, stdout: "true\n" })); +const getSandboxMock = vi.fn(() => null); +const isGatewayHealthyMock = vi.fn(() => true); +const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); + +vi.mock("../../adapters/docker", () => ({ + dockerCapture: vi.fn(() => ""), + dockerInspect: dockerInspectMock, +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: captureOpenshellMock, + getOpenshellBinary: vi.fn(() => "openshell"), + runOpenshell: vi.fn(() => ({ status: 0, output: "" })), +})); + +vi.mock("../../credentials/store", () => ({ + prompt: vi.fn(), +})); + +vi.mock("../../domain/sandbox/destroy", () => ({ + getSandboxDeleteOutcome: vi.fn(() => ({ alreadyGone: false })), +})); + +vi.mock("../../policy", () => ({})); + +vi.mock("../../runner", () => ({ + ROOT: "/repo", + run: vi.fn(() => ({ status: 0 })), + shellQuote: (value: string) => `'${value}'`, + validateName: vi.fn(), +})); + +vi.mock("../../runtime-recovery", () => ({ + parseLiveSandboxNames: parseLiveSandboxNamesMock, +})); + +vi.mock("../../shields", () => ({ + isShieldsDown: undefined, +})); + +vi.mock("../../state/gateway", () => ({ + isGatewayHealthy: isGatewayHealthyMock, +})); + +vi.mock("../../state/registry", () => ({ + getSandbox: getSandboxMock, + registerSandbox: vi.fn(), + removeSandbox: vi.fn(), +})); + +vi.mock("../../state/sandbox", () => ({ + backupSandboxState: backupSandboxStateMock, + findBackup: vi.fn(), + listBackups: vi.fn(() => []), +})); + +vi.mock("./destroy", () => ({ + cleanupShieldsDestroyArtifacts: vi.fn(), + removeSandboxRegistryEntry: vi.fn(), +})); + +describe("runSandboxSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + captureOpenshellMock.mockReturnValue({ status: 0, output: "alpha Ready\n" }); + dockerInspectMock.mockReturnValue({ status: 0, stdout: "true\n" }); + getSandboxMock.mockReturnValue(null); + isGatewayHealthyMock.mockReturnValue(true); + parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refuses snapshot creation before backup when the shields gate helper is unavailable", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect(runSandboxSnapshot("alpha", { kind: "create" })).rejects.toMatchObject({ + exitCode: 1, + }); + + expect(backupSandboxStateMock).not.toHaveBeenCalled(); + expect(consoleError.mock.calls.flat().join("\n")).toContain( + "Cannot verify shields state. Refusing to create snapshot.", + ); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index d8a72b66b7..7d1ebf1206 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -324,6 +324,9 @@ function probeGatewayRunning(sandboxName?: string): boolean { } function isSnapshotCreationAllowedByShields(sandboxName: string): boolean { + // Snapshot creation is a shields/policy boundary. If a packaged or mocked + // CommonJS interop surface ever omits the helper, fail closed before any + // backup side effect instead of throwing an ambiguous TypeError. const isShieldsDown = shields.isShieldsDown; if (typeof isShieldsDown !== "function") { console.error(" Cannot verify shields state. Refusing to create snapshot."); diff --git a/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index 4257876d1e..3b3ba8d4a5 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -7,6 +7,10 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const shieldsIndexMock = vi.hoisted(() => ({ + lockAgentConfig: vi.fn() as unknown, +})); + const runMock = vi.fn(() => ({ status: 0 })); vi.mock("../runner", () => ({ @@ -34,7 +38,9 @@ vi.mock("../sandbox/config", () => ({ })); vi.mock("./index", () => ({ - lockAgentConfig: vi.fn(), + get lockAgentConfig() { + return shieldsIndexMock.lockAgentConfig; + }, })); describe("shields timer authorization", () => { @@ -43,6 +49,7 @@ describe("shields timer authorization", () => { beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "shields-timer-")); vi.stubEnv("HOME", tmpHome); + shieldsIndexMock.lockAgentConfig = vi.fn(); vi.resetModules(); vi.clearAllMocks(); }); @@ -281,4 +288,75 @@ describe("shields timer authorization", () => { expect(updatedState.fileHashes[sensitiveHashPath]).toBeDefined(); expect(fs.existsSync(markerPath)).toBe(false); }); + + it("leaves shields down and audits when the lock helper export is unavailable", async () => { + const stateDir = path.join(tmpHome, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + + const sandboxName = "alpha"; + const configPath = "/sandbox/.openclaw/openclaw.json"; + const configDir = "/sandbox/.openclaw"; + const snapshotPath = path.join(stateDir, "snapshot.yaml"); + const restoreAtIso = new Date(Date.now() + 60_000).toISOString(); + const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); + const stateFile = path.join(stateDir, `shields-${sandboxName}.json`); + const auditFile = path.join(stateDir, "shields-audit.jsonl"); + + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n default: {}\n"); + fs.writeFileSync(stateFile, JSON.stringify({ shieldsDown: true }, null, 2)); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: process.pid, + sandboxName, + snapshotPath, + restoreAt: restoreAtIso, + processToken: "tok", + }), + ); + + const sandboxConfigModule = await import("../sandbox/config"); + (sandboxConfigModule.resolveAgentConfig as ReturnType).mockReturnValue({ + agentName: "openclaw", + configPath, + configDir, + sensitiveFiles: [], + }); + shieldsIndexMock.lockAgentConfig = undefined; + + const timer = await import("./timer"); + const args = timer.parseTimerArgs([ + sandboxName, + snapshotPath, + restoreAtIso, + configPath, + configDir, + "tok", + ]); + expect(args).not.toBeNull(); + + const exitCode = invokeTimerAndCaptureExit(timer.runRestoreTimer, args); + const updatedState = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + const auditEntries = fs.readFileSync(auditFile, "utf-8").trim().split("\n").map((line) => JSON.parse(line)); + + expect(exitCode).toBe(1); + expect(runMock).toHaveBeenCalledTimes(1); + expect(updatedState.shieldsDown).toBe(true); + expect(auditEntries).toContainEqual( + expect.objectContaining({ + action: "shields_auto_restore_lock_warning", + sandbox: sandboxName, + warning: "Shields lock helper is unavailable; cannot verify auto-restore lock state", + lock_verified: false, + }), + ); + expect(auditEntries).toContainEqual( + expect.objectContaining({ + action: "shields_up_failed", + sandbox: sandboxName, + error: "Config re-lock verification failed — shields remain DOWN", + }), + ); + expect(fs.existsSync(markerPath)).toBe(false); + }); }); diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index 02a4011ad9..a8ab068961 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -120,6 +120,9 @@ function readTimerMarker(markerPath: string): UnknownRecord | null { } function resolveLockAgentConfig(): LockAgentConfig { + // The timer is a detached child process that must never mark shields up + // unless it can call the lock verifier. Guard the CommonJS export boundary + // so packaging/mock drift leaves shields down with an auditable warning. const lockAgentConfig = shields.lockAgentConfig; if (typeof lockAgentConfig !== "function") { throw new Error("Shields lock helper is unavailable; cannot verify auto-restore lock state");