Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/network-policy/customize-network-policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,45 @@ $$nemoclaw my-assistant policy-remove my-internal-api --yes

`policy-remove` accepts both built-in and custom preset names. Run `$$nemoclaw <name> 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 <name> 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, internal IP ranges, and binary allowlists are not included — only host stems and category-level summaries appear.

The context also documents how the agent should classify a failed host or integration attempt:

| Classification | When it applies |
|----------------|-----------------|
| `blocked-by-policy` | The host is not declared by any applied preset, or the request is refused with HTTP 403 / a network-block error code (`EHOSTUNREACH`, `ENETUNREACH`, `ENOTFOUND`, `ECONNREFUSED`, `ETIMEDOUT`, `EAI_AGAIN`). |
| `missing-approval` | The host is allowed by an applied preset, but the request is refused with HTTP 401 or 403 (credentials or scope are missing). |
| `unsupported` | The capability is not offered by NemoClaw or OpenShell. |
| `unknown` | None of the above apply; the agent should surface the underlying error. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Use the classification to pick the next step.
For `blocked-by-policy`, run `$$nemoclaw <name> policy-add <preset>` 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.
Expand Down
25 changes: 25 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,31 @@ $$nemoclaw my-assistant policy-remove pypi --yes
Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`.
If the preset is unknown or not currently applied, the command exits non-zero with a clear error.

### `$$nemoclaw <name> policy-explain`
Comment thread
laitingsheng marked this conversation as resolved.
Resolved

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 — `blocked-by-policy`, `missing-approval`, `unsupported`, or `unknown` — so the agent can pick a remediation step instead of surfacing a lower-level network error.

| Flag | Description |
|------|-------------|
| `--yes`, `--force` | Skip the confirmation prompt (requires a preset name) |
Expand Down
44 changes: 44 additions & 0 deletions src/commands/sandbox/policy/explain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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 = ["<name> [--json]"];
static examples = [
"<%= config.bin %> sandbox policy explain alpha",
"<%= config.bin %> sandbox policy explain alpha --json",
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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<void> {
const { args, flags } = await this.parse(SandboxPolicyExplainCommand);
explainSandboxPolicy(
args.sandboxName,
{ json: flags.json, writeToSandbox: flags.write },
{ logJson: (value) => this.logJson(value) },
);
}
}
16 changes: 16 additions & 0 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,23 @@ import {
isDockerRuntimeDown,
printDockerRuntimeDownGuidance,
} from "./gateway-failure-classifier";
import {
POLICY_CONTEXT_SANDBOX_PATH,
writePolicyContextToSandbox,
} from "./policy-explain";
import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery";
import { rebuildSandbox } from "./rebuild";
import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification";

function refreshSandboxPolicyContextFile(sandboxName: string): void {
const result = writePolicyContextToSandbox(sandboxName);
if (!result.written && result.reason && result.reason !== "sandbox unreachable") {
console.error(
` Could not refresh ${POLICY_CONTEXT_SANDBOX_PATH} for sandbox '${sandboxName}': ${result.reason}`,
);
}
}

type ChannelMutationOptions = {
channel?: string;
dryRun?: boolean;
Expand Down Expand Up @@ -194,6 +207,7 @@ export async function addSandboxPolicy(
process.exit(1);
}
syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add");
refreshSandboxPolicyContextFile(sandboxName);
}

/**
Expand Down Expand Up @@ -247,6 +261,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) {
Expand Down Expand Up @@ -1580,4 +1595,5 @@ export async function removeSandboxPolicy(
process.exit(1);
}
syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "remove");
refreshSandboxPolicyContextFile(sandboxName);
}
142 changes: 142 additions & 0 deletions src/lib/actions/sandbox/policy-explain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// 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 <preset>`,
remove: `nemoclaw ${sandboxName} policy remove <preset>`,
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();
});
});

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("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).toEqual({ written: false, reason: "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");
});
});
Loading
Loading