Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> shields up`.
If the retry still fails, rebuild a known-good baseline with `$$nemoclaw <name> rebuild --yes`.

<AgentOnly variant="deepagents">

A `CRITICAL` Deep Agents config-lock failure is not an ordinary unlocked or drifted result.
The retry and rebuild guidance above does not apply when the diagnostic includes `fail-closed containment=` or `rollback failed`.
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.

</AgentOnly>

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.
Expand Down
61 changes: 61 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,67 @@ 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 failure reports `fail-closed containment=` or `rollback failed`.
A containment result identifies one of two confirmed postures or an incomplete containment attempt.
A `rollback failed` result does not confirm containment.
It means 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` because it could not trust the config root or install and verify a fresh canonical config-and-hash pair.
- `fail-closed containment=incomplete` means NemoClaw could not confirm either complete posture.

Preserve the complete `CRITICAL` diagnostic.
Do not run `chmod`, `chown`, `shields up`, or another repair from inside the sandbox.
A confirmed containment posture deliberately removes the sandbox identity's access.
An incomplete containment result or `rollback failed` 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 <name> snapshot list
```

<Warning>
Destroying the sandbox permanently discards state newer than the selected snapshot.
Confirm that the trusted host-side snapshot exists before you destroy the sandbox.
</Warning>

Destroy the sandbox, re-onboard the same name from trusted host configuration, and restore the snapshot:

```bash
$$nemoclaw <name> destroy
$$nemoclaw onboard --name <name> --agent dcode
$$nemoclaw <name> snapshot restore <selector>
```

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.

<Warning>
This recreation permanently discards the current sandbox state.
Continue only if you accept that loss.
</Warning>

```bash
$$nemoclaw <name> destroy
$$nemoclaw onboard --name <name> --agent dcode
```

After either recovery path, verify the recreated sandbox from the host:

```bash
$$nemoclaw <name> status
$$nemoclaw <name> 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`.
Expand Down
181 changes: 181 additions & 0 deletions nemoclaw/src/commands/migration-state-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
existsSync,
mkdirSync,
mkdtempSync,
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 {
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,
};
}

afterEach(() => {
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,
});
expect(bundle).not.toBeNull();
if (bundle === null) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const preparedConfigPath = path.join(bundle.preparedStateDir, "openclaw.json");
const preparedConfig = JSON.parse(readFileSync(preparedConfigPath, "utf-8")) as {
gateway?: unknown;
metadata: Record<string, string>;
};
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 originalMode = statSync(externalConfigPath).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(externalConfigPath, "utf-8")).toBe(original);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
expect(statSync(externalConfigPath).mode & 0o777).toBe(originalMode);
const stagingDir = path.join(home, ".nemoclaw", "staging");
expect(existsSync(stagingDir) ? readdirSync(stagingDir) : []).toEqual([]);
},
);
});

describe("migration-state config path security", () => {
const expectPrototypeClean = (): void => {
const probe: Record<string, unknown> = {};
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<string, unknown> = {};
expect(() => {
setConfigValue(doc, `${segment}.polluted`, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});

it("rejects __proto__ in nested position", () => {
const doc: Record<string, unknown> = {};
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<string, unknown> = {};
expect(() => {
setConfigValue(doc, configPath, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});

it("allows simple top-level keys", () => {
const doc: Record<string, unknown> = {};
setConfigValue(doc, "theme", "dark");
expect(doc.theme).toBe("dark");
});
});
94 changes: 47 additions & 47 deletions nemoclaw/src/commands/migration-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
if (store.has(targetPath)) return false;
addFile(targetPath, content);
return true;
},
scanDescriptorSnapshot: (
root: { canonicalPath: string },
_sensitive: unknown,
target: string,
) => {
const entry = store.get(`${root.canonicalPath}/${target}`);
if (entry?.type !== "file") return null;
return {
root: identity,
directories: {},
files: [
{
path: target,
metadata: identity,
content: Buffer.from(entry.content ?? "", "utf-8").toString("base64"),
},
],
};
},
};
});

// Mock tar to avoid real archive creation
vi.mock("tar", () => ({
create: vi.fn(async () => {}),
Expand Down Expand Up @@ -1503,60 +1550,13 @@ describe("commands/migration-state", () => {
});
});

// ── setConfigValue prototype pollution guard ─────────────────────

describe("setConfigValue", () => {
const expectPrototypeClean = (): void => {
const probe: Record<string, unknown> = {};
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<string, unknown> = {};
expect(() => {
setConfigValue(doc, `${segment}.polluted`, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});

it("rejects __proto__ in nested position", () => {
const doc: Record<string, unknown> = {};
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<string, unknown> = {};
expect(() => {
setConfigValue(doc, configPath, "true");
}).toThrow(/Unsafe config path segment/);
expectPrototypeClean();
});

it("allows legitimate dotted paths", () => {
const doc: Record<string, unknown> = {};
setConfigValue(doc, "agents.list[0].workspace", "/tmp/ws");
const agents = doc.agents as Record<string, unknown>;
const list = agents.list as Record<string, unknown>[];
expect(list[0].workspace).toBe("/tmp/ws");
});

it("allows simple top-level keys", () => {
const doc: Record<string, unknown> = {};
setConfigValue(doc, "theme", "dark");
expect(doc.theme).toBe("dark");
});
});
});
Loading
Loading