Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,38 @@ The classifications are `blocked-by-policy`, `missing-approval`, `unsupported`,
| `--write` | Refresh `/sandbox/.openclaw/workspace/POLICY.md` inside the sandbox in addition to printing |
</AgentOnly>

### `$$nemoclaw <name> policy-simulate`

Dry-run a policy against a recorded agent execution trace without changing the sandbox.
The command evaluates each request in the trace against the sandbox's active policy presets, or against a candidate policy file, and reports which requests would be allowed, blocked, or not covered by any preset.
It exits non-zero when any request would be blocked or uncovered, so automation can gate a policy change on the result.

Trace files are JSONL with one JSON object per line.
Only the `host` field is required; missing `port`, `method`, and `path` fields match any endpoint rule for that host.

```bash
$$nemoclaw my-assistant policy-simulate --from-file ./agent-trace.jsonl
```

Preview a candidate policy file before applying it with `policy-add`:

```bash
$$nemoclaw my-assistant policy-simulate --policy-file ./presets/my-api.yaml --from-file ./agent-trace.jsonl
```

Pipe requests from another process by passing `-` as the trace file:

```bash
cat trace.jsonl | $$nemoclaw my-assistant policy-simulate --from-file -
```

| Flag | Description |
|------|-------------|
| `--from-file` | Path to a JSONL trace file, or `-` to read the trace from stdin |
| `--policy-file` | Simulate a candidate policy YAML file instead of the active sandbox policy |
| `--preset-name` | Name to assign to the candidate presets when `--policy-file` is used |
| `--json` | Emit the simulation results as a structured JSON object |

### `$$nemoclaw <name> hosts-add`

Add a host alias to the sandbox pod template.
Expand Down
112 changes: 112 additions & 0 deletions src/commands/sandbox/policy/simulate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import readline from "node:readline";

import { Args, Flags } from "@oclif/core";

import { simulateSandboxPolicy } from "../../../lib/actions/sandbox/policy-simulate";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";
import { renderSimulationReport, summaryNeedsAttention } from "../../../lib/policy/simulate";

export default class PolicySimulateCommand extends NemoClawCommand {
static id = "sandbox:policy:simulate";
static strict = true;
static summary = "Statically evaluate a recorded trace against registered policy content";
static description =
`Statically evaluate which network requests in a recorded trace match the sandbox's registered policy content (built-in presets plus custom and generated policies), or a candidate policy file.

The evaluation is fail-closed and covers host, port, method, and path only. A request is reported ALLOWED only when every evaluated dimension is proven from the trace row; policy constraints the engine does not evaluate (protocol, allowed_ips, TLS, ancestry, MCP, deny rules) and trace rows missing a constrained field produce UNKNOWN verdicts. Malformed trace rows are reported, not dropped. Live gateway state is not consulted, so drift between the registry and the gateway is not detected.

Trace files are JSONL, one JSON object per line with at minimum a "host" field:
{"host":"api.slack.com","port":443,"method":"POST","path":"/api/chat.postMessage"}

Use --from-file to provide a recorded trace, or pipe requests to stdin.
Use --policy-file to evaluate a candidate policy YAML without applying it.
Exit code is non-zero when any request is blocked, uncovered, or unknown, or any trace row is invalid.`;

static usage = ["<name> --from-file <trace.jsonl> [--policy-file <policy.yaml>] [--json]"];

static examples = [
"<%= config.bin %> sandbox policy simulate alpha --from-file ./agent-trace.jsonl",
"<%= config.bin %> sandbox policy simulate alpha --policy-file ./slack.yaml --from-file ./trace.jsonl",
"<%= config.bin %> sandbox policy simulate alpha --from-file ./trace.jsonl --json",
];

static args = {
sandboxName: Args.string({
name: "sandbox",
description: "Sandbox name",
ignoreStdin: true,
required: true,
}),
};

static flags = {
"from-file": Flags.string({
description:
'Path to a JSONL trace file, or "-" to read from stdin. Each line: {"host":"...","port":443,"method":"GET","path":"/"}',
required: true,
}),
"policy-file": Flags.string({
description:
"Path to a candidate policy YAML file to test instead of the active sandbox policy. Useful for previewing a policy before applying it.",
required: false,
}),
"preset-name": Flags.string({
description:
"Name to assign to the candidate presets when --policy-file is used. When omitted, preset names from the YAML file are kept.",
required: false,
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
json: Flags.boolean({
description: "Output simulation results as JSON",
default: false,
}),
};

public async run(): Promise<void> {
const { args, flags } = await this.parse(PolicySimulateCommand);

const stdinLines = flags["from-file"] === "-" ? await readStdin() : undefined;

const result = simulateSandboxPolicy({
sandboxName: args.sandboxName,
fromFile: flags["from-file"],
policyFile: flags["policy-file"],
presetName: flags["preset-name"],
stdinLines,
});

if (result.kind === "error") {
this.failWithLines(result.lines);
return;
}

const report = flags.json
? JSON.stringify({ ...result.summary, notes: result.notes }, null, 2)
: renderSimulationReport(result.summary, false);
process.stdout.write(report + (report.endsWith("\n") ? "" : "\n"));
if (!flags.json) {
for (const note of result.notes) {
console.error(` Note: ${note}`);
}
}

if (summaryNeedsAttention(result.summary)) {
this.setExitCode(1);
}
}
}

async function readStdin(): Promise<string[]> {
return new Promise((resolve) => {
const lines: string[] = [];
if (process.stdin.isTTY) {
resolve([]);
return;
}
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on("line", (line) => lines.push(line));
rl.on("close", () => resolve(lines));
});
}
Loading
Loading