Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
52 changes: 52 additions & 0 deletions .agents/skills/nemoclaw-user-manage-policy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ To include a preset in the baseline, merge its entries into `openclaw-sandbox.ya
> **Note:** The `openshell policy set --policy <file> <sandbox-name>` command operates on raw policy files and does not
> accept the `preset:` metadata block used in preset YAML files. Use `nemoclaw <name> policy-add` for
> presets.

For scripted workflows, `policy-add` and `policy-remove` accept the preset name as a positional argument:

```console
Expand All @@ -259,6 +260,57 @@ See Commands (use the `nemoclaw-user-reference` skill) for the full flag referen

`nemoclaw <name> rebuild` reapplies every policy preset to the recreated sandbox, so presets survive an agent-version upgrade without manual reapplication.

## Step 8: Custom Preset Files

Apply a user-authored preset YAML to a running sandbox without editing the baseline or dropping to `openshell policy set`.

### Authoring

A custom preset follows the same shape as the built-in ones under `nemoclaw-blueprint/policies/presets/`:

```yaml
preset:
name: my-internal-api
description: "Internal service"
network_policies:
my-internal-api:
name: my-internal-api
endpoints:
- host: api.example.internal
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
```

The top-level `preset.name` must be a lowercase RFC 1123 label (letters, digits, hyphens) and must not collide with a built-in preset name such as `slack` or `pypi`.
Rename `preset.name` if NemoClaw refuses to apply the file because of a collision.

### Apply a Single File

```console
$ nemoclaw my-assistant policy-add --from-file ./presets/my-internal-api.yaml
```

Preview the endpoints without applying with `--dry-run`, and skip the confirmation prompt with `--yes` or by exporting `NEMOCLAW_NON_INTERACTIVE=1`.

### Apply Every File in a Directory

```console
$ nemoclaw my-assistant policy-add --from-dir ./presets/ --yes
```

Files are processed in lexicographic order.
Processing stops at the first failure; presets already applied are not rolled back.
Fix the failing file and re-run the command to continue.

> [!WARNING]
> Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations.
> Review every host in a custom preset before applying it, especially when the file originates outside your team.

## Related Skills

- `nemoclaw-user-reference` — Network Policies (use the `nemoclaw-user-reference` skill) for the full baseline policy reference
Expand Down
52 changes: 52 additions & 0 deletions docs/network-policy/customize-network-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ The `openshell policy set --policy <file> <sandbox-name>` command operates on ra
accept the `preset:` metadata block used in preset YAML files. Use `nemoclaw <name> policy-add` for
presets.
:::

For scripted workflows, `policy-add` and `policy-remove` accept the preset name as a positional argument:

```console
Expand All @@ -230,6 +231,57 @@ See [Commands](../reference/commands.md#nemoclaw-name-policy-add) for the full f

`nemoclaw <name> rebuild` reapplies every policy preset to the recreated sandbox, so presets survive an agent-version upgrade without manual reapplication.

## Custom Preset Files

Apply a user-authored preset YAML to a running sandbox without editing the baseline or dropping to `openshell policy set`.

### Authoring

A custom preset follows the same shape as the built-in ones under `nemoclaw-blueprint/policies/presets/`:

```yaml
preset:
name: my-internal-api
description: "Internal service"
network_policies:
my-internal-api:
name: my-internal-api
endpoints:
- host: api.example.internal
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
```

The top-level `preset.name` must be a lowercase RFC 1123 label (letters, digits, hyphens) and must not collide with a built-in preset name such as `slack` or `pypi`.
Rename `preset.name` if NemoClaw refuses to apply the file because of a collision.

### Apply a Single File

```console
$ nemoclaw my-assistant policy-add --from-file ./presets/my-internal-api.yaml
```

Preview the endpoints without applying with `--dry-run`, and skip the confirmation prompt with `--yes` or by exporting `NEMOCLAW_NON_INTERACTIVE=1`.

### Apply Every File in a Directory

```console
$ nemoclaw my-assistant policy-add --from-dir ./presets/ --yes
```

Files are processed in lexicographic order.
Processing stops at the first failure; presets already applied are not rolled back.
Fix the failing file and re-run the command to continue.

> [!WARNING]
> Custom preset hosts bypass NemoClaw's review process and can widen sandbox egress to arbitrary destinations.
> Review every host in a custom preset before applying it, especially when the file originates outside your team.

## Related Topics

- [Approve or Deny Agent Network Requests](approve-network-requests.md) for real-time operator approval.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export const COMMANDS: readonly CommandDef[] = [
{
usage: "nemoclaw <name> policy-add",
description: "Add a network or filesystem policy preset",
flags: "(--yes, --dry-run)",
flags: "(--yes, --dry-run, --from-file <path>, --from-dir <path>)",
group: "Policy Presets",
scope: "sandbox",
},
Expand Down
146 changes: 139 additions & 7 deletions src/lib/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ function isPolicyDocument(value: PolicyValue): value is PolicyDocument {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Enumerate every preset YAML under `nemoclaw-blueprint/policies/presets/`
* and return `{ file, name, description }` triples parsed from the file's
* `preset:` header.
*/
function listPresets(): PresetInfo[] {
if (!fs.existsSync(PRESETS_DIR)) return [];
return fs
Expand All @@ -56,6 +61,10 @@ function listPresets(): PresetInfo[] {
});
}

/**
* Read a built-in preset by short name from `PRESETS_DIR`. Guards against
* path traversal and returns `null` if the preset does not exist.
*/
function loadPreset(name: string): string | null {
const file = path.resolve(PRESETS_DIR, `${name}.yaml`);
if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) {
Expand All @@ -69,6 +78,11 @@ function loadPreset(name: string): string | null {
return fs.readFileSync(file, "utf-8");
}

/**
* Extract the bare hostnames declared in a preset YAML (anything matched by
* `host: <value>`), with surrounding quotes stripped. Used to show the
* "endpoints that would be opened" preview before applying a preset.
*/
function getPresetEndpoints(content: string): string[] {
const hosts: string[] = [];
const regex = /host:\s*([^\s,}]+)/g;
Expand Down Expand Up @@ -300,6 +314,11 @@ function removePresetFromPolicy(
return YAML.stringify(current);
}

/**
* Remove a previously-applied preset from the running sandbox policy and
* delete its name from the registry entry. Returns `false` if the preset is
* unknown or has no `network_policies` section.
*/
function removePreset(sandboxName: string, presetName: string): boolean {
// Guard against truncated sandbox names — WSL can truncate hyphenated
// names during argument parsing, e.g. "my-assistant" → "m"
Expand Down Expand Up @@ -378,6 +397,11 @@ function removePreset(sandboxName: string, presetName: string): boolean {
return true;
}

/**
* Interactive preset picker for the `policy-remove` command. Prompts on
* stderr and resolves to the chosen preset name, or `null` if the user
* cancels or enters an invalid selection.
*/
function selectForRemoval(
items: PresetInfo[],
{ applied = [] }: SelectionOptions = {},
Expand Down Expand Up @@ -425,7 +449,20 @@ function selectForRemoval(
});
}

function applyPreset(sandboxName: string, presetName: string, _options = {}): boolean {
/**
* Apply raw preset content (already loaded in memory) to a running sandbox.
* Validates the sandbox name, extracts the `network_policies` entries, merges
* them into the sandbox's current policy, runs `openshell policy set --wait`,
* and records the preset name in the registry. Returns `false` if the content
* has no `network_policies` section. Used by both `applyPreset` (built-in
* presets) and the `--from-file` / `--from-dir` paths (custom preset files).
*/
function applyPresetContent(
sandboxName: string,
presetName: string,
presetContent: string,
_options: Record<string, unknown> = {},
): boolean {
// Guard against truncated sandbox names — WSL can truncate hyphenated
// names during argument parsing, e.g. "my-assistant" → "m"
const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName);
Expand All @@ -436,12 +473,6 @@ function applyPreset(sandboxName: string, presetName: string, _options = {}): bo
);
}

const presetContent = loadPreset(presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
return false;
}

const presetEntries = extractPresetEntries(presetContent);
if (!presetEntries) {
console.error(` Preset ${presetName} has no network_policies section.`);
Expand Down Expand Up @@ -497,6 +528,95 @@ function applyPreset(sandboxName: string, presetName: string, _options = {}): bo
return true;
}

/**
* Apply a built-in preset (by name) to a running sandbox. Loads the preset
* from `nemoclaw-blueprint/policies/presets/<name>.yaml` and delegates to
* `applyPresetContent`. Returns `false` if the named preset does not exist.
*/
function applyPreset(
sandboxName: string,
presetName: string,
options: Record<string, unknown> = {},
): boolean {
const presetContent = loadPreset(presetName);
if (!presetContent) {
console.error(` Cannot load preset: ${presetName}`);
return false;
}
return applyPresetContent(sandboxName, presetName, presetContent, options);
}

/**
* Load a user-authored preset YAML from an arbitrary path on disk, validate
* its shape, and return `{ presetName, content }` for use with
* `applyPresetContent`. Returns `null` (and logs a specific error) for any
* of: missing/non-file path, non-`.yaml`/`.yml` extension, invalid YAML,
* missing or malformed `preset.name`, missing `network_policies` object, or
* a name collision with a built-in preset (built-ins must be addressed by
* their own name, so the custom file must be renamed).
*/
function loadPresetFromFile(filePath: string): { presetName: string; content: string } | null {
const abs = path.resolve(filePath);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
console.error(` Preset file not found: ${filePath}`);
return null;
}
if (!/\.ya?ml$/i.test(abs)) {
console.error(` Preset file must be .yaml or .yml: ${filePath}`);
return null;
}
let content: string;
let parsed: PolicyValue;
try {
content = fs.readFileSync(abs, "utf-8");
parsed = YAML.parse(content);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException)?.code;
const message = err instanceof Error ? err.message : String(err);
const msg =
code === "ENOENT" || code === "EACCES"
? `Cannot read ${filePath}: ${message}`
: `Invalid YAML in ${filePath}: ${message}`;
console.error(` ${msg}`);
return null;
}
if (!isPolicyDocument(parsed)) {
console.error(` Preset must be a YAML mapping: ${filePath}`);
return null;
}
const presetMeta = parsed.preset;
const presetName =
presetMeta && typeof presetMeta === "object" && !Array.isArray(presetMeta)
? (presetMeta as PolicyObject).name
: undefined;
if (typeof presetName !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(presetName)) {
console.error(
` Preset must declare preset.name (lowercase, hyphenated RFC 1123 label): ${filePath}`,
);
return null;
}
if (
!parsed.network_policies ||
typeof parsed.network_policies !== "object" ||
Array.isArray(parsed.network_policies)
) {
console.error(` Preset missing network_policies section: ${filePath}`);
return null;
}
const builtin = listPresets().map((p) => p.name);
if (builtin.includes(presetName)) {
console.error(
` Preset name '${presetName}' collides with a built-in preset. Rename 'preset.name' in ${filePath}.`,
);
return null;
}
return { presetName, content };
}

/**
* Return the list of preset names currently recorded as applied to the
* sandbox, or an empty array if the sandbox is not tracked in the registry.
*/
function getAppliedPresets(sandboxName: string): string[] {
const sandbox = registry.getSandbox(sandboxName);
return sandbox ? sandbox.policies || [] : [];
Expand Down Expand Up @@ -571,6 +691,11 @@ function getGatewayPresets(sandboxName: string): string[] | null {
return matched;
}

/**
* Interactive preset picker for the `policy-add` command. Prints the
* presets on stderr (● applied, ○ not applied), prompts for a number, and
* resolves to the chosen preset name or `null` on cancel.
*/
function selectFromList(
items: PresetInfo[],
{ applied = [] }: SelectionOptions = {},
Expand Down Expand Up @@ -628,6 +753,11 @@ const PERMISSIVE_POLICY_PATH = path.join(
"openclaw-sandbox-permissive.yaml",
);

/**
* Resolve the on-disk path to the permissive policy YAML for the given
* sandbox, honoring the agent-specific override registered in
* `agent-defs.ts`. Returns `null` if no permissive policy is configured.
*/
function resolvePermissivePolicyPath(sandboxName: string): string {
// Use agent-specific permissive policy if the sandbox has an agent with one.
try {
Expand Down Expand Up @@ -683,6 +813,8 @@ export {
mergePresetIntoPolicy,
removePresetFromPolicy,
applyPreset,
applyPresetContent,
loadPresetFromFile,
removePreset,
applyPermissivePolicy,
getAppliedPresets,
Expand Down
Loading
Loading