From 1e4243e55619242ac5119896c3acafad9f02b4b5 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Mon, 3 Aug 2026 10:37:02 +0000 Subject: [PATCH 1/8] fix(policy): accept acknowledgement flags on policy restore Restore now previews the egress it re-allows and then requires the same explicit acknowledgement as exclude, so both halves of the pair share one flag set and one non-interactive contract. Signed-off-by: Tinson Lai --- docs/reference/commands.mdx | 14 +++-- docs/reference/network-policies.mdx | 8 +-- src/commands/sandbox/policy/exclude.ts | 2 +- src/commands/sandbox/policy/mutate.test.ts | 20 +++++++ src/commands/sandbox/policy/restore.ts | 7 +-- .../sandbox/policy-channel-baseline.test.ts | 53 ++++++++++++++++++- src/lib/actions/sandbox/policy-channel.ts | 14 ++++- src/lib/sandbox/policy-command-support.ts | 2 - 8 files changed, 105 insertions(+), 15 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index b7d6dbb3fcd..47ee4e93d21 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1959,21 +1959,27 @@ $$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 ` to clear the stale record, preview the current scope with `policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. -When a release removes the entry, run `policy restore ` 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 `policy restore --force` to clear the stale record, preview the current scope with `policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. +When a release removes the entry, run `policy restore --force` only to clear the stale exclusion record; there is no replacement scope to review or re-approve. ### `$$nemoclaw policy restore` 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. +The command prints the egress the restore re-allows, then asks for the same explicit acknowledgement as `policy exclude`. +Use `--force` or `--yes` for non-interactive acknowledgement, or `--dry-run` to preview the egress that would be restored. 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 ``` +| Flag | Description | +|------|-------------| +| `--yes`, `-y`, `--force` | Skip the confirmation prompt | +| `--dry-run` | Preview the restored egress without applying changes | + ### `$$nemoclaw 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. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index bc07eb619d1..2cc5109ff65 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -316,7 +316,7 @@ To recover from baseline drift, first check whether the release changed the entr 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: ```bash -$$nemoclaw policy restore +$$nemoclaw policy restore --force $$nemoclaw policy exclude --dry-run $$nemoclaw policy exclude --force ``` @@ -325,7 +325,7 @@ If the release removed the entry entirely, `policy exclude ` fails with "Un Clear the stale record instead: ```bash -$$nemoclaw policy restore +$$nemoclaw policy restore --force ``` List active exclusions with `policy list`. @@ -335,7 +335,9 @@ An unreadable live policy is unverified, while a live policy that contains the k Restore an entry against the current baseline and clear its exclusion: ```bash -$$nemoclaw policy restore +$$nemoclaw policy restore --force ``` +`policy restore` previews the egress it re-allows and then requires the same explicit acknowledgement as `policy exclude` (`--force` or `--yes` in non-interactive use). + Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/src/commands/sandbox/policy/exclude.ts b/src/commands/sandbox/policy/exclude.ts index ac6dc33c01d..19923d10cbc 100644 --- a/src/commands/sandbox/policy/exclude.ts +++ b/src/commands/sandbox/policy/exclude.ts @@ -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 = [" [--force|-f] [--yes|-y] [--dry-run]"]; + static usage = [" [--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", diff --git a/src/commands/sandbox/policy/mutate.test.ts b/src/commands/sandbox/policy/mutate.test.ts index 0eed1116d21..e3a02bc0871 100644 --- a/src/commands/sandbox/policy/mutate.test.ts +++ b/src/commands/sandbox/policy/mutate.test.ts @@ -92,6 +92,26 @@ 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", "--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(); diff --git a/src/commands/sandbox/policy/restore.ts b/src/commands/sandbox/policy/restore.ts index 0a9a426cdc6..649cef6cc39 100644 --- a/src/commands/sandbox/policy/restore.ts +++ b/src/commands/sandbox/policy/restore.ts @@ -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 { @@ -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 = [" [--dry-run]"]; + static usage = [" [--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 { const { args, flags } = await this.parse(PolicyRestoreCommand); diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index 6d2bba67c24..0fb84a576fa 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -191,12 +191,63 @@ 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(promptMock).toHaveBeenCalledOnce(); expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research"); }); + 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(restoreBaselineEntryMock).not.toHaveBeenCalled(); + }); + + 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"); + }); + + 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"); + }); + + 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 () => { getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); vi.mocked(policies.resolveSandboxBaselinePolicy).mockImplementation(() => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index ed1bb1c059a..1d61f9ee9b2 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -2014,10 +2014,11 @@ async function restoreSandboxBaselineUnlocked( options: PolicyBaselineOptions, ): Promise { 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} policy restore [--dry-run]`); + console.error(` Usage: ${CLI_NAME} policy restore [--force] [--dry-run]`); process.exit(1); } @@ -2053,6 +2054,17 @@ async function restoreSandboxBaselineUnlocked( return; } + if (isNonInteractive() && !explicitAck) { + console.error( + " Non-interactive restore requires explicit acknowledgement: pass --force (or --yes).", + ); + process.exit(1); + } + if (!explicitAck) { + const confirm = await askPrompt(` Restore '${key}' for sandbox '${sandboxName}'? [y/N]: `); + if (!confirm.trim().toLowerCase().startsWith("y")) return; + } + if (!policies.restoreBaselineEntry(sandboxName, key)) { refreshSandboxPolicyContextFile(sandboxName); process.exit(1); diff --git a/src/lib/sandbox/policy-command-support.ts b/src/lib/sandbox/policy-command-support.ts index fdcc4d33113..bce1dae2a1a 100644 --- a/src/lib/sandbox/policy-command-support.ts +++ b/src/lib/sandbox/policy-command-support.ts @@ -45,5 +45,3 @@ export const policyMutationFlags = { force: forceFlag(), "dry-run": dryRunFlag(), }; - -export const policyBaselineRestoreFlags = { "dry-run": dryRunFlag() }; From 262ba364ee841d3f3b050ba590265f51c29cfda5 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 16:20:56 -0700 Subject: [PATCH 2/8] fix(policy): handle restore acknowledgement failures Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 9 +++++---- docs/reference/network-policies.mdx | 4 +++- src/commands/sandbox/policy/mutate.test.ts | 9 +++++++++ .../sandbox/policy-channel-baseline.test.ts | 19 +++++++++++++++++++ src/lib/actions/sandbox/policy-channel.ts | 17 +++++++++++++++-- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 47ee4e93d21..eb035a0be6b 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1959,14 +1959,15 @@ $$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 --force` to clear the stale record, preview the current scope with `policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. -When a release removes the entry, run `policy restore --force` 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 policy restore --force` to clear the stale record, preview the current scope with `$$nemoclaw policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. +When a release removes the entry, run `$$nemoclaw policy restore --force` only to clear the stale exclusion record; there is no replacement scope to review or re-approve. ### `$$nemoclaw policy restore` Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record. -The command prints the egress the restore re-allows, then asks for the same explicit acknowledgement as `policy exclude`. -Use `--force` or `--yes` for non-interactive acknowledgement, or `--dry-run` to preview the egress that would be restored. +When the current baseline still defines the entry, the command prints the egress it re-allows. +When the baseline no longer defines the entry, the command states that it will clear only the stale exclusion record. +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. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 2cc5109ff65..f2e442b1e45 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -338,6 +338,8 @@ Restore an entry against the current baseline and clear its exclusion: $$nemoclaw policy restore --force ``` -`policy restore` previews the egress it re-allows and then requires the same explicit acknowledgement as `policy exclude` (`--force` or `--yes` in non-interactive use). +When the current baseline still defines the entry, `policy restore` prints the egress it re-allows. +When the baseline no longer defines the entry, the command states that it will clear only the stale exclusion record. +Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/src/commands/sandbox/policy/mutate.test.ts b/src/commands/sandbox/policy/mutate.test.ts index e3a02bc0871..bf33da08561 100644 --- a/src/commands/sandbox/policy/mutate.test.ts +++ b/src/commands/sandbox/policy/mutate.test.ts @@ -102,6 +102,15 @@ describe("policy mutation oclif commands", () => { 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", { diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index 0fb84a576fa..6aa399469d7 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -203,6 +203,25 @@ describe("restoreSandboxBaseline (#7178)", () => { 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(); }); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 1d61f9ee9b2..da957560911 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -2018,7 +2018,9 @@ async function restoreSandboxBaselineUnlocked( const key = options.key?.trim(); if (!key) { console.error(" A baseline key is required."); - console.error(` Usage: ${CLI_NAME} policy restore [--force] [--dry-run]`); + console.error( + ` Usage: ${CLI_NAME} policy restore [--yes|-y] [--force] [--dry-run]`, + ); process.exit(1); } @@ -2061,7 +2063,18 @@ async function restoreSandboxBaselineUnlocked( process.exit(1); } if (!explicitAck) { - const confirm = await askPrompt(` Restore '${key}' for sandbox '${sandboxName}'? [y/N]: `); + 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} policy restore [--yes|-y] [--force] [--dry-run]`, + ); + process.exit(1); + } if (!confirm.trim().toLowerCase().startsWith("y")) return; } From 34661373ffbb12ee610fe4053678570f3f14b0a6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 19:47:43 -0700 Subject: [PATCH 3/8] fix(cli): correct policy help flags Show the supported acknowledgement flags for policy exclude and restore. Cover the root-help entries so unsupported aliases cannot return. Signed-off-by: Carlos Villela --- src/lib/cli/public-display-defaults.ts | 6 ++++-- test/root-help.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index cc50558897c..161d980ae26 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -390,16 +390,18 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { group: "Policy Presets", order: 21, + usage: "nemoclaw policy exclude ", 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 policy restore ", description: "Restore a previously excluded baseline entry", - flags: "(--dry-run)", + flags: "(--force, --yes, -y, --dry-run)", }, ], "sandbox:rebuild": [ diff --git a/test/root-help.test.ts b/test/root-help.test.ts index 2fb8b413fb3..fd2047c278c 100644 --- a/test/root-help.test.ts +++ b/test/root-help.test.ts @@ -63,6 +63,21 @@ describe("root help", () => { } }); + it("shows the supported policy acknowledgement flags", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + renderRootHelp(); + + const output = log.mock.calls.map(([line]) => String(line)).join("\n"); + expect(output).toMatch( + /nemoclaw policy exclude [^\n]*\(--force, --yes, -y, --dry-run\)/, + ); + expect(output).toMatch( + /nemoclaw policy restore [^\n]*\(--force, --yes, -y, --dry-run\)/, + ); + expect(output).not.toContain("(--force, -f, --yes, -y, --dry-run)"); + }); + it("lists --destroy-user-data under uninstall flags without unsupported --keep flags", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); From bc8ee8301eb22c2e401e1c37fe271966ae8dcac3 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 20:13:04 -0700 Subject: [PATCH 4/8] docs(cli): align policy command headings Include the required key argument in the exclude and restore reference headings. Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index ca50f3ac1de..0830a9ff36b 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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 policy exclude` +### `$$nemoclaw policy exclude ` 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. @@ -1975,7 +1975,7 @@ $$nemoclaw my-assistant policy exclude nous_research --force When a release changes an excluded entry, first run `$$nemoclaw policy restore --force` to clear the stale record, preview the current scope with `$$nemoclaw policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. When a release removes the entry, run `$$nemoclaw policy restore --force` only to clear the stale exclusion record; there is no replacement scope to review or re-approve. -### `$$nemoclaw policy restore` +### `$$nemoclaw policy restore ` Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record. When the current baseline still defines the entry, the command prints the egress it re-allows. From 8a09d19a870d23e37060fee2aa96b1cbf368a134 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 21:25:49 -0700 Subject: [PATCH 5/8] fix(policy): bind restore to reviewed scope Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 17 ++++++--- docs/reference/network-policies.mdx | 15 +++++--- .../sandbox/policy-channel-baseline.test.ts | 24 ++++++++++-- src/lib/actions/sandbox/policy-channel.ts | 3 +- .../baseline-exclusion-persistence.test.ts | 22 +++++++++++ src/lib/policy/index.ts | 37 ++++++++++++++----- 6 files changed, 95 insertions(+), 23 deletions(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0830a9ff36b..ef7ac57e810 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1972,14 +1972,19 @@ $$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 `$$nemoclaw policy restore --force` to clear the stale record, preview the current scope with `$$nemoclaw policy exclude --dry-run`, and then explicitly exclude it again if you still accept the support impact. -When a release removes the entry, run `$$nemoclaw policy restore --force` 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 policy restore --dry-run` to preview the current baseline egress that restoration would allow again. +After you review the output, run `$$nemoclaw policy restore --force` to allow that egress again and clear the stale exclusion record. +Then preview the current exclusion scope with `$$nemoclaw policy exclude --dry-run` and reapply it with `$$nemoclaw policy exclude --force` only if you still accept the support impact. +When a release removes the entry, first run `$$nemoclaw policy restore --dry-run` to confirm that restoration will clear only the stale exclusion record. +After you review the output, run `$$nemoclaw policy restore --force`; there is no replacement scope to review or approve. ### `$$nemoclaw policy restore ` Restore a previously excluded entry from the current agent baseline and clear its durable exclusion record. -When the current baseline still defines the entry, the command prints the egress it re-allows. -When the baseline no longer defines the entry, the command states that it will clear only the stale exclusion record. +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. @@ -1989,10 +1994,12 @@ $$nemoclaw my-assistant policy restore nous_research --dry-run $$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 restored egress without applying changes | +| `--dry-run` | Preview the egress restoration or stale-record cleanup without applying changes | ### `$$nemoclaw policy explain` diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index f2e442b1e45..14784ab6a2c 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -313,18 +313,22 @@ 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 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 --force` restores the current baseline entry and clears the stale exclusion record. +This allows the entry's listed egress again before you can exclude it again. +Preview the restore and exclusion scopes before applying either change: ```bash +$$nemoclaw policy restore --dry-run $$nemoclaw policy restore --force $$nemoclaw policy exclude --dry-run $$nemoclaw policy exclude --force ``` If the release removed the entry entirely, `policy exclude ` fails with "Unknown baseline entry" because there is nothing left to exclude. -Clear the stale record instead: +Preview the stale-record cleanup, then clear the record without changing live egress: ```bash +$$nemoclaw policy restore --dry-run $$nemoclaw policy restore --force ``` @@ -332,14 +336,15 @@ 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 --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 policy restore --dry-run $$nemoclaw policy restore --force ``` -When the current baseline still defines the entry, `policy restore` prints the egress it re-allows. -When the baseline no longer defines the entry, the command states that it will clear only the stale exclusion record. Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index 6aa399469d7..e05f721da4e 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -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"; @@ -195,7 +196,9 @@ describe("restoreSandboxBaseline (#7178)", () => { getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); await restoreSandboxBaseline("alpha", { key: "nous_research" }); expect(promptMock).toHaveBeenCalledOnce(); - expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research"); + expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research", { + expectedTargetDigest: digestBaselineEntry(NOUS_ENTRY), + }); }); it("requires explicit acknowledgement in non-interactive mode (#8114)", async () => { @@ -230,14 +233,29 @@ describe("restoreSandboxBaseline (#7178)", () => { process.env.NEMOCLAW_NON_INTERACTIVE = "1"; await restoreSandboxBaseline("alpha", { key: "nous_research", yes: true }); expect(promptMock).not.toHaveBeenCalled(); - expect(restoreBaselineEntryMock).toHaveBeenCalledWith("alpha", "nous_research"); + 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"); + 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 () => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index da957560911..ebad45040b4 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -2039,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:`, @@ -2078,7 +2079,7 @@ async function restoreSandboxBaselineUnlocked( if (!confirm.trim().toLowerCase().startsWith("y")) return; } - if (!policies.restoreBaselineEntry(sandboxName, key)) { + if (!policies.restoreBaselineEntry(sandboxName, key, { expectedTargetDigest })) { refreshSandboxPolicyContextFile(sandboxName); process.exit(1); } diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts index 0595509fe53..06120245d27 100644 --- a/src/lib/policy/baseline-exclusion-persistence.test.ts +++ b/src/lib/policy/baseline-exclusion-persistence.test.ts @@ -380,6 +380,28 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { for (const mock of Object.values(mocks)) mock.mockReset(); }); + it.each([ + ["changes", "nous_research", "stale-preview-digest"], + ["appears", "nous_research", null], + ["disappears", "legacy_entry", LIVE_DIGEST], + ] as const)("does not mutate when the baseline entry %s after the operator preview", (_change, key, expectedTargetDigest) => { + if (key === "legacy_entry") { + mocks.getBaselineExclusions.mockReturnValue([{ ...RECORDED, key }]); + } + + 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); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index b5bb851253d..3928d1c8fcd 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1387,10 +1387,15 @@ function excludeBaselineEntryOnGateway( * baseline and drop its recorded exclusion. When the release removed the entry * entirely, only the registry record is cleared. */ +type RestoreBaselineEntryOptions = { + nonFatal?: boolean; + expectedTargetDigest?: string | null; +}; + function restoreBaselineEntry( sandboxName: string, key: string, - options: { nonFatal?: boolean } = {}, + options: RestoreBaselineEntryOptions = {}, ): boolean { return withRecordedSandboxGateway(sandboxName, (gatewayName) => restoreBaselineEntryOnGateway(sandboxName, key, options, gatewayName), @@ -1400,9 +1405,28 @@ function restoreBaselineEntry( function restoreBaselineEntryOnGateway( sandboxName: string, key: string, - options: { nonFatal?: boolean }, + options: RestoreBaselineEntryOptions, gatewayName: string, ): boolean { + // Resolve the current agent baseline before changing either durable or live + // state. A missing non-OpenClaw baseline must not be mistaken for a release + // that intentionally removed this key. + // Bind the mutation to the target disclosed before acknowledgement. This + // check precedes transaction recovery because reconciliation can change the + // durable journal. + const entry = getSandboxBaselineEntry(sandboxName, key); + const target = entry ? { entry, digest: digestBaselineEntry(entry) } : null; + const targetDigest = target?.digest ?? null; + if ( + Object.prototype.hasOwnProperty.call(options, "expectedTargetDigest") && + targetDigest !== options.expectedTargetDigest + ) { + console.error( + ` Baseline entry '${key}' changed after preview. Re-run the command to review its current scope; no policy changes were made.`, + ); + return false; + } + const reconciled = reconcileBaselineExclusionTransition(sandboxName, key, gatewayName); if (!reconciled) return false; if (reconciled.state === "restored") return true; @@ -1419,22 +1443,17 @@ function restoreBaselineEntryOnGateway( ); return false; } - // Resolve the current agent baseline before changing either durable or live - // state. A missing non-OpenClaw baseline must not be mistaken for a release - // that intentionally removed this key. - const entry = getSandboxBaselineEntry(sandboxName, key); const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); if (!currentPolicy) { console.error(` Could not read current policy for sandbox '${sandboxName}'.`); return false; } - if (!entry) { + if (!target) { return registryTransitionStep( () => registry.removeBaselineExclusion(sandboxName, key), `The obsolete exclusion for '${key}' could not be cleared; no live policy changes were made.`, ); } - const targetDigest = digestBaselineEntry(entry); const live = inspectLiveBaselineEntry(currentPolicy, key); if (live.state === "invalid") { console.error( @@ -1462,7 +1481,7 @@ function restoreBaselineEntryOnGateway( ? reconciled.transition : beginBaselineExclusionTransition(sandboxName, "restore", recordedExclusion, targetDigest); if (!transition) return false; - const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, entry); + const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, target.entry); const pushSucceeded = attemptBaselineTransitionPolicyPush( sandboxName, updated, From 0f8f0e196060eb6f355761e546dbf441d9744758 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 21:40:23 -0700 Subject: [PATCH 6/8] test(policy): keep preview drift cases linear Signed-off-by: Carlos Villela --- .../policy/baseline-exclusion-persistence.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts index 06120245d27..da72203420d 100644 --- a/src/lib/policy/baseline-exclusion-persistence.test.ts +++ b/src/lib/policy/baseline-exclusion-persistence.test.ts @@ -381,13 +381,11 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { }); it.each([ - ["changes", "nous_research", "stale-preview-digest"], - ["appears", "nous_research", null], - ["disappears", "legacy_entry", LIVE_DIGEST], - ] as const)("does not mutate when the baseline entry %s after the operator preview", (_change, key, expectedTargetDigest) => { - if (key === "legacy_entry") { - mocks.getBaselineExclusions.mockReturnValue([{ ...RECORDED, key }]); - } + ["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, From ca6988bae95ccd10753c1681db4020e79d45c4bc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 21:47:51 -0700 Subject: [PATCH 7/8] docs(policy): clarify restore acknowledgement Signed-off-by: Carlos Villela --- docs/reference/network-policies.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 14784ab6a2c..dc20c7faabc 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -314,7 +314,7 @@ 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 policy explain` or `doctor` reports which). If the entry still exists with different content, `policy restore --force` restores the current baseline entry and clears the stale exclusion record. -This allows the entry's listed egress again before you can exclude it again. +This allows the entry's listed egress before you can exclude it again. Preview the restore and exclusion scopes before applying either change: ```bash @@ -324,7 +324,7 @@ $$nemoclaw policy exclude --dry-run $$nemoclaw policy exclude --force ``` -If the release removed the entry entirely, `policy exclude ` fails with "Unknown baseline entry" because there is nothing left to exclude. +If the release removed the entry entirely, `policy exclude ` fails with `Unknown baseline entry ''.` because there is nothing left to exclude. Preview the stale-record cleanup, then clear the record without changing live egress: ```bash @@ -345,6 +345,8 @@ $$nemoclaw policy restore --dry-run $$nemoclaw policy restore --force ``` -Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. +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. From 8800728496039aef6b5d22c1f9c9fdee8872d5a6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 3 Aug 2026 22:07:49 -0700 Subject: [PATCH 8/8] fix(policy): reject unreadable restore baselines Signed-off-by: Carlos Villela --- .../policy/baseline-exclusion-persistence.test.ts | 4 ++++ src/lib/policy/index.ts | 14 +++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts index da72203420d..2ce24a84f7d 100644 --- a/src/lib/policy/baseline-exclusion-persistence.test.ts +++ b/src/lib/policy/baseline-exclusion-persistence.test.ts @@ -568,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"), ); diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 3928d1c8fcd..dfcfc7f3f2f 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1332,7 +1332,7 @@ function excludeBaselineEntryOnGateway( } if (live.state === "present" && live.digest !== digest) { console.error( - ` Baseline entry '${key}' changed after preview. Re-run the command to review its current scope; no policy changes were made.`, + ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, ); return false; } @@ -1414,7 +1414,15 @@ function restoreBaselineEntryOnGateway( // Bind the mutation to the target disclosed before acknowledgement. This // check precedes transaction recovery because reconciliation can change the // durable journal. - const entry = getSandboxBaselineEntry(sandboxName, key); + let entry: PolicyObject | null; + try { + entry = getSandboxBaselineEntry(sandboxName, key); + } catch { + console.error( + ` The current release baseline for '${key}' is unreadable. No policy changes were made.`, + ); + return false; + } const target = entry ? { entry, digest: digestBaselineEntry(entry) } : null; const targetDigest = target?.digest ?? null; if ( @@ -1422,7 +1430,7 @@ function restoreBaselineEntryOnGateway( targetDigest !== options.expectedTargetDigest ) { console.error( - ` Baseline entry '${key}' changed after preview. Re-run the command to review its current scope; no policy changes were made.`, + ` Baseline entry '${key}' changed after preview. Rerun the command to review its current scope; no policy changes were made.`, ); return false; }