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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 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,57 @@ $$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, 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 <sandbox> 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` — 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.
5. `unknown` — none of the above apply; the agent should surface the underlying error.

Each classification also carries a `confidence` field set to `high` or `low`. Low-confidence verdicts (notably 403 on an active host) mean the agent should report multiple possibilities to the user instead of treating the next-step recommendation as authoritative.

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
31 changes: 31 additions & 0 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> policy-explain`

Print a redacted summary of the active policy context for a sandbox so an agent or operator can reason about what is allowed, what is blocked, and how to request a change.
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 <name> hosts-add`

Add a host alias to the sandbox pod template.
Expand Down
31 changes: 31 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
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 <name> hosts-add`

Add a host alias to the sandbox pod template.
Expand Down
45 changes: 45 additions & 0 deletions src/commands/sandbox/policy/explain.ts
Original file line number Diff line number Diff line change
@@ -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 = ["<name> [--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<void> {
const { args, flags } = await this.parse(SandboxPolicyExplainCommand);
explainSandboxPolicy(
args.sandboxName,
{ json: flags.json, writeToSandbox: flags.write },
{ logJson: (value) => this.logJson(value) },
);
}
}
6 changes: 6 additions & 0 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -194,6 +195,7 @@ export async function addSandboxPolicy(
process.exit(1);
}
syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "add");
refreshSandboxPolicyContextFile(sandboxName);
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1580,4 +1585,5 @@ export async function removeSandboxPolicy(
process.exit(1);
}
syncSessionPolicyPresetsWithRegistry(sandboxName, answer, "remove");
refreshSandboxPolicyContextFile(sandboxName);
}
73 changes: 73 additions & 0 deletions src/lib/actions/sandbox/policy-context-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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();
});
});
77 changes: 77 additions & 0 deletions src/lib/actions/sandbox/policy-context-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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.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 };
Loading
Loading