Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
26 changes: 20 additions & 6 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1956,7 +1956,7 @@ If the preset is unknown or not currently applied, the command exits non-zero wi

Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox.

### `$$nemoclaw <name> policy exclude`
### `$$nemoclaw <name> policy exclude <key>`

Persistently exclude one exact entry from the agent baseline policy after previewing the egress and support impact that the change removes.
The preview names the supported features that may stop working.
Expand All @@ -1972,21 +1972,35 @@ $$nemoclaw my-assistant policy exclude nous_research --dry-run
$$nemoclaw my-assistant policy exclude nous_research --force
```

When a release changes an excluded entry, first run `policy restore <key>` to clear the stale record, preview the current scope with `policy exclude <key> --dry-run`, and then explicitly exclude it again if you still accept the support impact.
When a release removes the entry, run `policy restore <key>` only to clear the stale exclusion record; there is no replacement scope to review or re-approve.
When a release changes an excluded entry, first run `$$nemoclaw <name> policy restore <key> --dry-run` to preview the current baseline egress that restoration would allow again.
After you review the output, run `$$nemoclaw <name> policy restore <key> --force` to allow that egress again and clear the stale exclusion record.
Then preview the current exclusion scope with `$$nemoclaw <name> policy exclude <key> --dry-run` and reapply it with `$$nemoclaw <name> policy exclude <key> --force` only if you still accept the support impact.
When a release removes the entry, first run `$$nemoclaw <name> policy restore <key> --dry-run` to confirm that restoration will clear only the stale exclusion record.
After you review the output, run `$$nemoclaw <name> policy restore <key> --force`; there is no replacement scope to review or approve.

### `$$nemoclaw <name> policy restore`
### `$$nemoclaw <name> policy restore <key>`

Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record.
Use `--dry-run` to preview the egress that would be restored.
When the current baseline still defines the entry, `--dry-run` lists the egress that restoration would allow again.
After you review the output, `--force` allows that egress again and clears the exclusion record.
When the baseline no longer defines the entry, `--dry-run` states that restoration will clear only the stale exclusion record.
After you review the output, `--force` clears that record without changing live egress.
Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement.
If a restore is interrupted, NemoClaw finalizes it only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled live target.
If either value changed or the current baseline is unreadable, the journal remains in `repair required` state so you can inspect and re-review the current scope instead of silently accepting a different entry.

```bash
$$nemoclaw my-assistant policy restore nous_research --dry-run
$$nemoclaw my-assistant policy restore nous_research
$$nemoclaw my-assistant policy restore nous_research --force
```

The restore command accepts these flags:

| Flag | Description |
|------|-------------|
| `--yes`, `-y`, `--force` | Skip the confirmation prompt |
| `--dry-run` | Preview the egress restoration or stale-record cleanup without applying changes |

### `$$nemoclaw <name> policy explain`

Print a redacted summary of the active policy context for a sandbox so an agent or operator can reason about what is allowed, what is blocked, and how to request a change.
Expand Down
25 changes: 18 additions & 7 deletions docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -313,29 +313,40 @@ Restore the baseline entry before applying a preset that intentionally owns the

To recover from baseline drift, first check whether the release changed the entry's content or removed it entirely (`$$nemoclaw <sandbox-name> policy explain` or `doctor` reports which).

If the entry still exists with different content, clear the stale record, review the current scope, and explicitly approve it again only if you still accept the impact:
If the entry still exists with different content, `policy restore <key> --force` restores the current baseline entry and clears the stale exclusion record.
This allows the entry's listed egress before you can exclude it again.
Preview the restore and exclusion scopes before applying either change:

```bash
$$nemoclaw <sandbox-name> policy restore <key>
$$nemoclaw <sandbox-name> policy restore <key> --dry-run
$$nemoclaw <sandbox-name> policy restore <key> --force
$$nemoclaw <sandbox-name> policy exclude <key> --dry-run
$$nemoclaw <sandbox-name> policy exclude <key> --force
```

If the release removed the entry entirely, `policy exclude <key>` fails with "Unknown baseline entry" because there is nothing left to exclude.
Clear the stale record instead:
If the release removed the entry entirely, `policy exclude <key>` fails with `Unknown baseline entry '<key>'.` because there is nothing left to exclude.
Preview the stale-record cleanup, then clear the record without changing live egress:

```bash
$$nemoclaw <sandbox-name> policy restore <key>
$$nemoclaw <sandbox-name> policy restore <key> --dry-run
$$nemoclaw <sandbox-name> policy restore <key> --force
```

List active exclusions with `policy list`.
`policy explain`, `status`, `doctor`, and snapshot and rebuild summaries also disclose active exclusions and their reduced-support impact.
Status and doctor compare each approval with the active agent baseline and verify that the excluded key is absent from the live OpenShell policy.
An unreadable live policy is unverified, while a live policy that contains the key is a mismatch that requires repair before you rely on the exclusion.
Restore an entry against the current baseline and clear its exclusion:
When the current baseline still defines the entry, `policy restore <key> --force` allows the entry's listed egress again and clears its exclusion.
When the baseline no longer defines the entry, the command clears only the stale exclusion record.
Preview the applicable result before you apply it:

```bash
$$nemoclaw <sandbox-name> policy restore <key>
$$nemoclaw <sandbox-name> policy restore <key> --dry-run
$$nemoclaw <sandbox-name> policy restore <key> --force
```

Both restore paths require acknowledgement unless you use `--dry-run`.
Interactive runs prompt for confirmation when no acknowledgement flag is present.
Non-interactive runs require `--force`, `--yes`, or `-y`.

Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox.
2 changes: 1 addition & 1 deletion src/commands/sandbox/policy/exclude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class PolicyExcludeCommand extends NemoClawCommand {
static summary = "Exclude an entry from the agent baseline policy";
static description =
"Persistently exclude an exact baseline network policy entry from a sandbox. The removed egress and its support impact are previewed before mutation, and the exclusion is replayed across rebuild.";
static usage = ["<name> <key> [--force|-f] [--yes|-y] [--dry-run]"];
static usage = ["<name> <key> [--force] [--yes|-y] [--dry-run]"];
static examples = [
"<%= config.bin %> sandbox policy exclude alpha nous_research --force",
"<%= config.bin %> sandbox policy exclude alpha nous_research --dry-run",
Expand Down
29 changes: 29 additions & 0 deletions src/commands/sandbox/policy/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ describe("policy mutation oclif commands", () => {
});
});

it("accepts the same acknowledgement flags on restore as on exclude (#8114)", async () => {
await PolicyRestoreCommand.run(["alpha", "nous_research", "-y"], rootDir);

expect(mocks.restoreSandboxBaseline).toHaveBeenCalledWith("alpha", {
key: "nous_research",
yes: true,
force: false,
dryRun: false,
});

await PolicyRestoreCommand.run(["alpha", "nous_research", "--yes"], rootDir);

expect(mocks.restoreSandboxBaseline).toHaveBeenLastCalledWith("alpha", {
key: "nous_research",
yes: true,
force: false,
dryRun: false,
});

await PolicyRestoreCommand.run(["alpha", "nous_research", "--force"], rootDir);

expect(mocks.restoreSandboxBaseline).toHaveBeenLastCalledWith("alpha", {
key: "nous_research",
yes: false,
force: true,
dryRun: false,
});
});

it("requires an explicit baseline key before dispatch", async () => {
await expect(PolicyExcludeCommand.run(["alpha"], rootDir)).rejects.toThrow();

Expand Down
7 changes: 4 additions & 3 deletions src/commands/sandbox/policy/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import {
commonPolicyOptions,
policyBaselineArgs,
policyBaselineRestoreFlags,
policyMutationFlags,
} from "../../../lib/sandbox/policy-command-support";

export default class PolicyRestoreCommand extends NemoClawCommand {
Expand All @@ -16,13 +16,14 @@ export default class PolicyRestoreCommand extends NemoClawCommand {
static summary = "Restore a previously excluded baseline entry";
static description =
"Restore an excluded baseline network policy entry against the current release baseline and drop its recorded exclusion.";
static usage = ["<name> <key> [--dry-run]"];
static usage = ["<name> <key> [--force] [--yes|-y] [--dry-run]"];
static examples = [
"<%= config.bin %> sandbox policy restore alpha nous_research",
"<%= config.bin %> sandbox policy restore alpha nous_research --yes",
"<%= config.bin %> sandbox policy restore alpha nous_research --dry-run",
];
static args = policyBaselineArgs;
static flags = policyBaselineRestoreFlags;
static flags = policyMutationFlags;

public async run(): Promise<void> {
const { args, flags } = await this.parse(PolicyRestoreCommand);
Expand Down
92 changes: 90 additions & 2 deletions src/lib/actions/sandbox/policy-channel-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr

import * as store from "../../credentials/store";
import * as policies from "../../policy";
import { digestBaselineEntry } from "../../policy/baseline-exclusion";
import type { PolicyObject } from "../../policy/preset-parsing";
import * as registry from "../../state/registry";

Expand Down Expand Up @@ -191,10 +192,97 @@ describe("restoreSandboxBaseline (#7178)", () => {
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});

it("restores a recorded exclusion", async () => {
it("restores a recorded exclusion after interactive acknowledgement", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
await restoreSandboxBaseline("alpha", { key: "nous_research" });
expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research");
expect(promptMock).toHaveBeenCalledOnce();
expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", {
expectedTargetDigest: digestBaselineEntry(NOUS_ENTRY),
});
});

it("requires explicit acknowledgement in non-interactive mode (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" }));
expect(code).toBe(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Non-interactive restore requires explicit acknowledgement"),
);
expect(promptMock).not.toHaveBeenCalled();
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});

it("does not restore when standard input closes before acknowledgement (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
promptMock.mockRejectedValue(
Object.assign(new Error("Prompt closed before input"), { code: "EOF" }),
);

const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" }));

expect(code).toBe(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("No input available on stdin"),
);
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("restores without prompting when acknowledged via --yes (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
process.env.NEMOCLAW_NON_INTERACTIVE = "1";
await restoreSandboxBaseline("alpha", { key: "nous_research", yes: true });
expect(promptMock).not.toHaveBeenCalled();
expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", {
expectedTargetDigest: digestBaselineEntry(NOUS_ENTRY),
});
});

it("restores without prompting when acknowledged via --force (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
await restoreSandboxBaseline("alpha", { key: "nous_research", force: true });
expect(promptMock).not.toHaveBeenCalled();
expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", {
expectedTargetDigest: digestBaselineEntry(NOUS_ENTRY),
});
});

it("binds stale exclusion cleanup to an absent preview", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "legacy_entry", digest: "digest-1" }]);
vi.mocked(policies.getSandboxBaselineEntry).mockReturnValue(null);

await restoreSandboxBaseline("alpha", { key: "legacy_entry", force: true });

expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "legacy_entry", {
expectedTargetDigest: null,
});
});

it("discloses the restored egress before interactive acknowledgement (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
promptMock.mockImplementation(async () => {
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("re-allows:"));
return "n";
});

await restoreSandboxBaseline("alpha", { key: "nous_research" });

expect(promptMock).toHaveBeenCalledOnce();
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});

it("aborts when the interactive confirmation is declined (#8114)", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
promptMock.mockResolvedValue("n");
await restoreSandboxBaseline("alpha", { key: "nous_research" });
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});

it("does not mutate on --dry-run", async () => {
getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]);
await restoreSandboxBaseline("alpha", { key: "nous_research", dryRun: true });
expect(promptMock).not.toHaveBeenCalled();
expect(restoreBaselineEntryMock).not.toHaveBeenCalled();
});

it("does not mutate when a recorded agent baseline cannot be resolved (#7194)", async () => {
Expand Down
30 changes: 28 additions & 2 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,10 +2014,13 @@ async function restoreSandboxBaselineUnlocked(
options: PolicyBaselineOptions,
): Promise<void> {
const dryRun = Boolean(options.dryRun);
const explicitAck = Boolean(options.yes || options.force);
const key = options.key?.trim();
if (!key) {
console.error(" A baseline key is required.");
console.error(` Usage: ${CLI_NAME} <sandbox> policy restore <key> [--dry-run]`);
console.error(
` Usage: ${CLI_NAME} <sandbox> policy restore <key> [--yes|-y] [--force] [--dry-run]`,
);
process.exit(1);
}

Expand All @@ -2036,6 +2039,7 @@ async function restoreSandboxBaselineUnlocked(
}

const entry = policies.getSandboxBaselineEntry(sandboxName, key);
const expectedTargetDigest = entry ? digestBaselineEntry(entry) : null;
if (entry) {
printBaselineEntryScope(
` Restoring baseline entry '${key}' for '${sandboxName}' re-allows:`,
Expand All @@ -2053,7 +2057,29 @@ async function restoreSandboxBaselineUnlocked(
return;
}

if (!policies.restoreBaselineEntry(sandboxName, key)) {
if (isNonInteractive() && !explicitAck) {
console.error(
" Non-interactive restore requires explicit acknowledgement: pass --force (or --yes).",
);
process.exit(1);
}
if (!explicitAck) {
let confirm: string;
try {
confirm = await askPrompt(` Restore '${key}' for sandbox '${sandboxName}'? [y/N]: `);
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
if (code !== "EOF") throw error;
console.error(" No input available on stdin, so policy restore cannot prompt.");
console.error(
` Usage: ${CLI_NAME} <sandbox> policy restore <key> [--yes|-y] [--force] [--dry-run]`,
);
process.exit(1);
}
if (!confirm.trim().toLowerCase().startsWith("y")) return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!policies.restoreBaselineEntry(sandboxName, key, { expectedTargetDigest })) {
refreshSandboxPolicyContextFile(sandboxName);
process.exit(1);
}
Expand Down
6 changes: 4 additions & 2 deletions src/lib/cli/public-display-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,16 +390,18 @@ const PUBLIC_DISPLAY_LAYOUT: Record<string, readonly PublicDisplayLayout[]> = {
{
group: "Policy Presets",
order: 21,
usage: "nemoclaw <name> policy exclude <key>",
description: "Exclude a baseline policy entry (persisted, replayed on rebuild)",
flags: "(--force, -f, --yes, -y, --dry-run)",
flags: "(--force, --yes, -y, --dry-run)",
},
],
"sandbox:policy:restore": [
{
group: "Policy Presets",
order: 22,
usage: "nemoclaw <name> policy restore <key>",
description: "Restore a previously excluded baseline entry",
flags: "(--dry-run)",
flags: "(--force, --yes, -y, --dry-run)",
},
],
"sandbox:rebuild": [
Expand Down
24 changes: 24 additions & 0 deletions src/lib/policy/baseline-exclusion-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,26 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => {
for (const mock of Object.values(mocks)) mock.mockReset();
});

it.each([
["changes", "nous_research", "stale-preview-digest", [RECORDED]],
["appears", "nous_research", null, [RECORDED]],
["disappears", "legacy_entry", LIVE_DIGEST, [{ ...RECORDED, key: "legacy_entry" }]],
] as const)("does not mutate when the baseline entry %s after the operator preview", (_change, key, expectedTargetDigest, exclusions) => {
mocks.getBaselineExclusions.mockReturnValue([...exclusions]);

expect(restoreBaselineEntry("alpha", key, { nonFatal: true, expectedTargetDigest })).toBe(
false,
);

expect(mocks.runCapture).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview"));
});

it("does not widen live egress when its durable transaction cannot be recorded", () => {
mocks.beginBaselineExclusionTransition.mockReturnValue(false);

Expand Down Expand Up @@ -548,8 +568,12 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => {

expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false);

expect(mocks.runCapture).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled();
expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("current release baseline for 'nous_research' is unreadable"),
);
Expand Down
Loading
Loading