Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -974,15 +976,18 @@ 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
if not stat.S_ISDIR(mode):
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):
Expand Down Expand Up @@ -1016,13 +1021,17 @@ 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",
"-I",
"-c",
SHIELDS_DOWN_CONFIG_PATH_PREFLIGHT_SCRIPT,
target.configDir,
allowedMissingPath,
...files,
]);
} catch (error) {
Expand Down Expand Up @@ -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}`,
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" &&
Expand Down
164 changes: 164 additions & 0 deletions src/lib/shields/policy-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,80 @@ 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 { CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT } = requireSource(
"./seal.js",
) as typeof import("./seal.js");
let hashRecord: { content: string; mode: string } | undefined;
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] ?? "");
return new Map<boolean, () => string>([
[true, () => ""],
[
command[0] === "lsattr",
() => `-------------- ${String(command.at(-1))}`,
],
[command[0] === "sha256sum", () => `${"a".repeat(64)} ${configPath}`],
[
command[0] === "stat",
() =>
new Map<string, string>([
["/sandbox", "755 sandbox:sandbox"],
[configDir, "2770 sandbox:sandbox"],
]).get(String(command.at(-1))) ?? "660 sandbox:sandbox",
],
[
command[0] === "python3" &&
script !== CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT &&
!script.includes("nemoclaw-shields-down-path-preflight"),
() => {
expect(hashRecord).toEqual({ content: `${"a".repeat(64)}\n`, mode: "444" });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return "";
},
],
[
command[0] === "python3" && script === CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT,
() => {
hashRecord = { content: `${"a".repeat(64)}\n`, mode: "444" };
return "";
},
],
]).get(true)!();
},
});

harness.shieldsDown("dcode-safety", { throwOnError: true });

expect(hashRecord).toEqual({ content: `${"a".repeat(64)}\n`, mode: "444" });
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, {
Expand Down Expand Up @@ -440,6 +514,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;
Expand Down Expand Up @@ -514,6 +589,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<boolean, () => string>([
[
true,
() => {
throw sandboxCommandFailure(
`missing config path: ${missing}\n`,
`missing config path: ${missing}`,
);
},
],
[false, () => ""],
]).get(missing !== undefined)!();
}

const exactPythonFixtureHandlers = new Map<string, (command: string[]) => string>([
[LOCK_COMMAND_KEY, runConfigLock],
]);
Expand All @@ -522,7 +616,11 @@ describe("shields config lock without a shipped config hash", () => {
]);

function runPythonFixtureCommand(_args: string[], command: string[]): string {
const classifiedHandler = new Map<string, (command: string[]) => string>([
[PREFLIGHT_COMMAND_KEY, runConfigPreflight],
]).get(command.slice(4).join("\0"));
const handler =
classifiedHandler ??
exactPythonFixtureHandlers.get(pythonCommandKey(command)) ??
leadingPythonFixtureHandlers.get(String(command[4])) ??
unsupportedCommand;
Expand Down Expand Up @@ -881,6 +979,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(
Expand Down
29 changes: 15 additions & 14 deletions src/lib/shields/seal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -207,6 +193,21 @@ 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(hashRecordPath(configDir))).toBe(false);
});

it("pins and publishes the managed sandbox parent around repair", async () => {
const configDir = makeConfigDir();
const parentDir = path.dirname(configDir);
Expand Down
6 changes: 4 additions & 2 deletions test/helpers/hermes-unsafe-config-shields-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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",
Expand Down
Loading