diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index b7d6dbb3fcd..35b92acc388 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1160,6 +1160,15 @@ $$nemoclaw my-assistant shields down --timeout 5m --reason "maintenance" If `shields up` reports that the config remains unlocked or drifted, confirm that the sandbox is running and ready, then retry `$$nemoclaw shields up`. If the retry still fails, rebuild a known-good baseline with `$$nemoclaw rebuild --yes`. + + +A `CRITICAL` Deep Agents config-lock failure is not an ordinary unlocked or drifted result. +The retry and rebuild guidance above does not apply to a `CRITICAL` Deep Agents config-lock diagnostic. +Do not retry `shields up` or attempt an in-sandbox repair. +Follow [Deep Agents Config Lock Failure Recovery](troubleshooting#deep-agents-config-lock-failure-recovery) to restore a trusted snapshot or recreate the sandbox before retrying. + + + Host-side config and inference writes, snapshot mutation, sandbox destruction, and shields transitions serialize per sandbox. When a timed shields-down window reaches its deadline, auto-restore can interrupt the exact process tree holding that transition and restore lockdown. Retry an interrupted command in a new shields-down window. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 514f5ea50e4..f25cae759f1 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1258,6 +1258,69 @@ Run `openshell sandbox list` on the host to check the underlying sandbox state. ## Deep Agents +### Deep Agents Config Lock Failure Recovery + +A `CRITICAL` Deep Agents config-lock diagnostic can report `fail-closed containment=`, `rollback failed`, or that the lock rollback could not restore the trusted posture. +A containment result identifies one of two confirmed postures or an incomplete containment attempt. +A `rollback failed` result or lock-rollback diagnostic does not confirm containment. +Both rollback diagnostics mean NemoClaw could not restore or confirm the original trusted posture. + +- **Config-root posture** (`fail-closed containment=config-root`) means NemoClaw installed fresh `0444 root:root` config and hash inodes. + NemoClaw also confirmed `0500 root:root` on `/sandbox/.deepagents` and `1775 root:sandbox` on `/sandbox`. +- **Sandbox-parent posture** (`fail-closed containment=sandbox-parent`) means NemoClaw confirmed `0700 root:root` on `/sandbox`. + NemoClaw uses this posture when it cannot confirm the complete config-root posture. +- `fail-closed containment=incomplete` means NemoClaw could not confirm either complete posture. + +Preserve the complete `CRITICAL` diagnostic. +Do not retry `shields up`. +Do not run `chmod`, `chown`, or another repair inside the sandbox. +A confirmed containment posture removes the sandbox identity's access to the Deep Agents configuration. +An incomplete containment result, a `rollback failed` result, or a lock-rollback diagnostic does not establish a trustworthy boundary from which to accept the current bytes. +An ordinary `rebuild` cannot turn the current state into a trustworthy snapshot. + +If you have a trusted host-side snapshot from before the failure, list the snapshots and record its selector: + +```bash +$$nemoclaw snapshot list +``` + + +Destroying the sandbox permanently discards state newer than the selected snapshot. +Confirm that the trusted host-side snapshot exists before you destroy the sandbox. + + +Destroy the sandbox, re-onboard the same name from trusted host configuration, and restore the snapshot: + +```bash +$$nemoclaw destroy +$$nemoclaw onboard --name --agent dcode +$$nemoclaw snapshot restore +``` + +For snapshot contents and selector rules, refer to [Create and Restore Snapshots](../manage-sandboxes/state-and-backups/create-and-restore-snapshots). + +If no trusted snapshot exists and you do not need to preserve the current state, recreate the sandbox from host-side onboarding configuration. + + +This recreation permanently discards the current sandbox state. +Continue only if you accept that loss. + + +```bash +$$nemoclaw destroy +$$nemoclaw onboard --name --agent dcode +``` + +After either recovery path, verify the recreated sandbox from the host: + +```bash +$$nemoclaw status +$$nemoclaw shields status +``` + +Continue only when `status` identifies the expected Deep Agents sandbox and `shields status` returns without a `CRITICAL` or corrupt-state diagnostic. +Then retry the original `shields up` operation. + ### `dcode status` reports a stale inference route The managed `dcode` runtime reads provider and model settings from `/sandbox/.deepagents/config.toml`. diff --git a/nemoclaw/src/commands/migration-state-security.test.ts b/nemoclaw/src/commands/migration-state-security.test.ts new file mode 100644 index 00000000000..4aab12776dc --- /dev/null +++ b/nemoclaw/src/commands/migration-state-security.test.ts @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginLogger } from "../index.js"; +import * as credentialFilter from "../security/credential-filter.js"; +import * as snapshotSanitizer from "../security/snapshot-sanitizer.js"; +import * as snapshotBoundary from "../shared/snapshot-sanitizer-boundary.cjs"; +import { + cleanupSnapshotBundle, + createSnapshotBundle, + type HostOpenClawState, + setConfigValue, +} from "./migration-state.js"; + +const roots: string[] = []; + +function makeHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-state-security-")); + roots.push(home); + return home; +} + +function makeLogger(): PluginLogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; +} + +function makeHostState(homeDir: string, configPath: string): HostOpenClawState { + const stateDir = path.join(homeDir, ".openclaw"); + return { + exists: true, + homeDir, + stateDir, + configDir: stateDir, + configPath, + workspaceDir: null, + extensionsDir: null, + skillsDir: null, + hooksDir: null, + externalRoots: [], + warnings: [], + errors: [], + hasExternalConfig: false, + }; +} + +function expectSnapshotBundle( + bundle: ReturnType, +): asserts bundle is NonNullable> { + expect(bundle).not.toBeNull(); +} + +function makeMinimalHostSnapshot(): { + home: string; + configPath: string; + logger: PluginLogger; +} { + const home = makeHome(); + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(configPath, "{}"); + return { home, configPath, logger: makeLogger() }; +} + +function expectSnapshotFailure( + home: string, + logger: PluginLogger, + bundle: ReturnType, + message: string, +): void { + expect(bundle).toBeNull(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(message)); + expect(readdirSync(path.join(home, ".nemoclaw", "staging"))).toEqual([]); +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +describe("migration-state prepared config security", () => { + it("installs a mode-0600 config after scrubbing contextual secrets in memory", () => { + const home = makeHome(); + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + gateway: { auth: { token: "must-not-migrate" } }, + metadata: { + environmentAssignment: "GITHUB_TOKEN=opaque-secret-value-123", + camelAssignment: "apiKey=opaque-secret-value-123", + model: "keep-me", + }, + }), + ); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), makeLogger(), { + persist: false, + }); + expectSnapshotBundle(bundle); + + const preparedConfigPath = path.join(bundle.preparedStateDir, "openclaw.json"); + const preparedConfig = JSON.parse(readFileSync(preparedConfigPath, "utf-8")) as { + gateway?: unknown; + metadata: Record; + }; + expect(preparedConfig.gateway).toBeUndefined(); + expect(preparedConfig.metadata).toEqual({ + environmentAssignment: "[STRIPPED_BY_MIGRATION]", + camelAssignment: "[STRIPPED_BY_MIGRATION]", + model: "keep-me", + }); + expect(statSync(preparedConfigPath).mode & 0o777).toBe(0o600); + + cleanupSnapshotBundle(bundle); + }); + + it.runIf(process.platform !== "win32")( + "rejects an in-tree config symlink without touching its external target", + () => { + const home = makeHome(); + const stateDir = path.join(home, ".openclaw"); + const configPath = path.join(stateDir, "openclaw.json"); + const externalConfigPath = path.join(home, "external-openclaw.json"); + const original = JSON.stringify({ external: "must-remain" }); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(externalConfigPath, original, { mode: 0o640 }); + const externalConfigFd = openSync(externalConfigPath, "r"); + try { + const originalMode = fstatSync(externalConfigFd).mode & 0o777; + symlinkSync(externalConfigPath, configPath); + const logger = makeLogger(); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expect(bundle).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + expect(readFileSync(externalConfigFd, "utf-8")).toBe(original); + expect(fstatSync(externalConfigFd).mode & 0o777).toBe(originalMode); + const stagingDir = path.join(home, ".nemoclaw", "staging"); + expect(existsSync(stagingDir) ? readdirSync(stagingDir) : []).toEqual([]); + } finally { + closeSync(externalConfigFd); + } + }, + ); +}); + +describe("migration-state prepared config fail-closed boundaries", () => { + it("removes staging when the copied config parent cannot be inspected", () => { + const { home, configPath, logger } = makeMinimalHostSnapshot(); + const inspect = vi + .spyOn(snapshotBoundary, "inspectDescriptorSnapshotRoot") + .mockReturnValue(null); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expectSnapshotFailure(home, logger, bundle, "Failed to inspect copied OpenClaw config parent"); + expect(inspect).toHaveBeenCalledTimes(2); + }); + + it("removes staging when copied config bytes cannot be decoded", () => { + const { home, configPath, logger } = makeMinimalHostSnapshot(); + const decodeDescriptorSnapshotContent = snapshotBoundary.decodeDescriptorSnapshotContent; + const decode = vi + .spyOn(snapshotBoundary, "decodeDescriptorSnapshotContent") + .mockImplementationOnce(decodeDescriptorSnapshotContent) + .mockReturnValue(null); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expectSnapshotFailure( + home, + logger, + bundle, + "Failed canonical decoding of copied OpenClaw config", + ); + expect(decode).toHaveBeenCalledTimes(2); + }); + + it("removes staging when in-memory credential stripping returns a non-object", () => { + const { home, configPath, logger } = makeMinimalHostSnapshot(); + const stripCredentials = credentialFilter.stripCredentials; + const strip = vi + .spyOn(credentialFilter, "stripCredentials") + .mockImplementationOnce(stripCredentials) + .mockReturnValue([]); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expectSnapshotFailure( + home, + logger, + bundle, + "Failed to sanitize prepared OpenClaw config in memory", + ); + expect(strip).toHaveBeenCalledTimes(2); + }); + + it("removes staging when the prepared config cannot be installed", () => { + const { home, configPath, logger } = makeMinimalHostSnapshot(); + const install = vi + .spyOn(snapshotBoundary, "installDescriptorSnapshotFile") + .mockReturnValue(false); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expectSnapshotFailure( + home, + logger, + bundle, + "Failed descriptor-bound installation of prepared OpenClaw config", + ); + expect(install).toHaveBeenCalledOnce(); + }); + + it("removes staging when the installed config cannot be sanitized", () => { + const { home, configPath, logger } = makeMinimalHostSnapshot(); + const sanitize = vi + .spyOn(snapshotSanitizer, "sanitizeOpenClawConfigFile") + .mockReturnValue(false); + + const bundle = createSnapshotBundle(makeHostState(home, configPath), logger, { + persist: false, + }); + + expectSnapshotFailure(home, logger, bundle, "Failed to sanitize prepared OpenClaw config"); + expect(sanitize).toHaveBeenCalledOnce(); + }); +}); + +describe("migration-state config path security", () => { + const expectPrototypeClean = (): void => { + const probe: Record = {}; + for (const key of ["polluted", "isAdmin", "bar"]) { + expect(Object.prototype.hasOwnProperty.call(Object.prototype, key)).toBe(false); + expect(probe[key]).toBeUndefined(); + } + }; + + it.each([ + "__proto__", + "constructor", + "prototype", + ])("rejects prototype-related config path segment: %s", (segment) => { + const doc: Record = {}; + expect(() => { + setConfigValue(doc, `${segment}.polluted`, "true"); + }).toThrow(/Unsafe config path segment/); + expectPrototypeClean(); + }); + + it("rejects __proto__ in nested position", () => { + const doc: Record = {}; + expect(() => { + setConfigValue(doc, "agents.__proto__.isAdmin", "true"); + }).toThrow(/Unsafe config path segment/); + expectPrototypeClean(); + }); + + it.each([ + "foo.prototype.bar", + "foo.constructor.bar", + ])("rejects prototype-related segment in nested config path: %s", (configPath) => { + const doc: Record = {}; + expect(() => { + setConfigValue(doc, configPath, "true"); + }).toThrow(/Unsafe config path segment/); + expectPrototypeClean(); + }); + + it("allows simple top-level keys", () => { + const doc: Record = {}; + setConfigValue(doc, "theme", "dark"); + expect(doc.theme).toBe("dark"); + }); +}); diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 268f43b01b1..bc2c5352d1c 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -99,6 +99,53 @@ vi.mock("../security/snapshot-sanitizer.js", async () => ), ); +vi.mock("../shared/snapshot-sanitizer-boundary.cjs", () => { + const identity = { + dev: "1", + ino: "2", + mode: "16832", + nlink: "1", + size: "0", + mtimeNs: "3", + ctimeNs: "4", + }; + return { + decodeDescriptorSnapshotContent: (content: string | undefined) => + content === undefined ? null : Buffer.from(content, "base64").toString("utf-8"), + inspectDescriptorSnapshotRoot: (rootPath: string) => + store.get(rootPath)?.type === "dir" ? { canonicalPath: rootPath, identity } : null, + installDescriptorSnapshotFile: ( + root: { canonicalPath: string }, + targetName: string, + content: string, + ) => { + const targetPath = `${root.canonicalPath}/${targetName}`; + const existing = store.get(targetPath); + store.set(targetPath, existing ?? { type: "file", content }); + return existing === undefined; + }, + scanDescriptorSnapshot: ( + root: { canonicalPath: string }, + _sensitive: unknown, + target: string, + ) => { + const entry = store.get(`${root.canonicalPath}/${target}`); + return entry?.type === "file" + ? { + root: identity, + directories: {}, + files: [ + { + path: target, + metadata: identity, + content: Buffer.from(entry.content ?? "", "utf-8").toString("base64"), + }, + ], + } + : null; + }, + }; +}); // Mock tar to avoid real archive creation vi.mock("tar", () => ({ create: vi.fn(async () => {}), @@ -1503,48 +1550,7 @@ describe("commands/migration-state", () => { }); }); - // ── setConfigValue prototype pollution guard ───────────────────── - describe("setConfigValue", () => { - const expectPrototypeClean = (): void => { - const probe: Record = {}; - for (const key of ["polluted", "isAdmin", "bar"]) { - expect(Object.prototype.hasOwnProperty.call(Object.prototype, key)).toBe(false); - expect(probe[key]).toBeUndefined(); - } - }; - - it.each([ - "__proto__", - "constructor", - "prototype", - ])("rejects unsafe path segment: %s", (segment) => { - const doc: Record = {}; - expect(() => { - setConfigValue(doc, `${segment}.polluted`, "true"); - }).toThrow(/Unsafe config path segment/); - expectPrototypeClean(); - }); - - it("rejects __proto__ in nested position", () => { - const doc: Record = {}; - expect(() => { - setConfigValue(doc, "agents.__proto__.isAdmin", "true"); - }).toThrow(/Unsafe config path segment/); - expectPrototypeClean(); - }); - - it.each([ - "foo.prototype.bar", - "foo.constructor.bar", - ])("rejects unsafe segment in nested path: %s", (configPath) => { - const doc: Record = {}; - expect(() => { - setConfigValue(doc, configPath, "true"); - }).toThrow(/Unsafe config path segment/); - expectPrototypeClean(); - }); - it("allows legitimate dotted paths", () => { const doc: Record = {}; setConfigValue(doc, "agents.list[0].workspace", "/tmp/ws"); @@ -1552,11 +1558,5 @@ describe("commands/migration-state", () => { const list = agents.list as Record[]; expect(list[0].workspace).toBe("/tmp/ws"); }); - - it("allows simple top-level keys", () => { - const doc: Record = {}; - setConfigValue(doc, "theme", "dark"); - expect(doc.theme).toBe("dark"); - }); }); }); diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index 7335be304ae..6b03c0783f9 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -20,12 +20,22 @@ import path from "node:path"; import JSON5 from "json5"; import { create as createTar } from "tar"; import type { PluginLogger } from "../index.js"; -import { isSensitiveFile } from "../security/credential-filter.js"; +import { + CREDENTIAL_SENSITIVE_BASENAMES, + isSensitiveFile, + stripCredentials, +} from "../security/credential-filter.js"; import { sanitizeMigrationDirectory, sanitizeOpenClawConfigFile, } from "../security/snapshot-sanitizer.js"; import { isObjectRecord, type UnknownRecord } from "../shared/object-record.js"; +import { + decodeDescriptorSnapshotContent, + inspectDescriptorSnapshotRoot, + installDescriptorSnapshotFile, + scanDescriptorSnapshot, +} from "../shared/snapshot-sanitizer-boundary.cjs"; const SANDBOX_MIGRATION_DIR = "/sandbox/.nemoclaw/migration"; const SNAPSHOT_VERSION = 3; @@ -181,11 +191,7 @@ function resolveConfigPath(stateDir: string, env: NodeJS.ProcessEnv = process.en return path.join(stateDir, "openclaw.json"); } -function loadConfigDocument(configPath: string): OpenClawConfigDocument | null { - if (!existsSync(configPath)) { - return null; - } - const raw = readFileSync(configPath, "utf-8"); +function parseConfigDocumentText(raw: string, configPath: string): OpenClawConfigDocument { // Empty / whitespace-only openclaw.json — the upstream openshell-inference-set // truncate-then-write window can leave the file at 0 bytes (#3118). JSON5.parse // would throw "JSON5: invalid end of input at 1:1"; surface a recovery hint @@ -201,6 +207,13 @@ function loadConfigDocument(configPath: string): OpenClawConfigDocument | null { return parseConfigDocument(JSON5.parse(raw), `Config at ${configPath}`); } +function loadConfigDocument(configPath: string): OpenClawConfigDocument | null { + if (!existsSync(configPath)) { + return null; + } + return parseConfigDocumentText(readFileSync(configPath, "utf-8"), configPath); +} + function collectSymlinkPaths(rootPath: string): string[] { const symlinks: string[] = []; @@ -520,12 +533,18 @@ function computeFileDigest(filePath: string): string { function copyDirectory( sourcePath: string, destinationPath: string, - options?: { stripCredentials?: boolean }, + options?: { excludeSourcePaths?: ReadonlySet; stripCredentials?: boolean }, ): void { + const excludedSourcePaths = new Set( + [...(options?.excludeSourcePaths ?? [])].map((source) => normalizeHostPath(source)), + ); + const shouldFilter = options?.stripCredentials === true || excludedSourcePaths.size > 0; cpSync(sourcePath, destinationPath, { recursive: true, - filter: options?.stripCredentials - ? (source: string) => !isSensitiveFile(path.basename(source)) + filter: shouldFilter + ? (source: string) => + !excludedSourcePaths.has(normalizeHostPath(source)) && + (!options?.stripCredentials || !isSensitiveFile(path.basename(source))) : undefined, }); } @@ -588,6 +607,27 @@ function resolveConfigSourcePath(manifest: SnapshotManifest, snapshotDir: string return path.join(snapshotDir, "openclaw", "openclaw.json"); } +function loadCopiedConfigDocument(configPath: string): OpenClawConfigDocument { + const root = inspectDescriptorSnapshotRoot(path.dirname(configPath)); + if (root === null) { + throw new Error(`Failed to inspect copied OpenClaw config parent: ${configPath}`); + } + const scan = scanDescriptorSnapshot( + root, + CREDENTIAL_SENSITIVE_BASENAMES, + path.basename(configPath), + ); + const scanned = scan?.files[0]; + if (scan === null || scan.files.length !== 1 || scanned?.path !== path.basename(configPath)) { + throw new Error(`Failed descriptor-bound scan of copied OpenClaw config: ${configPath}`); + } + const raw = decodeDescriptorSnapshotContent(scanned.content); + if (raw === null) { + throw new Error(`Failed canonical decoding of copied OpenClaw config: ${configPath}`); + } + return parseConfigDocumentText(raw, configPath); +} + const UNSAFE_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); function isArrayIndexToken(token: string): boolean { @@ -664,10 +704,14 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s const preparedStateDir = path.join(snapshotDir, "sandbox-bundle", "openclaw"); rmSync(preparedStateDir, { recursive: true, force: true }); mkdirSync(path.dirname(preparedStateDir), { recursive: true }); - copyDirectory(path.join(snapshotDir, "openclaw"), preparedStateDir, { stripCredentials: true }); + const snapshotStateDir = path.join(snapshotDir, "openclaw"); + copyDirectory(snapshotStateDir, preparedStateDir, { + excludeSourcePaths: new Set([path.join(snapshotStateDir, "openclaw.json")]), + stripCredentials: true, + }); const configSourcePath = resolveConfigSourcePath(manifest, snapshotDir); - const config = existsSync(configSourcePath) ? (loadConfigDocument(configSourcePath) ?? {}) : {}; + const config = manifest.configPath === null ? {} : loadCopiedConfigDocument(configSourcePath); for (const root of manifest.externalRoots) { for (const binding of root.bindings) { @@ -679,8 +723,23 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s delete config["gateway"]; const configPath = path.join(preparedStateDir, "openclaw.json"); - writeFileSync(configPath, JSON.stringify(config, null, 2)); - chmodSync(configPath, 0o600); + const sanitizedConfig = stripCredentials(config); + if (!isObjectRecord(sanitizedConfig)) { + throw new Error(`Failed to sanitize prepared OpenClaw config in memory: ${configPath}`); + } + const preparedRoot = inspectDescriptorSnapshotRoot(preparedStateDir); + if ( + preparedRoot === null || + !installDescriptorSnapshotFile( + preparedRoot, + path.basename(configPath), + JSON.stringify(sanitizedConfig, null, 2), + ) + ) { + throw new Error( + `Failed descriptor-bound installation of prepared OpenClaw config: ${configPath}`, + ); + } // SECURITY: Strip all credentials from the bundle before it enters the sandbox. // Credentials must be injected at runtime via OpenShell's provider credential diff --git a/nemoclaw/src/security/credential-filter.test.ts b/nemoclaw/src/security/credential-filter.test.ts index a2466e512ff..dc82de8a7b1 100644 --- a/nemoclaw/src/security/credential-filter.test.ts +++ b/nemoclaw/src/security/credential-filter.test.ts @@ -97,6 +97,9 @@ describe("plugin credential-filter", () => { expect(valueLooksLikeSecret("sk-abcdefghijklmnopqrstuvwxyz")).toBe(true); expect(valueLooksLikeSecret("glpat-abcdefghijklmnopqrst")).toBe(true); expect(valueLooksLikeSecret("nvcf-abcdefghij")).toBe(true); + expect(valueLooksLikeSecret("GITHUB_TOKEN=opaque-secret-value-123")).toBe(true); + expect(valueLooksLikeSecret("apiKey=opaque-secret-value-123")).toBe(true); + expect(valueLooksLikeSecret("KEY=opaque-secret-value-123")).toBe(true); expect(valueLooksLikeSecret("not-a-secret")).toBe(false); }); diff --git a/nemoclaw/src/security/credential-filter.ts b/nemoclaw/src/security/credential-filter.ts index 99b5a4d5d44..5408f73f6f4 100644 --- a/nemoclaw/src/security/credential-filter.ts +++ b/nemoclaw/src/security/credential-filter.ts @@ -58,6 +58,18 @@ const SAFE_CREDENTIAL_PLACEHOLDER_LITERALS: ReadonlySet = new Set([ CREDENTIAL_PLACEHOLDER, ]); +/** + * Context-anchored secret shapes mirrored from + * src/lib/security/secret-patterns.ts. The plugin package cannot import + * src/lib at runtime, so a repository-level parity test pins this copy. + */ +export const CONTEXT_SECRET_PATTERNS: readonly RegExp[] = [ + /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/gi, + /(?<=(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)|(?:X[-_])?API[-_]KEY|TOKEN|SECRET|CREDENTIAL|PASSWORD|PASSWD|PASS)["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/gi, + /(?<=(?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/g, + /(?<=(?:^|[^A-Za-z0-9])KEY["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]{10,}/g, +]; + /** * High-confidence raw secret shapes used as a value-level backstop. * Kept aligned with TOKEN_PREFIX / STRUCTURED / SECRET_BLOCK patterns from @@ -83,8 +95,8 @@ const VALUE_SECRET_PATTERNS: readonly RegExp[] = [ /tvly-[A-Za-z0-9_-]{10,}/, /lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/, /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/, - /(?<=Bearer\s+)[A-Za-z0-9_.+/=-]{10,}/i, /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----/, + ...CONTEXT_SECRET_PATTERNS, ]; function hasPassCredentialSegment(key: string): boolean { @@ -115,7 +127,12 @@ export function isCredentialField(key: string): boolean { } export function valueLooksLikeSecret(value: string): boolean { - return VALUE_SECRET_PATTERNS.some((pattern) => pattern.test(value)); + return VALUE_SECRET_PATTERNS.some((pattern) => { + pattern.lastIndex = 0; + const matched = pattern.test(value); + pattern.lastIndex = 0; + return matched; + }); } export function isSafeCredentialPlaceholder(value: unknown): boolean { diff --git a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts index f6d44b9d89b..d8d50c5ae6b 100644 --- a/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts +++ b/nemoclaw/src/security/snapshot-sanitizer-failure.test.ts @@ -1,7 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + fstatSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +19,7 @@ import { applyDescriptorSnapshotActions, decodeDescriptorSnapshotContent, inspectDescriptorSnapshotRoot, + installDescriptorSnapshotFile, resolveTrustedSnapshotSanitizerPythonPath, type SnapshotFileIdentity, scanDescriptorSnapshot, @@ -18,6 +29,9 @@ import { sanitizeMigrationDirectory, sanitizeOpenClawConfigFile } from "./snapsh const roots: string[] = []; +const LARGE_INSTALL_CONTENT = "x".repeat(15 * 1024 * 1024); +const SHELL_WAIT_ATTEMPTS = 10_000; + function makeRoot(): string { const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-migration-sanitizer-failure-")); roots.push(root); @@ -28,6 +42,17 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } +function boundedShellWait(condition: string, pauseCommand = "sleep 0.001"): string[] { + return [ + " wait_attempt=0", + ` while ${condition}; do`, + " wait_attempt=$((wait_attempt + 1))", + ` [ "$wait_attempt" -lt ${String(SHELL_WAIT_ATTEMPTS)} ] || exit 1`, + ` ${pauseCommand}`, + " done", + ]; +} + function writePythonWrapper(lines: readonly string[]): string { const wrapperRoot = makeRoot(); const wrapper = path.join(wrapperRoot, "python3"); @@ -81,6 +106,131 @@ describe("migration snapshot sanitizer fallbacks", () => { ).toBe(false); }); + it("fails closed when the descriptor install helper is unavailable", () => { + const root = inspectDescriptorSnapshotRoot(makeRoot()); + expect(root).not.toBeNull(); + setSnapshotSanitizerPythonPathForTest(null); + + expect( + installDescriptorSnapshotFile(root as NonNullable, "openclaw.json", "{}"), + ).toBe(false); + }); + + it("rejects nested install targets before creating any entry", () => { + const rootPath = makeRoot(); + const root = inspectDescriptorSnapshotRoot(rootPath); + expect(root).not.toBeNull(); + + expect( + installDescriptorSnapshotFile(root as NonNullable, "nested/openclaw.json", "{}"), + ).toBe(false); + expect(() => statSync(path.join(rootPath, "nested", "openclaw.json"))).toThrow(); + }); + + it.runIf(process.platform !== "win32")( + "rejects a destination swap before exclusive config installation", + () => { + const rootPath = makeRoot(); + const outsideRoot = makeRoot(); + const outsideConfig = path.join(outsideRoot, "outside.json"); + const original = JSON.stringify({ mustRemain: true }); + writeFileSync(outsideConfig, original, { mode: 0o640 }); + const outsideConfigFd = openSync(outsideConfig, "r"); + try { + const originalMode = fstatSync(outsideConfigFd).mode & 0o777; + const root = inspectDescriptorSnapshotRoot(rootPath); + expect(root).not.toBeNull(); + const python = requireTrustedPython(); + writePythonWrapper([ + `if [ "\${4-}" = install ]; then ln -s ${shellQuote(outsideConfig)} ${shellQuote( + path.join(rootPath, "openclaw.json"), + )}; fi`, + `exec ${shellQuote(python)} "$@"`, + ]); + + expect( + installDescriptorSnapshotFile( + root as NonNullable, + "openclaw.json", + JSON.stringify({ installed: true }), + ), + ).toBe(false); + expect(readFileSync(outsideConfigFd, "utf-8")).toBe(original); + expect(fstatSync(outsideConfigFd).mode & 0o777).toBe(originalMode); + } finally { + closeSync(outsideConfigFd); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects a persistent hard link created while sanitized config is installed", + () => { + const rootPath = makeRoot(); + const targetPath = path.join(rootPath, "openclaw.json"); + const aliasPath = path.join(rootPath, "openclaw-alias.json"); + const root = inspectDescriptorSnapshotRoot(rootPath); + expect(root).not.toBeNull(); + const python = requireTrustedPython(); + writePythonWrapper([ + `if [ "\${4-}" = install ]; then`, + " (", + ...boundedShellWait(`[ ! -s ${shellQuote(targetPath)} ]`), + ` ln ${shellQuote(targetPath)} ${shellQuote(aliasPath)}`, + " ) &", + "fi", + `exec ${shellQuote(python)} "$@"`, + ]); + + expect( + installDescriptorSnapshotFile( + root as NonNullable, + "openclaw.json", + LARGE_INSTALL_CONTENT, + ), + ).toBe(false); + expect(() => statSync(targetPath)).toThrow(); + expect(statSync(aliasPath).isFile()).toBe(true); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects a transient hard-link mutation while sanitized config is installed", + () => { + const rootPath = makeRoot(); + const targetPath = path.join(rootPath, "openclaw.json"); + const aliasPath = path.join(rootPath, "openclaw-alias.json"); + const root = inspectDescriptorSnapshotRoot(rootPath); + expect(root).not.toBeNull(); + const python = requireTrustedPython(); + writePythonWrapper([ + `if [ "\${4-}" = install ]; then`, + " (", + ...boundedShellWait(`[ ! -s ${shellQuote(targetPath)} ]`), + ` ln ${shellQuote(targetPath)} ${shellQuote(aliasPath)}`, + ...boundedShellWait( + `[ "$(wc -c < ${shellQuote(aliasPath)})" -lt ${String(LARGE_INSTALL_CONTENT.length)} ]`, + "sleep 0.001", + ), + ` printf M | dd of=${shellQuote(aliasPath)} bs=1 count=1 conv=notrunc 2>/dev/null`, + ` rm ${shellQuote(aliasPath)}`, + " ) &", + "fi", + `exec ${shellQuote(python)} "$@"`, + ]); + + expect( + installDescriptorSnapshotFile( + root as NonNullable, + "openclaw.json", + LARGE_INSTALL_CONTENT, + ), + ).toBe(false); + expect(() => statSync(targetPath)).toThrow(); + expect(() => statSync(aliasPath)).toThrow(); + }, + ); + it("accepts only absolute helper substitutions under Vitest", () => { expect(() => setSnapshotSanitizerPythonPathForTest("python3")).toThrow( /test Python path must be absolute/u, diff --git a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts index 28a909e7301..27d8e4c6044 100644 --- a/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts +++ b/nemoclaw/src/shared/snapshot-sanitizer-boundary.cts @@ -119,6 +119,7 @@ export interface DescriptorSnapshotRoot { */ const SNAPSHOT_SANITIZER_PYTHON = String.raw` import base64 +import hashlib import json import os import secrets @@ -415,6 +416,87 @@ def create_staged_file(parent_fd): fail("snapshot staging file could not be created") +def install_file(root_path, plan): + root = plan.get("root") + target_name = validate_name(plan.get("name")) + raw = plan.get("content") + if not isinstance(root, dict) or not isinstance(raw, str): + fail("snapshot install plan is invalid") + try: + payload = base64.b64decode(raw, validate=True) + except ValueError: + fail("snapshot install content is invalid") + if len(payload) > MAX_FILE_BYTES: + fail("snapshot install content exceeds the size limit") + expected_digest = hashlib.sha256(payload).digest() + + root_fd = open_absolute_dir_no_follow(root_path) + target_fd = -1 + target_metadata = None + installed = False + try: + if not same_version(root, os.fstat(root_fd)): + fail("snapshot root changed before output was installed") + try: + os.stat(target_name, dir_fd=root_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + fail("snapshot install target already exists") + + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW | O_CLOEXEC + target_fd = os.open(target_name, flags, 0o600, dir_fd=root_fd) + opened = os.fstat(target_fd) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + fail("snapshot install target is unsafe") + target_metadata = metadata(opened) + + written = 0 + while written < len(payload): + written += os.write(target_fd, payload[written:]) + os.fchmod(target_fd, 0o600) + os.fsync(target_fd) + + verified_before = os.fstat(target_fd) + target_metadata = metadata(verified_before) + if ( + not stat.S_ISREG(verified_before.st_mode) + or verified_before.st_nlink != 1 + or verified_before.st_size != len(payload) + ): + fail("snapshot install target became unsafe while it was written") + + os.lseek(target_fd, 0, os.SEEK_SET) + observed_digest = hashlib.sha256() + remaining = len(payload) + while remaining: + chunk = os.read(target_fd, min(64 * 1024, remaining)) + if not chunk: + fail("snapshot install target became incomplete while it was verified") + observed_digest.update(chunk) + remaining -= len(chunk) + + verified_after = os.fstat(target_fd) + verified_metadata = metadata(verified_before) + target_metadata = metadata(verified_after) + if not same_version(verified_metadata, verified_after): + fail("snapshot install target changed while its content was verified") + if not secrets.compare_digest(observed_digest.digest(), expected_digest): + fail("snapshot install target content changed while it was written") + + current = os.stat(target_name, dir_fd=root_fd, follow_symlinks=False) + if not same_version(target_metadata, current): + fail("snapshot install target changed while it was written") + os.fsync(root_fd) + installed = True + finally: + if target_fd >= 0: + os.close(target_fd) + if not installed: + unlink_staged_if_owned(root_fd, target_name, target_metadata) + os.close(root_fd) + + def unlink_staged_if_owned(parent_fd, name, expected): if not name or expected is None: return @@ -540,6 +622,9 @@ def main(): if mode == "apply" and len(sys.argv) == 3: apply(root_path, read_plan()) return + if mode == "install" and len(sys.argv) == 3: + install_file(root_path, read_plan()) + return fail("snapshot sanitizer mode is invalid") @@ -682,6 +767,33 @@ export function applyDescriptorSnapshotActions( return result.status === 0 && !result.error; } +/** Create one direct child through a pinned directory descriptor without replacing an entry. */ +export function installDescriptorSnapshotFile( + root: DescriptorSnapshotRoot, + targetName: string, + content: string, +): boolean { + if (!isSafeRelativePath(targetName) || targetName.includes("/")) return false; + const pythonPath = snapshotSanitizerPythonPath(); + if (pythonPath === null) return false; + const result = spawnSync( + pythonPath, + ["-I", "-c", SNAPSHOT_SANITIZER_PYTHON, "install", root.canonicalPath], + { + encoding: "utf-8", + env: {}, + input: JSON.stringify({ + root: root.identity, + name: targetName, + content: Buffer.from(content, "utf-8").toString("base64"), + }), + maxBuffer: HELPER_MAX_BUFFER_BYTES, + timeout: HELPER_TIMEOUT_MS, + }, + ); + return result.status === 0 && !result.error; +} + /** Decode one helper payload and reject non-canonical base64 or invalid UTF-8. */ export function decodeDescriptorSnapshotContent(content: string | undefined): string | null { if ( diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 12e7ffacc94..a4cc994cf78 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -66,7 +66,7 @@ MAX_COPIED_BYTES_PER_PASS = 8 * 1024 * 1024 * 1024 MAX_GUARD_SECONDS = 10 * 60 PRODUCTION_FAIL_CLOSED_CONFIG_DIRS = frozenset( - {"/sandbox/.openclaw", "/sandbox/.hermes"} + {"/sandbox/.openclaw", "/sandbox/.hermes", "/sandbox/.deepagents"} ) OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock" # Keep this exact source/target contract aligned with @@ -1711,6 +1711,7 @@ def _run_guard_unserialized( normalized_config in PRODUCTION_FAIL_CLOSED_CONFIG_DIRS or os.environ.get("NEMOCLAW_TEST_OPENCLAW_FAIL_CLOSED") == "1" or os.environ.get("NEMOCLAW_TEST_HERMES_FAIL_CLOSED") == "1" + or os.environ.get("NEMOCLAW_TEST_DEEP_AGENTS_FAIL_CLOSED") == "1" ) config_fd = -1 try: diff --git a/src/lib/shields/auto-restore-target.test.ts b/src/lib/shields/auto-restore-target.test.ts new file mode 100644 index 00000000000..8a1ef4ed3e6 --- /dev/null +++ b/src/lib/shields/auto-restore-target.test.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { resolvePersistedAutoRestoreTarget } from "./index"; + +describe("persisted auto-restore target resolution", () => { + it("augments a legacy marker without agentName when registry paths still match (#8074)", () => { + const registryTarget = { + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json" as const, + sensitiveFiles: ["/sandbox/.openclaw/credentials.json"], + }; + + expect( + resolvePersistedAutoRestoreTarget( + "legacy-openclaw", + { + configPath: registryTarget.configPath, + configDir: registryTarget.configDir, + }, + () => registryTarget, + ), + ).toEqual({ + ...registryTarget, + sensitiveFiles: ["/sandbox/.openclaw/credentials.json", "/sandbox/.openclaw/.config-hash"], + }); + }); + + it("keeps legacy marker paths when the registry now describes another target (#8074)", () => { + const registryTarget = { + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json" as const, + }; + + expect( + resolvePersistedAutoRestoreTarget( + "legacy-target", + { + configPath: "/sandbox/.legacy/config.json", + configDir: "/sandbox/.legacy/", + }, + () => registryTarget, + ), + ).toEqual({ + configPath: "/sandbox/.legacy/config.json", + configDir: "/sandbox/.legacy/", + sensitiveFiles: ["/sandbox/.legacy/.config-hash"], + }); + }); + + it("keeps a named Hermes marker when the registry agent differs at the same paths (#8074)", () => { + const registryTarget = { + agentName: "openclaw", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes/", + configFile: "config.yaml", + format: "yaml" as const, + }; + + expect( + resolvePersistedAutoRestoreTarget( + "hermes", + { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes/", + }, + () => registryTarget, + ), + ).toEqual({ + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes/", + sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + }); + }); + + it("keeps legacy marker paths when registry resolution throws (#8074)", () => { + const failRegistryResolution = () => { + throw new Error("registry unavailable"); + }; + + expect( + resolvePersistedAutoRestoreTarget( + "legacy-target", + { + configPath: "/sandbox/.legacy/config.json", + configDir: "/sandbox/.legacy/", + }, + failRegistryResolution, + ), + ).toEqual({ + configPath: "/sandbox/.legacy/config.json", + configDir: "/sandbox/.legacy/", + sensitiveFiles: ["/sandbox/.legacy/.config-hash"], + }); + }); +}); diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 1fd6bc6f8aa..40e427aeab8 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -54,7 +54,7 @@ type HarnessOptions = { path: string; detail: string; }>; - fork?: () => { + fork?: (...args: unknown[]) => { pid: number; disconnect: () => void; unref: () => void; @@ -812,6 +812,7 @@ describe("shields command flow", () => { let observedPreparingDuringPolicy = false; let observedPreparingDuringUnlock = false; let authorizationSawMarker = false; + let timerArgs: string[] = []; const readOnlyTransition = () => { const transitionName = fs .readdirSync(stateDir) @@ -820,18 +821,21 @@ describe("shields command flow", () => { return JSON.parse(fs.readFileSync(path.join(stateDir, transitionName!), "utf-8")); }; const harness = createHarness({ - fork: () => ({ - pid: 4242, - disconnect: vi.fn(), - unref: vi.fn(), - send: vi.fn(() => { - authorizationSawMarker = fs.existsSync( - path.join(stateDir, "shields-timer-openclaw.json"), - ); - return true; - }), - kill: vi.fn(() => true), - }), + fork: (_modulePath, args) => { + timerArgs = args as string[]; + return { + pid: 4242, + disconnect: vi.fn(), + unref: vi.fn(), + send: vi.fn(() => { + authorizationSawMarker = fs.existsSync( + path.join(stateDir, "shields-timer-openclaw.json"), + ); + return true; + }), + kill: vi.fn(() => true), + }; + }, run: () => { observedPreparingDuringPolicy = readOnlyTransition().phase === "preparing"; return { status: 0 }; @@ -864,6 +868,7 @@ describe("shields command flow", () => { expect(observedPreparingDuringPolicy).toBe(true); expect(observedPreparingDuringUnlock).toBe(true); expect(authorizationSawMarker).toBe(true); + expect(timerArgs.at(9)).toBe("openclaw"); expect(transition).toMatchObject({ version: 1, phase: "active", @@ -872,6 +877,13 @@ describe("shields command flow", () => { snapshotPath: expect.stringContaining("policy-snapshot-"), }); expect(fs.existsSync(path.join(stateDir, "shields-timer-openclaw.json"))).toBe(true); + expect( + JSON.parse(fs.readFileSync(path.join(stateDir, "shields-timer-openclaw.json"), "utf-8")), + ).toMatchObject({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + }); }); it("shieldsUp refuses to mark lockdown active when the saved restrictive policy snapshot is missing", () => { diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index cd3110b8d3f..c00843929cb 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -73,6 +73,8 @@ const { }: typeof import("./timer-bound-lock") = require("./timer-bound-lock"); const { buildConfigHashRepairCommand, + buildDeepAgentsConfigLockCommand, + DEEP_AGENTS_CONFIG_LOCK_ERROR_PROTOCOL_PREFIX, parseSha256Output, isHashVerificationIssue, isSha256Hex, @@ -754,6 +756,31 @@ type AgentConfigTarget = { sensitiveFiles?: string[]; }; +const DEEP_AGENTS_NAME = "langchain-deepagents-code"; +const DEEP_AGENTS_CONFIG_DIR = "/sandbox/.deepagents"; +const DEEP_AGENTS_CONFIG_PATH = `${DEEP_AGENTS_CONFIG_DIR}/config.toml`; +const DEEP_AGENTS_CONFIG_HASH_PATH = `${DEEP_AGENTS_CONFIG_DIR}/.config-hash`; + +function isDeepAgentsTarget(target: AgentConfigTarget): boolean { + return target.agentName === DEEP_AGENTS_NAME; +} + +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 + ) { + throw new Error( + `Deep Agents shields require the canonical protected-file set under ${DEEP_AGENTS_CONFIG_DIR}`, + ); + } +} + function requiresProtectedSandboxParent(target: AgentConfigTarget): boolean { return ( target.configDir.startsWith("/sandbox/") && @@ -774,6 +801,38 @@ function ensureConfigHashSensitiveFile(target: T): return { ...target, sensitiveFiles: [...sensitiveFiles, hashPath] } as T; } +function resolvePersistedAutoRestoreTarget( + sandboxName: string, + marker: { agentName?: string; configPath?: string; configDir?: string }, + resolveConfig: (sandboxName: string) => AgentConfigTarget = resolveAgentConfig, +): AgentConfigTarget | undefined { + if (!marker.configPath || !marker.configDir) return undefined; + + const persistedTarget: AgentConfigTarget = { + ...(marker.agentName ? { agentName: marker.agentName } : {}), + configPath: marker.configPath, + configDir: marker.configDir, + sensitiveFiles: [ + configHashPath(marker.configDir), + ...(marker.agentName === "hermes" ? [`${marker.configDir.replace(/\/+$/, "")}/.env`] : []), + ], + }; + + try { + const resolved = ensureConfigHashSensitiveFile(resolveConfig(sandboxName)); + return (!marker.agentName || resolved.agentName === marker.agentName) && + resolved.configPath === marker.configPath && + resolved.configDir === marker.configDir + ? resolved + : persistedTarget; + } catch { + // The host-side timer marker is the recovery authority when the registry + // is unavailable. Keep the original target instead of silently selecting + // another agent's default configuration. + return persistedTarget; + } +} + const { DeferredShieldsExit }: typeof import("./deferred-exit") = require("./deferred-exit"); function failShieldsCommand(message: string, _shouldThrow?: boolean): never { @@ -1604,6 +1663,81 @@ function writeAbsentConfigHashNoSymlinkFollow( ); } +type DeepAgentsConfigLockFailureStatus = + | "config-root" + | "sandbox-parent" + | "incomplete" + | "rollback-failed" + | "transaction-failed"; + +const DEEP_AGENTS_CONFIG_LOCK_GENERIC_ERROR = "Deep Agents config lock transaction failed."; +const DEEP_AGENTS_CONFIG_LOCK_PROTOCOL_MAX_BYTES = 128; + +function parseDeepAgentsConfigLockFailure( + error: unknown, +): DeepAgentsConfigLockFailureStatus | null { + const stderr = (error as { stderr?: unknown } | null)?.stderr; + if (typeof stderr !== "string" && !Buffer.isBuffer(stderr)) return null; + + const byteLength = Buffer.isBuffer(stderr) ? stderr.length : Buffer.byteLength(stderr); + if (byteLength === 0 || byteLength > DEEP_AGENTS_CONFIG_LOCK_PROTOCOL_MAX_BYTES) return null; + + let line = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : stderr; + if (line.endsWith("\n")) line = line.slice(0, -1); + if (!line || line.includes("\n") || line.includes("\r")) return null; + + const prefix = `${DEEP_AGENTS_CONFIG_LOCK_ERROR_PROTOCOL_PREFIX}:`; + if (!line.startsWith(prefix)) return null; + const status = line.slice(prefix.length); + switch (status) { + case "config-root": + case "sandbox-parent": + case "incomplete": + case "rollback-failed": + case "transaction-failed": + return status; + default: + return null; + } +} + +function lockDeepAgentsTopConfig( + sandboxName: string, + target: AgentConfigTarget, + failClosedOnError: boolean, +): void { + assertCanonicalDeepAgentsTarget(target); + let outcome: string; + try { + outcome = privilegedSandboxExecCapture( + sandboxName, + buildDeepAgentsConfigLockCommand(target.configDir, target.configPath, failClosedOnError), + ); + } catch (error) { + const status = parseDeepAgentsConfigLockFailure(error); + if (status === "config-root") { + console.error( + " CRITICAL: Deep Agents lock failed after containment began. NemoClaw confirmed fail-closed containment at the config root. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=config-root", + ); + } else if (status === "sandbox-parent") { + console.error( + " CRITICAL: Deep Agents lock failed after containment began. NemoClaw confirmed fail-closed containment at the sandbox parent because NemoClaw could not confirm the complete config-root posture. In-sandbox recovery is unavailable. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=sandbox-parent", + ); + } else if (status === "incomplete") { + console.error( + " CRITICAL: Deep Agents lock failed after containment began, and NemoClaw could not confirm fail-closed containment. Do not retry or repair from inside the sandbox. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=incomplete", + ); + } else if (status === "rollback-failed") { + console.error( + " CRITICAL: Deep Agents config lock transaction could not restore its original posture. Restore this sandbox from a trusted snapshot or recreate it before retrying. rollback failed", + ); + } + throw new Error(DEEP_AGENTS_CONFIG_LOCK_GENERIC_ERROR); + } + if (outcome === "hash-created" || outcome === "hash-existing") return; + throw new Error("Deep Agents config lock returned an unexpected result."); +} + function legacyDataDirFor(configDir: string): string { return `${configDir}-data`; } @@ -2011,6 +2145,7 @@ function lockAgentConfigUnderMutationLock( const errors: string[] = []; const filesToLock = [target.configPath, ...(target.sensitiveFiles || [])]; const openClawProtocol = target.agentName === "openclaw"; + const deepAgentsProtocol = isDeepAgentsTarget(target); let transaction: { token: string; originalLocked: boolean; @@ -2018,13 +2153,19 @@ function lockAgentConfigUnderMutationLock( } | null = null; const legacyHermesProtocol = target.agentName === "hermes" && protocol === "legacy"; let openClawMutationStarted = false; + let deepAgentsLockSucceeded = false; let chattrSucceeded = target.agentName === "hermes" && !legacyHermesProtocol ? false : true; // Agents without a descriptor-sealed top-level transaction retain the - // historical validate-before-mutate ordering. OpenClaw and current Hermes - // must revoke writes to their canonical config first: otherwise an agent can - // plant one invalid nested entry and veto the auto-restore deadline forever. - if (!openClawProtocol && (target.agentName !== "hermes" || legacyHermesProtocol)) { + // historical validate-before-mutate ordering. OpenClaw, sealed Hermes, and + // Deep Agents must revoke writes to their canonical config first. Otherwise, + // an agent can plant one invalid nested entry and prevent the deadline from + // restoring Shields up. + if ( + !openClawProtocol && + !deepAgentsProtocol && + (target.agentName !== "hermes" || legacyHermesProtocol) + ) { const preflightIssues = preflightStateDirLock(stateDirLockExec(sandboxName), target.configDir); if (preflightIssues.length > 0) { throw new Error(`Config not locked: ${preflightIssues.join(", ")}`); @@ -2048,7 +2189,12 @@ function lockAgentConfigUnderMutationLock( } else if (legacyHermesProtocol) { transitionLegacyHermesConfig(sandboxName, target, "lock", filesToLock); } else if (target.agentName !== "hermes") { - writeAbsentConfigHashNoSymlinkFollow(sandboxName, target); + if (isDeepAgentsTarget(target)) { + lockDeepAgentsTopConfig(sandboxName, target, !rollbackLocked); + deepAgentsLockSucceeded = true; + } else { + writeAbsentConfigHashNoSymlinkFollow(sandboxName, target); + } for (const f of filesToLock) { try { privilegedSandboxExec(sandboxName, ["chmod", "444", f]); @@ -2232,6 +2378,37 @@ function lockAgentConfigUnderMutationLock( ); } } + } else if (deepAgentsLockSucceeded) { + const rollbackIssues: string[] = []; + if (rollbackLocked) { + try { + rollbackIssues.push( + ...restoreStateDirLockPosture(stateDirLockExec(sandboxName), target.configDir, true), + ); + } catch (rollbackError) { + rollbackIssues.push( + `state-directory rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + try { + rollbackIssues.push( + ...verifyShieldsLockState(sandboxName, target, { + verifyParentProtection: true, + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + }).issues, + ); + } catch (rollbackError) { + rollbackIssues.push( + `locked rollback verification failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + } + if (rollbackIssues.length > 0) { + console.error( + ` CRITICAL: Deep Agents lock rollback could not restore the trusted posture. Restore this sandbox from a trusted snapshot or recreate it before retrying. ${rollbackIssues.join(", ")}`, + ); + } } throw error; } @@ -2533,10 +2710,12 @@ function recoverExpiredAutoRestoreInline( } } + const cachedTarget = resolvePersistedAutoRestoreTarget(sandboxName, marker); const activation = activateLockdownFromSnapshot( sandboxName, marker.snapshotPath, marker.allowLegacyHermesProtocol === true, + cachedTarget, ); const nowIso = new Date().toISOString(); if (!activation.ok) { @@ -2755,6 +2934,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = opts.allowLegacyHermesProtocol === true ? "1" : "0", leaseOwnerPid === null ? "" : String(leaseOwnerPid), leaseOwnerStartIdentity ?? "", + target.agentName ?? "", ], { detached: true, @@ -2771,6 +2951,9 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = restoreAt: restoreAt.toISOString(), processToken, allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, + agentName: target.agentName, + configPath: target.configPath, + configDir: target.configDir, ...(leaseOwnerPid !== null && leaseOwnerStartIdentity ? { leaseOwnerPid, leaseOwnerStartIdentity } : {}), @@ -3443,6 +3626,7 @@ export { parseDuration, prepareAutoRestoreTransitionTakeover, repairMutableConfigPerms, + resolvePersistedAutoRestoreTarget, shieldsDown, shieldsStatus, shieldsUp, diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 50df05618fb..4bc819394b2 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -10,6 +10,20 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr const requireSource = createRequire(import.meta.url); const SHIELDS_MODULE = "./index.js"; +const DEEP_AGENTS_LOCK_ERROR_PREFIX = "NEMOCLAW_DEEP_AGENTS_CONFIG_LOCK_ERROR_V1"; +const DEEP_AGENTS_LOCK_GENERIC_ERROR = "Deep Agents config lock transaction failed."; + +function lockFailure(status: string): string { + return `${DEEP_AGENTS_LOCK_ERROR_PREFIX}:${status}\n`; +} + +function sandboxCommandFailure( + stderr: string | Buffer | undefined, + message = "sandbox command failed", + stdout: string | Buffer | undefined = undefined, +): Error { + return Object.assign(new Error(message), { stderr, stdout }); +} const TRANSITION_LOCK_MODULE = "./transition-lock.js"; describe("shields policy transition", () => { @@ -86,14 +100,24 @@ describe("shields config lock without a shipped config hash", () => { const CONFIG_DIR = "/sandbox/.deepagents"; 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 TIMER_PROCESS_KEY = ["number", "4242", "number", "0"].join("\0"); type SandboxEntry = { mode: string; owner: string }; + type SandboxCommandHandler = (args: string[], command: string[]) => string; let homeDir: string; let shields: typeof import("./index.js"); let entries: Map; - let repairCalls: string[][]; - let commandHandlers: Map string>; + let immutablePaths: Set; + let lockCalls: string[][]; + let unlockCalls: string[][]; + let stateDirGuardActions: string[]; + let applyStateDirLockModeSpy: MockInstance; + let restoreStateDirLockPostureSpy: MockInstance; + let resolveAgentConfigSpy: MockInstance; + let errorSpy: MockInstance; + let commandHandlers: Map; function target() { return { @@ -118,6 +142,90 @@ describe("shields config lock without a shipped config hash", () => { throw new Error(`unsupported sandbox command in fixture: ${command.join(" ")}`); } + function pythonCommandKey(command: string[]): string { + return command.slice(4, 6).join("\0"); + } + + function runConfigLock(command: string[]): string { + const hashCreated = !entries.has(HASH_PATH); + lockCalls.push(command); + entries.set("/sandbox", { mode: "1775", owner: "root:sandbox" }); + entries.set(CONFIG_DIR, { mode: "755", owner: "root:root" }); + entries.set(CONFIG_PATH, { mode: "444", owner: "root:root" }); + entries.set(HASH_PATH, { mode: "444", owner: "root:root" }); + return hashCreated ? "hash-created" : "hash-existing"; + } + + function runConfigUnlock(command: string[]): string { + unlockCalls.push(command); + entries.set("/sandbox", { mode: "755", owner: "sandbox:sandbox" }); + entries.set(CONFIG_DIR, { mode: "2770", owner: "sandbox:sandbox" }); + for (const pathname of command.slice(9)) { + entries.set(pathname, { mode: "660", owner: "sandbox:sandbox" }); + immutablePaths.delete(pathname); + } + return ""; + } + + const exactPythonFixtureHandlers = new Map string>([ + [LOCK_COMMAND_KEY, runConfigLock], + ]); + const leadingPythonFixtureHandlers = new Map string>([ + ["660", runConfigUnlock], + ]); + + function runPythonFixtureCommand(_args: string[], command: string[]): string { + const handler = + exactPythonFixtureHandlers.get(pythonCommandKey(command)) ?? + leadingPythonFixtureHandlers.get(String(command[4])) ?? + unsupportedCommand; + return handler(command); + } + + function rejectConfigLock(failure: Error): SandboxCommandHandler { + const exactHandlers = new Map(exactPythonFixtureHandlers); + exactHandlers.set(LOCK_COMMAND_KEY, () => { + throw failure; + }); + return (_args, command) => { + const handler = + exactHandlers.get(pythonCommandKey(command)) ?? + leadingPythonFixtureHandlers.get(String(command[4])) ?? + unsupportedCommand; + return handler(command); + }; + } + + function reportTimerProcessMissing(): never { + const error = new Error("timer is gone") as NodeJS.ErrnoException; + error.code = "ESRCH"; + throw error; + } + + function reportTimerProcessRunning(): true { + return true; + } + + const timerProcessHandlers = new Map true>([ + [TIMER_PROCESS_KEY, reportTimerProcessMissing], + ]); + + function reportMissingTimerProcess(pid: number, signal?: string | number): true { + const key = [typeof pid, String(pid), typeof signal, String(signal)].join("\0"); + const behavior = timerProcessHandlers.get(key) ?? reportTimerProcessRunning; + return behavior(); + } + + function makePathImmutable(pathname: string): void { + immutablePaths.add(pathname); + } + + function ignoreChattrOperation(_pathname: string): void {} + + const chattrOperationHandlers = new Map void>([ + ["+i", makePathImmutable], + ]); + function runSandboxCommand(cmd: string[]): string { const [head, ...rest] = cmd; const handler = commandHandlers.get(head) ?? unsupportedCommand(cmd); @@ -127,22 +235,17 @@ describe("shields config lock without a shipped config hash", () => { beforeEach(() => { homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-config-lock-")); vi.stubEnv("HOME", homeDir); - repairCalls = []; + lockCalls = []; + unlockCalls = []; + stateDirGuardActions = []; + immutablePaths = new Set(); entries = new Map([ ["/sandbox", { mode: "1775", owner: "root:sandbox" }], [CONFIG_DIR, { mode: "2770", owner: "sandbox:sandbox" }], [CONFIG_PATH, { mode: "660", owner: "sandbox:sandbox" }], ]); - commandHandlers = new Map string>([ - [ - "python3", - (_args, command) => { - repairCalls.push(command); - entries.set(CONFIG_DIR, { mode: "700", owner: "root:root" }); - entries.set(HASH_PATH, { mode: "600", owner: "sandbox:sandbox" }); - return ""; - }, - ], + commandHandlers = new Map([ + ["python3", runPythonFixtureCommand], [ "chmod", ([mode, pathname]) => { @@ -165,8 +268,11 @@ describe("shields config lock without a shipped config hash", () => { ], [ "chattr", - (_args, command) => { - requireEntry(String(command.at(-1)), "chattr"); + ([operation], command) => { + const pathname = String(command.at(-1)); + requireEntry(pathname, "chattr"); + const applyOperation = chattrOperationHandlers.get(operation) ?? ignoreChattrOperation; + applyOperation(pathname); return ""; }, ], @@ -175,7 +281,8 @@ describe("shields config lock without a shipped config hash", () => { (_args, command) => { const pathname = String(command.at(-1)); requireEntry(pathname, "lsattr"); - return `----i---------e----- ${pathname}`; + const flags = immutablePaths.has(pathname) ? "----i---------" : "--------------"; + return flags + " " + pathname; }, ], [ @@ -200,25 +307,76 @@ describe("shields config lock without a shipped config hash", () => { delete require.cache[requireSource.resolve(TRANSITION_LOCK_MODULE)]; const runner = requireSource("../runner.js"); - const sandboxConfig = requireSource("../sandbox/config.js"); + const agentConfig = requireSource("../sandbox/agent-config.js"); const privilegedExec = requireSource("../sandbox/privileged-exec.js"); const dockerExec = requireSource("../adapters/docker/exec.js"); const stateDirLock = requireSource("./state-dir-lock.js"); + const stateDirGuardCommandHandlers = new Map void>([ + ["test", () => undefined], + [ + "preflight", + () => { + stateDirGuardActions.push("preflight"); + }, + ], + [ + "lock", + () => { + stateDirGuardActions.push("lock"); + entries.set(CONFIG_DIR, { mode: "755", owner: "root:root" }); + }, + ], + [ + "unlock", + () => { + stateDirGuardActions.push("unlock"); + }, + ], + ]); vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); vi.spyOn(runner, "run").mockReturnValue({ status: 0 }); vi.spyOn(runner, "runCapture").mockReturnValue(""); - vi.spyOn(sandboxConfig, "resolveAgentConfig").mockImplementation(() => target()); + resolveAgentConfigSpy = vi + .spyOn(agentConfig, "resolveAgentConfig") + .mockImplementation(() => target()); vi.spyOn(privilegedExec, "privilegedSandboxExecArgv").mockImplementation( (_sandboxName: unknown, cmd: unknown) => cmd as string[], ); vi.spyOn(dockerExec, "dockerExecFileSync").mockImplementation((cmd: unknown) => runSandboxCommand(cmd as string[]), ); + vi.spyOn(dockerExec, "dockerSpawnSync").mockImplementation((rawCommand: unknown) => { + const command = Array.isArray(rawCommand) ? rawCommand.map(String) : []; + const action = (["preflight", "lock", "unlock"] as const).find((candidate) => + command.includes(candidate), + ); + const handler = + stateDirGuardCommandHandlers.get(String(action ?? command[0])) ?? + (() => unsupportedCommand(command)); + handler(); + + return { + status: 0, + signal: null, + stdout: + action === undefined + ? "" + : `${JSON.stringify({ + type: "result", + action, + status: "ok", + issueCount: 0, + })}\n`, + stderr: "", + pid: 0, + output: [], + } as never; + }); vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]); - vi.spyOn(stateDirLock, "applyStateDirLockMode").mockReturnValue([]); - vi.spyOn(stateDirLock, "restoreStateDirLockPosture").mockReturnValue([]); - vi.spyOn(console, "error").mockImplementation(() => undefined); + applyStateDirLockModeSpy = vi.spyOn(stateDirLock, "applyStateDirLockMode").mockReturnValue([]); + restoreStateDirLockPostureSpy = vi.spyOn(stateDirLock, "restoreStateDirLockPosture"); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); }); @@ -231,38 +389,203 @@ describe("shields config lock without a shipped config hash", () => { fs.rmSync(homeDir, { recursive: true, force: true }); }); - it("repairs the absent hash record before locking the protected files", () => { + it("fresh-seals an absent record before locking recursive state (#7977)", () => { const result = shields.lockAgentConfig("dcode-safety", target(), false); - expect(repairCalls).toHaveLength(1); - expect(repairCalls[0].slice(-2)).toEqual([CONFIG_DIR, CONFIG_PATH]); + expect(lockCalls).toHaveLength(1); + expect(lockCalls[0].slice(4)).toEqual([CONFIG_DIR, CONFIG_PATH, "--fail-closed-on-error"]); expect(entries.get(CONFIG_PATH)).toEqual({ mode: "444", owner: "root:root" }); expect(entries.get(HASH_PATH)).toEqual({ mode: "444", owner: "root:root" }); expect(Object.keys(result.fileHashes)).toEqual([CONFIG_PATH, HASH_PATH]); }); - it("leaves the config unlocked when the hash record cannot be repaired", () => { - const dockerExec = requireSource("../adapters/docker/exec.js"); - const injectedFailures = new Map string>([ - [ - "python3", - () => { - throw new Error("not a regular file: /sandbox/.deepagents/.config-hash"); - }, - ], - ]); - vi.spyOn(dockerExec, "dockerExecFileSync").mockImplementation((cmd: unknown) => { - const argv = cmd as string[]; - const execute = injectedFailures.get(argv[0]) ?? (() => runSandboxCommand(argv)); - return execute(); - }); + it.each([ + [ + "config-root", + " CRITICAL: Deep Agents lock failed after containment began. NemoClaw confirmed fail-closed containment at the config root. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=config-root", + ], + [ + "sandbox-parent", + " CRITICAL: Deep Agents lock failed after containment began. NemoClaw confirmed fail-closed containment at the sandbox parent because NemoClaw could not confirm the complete config-root posture. In-sandbox recovery is unavailable. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=sandbox-parent", + ], + [ + "incomplete", + " CRITICAL: Deep Agents lock failed after containment began, and NemoClaw could not confirm fail-closed containment. Do not retry or repair from inside the sandbox. Restore this sandbox from a trusted snapshot or recreate it before retrying. fail-closed containment=incomplete", + ], + [ + "rollback-failed", + " CRITICAL: Deep Agents config lock transaction could not restore its original posture. Restore this sandbox from a trusted snapshot or recreate it before retrying. rollback failed", + ], + ] as const)("maps the anchored %s child protocol to exact bounded guidance (#7995)", (status, expectedGuidance) => { + const stderr = + status === "sandbox-parent" ? Buffer.from(lockFailure(status), "utf8") : lockFailure(status); + commandHandlers.set( + "python3", + rejectConfigLock( + sandboxCommandFailure( + stderr, + `hostile argv marker ${lockFailure("incomplete")}`, + lockFailure("config-root"), + ), + ), + ); + + expect(() => shields.lockAgentConfig("dcode-safety", target(), false)).toThrow( + DEEP_AGENTS_LOCK_GENERIC_ERROR, + ); + expect(errorSpy).toHaveBeenCalledWith(expectedGuidance); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it("accepts transaction-failed without inventing a containment or rollback claim (#7995)", () => { + commandHandlers.set( + "python3", + rejectConfigLock(sandboxCommandFailure(lockFailure("transaction-failed"))), + ); + + expect(() => shields.lockAgentConfig("dcode-safety", target(), false)).toThrow( + DEEP_AGENTS_LOCK_GENERIC_ERROR, + ); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("CRITICAL")); + }); + + it("ignores markers in Error.message, stdout, unanchored stderr, and oversized stderr (#7995)", () => { + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = requireSource( + "./seal.js", + ) as typeof import("./seal.js"); + const hostileMessage = `python3 -I -c ${DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT} ${lockFailure("config-root")}`; + const failures = [ + sandboxCommandFailure(undefined, hostileMessage), + sandboxCommandFailure(undefined, "transport failed", lockFailure("config-root")), + sandboxCommandFailure(`untrusted preface ${lockFailure("config-root")}`, "transport failed"), + sandboxCommandFailure( + `${lockFailure("config-root")}untrusted trailing stderr`, + "transport failed", + ), + sandboxCommandFailure(`${"x".repeat(100_000)}${lockFailure("config-root")}`, hostileMessage), + ]; + + for (const failure of failures) { + errorSpy.mockClear(); + commandHandlers.set("python3", rejectConfigLock(failure)); + + let caught: unknown; + try { + shields.lockAgentConfig("dcode-safety", target(), false); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe(DEEP_AGENTS_LOCK_GENERIC_ERROR); + expect((caught as Error).message.length).toBeLessThan(128); + expect((caught as Error).message).not.toContain("python3"); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("CRITICAL")); + } + }); + + it("keeps the fresh seal when a nested entry blocks recursive containment (#7977)", () => { + applyStateDirLockModeSpy.mockReturnValueOnce(["injected recursive lock failure"]); expect(() => shields.lockAgentConfig("dcode-safety", target(), false)).toThrow( - /not a regular file/, + /injected recursive lock failure/, + ); + + expect(unlockCalls).toHaveLength(0); + expect(restoreStateDirLockPostureSpy).not.toHaveBeenCalled(); + expect(entries.get("/sandbox")).toEqual({ mode: "1775", owner: "root:sandbox" }); + expect(entries.get(CONFIG_DIR)).toEqual({ mode: "755", owner: "root:root" }); + expect(entries.get(CONFIG_PATH)).toEqual({ mode: "444", owner: "root:root" }); + expect(entries.get(HASH_PATH)).toEqual({ mode: "444", owner: "root:root" }); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("CRITICAL")); + }); + + it("restores and verifies the prior locked posture after a downstream failure (#7977)", () => { + entries.set(CONFIG_DIR, { mode: "755", owner: "root:root" }); + entries.set(CONFIG_PATH, { mode: "444", owner: "root:root" }); + entries.set(HASH_PATH, { mode: "444", owner: "root:root" }); + applyStateDirLockModeSpy.mockImplementationOnce(() => { + entries.set(CONFIG_DIR, { mode: "500", owner: "root:root" }); + return ["injected recursive lock failure"]; + }); + + expect(() => shields.lockAgentConfig("dcode-safety", target(), true)).toThrow( + /injected recursive lock failure/, + ); + + expect(lockCalls[0].slice(4)).toEqual([CONFIG_DIR, CONFIG_PATH]); + expect(unlockCalls).toHaveLength(0); + expect(restoreStateDirLockPostureSpy).toHaveBeenCalledWith(expect.anything(), CONFIG_DIR, true); + expect(stateDirGuardActions).toEqual(["preflight", "lock"]); + expect(entries.get(CONFIG_DIR)).toEqual({ mode: "755", owner: "root:root" }); + expect(entries.get(CONFIG_PATH)).toEqual({ mode: "444", owner: "root:root" }); + expect(entries.get(HASH_PATH)).toEqual({ mode: "444", owner: "root:root" }); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("CRITICAL")); + }); + + it.each([ + [ + "is unavailable", + () => { + throw new Error("registry unavailable"); + }, + ], + [ + "falls back to a changed OpenClaw target", + () => ({ + agentName: "openclaw", + configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + configPath: "/sandbox/.openclaw/openclaw.json", + format: "json", + }), + ], + ])("pins expired inline recovery to Deep Agents when the registry %s (#7995)", (_scenario, resolveTarget) => { + const sandboxName = "dcode-safety"; + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, "policy-snapshot-inline-recovery.yaml"); + const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), + shieldsDownTimeout: 60, + shieldsDownReason: "identity coverage", + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: 4242, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 30_000).toISOString(), + processToken: "7".repeat(32), + agentName: "langchain-deepagents-code", + configPath: CONFIG_PATH, + configDir: CONFIG_DIR, + }), + { mode: 0o600 }, + ); + vi.spyOn(process, "kill").mockImplementation(reportMissingTimerProcess); + resolveAgentConfigSpy.mockImplementation(resolveTarget); + + const posture = shields.getShieldsPosture(sandboxName, true); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf-8"), ); - expect(entries.get(CONFIG_PATH)).toEqual({ mode: "660", owner: "sandbox:sandbox" }); - expect(entries.has(HASH_PATH)).toBe(false); + expect(posture.mode).toBe("locked"); + expect(lockCalls).toHaveLength(2); + expect(lockCalls.every((command) => command[4] === CONFIG_DIR)).toBe(true); + expect(lockCalls.every((command) => command[5] === CONFIG_PATH)).toBe(true); + expect(Object.keys(state.fileHashes)).toEqual([CONFIG_PATH, HASH_PATH]); + expect(fs.existsSync(markerPath)).toBe(false); }); it("restores the managed sandbox parent when the config is unlocked", () => { diff --git a/src/lib/shields/seal.test.ts b/src/lib/shields/seal.test.ts index 63deb7811db..7060a40e1aa 100644 --- a/src/lib/shields/seal.test.ts +++ b/src/lib/shields/seal.test.ts @@ -14,6 +14,20 @@ async function loadSeal(): Promise { return import("./seal"); } +const EFFECTIVE_UID = process.geteuid?.() ?? os.userInfo().uid; +const EFFECTIVE_GID = process.getegid?.() ?? os.userInfo().gid; + +const DEEP_AGENTS_LOCK_ERROR_PREFIX = "NEMOCLAW_DEEP_AGENTS_CONFIG_LOCK_ERROR_V1"; +type DeepAgentsLockFailure = + | "config-root" + | "sandbox-parent" + | "incomplete" + | "rollback-failed" + | "transaction-failed"; + +function lockFailure(status: DeepAgentsLockFailure): string { + return `${DEEP_AGENTS_LOCK_ERROR_PREFIX}:${status}`; +} const CONFIG_BODY = 'model = "nvidia/nemotron"\n'; const EXPECTED_RECORD = `${createHash("sha256").update(CONFIG_BODY).digest("hex")} config.toml\n`; const fixtures: string[] = []; @@ -361,3 +375,616 @@ exec(compile(source, "", "exec"), {"__name__": "__main__"}) expect(fs.existsSync(hashRecordPath(configDir))).toBe(false); }); }); + +describe("buildDeepAgentsConfigLockCommand", () => { + afterEach(() => { + while (fixtures.length > 0) { + fs.rmSync(fixtures.pop() as string, { recursive: true, force: true }); + } + }); + + async function lockCommand(configDir: string, failClosedOnError = false): Promise { + const { buildDeepAgentsConfigLockCommand } = await loadSeal(); + return buildDeepAgentsConfigLockCommand( + configDir, + path.join(configDir, "config.toml"), + failClosedOnError, + ); + } + + function expectFailClosedPosture(configDir: string): void { + const configDirStat = fs.statSync(configDir); + const parentDirStat = fs.statSync(path.dirname(configDir)); + expect(configDirStat.mode & 0o7777).toBe(0o500); + expect(configDirStat.uid).toBe(EFFECTIVE_UID); + expect(configDirStat.gid).toBe(EFFECTIVE_GID); + expect(parentDirStat.mode & 0o7777).toBe(0o1775); + expect(parentDirStat.uid).toBe(EFFECTIVE_UID); + expect(parentDirStat.gid).toBe(EFFECTIVE_GID); + } + + function restoreFixtureAccess(configDir: string): void { + fs.chmodSync(configDir, 0o700); + fs.chmodSync(path.dirname(configDir), 0o700); + } + + function runLock(command: string[], inheritedFd?: number) { + const [binary, ...args] = command; + const result = spawnSync(binary, args, { + encoding: "utf-8", + ...(inheritedFd === undefined ? {} : { stdio: ["ignore", "pipe", "pipe", inheritedFd] }), + }); + ifError(result.error); + return { + status: result.status, + stdout: String(result.stdout ?? "").trim(), + stderr: String(result.stderr ?? "").trim(), + }; + } + type FileObservation = { body: Buffer; inode: number; mode: number }; + + function observeFile(pathname: string): FileObservation { + const fd = fs.openSync(pathname, "r"); + try { + const stat = fs.fstatSync(fd); + return { + body: fs.readFileSync(fd), + inode: stat.ino, + mode: stat.mode & 0o7777, + }; + } finally { + fs.closeSync(fd); + } + } + + function injectedScript(source: string, body: string): string { + const encoded = Buffer.from(source, "utf-8").toString("base64"); + return String.raw` +import base64 +import os + +source = base64.b64decode("${encoded}").decode("utf-8") +${body} +exec(compile(source, "", "exec"), {"__name__": "__main__"}) +`; + } + + it("fresh-replaces the config and record from one snapshot despite a retained writable descriptor (#7977)", async () => { + const configDir = makeConfigDir(); + const configPath = path.join(configDir, "config.toml"); + const recordPath = hashRecordPath(configDir); + const retainedFd = fs.openSync(configPath, "r+"); + const oldInode = fs.fstatSync(retainedFd).ino; + const command = await lockCommand(configDir); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_replace = os.replace +mutated = {"done": False} +def raced_replace(src, dst, *args, **kwargs): + result = real_replace(src, dst, *args, **kwargs) + if dst == "config.toml" and not mutated["done"]: + mutated["done"] = True + os.lseek(3, 0, os.SEEK_SET) + os.write(3, b'retained-fd-mutation') + os.fsync(3) + return result +os.replace = raced_replace +`, + ); + + const outcome = runLock(command, retainedFd); + fs.closeSync(retainedFd); + + expect(outcome).toEqual({ status: 0, stdout: "hash-created", stderr: "" }); + const config = observeFile(configPath); + const record = observeFile(recordPath); + expect(config.inode).not.toBe(oldInode); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(record.body.toString("utf-8")).toBe( + `${createHash("sha256").update(config.body).digest("hex")} config.toml\n`, + ); + expect(config.mode).toBe(0o444); + expect(record.mode).toBe(0o444); + }); + + type FixtureVerification = () => void; + + function prepareMissingConfigRoot(configDir: string): FixtureVerification { + return () => { + expect(fs.existsSync(configDir)).toBe(false); + }; + } + + function prepareSymlinkConfigRoot(configDir: string): FixtureVerification { + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-root-target-")); + fixtures.push(outsideRoot); + const externalPath = path.join(outsideRoot, "outside-config"); + const externalBody = "outside config root\n"; + fs.writeFileSync(externalPath, externalBody, { mode: 0o640 }); + fs.chmodSync(externalPath, 0o640); + const initialExternal = observeFile(externalPath); + fs.symlinkSync(externalPath, configDir); + + return () => { + const external = observeFile(externalPath); + expect(fs.lstatSync(configDir).isSymbolicLink()).toBe(true); + expect(external.body.toString("utf-8")).toBe(externalBody); + expect(external.inode).toBe(initialExternal.inode); + expect(external.mode).toBe(initialExternal.mode); + }; + } + + function prepareNonDirectoryConfigRoot(configDir: string): FixtureVerification { + const invalidRootBody = "sandbox-owned invalid config root\n"; + fs.writeFileSync(configDir, invalidRootBody, { mode: 0o660 }); + fs.chmodSync(configDir, 0o660); + const initialInvalidRoot = observeFile(configDir); + + return () => { + const invalidRoot = observeFile(configDir); + expect(invalidRoot.body.toString("utf-8")).toBe(invalidRootBody); + expect(invalidRoot.inode).toBe(initialInvalidRoot.inode); + expect(invalidRoot.mode).toBe(initialInvalidRoot.mode); + }; + } + + function prepareLinkedRecord( + recordPath: string, + parentDir: string, + recordKind: "symlink" | "hardlink", + linkRecord: (outsidePath: string, recordPath: string) => void, + ): FixtureVerification { + const outsidePath = path.join(parentDir, `outside-${recordKind}`); + const outsideBody = `outside ${recordKind}\n`; + fs.writeFileSync(outsidePath, outsideBody, { mode: 0o640 }); + fs.chmodSync(outsidePath, 0o640); + const initialOutside = observeFile(outsidePath); + linkRecord(outsidePath, recordPath); + + return () => { + const outside = observeFile(outsidePath); + expect(outside.body.toString("utf-8")).toBe(outsideBody); + expect(outside.inode).toBe(initialOutside.inode); + expect(outside.mode).toBe(initialOutside.mode); + }; + } + + function prepareSymlinkRecord(recordPath: string, parentDir: string): FixtureVerification { + return prepareLinkedRecord(recordPath, parentDir, "symlink", (outsidePath, pathname) => + fs.symlinkSync(outsidePath, pathname), + ); + } + + function prepareHardlinkRecord(recordPath: string, parentDir: string): FixtureVerification { + return prepareLinkedRecord(recordPath, parentDir, "hardlink", (outsidePath, pathname) => + fs.linkSync(outsidePath, pathname), + ); + } + + function noFixtureVerification(): void {} + + function prepareNonregularRecord(recordPath: string, _parentDir: string): FixtureVerification { + fs.mkdirSync(recordPath); + return noFixtureVerification; + } + + function prepareOversizedRecord(recordPath: string, _parentDir: string): FixtureVerification { + fs.writeFileSync(recordPath, Buffer.alloc(1025, "a"), { mode: 0o660 }); + return noFixtureVerification; + } + + function expectCanonicalRecordPosture( + configDir: string, + recordPath: string, + _parentDir: string, + ): void { + const record = observeFile(recordPath); + expect(record.body.toString("utf-8")).toBe(EXPECTED_RECORD); + expect(record.mode).toBe(0o444); + expectFailClosedPosture(configDir); + } + + function expectNonregularRecordPosture( + configDir: string, + recordPath: string, + parentDir: string, + ): void { + expect(fs.lstatSync(recordPath).isDirectory()).toBe(true); + expect(fs.statSync(configDir).mode & 0o7777).toBe(0o500); + expect(fs.statSync(parentDir).mode & 0o7777).toBe(0o700); + } + + it.each([ + ["missing", prepareMissingConfigRoot], + ["symlink", prepareSymlinkConfigRoot], + ["non-directory", prepareNonDirectoryConfigRoot], + ] as const)("contains a %s config root before it can be pinned (#7977)", async (_rootKind, prepareRoot) => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + fs.rmSync(configDir, { recursive: true, force: true }); + fs.chmodSync(parentDir, 0o1775); + const verifyRoot = prepareRoot(configDir); + + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const outcome = runLock(command); + + try { + expect(outcome.status).toBe(1); + expect(outcome.stderr).toBe(lockFailure("sandbox-parent")); + const parentStat = fs.statSync(parentDir); + expect(parentStat.mode & 0o7777).toBe(0o700); + expect(parentStat.uid).toBe(EFFECTIVE_UID); + expect(parentStat.gid).toBe(EFFECTIVE_GID); + verifyRoot(); + } finally { + fs.chmodSync(parentDir, 0o700); + } + }); + + it("uses the sandbox parent when the config root cannot be clamped (#7977)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + fs.writeFileSync(hashRecordPath(configDir), `${"0".repeat(64)} config.toml\n`, { + mode: 0o660, + }); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_fchmod = os.fchmod +def failed_config_dir_clamp(fd, mode): + if mode == 0o500: + raise OSError("injected config dir clamp failure") + return real_fchmod(fd, mode) +os.fchmod = failed_config_dir_clamp +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome.status).toBe(1); + expect(outcome.stderr).toBe(lockFailure("sandbox-parent")); + const configDirStat = fs.statSync(configDir); + const parentDirStat = fs.statSync(parentDir); + expect(configDirStat.mode & 0o7777).toBe(0o700); + expect(configDirStat.uid).toBe(EFFECTIVE_UID); + expect(configDirStat.gid).toBe(EFFECTIVE_GID); + expect(parentDirStat.mode & 0o7777).toBe(0o700); + expect(parentDirStat.uid).toBe(EFFECTIVE_UID); + expect(parentDirStat.gid).toBe(EFFECTIVE_GID); + } finally { + restoreFixtureAccess(configDir); + } + }); + + it("reports incomplete containment when no parent posture can be confirmed (#7977)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_fchmod = os.fchmod +def failed_parent_posture(fd, mode): + if mode in (0o700, 0o1775): + raise OSError("injected parent posture failure") + return real_fchmod(fd, mode) +os.fchmod = failed_parent_posture +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome.status).toBe(1); + expect(outcome.stderr).toBe(lockFailure("incomplete")); + const configDirStat = fs.statSync(configDir); + const parentDirStat = fs.statSync(parentDir); + expect(configDirStat.mode & 0o7777).toBe(0o500); + expect(configDirStat.uid).toBe(EFFECTIVE_UID); + expect(configDirStat.gid).toBe(EFFECTIVE_GID); + expect(parentDirStat.mode & 0o7777).toBe(0o755); + expect(parentDirStat.uid).toBe(EFFECTIVE_UID); + expect(parentDirStat.gid).toBe(EFFECTIVE_GID); + } finally { + restoreFixtureAccess(configDir); + } + }); + + it.each([ + "stale", + "malformed", + ])("fresh-replaces a %s record and revokes retained canonical descriptors (#7995)", async (recordKind) => { + const configDir = makeConfigDir(); + const configPath = path.join(configDir, "config.toml"); + const recordPath = hashRecordPath(configDir); + const body = recordKind === "stale" ? `${"0".repeat(64)} config.toml\n` : "not-a-hash\n"; + fs.writeFileSync(recordPath, body, { mode: 0o660 }); + const retainedConfigFd = fs.openSync(configPath, "r+"); + const retainedRecordFd = fs.openSync(recordPath, "r+"); + const oldConfigInode = fs.fstatSync(retainedConfigFd).ino; + const oldRecordInode = fs.fstatSync(retainedRecordFd).ino; + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + + const outcome = runLock(command); + + try { + expect(outcome).toEqual({ + status: 1, + stdout: "", + stderr: lockFailure("config-root"), + }); + const installedConfig = observeFile(configPath); + const installedRecord = observeFile(recordPath); + expect(installedConfig.inode).not.toBe(oldConfigInode); + expect(installedRecord.inode).not.toBe(oldRecordInode); + + fs.writeSync(retainedConfigFd, "retained-config-write", 0, "utf8"); + fs.fsyncSync(retainedConfigFd); + fs.writeSync(retainedRecordFd, "retained-record-write", 0, "utf8"); + fs.fsyncSync(retainedRecordFd); + + const config = observeFile(configPath); + const record = observeFile(recordPath); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(record.body.toString("utf-8")).toBe(EXPECTED_RECORD); + expect(config.inode).toBe(installedConfig.inode); + expect(record.inode).toBe(installedRecord.inode); + expect(config.mode).toBe(0o444); + expect(record.mode).toBe(0o444); + expectFailClosedPosture(configDir); + } finally { + fs.closeSync(retainedConfigFd); + fs.closeSync(retainedRecordFd); + restoreFixtureAccess(configDir); + } + }); + + it.each([ + ["symlink", "config-root", prepareSymlinkRecord, expectCanonicalRecordPosture], + ["hardlink", "config-root", prepareHardlinkRecord, expectCanonicalRecordPosture], + ["nonregular", "sandbox-parent", prepareNonregularRecord, expectNonregularRecordPosture], + ["oversize", "config-root", prepareOversizedRecord, expectCanonicalRecordPosture], + ] as const)("claims config-root for a %s record only after installing a fresh canonical pair (#7995)", async (_recordKind, expectedStatus, prepareRecord, verifyRecordPosture) => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + const recordPath = hashRecordPath(configDir); + const configPath = path.join(configDir, "config.toml"); + const oldConfig = observeFile(configPath); + const verifyFixture = prepareRecord(recordPath, parentDir); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + + const outcome = runLock(command); + + try { + expect(outcome).toEqual({ + status: 1, + stdout: "", + stderr: lockFailure(expectedStatus), + }); + const config = observeFile(configPath); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(config.inode).not.toBe(oldConfig.inode); + expect(config.mode).toBe(0o444); + verifyRecordPosture(configDir, recordPath, parentDir); + verifyFixture(); + } finally { + restoreFixtureAccess(configDir); + } + }); + + it("contains a staging-body failure after freezing mutable state (#7977)", async () => { + const configDir = makeConfigDir(); + const configPath = path.join(configDir, "config.toml"); + const oldConfig = observeFile(configPath); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_write = os.write +state = {"write_failed": False} +def failed_write(fd, data): + if not state["write_failed"]: + state["write_failed"] = True + raise OSError("injected staging write failure") + return real_write(fd, data) +os.write = failed_write +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome).toEqual({ status: 1, stdout: "", stderr: lockFailure("config-root") }); + const config = observeFile(configPath); + const record = observeFile(hashRecordPath(configDir)); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(config.inode).not.toBe(oldConfig.inode); + expect(config.mode).toBe(0o444); + expect(record.body.toString("utf-8")).toBe(EXPECTED_RECORD); + expect(record.mode).toBe(0o444); + expect(fs.readdirSync(configDir).filter((name) => name.includes(".nemoclaw."))).toEqual([]); + expectFailClosedPosture(configDir); + } finally { + restoreFixtureAccess(configDir); + } + }); + + it("uses sandbox-parent when the canonical pair cannot be freshly installed (#7995)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + const configPath = path.join(configDir, "config.toml"); + const oldConfig = observeFile(configPath); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +def failed_write(_fd, _data): + raise OSError("injected persistent staging failure") +os.write = failed_write +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome).toEqual({ + status: 1, + stdout: "", + stderr: lockFailure("sandbox-parent"), + }); + const config = observeFile(configPath); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(config.inode).toBe(oldConfig.inode); + expect(config.mode).toBe(oldConfig.mode); + expect(fs.existsSync(hashRecordPath(configDir))).toBe(false); + expect(fs.statSync(configDir).mode & 0o7777).toBe(0o500); + expect(fs.statSync(parentDir).mode & 0o7777).toBe(0o700); + } finally { + restoreFixtureAccess(configDir); + } + }); + + it("restores both original paths when the record cutover fails (#7977)", async () => { + const configDir = makeConfigDir(); + const configPath = path.join(configDir, "config.toml"); + const oldConfig = observeFile(configPath); + const command = await lockCommand(configDir); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_replace = os.replace +def failed_record_replace(src, dst, *args, **kwargs): + if dst == ".config-hash": + raise OSError("injected record cutover failure") + return real_replace(src, dst, *args, **kwargs) +os.replace = failed_record_replace +`, + ); + + const outcome = runLock(command); + + expect(outcome.status).toBe(1); + expect(outcome.stderr).toBe(lockFailure("transaction-failed")); + const config = observeFile(configPath); + expect(config.body.toString("utf-8")).toBe(CONFIG_BODY); + expect(fs.existsSync(hashRecordPath(configDir))).toBe(false); + expect(config.inode).not.toBe(oldConfig.inode); + expect(config.mode).toBe(oldConfig.mode); + expect(fs.readdirSync(configDir).filter((name) => name.includes(".nemoclaw."))).toEqual([]); + }); + + it("reports when a failed cutover cannot restore the original config (#7977)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + const command = await lockCommand(configDir); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_replace = os.replace +state = {"config_replaced": False} +def failed_replace(src, dst, *args, **kwargs): + if dst == "config.toml": + if state["config_replaced"]: + raise OSError("injected config rollback failure") + state["config_replaced"] = True + return real_replace(src, dst, *args, **kwargs) + if dst == ".config-hash": + raise OSError("injected record cutover failure") + return real_replace(src, dst, *args, **kwargs) +os.replace = failed_replace +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome.status).toBe(1); + expect(outcome.stderr).toBe(lockFailure("rollback-failed")); + const configDirStat = fs.statSync(configDir); + const parentDirStat = fs.statSync(parentDir); + expect(configDirStat.mode & 0o7777).toBe(0o500); + expect(configDirStat.uid).toBe(EFFECTIVE_UID); + expect(configDirStat.gid).toBe(EFFECTIVE_GID); + expect(parentDirStat.mode & 0o7777).toBe(0o1775); + expect(parentDirStat.uid).toBe(EFFECTIVE_UID); + expect(parentDirStat.gid).toBe(EFFECTIVE_GID); + } finally { + fs.chmodSync(configDir, 0o700); + fs.chmodSync(parentDir, 0o700); + } + }); + + it("fails closed when a staging write and its cleanup both fail (#7977)", async () => { + const configDir = makeConfigDir(); + const parentDir = path.dirname(configDir); + const command = await lockCommand(configDir, true); + command.push("--test-protect-parent"); + const { DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT } = await loadSeal(); + command[3] = injectedScript( + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + String.raw` +real_write = os.write +real_unlink = os.unlink +state = {"write_failed": False, "cleanup_failed": False} +def failed_write(fd, data): + if not state["write_failed"]: + state["write_failed"] = True + raise OSError("injected staging write failure") + return real_write(fd, data) +def failed_unlink(path, *args, **kwargs): + if ".nemoclaw." in path and state["write_failed"] and not state["cleanup_failed"]: + state["cleanup_failed"] = True + raise OSError("injected staging cleanup failure") + return real_unlink(path, *args, **kwargs) +os.write = failed_write +os.unlink = failed_unlink +`, + ); + + const outcome = runLock(command); + + try { + expect(outcome).toEqual({ status: 1, stdout: "", stderr: lockFailure("config-root") }); + expect(fs.readFileSync(path.join(configDir, "config.toml"), "utf-8")).toBe(CONFIG_BODY); + expect(fs.statSync(path.join(configDir, "config.toml")).mode & 0o7777).toBe(0o444); + expect(fs.readFileSync(hashRecordPath(configDir), "utf-8")).toBe(EXPECTED_RECORD); + expect(fs.statSync(hashRecordPath(configDir)).mode & 0o7777).toBe(0o444); + const configDirStat = fs.statSync(configDir); + const parentDirStat = fs.statSync(parentDir); + expect(configDirStat.mode & 0o7777).toBe(0o500); + expect(configDirStat.uid).toBe(EFFECTIVE_UID); + expect(configDirStat.gid).toBe(EFFECTIVE_GID); + expect(parentDirStat.mode & 0o7777).toBe(0o1775); + expect(parentDirStat.uid).toBe(EFFECTIVE_UID); + expect(parentDirStat.gid).toBe(EFFECTIVE_GID); + const stagingArtifacts = fs + .readdirSync(configDir) + .filter((name) => name.includes(".nemoclaw.")); + expect(stagingArtifacts).toHaveLength(1); + expect(stagingArtifacts[0]).toMatch(/^\.config\.toml\.nemoclaw\.\d+\.[0-9a-f]+$/); + expect(fs.lstatSync(path.join(configDir, stagingArtifacts[0])).isFile()).toBe(true); + expect(fs.readdirSync(parentDir).filter((name) => name.includes(".nemoclaw."))).toEqual([]); + } finally { + fs.chmodSync(configDir, 0o700); + fs.chmodSync(parentDir, 0o700); + } + }); +}); diff --git a/src/lib/shields/seal.ts b/src/lib/shields/seal.ts index 99a91c2d4f1..3759b05aa42 100644 --- a/src/lib/shields/seal.ts +++ b/src/lib/shields/seal.ts @@ -237,10 +237,24 @@ try: os.fchown(dir_fd, os.geteuid(), os.getegid()) os.fchmod(dir_fd, 0o755) os.fsync(dir_fd) + locked_dir = os.fstat(dir_fd) + if ( + locked_dir.st_uid != os.geteuid() + or locked_dir.st_gid != os.getegid() + or stat.S_IMODE(locked_dir.st_mode) != 0o755 + ): + die("replacement metadata mismatch for " + config_dir) if protect_parent: os.fchown(parent_fd, os.geteuid(), sandbox_gid) os.fchmod(parent_fd, 0o1775) os.fsync(parent_fd) + locked_parent = os.fstat(parent_fd) + if ( + locked_parent.st_uid != os.geteuid() + or locked_parent.st_gid != sandbox_gid + or stat.S_IMODE(locked_parent.st_mode) != 0o1775 + ): + die("replacement metadata mismatch for " + parent_dir) except BaseException as exc: body_error = exc finally: @@ -274,3 +288,603 @@ if body_error is not None: export function buildConfigHashRepairCommand(configDir: string, configPath: string): string[] { return ["python3", "-I", "-c", CONFIG_HASH_REPAIR_NOFOLLOW_SCRIPT, configDir, configPath]; } + +// Deep Agents uses a separate fresh-inode lock transaction below; the generic +// absent-record repair above remains unchanged for every other agent. +export const DEEP_AGENTS_CONFIG_LOCK_ERROR_PROTOCOL_PREFIX = + "NEMOCLAW_DEEP_AGENTS_CONFIG_LOCK_ERROR_V1"; + +export const DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT = String.raw` +import fcntl +import grp +import hashlib +import os +import secrets +import stat +import struct +import sys + +HASH_NAME = ".config-hash" +MAX_CONFIG_BYTES = 16 * 1024 * 1024 +MAX_HASH_BYTES = 1024 +FS_IMMUTABLE_FL = 0x00000010 +FS_IOC_GETFLAGS = 0x80086601 +FS_IOC_SETFLAGS = 0x40086602 +ERROR_PROTOCOL_PREFIX = ${JSON.stringify(DEEP_AGENTS_CONFIG_LOCK_ERROR_PROTOCOL_PREFIX)} + +def die(message): + raise RuntimeError(message) + +def required_flag(name): + value = getattr(os, name, None) + if not isinstance(value, int) or value == 0: + die("required open flag is unavailable: " + name) + return value + +O_NOFOLLOW = required_flag("O_NOFOLLOW") + +def open_checked(path, want_dir, dir_fd=None): + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | O_NOFOLLOW + flags |= getattr(os, "O_DIRECTORY", 0) if want_dir else getattr(os, "O_NONBLOCK", 0) + try: + fd = os.open(path, flags, dir_fd=dir_fd) + except OSError as exc: + die("open failed for %s: %s" % (path, exc)) + opened = os.fstat(fd) + if want_dir and not stat.S_ISDIR(opened.st_mode): + os.close(fd) + die("not a directory: " + path) + if not want_dir and not stat.S_ISREG(opened.st_mode): + os.close(fd) + die("not a regular file: " + path) + if not want_dir and opened.st_nlink != 1: + os.close(fd) + die("refusing multiply linked file: " + path) + return fd, opened + +def open_optional_file(name, dir_fd): + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + | O_NOFOLLOW + ) + try: + fd = os.open(name, flags, dir_fd=dir_fd) + except FileNotFoundError: + return None, None + except OSError as exc: + die("open failed for %s: %s" % (name, exc)) + opened = os.fstat(fd) + if not stat.S_ISREG(opened.st_mode): + os.close(fd) + die("not a regular file: " + name) + if opened.st_nlink != 1: + os.close(fd) + die("refusing multiply linked file: " + name) + return fd, opened + +def child_name(config_dir, path): + normalized = os.path.normpath(path) + if os.path.dirname(normalized) != config_dir: + die("refusing config path outside config dir: " + path) + name = os.path.basename(normalized) + if name in ("", ".", ".."): + die("refusing invalid config path: " + path) + return name + +def read_fd(fd, limit, label): + chunks = [] + total = 0 + while True: + chunk = os.read(fd, min(1 << 16, limit + 1 - total)) + if not chunk: + return b"".join(chunks) + total += len(chunk) + if total > limit: + die("file exceeds size limit: " + label) + chunks.append(chunk) + +def same_inode(left, right): + return left.st_dev == right.st_dev and left.st_ino == right.st_ino + +def inode_flags(fd): + try: + value = bytearray(4) + fcntl.ioctl(fd, FS_IOC_GETFLAGS, value, True) + return struct.unpack("I", value)[0] + except OSError: + return 0 + +def clear_immutable(fd): + flags = inode_flags(fd) + if flags & FS_IMMUTABLE_FL: + fcntl.ioctl(fd, FS_IOC_SETFLAGS, struct.pack("I", flags & ~FS_IMMUTABLE_FL)) + return flags + +def set_flags(fd, flags): + if flags: + fcntl.ioctl(fd, FS_IOC_SETFLAGS, struct.pack("I", flags)) + +def saved_file(st, data, flags): + return { + "data": data, + "uid": st.st_uid, + "gid": st.st_gid, + "mode": stat.S_IMODE(st.st_mode), + "flags": flags, + } + +def stage(dir_fd, name, data, uid, gid, mode): + temp = ".%s.nemoclaw.%d.%s" % (name, os.getpid(), secrets.token_hex(8)) + flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | O_NOFOLLOW + ) + fd = os.open(temp, flags, 0o600, dir_fd=dir_fd) + try: + os.fchown(fd, uid, gid) + os.fchmod(fd, mode) + view = memoryview(data) + while view: + count = os.write(fd, view) + if count <= 0: + die("short write while staging " + name) + view = view[count:] + os.fsync(fd) + except BaseException: + os.close(fd) + try: + os.unlink(temp, dir_fd=dir_fd) + except FileNotFoundError: + pass + except BaseException as cleanup_error: + rollback_errors.append("%s staging cleanup: %s" % (name, cleanup_error)) + raise + os.close(fd) + return temp + +def replace_saved(dir_fd, name, saved): + current_fd, _current_st = open_optional_file(name, dir_fd) + if current_fd is not None: + try: + clear_immutable(current_fd) + finally: + os.close(current_fd) + temp = stage( + dir_fd, + name, + saved["data"], + saved["uid"], + saved["gid"], + saved["mode"], + ) + try: + os.replace(temp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) + finally: + try: + os.unlink(temp, dir_fd=dir_fd) + except FileNotFoundError: + pass + fd, opened = open_checked(name, False, dir_fd=dir_fd) + try: + limit = MAX_HASH_BYTES if name == HASH_NAME else MAX_CONFIG_BYTES + if ( + opened.st_uid != saved["uid"] + or opened.st_gid != saved["gid"] + or stat.S_IMODE(opened.st_mode) != saved["mode"] + or read_fd(fd, limit, name) != saved["data"] + ): + die("rollback replacement mismatch for " + name) + set_flags(fd, saved["flags"]) + finally: + os.close(fd) + +def verify_locked(dir_fd, name, expected): + fd, opened = open_checked(name, False, dir_fd=dir_fd) + try: + limit = MAX_HASH_BYTES if name == HASH_NAME else MAX_CONFIG_BYTES + if read_fd(fd, limit, name) != expected: + die("replacement content mismatch for " + name) + if ( + opened.st_uid != os.geteuid() + or opened.st_gid != os.getegid() + or stat.S_IMODE(opened.st_mode) != 0o444 + ): + die("replacement metadata mismatch for " + name) + return opened + finally: + os.close(fd) + +def entry_identity(dir_fd, name): + try: + return os.stat(name, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + return None + +def clear_entry_immutable(dir_fd, name): + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + | O_NOFOLLOW + ) + try: + fd = os.open(name, flags, dir_fd=dir_fd) + except FileNotFoundError: + return + except OSError: + # Symlinks cannot carry regular-file immutable state. Replacing the + # no-follow directory entry below still revokes the canonical name. + return + try: + clear_immutable(fd) + finally: + os.close(fd) + +def install_contained_pair(dir_fd, config_name, config_bytes, record_bytes): + previous = { + config_name: entry_identity(dir_fd, config_name), + HASH_NAME: entry_identity(dir_fd, HASH_NAME), + } + containment_staged = {} + try: + clear_entry_immutable(dir_fd, config_name) + clear_entry_immutable(dir_fd, HASH_NAME) + containment_staged[config_name] = stage( + dir_fd, config_name, config_bytes, os.geteuid(), os.getegid(), 0o444 + ) + containment_staged[HASH_NAME] = stage( + dir_fd, HASH_NAME, record_bytes, os.geteuid(), os.getegid(), 0o444 + ) + os.replace( + containment_staged[config_name], + config_name, + src_dir_fd=dir_fd, + dst_dir_fd=dir_fd, + ) + containment_staged.pop(config_name) + os.replace( + containment_staged[HASH_NAME], + HASH_NAME, + src_dir_fd=dir_fd, + dst_dir_fd=dir_fd, + ) + containment_staged.pop(HASH_NAME) + installed_config = verify_locked(dir_fd, config_name, config_bytes) + installed_hash = verify_locked(dir_fd, HASH_NAME, record_bytes) + if previous[config_name] is not None and same_inode( + previous[config_name], installed_config + ): + die("containment did not replace config inode") + if previous[HASH_NAME] is not None and same_inode( + previous[HASH_NAME], installed_hash + ): + die("containment did not replace config hash inode") + os.fsync(dir_fd) + finally: + for name, temp in containment_staged.items(): + try: + os.unlink(temp, dir_fd=dir_fd) + except FileNotFoundError: + pass + except BaseException as cleanup_error: + rollback_errors.append( + "%s containment staging cleanup: %s" % (name, cleanup_error) + ) + +if len(sys.argv) < 3: + die("unsupported Deep Agents config lock arguments") +options = sys.argv[3:] +allowed_options = {"--fail-closed-on-error", "--test-protect-parent"} +if len(options) != len(set(options)) or any(option not in allowed_options for option in options): + die("unsupported Deep Agents config lock arguments") + +config_dir = os.path.normpath(sys.argv[1]) +config_name = child_name(config_dir, sys.argv[2]) +fail_closed_on_error = "--fail-closed-on-error" in options +test_parent = "--test-protect-parent" in options +parent_dir = os.path.dirname(config_dir) +dir_name = os.path.basename(config_dir) +if parent_dir in ("", config_dir) or dir_name in ("", ".", ".."): + die("refusing invalid config dir: " + config_dir) + +parent_fd, parent_st = open_checked(parent_dir, True) +parent_saved = { + "uid": parent_st.st_uid, + "gid": parent_st.st_gid, + "mode": stat.S_IMODE(parent_st.st_mode), + "flags": inode_flags(parent_fd), +} +protect_parent = parent_dir == "/sandbox" or test_parent +sandbox_gid = None +if protect_parent: + sandbox_gid = os.getegid() if test_parent else grp.getgrnam("sandbox").gr_gid +elif parent_st.st_uid != os.geteuid() or ( + stat.S_IMODE(parent_st.st_mode) & 0o022 + and not stat.S_IMODE(parent_st.st_mode) & stat.S_ISVTX +): + os.close(parent_fd) + die("refusing unsafe config parent: " + parent_dir) + +dir_fd = None +dir_saved = None +dir_flags = 0 +opened = {} +staged = {} +mutation_started = False +body_error = None +rollback_errors = [] +hash_created = False +parent_freeze_started = False +freeze_completed = False +containment_attempted = False +containment_result = None +config_bytes = None +record_bytes = None +try: + if protect_parent: + parent_freeze_started = True + clear_immutable(parent_fd) + os.fchown(parent_fd, os.geteuid(), os.getegid()) + os.fchmod(parent_fd, 0o755) + + dir_fd, dir_st = open_checked(dir_name, True, dir_fd=parent_fd) + dir_saved = { + "uid": dir_st.st_uid, + "gid": dir_st.st_gid, + "mode": stat.S_IMODE(dir_st.st_mode), + } + dir_flags = clear_immutable(dir_fd) + os.fchown(dir_fd, os.geteuid(), os.getegid()) + os.fchmod(dir_fd, 0o700) + freeze_completed = True + if not same_inode(os.stat(dir_name, dir_fd=parent_fd, follow_symlinks=False), dir_st): + die("config directory changed during lock: " + config_dir) + + config_fd, config_st = open_checked(config_name, False, dir_fd=dir_fd) + opened[config_name] = {"fd": config_fd, "stat": config_st, "saved": None} + config_bytes = read_fd(config_fd, MAX_CONFIG_BYTES, os.path.join(config_dir, config_name)) + opened[config_name]["saved"] = saved_file( + config_st, + config_bytes, + inode_flags(config_fd), + ) + record_bytes = ( + "%s %s\n" % (hashlib.sha256(config_bytes).hexdigest(), config_name) + ).encode("ascii") + + hash_fd, hash_st = open_optional_file(HASH_NAME, dir_fd) + hash_created = hash_fd is None + if hash_fd is not None: + opened[HASH_NAME] = {"fd": hash_fd, "stat": hash_st, "saved": None} + hash_bytes = read_fd(hash_fd, MAX_HASH_BYTES, os.path.join(config_dir, HASH_NAME)) + opened[HASH_NAME]["saved"] = saved_file( + hash_st, + hash_bytes, + inode_flags(hash_fd), + ) + if hash_bytes != record_bytes: + die("existing config hash is stale or malformed: " + os.path.join(config_dir, HASH_NAME)) + + staged[config_name] = stage( + dir_fd, config_name, config_bytes, os.geteuid(), os.getegid(), 0o444 + ) + staged[HASH_NAME] = stage( + dir_fd, HASH_NAME, record_bytes, os.geteuid(), os.getegid(), 0o444 + ) + if not same_inode( + os.stat(config_name, dir_fd=dir_fd, follow_symlinks=False), + opened[config_name]["stat"], + ): + die("config path changed during lock: " + os.path.join(config_dir, config_name)) + if HASH_NAME in opened: + if not same_inode( + os.stat(HASH_NAME, dir_fd=dir_fd, follow_symlinks=False), + opened[HASH_NAME]["stat"], + ): + die("config hash changed during lock: " + os.path.join(config_dir, HASH_NAME)) + else: + try: + os.stat(HASH_NAME, dir_fd=dir_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + die("config hash appeared during lock: " + os.path.join(config_dir, HASH_NAME)) + + mutation_started = True + clear_immutable(opened[config_name]["fd"]) + if HASH_NAME in opened: + clear_immutable(opened[HASH_NAME]["fd"]) + os.replace(staged[config_name], config_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) + staged.pop(config_name) + os.replace(staged[HASH_NAME], HASH_NAME, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) + staged.pop(HASH_NAME) + verify_locked(dir_fd, config_name, config_bytes) + verify_locked(dir_fd, HASH_NAME, record_bytes) + os.fchown(dir_fd, os.geteuid(), os.getegid()) + os.fchmod(dir_fd, 0o755) + os.fsync(dir_fd) + if protect_parent: + os.fchown(parent_fd, os.geteuid(), sandbox_gid) + os.fchmod(parent_fd, 0o1775) + os.fsync(parent_fd) +except BaseException as exc: + body_error = exc + if dir_fd is not None and mutation_started: + try: + replace_saved(dir_fd, config_name, opened[config_name]["saved"]) + except BaseException as rollback_error: + rollback_errors.append("%s: %s" % (config_name, rollback_error)) + if HASH_NAME in opened: + try: + replace_saved(dir_fd, HASH_NAME, opened[HASH_NAME]["saved"]) + except BaseException as rollback_error: + rollback_errors.append("%s: %s" % (HASH_NAME, rollback_error)) + else: + try: + os.unlink(HASH_NAME, dir_fd=dir_fd) + except FileNotFoundError: + pass + except BaseException as rollback_error: + rollback_errors.append("%s cleanup: %s" % (HASH_NAME, rollback_error)) +finally: + if dir_fd is not None: + for name, temp in staged.items(): + try: + os.unlink(temp, dir_fd=dir_fd) + except FileNotFoundError: + pass + except BaseException as rollback_error: + rollback_errors.append("%s staging cleanup: %s" % (name, rollback_error)) + for item in opened.values(): + try: + os.close(item["fd"]) + except OSError: + pass + if body_error is not None: + contain_on_error = fail_closed_on_error and ( + freeze_completed or parent_freeze_started + ) + if not contain_on_error and not rollback_errors: + if dir_fd is not None and dir_saved is not None: + try: + os.fchown(dir_fd, dir_saved["uid"], dir_saved["gid"]) + os.fchmod(dir_fd, dir_saved["mode"]) + set_flags(dir_fd, dir_flags) + except BaseException as rollback_error: + rollback_errors.append("config dir: %s" % rollback_error) + if not rollback_errors: + try: + os.fchown(parent_fd, parent_saved["uid"], parent_saved["gid"]) + os.fchmod(parent_fd, parent_saved["mode"]) + set_flags(parent_fd, parent_saved["flags"]) + except BaseException as rollback_error: + rollback_errors.append("config parent: %s" % rollback_error) + if contain_on_error or rollback_errors: + if contain_on_error: + containment_attempted = True + containment_result = "incomplete" + + canonical_pair_contained = False + if ( + contain_on_error + and dir_fd is not None + and config_bytes is not None + and record_bytes is not None + ): + try: + install_contained_pair( + dir_fd, + config_name, + config_bytes, + record_bytes, + ) + canonical_pair_contained = True + except BaseException as containment_error: + rollback_errors.append( + "canonical pair fail-closed replacement: %s" % containment_error + ) + + dir_clamped = False + if dir_fd is not None: + try: + clear_immutable(dir_fd) + os.fchown(dir_fd, os.geteuid(), os.getegid()) + os.fchmod(dir_fd, 0o500) + os.fsync(dir_fd) + clamped_dir = os.fstat(dir_fd) + if ( + clamped_dir.st_uid != os.geteuid() + or clamped_dir.st_gid != os.getegid() + or stat.S_IMODE(clamped_dir.st_mode) != 0o500 + ): + die("config dir fail-closed clamp metadata mismatch") + dir_clamped = True + except BaseException as clamp_error: + rollback_errors.append("config dir fail-closed clamp: %s" % clamp_error) + + if protect_parent: + config_root_candidate = ( + contain_on_error and canonical_pair_contained and dir_clamped + ) + if config_root_candidate or (not contain_on_error and dir_clamped): + parent_gid = sandbox_gid + parent_mode = 0o1775 + else: + parent_gid = os.getegid() + parent_mode = 0o700 + + parent_clamped = False + try: + clear_immutable(parent_fd) + os.fchown(parent_fd, os.geteuid(), parent_gid) + os.fchmod(parent_fd, parent_mode) + os.fsync(parent_fd) + clamped_parent = os.fstat(parent_fd) + if ( + clamped_parent.st_uid != os.geteuid() + or clamped_parent.st_gid != parent_gid + or stat.S_IMODE(clamped_parent.st_mode) != parent_mode + ): + die("config parent fail-closed clamp metadata mismatch") + parent_clamped = True + except BaseException as clamp_error: + rollback_errors.append("config parent fail-closed clamp: %s" % clamp_error) + + if contain_on_error and parent_clamped: + containment_result = ( + "config-root" if config_root_candidate else "sandbox-parent" + ) + elif contain_on_error and config_root_candidate: + try: + clear_immutable(parent_fd) + os.fchown(parent_fd, os.geteuid(), os.getegid()) + os.fchmod(parent_fd, 0o700) + os.fsync(parent_fd) + clamped_parent = os.fstat(parent_fd) + if ( + clamped_parent.st_uid != os.geteuid() + or clamped_parent.st_gid != os.getegid() + or stat.S_IMODE(clamped_parent.st_mode) != 0o700 + ): + die("config parent fail-closed fallback metadata mismatch") + containment_result = "sandbox-parent" + except BaseException as clamp_error: + rollback_errors.append( + "config parent fail-closed fallback: %s" % clamp_error + ) + if dir_fd is not None: + os.close(dir_fd) + os.close(parent_fd) + +if body_error is not None: + status = "transaction-failed" + if containment_attempted: + status = containment_result + elif rollback_errors: + status = "rollback-failed" + sys.stderr.write("%s:%s\n" % (ERROR_PROTOCOL_PREFIX, status)) + sys.stderr.flush() + raise SystemExit(1) +print("hash-created" if hash_created else "hash-existing") +`; + +export function buildDeepAgentsConfigLockCommand( + configDir: string, + configPath: string, + failClosedOnError = false, +): string[] { + return [ + "python3", + "-I", + "-c", + DEEP_AGENTS_CONFIG_LOCK_NOFOLLOW_SCRIPT, + configDir, + configPath, + ...(failClosedOnError ? ["--fail-closed-on-error"] : []), + ]; +} diff --git a/src/lib/shields/timer-control.ts b/src/lib/shields/timer-control.ts index 727a89b7347..77f7e4297bf 100644 --- a/src/lib/shields/timer-control.ts +++ b/src/lib/shields/timer-control.ts @@ -35,6 +35,9 @@ interface TimerMarker { restoreAt: string; processToken?: string; allowLegacyHermesProtocol?: boolean; + agentName?: string; + configPath?: string; + configDir?: string; leaseOwnerPid?: number; leaseOwnerStartIdentity?: string; } @@ -52,6 +55,11 @@ function isTimerMarker(value: unknown): value is TimerMarker { (value.processToken === undefined || typeof value.processToken === "string") && (value.allowLegacyHermesProtocol === undefined || typeof value.allowLegacyHermesProtocol === "boolean") && + (value.agentName === undefined || typeof value.agentName === "string") && + (value.configPath === undefined || typeof value.configPath === "string") && + (value.configDir === undefined || typeof value.configDir === "string") && + ((value.configPath === undefined && value.configDir === undefined) || + (typeof value.configPath === "string" && typeof value.configDir === "string")) && (value.leaseOwnerPid === undefined || (typeof value.leaseOwnerPid === "number" && Number.isInteger(value.leaseOwnerPid) && diff --git a/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index bdad08cf883..9343d0ee8df 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -11,6 +11,7 @@ import { getMcpLifecycleLockPath } from "../state/mcp-lifecycle-lock"; const shieldsIndexMock = vi.hoisted(() => ({ lockAgentConfig: vi.fn() as unknown, prepareAutoRestoreTransitionTakeover: vi.fn(), + resolvePersistedAutoRestoreTarget: vi.fn() as unknown, })); const PROCESS_TOKEN = "a".repeat(32); @@ -34,19 +35,14 @@ vi.mock("../policy", () => ({ ]), })); -vi.mock("../sandbox/agent-config", () => ({ - DEFAULT_AGENT_CONFIG: Symbol("DEFAULT_AGENT_CONFIG"), - resolveAgentConfig: vi.fn(() => ({ - configPath: "/sandbox/.openclaw/openclaw.json", - configDir: "/sandbox/.openclaw", - })), -})); - vi.mock("./index", () => ({ get lockAgentConfig() { return shieldsIndexMock.lockAgentConfig; }, prepareAutoRestoreTransitionTakeover: shieldsIndexMock.prepareAutoRestoreTransitionTakeover, + get resolvePersistedAutoRestoreTarget() { + return shieldsIndexMock.resolvePersistedAutoRestoreTarget; + }, })); describe("shields timer authorization", () => { @@ -56,6 +52,25 @@ describe("shields timer authorization", () => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "shields-timer-")); vi.stubEnv("HOME", tmpHome); shieldsIndexMock.lockAgentConfig = vi.fn(); + shieldsIndexMock.resolvePersistedAutoRestoreTarget = vi.fn( + ( + _sandboxName: string, + marker: { agentName?: string; configPath?: string; configDir?: string }, + ) => + marker.configPath && marker.configDir + ? { + ...(marker.agentName ? { agentName: marker.agentName } : {}), + configPath: marker.configPath, + configDir: marker.configDir, + sensitiveFiles: [ + `${marker.configDir.replace(/\/+$/, "")}/.config-hash`, + ...(marker.agentName === "hermes" + ? [`${marker.configDir.replace(/\/+$/, "")}/.env`] + : []), + ], + } + : undefined, + ); vi.resetModules(); vi.clearAllMocks(); }); @@ -188,6 +203,9 @@ describe("shields timer authorization", () => { restoreAt: restoreAtIso, processToken: PROCESS_TOKEN, allowLegacyHermesProtocol: true, + agentName: "openclaw", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", }), ); @@ -199,6 +217,9 @@ describe("shields timer authorization", () => { "/sandbox/.hermes", PROCESS_TOKEN, "1", + "", + "", + "openclaw", ]); const ordinary = timer.parseTimerArgs([ sandboxName, @@ -208,12 +229,43 @@ describe("shields timer authorization", () => { "/sandbox/.hermes", PROCESS_TOKEN, "0", + "", + "", + "openclaw", + ]); + const mismatchedAgent = timer.parseTimerArgs([ + sandboxName, + snapshotPath, + restoreAtIso, + "/sandbox/.hermes/config.yaml", + "/sandbox/.hermes", + PROCESS_TOKEN, + "1", + "", + "", + "langchain-deepagents-code", + ]); + const mismatchedTarget = timer.parseTimerArgs([ + sandboxName, + snapshotPath, + restoreAtIso, + "/sandbox/.openclaw/openclaw.json", + "/sandbox/.openclaw", + PROCESS_TOKEN, + "1", + "", + "", + "openclaw", ]); expect(authorized).not.toBeNull(); + expect(mismatchedAgent).not.toBeNull(); + expect(mismatchedTarget).not.toBeNull(); expect(authorized?.allowLegacyHermesProtocol).toBe(true); expect(timer.markerMatchesCurrentTimer(authorized!)).toBe(true); expect(timer.markerMatchesCurrentTimer(ordinary!)).toBe(false); + expect(timer.markerMatchesCurrentTimer(mismatchedAgent!)).toBe(false); + expect(timer.markerMatchesCurrentTimer(mismatchedTarget!)).toBe(false); expect( timer.parseTimerArgs([sandboxName, snapshotPath, restoreAtIso, "", "", PROCESS_TOKEN, "yes"]), ).toBeNull(); @@ -562,13 +614,6 @@ describe("shields timer authorization", () => { chattrApplied: true, fileHashes: sealedHashes, })); - const agentConfigModule = await import("../sandbox/agent-config"); - (agentConfigModule.resolveAgentConfig as ReturnType).mockReturnValue({ - agentName: "openclaw", - configPath, - configDir, - sensitiveFiles: [sensitiveHashPath], - }); const indexModule = await import("./index"); (indexModule.lockAgentConfig as ReturnType).mockImplementation(lockMock); @@ -599,6 +644,76 @@ describe("shields timer authorization", () => { expect(fs.existsSync(markerPath)).toBe(false); }); + it("preserves the Deep Agents lock protocol through shared target resolution (#7977)", async () => { + const stateDir = path.join(tmpHome, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + + const sandboxName = "dcode-safety"; + const agentName = "langchain-deepagents-code"; + const configPath = "/sandbox/.deepagents/config.toml"; + const configDir = "/sandbox/.deepagents"; + const sensitiveHashPath = `${configDir}/.config-hash`; + 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`); + + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n default: {}\n"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: process.pid, + sandboxName, + snapshotPath, + restoreAt: restoreAtIso, + processToken: PROCESS_TOKEN, + agentName, + }), + ); + + const lockMock = vi.fn(() => ({ + chattrApplied: false, + fileHashes: { + [configPath]: "a".repeat(64), + [sensitiveHashPath]: "b".repeat(64), + }, + })); + const indexModule = await import("./index"); + (indexModule.lockAgentConfig as ReturnType).mockImplementation(lockMock); + + const timer = await import("./timer"); + const args = timer.parseTimerArgs([ + sandboxName, + snapshotPath, + restoreAtIso, + configPath, + configDir, + PROCESS_TOKEN, + "0", + "", + "", + agentName, + ]); + expect(args).not.toBeNull(); + + const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); + const fallbackTarget = { + agentName, + configPath, + configDir, + sensitiveFiles: [sensitiveHashPath], + }; + + expect(shieldsIndexMock.resolvePersistedAutoRestoreTarget).toHaveBeenCalledWith( + sandboxName, + args, + ); + expect(exitCode).toBe(0); + expect(lockMock).toHaveBeenCalledTimes(2); + expect(lockMock).toHaveBeenNthCalledWith(1, sandboxName, fallbackTarget, false, false); + expect(lockMock).toHaveBeenNthCalledWith(2, sandboxName, fallbackTarget, false, false); + 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 }); @@ -625,13 +740,6 @@ describe("shields timer authorization", () => { }), ); - const agentConfigModule = await import("../sandbox/agent-config"); - (agentConfigModule.resolveAgentConfig as ReturnType).mockReturnValue({ - agentName: "openclaw", - configPath, - configDir, - sensitiveFiles: [], - }); shieldsIndexMock.lockAgentConfig = undefined; const timer = await import("./timer"); @@ -723,13 +831,6 @@ describe("shields timer authorization", () => { }; const lockMock = vi.fn(() => ({ chattrApplied: true, fileHashes: sealedHashes })); - const agentConfigModule = await import("../sandbox/agent-config"); - (agentConfigModule.resolveAgentConfig as ReturnType).mockReturnValue({ - agentName: "openclaw", - configPath, - configDir, - sensitiveFiles: [sensitiveHashPath], - }); const indexModule = await import("./index"); (indexModule.lockAgentConfig as ReturnType).mockImplementation(lockMock); @@ -796,13 +897,6 @@ describe("shields timer authorization", () => { ); }); - const agentConfigModule = await import("../sandbox/agent-config"); - (agentConfigModule.resolveAgentConfig as ReturnType).mockReturnValue({ - agentName: "openclaw", - configPath, - configDir, - sensitiveFiles: [sensitiveHashPath], - }); const indexModule = await import("./index"); (indexModule.lockAgentConfig as ReturnType).mockImplementation(lockMock); diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index 1b3ff66938f..70613b4c523 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -6,14 +6,13 @@ // restores the captured policy snapshot. // // Usage (internal — called by shields.ts via fork()): -// node shields-timer.js +// node shields-timer.js import fs from "node:fs"; import path from "node:path"; import { isObjectRecord, type UnknownRecord } from "../core/json-types"; import { buildPolicySetCommand } from "../policy"; import { run } from "../runner"; -import { resolveAgentConfig } from "../sandbox/agent-config"; import { withSandboxMutationLock } from "../state/mcp-lifecycle-lock"; import { resolveNemoclawStateDir } from "../state/paths"; import { appendAuditEntry, type ShieldsAuditEntry } from "./audit"; @@ -42,6 +41,7 @@ interface TimerArgs { markerPath: string; configPath?: string; configDir?: string; + agentName?: string; processToken?: string; allowLegacyHermesProtocol: boolean; leaseOwnerPid?: number; @@ -64,6 +64,7 @@ function parseTimerArgs(argv: string[]): TimerArgs | null { allowLegacyHermes, leaseOwnerPidRaw, leaseOwnerStartIdentityRaw, + agentNameRaw, ] = argv; const restoreAtMs = restoreAtIso ? new Date(restoreAtIso).getTime() : Number.NaN; const leaseOwnerPid = leaseOwnerPidRaw ? Number(leaseOwnerPidRaw) : undefined; @@ -91,6 +92,7 @@ function parseTimerArgs(argv: string[]): TimerArgs | null { markerPath: path.join(STATE_DIR, `shields-timer-${sandboxName}.json`), configPath, configDir, + ...(agentNameRaw ? { agentName: agentNameRaw } : {}), processToken, allowLegacyHermesProtocol: allowLegacyHermes === "1", ...(leaseOwnerPid && leaseOwnerStartIdentity ? { leaseOwnerPid, leaseOwnerStartIdentity } : {}), @@ -172,7 +174,10 @@ function markerRecordMatchesCurrentTimer(marker: UnknownRecord | null, args: Tim marker.processToken === args.processToken && (marker.allowLegacyHermesProtocol === true) === args.allowLegacyHermesProtocol && marker.leaseOwnerPid === args.leaseOwnerPid && - marker.leaseOwnerStartIdentity === args.leaseOwnerStartIdentity + marker.leaseOwnerStartIdentity === args.leaseOwnerStartIdentity && + marker.agentName === args.agentName && + (marker.configPath === undefined || marker.configPath === args.configPath) && + (marker.configDir === undefined || marker.configDir === args.configDir) ); } @@ -318,48 +323,27 @@ async function runRestoreTimer(args: TimerArgs): Promise { // lockAgentConfig runs each operation independently and verifies the // on-disk state — it throws if verification fails. // - // NC-2227-03: Resolve the full agent config target (including sensitive - // files like .config-hash, .env) so the timer re-locks the same scope - // that interactive `shields up` uses. Fall back to the bare configPath/ - // configDir from argv if resolution fails (e.g., registry unavailable). + // NC-2227-03: Reuse resolved registry metadata only when configPath, + // configDir, and the optional agentName match the timer arguments. + // Otherwise the persisted paths remain the recovery authority. This + // keeps older markers without agentName pinned by path while preserving + // the full sensitive-file set whenever the registry still describes the + // same target. let lockVerified = true; let lockedChattr: boolean | null = null; let lockedHashes: { [path: string]: string } | null = null; if (args.configPath) { - let lockTarget: { - agentName?: string; - configPath: string; - configDir: string; - sensitiveFiles?: string[]; - } | null = null; - try { - // Always prefer the resolved target — even DEFAULT_AGENT_CONFIG - // carries the OpenClaw sensitiveFiles (.config-hash) that - // shields-up locks and that the content seal hashes. Dropping - // them here would persist a partial fileHashes map and the next - // `shields status` would flag the missing entries as drift. - lockTarget = resolveAgentConfig(args.sandboxName); - } catch { - // Resolver itself threw (registry unavailable). Fall back to - // argv-supplied paths, but still infer sensitiveFiles from - // configDir so the locked set matches what shields-up uses. - if (args.configDir) { - lockTarget = { - configPath: args.configPath, - configDir: args.configDir, - sensitiveFiles: [`${args.configDir}/.config-hash`], - }; - } else { - lockVerified = false; - appendAudit({ - action: "shields_auto_restore_lock_warning", - sandbox: args.sandboxName, - timestamp: now, - restored_by: "auto_timer", - warning: "Missing config directory for auto-restore re-lock verification", - lock_verified: false, - }); - } + const lockTarget = shields.resolvePersistedAutoRestoreTarget(args.sandboxName, args); + if (!lockTarget) { + lockVerified = false; + appendAudit({ + action: "shields_auto_restore_lock_warning", + sandbox: args.sandboxName, + timestamp: now, + restored_by: "auto_timer", + warning: "Missing config directory for auto-restore re-lock verification", + lock_verified: false, + }); } if (lockTarget) { try { diff --git a/test/nemoclaw-plugin-secret-pattern-parity.test.ts b/test/nemoclaw-plugin-secret-pattern-parity.test.ts new file mode 100644 index 00000000000..7704aa8df7d --- /dev/null +++ b/test/nemoclaw-plugin-secret-pattern-parity.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { CONTEXT_SECRET_PATTERNS } from "../nemoclaw/src/security/credential-filter.ts"; +import { CONTEXT_PATTERNS } from "../src/lib/security/secret-patterns.ts"; + +function fingerprint(patterns: readonly RegExp[]): string[] { + return patterns.map((pattern) => `${pattern.source}::${pattern.flags}`); +} + +describe("NemoClaw plugin secret-pattern parity", () => { + it("matches every canonical context pattern source and flag", () => { + expect(CONTEXT_PATTERNS.length).toBeGreaterThan(0); + expect(fingerprint(CONTEXT_SECRET_PATTERNS)).toEqual(fingerprint(CONTEXT_PATTERNS)); + }); +}); diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 5dc23787d70..201a619f4e4 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -748,6 +748,7 @@ describe("state-dir-guard", () => { it.each([ ["OpenClaw", "NEMOCLAW_TEST_OPENCLAW_FAIL_CLOSED"], ["Hermes", "NEMOCLAW_TEST_HERMES_FAIL_CLOSED"], + ["Deep Agents", "NEMOCLAW_TEST_DEEP_AGENTS_FAIL_CLOSED"], ])("leaves the %s config root fail-closed when a state-tree budget aborts lock", (_agent, env) => { const { configDir } = fixture(); const pluginsDir = path.join(configDir, "plugins"); @@ -778,6 +779,25 @@ describe("state-dir-guard", () => { } }); + it.each([ + ["OpenClaw", "NEMOCLAW_TEST_OPENCLAW_FAIL_CLOSED"], + ["Hermes", "NEMOCLAW_TEST_HERMES_FAIL_CLOSED"], + ["Deep Agents", "NEMOCLAW_TEST_DEEP_AGENTS_FAIL_CLOSED"], + ])("restores traversal of the %s config root only after a successful lock", (_agent, env) => { + const { configDir } = fixture(); + const pluginsDir = path.join(configDir, "plugins"); + fs.mkdirSync(pluginsDir); + fs.writeFileSync(path.join(pluginsDir, "plugin.js"), "module.exports = true;\n"); + + const result = runGuard("lock", configDir, { [env]: "1" }); + + expect(result.status).toBe(0); + expect(result.lines).toContainEqual( + expect.objectContaining({ type: "result", action: "lock", status: "ok" }), + ); + expect(mode(configDir)).toBe(0o755); + }); + it("serializes an orphaned recursive unlock ahead of the restoring lock", async () => { const { root, configDir } = fixture(); const pluginDir = path.join(configDir, "plugins");