Skip to content

fix(shields,state): keep gateway readable and runtime sessions writable under shields-up#4155

Open
laitingsheng wants to merge 8 commits into
mainfrom
fix/shields-up-lockdown-runtime-perms
Open

fix(shields,state): keep gateway readable and runtime sessions writable under shields-up#4155
laitingsheng wants to merge 8 commits into
mainfrom
fix/shields-up-lockdown-runtime-perms

Conversation

@laitingsheng
Copy link
Copy Markdown
Contributor

@laitingsheng laitingsheng commented May 24, 2026

Summary

Fix two related shields-up regressions: under lockdown the OpenClaw gateway lost sandbox-group access to plugin/agent state dirs (so /nemoclaw and openclaw-weixin reported "plugin not found" + the TUI crashed with EACCES: mkdir agents/main/sessions), and nemoclaw rebuild aborted its pre-backup audit on a fresh sandbox because find exits non-zero when it walks the base-image's root-owned subdirs.

Related Issue

Fixes #4065
Fixes #4059

Changes

  • src/lib/shields/index.ts — lock the high-risk state dirs as root:sandbox (top-level config dir is still root:root) so the gateway keeps r-x access to descendants stripped to 2750 by chmod -R go-w. Add WRITABLE_RUNTIME_SUBPATHS = ["agents/*/sessions"] and a restoreWritableRuntimeSubpaths helper that runs at the end of the lock and chowns those paths back to sandbox:sandbox 2770 so the agent keeps writing session metadata under lockdown. Promote isLocking from a string-equality derivation to an explicit parameter so the new owner string can change without affecting locking semantics.
  • src/lib/state/sandbox.ts — replace the &&-joined per-dir find chain in backupSandboxState's pre-backup audit with ;-joined { find … || true; } blocks. The audit's real signal is its stdout (the printf-emitted symlink / hardlink / special-file rows); exit codes from permission-denied root-owned subdirs are noise.
  • test/shields-up-runtime-perms.test.ts (new) — regression coverage for the lockAgentConfig ownership change and the agents/*/sessions restoration shell loop.
  • test/repro-2681-group-writable.test.ts — update the existing lockAgentConfig assertion to expect root:sandbox in the state-dir-lock shell command.
  • test/snapshot.test.ts — add two regressions for the audit-find tolerance: (a) find exit 1 with empty stdout still produces a successful audit, and (b) violations from a readable sibling dir are still rejected even when a co-located dir is unreadable.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Verification

  • npx prek run --all-files passes
  • npm test passes
  • Tests added or updated for new or changed behavior
  • No secrets, API keys, or credentials committed
  • Docs updated for user-facing behavior changes
  • make docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

Signed-off-by: Tinson Lai tinsonl@nvidia.com

Summary by CodeRabbit

  • Bug Fixes

    • Backup audit tolerates unreadable state directories and now detects & reports symlinked state-dir roots instead of following them.
  • Improvements

    • Locking applies stricter ownership/permission semantics for high-risk state directories and performs best-effort restoration of runtime-writable session subtrees when enabling locks; unlocking skips restoration.
  • Tests

    • Added regression and runtime tests covering backup-audit behavior, session-subtree restoration, ownership/permission expectations, and symlink safety.
  • Documentation

    • Expanded guidance on agent config directory immutability, ownership/permission model, and symlink failure behavior.

Review Change Stack

…te root-owned subdirs in audit

NemoClaw #4065: shields-up locked HIGH_RISK_STATE_DIRS as root:root,
which stripped sandbox-group ownership from descendants and broke
OpenClaw plugin discovery (`extensions/<plugin>/` became unreachable to
the gateway, which is only granted sandbox-group access). It also left
`agents/main/` non-writable to the sandbox user, so the OpenClaw TUI's
lazy mkdir of `agents/main/sessions/` failed with EACCES on first
launch under lockdown.

Switch the state-dir lock to `root:sandbox` (top-level configDir is
still `root:root`) so the gateway keeps `r-x` via the sandbox group on
descendants stripped to 2750 by `chmod -R go-w`, and restore
`agents/*/sessions` to `sandbox:sandbox 2770` after the main lock loop
so the agent keeps writing session metadata under lockdown.

NemoClaw #4059: pre-backup audit joined per-dir `find` invocations with
`&&`, so a single permission-denied subdir made the whole chain exit 1
and the rebuild treated every state dir as failed. Join with `;` and
wrap each `find` with `|| true` — the audit's real signal is its
stdout (symlink / hardlink / special-file rows); exit codes from
perm-denied root-owned subdirs are noise.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@laitingsheng laitingsheng added fix Sandbox Use this label to identify issues related to the NemoClaw isolated environment based on OpenShell. labels May 24, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1c1fedd4-2a8d-449a-877d-75486b196e76

📥 Commits

Reviewing files that changed from the base of the PR and between b95913a and f099b8b.

📒 Files selected for processing (3)
  • docs/security/best-practices.mdx
  • src/lib/shields/index.ts
  • test/shields-up-runtime-perms.test.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/security/best-practices.mdx

📝 Walkthrough

Walkthrough

State-directory lockdown now uses an explicit lock/unlock flag; high-risk dirs are locked as root:sandbox, writable runtime subpaths (agents/*/sessions) are restored during shields-up, and the pre-backup audit tolerates permission-denied subdirs. Tests validate lock scripts, symlink preflight checks, runtime restoration, and backup semantics.

Changes

Security Hardening and Runtime Permissions

Layer / File(s) Summary
Shields lockdown refactoring and ownership contract
src/lib/shields/index.ts, docs/security/best-practices.mdx
applyStateDirLockMode now accepts explicit isLocking; WRITABLE_RUNTIME_SUBPATHS added; NC-2227-05 docs updated to describe root:sandbox ownership for high-risk state dirs while top-level config stays root:root; internals branch on isLocking, add symlink-guard privileged scripts, and shields-up/down call sites updated.
Pre-backup audit command resilience
src/lib/state/sandbox.ts, test/snapshot.test.ts
Pre-backup audit find invocations are joined with ; and each find is appended with `
Runtime permission probe tests and fixture validations
test/shields-up-runtime-perms.test.ts, src/lib/shields/index.ts
Adds a Node subprocess probe that captures lock sh -c scripts and docker-exec payloads; asserts root:sandbox semantics and symlink-guard logic; extracts post-lock restore snippet and re-executes it against fixtures to verify agents/<id>/sessions creation and symlink safety; includes atomicity checks.
Test adjustments for shields expectations
test/repro-2681-group-writable.test.ts
Shields-down assertions now filter recorded sh -c invocations for sandbox:sandbox, g+rwX,o-rwx, 2770, and workspace handling; shields-up test updated to expect root:sandbox in the recorded lock command.

Sequence Diagram

sequenceDiagram
  participant Shields
  participant applyStateDirLockMode
  participant RestoreRuntimeSubpaths
  participant Filesystem
  Shields->>applyStateDirLockMode: request lock (isLocking=true, owner=root:sandbox)
  applyStateDirLockMode->>Filesystem: recursive chown/chmod (strip write bits, set owner)
  applyStateDirLockMode->>RestoreRuntimeSubpaths: run restore script for agents/*/sessions
  RestoreRuntimeSubpaths->>Filesystem: mkdir/chown sandbox:sandbox + chmod 2770
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Suggested labels

Integration: OpenClaw, documentation

Suggested reviewers

  • ericksoa
  • cv

Poem

I am a rabbit, light and keen,
I guard the roots you cannot see,
I chown and chmod, mend sessions tight,
I skip the symlinks, keep things right,
Hop, test, and sleep — secure and free 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: keeping the gateway readable and runtime sessions writable during shields-up, which directly addresses the two regressions fixed (OpenClaw gateway access loss and session restoration).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 fix/shields-up-lockdown-runtime-perms

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

E2E Advisor Recommendation

Required E2E: shields-config-e2e, state-backup-restore-e2e, snapshot-commands-e2e
Optional E2E: rebuild-openclaw-e2e, sandbox-operations-e2e

Dispatch hint: shields-config-e2e,state-backup-restore-e2e,snapshot-commands-e2e

Auto-dispatched E2E: shields-config-e2e, state-backup-restore-e2e, snapshot-commands-e2e via nightly-e2e.yaml at f099b8b9a3cc84464272a3a34a478064a0f661ffnightly run

Workflow run

Full advisor summary

E2E Recommendation Advisor

Base: origin/main
Head: HEAD
Confidence: high

Required E2E

  • shields-config-e2e (medium (~30 min timeout, live Docker sandbox + NVIDIA API key)): Required because shields-up/shields-down behavior and filesystem lockdown permissions changed. This E2E validates the live sandbox shields lifecycle, config mutability transitions, workspace write blocking while shields are up, shields-down restoration, audit trail, and auto-restore timer.
  • state-backup-restore-e2e (high (~60 min timeout, live Docker sandbox + NVIDIA API key)): Required because backupSandboxState audit logic changed. This E2E validates real backup, destroy/recreate, and restore behavior against a live sandbox state tree, which is the direct runtime path affected by the find/audit changes.
  • snapshot-commands-e2e (medium (~30 min timeout, live Docker sandbox + NVIDIA API key)): Required because snapshot create uses backupSandboxState and the changed audit behavior can make snapshot creation either falsely fail or miss unsafe entries. This E2E validates snapshot create/list/restore against a live sandbox.

Optional E2E

  • rebuild-openclaw-e2e (high (~60 min timeout, builds older base image + live Docker sandbox + NVIDIA API key)): Useful adjacent confidence because rebuild backs up sandbox state via backupSandboxState before recreating/upgrading an OpenClaw sandbox. The changed audit behavior could affect rebuild state preservation, but state-backup-restore-e2e and snapshot-commands-e2e cover the primary changed path more directly.
  • sandbox-operations-e2e (high (~60 min timeout, live Docker sandbox + NVIDIA API key)): Optional broad lifecycle check for sandbox registry, status/logs/exec, destroy, and recovery. It is adjacent to sandbox state and security posture changes but does not specifically target the changed shields or backup audit logic.

New E2E recommendations

  • security-shields-filesystem-lockdown (high): Existing shields-config-e2e verifies config and workspace mutability, but it does not appear to assert the new root:sandbox ownership contract for high-risk state directories, gateway/plugin read access while shields are up, agents/*/sessions writable restoration, or symlinked high-risk state-dir root refusal in a real sandbox.
    • Suggested test: Extend shields-config-e2e or add a dedicated shields-state-dir-lockdown-e2e that creates representative high-risk state dirs, runs shields up, verifies root:sandbox/go-w permissions, verifies sandbox user cannot write, verifies gateway-readable plugin/agent paths still work, verifies agents//sessions remains sandbox:sandbox 2770, and verifies shields up fails closed when a high-risk state-dir root is a symlink.
  • sandbox-state-backup-snapshot (medium): Unit tests cover the permission-denied find tolerance, but existing E2E backup/snapshot flows may not create a mixed state tree where one declared state dir has unreadable descendants while another contains an unsafe symlink/hardlink/special-file violation. That is the subtle behavior changed in backupSandboxState.
    • Suggested test: Add an E2E fixture step to state-backup-restore-e2e or snapshot-commands-e2e that prepares root-owned/unreadable state subdirectories plus a readable sibling with an unsafe symlink, then asserts backup/snapshot still rejects the violation while tolerating the unreadable sibling's find exit.

Dispatch hint

  • Workflow: .github/workflows/nightly-e2e.yaml
  • jobs input: shields-config-e2e,state-backup-restore-e2e,snapshot-commands-e2e

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

E2E Scenario Advisor Recommendation

Required scenario E2E: None
Optional scenario E2E: None

Workflow run

Full scenario advisor summary

E2E Scenario Advisor

Base: origin/main
Head: HEAD
Confidence: high

Required scenario E2E

  • None. No scenario workflow, scenario metadata, scenario runtime, or validation-suite files changed.

Optional scenario E2E

  • None.

Relevant changed files

  • None.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

PR Review Advisor

Findings: 2 needs attention, 3 worth checking, 0 nice ideas
Since last review: 0 prior items resolved, 4 still apply, 1 new item found

Review findings

🛠️ Needs attention

  • Propagate mutation-time symlink root skips (src/lib/shields/index.ts:360): The lock flow preflights symlinked high-risk roots before mutation, but the later mutation scripts also detect symlinked roots and their returned skip lists are ignored. If the preflight exec fails, or a sandbox-controlled process swaps a root to a symlink between preflight and mutation, the mutation script prints `symlinked-root` and skips the path, while `applyStateDirLockMode` still returns success. That can leave a high-risk state root symlinked and unlocked while shields-up reports the config locked.
    • Recommendation: Capture the return value from both `runStateDirLockScript` calls and, when `isLocking`, merge any skipped symlink roots into `stateDirLockIssues` so `lockAgentConfig` fails. Also consider failing closed if `preflightSymlinkedRoots` cannot run, because this is a security boundary.
    • Evidence: `runStateDirLockScript` parses and returns `symlinked-root\t<path>` lines, but the two calls in `applyStateDirLockMode` are not assigned or checked. `preflightSymlinkedRoots` catches all errors and returns `[]`, so preflight failure does not prevent the later best-effort mutation path from succeeding.
  • Extract or offset shields monolith growth (src/lib/shields/index.ts:1): This PR continues to add substantial code to the already-large shields/sandbox-security module. The changed code is in sandbox lockdown permission handling, symlink root handling, and runtime writable subpath restoration, which are high-risk areas that become harder to audit when concentrated in a monolith.
    • Recommendation: Move the runtime-subpath permission helper, state-dir lock shell construction, and/or related constants into a focused module, or offset the growth by extracting existing shields permission logic so the net monolith increase falls below the gate.
    • Evidence: Deterministic monolith context reports `src/lib/shields/index.ts` baseLines=1353, headLines=1522, delta=169, severity=blocker: "Current monolith grew by 20 or more lines; extract or offset the growth before merge." This prior advisor finding still applies and the delta increased since the prior review.

🔎 Worth checking

  • Audit tolerance masks all per-directory find failures (src/lib/state/sandbox.ts:1116): The pre-backup audit now wraps each per-directory `find` with `|| true` and discards stderr. This fixes the permission-denied false positive, but also makes permission-denied, find syntax/runtime failures, and other per-directory audit gaps indistinguishable as long as no violation rows are printed. For a security audit intended to prevent symlink, hardlink, and special-file exfiltration during backup, this can hide unexpected unaudited subtrees.
    • Recommendation: Narrow the tolerated condition where practical: record skipped roots, distinguish permission-denied from other find failures, or prune known root-owned base-image paths explicitly while preserving hard failures for unexpected audit errors. At minimum, log skipped paths so backup diagnostics show unaudited subtrees.
    • Evidence: Current code builds `{ find ... -printf ... 2>/dev/null || true; }` for every existing state dir and joins blocks with `;`, so the later `auditResult.status !== 0` branch no longer catches per-dir find failures. Tests cover empty-stdout tolerance and readable-sibling violations, but not reporting or differentiating skipped roots. This prior advisor finding still applies.
  • Runtime acceptance still needs sandbox-level validation (test/shields-up-runtime-perms.test.ts:20): The new tests are useful command-shape and shell-snippet regression tests, including checks for fresh `sessions/` creation, symlinked state-dir root refusal, and symlinked parent avoidance. They still do not validate the real container/sandbox contract for the linked issue acceptance clauses: rebuild/channel config materialization, TUI session creation, and `/nemoclaw` plugin discovery under shields-up.
    • Recommendation: Add or identify targeted runtime/integration validation that runs shields up in a sandbox and verifies plugin discovery, `/sandbox/.openclaw/agents/<id>/sessions` writability by the agent, non-writability of high-risk state dirs by sandbox-controlled code, symlink-root refusal, restoration after shields down, and the rebuild backup path. Do not rely only on docker-exec mocks and local shell fixtures for this high-risk path.
    • Evidence: `test/shields-up-runtime-perms.test.ts` uses `Module._load` mocking to capture docker exec commands and replays embedded shell snippets against local temporary directories. `test/snapshot.test.ts` uses fake `ssh`/`openshell` fixtures. Trusted testDepth context recommends runtime validation for `src/lib/shields/index.ts` and `src/lib/state/sandbox.ts`. This prior advisor finding still applies.
  • Review whether sandbox-state monolith growth can be offset (src/lib/state/sandbox.ts:1): The sandbox state module also grows in this PR, in backup/audit code that is security-sensitive and already large. The delta is smaller than the shields blocker but still adds to a monolithic state-management surface.
    • Recommendation: If practical, extract the pre-backup audit command construction/parsing into a focused helper or module, or offset this growth with nearby cleanup so future security reviews can isolate audit behavior more easily.
    • Evidence: Deterministic monolith context reports `src/lib/state/sandbox.ts` baseLines=1654, headLines=1662, delta=8, severity=warning: "Current monolith grew by 1-19 lines; review whether extraction is feasible." This prior advisor finding still applies.

🌱 Nice ideas

  • None.
Since last review details

Current findings:

  • Propagate mutation-time symlink root skips (src/lib/shields/index.ts:360): The lock flow preflights symlinked high-risk roots before mutation, but the later mutation scripts also detect symlinked roots and their returned skip lists are ignored. If the preflight exec fails, or a sandbox-controlled process swaps a root to a symlink between preflight and mutation, the mutation script prints `symlinked-root` and skips the path, while `applyStateDirLockMode` still returns success. That can leave a high-risk state root symlinked and unlocked while shields-up reports the config locked.
    • Recommendation: Capture the return value from both `runStateDirLockScript` calls and, when `isLocking`, merge any skipped symlink roots into `stateDirLockIssues` so `lockAgentConfig` fails. Also consider failing closed if `preflightSymlinkedRoots` cannot run, because this is a security boundary.
    • Evidence: `runStateDirLockScript` parses and returns `symlinked-root\t<path>` lines, but the two calls in `applyStateDirLockMode` are not assigned or checked. `preflightSymlinkedRoots` catches all errors and returns `[]`, so preflight failure does not prevent the later best-effort mutation path from succeeding.
  • Extract or offset shields monolith growth (src/lib/shields/index.ts:1): This PR continues to add substantial code to the already-large shields/sandbox-security module. The changed code is in sandbox lockdown permission handling, symlink root handling, and runtime writable subpath restoration, which are high-risk areas that become harder to audit when concentrated in a monolith.
    • Recommendation: Move the runtime-subpath permission helper, state-dir lock shell construction, and/or related constants into a focused module, or offset the growth by extracting existing shields permission logic so the net monolith increase falls below the gate.
    • Evidence: Deterministic monolith context reports `src/lib/shields/index.ts` baseLines=1353, headLines=1522, delta=169, severity=blocker: "Current monolith grew by 20 or more lines; extract or offset the growth before merge." This prior advisor finding still applies and the delta increased since the prior review.
  • Audit tolerance masks all per-directory find failures (src/lib/state/sandbox.ts:1116): The pre-backup audit now wraps each per-directory `find` with `|| true` and discards stderr. This fixes the permission-denied false positive, but also makes permission-denied, find syntax/runtime failures, and other per-directory audit gaps indistinguishable as long as no violation rows are printed. For a security audit intended to prevent symlink, hardlink, and special-file exfiltration during backup, this can hide unexpected unaudited subtrees.
    • Recommendation: Narrow the tolerated condition where practical: record skipped roots, distinguish permission-denied from other find failures, or prune known root-owned base-image paths explicitly while preserving hard failures for unexpected audit errors. At minimum, log skipped paths so backup diagnostics show unaudited subtrees.
    • Evidence: Current code builds `{ find ... -printf ... 2>/dev/null || true; }` for every existing state dir and joins blocks with `;`, so the later `auditResult.status !== 0` branch no longer catches per-dir find failures. Tests cover empty-stdout tolerance and readable-sibling violations, but not reporting or differentiating skipped roots. This prior advisor finding still applies.
  • Runtime acceptance still needs sandbox-level validation (test/shields-up-runtime-perms.test.ts:20): The new tests are useful command-shape and shell-snippet regression tests, including checks for fresh `sessions/` creation, symlinked state-dir root refusal, and symlinked parent avoidance. They still do not validate the real container/sandbox contract for the linked issue acceptance clauses: rebuild/channel config materialization, TUI session creation, and `/nemoclaw` plugin discovery under shields-up.
    • Recommendation: Add or identify targeted runtime/integration validation that runs shields up in a sandbox and verifies plugin discovery, `/sandbox/.openclaw/agents/<id>/sessions` writability by the agent, non-writability of high-risk state dirs by sandbox-controlled code, symlink-root refusal, restoration after shields down, and the rebuild backup path. Do not rely only on docker-exec mocks and local shell fixtures for this high-risk path.
    • Evidence: `test/shields-up-runtime-perms.test.ts` uses `Module._load` mocking to capture docker exec commands and replays embedded shell snippets against local temporary directories. `test/snapshot.test.ts` uses fake `ssh`/`openshell` fixtures. Trusted testDepth context recommends runtime validation for `src/lib/shields/index.ts` and `src/lib/state/sandbox.ts`. This prior advisor finding still applies.
  • Review whether sandbox-state monolith growth can be offset (src/lib/state/sandbox.ts:1): The sandbox state module also grows in this PR, in backup/audit code that is security-sensitive and already large. The delta is smaller than the shields blocker but still adds to a monolithic state-management surface.
    • Recommendation: If practical, extract the pre-backup audit command construction/parsing into a focused helper or module, or offset this growth with nearby cleanup so future security reviews can isolate audit behavior more easily.
    • Evidence: Deterministic monolith context reports `src/lib/state/sandbox.ts` baseLines=1654, headLines=1662, delta=8, severity=warning: "Current monolith grew by 1-19 lines; review whether extraction is feasible." This prior advisor finding still applies.

Workflow run details

This is an automated advisory review. A human maintainer must make the final merge decision.

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

🧹 Nitpick comments (1)
src/lib/state/sandbox.ts (1)

1108-1122: Run the state lifecycle E2E jobs for this audit-semantic change before merge.

This changes backup audit behavior under permission-denied traversal, so it’s worth validating with state-backup-restore-e2e, snapshot-commands-e2e, and rebuild-openclaw-e2e.

As per coding guidelines: "src/lib/state/sandbox.ts: This file manages sandbox state ... E2E test recommendation: state-backup-restore-e2e, snapshot-commands-e2e, rebuild-openclaw-e2e."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/state/sandbox.ts` around lines 1108 - 1122, This change alters the
backup audit behavior in src/lib/state/sandbox.ts (see the auditCmd construction
and the "Pre-backup audit" log) so run the full state lifecycle E2E suites
before merging: execute state-backup-restore-e2e, snapshot-commands-e2e, and
rebuild-openclaw-e2e against a fresh sandbox to validate permission-denied
traversal is tolerated and legitimate rebuilds still succeed; if any test fails,
adjust the auditCmd logic or error handling around the shell-quoted dir mapping
(the code building auditCmd and the surrounding pre-backup audit flow) until the
E2E suites pass.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/snapshot.test.ts`:
- Around line 1056-1171: Update the fake ssh stub used in the two tests ("treats
audit-find exit 1 with empty stdout as a successful audit (NemoClaw `#4059`)" and
"still rejects violations from readable dirs even if a sibling find exits
non-zero") so that when the command contains "find " the script exits non-zero
(e.g., process.exit(1)) unless the command string also contains the tolerant
shape "|| true"; in other words, change the cmd.includes("find ") branch in both
writeExecutable calls to check for cmd.includes("|| true") and only return
non-zero when that tolerant token is absent, preserving existing stdout behavior
for other branches.

---

Nitpick comments:
In `@src/lib/state/sandbox.ts`:
- Around line 1108-1122: This change alters the backup audit behavior in
src/lib/state/sandbox.ts (see the auditCmd construction and the "Pre-backup
audit" log) so run the full state lifecycle E2E suites before merging: execute
state-backup-restore-e2e, snapshot-commands-e2e, and rebuild-openclaw-e2e
against a fresh sandbox to validate permission-denied traversal is tolerated and
legitimate rebuilds still succeed; if any test fails, adjust the auditCmd logic
or error handling around the shell-quoted dir mapping (the code building
auditCmd and the surrounding pre-backup audit flow) until the E2E suites pass.
🪄 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: Enterprise

Run ID: b97cf8fe-d359-487b-8af3-17cb329e2c93

📥 Commits

Reviewing files that changed from the base of the PR and between 8f5db10 and 1ef364e.

📒 Files selected for processing (5)
  • src/lib/shields/index.ts
  • src/lib/state/sandbox.ts
  • test/repro-2681-group-writable.test.ts
  • test/repro-4065-shields-up-runtime-perms.test.ts
  • test/snapshot.test.ts

Comment thread test/snapshot.test.ts Outdated
…file

Address review: scrub `#4065` / `#4059` mentions from production code
comments and test docstrings, and rename the new shields-up regression
test from `repro-4065-…` to `shields-up-runtime-perms.test.ts` so the
filename describes behaviour rather than an issue number.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26355812622
Target ref: 1ef364e6574a9298b239e75e6523d7966b6f3bf9
Workflow ref: main
Requested jobs: shields-config-e2e,snapshot-commands-e2e,rebuild-openclaw-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
rebuild-openclaw-e2e ✅ success
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26356219674
Target ref: 15d095f4845aef820a1b3661855c531758f15fc1
Workflow ref: main
Requested jobs: shields-config-e2e,snapshot-commands-e2e,rebuild-openclaw-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
rebuild-openclaw-e2e ✅ success
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success

…d test

Address review:

1. `restoreWritableRuntimeSubpaths` expanded the full pattern
   `agents/*/sessions` as a single glob. On a fresh sandbox where
   `sessions` does not exist yet, the glob has no matches and the shell
   leaves the literal pattern, which the `*"*"*` guard then drops — so
   `sessions/` was never created and the post-lockdown TUI mkdir still
   failed with EACCES. Split each pattern into a parent glob (expanded
   against the existing tree) plus a leaf to create, so the helper
   always mkdir's the missing leaf inside every existing parent.
2. The two pre-backup audit tests stubbed the SSH fake as always-exit-0
   on `find`, so the `|| true` tolerance wrapper was not actually
   exercised. Make the fake exit non-zero with a Permission-denied
   stderr unless the audit cmd includes `|| true`, so the tests fail
   loudly if the wrapper is dropped.
3. New behavioural test runs the actual restore-helper script body
   against a real filesystem fixture and asserts that
   `agents/main/sessions` is created when only `agents/main` exists
   beforehand.

Signed-off-by: Tinson Lai <tinsonl@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/shields/index.ts`:
- Around line 490-499: The loop in restoreWritableRuntimeSubpaths
(WRITABLE_RUNTIME_SUBPATHS handling) currently treats symlinked directories as
regular dirs and thus creates/mutates sessions/ on the symlink target; update
the parent existence guard to skip symlinks (e.g., require directory AND not a
symlink) or explicitly check [ -L "$parent" ] and continue when true so
mkdir/chown/chmod are not applied to symlink targets; make this change around
the loop that iterates parents (the for parent in "$@"; do ... [ -d "$parent" ]
|| continue portion) to ensure symlinked runtime parents are ignored.

In `@test/shields-up-runtime-perms.test.ts`:
- Around line 129-135: The test extracts patterns with restoreShell.slice(4)
which includes configDir, causing configDir to be passed twice into spawnSync
and altering the argv layout; change the extraction to restoreShell.slice(5) so
that patterns contains only the glob patterns, leaving the explicit "configDir"
argument in the spawnSync call (referencing restoreShell, script, patterns, and
the spawnSync invocation).
🪄 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: Enterprise

Run ID: 1eb0107a-ac61-4d44-ab10-07c74795f156

📥 Commits

Reviewing files that changed from the base of the PR and between 15d095f and 31ce569.

📒 Files selected for processing (3)
  • src/lib/shields/index.ts
  • test/shields-up-runtime-perms.test.ts
  • test/snapshot.test.ts

Comment thread src/lib/shields/index.ts
Comment thread test/shields-up-runtime-perms.test.ts
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26356782985
Target ref: 31ce5693e17db64ad95ce41d75623cfc296c3682
Workflow ref: main
Requested jobs: shields-config-e2e,snapshot-commands-e2e,rebuild-openclaw-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
rebuild-openclaw-e2e ✅ success
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success

… test argv slice

Address review:

1. The privileged restore helper called `[ -d "$parent" ]` before
   `mkdir`/`chown`/`chmod`, but `[ -d ]` follows symlinks. A pre-lockdown
   agent that swapped `agents/<id>` for a symlink to an arbitrary host
   path could redirect the post-lock `mkdir -p ".../sessions"` and
   `chown -R sandbox:sandbox` through that link and rewrite ownership on
   any directory the privileged exec context can reach. Drop the parent
   (and the target leaf) when either is a symlink, before any mutation.

2. The behavioural test extracted patterns with `slice(4)`, which kept
   the captured `configDir` in the argv passed to bash — so the helper
   ran with `configDir` listed twice and the test argv diverged from
   the real `privilegedSandboxExec` call shape. Use `slice(5)` so only
   the patterns are forwarded.

3. New behavioural test asserts the symlink guard: when
   `agents/<id>` is a symlink to a sibling host directory, the helper
   must not create `sessions/` under either the link target or the link
   itself.

Also reword one comment to avoid contested terminology.

Signed-off-by: Tinson Lai <tinsonl@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/shields-up-runtime-perms.test.ts (1)

135-139: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use sh (not bash) when replaying the restore helper

Both restore-shell captures match sh -c, but both behavioral replays run spawnSync("bash", ["-c", ...]), which can mask /bin/sh compatibility regressions. Switch the replay shell to sh in both replay blocks (around lines 135-139 and 173-177).

♻️ Proposed fix
-    const result = spawnSync(
-      "bash",
-      ["-c", `${script}\n`, "sh", configDir, ...patterns],
-      { encoding: "utf-8", timeout: 5000 },
-    );
+    const result = spawnSync(
+      "sh",
+      ["-c", `${script}\n`, "sh", configDir, ...patterns],
+      { encoding: "utf-8", timeout: 5000 },
+    );

Apply the same change to the second replay block as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/shields-up-runtime-perms.test.ts` around lines 135 - 139, In
test/shields-up-runtime-perms.test.ts the replay uses spawnSync("bash", ["-c",
...]) which hides /bin/sh compatibility issues; change both replay invocations
to use spawnSync("sh", ["-c", ...]) instead (update the call sites where
spawnSync is invoked with "bash" in the two replay blocks around the current
spawnSync(...) usages so they call "sh" and keep the same args and options).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/shields-up-runtime-perms.test.ts`:
- Around line 135-139: In test/shields-up-runtime-perms.test.ts the replay uses
spawnSync("bash", ["-c", ...]) which hides /bin/sh compatibility issues; change
both replay invocations to use spawnSync("sh", ["-c", ...]) instead (update the
call sites where spawnSync is invoked with "bash" in the two replay blocks
around the current spawnSync(...) usages so they call "sh" and keep the same
args and options).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ea69a11f-9462-4b87-aa13-0ece1af0192b

📥 Commits

Reviewing files that changed from the base of the PR and between 31ce569 and 8608886.

📒 Files selected for processing (3)
  • src/lib/shields/index.ts
  • test/shields-up-runtime-perms.test.ts
  • test/snapshot.test.ts

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26357693833
Target ref: 8608886b21b09df026e49326d798f54742b81072
Workflow ref: main
Requested jobs: shields-config-e2e,snapshot-commands-e2e
Summary: 2 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success

…ed comment

Address review:

1. The behavioural test for the runtime-subpath restore was running the
   captured script body under `bash` rather than `sh`, while the
   production helper invokes it via `sh -c`. A bash-only construct
   slipping into the helper would pass the test but break the real call
   site. Switch the replay to `spawnSync("sh", …)` in both fixtures so
   the test covers exactly what the privileged exec runs.

2. Drop the NC-2227-05 tag from the updated `lockAgentConfig` comment.
   The state-directory ownership story is now self-contained in
   `HIGH_RISK_STATE_DIRS` doc + the helper itself; the issue tag does
   not add information and conflicts with the no-issue-refs-in-comments
   guidance applied across the rest of this PR.

Signed-off-by: Tinson Lai <tinsonl@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/shields/index.ts (1)

399-458: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Reject symlinked state-dir roots before recursive chmod -R/chown -R lock step

GNU coreutils defaults make chmod -R follow symlink command-line arguments, so if a configDir/<stateDir> or configDir/workspace-* entry is a symlink to a directory, the recursive chmod -R can apply outside configDir during the shields-up lock path. (chown -R defaults to non-dereference for symlink command-line args, but the recursive chmod -R is enough for this to be a security/correctness blocker.)

Suggested hardening
   for (const dirName of HIGH_RISK_STATE_DIRS) {
     const dirPath = `${configDir}/${dirName}`;
+    try {
+      privilegedSandboxExec(sandboxName, [
+        "sh",
+        "-c",
+        '[ -d "$1" ] && [ ! -L "$1" ]',
+        "sh",
+        dirPath,
+      ]);
+    } catch {
+      continue;
+    }
     try {
       privilegedSandboxExec(sandboxName, ["chown", "-R", owner, dirPath]);
     } catch {
       // Directory may not exist for this agent — silently skip
     }
@@
 for dir in "$config_dir"/workspace-*; do
+  [ -L "$dir" ] && continue
   [ -d "$dir" ] || continue
   chown -R "$owner" "$dir" 2>/dev/null || true
   chmod "$dir_mode" "$dir" 2>/dev/null || true
   [ "$clear_setgid" = "1" ] && chmod g-s "$dir" 2>/dev/null || true
   chmod -R "$recursive_mode" "$dir" 2>/dev/null || true
 done

Also applies to: 756-760

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/shields/index.ts` around lines 399 - 458, The recursive chown/chmod
operations in the HIGH_RISK_STATE_DIRS loop and the workspace-* shell block can
follow symlinked roots and affect paths outside configDir; before calling
privilegedSandboxExec for any dirPath or iterating workspace-* entries (symbols:
HIGH_RISK_STATE_DIRS, privilegedSandboxExec, configDir, dirPath, workspace-*
pattern, recursiveMode, dirMode, owner, clearSetgid, isLocking), reject/skips
any entry that is a symlink (use a non-following lstat/test -L check) so
recursive -R operations are only applied to real directories; update both the
TypeScript loop (skip when lstat indicates symlink) and the embedded shell
script (skip entries where [ -L "$dir" ] ) to avoid following symlink roots.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/lib/shields/index.ts`:
- Around line 399-458: The recursive chown/chmod operations in the
HIGH_RISK_STATE_DIRS loop and the workspace-* shell block can follow symlinked
roots and affect paths outside configDir; before calling privilegedSandboxExec
for any dirPath or iterating workspace-* entries (symbols: HIGH_RISK_STATE_DIRS,
privilegedSandboxExec, configDir, dirPath, workspace-* pattern, recursiveMode,
dirMode, owner, clearSetgid, isLocking), reject/skips any entry that is a
symlink (use a non-following lstat/test -L check) so recursive -R operations are
only applied to real directories; update both the TypeScript loop (skip when
lstat indicates symlink) and the embedded shell script (skip entries where [ -L
"$dir" ] ) to avoid following symlink roots.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: cf8d1c18-3301-4936-baba-55a23d2cf2e7

📥 Commits

Reviewing files that changed from the base of the PR and between 8608886 and 1262ddf.

📒 Files selected for processing (2)
  • src/lib/shields/index.ts
  • test/shields-up-runtime-perms.test.ts

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26358552843
Target ref: 1262ddfb28d5b697630c40beb935a162170ca364
Workflow ref: main
Requested jobs: shields-config-e2e,state-backup-restore-e2e,snapshot-commands-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success
state-backup-restore-e2e ✅ success

…p lock

Address review:

1. The shields-up lock loop ran `chown -R`/`chmod -R`/`chmod g-s` on
   each `${configDir}/${dirName}` (and on every `workspace-*` glob hit)
   without rejecting symlinked roots. A pre-lockdown agent that swapped
   e.g. `extensions/` or `workspace-main/` for a symlink to a host path
   could redirect those recursive ownership and mode mutations at an
   attacker-controlled directory. Consolidate the per-state-dir loop
   into a single privileged shell exec that skips symlinks (`[ -L "$path" ]
   && continue`) before any mutation, and add the same guard to the
   existing `workspace-*` shell loop.

2. Drop the `NC-2227-05` issue tag from the state-directory header
   comment for consistency with the rest of this PR.

Updates the regression tests:
- `repro-2681`: assert the unlock fan-out via the new `sh -c` script
  shape (workspace included as an arg, plus the workspace-* glob path
  still present).
- `shields-up-runtime-perms`: assert the state-dir lock and workspace-*
  scripts both contain the `[ -L … ] && continue` guard, and add a
  behavioural fixture that proves a symlinked `extensions/` root is
  skipped (its host target keeps its original mode and file contents).

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26359318121
Target ref: 4bcb560e9a85f575385a155c4809e7c0d17480a1
Workflow ref: main
Requested jobs: shields-config-e2e,snapshot-commands-e2e,state-backup-restore-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success
state-backup-restore-e2e ✅ success

Address review:

Previously, when shields-up encountered a symlinked high-risk state-dir
root (or a symlinked `workspace-*` dir), the privileged lock script
silently skipped it via `[ -L "$path" ] && continue`. That refused to
follow the link — good — but left the dir as-is and reported the lock
as successful. The sandbox would then sit in "shields up (lockdown
active)" status while a state-dir root still pointed at a writable
host path, exactly the security regression the symlink guard was meant
to prevent.

Have the two consolidated lock shell scripts always exit 0 but emit
`symlinked-root\t<path>` on stdout for every symlinked root they
refuse to touch. `applyStateDirLockMode` parses those lines and
returns them as lock failures when invoked under `isLocking=true`.
`lockAgentConfig` now throws "Config not locked: state dir root is a
symlink: …" before any further verification, refusing to acknowledge
shields-up. Unlock is unchanged: skipping symlinked roots is the
correct best-effort behaviour there.

New regression test exercises the end-to-end path: when the captured
shell script reports a symlinked root via the mocked exec, the
`lockAgentConfig` call throws with the expected diagnostic.

Tests for the static script shape are updated to match the new
`if [ -L … ]; then printf …; fi` form instead of the previous
`[ -L … ] && continue`.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26360739914
Target ref: b95913ad0c78d1efd972054c21318a6901c01446
Workflow ref: main
Requested jobs: shields-config-e2e,state-backup-restore-e2e,snapshot-commands-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success
state-backup-restore-e2e ✅ success

… + doc update

Address review:

1. The inline `[ -L … ] && printf 'symlinked-root\t…'` guard in the
   mutation script ran per-iteration. A symlinked root later in the
   list could still leave earlier (non-symlinked) state dirs already
   reowned to `root:sandbox` by the time the lock helper bailed out.
   shields-up would then report "config not locked" while the tree was
   partially mutated.

   Add a dedicated preflight pass that runs before any `chown`/`chmod`,
   scans every high-risk state-dir root *and* every `workspace-*` dir
   for symlinks, and returns the full list. When `isLocking=true` and
   the preflight finds any symlinked root, `applyStateDirLockMode`
   short-circuits without touching the mutation pass or the
   sessions-restore helper, and `lockAgentConfig` throws `Config not
   locked: state dir root is a symlink: …`. The inline symlink guards
   in the mutation scripts stay for defence-in-depth in case the
   preflight and the mutation observe different fs state.

2. New regression test mocks the preflight script to report a
   symlinked root and asserts (a) `lockAgentConfig` throws with the
   expected diagnostic and (b) no mutation calls (state-dir lock,
   workspace-* lock, or sessions-restore) were ever issued.

3. Add a second regression test that asserts a dedicated preflight
   script (no `chown`/`chmod`, just the `[ -L … ] && printf` checks)
   is present in the recorded call sequence.

4. Update `docs/security/best-practices.mdx` to document the new
   `root:sandbox` state-dir ownership, the `agents/<id>/sessions`
   runtime carve-out, and the hard fail on symlinked state-dir roots.

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@github-actions
Copy link
Copy Markdown
Contributor

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26361443080
Target ref: f099b8b9a3cc84464272a3a34a478064a0f661ff
Workflow ref: main
Requested jobs: shields-config-e2e,state-backup-restore-e2e,snapshot-commands-e2e
Summary: 3 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success
snapshot-commands-e2e ✅ success
state-backup-restore-e2e ✅ success

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Sandbox Use this label to identify issues related to the NemoClaw isolated environment based on OpenShell.

Projects

None yet

1 participant