Skip to content

feat(sandbox-mgmt): phase 1 MVP — shields, config get, audit logging#1849

Closed
ericksoa wants to merge 35 commits intomainfrom
feat/sandbox-management-commands
Closed

feat(sandbox-mgmt): phase 1 MVP — shields, config get, audit logging#1849
ericksoa wants to merge 35 commits intomainfrom
feat/sandbox-management-commands

Conversation

@ericksoa
Copy link
Copy Markdown
Contributor

@ericksoa ericksoa commented Apr 13, 2026

Summary

Draft implementation of Phase 1 MVP from the Sandbox Management
Commands RFC
(v0.1, 2026-04-13). Ready to circulate alongside the
RFC for review — merge blocked until the RFC is finalized.

What's implemented

  • nemoclaw shields down/up/status — time-bounded policy
    relaxation with auto-restore timer, policy snapshot capture,
    and append-only JSONL audit trail
  • nemoclaw config get — read-only sandbox config inspection
    with credential redaction and dotpath extraction
  • /nemoclaw shields — read-only shields status slash command
  • /nemoclaw config — read-only config display slash command

Security invariants enforced

  • Sandbox never writes its own config (slash commands read-only)
  • Sandbox never lowers its own shields (host-initiated only)
  • Credentials never appear in CLI output, logs, or audit trails
  • Shields-down always time-bounded (max 30 minutes)
  • Policy restore uses exact captured snapshot, not assumed default

New files

Path Purpose
src/lib/shields.ts Host CLI shields logic
src/lib/shields-timer.ts Detached auto-restore process
src/lib/shields-audit.ts JSONL audit logger
src/lib/duration.ts Duration parser (5m, 30m)
src/lib/sandbox-config.ts Host CLI config get
src/lib/credential-strip.ts Shared credential stripping
nemoclaw/src/commands/shields-status.ts Slash command
nemoclaw/src/commands/config-show.ts Slash command

Open RFC questions (not blocking this draft)

  1. Timer reliability on host reboot (Phase 2)
  2. Per-sandbox state files for multi-sandbox (Phase 2)
  3. Named policy tiers: network-only, fs-only (Phase 2)
  4. Config schema versioning location

Test plan

  • 59 new tests across 7 test files — all passing
  • Plugin builds cleanly (tsc --noEmit)
  • CLI type-checks (tsconfig.cli.json)
  • All pre-commit/pre-push hooks pass
  • E2E: shields down/up cycle against live sandbox
  • E2E: config get from running sandbox

Summary by CodeRabbit

  • New Features

    • /shields command: down/up/status with readable status output, reason/policy, timeout and auto-restore behavior
    • /config command: get/set/rotate-token with credential redaction and dotpath access
    • Audit log for shields operations and end-to-end test coverage
  • Improvements

    • Robust timeout parsing (s/m/h), validation, and 30-minute maximum
    • CLI help updated with shields/config usage examples
    • Sandbox policies adjusted to permit expected config write access
  • Bug Fixes

    • Endpoint URLs shown in config display now sanitize credentials and sensitive query params

ericksoa and others added 5 commits April 13, 2026 06:15
…NEMOCLAW_PREFERRED_API override

Backends like SGLang expose /v1/responses and pass the existing non-streaming
validation probe, but their streaming mode only emits lifecycle events
(created/in_progress/completed) without the granular content deltas OpenClaw
requires (output_text.delta, etc.). This causes runtime failures after
onboarding succeeds.

Changes:
- Add runStreamingEventProbe() in http-probe.ts that sends a stream:true
  request and verifies the SSE event stream includes response.output_text.delta
- Integrate the streaming probe into probeOpenAiLikeEndpoint for custom
  endpoints (probeStreaming: true) — falls back to /chat/completions when
  streaming events are incomplete
- Add shouldForceCompletionsApi() in validation.ts checking
  NEMOCLAW_PREFERRED_API env var so users can bypass /responses entirely
- Wire both into validateCustomOpenAiLikeSelection
- Add unit tests for the new functions (11 new test cases)
- Document NEMOCLAW_PREFERRED_API, the NEMOCLAW_INFERENCE_API_OVERRIDE
  workaround, and a troubleshooting entry for the runtime failure scenario

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
…file ARG precedence

NEMOCLAW_INFERENCE_API_OVERRIDE only patches openclaw.json at container
startup — it does not update the Dockerfile ARG baked into the image. On
recreate-sandbox the baked value wins. The reliable fix is a fresh
nemoclaw onboard which re-probes and rebakes the image.

Updated all three doc pages to recommend nemoclaw onboard instead of the
override env var, and added a note explaining the limitation.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
- Distinguish transport failures from missing-events in streaming probe
  fallback: only fall back to /chat/completions when missingEvents is
  non-empty; surface transport errors as hard validation failures
- Make shouldForceCompletionsApi() pure by accepting the preferred API
  value as a parameter instead of reading process.env directly, keeping
  validation.ts free of I/O per its module contract
- Fix passive voice and second-person wording in docs

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Implements shields down/up/status, config get, and audit
logging from the Sandbox Management Commands RFC.

Security invariants enforced:
- Host-only mutations
- Credential redaction
- 30-minute max timeout
- Exact policy snapshot restore

RFC: NemoClaw Sandbox Management Commands v0.1
Signed-off-by: Test User <test@example.com>
Signed-off-by: Test User <test@example.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

Adds shields (per-sandbox lockdown) and sandbox config management: state schema extensions, policy snapshot/restore with auto-restore timers and audit logging, host-side config get/set/rotate-token with SSRF checks, duration parsing, CLI + slash-command integrations, tests, and E2E coverage.

Changes

Cohort / File(s) Summary
State schema & tests
nemoclaw/src/blueprint/state.ts, nemoclaw/src/blueprint/state.test.ts
Added shields-related fields to NemoClawState and updated loadState() to merge persisted JSON into blankState() for backward compatibility; updated tests to cover new fields and legacy backfilling.
Shields implementation
src/lib/shields.ts, src/lib/shields-timer.ts, src/lib/shields-audit.ts
New shields lifecycle: shieldsDown/shieldsUp/shieldsStatus, policy capture/restore, auto-restore timer subprocess, per-sandbox state files, and append-only JSONL audit logging.
Sandbox config management
src/lib/sandbox-config.ts
New host-side config inspection/mutation and credential rotation: agent-aware resolution, configGet/configSet/configRotateToken, dotpath utilities, SSRF URL validation, format-aware read/write, and optional agent reload.
CLI & slash handlers
src/nemoclaw.ts, nemoclaw/src/commands/slash.ts, nemoclaw/src/commands/shields-status.ts, nemoclaw/src/commands/config-show.ts, nemoclaw/src/commands/*.test.ts
Integrated shields/config subcommands into CLI and slash command routing; added status/config show handlers and corresponding unit tests for slash routing and output.
Duration parsing
src/lib/duration.ts, test/duration.test.ts
Added parseDuration() with validation and 30-minute cap, plus MAX_SECONDS/DEFAULT_SECONDS constants and tests for parsing/validation boundaries.
Tests & E2E
test/*.test.ts, test/e2e/test-shields-config.sh
Extensive unit tests for shields, audit, sandbox-config helpers, duration parsing, and a new end-to-end Bash script exercising the shields/config lifecycle and audit checks.
Policies
agents/hermes/policy-permissive.yaml, nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
Adjusted filesystem policy entries to explicitly allow /sandbox write access and updated include_workdir/read-only entries to match shields semantics.
Onboard sanitization
nemoclaw/src/onboard/config.ts, nemoclaw/src/onboard/config.test.ts
describeOnboardEndpoint now parses and redacts credentials and sensitive query params from endpoint URLs; added tests for redaction and non-URL handling.
CI workflow
.github/workflows/nightly-e2e.yaml
Added shields-config-e2e to notify-on-failure dependencies and failure condition.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as NemoClaw CLI
    participant Shields as shields.ts
    participant State as State Store
    participant Runner as Policy Runner
    participant Timer as Auto-Restore Timer
    participant Audit as Audit Log

    CLI->>Shields: shieldsDown(sandbox, {timeout, reason, policy})
    Shields->>Runner: capture current policy
    Runner-->>Shields: policy YAML
    Shields->>State: write policy snapshot + down state
    Shields->>Runner: apply relaxed policy
    Shields->>Timer: fork timer (snapshot, restore-at)
    Shields->>Audit: append shields_down entry
    Shields-->>CLI: confirm DOWN
Loading
sequenceDiagram
    participant Timer as Timer Process
    participant State as State Store
    participant Runner as Policy Runner
    participant Audit as Audit Log

    Timer->>Timer: wait until restore time
    Timer->>State: verify snapshot exists
    alt snapshot exists
        Timer->>Runner: apply snapshot (restore)
        Runner-->>Timer: success
        Timer->>State: clear down fields
        Timer->>Audit: append shields_auto_restore
        Timer-->>Timer: exit 0
    else failure
        Timer->>Audit: append shields_up_failed
        Timer-->>Timer: exit 1
    end
Loading
sequenceDiagram
    participant CLI as NemoClaw CLI
    participant Config as sandbox-config.ts
    participant Resolver as Agent Resolver
    participant Sandbox as Running Sandbox
    participant Validator as URL Validator
    participant Audit as Audit Log

    CLI->>Config: configGet(sandbox, {key?, format})
    Config->>Resolver: resolve agent config path/format
    Resolver-->>Config: agent details
    Config->>Sandbox: exec cat <configPath>
    Sandbox-->>Config: raw config
    Config->>Config: parse, redact, remove gateway
    alt key provided
        Config->>Config: extract dotpath
    end
    Config-->>CLI: formatted output

    CLI->>Config: configSet(sandbox, {key,value,restart})
    Config->>Sandbox: read current config
    Config->>Validator: validate URL values if applicable
    Validator-->>Config: ok / error
    Config->>Sandbox: write updated config
    opt restart
        Config->>Sandbox: signal reload
    end
    Config->>Audit: append config_set entry
    Config-->>CLI: confirm change
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Shields lowered, snapshots kept tight,
A timer hums through the quiet night,
Policies captured, secrets concealed,
Configs redacted, safe and revealed,
A little rabbit nods—everything’s right.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main feature additions: shields management, config inspection, and audit logging as a Phase 1 MVP, directly matching the primary changes in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sandbox-management-commands

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

🚀 Docs preview ready!

https://NVIDIA.github.io/NemoClaw/pr-preview/pr-1849/

@ericksoa ericksoa changed the title feat(sandbox-mgmt): Phase 1 MVP — shields, config get, audit logging feat(sandbox-mgmt): phase 1 MVP — shields, config get, audit logging Apr 13, 2026
ericksoa and others added 2 commits April 13, 2026 17:06
Hermes caches skill slash-commands in a module-global dict on first
scan, making skills installed after gateway startup invisible. This
adds a nemoclaw_reload_skills tool that clears the cache and re-scans,
plus auto-refresh on session start, so new skills are available without
a gateway restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Brev E2E (all): PASSED on branch feat/sandbox-management-commandsSee logs

Instance e2e-pr-1849 is still running. To SSH in:

brev refresh && ssh e2e-pr-1849

When done, delete it: brev delete e2e-pr-1849

@github-actions
Copy link
Copy Markdown
Contributor

Brev E2E (all): PASSED on branch feat/sandbox-management-commandsSee logs

Instance e2e-pr-1849 is still running. To SSH in:

brev refresh && ssh e2e-pr-1849

When done, delete it: brev delete e2e-pr-1849

@github-actions
Copy link
Copy Markdown
Contributor

Brev E2E (all): FAILED on branch feat/sandbox-management-commandsSee logs

Instance e2e-pr-1849 is still running. To SSH in:

brev refresh && ssh e2e-pr-1849

When done, delete it: brev delete e2e-pr-1849

@github-actions
Copy link
Copy Markdown
Contributor

Brev E2E (all): PASSED on branch feat/sandbox-management-commandsSee logs

Instance e2e-pr-1849 is still running. To SSH in:

brev refresh && ssh e2e-pr-1849

When done, delete it: brev delete e2e-pr-1849

@wscurran wscurran added security Something isn't secure priority: high Important issue that should be resolved in the next release NemoClaw CLI Use this label to identify issues with the NemoClaw command-line interface (CLI). enhancement: feature Use this label to identify requests for new capabilities in NemoClaw. labels Apr 14, 2026
Resolve conflicts:
- nemoclaw/src/blueprint/state.ts: merge rebuild + shields fields
- src/nemoclaw.ts: merge skill/rebuild/snapshot + shields/config cases
- state.test.ts: add rebuild fields to full state assertion

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
…ules

- shields.ts: remove @ts-nocheck, import duration parsing from shared
  duration.ts, import audit logging from shields-audit.ts, add types
- sandbox-config.ts: remove @ts-nocheck, import stripCredentials from
  credential-filter.ts instead of inlining, add types
- Delete unused credential-strip.ts (sandbox-config.ts now uses
  credential-filter.ts, plugin config-show.ts does its own redaction)

All 1645 tests pass.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
@ericksoa ericksoa marked this pull request as ready for review April 15, 2026 19:28
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (2)
src/lib/sandbox-config.ts (1)

10-11: Consider using ESM imports instead of CommonJS require.

This TypeScript file uses CommonJS require() while using TypeScript syntax (interfaces, type annotations). For consistency with other files in src/lib/ and to enable better static analysis and tree-shaking, consider ESM imports:

-const { runCapture, validateName, shellQuote } = require("./runner");
-const { stripCredentials } = require("./credential-filter");
+import { runCapture, validateName, shellQuote } from "./runner.js";
+import { stripCredentials } from "./credential-filter.js";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 10 - 11, The file mixes CommonJS
require() with TypeScript; replace the require calls that import runCapture,
validateName, shellQuote and stripCredentials with ESM import statements (e.g.,
import { runCapture, validateName, shellQuote } from "./runner" and import {
stripCredentials } from "./credential-filter"), update any default/CommonJS
export usage to named exports if needed, and ensure the module target in
tsconfig supports ESM so TypeScript compiles the new import syntax correctly.
nemoclaw/src/commands/slash.test.ts (1)

74-80: Same concern: blankState may be missing required fields.

Similar to shields-status.test.ts, this blankState() helper is missing lastRebuildAt and lastRebuildBackupPath fields. Consider adding them for consistency and type safety:

     updatedAt: new Date().toISOString(),
+    lastRebuildAt: null,
+    lastRebuildBackupPath: null,
     shieldsDown: false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/commands/slash.test.ts` around lines 74 - 80, The blankState()
test helper in slash.test.ts is missing the required fields lastRebuildAt and
lastRebuildBackupPath (causing type/consistency issues); update the blankState
object to include lastRebuildAt: null and lastRebuildBackupPath: null (or
appropriate default values matching your State type) so it matches the other
helper (shields-status.test.ts) and satisfies type safety for functions that
consume blankState.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoclaw/src/commands/config-show.ts`:
- Around line 39-41: The output currently prints raw endpointUrl via
describeOnboardEndpoint(config), which can leak basic-auth usernames/passwords
or tokenized query params; update describeOnboardEndpoint (and callers such as
the template in config-show.ts) to parse and redact sensitive parts of
config.endpointUrl: remove username/password from URL.origin, strip or replace
query parameter values (e.g., show keys only or replace values with
"<redacted>"), and preserve endpointType for non-local routes; use a robust URL
parser (URL class) to reconstruct a safe string like "type
(https://host[:port]/path?param=<redacted>)" or simply "type
(https://host/path...)" so no credentials or token values are echoed.
- Around line 47-48: Update the user-facing hint strings in the config-show
command so they show the actual host CLI syntax accepted by the dispatcher:
replace "Use `nemoclaw config get <sandbox>` for the full sandbox config." with
"Use `nemoclaw <sandbox> config get` for the full sandbox config." in the
message array in commands/config-show.ts (the constant/array that contains the
two hint strings used when refusing sandbox-side config modification).

In `@nemoclaw/src/commands/shields-status.test.ts`:
- Around line 16-33: The helper function blankState() is missing the
NemoClawState fields lastRebuildAt and lastRebuildBackupPath; update
blankState() to include lastRebuildAt: null and lastRebuildBackupPath: null so
the returned object conforms to NemoClawState and avoids type errors.

In `@nemoclaw/src/commands/shields-status.ts`:
- Around line 42-45: Update the recovery hint emitted in shields-status.ts so it
includes the sandbox argument; specifically, change the string pushed in
lines.push (the warning about running "nemoclaw shields up") to either instruct
using "nemoclaw <sandbox> shields up" or interpolate the current sandbox name
using state.sandboxName when available (refer to state.sandboxName and the
lines.push call in shields-status.ts to locate and update the message).

In `@src/lib/sandbox-config.ts`:
- Around line 49-58: Remove the redundant try/catch around runCapture and
instead call runCapture(cmd, { ignoreError: false }) so errors propagate; then
wrap that call in a single catch that inspects the thrown error (and the
resulting raw output) to produce specific messages: if error.code === 'ENOENT'
log that the openshell binary was not found, if error.code indicates permission
denied log permission issues, if no error but raw is empty/whitespace log that
the sandbox config was not read (suggest sandbox not running or config missing),
and in all cases include the original error/message for diagnostics; update
references to runCapture, cmd, and raw in sandbox-config.ts accordingly.

In `@src/lib/shields-audit.ts`:
- Around line 4-10: The doc claims entries never contain credential values but
appendAuditEntry currently writes entries directly; update appendAuditEntry to
defensively sanitize any user-provided fields by running stripCredentials
(and/or a new sanitizeAuditEntry helper) on the reason and the whole entry
object before serializing to JSONL so no credential-like patterns are written;
ensure this happens inside appendAuditEntry (and any other writer helpers) so
callers need not trust they sanitized values.
- Around line 16-17: The current AUDIT_DIR/AUDIT_FILE fallback to "/tmp" when
HOME is unset is unsafe on multi-user systems; change the logic so that if HOME
is unset you either throw an explicit error (fail fast) or create a
process-specific, restricted directory instead of a global /tmp path.
Specifically update the code that computes AUDIT_DIR/AUDIT_FILE to: prefer HOME,
otherwise use a secure temp location (e.g., os.tmpdir() combined with a unique
suffix like process.pid or mkdtemp-style name or XDG_RUNTIME_DIR) and ensure the
directory is created with restrictive permissions (mode 0o700) before writing
shields-audit.jsonl; if you choose to fail, throw a clear error when HOME is
missing. Ensure these changes are applied around the AUDIT_DIR/AUDIT_FILE
definitions and any directory-creation logic that uses them.

In `@src/lib/shields-timer.ts`:
- Around line 77-97: The restore command is being executed with
run(buildPolicySetCommand(...), { ignoreError: true }) but the code always calls
updateState(...) and appendAudit(...) regardless of whether the restore actually
succeeded; change the logic so you first capture the result/error of
run(buildPolicySetCommand(...)) and only call updateState (clearing
shieldsDown*, shieldsDownAt, shieldsDownTimeout, shieldsDownReason,
shieldsDownPolicy) and appendAudit({... action: "shields_auto_restore" ...})
when the run completed successfully; if run fails, do not clear the shields
state or record a successful restore—log or record the failure instead so the
sandbox remains correctly marked as relaxed until a real restore occurs.
- Around line 1-15: Remove the top-line directive "// `@ts-nocheck`" from
shields-timer.ts and restore full TypeScript checking; replace CommonJS requires
with typed ES module imports for fs, path, run (from "./runner") and
buildPolicySetCommand (from "./policies"), add explicit type annotations for any
function parameters/variables used in the timer logic (e.g. the forked process
args: sandbox name, snapshot path, timeoutSeconds) and for the return types of
functions that call run or buildPolicySetCommand, and then run the ts compiler
(tsconfig.cli.json) to fix any resulting type errors reported and update code
accordingly until type-check passes. Ensure you reference and keep the existing
usage of run and buildPolicySetCommand intact while adding types.

In `@src/lib/shields.ts`:
- Around line 35-59: loadShieldsState()/saveShieldsState() currently persist a
single flat ShieldsState which causes cross-sandbox leakage; change their
storage shape to map sandboxName -> ShieldsState (e.g., Record<string,
ShieldsState>) keyed by the sandbox name, update both loadShieldsState and
saveShieldsState to read/write the per-sandbox entry (using STATE_FILE/STATE_DIR
as before) so callers like shieldsStatus, shieldsUp, and any code referencing
shieldsPolicySnapshotPath use the sandbox-scoped state; ensure saveShieldsState
merges the patch into the specific sandbox entry (preserving other sandboxes)
and that loadShieldsState returns the sandbox's ShieldsState (or {} if missing),
preserving updatedAt semantics and file permissions.
- Around line 164-202: If fork() fails we must not leave the sandbox relaxed:
after the catch in the timer-start block, roll back the previously persisted
relaxed policy by (1) restoring the captured snapshot file at snapshotPath (call
the existing snapshot restore routine—e.g. the function that performs sandbox
restore/restoreSnapshot/restoreSandboxFromSnapshot in this module or service),
and (2) call saveShieldsState(...) to clear shieldsDown (set shieldsDown: false,
clear
shieldsDownAt/shieldsDownTimeout/shieldsDownReason/shieldsDownPolicy/shieldsPolicySnapshotPath).
Do this before logging the warning and re-throw or return a non-zero failure so
the command fails rather than leaving the sandbox relaxed; reference
saveShieldsState, snapshotPath, fork, timerMarkerPath and the timer script names
(shields-timer.ts / shields-timer.js) to locate the code to change.
- Around line 87-100: The killTimer function currently unconditionally signals
the PID read from readTimerMarker(sandboxName) which risks killing an unrelated
reused PID; update the marker format (readTimerMarker and wherever it's written)
to include additional identity fields such as ppid, startTime (or command/argv)
and/or a unique token, then in killTimer verify the on-disk PID matches those
identity fields before calling process.kill(marker.pid, "SIGTERM") (e.g., check
process existence and compare /proc/<pid>/stat starttime or /proc/<pid>/cmdline
on Linux, or use a platform-appropriate ps API), and if verification fails avoid
sending the signal and still remove the marker via timerMarkerPath(sandboxName)
or implement a cancellation handshake/token file that the timer process checks
to exit itself instead of directly killing the PID.

In `@src/nemoclaw.ts`:
- Around line 2131-2136: The parser loop around actionArgs builds configOpts
with possible missing or invalid values which are silently accepted; update the
handling before calling sandboxConfig.configGet(cmd, configOpts) to validate
that when an option flag is present it has a following value (e.g. when
actionArgs[i] === "--key" or "--format" ensure actionArgs[i+1] exists and is not
another flag) and to validate allowed format values (accept only "json" or
"yaml"/"yml"); on invalid/missing values return a clear user-facing error or
throw so callers/scripts cannot silently proceed, and reference the existing
symbols configOpts, actionArgs and sandboxConfig.configGet to locate where to
add these checks.

In `@test/duration.test.ts`:
- Around line 1-3: Remove the leading "@ts-nocheck" and place the SPDX license
header as the very first lines of the file (move the current
SPDX-FileCopyrightText and SPDX-License-Identifier above any other content),
then run TypeScript (tsc) or your editor checks and fix any resulting type
errors in this test file (address incorrect typings, imports, or any implicit
any) so the file passes type checking; target the top-of-file directive and
existing test declarations in this file when making edits.

In `@test/shields-audit.test.ts`:
- Around line 29-31: The test currently reimplements appendAuditEntry(auditPath)
and never goes through the real audit logger or redaction/filtering logic;
replace the local appendAuditEntry with calls to the real module export from
src/lib/shields-audit.ts (import the audit logger or its write function) so the
test exercises directory creation, file-mode handling, and filtration/redaction
paths, and change the payload to include a secret-like value (e.g., a token
string) so redaction is triggered; also update the other occurrences noted
(lines ~99-114) to use the real logger and assert on file permissions, existence
of created directories, and that the secret is redacted in the audit file.

In `@test/shields.test.ts`:
- Around line 13-32: The tests are not exercising src/lib/shields.ts — update
the test cases to call the real exported functions shieldsDown() and shieldsUp()
instead of hand-writing nemoclaw.json, snapshots, timer markers, and audit files
so the actual implementation is exercised; ensure you import shieldsDown and
shieldsUp from src/lib/shields, trigger them in the relevant test flows, and
assert that mocked helpers (runner.run / runner.runCapture,
policies.buildPolicySetCommand / buildPolicyGetCommand / parseCurrentPolicy, and
child_process.fork) are invoked with the expected arguments and that
snapshot/audit outputs are produced by the implementation rather than being
manually created.

---

Nitpick comments:
In `@nemoclaw/src/commands/slash.test.ts`:
- Around line 74-80: The blankState() test helper in slash.test.ts is missing
the required fields lastRebuildAt and lastRebuildBackupPath (causing
type/consistency issues); update the blankState object to include lastRebuildAt:
null and lastRebuildBackupPath: null (or appropriate default values matching
your State type) so it matches the other helper (shields-status.test.ts) and
satisfies type safety for functions that consume blankState.

In `@src/lib/sandbox-config.ts`:
- Around line 10-11: The file mixes CommonJS require() with TypeScript; replace
the require calls that import runCapture, validateName, shellQuote and
stripCredentials with ESM import statements (e.g., import { runCapture,
validateName, shellQuote } from "./runner" and import { stripCredentials } from
"./credential-filter"), update any default/CommonJS export usage to named
exports if needed, and ensure the module target in tsconfig supports ESM so
TypeScript compiles the new import syntax correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c3ff043-0541-4b15-bee3-99b6f17e76bf

📥 Commits

Reviewing files that changed from the base of the PR and between 1d4d8a2 and 21ec22f.

📒 Files selected for processing (17)
  • nemoclaw/src/blueprint/state.test.ts
  • nemoclaw/src/blueprint/state.ts
  • nemoclaw/src/commands/config-show.test.ts
  • nemoclaw/src/commands/config-show.ts
  • nemoclaw/src/commands/shields-status.test.ts
  • nemoclaw/src/commands/shields-status.ts
  • nemoclaw/src/commands/slash.test.ts
  • nemoclaw/src/commands/slash.ts
  • src/lib/duration.ts
  • src/lib/sandbox-config.ts
  • src/lib/shields-audit.ts
  • src/lib/shields-timer.ts
  • src/lib/shields.ts
  • src/nemoclaw.ts
  • test/duration.test.ts
  • test/shields-audit.test.ts
  • test/shields.test.ts

Comment thread nemoclaw/src/commands/config-show.ts
Comment thread nemoclaw/src/commands/config-show.ts
Comment thread nemoclaw/src/commands/shields-status.test.ts
Comment thread nemoclaw/src/commands/shields-status.ts
Comment thread src/lib/sandbox-config.ts
Comment thread src/nemoclaw.ts
Comment thread src/nemoclaw.ts
Comment thread test/duration.test.ts Outdated
Comment thread test/shields-audit.test.ts
Comment thread test/shields.test.ts
Phase 2 of the Sandbox Management Commands RFC:

- config set: host-initiated config mutation with dotpath addressing,
  JSON value parsing, SSRF validation for URLs, gateway section
  protection, and optional sandbox restart signal
- config rotate-token: credential rotation via --from-env or --from-stdin
  (never CLI args), saves to credentials.json, updates openshell provider
- 32 new tests covering dotpath operations, URL validation, exports

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Config get/set/rotate-token were hardcoded to read/write
/sandbox/.openclaw/openclaw.json. This breaks for Hermes sandboxes,
which use /sandbox/.hermes/config.yaml (YAML format).

Add resolveAgentConfig() which looks up the sandbox's agent from the
registry, loads the agent definition from agents/*/manifest.yaml, and
returns the correct config path, directory, format, and filename. All
config commands now route through this resolver.

- config get: outputs in agent's native format by default (JSON for
  openclaw, YAML for hermes)
- config set: writes in agent's native format, shows agent name in
  output and audit trail
- rotate-token: includes agent name in audit entries
- CLI output now shows which agent is being configured

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
config set and config rotate-token now refuse to run unless shields
are down. This enforces the security model where shields down is the
explicit gate that unlocks config mutability, and shields up (or the
auto-restore timer) re-locks it.

Operator flow:
  nemoclaw <sandbox> shields down --reason "rotating token"
  nemoclaw <sandbox> config set --key inference.model --value "new-model"
  nemoclaw <sandbox> shields up

Add isShieldsDown() to shields.ts and requireShieldsDown() guard in
sandbox-config.ts. config get remains available with shields up
(read-only, no mutation).

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
…lity

Shields down now makes the agent config file physically writable inside
the sandbox. Shields up re-locks it. No software-level gate — the OS
permissions ARE the enforcement.

Mechanical changes:
- shields down: after applying permissive policy, chmod 666 the config
  file and chmod 777 the config dir via openshell sandbox exec (runs as
  root, bypasses Landlock)
- shields up: after restoring restrictive policy, chmod 444 the config
  file and chown root:root + chmod 755 the config dir
- openclaw permissive policy: add /sandbox/.openclaw to read_write so
  Landlock also allows writes (UNIX perms + Landlock must both agree)
- hermes permissive policy: move /sandbox/.hermes from read_only to
  read_write, same reasoning
- Remove software requireShieldsDown() gate from config set/rotate-token
  — the filesystem permissions are the real enforcement

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Nightly E2E covering the full shields down/up and config get/set/
rotate-token lifecycle against a live sandbox:

- Phase 2: config immutability with shields UP (UNIX perms block writes)
- Phase 3: shields down makes config writable (chmod 666)
- Phase 4: config get with credential redaction and gateway stripping
- Phase 5: config set with dotpath, gateway protection, SSRF validation
- Phase 6: shields status shows DOWN with reason and timeout
- Phase 7: shields up re-locks config (chmod 444, chown root:root)
- Phase 8: config changes persist through shields cycle
- Phase 9: rotate-token via --from-env with redacted output
- Phase 10: audit trail completeness and credential-free verification
- Phase 11: auto-restore timer fires after short timeout
- Phase 12: double shields-down rejected

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (6)
src/nemoclaw.ts (2)

2130-2137: ⚠️ Potential issue | 🟡 Minor

config get silently accepts invalid --format values.

The parsing loop doesn't validate that --format is one of the allowed values (json, yaml). Any unexpected value falls through to JSON output without an error, which could mislead scripts expecting YAML.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 2130 - 2137, The "get" handler (case "get")
currently parses actionArgs into configOpts.format without validating allowed
values, so invalid formats silently default to "json"; update the parsing loop
that builds configOpts (referencing actionArgs and configOpts) to validate that
any value provided for "--format" is one of the allowed values ("json" or
"yaml") and, if not, print a clear error and exit (or throw) before calling
sandboxConfig.configGet(cmd, configOpts); ensure the validation occurs
immediately after consuming the format token so configGet always receives a
valid format.

2102-2111: ⚠️ Potential issue | 🟠 Major

shields down silently accepts missing option values and unknown flags.

The flag parsing loop blindly consumes the next token for --timeout, --reason, and --policy. If a flag appears at the end without a value (e.g., shields down --timeout), it silently falls back to defaults. Unknown flags are also silently ignored. For a security-sensitive command, this should fail fast.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 2102 - 2111, The current shields down parsing
loop (shieldsFlags and opts used before calling shields.shieldsDown) silently
accepts missing values and ignores unknown flags; change it to validate each
flag: when encountering "--timeout", "--reason", or "--policy" ensure there is a
next token (i+1 < shieldsFlags.length) and that that token is not another flag
(doesn't start with "--"); if validation fails, throw or return a clear error
message instead of falling back to defaults. Also treat any token that doesn't
match the three known flags as an unknown flag and fail fast with an error
instead of ignoring it, then only call shields.shieldsDown(cmd, opts) when all
flags are validated and consumed.
src/lib/shields.ts (3)

46-61: ⚠️ Potential issue | 🔴 Critical

Shields state is not namespaced by sandbox — cross-sandbox leakage.

loadShieldsState() and saveShieldsState() operate on a single flat nemoclaw.json file without keying by sandbox name. This causes incorrect behavior when managing multiple sandboxes:

  • After shields down on sandbox alpha, calling shieldsStatus("beta") reports alpha's state.
  • shieldsUp("beta") could restore alpha's snapshot onto beta.

The state file should store a Record<string, ShieldsState> keyed by sandbox name.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 46 - 61, The current loadShieldsState and
saveShieldsState always read/write a single flat ShieldsState to STATE_FILE
causing cross-sandbox leakage; change the backing format to a Record<string,
ShieldsState> keyed by sandbox name and update the functions to load the whole
map, select or update the entry for the given sandbox, and persist the entire
map to STATE_FILE (use STATE_DIR/STATE_FILE and same mkdir/write modes).
Specifically modify loadShieldsState and saveShieldsState (or add
loadShieldsMap/saveShieldsMap helpers) so they operate on the map, have
saveShieldsState merge the patch into map[sandboxName] and set updatedAt for
that sandbox only, and have loadShieldsState return the sandbox-specific
ShieldsState (or undefined/empty) while preserving existing other sandbox
entries in the file.

88-101: ⚠️ Potential issue | 🟠 Major

PID reuse risk when killing timer process.

killTimer() sends SIGTERM to marker.pid read from disk without verifying the PID still belongs to the timer process. If the timer exited and the OS reused that PID for an unrelated process, this could terminate an innocent process.

Consider storing additional identity info (e.g., start time, ppid, or a unique token) in the marker and verifying before signaling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 88 - 101, The killTimer function currently
reads marker.pid (from readTimerMarker) and signals it directly, risking killing
an unrelated PID if the original timer exited and the PID was reused; change the
marker format (readTimerMarker / timerMarkerPath) to include extra identity info
(e.g., startTime or parentPid or a unique token) when writing the marker, then
in killTimer verify that the process with marker.pid matches that identity
before sending SIGTERM (for example, compare the process start time or ppid or
command line from the OS /proc or platform-appropriate API against
marker.startTime/marker.ppid/marker.token), only then call process.kill, and
still remove the marker file on success or if the process is confirmed gone;
ensure readTimerMarker is updated to parse the new fields and handle platforms
gracefully.

195-218: ⚠️ Potential issue | 🔴 Critical

Timer fork failure leaves sandbox relaxed indefinitely.

When fork() fails (lines 214-218), the code only logs a warning and continues. At this point, the relaxed policy is already applied (line 161) and state is persisted as shieldsDown: true (lines 180-187). This breaks the time-bounded security invariant — the sandbox remains relaxed with no auto-restore.

The code should roll back the policy change by restoring the snapshot and clearing the shields-down state before returning an error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 195 - 218, The try/catch around
fork(actualScript, ...) currently only logs on failure but leaves the sandbox
relaxed and persisted as shieldsDown; on fork error you must roll back the
policy change: in the catch block call the snapshot restore routine (e.g.,
restoreSnapshot or the existing function used elsewhere to revert to
snapshotPath for sandboxName), clear the persisted shieldsDown state (the same
state updater used where shieldsDown was set true), remove any timer marker file
created via timerMarkerPath(sandboxName) if present, and then propagate the
error (throw or return an error) instead of silently continuing; reference the
fork call, actualScript, sandboxName, snapshotPath, timerMarkerPath and the
shieldsDown state updater when implementing this rollback.
src/lib/sandbox-config.ts (1)

149-160: ⚠️ Potential issue | 🟡 Minor

Redundant try-catch and ambiguous error messaging.

The try-catch around runCapture with ignoreError: true is redundant since runCapture already handles errors internally. Additionally, the error message "Is the sandbox running?" doesn't distinguish between multiple failure modes (binary not found, sandbox not running, permission denied, empty config).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 149 - 160, Remove the redundant
try/catch around runCapture and rely on its internal error handling; call raw =
runCapture(cmd, { ignoreError: true }) directly, then replace the single vague
error block with targeted checks: if the config file at target.configPath does
not exist (use fs.existsSync) log "config file missing for {target.agentName}",
else if raw is empty re-run runCapture(cmd) without ignoreError to capture the
actual error/ stderr and include that in the log to distinguish "binary not
found / permission denied / sandbox not running", and ensure messages include
target.agentName and target.configPath for context.
🧹 Nitpick comments (1)
src/lib/shields.ts (1)

172-176: Consider using 755/644 instead of 777/666 for config permissions.

Setting chmod 777 on the config directory and chmod 666 on the config file grants world-readable/writable permissions. While this is inside a sandbox, using 755 and 644 (or 775/664) would be sufficient to allow the sandbox user to write while maintaining better hygiene.

Suggested change
-    run(`${openshell} sandbox exec ${sandboxName} chmod 777 ${target.configDir}`, { ignoreError: true });
-    run(`${openshell} sandbox exec ${sandboxName} chmod 666 ${target.configPath}`, { ignoreError: true });
+    run(`${openshell} sandbox exec ${sandboxName} chmod 755 ${target.configDir}`, { ignoreError: true });
+    run(`${openshell} sandbox exec ${sandboxName} chmod 644 ${target.configPath}`, { ignoreError: true });
+    run(`${openshell} sandbox exec ${sandboxName} chown sandbox:sandbox ${target.configPath}`, { ignoreError: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 172 - 176, The chmod calls in the try block
use overly-permissive modes (777/666); update the two run(...) invocations that
reference target.configDir and target.configPath (the calls using `${openshell}
sandbox exec ${sandboxName} chmod ...`) to use safer modes such as 755 for
directories and 644 for files (or 775/664 if group write is required by the
sandbox), keeping the same ignoreError behavior and leaving the existing
catch/error message intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sandbox-config.ts`:
- Around line 478-485: The audit entry for the rotate-token operation uses the
wrong action value ("shields_down"); update the object passed to
appendAuditEntry in the rotate-token flow to use a clear, dedicated action like
"token_rotated" (keep sandbox: sandboxName, timestamp: new Date().toISOString(),
and reason: `rotate-token ${target.agentName}:${credentialEnv}` intact) so the
audit log correctly reflects the token rotation event.
- Around line 347-354: The audit entry for config mutations is using the wrong
action; change the call to appendAuditEntry in the config set flow to use
action: "config_set" instead of "shields_down", and then add "config_set" to the
allowed action types in shields-audit.ts (update the union/type/enum that
defines audit actions and any related validations). Also search for any
validation or switch logic that checks audit actions and ensure "config_set" is
handled consistently (e.g., in typing, serializers, or tests).
- Around line 175-189: The isPrivateIp function currently only checks IPv4 and
the literal IPv6 loopback “[::1]” — update isPrivateIp to normalize IPv6
hostnames by stripping surrounding brackets (e.g., "[fd00::1]"), then add checks
for IPv6 link-local and unique-local ranges: detect hostnames that start with
"fe80:" (fe80::/10) and those that start with "fc" or "fd" (fc00::/7) — e.g.,
add constants or regexes alongside PRIVATE_IP_PREFIXES/PRIVATE_IP_172_RE and
return true for these matches within isPrivateIp.

In `@test/config-set.test.ts`:
- Around line 88-89: Rename the test case title to match the assertion: change
the `it("accepts public http URLs", ...)` description to `it("accepts public
https URLs", ...)` so the test name aligns with the URL passed to
`validateUrlValue("https://example.com")`; update the `it` string near the
`validateUrlValue` call to reflect HTTPS.

---

Duplicate comments:
In `@src/lib/sandbox-config.ts`:
- Around line 149-160: Remove the redundant try/catch around runCapture and rely
on its internal error handling; call raw = runCapture(cmd, { ignoreError: true
}) directly, then replace the single vague error block with targeted checks: if
the config file at target.configPath does not exist (use fs.existsSync) log
"config file missing for {target.agentName}", else if raw is empty re-run
runCapture(cmd) without ignoreError to capture the actual error/ stderr and
include that in the log to distinguish "binary not found / permission denied /
sandbox not running", and ensure messages include target.agentName and
target.configPath for context.

In `@src/lib/shields.ts`:
- Around line 46-61: The current loadShieldsState and saveShieldsState always
read/write a single flat ShieldsState to STATE_FILE causing cross-sandbox
leakage; change the backing format to a Record<string, ShieldsState> keyed by
sandbox name and update the functions to load the whole map, select or update
the entry for the given sandbox, and persist the entire map to STATE_FILE (use
STATE_DIR/STATE_FILE and same mkdir/write modes). Specifically modify
loadShieldsState and saveShieldsState (or add loadShieldsMap/saveShieldsMap
helpers) so they operate on the map, have saveShieldsState merge the patch into
map[sandboxName] and set updatedAt for that sandbox only, and have
loadShieldsState return the sandbox-specific ShieldsState (or undefined/empty)
while preserving existing other sandbox entries in the file.
- Around line 88-101: The killTimer function currently reads marker.pid (from
readTimerMarker) and signals it directly, risking killing an unrelated PID if
the original timer exited and the PID was reused; change the marker format
(readTimerMarker / timerMarkerPath) to include extra identity info (e.g.,
startTime or parentPid or a unique token) when writing the marker, then in
killTimer verify that the process with marker.pid matches that identity before
sending SIGTERM (for example, compare the process start time or ppid or command
line from the OS /proc or platform-appropriate API against
marker.startTime/marker.ppid/marker.token), only then call process.kill, and
still remove the marker file on success or if the process is confirmed gone;
ensure readTimerMarker is updated to parse the new fields and handle platforms
gracefully.
- Around line 195-218: The try/catch around fork(actualScript, ...) currently
only logs on failure but leaves the sandbox relaxed and persisted as
shieldsDown; on fork error you must roll back the policy change: in the catch
block call the snapshot restore routine (e.g., restoreSnapshot or the existing
function used elsewhere to revert to snapshotPath for sandboxName), clear the
persisted shieldsDown state (the same state updater used where shieldsDown was
set true), remove any timer marker file created via timerMarkerPath(sandboxName)
if present, and then propagate the error (throw or return an error) instead of
silently continuing; reference the fork call, actualScript, sandboxName,
snapshotPath, timerMarkerPath and the shieldsDown state updater when
implementing this rollback.

In `@src/nemoclaw.ts`:
- Around line 2130-2137: The "get" handler (case "get") currently parses
actionArgs into configOpts.format without validating allowed values, so invalid
formats silently default to "json"; update the parsing loop that builds
configOpts (referencing actionArgs and configOpts) to validate that any value
provided for "--format" is one of the allowed values ("json" or "yaml") and, if
not, print a clear error and exit (or throw) before calling
sandboxConfig.configGet(cmd, configOpts); ensure the validation occurs
immediately after consuming the format token so configGet always receives a
valid format.
- Around line 2102-2111: The current shields down parsing loop (shieldsFlags and
opts used before calling shields.shieldsDown) silently accepts missing values
and ignores unknown flags; change it to validate each flag: when encountering
"--timeout", "--reason", or "--policy" ensure there is a next token (i+1 <
shieldsFlags.length) and that that token is not another flag (doesn't start with
"--"); if validation fails, throw or return a clear error message instead of
falling back to defaults. Also treat any token that doesn't match the three
known flags as an unknown flag and fail fast with an error instead of ignoring
it, then only call shields.shieldsDown(cmd, opts) when all flags are validated
and consumed.

---

Nitpick comments:
In `@src/lib/shields.ts`:
- Around line 172-176: The chmod calls in the try block use overly-permissive
modes (777/666); update the two run(...) invocations that reference
target.configDir and target.configPath (the calls using `${openshell} sandbox
exec ${sandboxName} chmod ...`) to use safer modes such as 755 for directories
and 644 for files (or 775/664 if group write is required by the sandbox),
keeping the same ignoreError behavior and leaving the existing catch/error
message intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8688aa13-3ba9-43bd-aa8a-2ddabb0e1c0f

📥 Commits

Reviewing files that changed from the base of the PR and between 21ec22f and cb9ee8e.

📒 Files selected for processing (9)
  • .agents/skills/nemoclaw-user-reference/references/commands.md
  • agents/hermes/policy-permissive.yaml
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
  • src/lib/sandbox-config.ts
  • src/lib/shields.ts
  • src/nemoclaw.ts
  • test/config-rotate-token.test.ts
  • test/config-set.test.ts
  • test/e2e/test-shields-config.sh
✅ Files skipped from review due to trivial changes (1)
  • .agents/skills/nemoclaw-user-reference/references/commands.md

Comment thread src/lib/sandbox-config.ts
Comment thread src/lib/sandbox-config.ts
Comment thread src/lib/sandbox-config.ts
Comment thread test/config-set.test.ts
Wire test/e2e/test-shields-config.sh into the nightly-e2e workflow
as a new parallel job. Added to the notify-on-failure needs list.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
OpenShell rejects changing include_workdir on a live sandbox:
"filesystem include_workdir cannot be changed on a live sandbox"

The default policy uses include_workdir: false. The permissive policy
used include_workdir: true, which made shields down fail on every
running sandbox.

Fix: set include_workdir: false in both permissive policies and
explicitly list /sandbox and the config dir in read_write instead.
This achieves the same filesystem access without triggering the
OpenShell validation error.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
OpenShell also rejects removing read_only paths on a live sandbox:
"filesystem read_only path '/sandbox' cannot be removed on a live sandbox"

Fix: keep /sandbox and the config dir in both read_only AND read_write.
Landlock grants the union of all matching rules, so a path in both
lists gets read-write access. This satisfies OpenShell's constraint
that read_only paths can only be added, never removed, on live sandboxes.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@agents/hermes/policy-permissive.yaml`:
- Around line 14-17: The include_workdir value in policy-permissive.yaml
(include_workdir: false) conflicts with the Hermes strict policy's
include_workdir (true); change include_workdir in this permissive policy to
match the default/strict policy (set include_workdir: true or remove the
override so it inherits the default) to avoid OpenShell rejecting live policy
changes—update the include_workdir key in the policy-permissive.yaml entry that
currently sets include_workdir to false to match the Hermes strict snippet.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9fe3d493-0477-4141-8dc2-5b6a94be46c0

📥 Commits

Reviewing files that changed from the base of the PR and between fd575de and 1664761.

📒 Files selected for processing (2)
  • agents/hermes/policy-permissive.yaml
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
🚧 Files skipped from review as they are similar to previous changes (1)
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml

Comment thread agents/hermes/policy-permissive.yaml Outdated
Three layers protect the config file: Landlock (read_only), chattr +i
(immutable bit), and UNIX perms (444 root:root). openshell sandbox exec
runs INSIDE the Landlock domain and can't bypass any of them.

Fix: route chmod/chattr through docker exec → kubectl exec, which
starts a new process in the pod OUTSIDE the sandbox's Landlock ruleset.
Verified empirically: chattr -i + chmod 666 via kubectl makes the
config writable from inside the sandbox, and chattr +i + chmod 444
re-locks it.

Also fix permissive policies: filesystem_policy section must match the
default policy exactly (OpenShell rejects include_workdir changes and
read_only path removals on live sandboxes). Network relaxation is the
only policy change; config mutability is handled by kubectl exec.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Dump state file and docker containers after shields down to diagnose
why shields status shows UP on CI despite shields down succeeding.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
@ericksoa ericksoa force-pushed the feat/sandbox-management-commands branch from e996ccd to 47c3ec2 Compare April 16, 2026 01:33
Root cause of shields status showing UP immediately after shields down:
the timer was forked with a relative timeout (300s) at the START of
shieldsDown(), but steps 1-2b (policy apply + kubectl chmod) take 5+
minutes on CI. By the time saveShieldsState({shieldsDown: true}) runs
at step 3, the timer's setTimeout has already elapsed and fires
immediately, resetting shieldsDown to false.

Fix: pass an absolute ISO timestamp (restoreAt) to the timer instead
of relative seconds. The timer computes delayMs = restoreAt - now,
so it always fires at the intended wall-clock time regardless of how
long the parent function takes.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
…ad/write

readSandboxConfig was constructing a shell string instead of using
captureOpenshellCommand with the correct argv array format including
the -- separator. configSet was using the nonexistent openshell
sandbox cp subcommand. Switch config reads to captureOpenshellCommand
with proper argv and config writes to kubectl exec via docker (same
pattern shields.ts uses to bypass Landlock).

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
… --name flag

shields-timer: replace fork() with spawn(process.execPath, [...]) to
eliminate the IPC channel that fork() creates even with stdio:'ignore'.
On CI the IPC disconnect when the parent exits was causing the detached
timer child to terminate early, immediately resetting shieldsDown to
false via the finally block.

sandbox-config: openshell sandbox exec requires --name as a flag, not a
positional argument. Without it the sandbox name was consumed as part of
<COMMAND>..., so cat ran in the default sandbox context and returned
empty output.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
openshell policy get --full returns a metadata header (Version, Hash,
etc.) followed by --- and the actual policy YAML. The snapshot was
saving the raw output including the header. When shields up restored
from this snapshot, openshell policy set rejected the unknown Version
field. Use parseCurrentPolicy() to strip the header before writing.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
spawn() caused the timer child to silently crash on CI (stdio:'ignore'
swallowed all errors). Revert to fork() which handles Node.js module
resolution correctly, but explicitly include 'ipc' in the stdio array
and call child.disconnect() before child.unref(). This cleanly tears
down the IPC channel while the parent is still alive, avoiding the
surprise disconnect event that was causing early timer firing on CI.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
openshell policy set --wait takes ~8s on CI. The timer was updating
shieldsDown state after the policy restore, so callers checking state
within those 8 seconds still saw shields as DOWN. Move the state
update before the policy restore so it reflects the timer's intent
immediately.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
openshell policy set --wait can time out on slow CI runners, causing
the first shields down to fail. The double-down rejection test then
incorrectly passes a second shields down that should have been blocked.
Retry the first shields down once before testing the guard.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
…, redaction

Critical:
- Namespace shields state per sandbox (shields-<name>.json) so multiple
  sandboxes don't stomp each other's state.
- Roll back permissive policy and restore shields if the auto-timer fork
  fails, instead of leaving the sandbox relaxed indefinitely.
- Check policy restore result in the auto-timer; revert state to DOWN if
  the restore command fails.

Major:
- Redact credentials/tokens from endpoint URLs in config-show.
- Redact reason and error fields in shields audit entries.
- Validate that --timeout/--reason/--policy have values; reject unknown
  flags on shields down.
- Remove unnecessary @ts-nocheck from test/duration.test.ts.
- Update test/shields.test.ts for sandbox-namespaced state files and
  add mock setup for disconnect/execFileSync/audit.

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
src/lib/sandbox-config.ts (3)

486-493: ⚠️ Potential issue | 🟡 Minor

Audit action type is incorrect for token rotation.

Similar to config set, the rotate-token operation uses action: "shields_down" which doesn't accurately describe the operation. This should use a dedicated action type like "token_rotated".

Suggested fix
   appendAuditEntry({
-    action: "shields_down",
+    action: "token_rotated",
     sandbox: sandboxName,
     timestamp: new Date().toISOString(),
     reason: `rotate-token ${target.agentName}:${credentialEnv}`,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 486 - 493, The audit entry for the
rotate-token flow uses an incorrect action value "shields_down"; update the
appendAuditEntry call used during rotate-token (the block that builds { action:
"shields_down", sandbox: sandboxName, ... reason: `rotate-token
${target.agentName}:${credentialEnv}` }) to use a dedicated action like
"token_rotated" (or another agreed token rotation constant) so the audit log
accurately represents the operation performed by rotate-token; ensure you change
only the action field in that appendAuditEntry invocation (in the rotate-token
handling path).

354-361: ⚠️ Potential issue | 🟡 Minor

Audit action type is incorrect for config set operations.

The audit entry uses action: "shields_down" for config set operations, which is semantically incorrect. shields_down indicates a policy relaxation event, not a config mutation. This makes the audit log misleading.

Suggested fix

Add "config_set" to the allowed action types in shields-audit.ts, then:

   appendAuditEntry({
-    action: "shields_down",
+    action: "config_set",
     sandbox: sandboxName,
     timestamp: new Date().toISOString(),
     reason: `config set ${target.agentName}:${opts.key}`,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 354 - 361, The audit entry for config
mutations currently uses action: "shields_down" which is semantically wrong;
update the call to appendAuditEntry in src/lib/sandbox-config.ts to use action:
"config_set" for config set operations (the appendAuditEntry invocation that
sets action, sandbox, timestamp, reason), and also add "config_set" to the
allowed action types enum/validation in shields-audit.ts so the new action is
accepted by the audit schema/validator.

181-188: ⚠️ Potential issue | 🟡 Minor

IPv6 private/link-local ranges not covered.

The isPrivateIp function checks IPv4 private ranges and [::1] but doesn't cover IPv6 unique-local addresses (fc00::/7 like fd00::) or link-local addresses (fe80::/10). A URL like http://[fd00::1]:8080/ would pass the SSRF validation.

Suggested enhancement
 function isPrivateIp(hostname: string): boolean {
   if (hostname === "localhost" || hostname === "[::1]") return true;
+  // IPv6 unique local (fc00::/7) — matches fd00::, fc00::, etc.
+  if (/^\[f[cd][0-9a-f]{2}:/i.test(hostname)) return true;
+  // IPv6 link-local (fe80::/10)
+  if (/^\[fe[89ab][0-9a-f]:/i.test(hostname)) return true;
   for (const prefix of PRIVATE_IP_PREFIXES) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 181 - 188, The isPrivateIp function
currently only handles IPv4 ranges and the literal "[::1]"; extend it to detect
IPv6 unique-local (fc00::/7, e.g., fd00::/8) and IPv6 link-local (fe80::/10)
addresses by normalizing/removing surrounding brackets from the hostname (handle
inputs like "[fd00::1]"), then add tests against new regexes (e.g.,
PRIVATE_IPV6_ULA_RE and PRIVATE_IPV6_LINKLOCAL_RE) or inline checks after the
existing PRIVATE_IP_172_RE check; update or add these constants and return true
when either regex matches.
src/lib/shields.ts (1)

110-123: ⚠️ Potential issue | 🟠 Major

Avoid signaling an unverified PID from disk.

process.kill(marker.pid, "SIGTERM") trusts the PID persisted in the marker file without verification. If the timer process has exited and the OS reused that PID for an unrelated process, this could terminate the wrong process.

Consider storing additional identity information (e.g., process start time from /proc/<pid>/stat) in the marker and verifying it matches before signaling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 110 - 123, The killTimer function currently
calls process.kill(marker.pid, "SIGTERM") using an unverified PID from
readTimerMarker; to fix, augment the timer marker to include an identity field
(e.g., process start time / uptime or start timestamp) when creating the marker
and in killTimer verify that the PID on disk matches the live process identity
before signaling: read the marker (readTimerMarker), fetch the candidate process
metadata (e.g., /proc/<pid>/stat starttime on Linux or equivalent), compare it
to the stored identity value and only call process.kill(marker.pid, "SIGTERM")
if they match; if they don't match, avoid killing and still remove the stale
marker via timerMarkerPath(sandboxName). Ensure failures to read process
metadata are handled gracefully (treat as mismatch) and document/update the
marker writing code to include the added identity field.
src/nemoclaw.ts (1)

2149-2157: ⚠️ Potential issue | 🟡 Minor

Validate config get options instead of silently defaulting.

The flag parsing for --key and --format doesn't validate:

  1. Missing values (e.g., --key at end of args)
  2. Invalid format values (only json/yaml are valid)
  3. Unknown flags

Scripts may silently receive different output than expected without an error.

Possible hardening
           case "get": {
             const configOpts = { key: null, format: "json" };
             for (let i = 1; i < actionArgs.length; i++) {
-              if (actionArgs[i] === "--key") configOpts.key = actionArgs[++i];
-              else if (actionArgs[i] === "--format") configOpts.format = actionArgs[++i];
+              if (actionArgs[i] === "--key") {
+                const value = actionArgs[++i];
+                if (!value || value.startsWith("--")) {
+                  console.error("  --key requires a value");
+                  process.exit(1);
+                }
+                configOpts.key = value;
+              } else if (actionArgs[i] === "--format") {
+                const value = actionArgs[++i];
+                if (!value || !["json", "yaml"].includes(value)) {
+                  console.error("  --format must be one of: json, yaml");
+                  process.exit(1);
+                }
+                configOpts.format = value;
+              } else {
+                console.error(`  Unknown flag: ${actionArgs[i]}`);
+                process.exit(1);
+              }
             }
             sandboxConfig.configGet(cmd, configOpts);
             break;
           }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 2149 - 2157, The flag parsing in the "get" case
(inside the switch handling actionArgs) silently defaults and can consume
out-of-bounds args or accept invalid values; update the parsing around
actionArgs and configOpts so that before doing configOpts.key = actionArgs[++i]
or configOpts.format = actionArgs[++i] you verify that i+1 < actionArgs.length
and produce an error (throw or call the existing error-handling/logger) if a
value is missing, validate configOpts.format accepts only "json" or "yaml" and
error on anything else, and treat any unrecognized flag (anything starting with
"--" that is not --key or --format) as an error; only call
sandboxConfig.configGet(cmd, configOpts) after these validations succeed.
🧹 Nitpick comments (6)
agents/hermes/policy-permissive.yaml (1)

29-33: Consider removing redundant subdirectory entries under /sandbox.

With Line [29] granting write access to /sandbox, Lines [32]-[33] are functionally redundant. Dropping them would reduce policy drift risk and make intent clearer.

♻️ Optional cleanup
   read_write:
     - /sandbox
     - /tmp
     - /dev/null
-    - /sandbox/.hermes-data
-    - /sandbox/.nemoclaw
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents/hermes/policy-permissive.yaml` around lines 29 - 33, The policy grants
write access to /sandbox as a top-level entry, so the more-specific entries
/sandbox/.hermes-data and /sandbox/.nemoclaw are redundant and should be removed
to reduce policy drift; update agents/hermes/policy-permissive.yaml by deleting
those subdirectory lines, keep the general /sandbox entry, and run your policy
linter/validation to ensure no other code expects the explicit subdirectory
entries.
test/shields.test.ts (2)

50-50: Add type annotation to tmpDir.

As per coding guidelines, TypeScript code should have proper type annotations. Adding string type would make the intent clearer.

Suggested fix
-let tmpDir;
+let tmpDir: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/shields.test.ts` at line 50, The variable declaration for tmpDir lacks a
TypeScript type annotation; update the declaration of tmpDir (the variable named
tmpDir in test/shields.test.ts) to include an explicit type (e.g., string) so
its intent is clear and conforms to the project's typing guidelines.

1-1: Consider removing @ts-nocheck and adding proper types.

The // @ts-nocheck`` directive disables all TypeScript checking in this file. While this may be a pragmatic choice for test files with complex mocking, it also means type errors won't be caught. Consider using more targeted type assertions or @ts-expect-error comments for specific problem areas instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/shields.test.ts` at line 1, Remove the top-level "// `@ts-nocheck`" from
test/shields.test.ts and restore TypeScript checking; then explicitly type the
test helpers and mocks (e.g., annotate mocked modules with jest.Mocked<T> or
jest.MockedFunction<...>, cast temporary values with "as unknown as Type" where
necessary, and add targeted "// `@ts-expect-error`" comments only for the specific
lines that genuinely need suppressing). Update any untyped imports or variables
used in describe/it blocks to have concrete types or interfaces, and replace
broad "any" usage with narrow types so the file compiles cleanly under
TypeScript without disabling checking globally.
src/nemoclaw.ts (1)

2158-2167: Consider validating config set flags in CLI layer.

While configSet() validates that --key and --value are provided, validating at the CLI parsing layer would give more immediate feedback. Currently, passing --key without a value would set key to undefined (from actionArgs[++i]), which is caught later but with a generic error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 2158 - 2167, The CLI parsing in the "set" case
currently assigns flags without validating presence of values, so incrementing i
and reading actionArgs[++i] can yield undefined for --key/--value; add immediate
validation after parsing each flag in the case "set" block to ensure that when
actionArgs[i] === "--key" or "--value" the next token exists and is not another
flag (throw or print a clear CLI error and exit if missing), and only then set
setOpts.key/setOpts.value; keep the existing sandboxConfig.configSet(cmd,
setOpts) call but ensure setOpts.key and setOpts.value are validated at this CLI
layer before invoking sandboxConfig.configSet.
src/lib/sandbox-config.ts (2)

15-25: Consider using ESM imports for consistency.

The file uses CommonJS require() syntax while the coding guidelines encourage ESM for TypeScript files. While this works, ESM imports would provide better type inference and align with modern TypeScript practices.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 15 - 25, Replace the CommonJS
require() calls at the top of sandbox-config.ts with ESM-style imports: use
"import * as fs from 'fs'; import * as os from 'os'; import * as path from
'path'; import { execFileSync } from 'child_process'; import { validateName }
from './runner'; import { stripCredentials } from './credential-filter'; import
{ appendAuditEntry } from './shields-audit'; import { runOpenshellCommand,
captureOpenshellCommand } from './openshell';" and then adjust any subsequent
usage if necessary; ensure your build/tsconfig or package.json module settings
support ESM so these imports resolve at runtime.

329-333: Shell command with interpolated path — verify source is trusted.

The command cat > ${target.configPath} interpolates the config path into a shell command. While target.configPath is derived from the agent manifest (developer-controlled), consider using an array-based command construction to avoid potential issues if the path ever contains special characters.

Defensive improvement
   execFileSync("docker", [
     "exec", "-i", K3S_CONTAINER,
     "kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "-i", "--",
-    "sh", "-c", `cat > ${target.configPath}`,
+    "sh", "-c", `cat > "$1"`, "--", target.configPath,
   ], { input: content, stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 329 - 333, The current execFileSync
call builds a shell string with interpolation (cat > ${target.configPath}) which
risks mis-handling special characters from developer-controlled agent manifests;
change the invocation to avoid injecting the path into the shell string — e.g.,
stop using a single interpolated "sh -c" argument and instead pass the path as a
separate positional parameter to the shell (or use a non-shell approach like
piping into tee) so the path is not interpreted by the shell; update the
execFileSync call that uses K3S_CONTAINER, sandboxName and target.configPath to
pass the config path as an argument (for example: "sh", "-c", "cat > \"$1\"",
"--", target.configPath) or equivalent safe array-based construction.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoclaw/src/onboard/config.ts`:
- Around line 49-51: The catch block that returns the raw endpointUrl on parse
failure can leak credentials; update the catch in nemoclaw/src/onboard/config.ts
to return a sanitized version instead of the original string by invoking a new
or existing sanitizer (e.g., redactCredentials or sanitizeEndpoint) that strips
user:pass@ host credentials and sensitive query params (or masks them) before
passing the value to slashConfigShow; ensure the sanitizer is used wherever
endpointUrl is shown (e.g., slashConfigShow) so malformed URLs cannot bypass
redaction.

---

Duplicate comments:
In `@src/lib/sandbox-config.ts`:
- Around line 486-493: The audit entry for the rotate-token flow uses an
incorrect action value "shields_down"; update the appendAuditEntry call used
during rotate-token (the block that builds { action: "shields_down", sandbox:
sandboxName, ... reason: `rotate-token ${target.agentName}:${credentialEnv}` })
to use a dedicated action like "token_rotated" (or another agreed token rotation
constant) so the audit log accurately represents the operation performed by
rotate-token; ensure you change only the action field in that appendAuditEntry
invocation (in the rotate-token handling path).
- Around line 354-361: The audit entry for config mutations currently uses
action: "shields_down" which is semantically wrong; update the call to
appendAuditEntry in src/lib/sandbox-config.ts to use action: "config_set" for
config set operations (the appendAuditEntry invocation that sets action,
sandbox, timestamp, reason), and also add "config_set" to the allowed action
types enum/validation in shields-audit.ts so the new action is accepted by the
audit schema/validator.
- Around line 181-188: The isPrivateIp function currently only handles IPv4
ranges and the literal "[::1]"; extend it to detect IPv6 unique-local (fc00::/7,
e.g., fd00::/8) and IPv6 link-local (fe80::/10) addresses by
normalizing/removing surrounding brackets from the hostname (handle inputs like
"[fd00::1]"), then add tests against new regexes (e.g., PRIVATE_IPV6_ULA_RE and
PRIVATE_IPV6_LINKLOCAL_RE) or inline checks after the existing PRIVATE_IP_172_RE
check; update or add these constants and return true when either regex matches.

In `@src/lib/shields.ts`:
- Around line 110-123: The killTimer function currently calls
process.kill(marker.pid, "SIGTERM") using an unverified PID from
readTimerMarker; to fix, augment the timer marker to include an identity field
(e.g., process start time / uptime or start timestamp) when creating the marker
and in killTimer verify that the PID on disk matches the live process identity
before signaling: read the marker (readTimerMarker), fetch the candidate process
metadata (e.g., /proc/<pid>/stat starttime on Linux or equivalent), compare it
to the stored identity value and only call process.kill(marker.pid, "SIGTERM")
if they match; if they don't match, avoid killing and still remove the stale
marker via timerMarkerPath(sandboxName). Ensure failures to read process
metadata are handled gracefully (treat as mismatch) and document/update the
marker writing code to include the added identity field.

In `@src/nemoclaw.ts`:
- Around line 2149-2157: The flag parsing in the "get" case (inside the switch
handling actionArgs) silently defaults and can consume out-of-bounds args or
accept invalid values; update the parsing around actionArgs and configOpts so
that before doing configOpts.key = actionArgs[++i] or configOpts.format =
actionArgs[++i] you verify that i+1 < actionArgs.length and produce an error
(throw or call the existing error-handling/logger) if a value is missing,
validate configOpts.format accepts only "json" or "yaml" and error on anything
else, and treat any unrecognized flag (anything starting with "--" that is not
--key or --format) as an error; only call sandboxConfig.configGet(cmd,
configOpts) after these validations succeed.

---

Nitpick comments:
In `@agents/hermes/policy-permissive.yaml`:
- Around line 29-33: The policy grants write access to /sandbox as a top-level
entry, so the more-specific entries /sandbox/.hermes-data and /sandbox/.nemoclaw
are redundant and should be removed to reduce policy drift; update
agents/hermes/policy-permissive.yaml by deleting those subdirectory lines, keep
the general /sandbox entry, and run your policy linter/validation to ensure no
other code expects the explicit subdirectory entries.

In `@src/lib/sandbox-config.ts`:
- Around line 15-25: Replace the CommonJS require() calls at the top of
sandbox-config.ts with ESM-style imports: use "import * as fs from 'fs'; import
* as os from 'os'; import * as path from 'path'; import { execFileSync } from
'child_process'; import { validateName } from './runner'; import {
stripCredentials } from './credential-filter'; import { appendAuditEntry } from
'./shields-audit'; import { runOpenshellCommand, captureOpenshellCommand } from
'./openshell';" and then adjust any subsequent usage if necessary; ensure your
build/tsconfig or package.json module settings support ESM so these imports
resolve at runtime.
- Around line 329-333: The current execFileSync call builds a shell string with
interpolation (cat > ${target.configPath}) which risks mis-handling special
characters from developer-controlled agent manifests; change the invocation to
avoid injecting the path into the shell string — e.g., stop using a single
interpolated "sh -c" argument and instead pass the path as a separate positional
parameter to the shell (or use a non-shell approach like piping into tee) so the
path is not interpreted by the shell; update the execFileSync call that uses
K3S_CONTAINER, sandboxName and target.configPath to pass the config path as an
argument (for example: "sh", "-c", "cat > \"$1\"", "--", target.configPath) or
equivalent safe array-based construction.

In `@src/nemoclaw.ts`:
- Around line 2158-2167: The CLI parsing in the "set" case currently assigns
flags without validating presence of values, so incrementing i and reading
actionArgs[++i] can yield undefined for --key/--value; add immediate validation
after parsing each flag in the case "set" block to ensure that when
actionArgs[i] === "--key" or "--value" the next token exists and is not another
flag (throw or print a clear CLI error and exit if missing), and only then set
setOpts.key/setOpts.value; keep the existing sandboxConfig.configSet(cmd,
setOpts) call but ensure setOpts.key and setOpts.value are validated at this CLI
layer before invoking sandboxConfig.configSet.

In `@test/shields.test.ts`:
- Line 50: The variable declaration for tmpDir lacks a TypeScript type
annotation; update the declaration of tmpDir (the variable named tmpDir in
test/shields.test.ts) to include an explicit type (e.g., string) so its intent
is clear and conforms to the project's typing guidelines.
- Line 1: Remove the top-level "// `@ts-nocheck`" from test/shields.test.ts and
restore TypeScript checking; then explicitly type the test helpers and mocks
(e.g., annotate mocked modules with jest.Mocked<T> or jest.MockedFunction<...>,
cast temporary values with "as unknown as Type" where necessary, and add
targeted "// `@ts-expect-error`" comments only for the specific lines that
genuinely need suppressing). Update any untyped imports or variables used in
describe/it blocks to have concrete types or interfaces, and replace broad "any"
usage with narrow types so the file compiles cleanly under TypeScript without
disabling checking globally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18be7ebd-c1e1-41f6-af6b-65d06e7c7008

📥 Commits

Reviewing files that changed from the base of the PR and between fd575de and 2a194d9.

📒 Files selected for processing (11)
  • agents/hermes/policy-permissive.yaml
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
  • nemoclaw/src/onboard/config.ts
  • src/lib/sandbox-config.ts
  • src/lib/shields-audit.ts
  • src/lib/shields-timer.ts
  • src/lib/shields.ts
  • src/nemoclaw.ts
  • test/duration.test.ts
  • test/e2e/test-shields-config.sh
  • test/shields.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/duration.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
  • src/lib/shields-audit.ts
  • src/lib/shields-timer.ts
  • test/e2e/test-shields-config.sh

Comment thread nemoclaw/src/onboard/config.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
nemoclaw/src/onboard/config.ts (1)

49-51: ⚠️ Potential issue | 🔴 Critical

Critical: parse-failure fallback still exposes raw endpoint secrets.

On Line 50, the catch path keeps safeUrl unchanged, so malformed URLs can bypass masking and leak credentials/tokens in displayed output. Use conservative string-level redaction (or shared sanitizer) in the catch path instead of “show as-is.”

Suggested fix
   let safeUrl = config.endpointUrl;
   try {
     const parsed = new URL(config.endpointUrl);
     if (parsed.password) parsed.password = "****";
     if (parsed.username) parsed.username = "****";
     for (const key of [...parsed.searchParams.keys()]) {
       if (/(token|key|secret|auth|sig|credential|password)/i.test(key)) {
         parsed.searchParams.set(key, "****");
       }
     }
     safeUrl = parsed.toString();
   } catch {
-    // Not a valid URL — show as-is
+    // Not a valid URL — redact conservatively
+    safeUrl = config.endpointUrl
+      .replace(/\/\/([^:@\/\s]+):([^@\/\s]+)@/g, "//****:****@")
+      .replace(
+        /([?&][^=]*?(token|key|secret|auth|sig|credential|password)[^=]*=)[^&#]*/gi,
+        "$1****",
+      );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/onboard/config.ts` around lines 49 - 51, The catch block that
currently leaves safeUrl unchanged on parse failure must instead apply
conservative string-level redaction to avoid leaking credentials; update the
catch in onboard/config.ts where safeUrl is set so it calls the shared sanitizer
(e.g., sanitizeRawEndpoint) or performs a simple redaction (mask any "://...@"
userinfo and long token-like query/header fragments) and assigns that result to
safeUrl before using or returning it; ensure you reference the same variable
name safeUrl and replace the "show as-is" behavior with the sanitizer call.
🧹 Nitpick comments (3)
test/e2e/test-shields-config.sh (1)

450-491: Auto-restore timer test is valuable but timing-sensitive.

The 10s timeout with 15s wait is reasonable, but CI runners under load might occasionally fail this test. Consider adding a retry or increasing the margin.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/e2e/test-shields-config.sh` around lines 450 - 491, The auto-restore
timer test is flaky under CI due to tight timing (shields down uses --timeout
10s and the script sleeps 15s); update the test to be more robust by increasing
the timeout/wait margin or adding a retry loop: adjust the nemoclaw shields down
--timeout value and the subsequent sleep (or replace the single sleep+check
using STATUS_AFTER_TIMER with a short loop that queries nemoclaw
"${SANDBOX_NAME}" shields status up to N times with a small sleep between
attempts), and keep the existing cleanup logic; target the lines/variables
STATUS_TIMER, STATUS_AFTER_TIMER, the shields down --timeout argument, and the
sleep 15 call when making the change.
src/lib/shields.ts (1)

197-203: Consider documenting the broad permissions during shields-down.

chmod 666 on the config file and chmod 777 on the config directory are intentionally permissive to allow sandbox writes. This is noted in comments, but consider adding a warning in the audit entry or status output so operators are aware of the relaxed state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/shields.ts` around lines 197 - 203, When performing the permissive
chmods in the shields-down flow (the try block that calls
kubectlExec(sandboxName, ["chattr", "-i", target.configPath]) and
kubectlExec(sandboxName, ["chmod", "666", target.configPath]) /
kubectlExec(sandboxName, ["chmod", "777", target.configDir])), emit an explicit
audit/status warning that the config file and directory are now world-writable;
add a one-line audit entry or status field labeled something like "shields-down:
relaxed permissions" referencing target.configPath and target.configDir so
operators see the relaxed 666/777 state in logs or UI.
src/lib/sandbox-config.ts (1)

504-512: readStdin can hang indefinitely if stdin never closes.

If the caller doesn't close stdin (e.g., piped from a hung process or interactive terminal with no EOF), this Promise never resolves. Consider adding a timeout.

Suggested enhancement
-function readStdin(): Promise<string> {
-  return new Promise((resolve, reject) => {
+function readStdin(timeoutMs = 30000): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      process.stdin.removeAllListeners();
+      reject(new Error("stdin read timed out"));
+    }, timeoutMs);
     const chunks: Buffer[] = [];
     process.stdin.on("data", (chunk: Buffer) => chunks.push(chunk));
-    process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
-    process.stdin.on("error", reject);
+    process.stdin.on("end", () => {
+      clearTimeout(timer);
+      resolve(Buffer.concat(chunks).toString("utf-8").trim());
+    });
+    process.stdin.on("error", (err) => {
+      clearTimeout(timer);
+      reject(err);
+    });
     process.stdin.resume();
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-config.ts` around lines 504 - 512, readStdin can hang forever
if stdin never emits "end"; add a configurable timeout (e.g., default 10s)
inside readStdin so the Promise rejects or resolves with a safe value when time
expires. Implement by creating a timer (setTimeout) when the promise starts; on
timeout clear the stdin listeners (the "data", "end", and "error" handlers
attached to process.stdin), clear the timer on normal completion or error, and
then reject (or resolve with an empty string) with a clear timeout error
message. Ensure you reference the same event handlers/variables (chunks,
process.stdin listeners) so cleanup removes exactly those listeners.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/shields.ts`:
- Line 24: STATE_DIR currently falls back to "/tmp" which is insecure on
multi-user systems; change the resolution to prefer XDG_STATE_HOME, then HOME,
and only if neither is set create a secure per-process directory using
fs.mkdtemp (or similar) under os.tmpdir(), ensure the directory is created with
restrictive permissions (mode 0o700) and assign STATE_DIR to that path; update
any initialization code that uses the STATE_DIR constant (refer to STATE_DIR in
src/lib/shields.ts) to create the directory if missing and enforce the
permissions before using it.

In `@src/nemoclaw.ts`:
- Around line 2168-2176: The rotate-token handler in the "rotate-token" case
currently allows --from-env to consume undefined if it's the last element of
actionArgs; update the parsing logic around actionArgs, tokenOpts and the loop
in that case to validate that actionArgs[i+1] exists before incrementing i and
assigning tokenOpts.fromEnv, returning or throwing a user-facing error (or
printing usage) when the value is missing; also ensure mutual flag semantics for
--from-stdin/--from-env are respected before calling
sandboxConfig.configRotateToken(cmd, tokenOpts).
- Around line 2158-2167: The "set" case in the switch uses actionArgs parsing
that can consume undefined for "--key" or "--value" when those flags are last
and silently ignores unknown flags; update the parsing in the case "set" block
to validate that a next token exists before doing actionArgs[++i], reject or
error on unknown flags, and return a clear error when --key or --value are
missing/undefined before calling sandboxConfig.configSet (reference the setOpts
object, actionArgs array, and sandboxConfig.configSet call to locate the code).

---

Duplicate comments:
In `@nemoclaw/src/onboard/config.ts`:
- Around line 49-51: The catch block that currently leaves safeUrl unchanged on
parse failure must instead apply conservative string-level redaction to avoid
leaking credentials; update the catch in onboard/config.ts where safeUrl is set
so it calls the shared sanitizer (e.g., sanitizeRawEndpoint) or performs a
simple redaction (mask any "://...@" userinfo and long token-like query/header
fragments) and assigns that result to safeUrl before using or returning it;
ensure you reference the same variable name safeUrl and replace the "show as-is"
behavior with the sanitizer call.

---

Nitpick comments:
In `@src/lib/sandbox-config.ts`:
- Around line 504-512: readStdin can hang forever if stdin never emits "end";
add a configurable timeout (e.g., default 10s) inside readStdin so the Promise
rejects or resolves with a safe value when time expires. Implement by creating a
timer (setTimeout) when the promise starts; on timeout clear the stdin listeners
(the "data", "end", and "error" handlers attached to process.stdin), clear the
timer on normal completion or error, and then reject (or resolve with an empty
string) with a clear timeout error message. Ensure you reference the same event
handlers/variables (chunks, process.stdin listeners) so cleanup removes exactly
those listeners.

In `@src/lib/shields.ts`:
- Around line 197-203: When performing the permissive chmods in the shields-down
flow (the try block that calls kubectlExec(sandboxName, ["chattr", "-i",
target.configPath]) and kubectlExec(sandboxName, ["chmod", "666",
target.configPath]) / kubectlExec(sandboxName, ["chmod", "777",
target.configDir])), emit an explicit audit/status warning that the config file
and directory are now world-writable; add a one-line audit entry or status field
labeled something like "shields-down: relaxed permissions" referencing
target.configPath and target.configDir so operators see the relaxed 666/777
state in logs or UI.

In `@test/e2e/test-shields-config.sh`:
- Around line 450-491: The auto-restore timer test is flaky under CI due to
tight timing (shields down uses --timeout 10s and the script sleeps 15s); update
the test to be more robust by increasing the timeout/wait margin or adding a
retry loop: adjust the nemoclaw shields down --timeout value and the subsequent
sleep (or replace the single sleep+check using STATUS_AFTER_TIMER with a short
loop that queries nemoclaw "${SANDBOX_NAME}" shields status up to N times with a
small sleep between attempts), and keep the existing cleanup logic; target the
lines/variables STATUS_TIMER, STATUS_AFTER_TIMER, the shields down --timeout
argument, and the sleep 15 call when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18be7ebd-c1e1-41f6-af6b-65d06e7c7008

📥 Commits

Reviewing files that changed from the base of the PR and between fd575de and 2a194d9.

📒 Files selected for processing (11)
  • agents/hermes/policy-permissive.yaml
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
  • nemoclaw/src/onboard/config.ts
  • src/lib/sandbox-config.ts
  • src/lib/shields-audit.ts
  • src/lib/shields-timer.ts
  • src/lib/shields.ts
  • src/nemoclaw.ts
  • test/duration.test.ts
  • test/e2e/test-shields-config.sh
  • test/shields.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/duration.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • agents/hermes/policy-permissive.yaml
  • nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml
  • test/shields.test.ts
  • src/lib/shields-timer.ts

Comment thread src/lib/shields.ts
Comment thread src/nemoclaw.ts
Comment thread src/nemoclaw.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/nemoclaw.ts (1)

2291-2378: Consider extracting shields/config parsing into dedicated handlers.

This nested switch logic further increases dispatcher complexity. Pulling each action family into helper functions would keep this file maintainable and closer to the complexity target.

As per coding guidelines **/*.{js,ts,tsx,jsx}: Limit cyclomatic complexity to 20 in JavaScript/TypeScript files, with target of 15.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nemoclaw.ts` around lines 2291 - 2378, The nested switch for "shields"
and "config" should be moved into two helper functions (e.g.
handleShieldsCommand(cmd, actionArgs) and handleConfigCommand(cmd, actionArgs));
move the entire parsing logic that builds opts/configOpts/setOpts/tokenOpts and
the calls to shields.shieldsDown/shieldsUp/shieldsStatus and
sandboxConfig.configGet/configSet/configRotateToken into those functions,
preserving the exact flag validation, error messages, process.exit calls and
default usage branches; replace the existing case "shields": and case "config":
blocks with single calls to the new handlers to keep behavior identical while
reducing cyclomatic complexity in the main dispatcher.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/nemoclaw.ts`:
- Around line 2291-2378: The nested switch for "shields" and "config" should be
moved into two helper functions (e.g. handleShieldsCommand(cmd, actionArgs) and
handleConfigCommand(cmd, actionArgs)); move the entire parsing logic that builds
opts/configOpts/setOpts/tokenOpts and the calls to
shields.shieldsDown/shieldsUp/shieldsStatus and
sandboxConfig.configGet/configSet/configRotateToken into those functions,
preserving the exact flag validation, error messages, process.exit calls and
default usage branches; replace the existing case "shields": and case "config":
blocks with single calls to the new handlers to keep behavior identical while
reducing cyclomatic complexity in the main dispatcher.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fa6e1ed8-1a84-4bb4-bcc3-47f8a1df44de

📥 Commits

Reviewing files that changed from the base of the PR and between 2a194d9 and 435691b.

📒 Files selected for processing (2)
  • .github/workflows/nightly-e2e.yaml
  • src/nemoclaw.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/nightly-e2e.yaml

…rebuild info

Bring plugin branch coverage from 84.95% to 86.28% (above 86% ratchet):
- config.test.ts: cover URL credential redaction and non-URL fallback
- config-show.test.ts: cover empty credentialEnv branch
- shields-status.test.ts: cover UP with snapshot path, DOWN with no timeout
- slash.test.ts: cover lastRebuildAt and lastRebuildBackupPath display

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
nemoclaw/src/onboard/config.test.ts (1)

75-85: Expand redaction assertions to cover username leakage too.

This test validates password/query-token masking, but it doesn’t assert that username/userinfo is removed (Line 78 includes user:secret@...). Add an explicit negative assertion so credential redaction coverage is complete.

Suggested test tweak
     const result = describeOnboardEndpoint(config);
     expect(result).not.toContain("secret");
+    expect(result).not.toContain("user:");
+    expect(result).not.toContain("user@");
     expect(result).not.toContain("abc123");
     expect(result).toContain("api.example.com");
     expect(result).toContain("****");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/onboard/config.test.ts` around lines 75 - 85, The test for
describeOnboardEndpoint currently checks that password and query-token are
redacted but misses asserting that the username/userinfo is also removed; update
the test that uses makeConfig with endpointUrl "https://user:secret@..." to
include an explicit negative assertion that "user" (or the username portion) is
not present in result, while keeping existing positive checks for the host and
the redaction marker (e.g., "****").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@nemoclaw/src/onboard/config.test.ts`:
- Around line 75-85: The test for describeOnboardEndpoint currently checks that
password and query-token are redacted but misses asserting that the
username/userinfo is also removed; update the test that uses makeConfig with
endpointUrl "https://user:secret@..." to include an explicit negative assertion
that "user" (or the username portion) is not present in result, while keeping
existing positive checks for the host and the redaction marker (e.g., "****").

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 07fc40c0-0d6a-40a7-9964-0a44c90d68e6

📥 Commits

Reviewing files that changed from the base of the PR and between 435691b and 718de1a.

📒 Files selected for processing (4)
  • nemoclaw/src/commands/config-show.test.ts
  • nemoclaw/src/commands/shields-status.test.ts
  • nemoclaw/src/commands/slash.test.ts
  • nemoclaw/src/onboard/config.test.ts
✅ Files skipped from review due to trivial changes (1)
  • nemoclaw/src/commands/shields-status.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • nemoclaw/src/commands/config-show.test.ts
  • nemoclaw/src/commands/slash.test.ts

@ericksoa
Copy link
Copy Markdown
Contributor Author

Superseded by #1976 — clean branch with signed commit and updated description.

@ericksoa ericksoa closed this Apr 16, 2026
ericksoa added a commit that referenced this pull request Apr 17, 2026
## Summary

Experimental sandbox management commands for shields and config
operations. **This feature is not formally documented or advertised** —
it is gated behind the same experimental-feature conventions used
elsewhere in the codebase.

- `shields down/up/status` — time-bounded policy relaxation with
automatic restore
- `config get/set/rotate-token` — host-initiated config inspection and
mutation
- E2E test exercising the full lifecycle against a live sandbox

### Shields
- Per-sandbox state namespacing (`shields-<name>.json`)
- Auto-restore timer via detached `fork()` with explicit IPC disconnect
- Rollback on timer fork failure (sandbox never left unguarded)
- Timer verifies policy restore succeeded; reverts state on failure
- Append-only audit trail with credential redaction

### Config
- `config get`: read-only with credential stripping and gateway section
removal
- `config set`: SSRF validation, gateway write blocking, kubectl exec
for Landlock bypass
- `config rotate-token`: env var, stdin, or interactive prompt
- All `openshell sandbox exec` calls use `--name` flag (not positional)

### Security hardening (CodeRabbit review)
- Endpoint URL redaction in plugin `/nemoclaw config` slash command
- Audit reason/error fields pass through `redact()` before writing
- `shields down` rejects missing flag values and unknown flags
- `parseCurrentPolicy()` strips metadata header from policy snapshots

## Test plan
- [x] E2E: `test/e2e/test-shields-config.sh` — full 12-phase lifecycle
(37/37 pass on CI)
- [x] Unit: duration parsing, state management, audit format, config set
validation, URL redaction
- [x] Plugin branch coverage 86.28% (above 86% ratchet)

Supersedes #1849.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added NemoClaw shields: `shields down|up|status` with timeout, reason,
policy snapshot, audit logging, auto-restore, and CLI + slash
command/status outputs.
* Added sandbox config commands: `config get|set|rotate-token` with
redacted displays, validation, and optional in-sandbox apply.
  * Improved endpoint display sanitization and duration parsing/limits.

* **Tests**
* Expanded unit, integration, and E2E coverage for shields, config,
duration parsing, token rotation, and audit logging.

* **Chores**
* CI failure notification job updated; agent sandbox filesystem policies
adjusted.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement: feature Use this label to identify requests for new capabilities in NemoClaw. NemoClaw CLI Use this label to identify issues with the NemoClaw command-line interface (CLI). priority: high Important issue that should be resolved in the next release security Something isn't secure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants