diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index c2d7ac862c4..50bf7d5f93f 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -951,7 +951,7 @@ def open_flags(want_dir): flags |= getattr(os, "O_NONBLOCK", 0) return flags -def open_nofollow(path, want_dir, dir_fd=None): +def open_nofollow(path, want_dir, dir_fd=None, allow_missing=False): try: if dir_fd is None: return os.open(path, open_flags(want_dir)) @@ -960,6 +960,8 @@ def open_nofollow(path, want_dir, dir_fd=None): label = path if dir_fd is None else "%s/%s" % (sys.argv[1].rstrip("/"), path) if exc.errno in (errno.ELOOP, getattr(errno, "ENOTDIR", errno.EINVAL)): die("refusing symlink path: " + label) + if exc.errno == errno.ENOENT and allow_missing: + return None if exc.errno == errno.ENOENT: die("missing config path: " + label) die("open failed for %s: %s" % (label, exc)) @@ -974,7 +976,8 @@ def child_name(config_dir, path): return name config_dir = sys.argv[1] -files = sys.argv[2:] +allowed_missing_path = sys.argv[2] +files = sys.argv[3:] dir_fd = open_nofollow(config_dir, True) try: mode = os.fstat(dir_fd).st_mode @@ -982,7 +985,9 @@ try: die("refusing non-directory config path: " + config_dir) for path in files: name = child_name(config_dir, path) - fd = open_nofollow(name, False, dir_fd=dir_fd) + fd = open_nofollow(name, False, dir_fd=dir_fd, allow_missing=path == allowed_missing_path) + if fd is None: + continue try: mode = os.fstat(fd).st_mode if not stat.S_ISREG(mode): @@ -1016,6 +1021,9 @@ function isUnsafeShieldsConfigPathError(error: unknown): boolean { function assertShieldsDownConfigPathsSafe(sandboxName: string, target: AgentConfigTarget): void { const files = [target.configPath, ...(target.sensitiveFiles || [])]; + const allowedMissingPath = isCanonicalDeepAgentsTarget(target) + ? DEEP_AGENTS_CONFIG_HASH_PATH + : ""; try { privilegedSandboxExecCapture(sandboxName, [ "python3", @@ -1023,6 +1031,7 @@ function assertShieldsDownConfigPathsSafe(sandboxName: string, target: AgentConf "-c", SHIELDS_DOWN_CONFIG_PATH_PREFLIGHT_SCRIPT, target.configDir, + allowedMissingPath, ...files, ]); } catch (error) { @@ -1620,16 +1629,20 @@ function isDeepAgentsTarget(target: AgentConfigTarget): boolean { return target.agentName === DEEP_AGENTS_NAME; } +function isCanonicalDeepAgentsTarget(target: AgentConfigTarget): boolean { + const files = [target.configPath, ...(target.sensitiveFiles || [])]; + return ( + target.configDir === DEEP_AGENTS_CONFIG_DIR && + target.configPath === DEEP_AGENTS_CONFIG_PATH && + files.length === 2 && + files[0] === DEEP_AGENTS_CONFIG_PATH && + files[1] === DEEP_AGENTS_CONFIG_HASH_PATH + ); +} + function assertCanonicalDeepAgentsTarget(target: AgentConfigTarget): void { if (!isDeepAgentsTarget(target)) return; - const files = [target.configPath, ...(target.sensitiveFiles || [])]; - if ( - target.configDir !== DEEP_AGENTS_CONFIG_DIR || - target.configPath !== DEEP_AGENTS_CONFIG_PATH || - files.length !== 2 || - files[0] !== DEEP_AGENTS_CONFIG_PATH || - files[1] !== DEEP_AGENTS_CONFIG_HASH_PATH - ) { + if (!isCanonicalDeepAgentsTarget(target)) { throw new Error( `Deep Agents shields require the canonical protected-file set under ${DEEP_AGENTS_CONFIG_DIR}`, ); @@ -5356,6 +5369,10 @@ function shieldsDownWithoutHostLock( ? "provider-state-mutation-v2" : requireHermesShieldsProtocol(sandboxName, target, opts.allowLegacyHermesProtocol === true); + // Deep Agents Code may omit `/sandbox/.deepagents/.config-hash` after + // onboarding, `mcp restart`, or an image rebuild. Preflight permits only + // that exact absent path; it still rejects an unsafe existing hash or + // `/sandbox/.deepagents/config.toml` before any Shields mutation (#10752). // Refuse an unsafe config path before timer, host state, or policy mutation. // Otherwise unlock rejects the path after the provisional DOWN/permissive // record is already live, and status integrity fails when re-lock cannot @@ -5657,6 +5674,12 @@ function shieldsDownWithoutHostLock( if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); } + if (isCanonicalDeepAgentsTarget(target)) { + // Create the missing hash only after the policy is applied and the + // Shields-down auto-restore authority is recorded. If unlock fails, + // the Shields rollback restores lockdown. + writeAbsentConfigHashNoSymlinkFollow(sandboxName, target); + } if ( target.agentName === "hermes" && protocol === "provider-state-mutation-v2" && diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index f24d992227e..2a888432dda 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; @@ -234,6 +236,102 @@ describe("shields down policy rejection", () => { }); } + it("repairs a missing Deep Agents config hash immediately before unlock (#10752)", () => { + const configDir = "/sandbox/.deepagents"; + const configPath = `${configDir}/config.toml`; + const hashPath = `${configDir}/.config-hash`; + const fixtureConfigDir = path.join(tmpDir, "sandbox", ".deepagents"); + const fixtureConfigPath = path.join(fixtureConfigDir, "config.toml"); + const fixtureHashPath = path.join(fixtureConfigDir, ".config-hash"); + const configContent = "model = \"test\"\n"; + const expectedHashRecord = `${createHash("sha256").update(configContent).digest("hex")} config.toml\n`; + fs.mkdirSync(fixtureConfigDir, { recursive: true }); + fs.writeFileSync(fixtureConfigPath, configContent); + const agent = "langchain-deepagents-code"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + sandboxName: "dcode-safety", + sandboxEntry: { name: "dcode-safety", agent, openshellDriver: "docker" }, + agentConfigTarget: { + agentName: agent, + configDir, + configFile: "config.toml", + configPath, + format: "toml", + sensitiveFiles: [hashPath], + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + stateLockPlanInImage: false, + }, + dockerExecFileSync: (argv) => { + const args = Array.isArray(argv) ? argv.map(String) : []; + const command = args.slice(4); + const script = String(command[3] ?? ""); + const commandKind = new Map([ + [true, "default"], + [ + command[0] === "python3" && + !script.includes("nemoclaw-shields-down-path-preflight"), + "unlock", + ], + [ + command.length === 6 && + command[0] === "python3" && + command[1] === "-I" && + command[2] === "-c" && + command[4] === configDir && + command[5] === configPath, + "repair", + ], + [command[0] === "lsattr", "lsattr"], + [command[0] === "sha256sum", "sha"], + [command[0] === "stat", "stat"], + ]).get(true)!; + return new Map string>([ + ["default", () => ""], + ["lsattr", () => `-------------- ${String(command.at(-1))}`], + [ + "repair", + () => + execFileSync( + "python3", + ["-I", "-c", script, fixtureConfigDir, fixtureConfigPath], + { encoding: "utf8" }, + ), + ], + ["sha", () => `${"a".repeat(64)} ${configPath}`], + [ + "stat", + () => + new Map([ + ["/sandbox", "755 sandbox:sandbox"], + [configDir, "2770 sandbox:sandbox"], + ]).get(String(command.at(-1))) ?? "660 sandbox:sandbox", + ], + [ + "unlock", + () => { + expect(fs.readFileSync(fixtureHashPath, "utf8")).toBe(expectedHashRecord); + expect(fs.statSync(fixtureHashPath).mode & 0o777).toBe(0o444); + return ""; + }, + ], + ]).get(commandKind)!(); + }, + }); + + harness.shieldsDown("dcode-safety", { throwOnError: true }); + + expect(fs.readFileSync(fixtureHashPath, "utf8")).toBe(expectedHashRecord); + expect(fs.statSync(fixtureHashPath).mode & 0o777).toBe(0o444); + expect(harness.isShieldsDown("dcode-safety")).toBe(true); + }); + it("pins Shields policy inspection, reads, and writes to the recorded gateway (#9833)", () => { const gatewayName = "nemoclaw-18080"; const harness = createShieldsFlowHarness(requireSource, tmpDir, { @@ -440,6 +538,7 @@ describe("shields config lock without a shipped config hash", () => { const CONFIG_PATH = `${CONFIG_DIR}/config.toml`; const HASH_PATH = `${CONFIG_DIR}/.config-hash`; const LOCK_COMMAND_KEY = [CONFIG_DIR, CONFIG_PATH].join("\0"); + const PREFLIGHT_COMMAND_KEY = [CONFIG_DIR, HASH_PATH, CONFIG_PATH, HASH_PATH].join("\0"); type SandboxEntry = { mode: string; owner: string }; type SandboxCommandHandler = (args: string[], command: string[]) => string; @@ -514,6 +613,25 @@ describe("shields config lock without a shipped config hash", () => { return ""; } + function runConfigPreflight(command: string[]): string { + const allowedMissingPath = command[5]; + const missing = command + .slice(6) + .find((pathname) => pathname !== allowedMissingPath && !entries.has(pathname)); + return new Map string>([ + [ + true, + () => { + throw sandboxCommandFailure( + `missing config path: ${missing}\n`, + `missing config path: ${missing}`, + ); + }, + ], + [false, () => ""], + ]).get(missing !== undefined)!(); + } + const exactPythonFixtureHandlers = new Map string>([ [LOCK_COMMAND_KEY, runConfigLock], ]); @@ -522,7 +640,11 @@ describe("shields config lock without a shipped config hash", () => { ]); function runPythonFixtureCommand(_args: string[], command: string[]): string { + const classifiedHandler = new Map string>([ + [PREFLIGHT_COMMAND_KEY, runConfigPreflight], + ]).get(command.slice(4).join("\0")); const handler = + classifiedHandler ?? exactPythonFixtureHandlers.get(pythonCommandKey(command)) ?? leadingPythonFixtureHandlers.get(String(command[4])) ?? unsupportedCommand; @@ -881,6 +1003,72 @@ describe("shields config lock without a shipped config hash", () => { expect(entries.get("/sandbox")).toEqual({ mode: "755", owner: "sandbox:sandbox" }); expect(entries.get(CONFIG_DIR)).toEqual({ mode: "2770", owner: "sandbox:sandbox" }); }); + + it("does not repair an absent Deep Agents config hash when policy capture fails (#10752)", () => { + expect(entries.has(HASH_PATH)).toBe(false); + const initialParent = entries.get("/sandbox"); + const initialConfigDir = entries.get(CONFIG_DIR); + + let caught: unknown; + try { + shields.shieldsDown("dcode-safety", { + timeout: "15m", + reason: "provider continuity verification", + throwOnError: true, + }); + } catch (error) { + caught = error; + } + + expect(entries.has(HASH_PATH)).toBe(false); + expect(entries.get("/sandbox")).toEqual(initialParent); + expect(entries.get(CONFIG_DIR)).toEqual(initialConfigDir); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("Cannot capture current policy"); + }); + + it("does not repair a present Deep Agents config hash when policy capture fails (#10752)", () => { + entries.set("/sandbox", { mode: "755", owner: "sandbox:sandbox" }); + entries.set(CONFIG_DIR, { mode: "2770", owner: "sandbox:sandbox" }); + entries.set(HASH_PATH, { mode: "444", owner: "sandbox:sandbox" }); + + let caught: unknown; + try { + shields.shieldsDown("dcode-safety", { + timeout: "15m", + reason: "provider continuity verification", + throwOnError: true, + }); + } catch (error) { + caught = error; + } + + expect(entries.get("/sandbox")).toEqual({ mode: "755", owner: "sandbox:sandbox" }); + expect(entries.get(CONFIG_DIR)).toEqual({ mode: "2770", owner: "sandbox:sandbox" }); + expect(entries.get(HASH_PATH)).toEqual({ mode: "444", owner: "sandbox:sandbox" }); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("Cannot capture current policy"); + }); + + it("still refuses shields down when Deep Agents config.toml is absent (#10752)", () => { + entries.delete(CONFIG_PATH); + + let caught: unknown; + try { + shields.shieldsDown("dcode-safety", { + timeout: "15m", + reason: "provider continuity verification", + throwOnError: true, + }); + } catch (error) { + caught = error; + } + + expect(entries.has(HASH_PATH)).toBe(false); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe(`missing config path: ${CONFIG_PATH}`); + }); + }); function removeWithInjectedStateRestoreFailure( diff --git a/src/lib/shields/seal.test.ts b/src/lib/shields/seal.test.ts index 7060a40e1aa..2470fff4ee2 100644 --- a/src/lib/shields/seal.test.ts +++ b/src/lib/shields/seal.test.ts @@ -183,20 +183,6 @@ describe("buildConfigHashRepairCommand", () => { } }); - it("runs the repair helper under an isolated interpreter", async () => { - const { buildConfigHashRepairCommand, CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT } = await loadSeal(); - expect( - buildConfigHashRepairCommand("/sandbox/.deepagents", "/sandbox/.deepagents/config.toml"), - ).toEqual([ - "python3", - "-I", - "-c", - CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT, - "/sandbox/.deepagents", - "/sandbox/.deepagents/config.toml", - ]); - }); - it("writes a read-only record for a config directory that has none", async () => { const configDir = makeConfigDir(); @@ -207,6 +193,22 @@ describe("buildConfigHashRepairCommand", () => { expect(fs.statSync(record).mode & 0o777).toBe(0o444); }); + it("restores directory metadata when the config file is missing (#10752)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + fs.unlinkSync(path.join(configDir, "config.toml")); + fs.chmodSync(parentDir, 0o751); + fs.chmodSync(configDir, 0o2770); + + const outcome = await runRepair(configDir); + + expect(outcome.status).toBe(1); + expect(fs.statSync(parentDir).mode & 0o7777).toBe(0o751); + expect(fs.statSync(configDir).mode & 0o7777).toBe(0o2770); + expect(fs.existsSync(path.join(configDir, "config.toml"))).toBe(false); + expect(fs.existsSync(hashRecordPath(configDir))).toBe(false); + }); + it("pins and publishes the managed sandbox parent around repair", async () => { const configDir = makeConfigDir(); const parentDir = path.dirname(configDir); diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index f9977fdc6b6..d1dfa1d00d3 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -71,7 +71,8 @@ function isPathPreflight(cmd: string[]): boolean { cmd[1] === "-I" && cmd[2] === "-c" && cmd[4] === hermesTarget.configDir && - cmd[5] === hermesTarget.configPath; + cmd[5] === "" && + cmd[6] === hermesTarget.configPath; if (!matchesCommand) return false; if (typeof cmd[3] !== "string" || !cmd[3].includes(PATH_PREFLIGHT_MARKER)) { throw new Error(`Expected ${PATH_PREFLIGHT_MARKER} in the Shields path preflight command`); @@ -110,7 +111,8 @@ function defaultDockerExec(cmd: string[]): string { function runPathPreflight(cmd: string[], configFixtureDir: string): string { const fixtureArgs = cmd.slice(1, 4).concat( configFixtureDir, - cmd.slice(5).map((file) => path.join(configFixtureDir, path.basename(file))), + "", + cmd.slice(6).map((file) => path.join(configFixtureDir, path.basename(file))), ); return execFileSync(cmd[0], fixtureArgs, { encoding: "utf8",