Skip to content

feat(shields): seal locked files with SHA-256 and detect content drift#4460

Merged
cv merged 9 commits into
mainfrom
fix/4243-shields-content-seal
May 30, 2026
Merged

feat(shields): seal locked files with SHA-256 and detect content drift#4460
cv merged 9 commits into
mainfrom
fix/4243-shields-content-seal

Conversation

@laitingsheng
Copy link
Copy Markdown
Contributor

@laitingsheng laitingsheng commented May 28, 2026

Summary

shields up now captures a SHA-256 seal of each locked file into the host-side shields state. shields status recomputes the hash inside the sandbox and flags content drift, so a host-root tamper that flips perms back to 444 root:root after rewriting the file is detected. shields up refuses to launder hash drift, missing seals, or other hash-trust failures; legacy lockdowns require an explicit operator opt-in to seal the current bytes.

Related Issue

Fixes #4243.

Changes

  • src/lib/shields/seal.ts (new) + seal.test.ts: shared SHA-256 helpers (SHA256_HEX_RE, isSha256Hex, parseSha256Output) and the isHashVerificationIssue classifier used by shieldsUp and shieldsStatus.
  • src/lib/shields/verify-lock.ts + tests: new expectedHashes option; every hash-trust failure is prefixed with content drifted (missing seal, sha256sum failure, unparsable output, hash mismatch) so callers can refuse to launder.
  • src/lib/shields/index.ts + tests: ShieldsState.fileHashes schema with isOptionalHashMap validating SHA-256 hex; lockAgentConfig returns { chattrApplied, fileHashes }; shieldsUp, activateLockdownFromSnapshot, rollbackShieldsDown persist the seal; shieldsStatus threads state.fileHashes into the verifier, splits the recovery hint by drift type, and surfaces the legacy-state notice when no seal is recorded; legacy and partial-seal lockdowns refuse to seal without NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1.
  • src/lib/shields/timer.ts + tests: auto-restore timer passes the resolved agent target (incl. sensitive files) to lockAgentConfig and persists chattrApplied/fileHashes in the state file.
  • test/repro-2681-group-writable.test.ts: lock-probe stub answers sha256sum so the post-lock seal capture completes.
  • test/e2e/test-shields-config.sh: new Phase 5b drives a docker exec -u 0 chmod-write-chmod tamper, verifies shields status exits 2 with the DRIFTED line, verifies shields up refuses to re-seal, and uses a byte-preserving temp-file backup so the content restore matches the original SHA-256 exactly.
  • docs/security/best-practices.mdx, docs/reference/commands.mdx: document the content seal, the legacy-baseline refusal, and the NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE opt-in.

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
  • npm run 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

Release Notes

  • New Features

    • Content-seal functionality now captures file hashes of locked configuration and detects tampering. shields status reports drift with exit code 2.
    • Environment variable to override default refusal when sealing legacy sandboxes without prior seals.
  • Documentation

    • Added guidance on content-seal drift detection and legacy baseline handling.
  • Tests

    • Expanded test coverage for hash verification and drift detection workflows.

Review Change Stack

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

coderabbitai Bot commented May 28, 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: 76c6940d-90ee-4240-bb93-b71b6882212b

📥 Commits

Reviewing files that changed from the base of the PR and between 44bd0eb and 3b2fb47.

📒 Files selected for processing (8)
  • docs/reference/commands.mdx
  • docs/security/best-practices.mdx
  • src/lib/shields/index.test.ts
  • src/lib/shields/index.ts
  • src/lib/shields/seal.ts
  • src/lib/shields/timer.test.ts
  • src/lib/shields/timer.ts
  • test/e2e/test-shields-config.sh
✅ Files skipped from review due to trivial changes (1)
  • docs/reference/commands.mdx
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/lib/shields/seal.ts
  • test/e2e/test-shields-config.sh
  • docs/security/best-practices.mdx
  • src/lib/shields/index.test.ts
  • src/lib/shields/timer.ts
  • src/lib/shields/index.ts

📝 Walkthrough

Walkthrough

Adds per-file SHA-256 content seals captured at lock time, persists them in shields state, verifies them in shields status and shields up to detect host-induced content drift, refuses to re-seal tampered baselines by default (opt-in for legacy baselines), and adds tests and docs for these flows.

Changes

Content sealing and drift detection

Layer / File(s) Summary
Seal helpers and tests
src/lib/shields/seal.ts, src/lib/shields/seal.test.ts
Add SHA256_HEX_RE, isSha256Hex, parseSha256Output, HASH_ISSUE_PATTERNS, isHashVerificationIssue, and tests for parsing and hash-issue classification.
Verifier SHA-256 support and tests
src/lib/shields/verify-lock.ts, src/lib/shields/verify-lock.test.ts
Add VerifyShieldsLockOptions.expectedHashes and conditional per-file sha256sum verification that emits explicit drift issues; Vitest coverage for mismatch/match/missing/unparseable/exec-error and legacy-skip.
Shields state, capture, and integration
src/lib/shields/index.ts
Extend persisted ShieldsState with optional fileHashes and isOptionalHashMap validation; add captureSealHashes; have lockAgentConfig return { chattrApplied, fileHashes }; thread fileHashes through rollback, snapshot activation, inline auto-restore, shieldsUp, and persist in final state; refuse re-seal on hash/trust failures and support legacy opt-in.
Shields status tests and helpers
src/lib/shields/index.test.ts
Add/adjust tests and helpers: corrupt fileHashes fast-fail, write sealed locked-state helper, assert shieldsStatus behavior when fileHashes present/missing, and surface drift errors with correct exit codes.
Auto-restore timer persistence
src/lib/shields/timer.ts, src/lib/shields/timer.test.ts
Resolve agent config for restore, capture chattrApplied and fileHashes from lockAgentConfig, and conditionally include them in shields state patches; add test verifying persisted fields and restore marker removal.
Regression probe update
test/repro-2681-group-writable.test.ts
Extend spawned Node probe mock to intercept sha256sum and return deterministic hash lines to exercise lock verification in regression test.
E2E drift detection scenario
test/e2e/test-shields-config.sh
Add Phase 5b: host-root tamper of openclaw.json (preserving immutability posture), assert nemoclaw shields status detects content drift (exit code 2 and DRIFTED/content drifted), shields up refuses to re-seal, restore original file, and verify status returns clean.
Docs and CLI flag
docs/security/best-practices.mdx, docs/reference/commands.mdx
Document content-seal under shields up, drift recomputation via shields status, refusal-to-re-seal behavior, legacy UNSEALED status guidance, and NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE opt-in flag.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as "nemoclaw (shields status/up)"
  participant Verifier as "verifyShieldsLockState"
  participant Sandbox as "sandbox (sha256sum)"
  CLI->>Verifier: call with expectedHashes (state.fileHashes)
  Verifier->>Sandbox: run sha256sum <file>
  Sandbox-->>Verifier: sha256sum output / error
  Verifier-->>CLI: issues or ok (drift / clean)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • NVIDIA/NemoClaw#4294: Extends verifier-based filesystem cross-checks used by shields status; this PR adds SHA-256 content-seal checks on top of that flow.
  • NVIDIA/NemoClaw#4130: Modifies shields lock/unlock shapes and rollback/restore flows; overlaps on threading lock results and activation paths.
  • NVIDIA/NemoClaw#3866: Related shields status wording and output changes that may interact with new status messaging.

Suggested labels

Sandbox, security, E2E, documentation, v0.0.53

Suggested reviewers

  • ericksoa
  • cv

"I hid a hash beneath the shell,
a tiny seal to ring the bell.
If tamper comes to break the spell,
the drift will sing — the guarded well.
Hop safe, dear friend, all's well 🐇"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% 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 pull request title accurately summarizes the main change: adding SHA-256 content sealing for locked files and implementing content drift detection during shields status.
Linked Issues check ✅ Passed The PR comprehensively addresses all objectives from issue #4243: cross-checking sandbox file contents via SHA-256, classifying hash mismatches as content drift, and preventing re-sealing of tampered baselines without explicit opt-in.
Out of Scope Changes check ✅ Passed All code changes align with the issue objectives: SHA-256 sealing/verification infrastructure, drift detection, legacy baseline handling, and comprehensive test coverage. No unrelated changes detected.

✏️ 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/4243-shields-content-seal

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

E2E Advisor Recommendation

Required E2E: shields-config-e2e
Optional E2E: openclaw-onboard-security-posture-e2e

Dispatch hint: shields-config-e2e

Auto-dispatched E2E: shields-config-e2e via nightly-e2e.yaml at 3b2fb476bbe42a36566a84a21812498c1b77639dnightly run

Workflow run

Full advisor summary

E2E Recommendation Advisor

Base: origin/main
Head: HEAD
Confidence: high

Required E2E

  • shields-config-e2e (high; live sandbox install/onboard, requires NVIDIA_API_KEY, timeout 30 minutes): Direct coverage for the changed shields security boundary: full install/onboard, mutable default, shields up/down/status, config immutability, content-seal drift detection, refusal to re-seal tampered content, audit trail, and auto-restore timer against a live sandbox.

Optional E2E

  • openclaw-onboard-security-posture-e2e (high; full OpenClaw onboard with security posture assertions, requires NVIDIA_API_KEY, timeout 60 minutes): Adjacent confidence for the broader OpenClaw security posture after onboarding, including host/sandbox hardening assertions. Useful because shields protects OpenClaw config state, but it is broader than the changed code and not the primary merge-blocking check.

New E2E recommendations

  • shields legacy seal upgrade path (medium): Existing shields-config-e2e now covers fresh seal capture and content drift, but the new NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE behavior for legacy locked state with no fileHashes, and partial seals where the locked file set grew, appears to be covered mainly by unit tests. A focused live-sandbox E2E would catch state-file upgrade and operator opt-in regressions across real CLI, filesystem, and state persistence.
    • Suggested test: Add a shields legacy-baseline upgrade E2E that creates a locked shields state without fileHashes, verifies shields status exits 2 as UNSEALED, verifies shields up refuses without NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1, then accepts and persists a complete fileHashes seal.

Dispatch hint

  • Workflow: nightly-e2e.yaml
  • jobs input: shields-config-e2e

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 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 28, 2026

PR Review Advisor

Findings: 3 needs attention, 5 worth checking, 0 nice ideas
Since last review: 0 prior items resolved, 7 still apply, 0 new items found

Review findings

🛠️ Needs attention

  • Detached timer can relock and seal the wrong agent config target (src/lib/shields/timer.ts:166): The auto-restore timer still trusts resolveAgentConfig(args.sandboxName), but resolveAgentConfig catches registry and agent-definition failures internally and returns DEFAULT_AGENT_CONFIG. For Hermes or another non-default agent, a degraded resolver can make the timer restore the restrictive policy, relock /sandbox/.openclaw, persist hashes for that wrong target, and mark shields UP while the intended agent config path passed in argv remains mutable or unsealed.
    • Recommendation: Make the timer resolver contract explicit: use an API that reports degraded/default fallback, or compare the resolved target with the argv configPath/configDir and prefer argv when resolution disagrees or degrades. Preserve the full sensitive-file set for non-default agents, including Hermes .env, and add a non-default-agent timer unit test where resolver degradation would otherwise return DEFAULT_AGENT_CONFIG.
    • Evidence: timer.ts now comments that it will always prefer the resolved target and sets lockTarget = resolveAgentConfig(args.sandboxName). src/lib/sandbox/config.ts catches resolver failures and returns DEFAULT_AGENT_CONFIG with /sandbox/.openclaw paths, so the timer catch fallback is not reached for that degradation mode.
  • High-risk shields lifecycle monolith grew further (src/lib/shields/index.ts:1): This PR adds content-seal capture, hash-trust classification, legacy-baseline opt-in handling, rollback/inline recovery seal persistence, and status recovery branching to an already-large host-side shields lifecycle module. This file performs privileged sandbox mutations and policy restores, so continued growth makes fail-closed behavior and caller/callee contracts harder to audit.
    • Recommendation: Extract seal capture/persistence and hash-trust recovery classification into focused modules, or offset this PR with a comparable shields lifecycle refactor before merging.
    • Evidence: Deterministic monolith context reports src/lib/shields/index.ts grew from 1356 to 1573 lines (+217) and flags it as a current large-file hotspot.
  • Legacy and partial-seal shieldsUp command paths remain under-covered (src/lib/shields/index.test.ts:1): The PR adds useful verifier/status/timer tests, but the direct shieldsUp() command path that decides whether current bytes become a trusted baseline still lacks focused coverage for legacy and partial-seal cases.
    • Recommendation: Move seal lifecycle command-path coverage into focused suites or helper-driven test files, and add direct shieldsUp() tests for state.shieldsDown=false without fileHashes: default refusal, NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 acceptance with persisted hashes, sha256sum/capture failure, and partial-seal missing-entry refusal/opt-in.
    • Evidence: Added tests cover parseSha256Output, verifier hash drift, status pass-through, UNSEALED status, and timer persistence. The visible index.test additions do not directly invoke shieldsUp() for legacy no-seal refusal/acceptance, capture failure, or partial-seal opt-in/refusal. The deterministic monolith context also reports src/lib/shields/index.test.ts grew from 1017 to 1176 lines (+159).

🔎 Worth checking

  • Source-of-truth review needed: Legacy locked shields state without fileHashes: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: docs/security/best-practices.mdx documents legacy no-seal state and the env-var opt-in; shieldsUp() implements refusal/acceptance around missing state.fileHashes.
  • Source-of-truth review needed: Partial seal when the locked-file set grows: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: verify-lock.ts emits missing-seal drift entries; shieldsUp() comments and logic require NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 for missing seals.
  • Source-of-truth review needed: Timer registry-unavailable config-target fallback: The advisor marked localized patch analysis as missing.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: timer.ts calls resolveAgentConfig(args.sandboxName) and only falls back in catch; config.ts catches failures and returns DEFAULT_AGENT_CONFIG.
  • Legacy no-seal compatibility path still lacks removal criteria (docs/security/best-practices.mdx:210): The docs and code now identify the legacy invalid state and the trust tradeoff: sandboxes locked before the content seal have no recorded hash, so current bytes cannot be retroactively proven. However, the compatibility path does not state whether the opt-in escape hatch is indefinite or when it can be removed.
    • Recommendation: Document the removal condition, migration window, or explicitly state that legacy no-seal state support is indefinite. Pair that policy with direct shieldsUp() command-path tests so the operator acknowledgement contract cannot regress.
    • Evidence: docs/security/best-practices.mdx documents that legacy locked sandboxes have no recorded hash and require NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 to accept current bytes. The added documentation does not state a removal condition or permanence policy.
  • Timer fallback can still produce a partial non-default seal (src/lib/shields/timer.ts:187): Even when resolveAgentConfig actually throws and the catch fallback runs, the fallback infers only `${configDir}/.config-hash` as a sensitive file. For Hermes, the normal resolver also locks `${configDir}/.env`; omitting it can persist a partial fileHashes map and leave sensitive config material outside the auto-restore seal.
    • Recommendation: Carry the full sensitiveFiles list into the timer marker when shields down starts, or make the fallback derive the same per-agent sensitive-file set as the normal resolver. Add a Hermes/non-default test proving lockAgentConfig receives configPath, configDir, .config-hash, and .env in timer fallback mode.
    • Evidence: timer.ts catch fallback constructs sensitiveFiles: [`${args.configDir}/.config-hash`]. src/lib/sandbox/config.ts adds `${dir}/.env` for entry.agent === "hermes".

🌱 Nice ideas

  • None.
Since last review details

Current findings:

  • Source-of-truth review needed: Legacy locked shields state without fileHashes: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: docs/security/best-practices.mdx documents legacy no-seal state and the env-var opt-in; shieldsUp() implements refusal/acceptance around missing state.fileHashes.
  • Source-of-truth review needed: Partial seal when the locked-file set grows: The advisor marked localized patch analysis as needs_followup.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: verify-lock.ts emits missing-seal drift entries; shieldsUp() comments and logic require NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 for missing seals.
  • Source-of-truth review needed: Timer registry-unavailable config-target fallback: The advisor marked localized patch analysis as missing.
    • Recommendation: Identify the invalid state, source boundary, source-fix constraint, regression test, and removal condition before merging the localized behavior.
    • Evidence: timer.ts calls resolveAgentConfig(args.sandboxName) and only falls back in catch; config.ts catches failures and returns DEFAULT_AGENT_CONFIG.
  • Detached timer can relock and seal the wrong agent config target (src/lib/shields/timer.ts:166): The auto-restore timer still trusts resolveAgentConfig(args.sandboxName), but resolveAgentConfig catches registry and agent-definition failures internally and returns DEFAULT_AGENT_CONFIG. For Hermes or another non-default agent, a degraded resolver can make the timer restore the restrictive policy, relock /sandbox/.openclaw, persist hashes for that wrong target, and mark shields UP while the intended agent config path passed in argv remains mutable or unsealed.
    • Recommendation: Make the timer resolver contract explicit: use an API that reports degraded/default fallback, or compare the resolved target with the argv configPath/configDir and prefer argv when resolution disagrees or degrades. Preserve the full sensitive-file set for non-default agents, including Hermes .env, and add a non-default-agent timer unit test where resolver degradation would otherwise return DEFAULT_AGENT_CONFIG.
    • Evidence: timer.ts now comments that it will always prefer the resolved target and sets lockTarget = resolveAgentConfig(args.sandboxName). src/lib/sandbox/config.ts catches resolver failures and returns DEFAULT_AGENT_CONFIG with /sandbox/.openclaw paths, so the timer catch fallback is not reached for that degradation mode.
  • High-risk shields lifecycle monolith grew further (src/lib/shields/index.ts:1): This PR adds content-seal capture, hash-trust classification, legacy-baseline opt-in handling, rollback/inline recovery seal persistence, and status recovery branching to an already-large host-side shields lifecycle module. This file performs privileged sandbox mutations and policy restores, so continued growth makes fail-closed behavior and caller/callee contracts harder to audit.
    • Recommendation: Extract seal capture/persistence and hash-trust recovery classification into focused modules, or offset this PR with a comparable shields lifecycle refactor before merging.
    • Evidence: Deterministic monolith context reports src/lib/shields/index.ts grew from 1356 to 1573 lines (+217) and flags it as a current large-file hotspot.
  • Legacy and partial-seal shieldsUp command paths remain under-covered (src/lib/shields/index.test.ts:1): The PR adds useful verifier/status/timer tests, but the direct shieldsUp() command path that decides whether current bytes become a trusted baseline still lacks focused coverage for legacy and partial-seal cases.
    • Recommendation: Move seal lifecycle command-path coverage into focused suites or helper-driven test files, and add direct shieldsUp() tests for state.shieldsDown=false without fileHashes: default refusal, NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 acceptance with persisted hashes, sha256sum/capture failure, and partial-seal missing-entry refusal/opt-in.
    • Evidence: Added tests cover parseSha256Output, verifier hash drift, status pass-through, UNSEALED status, and timer persistence. The visible index.test additions do not directly invoke shieldsUp() for legacy no-seal refusal/acceptance, capture failure, or partial-seal opt-in/refusal. The deterministic monolith context also reports src/lib/shields/index.test.ts grew from 1017 to 1176 lines (+159).
  • Legacy no-seal compatibility path still lacks removal criteria (docs/security/best-practices.mdx:210): The docs and code now identify the legacy invalid state and the trust tradeoff: sandboxes locked before the content seal have no recorded hash, so current bytes cannot be retroactively proven. However, the compatibility path does not state whether the opt-in escape hatch is indefinite or when it can be removed.
    • Recommendation: Document the removal condition, migration window, or explicitly state that legacy no-seal state support is indefinite. Pair that policy with direct shieldsUp() command-path tests so the operator acknowledgement contract cannot regress.
    • Evidence: docs/security/best-practices.mdx documents that legacy locked sandboxes have no recorded hash and require NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 to accept current bytes. The added documentation does not state a removal condition or permanence policy.
  • Timer fallback can still produce a partial non-default seal (src/lib/shields/timer.ts:187): Even when resolveAgentConfig actually throws and the catch fallback runs, the fallback infers only `${configDir}/.config-hash` as a sensitive file. For Hermes, the normal resolver also locks `${configDir}/.env`; omitting it can persist a partial fileHashes map and leave sensitive config material outside the auto-restore seal.
    • Recommendation: Carry the full sensitiveFiles list into the timer marker when shields down starts, or make the fallback derive the same per-agent sensitive-file set as the normal resolver. Add a Hermes/non-default test proving lockAgentConfig receives configPath, configDir, .config-hash, and .env in timer fallback mode.
    • Evidence: timer.ts catch fallback constructs sensitiveFiles: [`${args.configDir}/.config-hash`]. src/lib/sandbox/config.ts adds `${dir}/.env` for entry.agent === "hermes".

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: 4

🧹 Nitpick comments (1)
docs/security/best-practices.mdx (1)

207-207: ⚡ Quick win

Split this into one sentence per source line.

This line currently contains multiple sentences; the docs style requires one sentence per line for readable diffs.

As per coding guidelines: "One sentence per line in source (makes diffs readable). Flag paragraphs where multiple sentences appear on the same line."

🤖 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 `@docs/security/best-practices.mdx` at line 207, The sentence block describing
the shields workflow packs multiple sentences on one source line; split it so
each sentence becomes its own source line (one sentence per line) for
readability and diff hygiene. Specifically break the paragraph so lines each
contain a single sentence referencing the commands and artifacts: "nemoclaw
<name> shields up", the SHA-256 seal of "openclaw.json" and other locked files,
the host-side shields state file, how "shields status" recomputes the hash and
surfaces drift, and that "shields up" refuses to re-seal a tampered
baseline—ensuring each of those sentences is on its own line.
🤖 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 `@docs/security/best-practices.mdx`:
- Line 207: Update the wording to limit the claim about hash re-verification to
sealed sandboxes: change the sentence that currently reads "Every `shields
status` call recomputes the hash..." to something like "For sealed sandboxes
(when the shields state contains `expectedHashes`/`fileHashes`), `shields
status` recomputes the hashes inside the sandbox and surfaces drift on any
mismatch; legacy state without `expectedHashes`/`fileHashes` falls back to
permission-only verification." Keep the rest about `shields up` refusing to
re-seal a tampered baseline and referencing `openclaw.json` and the host-side
shields state file intact.

In `@src/lib/shields/index.ts`:
- Around line 292-301: The isOptionalHashMap type guard currently accepts any
string for fileHashes; change it to validate each value is a SHA-256 hex string
(64 hex characters) instead of any string: inside isOptionalHashMap (and any
similar guard used for fileHashes/shieldsUp), if value === undefined return
true, ensure value is an object record, then check every Object.values(value)
matches a SHA-256 hex regex (e.g. /^[a-fA-F0-9]{64}$/) and return false if any
do not; update the type guard so downstream code (e.g., shieldsUp) only receives
validated hash strings.
- Around line 1165-1178: The current filter only detects entries that include
"content drifted"; broaden the refusal so hash verification failures also block
re-sealing by changing the predicate on issues (used to build contentDrift) to
match entries that include "content drifted" OR contain verification failure
indicators such as "sha256sum failed" or "sha256sum output unparsable" (or
simply any entry that includes "sha256sum" / "output unparsable"); keep the same
logging and failure flow (console.error block and the call to
failShieldsCommand) but ensure the error message and contentDrift join still
reflect the broader set of matched entries (referencing the issues array,
contentDrift variable, failShieldsCommand, and opts.throwOnError).

In `@src/lib/shields/verify-lock.ts`:
- Around line 111-137: The verification branches in verify-lock where issues are
pushed (inside the options.expectedHashes loop over filesToVerify) must prefix
all hash-verification failures with "content drifted" so shieldsUp can detect
drift; update the messages produced in the branches that push `"${f} no seal
recorded (expected SHA-256)"`, `"${f} sha256sum failed: ${msg}"`, and `"${f}
sha256sum output unparsable: ${raw.trim()}"` (the code around exec(),
parseSha256Output(), and the final content-drift comparison) to start with
"content drifted" (e.g. "content drifted: <existing message>") while leaving the
existing details intact.

---

Nitpick comments:
In `@docs/security/best-practices.mdx`:
- Line 207: The sentence block describing the shields workflow packs multiple
sentences on one source line; split it so each sentence becomes its own source
line (one sentence per line) for readability and diff hygiene. Specifically
break the paragraph so lines each contain a single sentence referencing the
commands and artifacts: "nemoclaw <name> shields up", the SHA-256 seal of
"openclaw.json" and other locked files, the host-side shields state file, how
"shields status" recomputes the hash and surfaces drift, and that "shields up"
refuses to re-seal a tampered baseline—ensuring each of those sentences is on
its own line.
🪄 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: a0ae2ca5-8264-4c0b-9d09-a60d51b4a319

📥 Commits

Reviewing files that changed from the base of the PR and between 2c826d9 and fa29a39.

📒 Files selected for processing (6)
  • docs/security/best-practices.mdx
  • src/lib/shields/index.test.ts
  • src/lib/shields/index.ts
  • src/lib/shields/verify-lock.test.ts
  • src/lib/shields/verify-lock.ts
  • test/repro-2681-group-writable.test.ts

Comment thread docs/security/best-practices.mdx Outdated
Comment thread src/lib/shields/index.ts
Comment thread src/lib/shields/index.ts Outdated
Comment thread src/lib/shields/verify-lock.ts
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26597763562
Target ref: fa29a3959f61a1e1c1cf8bdd96915cdae33430ac
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 1 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ❌ Some jobs failed

Run: 26613650342
Target ref: ff201d587721fd9f391782bbd53cf5085d9c502b
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 0 passed, 1 failed, 0 skipped

Job Result
shields-config-e2e ❌ failure

Failed jobs: shields-config-e2e. Check run artifacts for logs.

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

♻️ Duplicate comments (1)
src/lib/shields/verify-lock.ts (1)

105-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prefix all hash-verification failure paths with content drifted.

shields up drift gating relies on drift-classified issues; these branches still emit non-drift strings (no seal recorded, sha256sum failed, sha256sum output unparsable), which can allow reseal on failed verification paths.

Suggested minimal fix
       if (!want) {
         // Seal was missing for this file — flag explicitly rather than
         // silently passing. Callers that genuinely lack a seal pass
         // `expectedHashes: undefined` instead of an empty record.
-        issues.push(`${f} no seal recorded (expected SHA-256)`);
+        issues.push(`${f} content drifted (no seal recorded; expected SHA-256)`);
         continue;
       }
       let raw: string;
       try {
         raw = exec(["sha256sum", f]);
       } catch (err) {
         const msg = err instanceof Error ? err.message : String(err);
-        issues.push(`${f} sha256sum failed: ${msg}`);
+        issues.push(`${f} content drifted (sha256sum failed: ${msg})`);
         continue;
       }
       const got = parseSha256Output(raw);
       if (!got) {
-        issues.push(`${f} sha256sum output unparsable: ${raw.trim()}`);
+        issues.push(`${f} content drifted (sha256sum output unparsable: ${raw.trim()})`);
         continue;
       }
🤖 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/verify-lock.ts` around lines 105 - 123, The error messages in
verify-lock.ts that are pushed into the issues array for hash verification
failures (when want is falsy, when exec(["sha256sum", f]) throws, and when
parseSha256Output(raw) returns falsy) must be prefixed so they are classified as
drift; update the three pushes that produce `${f} no seal recorded (expected
SHA-256)`, `${f} sha256sum failed: ${msg}`, and `${f} sha256sum output
unparsable: ${raw.trim()}` to start with "content drifted" (e.g., "content
drifted: ...") while keeping the original details, referencing the surrounding
variables and helpers (want, f, exec, parseSha256Output, got, issues) so callers
relying on drift-classified issues see these as drift.
🤖 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/e2e/test-shields-config.sh`:
- Around line 346-350: Replace the set +e / set -e toggling with a non-mutating
command group that captures both output and exit code without changing global
errexit; e.g. for the shields status probe, use a grouped construct like {
STATUS_TAMPER_OUTPUT=$(nemoclaw "${SANDBOX_NAME}" shields status 2>&1);
STATUS_TAMPER_EXIT=$?; } || true and then echo "$STATUS_TAMPER_OUTPUT", and
apply the same pattern to the other probe (the block that sets
STATUS_TAMPER_OUTPUT/STATUS_TAMPER_EXIT around the shields status call) so you
no longer flip errexit for the rest of the test.

---

Duplicate comments:
In `@src/lib/shields/verify-lock.ts`:
- Around line 105-123: The error messages in verify-lock.ts that are pushed into
the issues array for hash verification failures (when want is falsy, when
exec(["sha256sum", f]) throws, and when parseSha256Output(raw) returns falsy)
must be prefixed so they are classified as drift; update the three pushes that
produce `${f} no seal recorded (expected SHA-256)`, `${f} sha256sum failed:
${msg}`, and `${f} sha256sum output unparsable: ${raw.trim()}` to start with
"content drifted" (e.g., "content drifted: ...") while keeping the original
details, referencing the surrounding variables and helpers (want, f, exec,
parseSha256Output, got, issues) so callers relying on drift-classified issues
see these as drift.
🪄 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: ad68e9ee-2347-42b4-97d7-9c8eb366ca8a

📥 Commits

Reviewing files that changed from the base of the PR and between fa29a39 and ff201d5.

📒 Files selected for processing (9)
  • docs/security/best-practices.mdx
  • src/lib/shields/index.test.ts
  • src/lib/shields/index.ts
  • src/lib/shields/seal.test.ts
  • src/lib/shields/seal.ts
  • src/lib/shields/timer.ts
  • src/lib/shields/verify-lock.test.ts
  • src/lib/shields/verify-lock.ts
  • test/e2e/test-shields-config.sh
✅ Files skipped from review due to trivial changes (2)
  • src/lib/shields/seal.ts
  • src/lib/shields/seal.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/security/best-practices.mdx
  • src/lib/shields/verify-lock.test.ts
  • src/lib/shields/index.test.ts
  • src/lib/shields/index.ts

Comment thread test/e2e/test-shields-config.sh Outdated
Comment thread test/e2e/test-shields-config.sh Outdated
Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ❌ Some jobs failed

Run: 26614673882
Target ref: 8b241be8f416396ebe4ba00f5914a04b0be279a9
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 0 passed, 1 failed, 0 skipped

Job Result
shields-config-e2e ❌ failure

Failed jobs: shields-config-e2e. Check run artifacts for logs.

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.

🧹 Nitpick comments (1)
docs/reference/commands.mdx (1)

1399-1399: ⚡ Quick win

Use second person ("you") instead of third person ("the operator").

The phrase "asks the operator to rebuild" should use "you" to address the reader directly. As per coding guidelines, documentation should use second person when addressing the reader.

📝 Suggested rewrite
-| `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | When `nemoclaw <name> shields up` runs against a sandbox that was locked before the SHA-256 content seal landed, the existing on-disk bytes have no independently verified baseline. By default, `shields up` refuses to capture a seal in that state and asks the operator to rebuild the sandbox for a known-good baseline. Set this to `1` to accept the current bytes as the trusted baseline and let the seal be captured anyway. Once captured, subsequent `shields status` runs detect any future drift. |
+| `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE` | `1` to opt in | When `nemoclaw <name> shields up` runs against a sandbox that was locked before the SHA-256 content seal landed, the existing on-disk bytes have no independently verified baseline. By default, `shields up` refuses to capture a seal in that state and asks you to rebuild the sandbox for a known-good baseline. Set this to `1` to accept the current bytes as the trusted baseline and let the seal be captured anyway. Once captured, subsequent `shields status` runs detect any future drift. |
🤖 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 `@docs/reference/commands.mdx` at line 1399, Change the phrasing in the
description for NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE to use second person
("you") instead of third person ("the operator"); specifically rewrite the
sentence "By default, `shields up` refuses to capture a seal in that state and
asks the operator to rebuild the sandbox for a known-good baseline." to address
the reader (e.g., "By default, `nemoclaw <name> shields up` refuses to capture a
seal in that state and asks you to rebuild the sandbox for a known-good
baseline.") while preserving references to
`NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE`, `nemoclaw <name> shields up`, and
`shields status`.
🤖 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.

Nitpick comments:
In `@docs/reference/commands.mdx`:
- Line 1399: Change the phrasing in the description for
NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE to use second person ("you") instead of
third person ("the operator"); specifically rewrite the sentence "By default,
`shields up` refuses to capture a seal in that state and asks the operator to
rebuild the sandbox for a known-good baseline." to address the reader (e.g., "By
default, `nemoclaw <name> shields up` refuses to capture a seal in that state
and asks you to rebuild the sandbox for a known-good baseline.") while
preserving references to `NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE`, `nemoclaw
<name> shields up`, and `shields status`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 4900a610-3754-475f-aec4-9e0f4b8f945f

📥 Commits

Reviewing files that changed from the base of the PR and between 8b241be and 44bd0eb.

📒 Files selected for processing (3)
  • docs/reference/commands.mdx
  • src/lib/shields/verify-lock.test.ts
  • src/lib/shields/verify-lock.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib/shields/verify-lock.test.ts
  • src/lib/shields/verify-lock.ts

@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ❌ Some jobs failed

Run: 26615118718
Target ref: 44bd0eb2364ec42c23ca02ae8a122952004343c7
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 0 passed, 1 failed, 0 skipped

Job Result
shields-config-e2e ❌ failure

Failed jobs: shields-config-e2e. Check run artifacts for logs.

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

Selective E2E Results — ✅ All requested jobs passed

Run: 26615584566
Target ref: 2647579239ff365c48b7fcb8430b213c6a7c1e3a
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 1 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success

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

Selective E2E Results — ✅ All requested jobs passed

Run: 26617046247
Target ref: 7b6923608619130a297030267850dbe00687b32f
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 1 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success

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

Selective E2E Results — ✅ All requested jobs passed

Run: 26617726235
Target ref: c385270fe92cbe9f715cfb467b2f9b59e424ceb4
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 1 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success

Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
@laitingsheng laitingsheng changed the title fix(shields): seal locked files with SHA-256 and detect content drift feat(shields): seal locked files with SHA-256 and detect content drift May 29, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Selective E2E Results — ✅ All requested jobs passed

Run: 26618302904
Target ref: 3b2fb476bbe42a36566a84a21812498c1b77639d
Workflow ref: main
Requested jobs: shields-config-e2e
Summary: 1 passed, 0 failed, 0 skipped

Job Result
shields-config-e2e ✅ success

@laitingsheng laitingsheng added the v0.0.55 Release target label May 29, 2026
@jyaunches jyaunches added R1 v0.0.56 Release target labels May 29, 2026
@jyaunches jyaunches removed v0.0.55 Release target R1 labels May 29, 2026
@cv cv merged commit 5fc700b into main May 30, 2026
35 of 36 checks passed
@cv cv deleted the fix/4243-shields-content-seal branch May 30, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix v0.0.56 Release target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Ubuntu 24.04][Security] shields status does not cross-check sandbox filesystem — drift after host-root tamper goes undetected

3 participants