From 90da1e0899ca834b1307c9ac3bfb5d8bdd934e66 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 11:58:24 -0400 Subject: [PATCH 01/25] refactor(agent): derive state handling from definitions Signed-off-by: Julie Yaunches --- Dockerfile | 4 + agents/hermes/Dockerfile | 5 +- agents/hermes/manifest.yaml | 38 +- agents/hermes/runtime-config-guard.py | 19 +- agents/hermes/state-lock-plan.json | 21 + .../langchain-deepagents-code/manifest.yaml | 6 +- agents/openclaw/manifest.yaml | 70 +-- agents/openclaw/state-lock-plan.json | 30 ++ ci/source-shape-test-budget.json | 5 + ci/test-file-size-budget.json | 2 +- docs/index.yml | 3 + docs/manage-sandboxes/backup-restore.mdx | 10 +- docs/security/best-practices.mdx | 21 +- docs/security/tcb-boundary.mdx | 17 +- package.json | 3 + .../lib/generate-agent-state-lock-plans.mts | 35 ++ scripts/nemoclaw-start.sh | 60 +-- scripts/state-dir-guard.py | 456 ++++++++++++++---- .../sandbox/channel-status.test-helpers.ts | 1 + src/lib/actions/sandbox/wipe-state.ts | 25 +- src/lib/agent/definition-types.ts | 40 +- src/lib/agent/defs.test.ts | 43 ++ src/lib/agent/defs.ts | 68 ++- .../hermes-recovery-boundary-fixtures.ts | 17 +- src/lib/agent/manifest-readers.ts | 16 + src/lib/agent/onboard.test.ts | 16 +- src/lib/agent/runtime-auth-state-dirs.test.ts | 84 ---- src/lib/agent/runtime.test.ts | 17 +- .../agent/state-directory-contract.test.ts | 216 +++++++++ src/lib/agent/state-directory-contract.ts | 308 ++++++++++++ .../onboard/verify-channel-runtime.test.ts | 1 + src/lib/sandbox/agent-config.test.ts | 198 +++++++- src/lib/sandbox/agent-config.ts | 89 +++- src/lib/sandbox/build-context.ts | 11 +- src/lib/sandbox/config-get.test.ts | 14 +- src/lib/shields/flow.test.ts | 27 ++ src/lib/shields/index.test.ts | 50 ++ src/lib/shields/index.ts | 117 ++++- src/lib/shields/legacy-hermes-compat.test.ts | 43 ++ src/lib/shields/openclaw-transition.test.ts | 29 ++ src/lib/shields/policy-transition.test.ts | 16 + src/lib/shields/state-dir-lock.test.ts | 180 ++++++- src/lib/shields/state-dir-lock.ts | 228 ++++++--- src/lib/state/sandbox.ts | 162 +++++-- .../state/user-managed-files-probe.test.ts | 1 + test/destroy-wipe-sandbox-state.test.ts | 72 ++- .../e2e/live/state-dir-guard-metadata.test.ts | 164 +++++-- test/helpers/base-image-test-harness.ts | 16 +- test/helpers/shell-source.ts | 33 ++ test/hermes-config-transaction-wiring.test.ts | 7 +- test/hermes-final-image-layout.test.ts | 2 + test/hermes-runtime-config-guard.test.ts | 41 +- test/nemoclaw-start-locked-migration.test.ts | 125 +++++ test/nemoclaw-start.test.ts | 40 +- ...openclaw-config-transaction-wiring.test.ts | 7 +- test/openclaw-final-image-layout.test.ts | 2 + .../openshell-policy-boundary.test.ts | 5 + test/rebuild-shields-auto-unlock.test.ts | 32 +- test/repro-2681-group-writable.test.ts | 178 +++++-- test/sandbox-build-context.test.ts | 4 + test/shields-up-runtime-perms.test.ts | 26 +- test/snapshot-runtime-auth-state.test.ts | 5 +- .../snapshot-state-directory-contract.test.ts | 196 ++++++++ test/snapshot.test.ts | 34 +- test/state-dir-guard.test.ts | 289 ++++++++++- 65 files changed, 3425 insertions(+), 675 deletions(-) create mode 100644 agents/hermes/state-lock-plan.json create mode 100644 agents/openclaw/state-lock-plan.json create mode 100644 scripts/lib/generate-agent-state-lock-plans.mts delete mode 100644 src/lib/agent/runtime-auth-state-dirs.test.ts create mode 100644 src/lib/agent/state-directory-contract.test.ts create mode 100644 src/lib/agent/state-directory-contract.ts create mode 100644 test/helpers/shell-source.ts create mode 100644 test/nemoclaw-start-locked-migration.test.ts create mode 100644 test/snapshot-state-directory-contract.test.ts diff --git a/Dockerfile b/Dockerfile index d78fbe53529..646609659c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,7 @@ COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/open COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py COPY scripts/lib/normalize_mutable_config_perms.py /usr/local/lib/nemoclaw/normalize_mutable_config_perms.py COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py +COPY agents/openclaw/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json COPY scripts/openclaw-config-guard.py /usr/local/lib/nemoclaw/openclaw-config-guard.py COPY scripts/managed-gateway-control.py /usr/local/lib/nemoclaw/managed-gateway-control.py COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start @@ -977,12 +978,14 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ && chown root:root /usr/local/bin/nemoclaw-gateway-control \ /usr/local/lib/nemoclaw/gateway-supervisor.sh \ /usr/local/lib/nemoclaw/state-dir-guard.py \ + /usr/local/share/nemoclaw/state-lock-plan.json \ /usr/local/lib/nemoclaw/openclaw-config-guard.py \ /usr/local/lib/nemoclaw/managed-gateway-control.py \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py \ /usr/local/lib/nemoclaw/openclaw-config-guard.py \ /usr/local/lib/nemoclaw/managed-gateway-control.py \ + && chmod 444 /usr/local/share/nemoclaw/state-lock-plan.json \ && chmod 444 /usr/local/lib/nemoclaw/gateway-supervisor.sh \ /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ @@ -1651,6 +1654,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts 'root:root:755' \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root:700' \ && check_metadata /usr/local/lib/nemoclaw/state-dir-guard.py 'root:root:500' \ + && check_metadata /usr/local/share/nemoclaw/state-lock-plan.json 'root:root:444' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644' \ && check_metadata /scripts/checks/node-tar-image-scan.mts 'root:root:755' \ && install -d -m 0755 /usr/local/share/nemoclaw \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 30b824ab38d..85249b2ce27 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -63,6 +63,7 @@ COPY agents/hermes/build-mcp-digest.py /usr/local/lib/nemoclaw/build-hermes-mcp- COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py +COPY agents/hermes/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ FROM scratch AS hermes-wrapper-payload @@ -222,9 +223,10 @@ RUN chmod -R a+rX /opt/nemoclaw-blueprint/ # minimum supported Hermes sandbox base tag guarantees those artifacts and # test/sandbox-rlimit-hooks.test.ts covers that base. RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ - && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ + && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ + && chmod 444 /usr/local/share/nemoclaw/state-lock-plan.json \ && chmod 444 /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ @@ -909,6 +911,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755' \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \ + && check_metadata /usr/local/share/nemoclaw/state-lock-plan.json 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ && check_metadata /scripts/checks/node-tar-image-scan.mts 'root:root 755' \ diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index dac843e4045..0ac8b19d343 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -52,6 +52,11 @@ config: dir: /sandbox/.hermes config_file: config.yaml env_file: .env # relative to dir — API keys + # Additional generated config files that Shields seals with config_file and + # .config-hash. Each entry must exist before a Shields transition. The + # runtime-config-guard parity test protects the separate in-image boundary. + shields_files: + - .env auth_file: auth.json # OAuth tokens (Nous Portal, Codex, etc.) format: yaml @@ -61,16 +66,29 @@ config: state_dirs: - memories - sessions - - skills - - plugins - - cron + - path: skills + shields: read-only + - path: plugins + shields: read-only + - path: cron + shields: read-only + # Hermes creates this mutable integration directory today, and the existing + # Shields helper protects it. Keep that lock behavior without newly treating + # its contents as portable snapshot state. + - path: hooks + backup: false + shields: read-only - logs - - skins + - path: skins + shields: read-only - plans - - workspace - - profiles + - path: workspace + shields: read-only + - path: profiles + shields: read-only - cache - - pairing + - path: pairing + shields: confidential # Hermes' Web dashboard uses a separate profile home for its config, # memories, and user identity. Preserve it so rebuilds do not reset the # dashboard profile independently of the main Hermes runtime. @@ -78,12 +96,14 @@ state_dirs: # Hermes' WhatsApp bridge stores QR-paired session credentials under # ~/.hermes/platforms/whatsapp/session. Preserve the parent so rebuilds # keep the in-sandbox pairing state without re-scanning. - - platforms + - path: platforms + shields: read-only # Hermes' iLink WeChat adapter persists per-account context tokens under # ~/.hermes/weixin/accounts/.context-tokens.json so the long-poll # cursor survives a rebuild. The bot token itself comes from .env via # the L7 proxy and is not stored on disk inside the sandbox. - - weixin + - path: weixin + shields: read-only # ── Top-level durable state files ─────────────────────────────── # NemoClaw stores Hermes gateway-created top-level state under runtime/ and diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index cf2cd0a4cac..d846e172173 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -54,6 +54,9 @@ ) NEMOCLAW_START_ARGV = (b"nemoclaw-start", b"/usr/local/bin/nemoclaw-start") OPENSHELL_SUPERVISOR_ARGV0 = b"/opt/openshell/bin/openshell-sandbox" +# Keep this in exact parity with manifest config_file + config.shields_files + +# .config-hash. The host manifest remains authoritative for host transitions; +# the integration test protects this separate in-image recovery boundary. SEALED_FILE_NAMES = ("config.yaml", ".env", ".config-hash") RESTART_ORPHAN_MARKER_NAME = ".nemoclaw-hermes-restart-seal" SHIELDS_TRANSITION_LEASE_SECONDS = 300 @@ -4411,7 +4414,11 @@ def abort_shields_transition(hermes_dir: str, state_file: str, lock_token: str) def run_state_dir_transition( - hermes_dir: str, state_file: str, lock_token: str, action: str + hermes_dir: str, + state_file: str, + lock_token: str, + action: str, + state_lock_plan_json: str, ) -> None: if action not in ("lock", "unlock"): raise UnsafePathError("refusing unsupported Hermes state-dir action") @@ -4433,6 +4440,13 @@ def run_state_dir_transition( helper = installed if os.path.isfile(installed) else checkout if not os.path.isfile(helper): raise UnsafePathError("Hermes state-dir guard is unavailable") + if state_lock_plan_json: + plan_args = ["--plan-json", state_lock_plan_json] + else: + plan_file = "/usr/local/share/nemoclaw/state-lock-plan.json" + if not os.path.isfile(plan_file): + raise UnsafePathError("Hermes state lock plan is unavailable") + plan_args = ["--plan-file", plan_file] # Preserve this exact PID/start identity as GNU timeout while it owns and # waits for the recursive worker. Cancel the Python alarm before exec so # timeout alone owns TERM/KILL tree cleanup and no orphan child survives. @@ -4449,6 +4463,7 @@ def run_state_dir_transition( action, "--config-dir", hermes_dir, + *plan_args, ], ) @@ -5004,6 +5019,7 @@ def main() -> int: parser.add_argument("--expected-config-sha256", default="") parser.add_argument("--lock-token", default="") parser.add_argument("--state-action", choices=("lock", "unlock"), default="") + parser.add_argument("--state-lock-plan-json", default="") parser.add_argument("--shields-mode", choices=("locked", "mutable"), default="") parser.add_argument( "--rollback-shields-mode", choices=("locked", "mutable"), default="" @@ -5186,6 +5202,7 @@ def main() -> int: args.state_file, args.lock_token, args.state_action, + args.state_lock_plan_json, ) except UnsafePathError as exc: _die(str(exc)) diff --git a/agents/hermes/state-lock-plan.json b/agents/hermes/state-lock-plan.json new file mode 100644 index 00000000000..e0231662cdd --- /dev/null +++ b/agents/hermes/state-lock-plan.json @@ -0,0 +1,21 @@ +{ + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", + "version": 1, + "readOnlyRoots": [ + "cron", + "hooks", + "platforms", + "plugins", + "profiles", + "skills", + "skins", + "weixin", + "workspace" + ], + "confidentialRoots": [ + "pairing" + ], + "readOnlyPrefixes": [], + "confidentialPrefixes": [], + "writableSubpaths": [] +} diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index d6a2d1cf915..7cf9aaad5d8 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -43,8 +43,10 @@ config: # .state and skills — otherwise skills are silently lost on rebuild. state_dirs: - .state - - skills - - agent/skills + - path: skills + shields: read-only + - path: agent/skills + shields: read-only # ── Top-level durable state files ─────────────────────────────── # config.toml mixes DCode preferences with NemoClaw-managed model routing. diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index fb4d077bd56..5fc03e37587 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -41,33 +41,49 @@ config: # ── State directories ────────────────────────────────────────── state_dirs: - - agents - - extensions - - workspace - - skills - - hooks - - identity - - devices - - canvas - - cron - - memory - - telegram - - wechat - - whatsapp - - credentials - -# Machine-local gateway auth state: the Ed25519 device identity -# (identity/device.json) and paired-device token store (devices/). Backup -# sanitization scrubs their key/token fields, so a restored copy can never -# authenticate — restoring it replaces working pairing state with corrupt -# files and the CLI fails with GatewayCredentialsRequiredError (issue #6852). -# These dirs stay in state_dirs so destroy still wipes them from the durable -# volume, but they are never captured into or restored from snapshots; -# OpenClaw regenerates the identity on demand and NemoClaw auto-pair -# re-pairs on connect. -runtime_auth_state_dirs: - - identity - - devices + - path: agents + shields: read-only + writable_subpaths: + - "*/sessions" + - path: extensions + shields: read-only + # Legacy layouts can still contain this pre-extensions directory. Preserve + # its existing Shields posture without newly treating it as portable state. + - path: plugins + backup: false + shields: read-only + - path: workspace + shields: read-only + # Multi-agent OpenClaw deployments create workspace- siblings. + - prefix: workspace- + shields: read-only + - path: skills + shields: read-only + - path: hooks + shields: read-only + # Machine-local gateway auth state is wiped on destroy but never captured. + # Sanitization removes the identity key and paired-device tokens, so a + # restored copy cannot authenticate (#6852). + - path: identity + backup: false + shields: confidential + - path: devices + backup: false + shields: read-only + - path: canvas + shields: read-only + - path: cron + shields: read-only + - path: memory + shields: read-only + - path: telegram + shields: read-only + - path: wechat + shields: read-only + - path: whatsapp + shields: read-only + - path: credentials + shields: confidential # ── Top-level durable state files ─────────────────────────────── # openclaw.json holds the core OpenClaw settings the state dirs above do not diff --git a/agents/openclaw/state-lock-plan.json b/agents/openclaw/state-lock-plan.json new file mode 100644 index 00000000000..d4f8920f7c0 --- /dev/null +++ b/agents/openclaw/state-lock-plan.json @@ -0,0 +1,30 @@ +{ + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0", + "version": 1, + "readOnlyRoots": [ + "agents", + "canvas", + "cron", + "devices", + "extensions", + "hooks", + "memory", + "plugins", + "skills", + "telegram", + "wechat", + "whatsapp", + "workspace" + ], + "confidentialRoots": [ + "credentials", + "identity" + ], + "readOnlyPrefixes": [ + "workspace-" + ], + "confidentialPrefixes": [], + "writableSubpaths": [ + "agents/*/sessions" + ] +} diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 8e473b6d06a..0b3beae906a 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -16,6 +16,11 @@ "test": "accepts only the tracked published Hermes base digest", "category": "security" }, + { + "file": "src/lib/agent/state-directory-contract.test.ts", + "test": "keeps generated image plans equal to their AgentDefinition projections (#8006)", + "category": "security" + }, { "file": "src/lib/onboard/managed-startup-profile.test.ts", "test": "classifies every stock Docker ARG as startup-affordance or deliberate exclusion", diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 5452bd9c9d8..7a197293a82 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1904, "test/generate-openclaw-config.test.ts": 1941, "test/install-preflight.test.ts": 3921, - "test/nemoclaw-start.test.ts": 4819, + "test/nemoclaw-start.test.ts": 4793, "test/onboard-messaging.test.ts": 2043, "test/onboard-selection.test.ts": 4769 } diff --git a/docs/index.yml b/docs/index.yml index ea0ad6939e0..4427bde74a7 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -678,6 +678,9 @@ navigation: - page: "Credential Storage" path: _build/agent-variants/security/credential-storage.deepagents.generated.mdx slug: credential-storage + - page: "Trusted Computing Base" + path: _build/agent-variants/security/tcb-boundary.deepagents.generated.mdx + slug: trusted-computing-base - page: "OpenShell 0.0.72 Compatibility Review" path: _build/agent-variants/security/openshell-0.0.72-compatibility-review.deepagents.generated.mdx slug: openshell-0.0.72-compatibility-review diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 10f2d02d2d4..b1c559cc621 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -34,7 +34,7 @@ They are the preferred backup and restore path. ## Understand Snapshot Contents -Snapshots capture all workspace state directories defined in the agent manifest and store them in `~/.nemoclaw/rebuild-backups//`. +Snapshots capture the manifest-declared snapshot state directories and store them in `~/.nemoclaw/rebuild-backups//`. Agent manifests can also declare durable top-level state files. Treat snapshot directories as private local data. @@ -70,10 +70,10 @@ Wait for active `dcode` work to finish before running `$$nemoclaw snapsho Snapshots preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy add --from-file` or `policy add --from-dir` and baseline network policy entries excluded with `policy exclude`. When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk, and rebuild continues to apply the recorded baseline exclusions. -The target sandbox's current agent manifest remains authoritative for state-file restore behavior. -NemoClaw rejects the restore when the snapshot's agent, config directory, state-file path, or state-file strategy conflicts with that manifest. -Restore limits directory cleanup to state directories declared by the snapshot manifest. -It preserves directories that exist only in the target manifest or whose backup failed. +The target sandbox's current agent manifest remains authoritative for directory and state-file restore behavior. +NemoClaw rejects the restore when the snapshot's agent, config directory, any snapshot directory, state-file path, or state-file strategy conflicts with that manifest. +Restore limits directory cleanup to state directories authorized by both the snapshot and the current manifest. +It preserves target-only directories and directories whose backup failed. For managed images, NemoClaw applies the current manifest's managed config merge rules by default and does not fall back to whole-file replacement. For Deep Agents targets, whole-file config replacement is limited to sandboxes created from a custom Dockerfile. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 2143c9a3b02..fa374205488 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -290,21 +290,21 @@ Writable agent state such as plugins, skills, hooks, and workspace metadata live By default, this directory starts writable so the agent can manage its own config, install skills, and write to standard home-directory paths natively. For sensitive workloads, use a reviewed host-side immutability workflow after initial setup so the sandbox user cannot change config or high-risk state entry points. -The immutability workflow locks high-risk state directories (`skills`, `agent`, `hooks`, `cron`, `agents`, `extensions`, `plugins`, `workspace`, `memory`, `devices`, `canvas`, `telegram`, `wechat`, `whatsapp`, `platforms`, `weixin`, `profiles`, `skins`) to `root:sandbox` and removes group and world write access. +The immutability workflow derives its path plan from the selected agent manifest. +For OpenClaw, it locks `agents`, `canvas`, `cron`, `devices`, `extensions`, `hooks`, `memory`, `plugins`, `skills`, `telegram`, `wechat`, `whatsapp`, `workspace`, and `workspace-*` directories to `root:sandbox` and removes group and world write access. The root-only helper traverses from opened directory descriptors with no-follow semantics instead of using recursive pathname `chown` or `chmod`. Read-only preflight and unlock operations reject unsafe external symlinks, hardlinks, special files, cross-device entries, and entries that race the traversal without modifying them. After the top-level config binding is frozen, lockdown makes containment monotonic. It removes unsafe symlinks, special entries, and protected-root names that are not directories through descriptor-relative operations without following their targets. For protected regular files, lockdown publishes a fresh inode, severing hardlinks while preserving file content, read/execute mode, timestamps, and supported extended attributes; this also revokes write authority held through a descriptor opened before `shields up`. The OpenClaw gateway (a member of the `sandbox` group) keeps read access to plugin and agent code; the sandbox user can no longer write them. -The same workflow also locks the secret-bearing directories (`credentials`, `identity`, `pairing`) to `root:root 700` with `chmod -R go-rwX`. +The same workflow locks the OpenClaw `credentials` and `identity` directories to `root:root 700` and removes group and world access. Neither the sandbox user nor the gateway can read those secrets while the lock is active. Restoring the mutable-default posture returns those directories to `sandbox:sandbox 2770`. -The list is the union of state directories declared by every shipped agent manifest. -The lock helper silently skips dirs that are not present in a given agent's config tree. -Two exemption kinds keep runtime data writable. -The lock inventory omits top-level Hermes runtime dirs (`sessions/`, `memories/`, `logs/`, `cache/`, `plans/`) and the image-build-regenerated `openclaw-weixin/`. -The lock helper never touches those paths. +For plan-aware current images and host-injected transitions, each agent manifest declares only its own protected paths, confidential paths, dynamic prefixes, and writable subpaths. +The lock helper applies only that selected manifest plan and skips declared paths that are not present. +Historical OpenClaw and Hermes images that have a bundled helper but no generated plan use the helper's reviewed legacy inventory until the sandbox is rebuilt. +State directories without a Shields declaration remain mutable. Inside a locked tree, the helper keeps each `agents//sessions/` root at `sandbox:sandbox 2770` so the OpenClaw TUI can create and write session metadata under an otherwise root-owned parent. After containment, when an agent directory has no `sessions` entry, lockdown creates that carve-out root. An agent booting for the first time under an active lock can then write sessions. @@ -344,6 +344,10 @@ Direct edits to these files can be overwritten when NemoClaw regenerates the ima Hermes also stores runtime state such as `state.db`, logs, and platform sessions under the `.hermes` tree. Messaging sessions such as WhatsApp pairing can remain mutable by design so they survive rebuilds. +For plan-aware current images, the Shields workflow derives the Hermes lock plan from its agent manifest. +Historical Hermes images that have a bundled helper but no generated plan use the helper's reviewed legacy inventory until the sandbox is rebuilt. +It locks `cron`, `hooks`, `platforms`, `plugins`, `profiles`, `skills`, `skins`, `weixin`, and `workspace` to `root:sandbox`, and locks `pairing` to `root:root 700`. +Hermes runtime directories without a Shields declaration remain mutable. | Aspect | Detail | |---|---| @@ -362,6 +366,9 @@ Direct edits to this file can be overwritten when NemoClaw regenerates the manag The managed Deep Agents image deliberately omits raw provider and service credentials from generated configuration. Credential-bearing files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are treated as user-managed files and are not included in NemoClaw snapshots. The managed `.deepagents/.nemoclaw-mcp.json` projection contains OpenShell placeholders and is reconstructed from host-side registry state. +The Shields workflow derives the Deep Agents lock plan from its agent manifest. +The `agent/skills` declaration locks the top-level `agent` directory, and the `skills` declaration locks the top-level `skills` directory. +The `.state` directory remains mutable. | Aspect | Detail | |---|---| diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index a725149fdb2..736f635f59e 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -1,19 +1,19 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Trusted Computing Base for Gateway Lifecycle Control" +title: "Trusted Computing Base for Lifecycle and Shields Control" sidebar-title: "Trusted Computing Base" -description: "Defines the trusted computing base, privilege boundaries, and review invariants for NemoClaw gateway lifecycle and shields operations." +description: "Defines the trusted computing base, privilege boundaries, and review invariants for NemoClaw lifecycle and Shields operations." description-agent: >- Maps NemoClaw gateway lifecycle and shields components to their trust boundaries, threats, privilege levels, and verification requirements. Use when reviewing privileged gateway restart, config mutation, or shields changes. keywords: ["nemoclaw trusted computing base", "gateway lifecycle security", "shields trust boundary"] content: type: "reference" -agent-variants: ["openclaw", "hermes"] +agent-variants: ["openclaw", "hermes", "deepagents"] --- -NemoClaw uses a small set of host and sandbox components to restart built-in gateways and change shields posture without granting lifecycle authority to the sandbox agent. +NemoClaw uses a small set of host and sandbox components to change Shields posture and, where present, restart built-in gateways without granting lifecycle authority to the sandbox agent. This page defines the trusted computing base for those operations and the evidence required when the boundary changes. ## Security Boundary @@ -25,7 +25,7 @@ Host root compromise and replacement of root-owned image files are outside this The lifecycle boundary maintains these invariants. - Only a registry-selected sandbox can receive a host lifecycle request. -- Only root-owned installed helpers can perform privileged lifecycle or filesystem transitions. +- Privileged lifecycle transitions use root-owned installed helpers. Filesystem transitions use an installed helper when the image provides one; otherwise, the trusted host CLI injects its own helper through authenticated root execution without writing it to mutable sandbox state. - A mutable path, status file, process ID, command line, or listener alone never grants authority. - Process decisions bind the observed process ID to its start identity, parent chain, user identity, PID namespace, executable shape, and listener ownership where the topology exposes those signals. - Filesystem transitions open trusted parents by descriptor, reject symlinks and unsafe hard links, bound traversal and input size, and verify the resulting inode state. @@ -41,7 +41,7 @@ A successful build does not replace review of privilege, process identity, descr | Component | Execution and privilege | Trusted input | Security responsibility | |---|---|---|---| -| `scripts/state-dir-guard.py` | The installed copy is root-owned and mode `0500`; the host reaches it through the shields transaction. | Fixed paths, a bounded action contract, and a lock token from the host coordinator. | Applies descriptor-rooted state-directory posture changes, rejects link and mount substitution, bounds traversal, and verifies the committed modes and ownership. | +| `scripts/state-dir-guard.py` | Current OpenClaw and Hermes images use a root-owned, mode `0500` installed copy and generated plan. A historical image that has the helper but no plan uses its co-bundled helper. Agents without an image recovery artifact, and images that predate both artifacts, receive the trusted host CLI copy over standard input to `python3 -I -` through authenticated root execution. | Fixed paths and a bounded action contract. Current and host-injected transitions use a strict versioned path plan derived from the selected agent manifest. A historical co-bundled helper uses its reviewed legacy inventory. | Applies descriptor-rooted state-directory posture changes, rejects link and mount substitution, bounds traversal, and verifies the committed modes and ownership. The host refuses a plan-aware image when the current host manifest differs from the installed recovery plan. | | `scripts/lib/normalize_mutable_config_perms.py` | The installed copy is root-owned and mode `0555`; startup invokes it under the entrypoint identity, and only root can reclaim a root-owned tree. | The fixed OpenClaw config path, the resolved sandbox identity, and an exact `root:root 0700/0600` mutable-drift signature under the expected sandbox-owned parent. | Restores the mutable `2770/660` contract, pins every privileged handoff by descriptor, and rejects ambiguous posture, links, mount substitution, metadata races, and sealed config. | | `scripts/openclaw-config-guard.py` | The installed copy is root-owned and mode `0500`; direct root PID 1 or the authenticated host transaction invokes it. | Bounded strict JSON for writes, stable captured config bytes for restart validation, and fixed installed parser paths for existing JSON5 config. | Seals and unseals OpenClaw config with no-follow descriptors, stable inode checks, atomic replacement, hash coherence, and recoverable transaction journals. | | `scripts/managed-gateway-control.py` | The installed copy is root-owned and mode `0500`; the host invokes it through sanitized registry-scoped direct-container execution. | A fixed action, a 64-character nonce, fixed installed helpers, and a live OpenShell process tree observed through `/proc`. | Authenticates the host action, proves the managed supervisor and gateway identity, holds a root-owned mode `0600` lifecycle lock, publishes one root-owned mode `0444` exact-exit authorization bound to the gateway and live root controller identities, signals through a pidfd, waits for the normal respawn loop, and verifies listener and HTTP health. | @@ -76,6 +76,7 @@ The host CLI first resolves the sandbox from host-owned registry state and selec Gateway restart generates a fresh nonce and enters `nemoclaw-gateway-control` as root with injection-capable environment variables cleared. The direct topology publishes a root-owned request to PID 1, while the OpenShell-managed topology executes `managed-gateway-control.py` directly. Both paths prove the exact replacement gateway and health state before the host repairs port forwards or reports success. +Terminal agents do not run these gateway branches; their Shields transitions use the state and verification branches. Shields mutations acquire the host transition lock before changing network policy, config posture, timer authority, or host state. The coordinator invokes the agent-specific config guard and `state-dir-guard.py`, verifies the resulting posture, then commits host state and audit output. @@ -135,5 +136,7 @@ The following conditions govern current compatibility code and architecture work - Decompose the host shields coordinator only with behavior-preserving changes that keep policy, config, timer, rollback, state, and audit ordering under one typed transaction contract. - Keep the managed controller's source-path and fake-root overrides disabled unless the explicit source test flag is present, and keep installed helpers bound to fixed production paths. -Final-image validation must cover both built-in images. +Final-image validation must cover the recovery-capable OpenClaw and Hermes images. It checks helper owners and modes, root and gateway supplementary groups, root execution of the read-only `probe` path, and refusal before helper entry when the sandbox user attempts execution. +Deep Agents uses the host-injected helper path instead of an installed recovery artifact. +Host wiring tests validate that selection and plan handoff, while the shared helper tests validate the descriptor-safe behavior; installed-helper metadata checks do not cover Deep Agents. diff --git a/package.json b/package.json index 92b617dee5c..e6989c99e46 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "clean:cli": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "typecheck:cli": "tsc -p tsconfig.cli.json", "validate:configs": "tsx scripts/validate-configs.mts", + "generate:agent-state-lock-plans": "node --import tsx scripts/lib/generate-agent-state-lock-plans.mts", "type-safety:hotspots": "tsx scripts/type-safety-hotspots.mts", "source-shape:scan": "tsx scripts/find-source-shape-tests.mts --metrics", "source-shape:check": "tsx scripts/find-source-shape-tests.mts --check", @@ -107,6 +108,8 @@ "files": [ ".version", ".source-revision", + "agents/*/manifest.yaml", + "agents/*/state-lock-plan.json", "agents/hermes/host/", "bin/", "dist/", diff --git a/scripts/lib/generate-agent-state-lock-plans.mts b/scripts/lib/generate-agent-state-lock-plans.mts new file mode 100644 index 00000000000..e3ea6c887b7 --- /dev/null +++ b/scripts/lib/generate-agent-state-lock-plans.mts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; + +const { buildStateLockPlan, readStateDirectories } = await import( + "../../src/lib/agent/state-directory-contract" +); + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const IMAGE_AGENTS = ["openclaw", "hermes"] as const; +const SPDX_COMMENT = + "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0"; + +for (const agentName of IMAGE_AGENTS) { + const manifestPath = path.join(REPO_ROOT, "agents", agentName, "manifest.yaml"); + const manifest = YAML.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; + if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) { + throw new Error(`Agent manifest must be an object: ${manifestPath}`); + } + const outputPath = path.join(REPO_ROOT, "agents", agentName, "state-lock-plan.json"); + const output = `${JSON.stringify( + { + $comment: SPDX_COMMENT, + ...buildStateLockPlan(readStateDirectories(manifest as Record)), + }, + null, + 2, + )}\n`; + fs.writeFileSync(outputPath, output); + process.stdout.write(`${path.relative(REPO_ROOT, outputPath)}\n`); +} diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 76a40eb8b8e..394a1935234 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -846,7 +846,8 @@ prepare_openclaw_config_startup() { echo "[config-guard] resuming interrupted recursive OpenClaw state lock" >&2 timeout --signal=TERM --kill-after=5s 12m \ python3 -I "$_OPENCLAW_STATE_DIR_GUARD" lock \ - --config-dir /sandbox/.openclaw || return 1 + --config-dir /sandbox/.openclaw \ + --plan-file /usr/local/share/nemoclaw/state-lock-plan.json || return 1 fi } @@ -4165,16 +4166,6 @@ ensure_mutable_for_migration() { return 1 } -restore_immutable_if_possible() { - command -v chattr >/dev/null 2>&1 || return 0 - local target - for target in "$@"; do - [ -e "$target" ] || [ -L "$target" ] || continue - [ -L "$target" ] && continue - chattr +i "$target" 2>/dev/null || true - done -} - chown_tree_no_symlink_follow() { local owner="$1" target="$2" [ -d "$target" ] || return 0 @@ -4324,6 +4315,26 @@ migrate_legacy_layout() { chown_tree_no_symlink_follow sandbox:sandbox "$entry" done + # Reapply the canonical shields posture before committing the migration. + # The config guard verifies that the protected config/hash pair is still + # sealed. The state-dir guard derives every recursive permission from the + # installed agent manifest plan. Keep the legacy data directory until both + # guards succeed so a failed relock remains retryable on the next startup. + if [ "$shields_were_active" = "true" ]; then + echo "[migration] Reapplying Shields up posture on ${config_dir}" >&2 + if ! run_openclaw_config_guard recover --startup-owner; then + echo "[SECURITY] ${label}: canonical config guard refused the migrated layout" >&2 + return 1 + fi + if ! timeout --signal=TERM --kill-after=5s 12m \ + python3 -I "$_OPENCLAW_STATE_DIR_GUARD" lock \ + --config-dir "$config_dir" \ + --plan-file /usr/local/share/nemoclaw/state-lock-plan.json; then + echo "[SECURITY] ${label}: canonical state-dir guard refused the migrated layout" >&2 + return 1 + fi + fi + rm -rf "$data_dir" assert_no_legacy_layout "$config_dir" "$data_dir" "$label" || return 1 @@ -4333,33 +4344,6 @@ migrate_legacy_layout() { chown root:root "$sentinel" 2>/dev/null || true chmod 444 "$sentinel" 2>/dev/null || true - # Reapply shields-up ownership if config dir was previously root-locked. - if [ "$shields_were_active" = "true" ]; then - echo "[migration] Reapplying shields-up ownership on ${config_dir}" >&2 - chown root:root "$config_dir" 2>/dev/null || true - chmod 755 "$config_dir" 2>/dev/null || true - # Re-lock known sensitive files if they exist - for f in "$config_dir"/openclaw.json "$config_dir"/.config-hash "$config_dir"/.env; do - if [ -f "$f" ]; then - chown root:root "$f" 2>/dev/null || true - chmod 444 "$f" 2>/dev/null || true - fi - done - for subdir in skills hooks cron agents extensions plugins; do - if [ -d "$config_dir/$subdir" ]; then - chown_tree_no_symlink_follow root:root "$config_dir/$subdir" - chmod 755 "$config_dir/$subdir" 2>/dev/null || true - chmod -R go-w "$config_dir/$subdir" 2>/dev/null || true - restore_immutable_if_possible "$config_dir/$subdir" - fi - done - restore_immutable_if_possible \ - "$config_dir"/openclaw.json \ - "$config_dir"/.config-hash \ - "$config_dir"/.env \ - "$config_dir" - fi - echo "[migration] Completed ${label} layout migration (${data_dir} removed)" >&2 } diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 12e7ffacc94..7148c9d1771 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -24,6 +24,7 @@ import os import posixpath import pwd +import re import secrets import stat import struct @@ -33,29 +34,6 @@ from typing import Literal -HIGH_RISK_STATE_DIRS = frozenset( - { - "skills", - "agent", - "hooks", - "cron", - "agents", - "extensions", - "plugins", - "workspace", - "memory", - "devices", - "canvas", - "telegram", - "wechat", - "whatsapp", - "platforms", - "weixin", - "profiles", - "skins", - } -) -CONFIDENTIALITY_STATE_DIRS = frozenset({"credentials", "identity", "pairing"}) MAX_SYMLINK_EXPANSIONS = 40 MAX_TRAVERSAL_DEPTH = 256 STABLE_COPY_ATTEMPTS = 3 @@ -84,6 +62,19 @@ ASCII_ALNUM_CHARS = frozenset( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" ) +SAFE_TOP_LEVEL_RE = re.compile(r"^[A-Za-z0-9._-]+$") +PLAN_KEYS = frozenset( + { + "version", + "readOnlyRoots", + "confidentialRoots", + "readOnlyPrefixes", + "confidentialPrefixes", + "writableSubpaths", + } +) +OPTIONAL_PLAN_KEYS = frozenset({"$comment"}) +MAX_PLAN_BYTES = 1024 * 1024 FS_IMMUTABLE_FL = 0x00000010 FS_APPEND_FL = 0x00000020 FS_IOC_GETFLAGS = 0x80086601 @@ -93,6 +84,31 @@ Policy = Literal["high-risk", "confidentiality"] +class PlanValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class AgentStateLockPlan: + version: Literal[1] + read_only_roots: tuple[str, ...] + confidential_roots: tuple[str, ...] + read_only_prefixes: tuple[str, ...] + confidential_prefixes: tuple[str, ...] + writable_subpaths: tuple[tuple[str, ...], ...] + + def policy_for_root(self, name: str) -> Policy | None: + if name in self.confidential_roots or any( + name.startswith(prefix) for prefix in self.confidential_prefixes + ): + return "confidentiality" + if name in self.read_only_roots or any( + name.startswith(prefix) for prefix in self.read_only_prefixes + ): + return "high-risk" + return None + + @dataclass(frozen=True) class Identity: root_uid: int @@ -221,34 +237,176 @@ def account_copy(self, path: str, size: int) -> None: ) -def _is_runtime_carveout(relative_path: str) -> bool: - """Return whether this is OpenClaw's intentional writable sessions root.""" +def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise PlanValidationError(f"plan repeats key {key!r}") + result[key] = value + return result - parts = relative_path.split("/") - return ( - len(parts) == 3 - and parts[0] == "agents" - and parts[1] not in {"", ".", ".."} - and parts[2] == "sessions" - ) +def _read_string_list(record: dict[str, object], key: str) -> tuple[str, ...]: + value = record[key] + if not isinstance(value, list): + raise PlanValidationError(f"plan field {key!r} must be an array") + result: list[str] = [] + seen: set[str] = set() + for index, item in enumerate(value): + if not isinstance(item, str): + raise PlanValidationError(f"plan field {key!r}[{index}] must be a string") + if item in seen: + raise PlanValidationError(f"plan field {key!r} repeats {item!r}") + seen.add(item) + result.append(item) + return tuple(result) + + +def _validate_top_level_name(value: str, field: str) -> None: + if ( + value in {"", ".", ".."} + or SAFE_TOP_LEVEL_RE.fullmatch(value) is None + or "/" in value + or "\\" in value + ): + raise PlanValidationError( + f"plan field {field!r} must contain one safe top-level name" + ) -def _is_under_runtime_carveout(relative_path: str) -> bool: - parts = relative_path.split("/") - return ( - len(parts) >= 3 - and parts[0] == "agents" - and parts[1] not in {"", ".", ".."} - and parts[2] == "sessions" + +def _validate_writable_subpath(value: str, field: str) -> tuple[str, ...]: + if value.startswith("/") or "\\" in value or any( + ord(character) < 32 or ord(character) == 127 for character in value + ): + raise PlanValidationError( + f"plan field {field!r} must be a canonical relative path" + ) + components = tuple(value.split("/")) + if len(components) < 2 or any( + component in {"", ".", ".."} for component in components + ): + raise PlanValidationError( + f"plan field {field!r} must be beneath a declared top-level root" + ) + for component in components: + if "*" in component and component != "*": + raise PlanValidationError( + f"plan field {field!r} may use '*' only as a complete path component" + ) + if components[-1] == "*": + raise PlanValidationError( + f"plan field {field!r} may not end with a wildcard component" + ) + return components + + +def _patterns_overlap(first: tuple[str, ...], second: tuple[str, ...]) -> bool: + return all( + left == "*" or right == "*" or left == right + for left, right in zip(first, second) ) -def _is_runtime_carveout_parent(relative_path: str) -> bool: - """Return whether this is an agent directory whose ``sessions`` child is - the carveout location.""" +def _validate_policy_boundaries( + read_only_roots: tuple[str, ...], + confidential_roots: tuple[str, ...], + read_only_prefixes: tuple[str, ...], + confidential_prefixes: tuple[str, ...], +) -> None: + exact = [ + *((name, "read-only") for name in read_only_roots), + *((name, "confidential") for name in confidential_roots), + ] + prefixes = [ + *((prefix, "read-only") for prefix in read_only_prefixes), + *((prefix, "confidential") for prefix in confidential_prefixes), + ] + exact_names: set[str] = set() + for name, _policy in exact: + if name in exact_names: + raise PlanValidationError(f"plan assigns root {name!r} more than once") + exact_names.add(name) + prefix_names: set[str] = set() + for prefix, _policy in prefixes: + if prefix in prefix_names: + raise PlanValidationError(f"plan assigns prefix {prefix!r} more than once") + prefix_names.add(prefix) + for name, _policy in exact: + for prefix, _prefix_policy in prefixes: + if name.startswith(prefix): + raise PlanValidationError( + f"plan root {name!r} also matches prefix {prefix!r}" + ) + for index, (prefix, _policy) in enumerate(prefixes): + for other, _other_policy in prefixes[index + 1 :]: + if prefix.startswith(other) or other.startswith(prefix): + raise PlanValidationError( + f"plan prefixes {prefix!r} and {other!r} overlap" + ) + - parts = relative_path.split("/") - return len(parts) == 2 and parts[0] == "agents" and parts[1] not in {"", ".", ".."} +def parse_agent_state_lock_plan(payload: str) -> AgentStateLockPlan: + try: + value = json.loads(payload, object_pairs_hook=_unique_json_object) + except (json.JSONDecodeError, PlanValidationError) as exc: + raise PlanValidationError(f"plan is not valid JSON: {exc}") from exc + if not isinstance(value, dict): + raise PlanValidationError("plan must be a JSON object") + keys = set(value) + missing = sorted(PLAN_KEYS - keys) + unknown = sorted(keys - PLAN_KEYS - OPTIONAL_PLAN_KEYS) + if missing: + raise PlanValidationError(f"plan is missing keys: {', '.join(missing)}") + if unknown: + raise PlanValidationError(f"plan has unknown keys: {', '.join(unknown)}") + if "$comment" in value and not isinstance(value["$comment"], str): + raise PlanValidationError("plan field '$comment' must be a string") + if type(value["version"]) is not int or value["version"] != 1: + raise PlanValidationError("plan field 'version' must be exactly 1") + + read_only_roots = _read_string_list(value, "readOnlyRoots") + confidential_roots = _read_string_list(value, "confidentialRoots") + read_only_prefixes = _read_string_list(value, "readOnlyPrefixes") + confidential_prefixes = _read_string_list(value, "confidentialPrefixes") + writable_values = _read_string_list(value, "writableSubpaths") + for key, entries in ( + ("readOnlyRoots", read_only_roots), + ("confidentialRoots", confidential_roots), + ("readOnlyPrefixes", read_only_prefixes), + ("confidentialPrefixes", confidential_prefixes), + ): + for index, entry in enumerate(entries): + _validate_top_level_name(entry, f"{key}[{index}]") + _validate_policy_boundaries( + read_only_roots, + confidential_roots, + read_only_prefixes, + confidential_prefixes, + ) + + writable_subpaths = tuple( + _validate_writable_subpath(value, f"writableSubpaths[{index}]") + for index, value in enumerate(writable_values) + ) + for index, components in enumerate(writable_subpaths): + if components[0] not in read_only_roots: + raise PlanValidationError( + f"plan field 'writableSubpaths[{index}]' must be beneath a read-only root" + ) + for other_index, other in enumerate(writable_subpaths[index + 1 :], index + 1): + if _patterns_overlap(components, other): + raise PlanValidationError( + "plan writable subpaths " + f"{writable_values[index]!r} and {writable_values[other_index]!r} overlap" + ) + return AgentStateLockPlan( + version=1, + read_only_roots=read_only_roots, + confidential_roots=confidential_roots, + read_only_prefixes=read_only_prefixes, + confidential_prefixes=confidential_prefixes, + writable_subpaths=writable_subpaths, + ) def _no_follow_flag() -> int: @@ -388,16 +546,11 @@ def _entry_kind(st: os.stat_result) -> str: return "unknown entry" -def _policy_for_root(name: str) -> Policy | None: - if name in CONFIDENTIALITY_STATE_DIRS: - return "confidentiality" - if name in HIGH_RISK_STATE_DIRS or name.startswith("workspace-"): - return "high-risk" - return None - - def _select_roots( - config_fd: int, config_path: str, config_dev: int + config_fd: int, + config_path: str, + config_dev: int, + plan: AgentStateLockPlan, ) -> tuple[list[RootSpec], list[Issue]]: issues: list[Issue] = [] try: @@ -406,7 +559,7 @@ def _select_roots( return [], [_os_issue("list-failed", config_path, "list config directory", exc)] selected_names = sorted( - name for name in present_names if _policy_for_root(name) is not None + name for name in present_names if plan.policy_for_root(name) is not None ) roots: list[RootSpec] = [] for name in selected_names: @@ -441,7 +594,7 @@ def _select_roots( ) ) continue - policy = _policy_for_root(name) + policy = plan.policy_for_root(name) if policy is None: # The name came from the same predicate above. continue roots.append(RootSpec(name=name, policy=policy, dev=st.st_dev, ino=st.st_ino)) @@ -449,7 +602,10 @@ def _select_roots( def _remove_unsupported_root_entries_for_lock( - config_fd: int, config_path: str, config_dev: int + config_fd: int, + config_path: str, + config_dev: int, + plan: AgentStateLockPlan, ) -> int: """Remove attacker-created protected-root names that cannot be traversed. @@ -461,7 +617,7 @@ def _remove_unsupported_root_entries_for_lock( removed = 0 for name in _bounded_directory_names(config_fd, config_path): - if _policy_for_root(name) is None: + if plan.policy_for_root(name) is None: continue path = _display_path(config_path, name) st = os.stat(name, dir_fd=config_fd, follow_symlinks=False) @@ -507,6 +663,7 @@ class TraversalContext: config_dev: int protected_roots: tuple[str, ...] budget: WorkBudget + writable_subpaths: tuple[tuple[str, ...], ...] = () def display(self, relative_path: str) -> str: return _display_path(self.config_path, relative_path) @@ -517,6 +674,36 @@ def is_protected(self, relative_path: str) -> bool: for root in self.protected_roots ) + @staticmethod + def _matches(pattern: tuple[str, ...], components: tuple[str, ...]) -> bool: + return len(pattern) == len(components) and all( + expected == "*" or expected == actual + for expected, actual in zip(pattern, components, strict=True) + ) + + def is_writable_root(self, relative_path: str) -> bool: + components = tuple(relative_path.split("/")) + return any( + self._matches(pattern, components) for pattern in self.writable_subpaths + ) + + def is_under_writable_root(self, relative_path: str) -> bool: + components = tuple(relative_path.split("/")) + return any( + len(components) >= len(pattern) + and self._matches(pattern, components[: len(pattern)]) + for pattern in self.writable_subpaths + ) + + def writable_children(self, relative_path: str) -> tuple[str, ...]: + components = tuple(relative_path.split("/")) + return tuple( + pattern[-1] + for pattern in self.writable_subpaths + if len(pattern) == len(components) + 1 + and self._matches(pattern[:-1], components) + ) + def _normalize_link_target( context: TraversalContext, @@ -597,14 +784,14 @@ def _resolve_internal_symlink( relative = _normalize_link_target( context, posixpath.dirname(link_relative_path), target ) - if _is_under_runtime_carveout(link_relative_path) or _is_under_runtime_carveout( - relative - ): + if context.is_under_writable_root( + link_relative_path + ) or context.is_under_writable_root(relative): raise GuardOperationError( Issue( "symlink-crosses-runtime-carveout", context.display(link_relative_path), - "symlinks may not enter or originate in the writable sessions carveout", + "symlinks may not enter or originate in a writable state subpath", ) ) seen: set[str] = set() @@ -679,12 +866,12 @@ def _resolve_internal_symlink( "expanded symlink target leaves protected roots", ) ) - if _is_under_runtime_carveout(relative): + if context.is_under_writable_root(relative): raise GuardOperationError( Issue( "symlink-crosses-runtime-carveout", context.display(link_relative_path), - "symlink chain enters the writable sessions carveout", + "symlink chain enters a writable state subpath", ) ) restart = True @@ -799,11 +986,11 @@ def _scan_dir( ) continue if stat.S_ISDIR(st.st_mode): - # Session contents are intentionally outside the shields integrity - # boundary and remain live while shields are up. Validate that the - # carve-out itself is a real in-tree directory, but do not traverse - # a subtree the gateway may be appending to concurrently. - if _is_runtime_carveout(relative_path): + # Writable contents are intentionally outside the shields integrity + # boundary and remain live while shields are up. Validate that the + # subpath root is a real in-tree directory, but do not traverse a + # subtree the gateway may be mutating concurrently. + if context.is_writable_root(relative_path): continue try: child_fd = _open_child_dir(dir_fd, name, st) @@ -847,8 +1034,9 @@ def _preflight( config_dev: int, deadline: float, action: Action, + plan: AgentStateLockPlan, ) -> tuple[list[RootSpec], list[Issue]]: - roots, issues = _select_roots(config_fd, config_path, config_dev) + roots, issues = _select_roots(config_fd, config_path, config_dev, plan) protected_roots = tuple(root.name for root in roots) context = TraversalContext( config_fd, @@ -856,6 +1044,7 @@ def _preflight( config_dev, protected_roots, WorkBudget(deadline), + plan.writable_subpaths, ) for root in roots: path = context.display(root.name) @@ -1234,30 +1423,27 @@ def _chown_symlink( raise GuardOperationError(issue) -def _ensure_runtime_carveout( +def _ensure_writable_subpath( context: TraversalContext, parent_fd: int, relative_dir: str, + name: str, policy: Policy, identity: Identity, result: GuardResult, ) -> None: - """Create the writable sessions carveout for an agent that has none. - - The locked agent directory is root-owned and read-only for the sandbox - identity, so an agent booting for the first time after shields-up cannot - create its own sessions directory and fails with EACCES. ``_mutate_dir`` - calls this after its entry loop so an agent whose unsafe ``sessions`` - entry was removed earlier in the same lock pass also converges on a - created carveout; a surviving non-directory entry keeps its locked - posture. A name that appears between the existence check and the mkdir - makes the lock fail closed, matching the raced-entry policy. + """Create a declared writable subpath when its parent already exists. + + The locked parent is root-owned and read-only for the sandbox identity, so + the runtime cannot create the declared final component after shields-up. + A surviving non-directory entry keeps its locked posture. A name that + appears between the existence check and mkdir makes the lock fail closed. """ - relative_path = posixpath.join(relative_dir, "sessions") + relative_path = posixpath.join(relative_dir, name) path = context.display(relative_path) try: - os.stat("sessions", dir_fd=parent_fd, follow_symlinks=False) + os.stat(name, dir_fd=parent_fd, follow_symlinks=False) return except FileNotFoundError: pass @@ -1266,22 +1452,22 @@ def _ensure_runtime_carveout( _os_issue( "carveout-create-failed", path, - "check for a sessions carveout", + "check for a writable state subpath", exc, ) ) from exc try: - os.mkdir("sessions", mode=0o700, dir_fd=parent_fd) + os.mkdir(name, mode=0o700, dir_fd=parent_fd) os.fsync(parent_fd) - created = os.stat("sessions", dir_fd=parent_fd, follow_symlinks=False) + created = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) context.budget.observe_entry(path, created) - child_fd = _open_child_dir(parent_fd, "sessions", created) + child_fd = _open_child_dir(parent_fd, name, created) except OSError as exc: raise GuardOperationError( _os_issue( "carveout-create-failed", path, - "create writable sessions carveout", + "create writable state subpath", exc, ) ) from exc @@ -1292,7 +1478,7 @@ def _ensure_runtime_carveout( _os_issue( "metadata-update-failed", path, - "prepare writable sessions carveout", + "prepare writable state subpath", exc, ) ) from exc @@ -1372,8 +1558,8 @@ def _mutate_dir( ) ) from exc try: - if _is_runtime_carveout(relative_path): - # Only the carve-out root has a shields contract. Its + if context.is_writable_root(relative_path): + # Only the writable root has a shields contract. Its # contents remain runtime-owned and may change while this # helper runs, so never chmod/chown/copy descendants. _clear_mutation_flags(child_fd) @@ -1472,10 +1658,17 @@ def _mutate_dir( ) from exc result.removed_entries += 1 - if action == "lock" and _is_runtime_carveout_parent(relative_dir): - _ensure_runtime_carveout( - context, dir_fd, relative_dir, policy, identity, result - ) + if action == "lock": + for writable_child in context.writable_children(relative_dir): + _ensure_writable_subpath( + context, + dir_fd, + relative_dir, + writable_child, + policy, + identity, + result, + ) if action == "unlock": try: @@ -1630,8 +1823,8 @@ def _verify_dir( ) continue try: - if _is_runtime_carveout(relative_path): - carveout_issue = _verify_metadata( + if context.is_writable_root(relative_path): + writable_issue = _verify_metadata( path, os.fstat(child_fd), "directory", @@ -1639,8 +1832,8 @@ def _verify_dir( "unlock", identity, ) - if carveout_issue is not None: - issues.append(carveout_issue) + if writable_issue is not None: + issues.append(writable_issue) else: _verify_dir( context, @@ -1701,6 +1894,7 @@ def _run_guard_unserialized( action: Action, config_dir: str, identity: Identity, + plan: AgentStateLockPlan, ) -> GuardResult: """Run one guard action. ``identity`` is explicit for focused tests.""" @@ -1740,7 +1934,7 @@ def _run_guard_unserialized( if action == "lock": result.removed_entries += _remove_unsupported_root_entries_for_lock( - config_fd, normalized_config, config_st.st_dev + config_fd, normalized_config, config_st.st_dev, plan ) roots, issues = _preflight( @@ -1749,6 +1943,7 @@ def _run_guard_unserialized( config_st.st_dev, deadline, action, + plan, ) result.roots = len(roots) result.issues.extend(issues) @@ -1758,7 +1953,7 @@ def _run_guard_unserialized( # Detect root swaps/creations between preflight and mutation before # changing any metadata. Each root inode is checked again when opened. current_roots, selection_issues = _select_roots( - config_fd, normalized_config, config_st.st_dev + config_fd, normalized_config, config_st.st_dev, plan ) result.issues.extend(selection_issues) expected_root_set = {(root.name, root.dev, root.ino) for root in roots} @@ -1779,6 +1974,7 @@ def _run_guard_unserialized( config_st.st_dev, tuple(root.name for root in roots), WorkBudget(deadline), + plan.writable_subpaths, ) replaced_inodes: dict[str, int] = {} for root in roots: @@ -1822,7 +2018,7 @@ def _run_guard_unserialized( # A second independent descriptor traversal verifies the recursive # result and catches entries changed by a concurrent pre-open FD. verify_roots, selection_issues = _select_roots( - config_fd, normalized_config, config_st.st_dev + config_fd, normalized_config, config_st.st_dev, plan ) result.issues.extend(selection_issues) verify_root_set = {(root.name, root.dev, root.ino) for root in verify_roots} @@ -1947,13 +2143,14 @@ def run_guard( action: Action, config_dir: str, identity: Identity, + plan: AgentStateLockPlan, ) -> GuardResult: """Serialize production OpenClaw recursive transitions with its top guard.""" normalized_config = posixpath.normpath(config_dir) lock_path = _transition_lock_path(normalized_config) if lock_path is None: - return _run_guard_unserialized(action, normalized_config, identity) + return _run_guard_unserialized(action, normalized_config, identity, plan) lock_fd = -1 try: @@ -1966,7 +2163,7 @@ def run_guard( hold_ms = int(os.environ.get("NEMOCLAW_TEST_TRANSACTION_LOCK_HOLD_MS", "0")) if hold_ms > 0: time.sleep(hold_ms / 1000) - return _run_guard_unserialized(action, normalized_config, identity) + return _run_guard_unserialized(action, normalized_config, identity, plan) except GuardOperationError as exc: result = GuardResult(action=action) result.issues.append(exc.issue) @@ -2006,17 +2203,62 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: ) parser.add_argument("action", choices=("preflight", "lock", "unlock")) parser.add_argument("--config-dir", required=True) + plan_source = parser.add_mutually_exclusive_group() + plan_source.add_argument("--plan-json") + plan_source.add_argument("--plan-file") return parser.parse_args(argv) +def _bundled_plan_path() -> str: + helper_dir = os.path.dirname(os.path.realpath(__file__)) + lib_dir = os.path.dirname(helper_dir) + if os.path.basename(helper_dir) != "nemoclaw" or os.path.basename(lib_dir) != "lib": + raise PlanValidationError( + "a plan source is required outside a bundled lib/nemoclaw helper layout" + ) + prefix = os.path.dirname(lib_dir) + return os.path.join(prefix, "share", "nemoclaw", "state-lock-plan.json") + + +def _load_plan(args: argparse.Namespace) -> AgentStateLockPlan: + if args.plan_json is not None: + payload = args.plan_json + if len(payload.encode("utf-8")) > MAX_PLAN_BYTES: + raise PlanValidationError( + f"plan exceeds the {MAX_PLAN_BYTES}-byte input limit" + ) + else: + plan_file = args.plan_file or _bundled_plan_path() + try: + with open(plan_file, "rb") as stream: + raw = stream.read(MAX_PLAN_BYTES + 1) + except OSError as exc: + raise PlanValidationError(f"cannot read plan file: {exc}") from exc + if len(raw) > MAX_PLAN_BYTES: + raise PlanValidationError( + f"plan exceeds the {MAX_PLAN_BYTES}-byte input limit" + ) + try: + payload = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise PlanValidationError("plan file must contain UTF-8 JSON") from exc + return parse_agent_state_lock_plan(payload) + + def main(argv: list[str] | None = None) -> int: args = _parse_args(sys.argv[1:] if argv is None else argv) - if os.geteuid() != 0: + result: GuardResult | None = None + try: + plan = _load_plan(args) + except PlanValidationError as exc: + result = GuardResult(action=args.action) + result.issues.append(Issue("invalid-plan", args.config_dir, str(exc))) + if result is None and os.geteuid() != 0: result = GuardResult(action=args.action) result.issues.append( Issue("root-required", args.config_dir, "state-dir guard must run as root") ) - else: + elif result is None: try: identity = _production_identity() except KeyError as exc: @@ -2029,8 +2271,10 @@ def main(argv: list[str] | None = None) -> int: ) ) else: - result = run_guard(args.action, args.config_dir, identity) + result = run_guard(args.action, args.config_dir, identity, plan) + if result is None: # All branches above assign a result. + raise RuntimeError("state-dir guard did not produce a result") for issue in result.issues: print(json.dumps(issue.as_json(), sort_keys=True, separators=(",", ":"))) print(json.dumps(result.summary_json(), sort_keys=True, separators=(",", ":"))) diff --git a/src/lib/actions/sandbox/channel-status.test-helpers.ts b/src/lib/actions/sandbox/channel-status.test-helpers.ts index fa126c725dc..9c52065fea5 100644 --- a/src/lib/actions/sandbox/channel-status.test-helpers.ts +++ b/src/lib/actions/sandbox/channel-status.test-helpers.ts @@ -79,6 +79,7 @@ function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { configFile: name === "openclaw" ? "openclaw.json" : "config.yaml", envFile: name === "hermes" ? ".env" : null, format: name === "openclaw" ? "json" : "yaml", + shieldsFiles: name === "hermes" ? [".env"] : [], }; }, get inferenceProviderOptions() { diff --git a/src/lib/actions/sandbox/wipe-state.ts b/src/lib/actions/sandbox/wipe-state.ts index cf5b39a5363..5e6591d51aa 100644 --- a/src/lib/actions/sandbox/wipe-state.ts +++ b/src/lib/actions/sandbox/wipe-state.ts @@ -3,7 +3,7 @@ import path from "node:path"; -import { YW, R } from "../../cli/terminal-style"; +import { R, YW } from "../../cli/terminal-style"; import { shellQuote } from "../../core/shell-quote"; import * as registry from "../../state/registry"; @@ -13,6 +13,7 @@ type RunOpenshell = (args: string[], opts?: Record) => RunOpens type AgentStateInfo = { configPaths: { dir: string }; stateDirs: string[]; + stateDirPrefixes: string[]; stateFiles: { path: string }[]; }; @@ -42,9 +43,8 @@ export type WipeSandboxStateDeps = { * resurrects the old workspace files (USER.md, SOUL.md, ...). * - Source boundary: the durable PVC retention is owned upstream by * OpenShell's `sandbox delete` semantics. This wipe is a host-side - * workaround so destroy is the inverse of `backupSandboxState`: it removes - * exactly the set the snapshot/backup path treats as durable state, plus - * the discovered multi-agent `workspace-*` dirs. + * workaround so destroy removes the exact directories, declared directory + * prefixes, and files that the agent contract treats as persistent state. * - Source-fix constraint: making `openshell sandbox delete` purge the PVC * by default is an upstream OpenShell change and would also affect * non-NemoClaw consumers that rely on PVC retention. NemoClaw needs the @@ -52,7 +52,7 @@ export type WipeSandboxStateDeps = { * the sandbox is still live and lets the subsequent `sandbox delete` tear * the pod down. * - Regression test: test/destroy-wipe-sandbox-state.test.ts covers the - * workspace target, the multi-agent glob, the best-effort warn path, the + * workspace target, declared prefix expansion, the best-effort warn path, the * path-escape rejection (state_dirs + state_files), and the contract * assertion that the script targets workspace/ under the config dir with * no `..` segments or quoted absolute path arguments. @@ -178,6 +178,9 @@ export function wipeSandboxState(sandboxName: string, deps: WipeSandboxStateDeps const validStateDirs = agent.stateDirs .map(validateManifestPath) .filter((p): p is string => p !== null); + const validStateDirPrefixes = agent.stateDirPrefixes + .map(validateManifestPath) + .filter((p): p is string => p !== null); const validStateFiles = agent.stateFiles .map((file) => validateManifestPath(file.path)) .filter((p): p is string => p !== null); @@ -185,15 +188,15 @@ export function wipeSandboxState(sandboxName: string, deps: WipeSandboxStateDeps const targets = [ ...validStateDirs.map(shellQuote), ...validStateFiles.map(shellQuote), - // Left unquoted so the sandbox shell expands the multi-agent - // `workspace-` glob (#1260). A no-match leaves the literal token, - // which `rm -rf` silently ignores. - "workspace-*", + // Quote the manifest-derived prefix and leave only the appended wildcard + // unquoted. A no-match leaves the literal token, which `rm -rf` ignores. + ...validStateDirPrefixes.map((prefix) => `${shellQuote(prefix)}*`), ]; - // cd into the config dir first so relative names and the glob resolve there; + // cd into the config dir first so relative names and globs resolve there; // `exit 0` keeps a partially provisioned (dir-absent) sandbox a clean no-op. - const script = `cd ${shellQuote(dir)} 2>/dev/null || exit 0; rm -rf -- ${targets.join(" ")}`; + const wipeCommand = targets.length > 0 ? `rm -rf -- ${targets.join(" ")}` : ":"; + const script = `cd ${shellQuote(dir)} 2>/dev/null || exit 0; ${wipeCommand}`; const result = runOpenshell( ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index 715b6120cf8..e2896065b50 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -21,6 +21,36 @@ export interface AgentConfigPaths { configFile: string; envFile: string | null; format: string; + shieldsFiles: string[]; +} + +export type AgentStateDirectoryShields = "read-only" | "confidential"; + +interface AgentStateDirectoryBehavior { + backup: boolean; + shields?: AgentStateDirectoryShields; +} + +export interface AgentStateDirectoryPath extends AgentStateDirectoryBehavior { + kind: "path"; + path: string; + writableSubpaths: string[]; +} + +export interface AgentStateDirectoryPrefix extends AgentStateDirectoryBehavior { + kind: "prefix"; + prefix: string; +} + +export type AgentStateDirectory = AgentStateDirectoryPath | AgentStateDirectoryPrefix; + +export interface AgentStateLockPlan { + version: 1; + readOnlyRoots: string[]; + confidentialRoots: string[]; + readOnlyPrefixes: string[]; + confidentialPrefixes: string[]; + writableSubpaths: string[]; } export type AgentStateFileStrategy = "copy" | "sqlite_backup"; @@ -119,8 +149,6 @@ export interface AgentDefinition { config?: ManifestRecord; inference?: AgentInference; mcp?: AgentMcpCapability; - state_dirs?: string[]; - runtime_auth_state_dirs?: string[]; state_files?: AgentStateFile[]; user_managed_files?: string[]; _legacy_paths?: StringMap; @@ -135,8 +163,14 @@ export interface AgentDefinition { readonly configPaths: AgentConfigPaths; readonly inferenceProviderOptions: string[]; readonly mcpCapability: AgentMcpCapability; + readonly stateDirectories: AgentStateDirectory[]; readonly stateDirs: string[]; - readonly runtimeAuthStateDirs: string[]; + readonly stateDirPrefixes: string[]; + readonly backupStateDirs: string[]; + readonly backupStateDirPrefixes: string[]; + readonly nonBackupStateDirs: string[]; + readonly nonBackupStateDirPrefixes: string[]; + readonly stateLockPlan: AgentStateLockPlan; readonly stateFiles: AgentStateFile[]; readonly userManagedFiles: string[]; readonly versionCommand: string; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 016dc59f92b..349841cc95b 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -105,6 +105,49 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/YAML object/); }); + it("rejects the superseded runtime auth directory inventory (#8006)", () => { + const agentName = `runtime-auth-inventory-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Runtime Auth Inventory", + "state_dirs:", + " - identity", + "runtime_auth_state_dirs:", + " - identity", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/replaced.*backup: false/); + }); + + it("derives protected configuration files from each agent manifest (#8006)", () => { + expect(loadAgent("hermes").configPaths.shieldsFiles).toEqual([".env"]); + expect(loadAgent("openclaw").configPaths.shieldsFiles).toEqual([]); + expect(loadAgent("langchain-deepagents-code").configPaths.shieldsFiles).toEqual([]); + }); + + it.each([ + ["a scalar", " shields_files: .env"], + ["a non-string entry", " shields_files:\n - 42"], + ])("rejects config.shields_files with %s", (_case, declaration) => { + const agentName = `invalid-shields-files-${String(Date.now())}-${_case.replaceAll(" ", "-")}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Invalid Shields Files", + "config:", + " dir: /sandbox/.invalid", + " config_file: config.json", + declaration, + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/config\.shields_files/); + }); + it("rejects invalid forward_ports values in manifests", () => { for (const port of [1023, 70000]) { const agentName = `invalid-forward-port-${String(port)}-${String(Date.now())}`; diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index 02d66401598..6ed3033d3b8 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -23,12 +23,15 @@ import type { AgentHealthProbe, AgentLegacyPaths, AgentMcpCapability, + AgentStateDirectory, AgentStateFile, + AgentStateLockPlan, AgentVersionScheme, } from "./definition-types"; import { loadManifestRecord, readBoolean, + readConfigShieldsFiles, readDashboard, readHealthProbe, readInference, @@ -43,6 +46,12 @@ import { readVersionScheme, } from "./manifest-readers"; import { type AgentRuntime, readAgentRuntime } from "./runtime-manifest"; +import { + buildStateLockPlan, + readStateDirectories, + stateDirectoryPaths, + stateDirectoryPrefixes, +} from "./state-directory-contract"; import { type AgentWebAuth, readWebAuth } from "./web-auth"; export type { @@ -57,8 +66,13 @@ export type { AgentMcpAdapter, AgentMcpCapability, AgentMcpSupport, + AgentStateDirectory, + AgentStateDirectoryPath, + AgentStateDirectoryPrefix, + AgentStateDirectoryShields, AgentStateFile, AgentStateFileStrategy, + AgentStateLockPlan, AgentVersionScheme, StateFileFreshHeader, StateFileKeyAllowlistRestoreOwnership, @@ -153,17 +167,22 @@ export function loadAgent(name: string): AgentDefinition { const webAuth = readWebAuth(raw); const healthProbe = readHealthProbe(raw); const config = readObject(raw, "config"); + const configShieldsFiles = readConfigShieldsFiles(config); const inference = readInference(raw); const mcp = readMcpCapability(raw); - const stateDirs = readStringArray(raw, "state_dirs"); - const runtimeAuthStateDirs = readStringArray(raw, "runtime_auth_state_dirs"); - for (const dir of runtimeAuthStateDirs ?? []) { - if (!stateDirs?.includes(dir)) { - throw new Error( - `Agent manifest field 'runtime_auth_state_dirs' entry '${dir}' must also be listed in 'state_dirs'`, - ); - } + if (raw.runtime_auth_state_dirs !== undefined) { + throw new Error( + "Agent manifest field 'runtime_auth_state_dirs' was replaced by state_dirs entries with backup: false", + ); } + const stateDirectories = readStateDirectories(raw); + const stateDirs = stateDirectoryPaths(stateDirectories); + const stateDirPrefixes = stateDirectoryPrefixes(stateDirectories); + const backupStateDirs = stateDirectoryPaths(stateDirectories, { backup: true }); + const backupStateDirPrefixes = stateDirectoryPrefixes(stateDirectories, { backup: true }); + const nonBackupStateDirs = stateDirectoryPaths(stateDirectories, { backup: false }); + const nonBackupStateDirPrefixes = stateDirectoryPrefixes(stateDirectories, { backup: false }); + const stateLockPlan = buildStateLockPlan(stateDirectories); const stateFiles = readStateFiles(raw); const userManagedFiles = readUserManagedFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); @@ -188,8 +207,6 @@ export function loadAgent(name: string): AgentDefinition { config, inference, mcp, - state_dirs: stateDirs, - runtime_auth_state_dirs: runtimeAuthStateDirs, state_files: stateFiles, user_managed_files: userManagedFiles, _legacy_paths: legacyPathConfig, @@ -238,6 +255,7 @@ export function loadAgent(name: string): AgentDefinition { configFile: readString(config ?? {}, "config_file") ?? "openclaw.json", envFile: readString(config ?? {}, "env_file") ?? null, format: readString(config ?? {}, "format") ?? "json", + shieldsFiles: configShieldsFiles, }; }, @@ -249,12 +267,36 @@ export function loadAgent(name: string): AgentDefinition { return mcp; }, + get stateDirectories(): AgentStateDirectory[] { + return stateDirectories; + }, + get stateDirs(): string[] { - return stateDirs ?? []; + return stateDirs; + }, + + get stateDirPrefixes(): string[] { + return stateDirPrefixes; + }, + + get backupStateDirs(): string[] { + return backupStateDirs; + }, + + get backupStateDirPrefixes(): string[] { + return backupStateDirPrefixes; + }, + + get nonBackupStateDirs(): string[] { + return nonBackupStateDirs; + }, + + get nonBackupStateDirPrefixes(): string[] { + return nonBackupStateDirPrefixes; }, - get runtimeAuthStateDirs(): string[] { - return runtimeAuthStateDirs ?? []; + get stateLockPlan(): AgentStateLockPlan { + return stateLockPlan; }, get stateFiles(): AgentStateFile[] { diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index 469e96446cd..2cad88be1cf 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -23,14 +23,28 @@ export function makeAgent(overrides: Partial = {}): AgentDefini configFile: "/tmp/agent/config.yaml", envFile: null, format: "yaml", + shieldsFiles: [], }, inferenceProviderOptions: [], mcpCapability: { support: "disabled", reason: "test fixture", }, + stateDirectories: [], stateDirs: [], - runtimeAuthStateDirs: [], + stateDirPrefixes: [], + backupStateDirs: [], + backupStateDirPrefixes: [], + nonBackupStateDirs: [], + nonBackupStateDirPrefixes: [], + stateLockPlan: { + version: 1, + readOnlyRoots: [], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", @@ -63,5 +77,6 @@ export const hermesAgent = makeAgent({ configFile: "/sandbox/.hermes/config.yaml", envFile: "/sandbox/.hermes/.env", format: "yaml", + shieldsFiles: [".env"], }, }); diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index bb181d12db6..8d284a4876b 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -63,6 +63,22 @@ export function readStringArray(record: ManifestRecord, key: string): string[] | return value.filter((entry): entry is string => typeof entry === "string"); } +export function readConfigShieldsFiles(config: ManifestRecord | undefined): string[] { + const value = config?.shields_files; + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error("Agent manifest field 'config.shields_files' must be an array"); + } + return value.map((entry, index) => { + if (typeof entry !== "string") { + throw new Error( + `Agent manifest field 'config.shields_files[${String(index)}]' must be a string`, + ); + } + return entry; + }); +} + const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; const STATE_FILE_FIELDS = new Set(["path", "strategy", "restore"]); diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 120188c8853..ccf6fa7638c 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -25,14 +25,28 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { configFile: "/tmp/agent/config.yaml", envFile: null, format: "yaml", + shieldsFiles: [], }, inferenceProviderOptions: [], mcpCapability: { support: "disabled", reason: "test fixture", }, + stateDirectories: [], stateDirs: [], - runtimeAuthStateDirs: [], + stateDirPrefixes: [], + backupStateDirs: [], + backupStateDirPrefixes: [], + nonBackupStateDirs: [], + nonBackupStateDirPrefixes: [], + stateLockPlan: { + version: 1, + readOnlyRoots: [], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, stateFiles: [], userManagedFiles: [], versionCommand: "agent --version", diff --git a/src/lib/agent/runtime-auth-state-dirs.test.ts b/src/lib/agent/runtime-auth-state-dirs.test.ts deleted file mode 100644 index 9f4b3413ec1..00000000000 --- a/src/lib/agent/runtime-auth-state-dirs.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { AGENTS_DIR, loadAgent } from "./defs"; - -const tempAgentDirs: string[] = []; - -function writeTempAgentManifest(name: string, contents: string): void { - const agentDir = path.join(AGENTS_DIR, name); - tempAgentDirs.push(agentDir); - fs.mkdirSync(agentDir, { recursive: true }); - fs.writeFileSync(path.join(agentDir, "manifest.yaml"), contents); -} - -afterEach(() => { - for (const agentDir of tempAgentDirs.splice(0)) { - fs.rmSync(agentDir, { recursive: true, force: true }); - } -}); - -describe("runtime_auth_state_dirs manifest field (#6852)", () => { - it("parses runtime_auth_state_dirs as a subset of state_dirs", () => { - const agentName = `runtime-auth-parse-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "display_name: RuntimeAuth", - "state_dirs:", - " - agents", - " - identity", - " - devices", - "runtime_auth_state_dirs:", - " - identity", - " - devices", - ].join("\n"), - ); - - const agent = loadAgent(agentName); - expect(agent.stateDirs).toEqual(["agents", "identity", "devices"]); - expect(agent.runtimeAuthStateDirs).toEqual(["identity", "devices"]); - }); - - it("defaults to no runtime auth dirs when the field is absent", () => { - const agentName = `runtime-auth-absent-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [`name: ${agentName}`, "display_name: RuntimeAuth", "state_dirs:", " - agents"].join("\n"), - ); - - expect(loadAgent(agentName).runtimeAuthStateDirs).toEqual([]); - }); - - it("rejects a runtime auth dir that is not also a state dir", () => { - const agentName = `runtime-auth-orphan-${String(Date.now())}`; - writeTempAgentManifest( - agentName, - [ - `name: ${agentName}`, - "display_name: RuntimeAuth", - "state_dirs:", - " - agents", - "runtime_auth_state_dirs:", - " - identity", - ].join("\n"), - ); - - expect(() => loadAgent(agentName)).toThrow( - /runtime_auth_state_dirs.*'identity'.*must also be listed in 'state_dirs'/, - ); - }); - - it("declares OpenClaw device identity and paired-device state as runtime auth dirs", () => { - const agent = loadAgent("openclaw"); - expect(agent.runtimeAuthStateDirs).toEqual(["identity", "devices"]); - // Still wiped on destroy: the dirs must remain declared durable state. - expect(agent.stateDirs).toEqual(expect.arrayContaining(["identity", "devices"])); - }); -}); diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 4b4da8dcaf3..32d18daf6aa 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -21,11 +21,25 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { configFile: "/tmp/agent/config.yaml", envFile: null, format: "yaml", + shieldsFiles: [], }, inferenceProviderOptions: [], mcpCapability: { support: "disabled", reason: "test fixture" }, + stateDirectories: [], stateDirs: [], - runtimeAuthStateDirs: [], + stateDirPrefixes: [], + backupStateDirs: [], + backupStateDirPrefixes: [], + nonBackupStateDirs: [], + nonBackupStateDirPrefixes: [], + stateLockPlan: { + version: 1, + readOnlyRoots: [], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", @@ -58,6 +72,7 @@ const hermesAgent = makeAgent({ configFile: "/sandbox/.hermes/config.yaml", envFile: "/sandbox/.hermes/.env", format: "yaml", + shieldsFiles: [".env"], }, }); diff --git a/src/lib/agent/state-directory-contract.test.ts b/src/lib/agent/state-directory-contract.test.ts new file mode 100644 index 00000000000..b28d1ae465d --- /dev/null +++ b/src/lib/agent/state-directory-contract.test.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { loadAgent } from "./defs"; +import { + buildStateLockPlan, + readStateDirectories, + stateDirectoryPaths, + stateDirectoryPrefixes, +} from "./state-directory-contract"; + +describe("agent state directory contract", () => { + it("derives independent backup and Shields projections from one declaration (#8006)", () => { + const directories = readStateDirectories({ + state_dirs: [ + "history", + { path: "identity", backup: false, shields: "confidential" }, + { + path: "agents", + shields: "read-only", + writable_subpaths: ["*/sessions"], + }, + { prefix: "agents-", backup: false, shields: "read-only" }, + ], + }); + + expect(stateDirectoryPaths(directories)).toEqual(["history", "identity", "agents"]); + expect(stateDirectoryPaths(directories, { backup: true })).toEqual(["history", "agents"]); + expect(stateDirectoryPaths(directories, { backup: false })).toEqual(["identity"]); + expect(stateDirectoryPrefixes(directories)).toEqual(["agents-"]); + expect(stateDirectoryPrefixes(directories, { backup: false })).toEqual(["agents-"]); + expect(buildStateLockPlan(directories)).toEqual({ + version: 1, + readOnlyRoots: ["agents"], + confidentialRoots: ["identity"], + readOnlyPrefixes: ["agents-"], + confidentialPrefixes: [], + writableSubpaths: ["agents/*/sessions"], + }); + }); + + it("keeps OpenClaw machine-local authentication state out of snapshots (#6852)", () => { + const agent = loadAgent("openclaw"); + + expect(agent.nonBackupStateDirs).toEqual(["plugins", "identity", "devices"]); + expect(agent.backupStateDirs).not.toEqual( + expect.arrayContaining(["plugins", "identity", "devices"]), + ); + expect(agent.stateDirs).toEqual(expect.arrayContaining(["plugins", "identity", "devices"])); + }); + + it("preserves the existing Hermes hooks lock without adding it to snapshots (#8006)", () => { + const agent = loadAgent("hermes"); + + expect(agent.stateLockPlan.readOnlyRoots).toContain("hooks"); + expect(agent.nonBackupStateDirs).toContain("hooks"); + }); + + it("normalizes nested DCode declarations to protected top-level roots (#8006)", () => { + expect(loadAgent("langchain-deepagents-code").stateLockPlan).toEqual({ + version: 1, + readOnlyRoots: ["agent", "skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }); + }); + + it.each([ + { + agentName: "openclaw", + expectedPlan: { + version: 1, + readOnlyRoots: [ + "agents", + "canvas", + "cron", + "devices", + "extensions", + "hooks", + "memory", + "plugins", + "skills", + "telegram", + "wechat", + "whatsapp", + "workspace", + ], + confidentialRoots: ["credentials", "identity"], + readOnlyPrefixes: ["workspace-"], + confidentialPrefixes: [], + writableSubpaths: ["agents/*/sessions"], + }, + expectedMutablePaths: [], + }, + { + agentName: "hermes", + expectedPlan: { + version: 1, + readOnlyRoots: [ + "cron", + "hooks", + "platforms", + "plugins", + "profiles", + "skills", + "skins", + "weixin", + "workspace", + ], + confidentialRoots: ["pairing"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + expectedMutablePaths: ["memories", "sessions", "logs", "plans", "cache", "dashboard-home"], + }, + ])("pins the complete shipped $agentName Shields boundary (#8006)", (testCase) => { + const agent = loadAgent(testCase.agentName); + + expect(agent.stateLockPlan).toEqual(testCase.expectedPlan); + expect( + agent.stateDirectories.flatMap((entry) => + entry.kind === "path" && entry.shields === undefined ? [entry.path] : [], + ), + ).toEqual(testCase.expectedMutablePaths); + }); + + // source-shape-contract: security -- Generated image plans must match the reviewed AgentDefinition projection + it("keeps generated image plans equal to their AgentDefinition projections (#8006)", () => { + for (const agentName of ["openclaw", "hermes"]) { + const generated = JSON.parse( + fs.readFileSync( + path.join(process.cwd(), "agents", agentName, "state-lock-plan.json"), + "utf8", + ), + ) as Record; + const { $comment, ...plan } = generated; + + expect(typeof $comment).toBe("string"); + expect(plan).toEqual(loadAgent(agentName).stateLockPlan); + } + }); + + it.each([ + [{ state_dirs: "state" }, /state_dirs.*array/], + [{ state_dirs: ["../state"] }, /canonical relative path/], + [{ state_dirs: [{ path: "/state" }] }, /relative path/], + [{ state_dirs: [{ path: "state", prefix: "state-" }] }, /exactly one/], + [{ state_dirs: [{ path: "state", unknown: true }] }, /unknown.*not allowed/], + [{ state_dirs: [{ path: "state", backup: "yes" }] }, /backup.*boolean/], + [ + { state_dirs: [{ path: "state", writable_subpaths: ["runtime"] }] }, + /requires shields: read-only/, + ], + [ + { + state_dirs: [{ path: "state", shields: "read-only", writable_subpaths: ["run*"] }], + }, + /complete path component/, + ], + [ + { + state_dirs: [{ path: "state", shields: "read-only", writable_subpaths: ["runtime/*"] }], + }, + /literal directory name/, + ], + [{ state_dirs: ["state", "state"] }, /repeats path:state/], + [{ state_dirs: [{ path: "state" }, { prefix: "other-" }] }, /must extend a declared/], + ])("rejects an invalid state declaration %# (#8006)", (record, expected) => { + expect(() => readStateDirectories(record)).toThrow(expected); + }); + + it("rejects conflicting Shields policies for one top-level root (#8006)", () => { + const directories = readStateDirectories({ + state_dirs: [ + { path: "agent/skills", shields: "read-only" }, + { path: "agent/secrets", shields: "confidential" }, + ], + }); + + expect(() => buildStateLockPlan(directories)).toThrow(/root 'agent'.*conflicting/); + }); + + it.each([ + [[{ path: "state dir", shields: "read-only" }], /root 'state dir'.*cannot be represented/], + [ + [ + { path: "workspace", shields: "read-only" }, + { prefix: "workspace-", shields: "read-only" }, + { path: "workspace-dev", backup: false }, + ], + /prefix 'workspace-'.*overlaps exact path 'workspace-dev'/, + ], + [ + [ + { + path: "agents", + shields: "read-only", + writable_subpaths: ["*/sessions", "main/sessions"], + }, + ], + /writable subpaths.*overlap/, + ], + ])("rejects a state plan the runtime helper cannot consume %# (#8006)", (stateDirs, expected) => { + expect(() => buildStateLockPlan(readStateDirectories({ state_dirs: stateDirs }))).toThrow( + expected, + ); + }); +}); diff --git a/src/lib/agent/state-directory-contract.ts b/src/lib/agent/state-directory-contract.ts new file mode 100644 index 00000000000..74eacc6d503 --- /dev/null +++ b/src/lib/agent/state-directory-contract.ts @@ -0,0 +1,308 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + AgentStateDirectory, + AgentStateDirectoryPath, + AgentStateDirectoryShields, + AgentStateLockPlan, +} from "./definition-types"; + +type UnknownRecord = Record; + +const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; +const STATE_DIRECTORY_FIELDS = new Set([ + "path", + "prefix", + "backup", + "shields", + "writable_subpaths", +]); +const SAFE_LOCK_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertCanonicalPath(value: string, field: string, allowWildcards = false): void { + if (value.length === 0) { + throw new Error(`Agent manifest field '${field}' must not be empty`); + } + if (CONTROL_CHAR_RE.test(value)) { + throw new Error(`Agent manifest field '${field}' must not contain control characters`); + } + if (value.startsWith("/")) { + throw new Error(`Agent manifest field '${field}' must be a relative path, not absolute`); + } + if (value.includes("\\")) { + throw new Error(`Agent manifest field '${field}' must use canonical forward slashes`); + } + const segments = value.split("/"); + if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error( + `Agent manifest field '${field}' must be a canonical relative path without empty, '.', or '..' components`, + ); + } + if (segments.some((segment) => segment.includes("*") && (!allowWildcards || segment !== "*"))) { + throw new Error( + `Agent manifest field '${field}' may use '*' only as a complete path component`, + ); + } +} + +function readShields(value: unknown, field: string): AgentStateDirectoryShields | undefined { + if (value === undefined) return undefined; + if (value === "read-only" || value === "confidential") return value; + throw new Error(`Agent manifest field '${field}' must be read-only or confidential`); +} + +function readWritableSubpaths(value: unknown, field: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error(`Agent manifest field '${field}' must be an array`); + } + const entries = value.map((entry, index) => { + const entryField = `${field}[${String(index)}]`; + if (typeof entry !== "string") { + throw new Error(`Agent manifest field '${entryField}' must be a string`); + } + assertCanonicalPath(entry, entryField, true); + if (entry.endsWith("/*")) { + throw new Error( + `Agent manifest field '${entryField}' must end with a literal directory name`, + ); + } + return entry; + }); + if (new Set(entries).size !== entries.length) { + throw new Error(`Agent manifest field '${field}' must not contain duplicates`); + } + return entries; +} + +function readStateDirectory(entry: unknown, index: number): AgentStateDirectory { + const field = `state_dirs[${String(index)}]`; + if (typeof entry === "string") { + assertCanonicalPath(entry, field); + return { kind: "path", path: entry, backup: true, writableSubpaths: [] }; + } + if (!isRecord(entry)) { + throw new Error(`Agent manifest field '${field}' must be a string or object`); + } + for (const key of Object.keys(entry)) { + if (!STATE_DIRECTORY_FIELDS.has(key)) { + throw new Error(`Agent manifest field '${field}.${key}' is not allowed`); + } + } + const path = entry.path; + const prefix = entry.prefix; + const hasPath = Object.hasOwn(entry, "path"); + const hasPrefix = Object.hasOwn(entry, "prefix"); + if (hasPath === hasPrefix) { + throw new Error(`Agent manifest field '${field}' must declare exactly one of path or prefix`); + } + if (entry.backup !== undefined && typeof entry.backup !== "boolean") { + throw new Error(`Agent manifest field '${field}.backup' must be a boolean`); + } + const backup = entry.backup !== false; + const shields = readShields(entry.shields, `${field}.shields`); + if (hasPath) { + if (typeof path !== "string") { + throw new Error(`Agent manifest field '${field}.path' must be a string`); + } + assertCanonicalPath(path, `${field}.path`); + const writableSubpaths = readWritableSubpaths( + entry.writable_subpaths, + `${field}.writable_subpaths`, + ); + if (writableSubpaths.length > 0 && shields !== "read-only") { + throw new Error( + `Agent manifest field '${field}.writable_subpaths' requires shields: read-only`, + ); + } + return { + kind: "path", + path, + backup, + ...(shields ? { shields } : {}), + writableSubpaths, + }; + } + if (entry.writable_subpaths !== undefined) { + throw new Error(`Agent manifest field '${field}.writable_subpaths' requires path`); + } + if (typeof prefix !== "string" || !SAFE_LOCK_NAME_RE.test(prefix)) { + throw new Error( + `Agent manifest field '${field}.prefix' must contain only letters, digits, '.', '_', or '-'`, + ); + } + return { + kind: "prefix", + prefix, + backup, + ...(shields ? { shields } : {}), + }; +} + +export function readStateDirectories(record: UnknownRecord): AgentStateDirectory[] { + const value = record.state_dirs; + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new Error("Agent manifest field 'state_dirs' must be an array"); + } + const directories = value.map(readStateDirectory); + const seen = new Set(); + for (const directory of directories) { + const key = directory.kind === "path" ? `path:${directory.path}` : `prefix:${directory.prefix}`; + if (seen.has(key)) { + throw new Error(`Agent manifest field 'state_dirs' repeats ${key}`); + } + seen.add(key); + } + const declaredPaths = new Set( + directories.filter((entry) => entry.kind === "path").map((entry) => entry.path), + ); + for (const directory of directories) { + if (directory.kind !== "prefix") continue; + const sibling = directory.prefix.slice(0, -1); + if (!directory.prefix.endsWith("-") || sibling.includes("/") || !declaredPaths.has(sibling)) { + throw new Error( + `Agent manifest field 'state_dirs' prefix '${directory.prefix}' must extend a declared top-level path with '-'`, + ); + } + const overlappingPath = directories.find( + (entry) => entry.kind === "path" && topLevelPath(entry.path).startsWith(directory.prefix), + ); + if (overlappingPath?.kind === "path") { + throw new Error( + `Agent manifest field 'state_dirs' prefix '${directory.prefix}' overlaps exact path '${overlappingPath.path}'`, + ); + } + } + return directories; +} + +function addPolicyRoot( + roots: Map, + root: string, + shields: AgentStateDirectoryShields, +): void { + const existing = roots.get(root); + if (existing && existing !== shields) { + throw new Error(`Agent state directory root '${root}' has conflicting Shields declarations`); + } + roots.set(root, shields); +} + +function topLevelPath(value: string): string { + const separator = value.indexOf("/"); + return separator === -1 ? value : value.slice(0, separator); +} + +function writablePatternsOverlap(first: string, second: string): boolean { + const firstComponents = first.split("/"); + const secondComponents = second.split("/"); + const sharedLength = Math.min(firstComponents.length, secondComponents.length); + for (let index = 0; index < sharedLength; index += 1) { + const left = firstComponents[index]; + const right = secondComponents[index]; + if (left !== "*" && right !== "*" && left !== right) return false; + } + return true; +} + +function validateStateLockPlan(plan: AgentStateLockPlan): void { + const roots = [...plan.readOnlyRoots, ...plan.confidentialRoots]; + const prefixes = [...plan.readOnlyPrefixes, ...plan.confidentialPrefixes]; + for (const root of roots) { + if (!SAFE_LOCK_NAME_RE.test(root)) { + throw new Error( + `Agent state directory root '${root}' cannot be represented by the Shields helper`, + ); + } + const matchingPrefix = prefixes.find((prefix) => root.startsWith(prefix)); + if (matchingPrefix) { + throw new Error( + `Agent state directory root '${root}' overlaps Shields prefix '${matchingPrefix}'`, + ); + } + } + for (let index = 0; index < prefixes.length; index += 1) { + const prefix = prefixes[index]; + for (const other of prefixes.slice(index + 1)) { + if (prefix.startsWith(other) || other.startsWith(prefix)) { + throw new Error(`Agent state directory prefixes '${prefix}' and '${other}' overlap`); + } + } + } + for (let index = 0; index < plan.writableSubpaths.length; index += 1) { + const writable = plan.writableSubpaths[index]; + for (const other of plan.writableSubpaths.slice(index + 1)) { + if (writablePatternsOverlap(writable, other)) { + throw new Error( + `Agent state directory writable subpaths '${writable}' and '${other}' overlap`, + ); + } + } + } +} + +export function buildStateLockPlan( + directories: readonly AgentStateDirectory[], +): AgentStateLockPlan { + const roots = new Map(); + const prefixes = new Map(); + const writableSubpaths: string[] = []; + for (const directory of directories) { + if (directory.kind === "prefix") { + if (directory.shields) addPolicyRoot(prefixes, directory.prefix, directory.shields); + continue; + } + if (directory.shields) { + addPolicyRoot(roots, topLevelPath(directory.path), directory.shields); + } + for (const subpath of directory.writableSubpaths) { + writableSubpaths.push(`${directory.path}/${subpath}`); + } + } + + const select = ( + values: Map, + policy: AgentStateDirectoryShields, + ): string[] => + [...values] + .filter(([, value]) => value === policy) + .map(([key]) => key) + .sort(); + + const plan: AgentStateLockPlan = { + version: 1, + readOnlyRoots: select(roots, "read-only"), + confidentialRoots: select(roots, "confidential"), + readOnlyPrefixes: select(prefixes, "read-only"), + confidentialPrefixes: select(prefixes, "confidential"), + writableSubpaths: [...new Set(writableSubpaths)].sort(), + }; + validateStateLockPlan(plan); + return plan; +} + +export function stateDirectoryPaths( + directories: readonly AgentStateDirectory[], + options: { backup?: boolean } = {}, +): string[] { + return directories + .filter((entry): entry is AgentStateDirectoryPath => entry.kind === "path") + .filter((entry) => options.backup === undefined || entry.backup === options.backup) + .map((entry) => entry.path); +} + +export function stateDirectoryPrefixes( + directories: readonly AgentStateDirectory[], + options: { backup?: boolean } = {}, +): string[] { + return directories + .filter((entry) => entry.kind === "prefix") + .filter((entry) => options.backup === undefined || entry.backup === options.backup) + .map((entry) => entry.prefix); +} diff --git a/src/lib/onboard/verify-channel-runtime.test.ts b/src/lib/onboard/verify-channel-runtime.test.ts index 4be433d45bd..a53b5f46973 100644 --- a/src/lib/onboard/verify-channel-runtime.test.ts +++ b/src/lib/onboard/verify-channel-runtime.test.ts @@ -13,6 +13,7 @@ function fakeAgent(format: string | null) { configFile: "openclaw.json", envFile: null, format, + shieldsFiles: [], }, } as unknown as Parameters[0]; } diff --git a/src/lib/sandbox/agent-config.test.ts b/src/lib/sandbox/agent-config.test.ts index d9c42847a62..316765e92ac 100644 --- a/src/lib/sandbox/agent-config.test.ts +++ b/src/lib/sandbox/agent-config.test.ts @@ -2,11 +2,30 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; -import { - type AgentConfigDependencies, - DEFAULT_AGENT_CONFIG, - resolveAgentConfig, -} from "./agent-config"; +import type { AgentStateLockPlan } from "../agent/definition-types"; +import { type AgentConfigDependencies, resolveAgentConfig } from "./agent-config"; + +const PLAN: AgentStateLockPlan = { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], +}; + +function openClawAgent() { + return { + configPaths: { + dir: "/sandbox/.openclaw", + configFile: "openclaw.json", + envFile: null, + format: "json", + shieldsFiles: [], + }, + stateLockPlan: PLAN, + }; +} function dependencies(overrides: Partial = {}): AgentConfigDependencies { return { @@ -19,16 +38,19 @@ function dependencies(overrides: Partial = {}): AgentCo } describe("agent config resolution", () => { - it("uses the legacy OpenClaw target when no agent is registered", () => { - expect(resolveAgentConfig("alpha", dependencies())).toEqual(DEFAULT_AGENT_CONFIG); - expect( - resolveAgentConfig( - "alpha", - dependencies({ - getSandbox: vi.fn(() => ({})), - }), - ), - ).toEqual(DEFAULT_AGENT_CONFIG); + it("loads the OpenClaw contract when no agent is registered", () => { + const loadAgent = vi.fn(() => openClawAgent()); + + expect(resolveAgentConfig("alpha", dependencies({ loadAgent }))).toEqual({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + format: "json", + configFile: "openclaw.json", + sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: PLAN, + }); + expect(loadAgent).toHaveBeenCalledWith("openclaw"); }); it("propagates a registered agent load failure", () => { @@ -42,11 +64,68 @@ describe("agent config resolution", () => { expect(() => resolveAgentConfig("alpha", deps)).toThrow("Hermes manifest is invalid"); }); + it.each([ + [ + "a config directory outside /sandbox", + { dir: "/etc", configFile: "config.yaml", envFile: ".env", shieldsFiles: [] }, + /canonical absolute path below \/sandbox\//, + ], + [ + "the shared sandbox root as a config directory", + { dir: "/sandbox/", configFile: "config.yaml", envFile: ".env", shieldsFiles: [] }, + /canonical absolute path below \/sandbox\//, + ], + [ + "a traversing config file", + { + dir: "/sandbox/.hermes", + configFile: "../config.yaml", + envFile: ".env", + shieldsFiles: [], + }, + /config_file.*canonical relative path/, + ], + [ + "a traversing sensitive env file", + { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + envFile: "../../host.env", + shieldsFiles: [], + }, + /env_file.*canonical relative path/, + ], + [ + "an empty sensitive env file", + { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + envFile: "", + shieldsFiles: [], + }, + /env_file.*canonical relative path/, + ], + ])("rejects %s before constructing privileged paths", (_case, configPaths, expected) => { + const deps = dependencies({ + getSandbox: vi.fn(() => ({ agent: "hermes" })), + loadAgent: vi.fn(() => ({ configPaths, stateLockPlan: PLAN })), + }); + + expect(() => resolveAgentConfig("alpha", deps)).toThrow(expected); + }); + it("resolves Hermes config paths and sensitive files", () => { const deps = dependencies({ getSandbox: vi.fn(() => ({ agent: "hermes" })), loadAgent: vi.fn(() => ({ - configPaths: { dir: "/sandbox/.hermes", configFile: "config.yaml", format: "yaml" }, + configPaths: { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + envFile: ".secrets", + format: "yaml", + shieldsFiles: [".secrets"], + }, + stateLockPlan: PLAN, })), }); @@ -56,7 +135,92 @@ describe("agent config resolution", () => { configDir: "/sandbox/.hermes", format: "yaml", configFile: "config.yaml", - sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.secrets"], + stateLockPlan: PLAN, }); }); + + it("does not require an optional environment file unless the manifest protects it", () => { + const deps = dependencies({ + getSandbox: vi.fn(() => ({ agent: "langchain-deepagents-code" })), + loadAgent: vi.fn(() => ({ + configPaths: { + dir: "/sandbox/.deepagents", + configFile: "config.toml", + envFile: ".env", + format: "toml", + shieldsFiles: [], + }, + stateLockPlan: PLAN, + })), + }); + + expect(resolveAgentConfig("alpha", deps).sensitiveFiles).toEqual([ + "/sandbox/.deepagents/.config-hash", + ]); + }); + + it.each([ + ["a traversing Shields file", ["../secrets"], /shields_files\[0\].*canonical relative path/], + ["an absolute Shields file", ["/etc/shadow"], /shields_files\[0\].*canonical relative path/], + ["a control character in a Shields file", ["secret\0file"], /canonical relative path/], + ["the primary config file", ["config.yaml"], /duplicates a protected config file/], + ["the config hash twice", [".config-hash"], /duplicates a protected config file/], + ["a repeated Shields file", [".env", ".env"], /duplicates a protected config file/], + ])("rejects %s", (_case, shieldsFiles, expected) => { + const deps = dependencies({ + getSandbox: vi.fn(() => ({ agent: "hermes" })), + loadAgent: vi.fn(() => ({ + configPaths: { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + envFile: ".env", + format: "yaml", + shieldsFiles, + }, + stateLockPlan: PLAN, + })), + }); + + expect(() => resolveAgentConfig("alpha", deps)).toThrow(expected); + }); + + it.each([ + ["a missing config.shields_files declaration", undefined], + ["a non-string config.shields_files declaration", [42]], + ])("fails closed for %s", (_case, shieldsFiles) => { + const deps = dependencies({ + getSandbox: vi.fn(() => ({ agent: "hermes" })), + loadAgent: vi.fn(() => ({ + configPaths: { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + envFile: ".env", + format: "yaml", + shieldsFiles: shieldsFiles as unknown as string[], + }, + stateLockPlan: PLAN, + })), + }); + + expect(() => resolveAgentConfig("alpha", deps)).toThrow(/config\.shields_files.*string array/); + }); + + it("rejects protected top-level files for agents without a descriptor-safe transaction", () => { + const deps = dependencies({ + getSandbox: vi.fn(() => ({ agent: "langchain-deepagents-code" })), + loadAgent: vi.fn(() => ({ + configPaths: { + dir: "/sandbox/.deepagents", + configFile: "config.toml", + envFile: ".env", + format: "toml", + shieldsFiles: [".env"], + }, + stateLockPlan: PLAN, + })), + }); + + expect(() => resolveAgentConfig("alpha", deps)).toThrow(/supported only for Hermes/); + }); }); diff --git a/src/lib/sandbox/agent-config.ts b/src/lib/sandbox/agent-config.ts index a29770c2e8a..02e09614341 100644 --- a/src/lib/sandbox/agent-config.ts +++ b/src/lib/sandbox/agent-config.ts @@ -1,6 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; +import type { AgentStateLockPlan } from "../agent/definition-types"; + +const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; +const SANDBOX_CONFIG_ROOT = "/sandbox/"; + export interface AgentConfigTarget { agentName: string; configPath: string; @@ -8,12 +14,20 @@ export interface AgentConfigTarget { format: string; configFile: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; } export interface AgentConfigDependencies { getSandbox: (name: string) => { agent?: string } | null; loadAgent: (name: string) => { - configPaths: { dir: string; configFile: string; format?: string }; + configPaths: { + dir: string; + configFile: string; + envFile?: string | null; + format?: string; + shieldsFiles: readonly string[]; + }; + stateLockPlan: AgentStateLockPlan; }; } @@ -32,26 +46,83 @@ function defaultDependencies(): AgentConfigDependencies { return { getSandbox: registry.getSandbox, loadAgent: agentDefs.loadAgent }; } +function requireCanonicalConfigDir(value: string): string { + if ( + !path.posix.isAbsolute(value) || + path.posix.normalize(value) !== value || + CONTROL_CHAR_RE.test(value) || + value.includes("\\") || + value === SANDBOX_CONFIG_ROOT || + !value.startsWith(SANDBOX_CONFIG_ROOT) + ) { + throw new Error( + `Agent config directory ${JSON.stringify(value)} must be a canonical absolute path below ${SANDBOX_CONFIG_ROOT}`, + ); + } + return value; +} + +function resolveConfigFile(configDir: string, value: string, field: string): string { + const components = value.split("/"); + if ( + value.length === 0 || + path.posix.isAbsolute(value) || + CONTROL_CHAR_RE.test(value) || + value.includes("\\") || + components.some((component) => component === "" || component === "." || component === "..") + ) { + throw new Error(`Agent config field '${field}' must be a canonical relative path`); + } + const resolved = path.posix.resolve(configDir, value); + if (!resolved.startsWith(`${configDir}/`)) { + throw new Error(`Agent config field '${field}' must stay below '${configDir}'`); + } + return resolved; +} + export function resolveAgentConfig( sandboxName: string, dependencies: AgentConfigDependencies = defaultDependencies(), ): AgentConfigTarget { const entry = dependencies.getSandbox(sandboxName); - if (!entry || !entry.agent) return DEFAULT_AGENT_CONFIG; - - const agent = dependencies.loadAgent(entry.agent); + const agentName = entry?.agent ?? DEFAULT_AGENT_CONFIG.agentName; + const agent = dependencies.loadAgent(agentName); const cfg = agent.configPaths; - const dir = cfg.dir; - const sensitiveFiles = [`${dir}/.config-hash`]; - if (entry.agent === "hermes") sensitiveFiles.push(`${dir}/.env`); + const dir = requireCanonicalConfigDir(cfg.dir); + const configPath = resolveConfigFile(dir, cfg.configFile, "config_file"); + const sensitiveFiles = [resolveConfigFile(dir, ".config-hash", "config hash")]; + if (cfg.envFile !== undefined && cfg.envFile !== null) { + resolveConfigFile(dir, cfg.envFile, "env_file"); + } + if ( + !Array.isArray(cfg.shieldsFiles) || + cfg.shieldsFiles.some((entry) => typeof entry !== "string") + ) { + throw new Error("Agent manifest field 'config.shields_files' must be a string array"); + } + if (agentName !== "hermes" && cfg.shieldsFiles.length > 0) { + throw new Error( + `Agent '${agentName}' declares config.shields_files, but protected top-level config files are currently supported only for Hermes`, + ); + } + for (const [index, shieldsFile] of cfg.shieldsFiles.entries()) { + const resolved = resolveConfigFile(dir, shieldsFile, `shields_files[${String(index)}]`); + if (resolved === configPath || sensitiveFiles.includes(resolved)) { + throw new Error( + `Agent config field 'shields_files[${String(index)}]' duplicates a protected config file`, + ); + } + sensitiveFiles.push(resolved); + } return { - agentName: entry.agent, - configPath: `${dir}/${cfg.configFile}`, + agentName, + configPath, configDir: dir, format: cfg.format || "json", configFile: cfg.configFile, sensitiveFiles, + stateLockPlan: agent.stateLockPlan, }; } diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 2bb0abafe0d..26a73b6ea2b 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -41,9 +41,16 @@ function normalizeReadModesForDockerCopy(rootDir: string): void { } function stageOpenClawRuntimeGraphs(rootDir: string, buildCtx: string): void { + const sourceAgentDir = path.join(rootDir, "agents", "openclaw"); + const stagedAgentDir = path.join(buildCtx, "agents", "openclaw"); + fs.mkdirSync(stagedAgentDir, { recursive: true }); + fs.copyFileSync( + path.join(sourceAgentDir, "state-lock-plan.json"), + path.join(stagedAgentDir, "state-lock-plan.json"), + ); for (const runtimeName of ["mcporter-runtime", "openclaw-runtime", "wechat-runtime"]) { - const sourceDir = path.join(rootDir, "agents", "openclaw", runtimeName); - const stagedDir = path.join(buildCtx, "agents", "openclaw", runtimeName); + const sourceDir = path.join(sourceAgentDir, runtimeName); + const stagedDir = path.join(stagedAgentDir, runtimeName); fs.mkdirSync(stagedDir, { recursive: true }); for (const fileName of ["package.json", "package-lock.json"]) { fs.copyFileSync(path.join(sourceDir, fileName), path.join(stagedDir, fileName)); diff --git a/src/lib/sandbox/config-get.test.ts b/src/lib/sandbox/config-get.test.ts index b2dc587e075..2ff988f458e 100644 --- a/src/lib/sandbox/config-get.test.ts +++ b/src/lib/sandbox/config-get.test.ts @@ -220,7 +220,12 @@ describe("configGet parsing for manifest-declared formats (#6548)", () => { // `format: toml`, so parseConfig takes the TOML branch. registry.getSandbox = () => ({ agent: "langchain-deepagents-code" }); agentDefs.loadAgent = () => ({ - configPaths: { dir: "/sandbox/.deepagents", configFile: "config.toml", format: "toml" }, + configPaths: { + dir: "/sandbox/.deepagents", + configFile: "config.toml", + format: "toml", + shieldsFiles: [], + }, }); // The sandbox `cat` returns the raw TOML text. client.captureOpenshellCommand = () => ({ @@ -313,7 +318,12 @@ describe("configGet parsing for manifest-declared formats (#6548)", () => { it("does not echo credential-bearing source lines from malformed YAML", () => { registry.getSandbox = () => ({ agent: "hermes" }); agentDefs.loadAgent = () => ({ - configPaths: { dir: "/sandbox/.hermes", configFile: "config.yaml", format: "yaml" }, + configPaths: { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + format: "yaml", + shieldsFiles: [".env"], + }, }); const secret = "nvapi-yamlabcdefghijklmnopqrstuvwxyz0123456789"; const sourceLine = `api_key: "${secret}" trailing-text`; diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 1fd6bc6f8aa..f753d4457d8 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -86,6 +86,7 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { const privilegedExec = requireDist("../sandbox/privileged-exec.js"); const dockerExec = requireDist("../adapters/docker/exec.js"); const audit = requireDist("./audit.js"); + const stateDirLock = requireDist("./state-dir-lock.js"); const childProcess = requireDist("node:child_process"); let openClawPosture: "locked" | "mutable" = "mutable"; @@ -108,6 +109,14 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { configFile: "openclaw.json", configPath: "/sandbox/.openclaw/openclaw.json", format: "json", + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, }); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "openclaw", openshellDriver: "docker" }); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [{ name: "openclaw" }] }); @@ -131,6 +140,23 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { ); vi.spyOn(dockerExec, "dockerSpawnSync").mockImplementation((argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; + if (args.includes("cat") && args.includes("/usr/local/share/nemoclaw/state-lock-plan.json")) { + return { + status: 0, + signal: null, + stdout: `${JSON.stringify({ + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + })}\n`, + stderr: "", + pid: 0, + output: [], + } as never; + } const action = ["preflight", "lock", "unlock"].find((candidate) => args.includes(candidate)); const openClawGuard = args.some((arg) => arg.endsWith("openclaw-config-guard.py")); const shouldFailOpenClawGuard = Boolean( @@ -206,6 +232,7 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { : ""; }); const auditSpy = vi.spyOn(audit, "appendAuditEntry").mockImplementation(() => undefined); + vi.spyOn(stateDirLock, "stateLockPlanCompatibilityIssues").mockReturnValue([]); const shields = requireDist(shieldsModulePath); logSpy.mockClear(); diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 2912f73d972..3e59be9c189 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -43,6 +43,14 @@ vi.mock("../sandbox/agent-config", () => ({ configDir: "/sandbox/.openclaw", format: "json", configFile: "openclaw.json", + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, })), })); @@ -710,6 +718,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -740,6 +749,7 @@ describe("shields — unit logic", () => { const { shieldsStatus } = await loadShieldsModule(); shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -752,6 +762,41 @@ describe("shields — unit logic", () => { expect(errorSpy).not.toHaveBeenCalled(); }); + it("reports a mismatched installed state lock plan as drift", async () => { + const sandboxName = "openclaw"; + writeSealedLockedState(sandboxName); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((code?: string | number | null) => { + throw new Error(`exit ${String(code)}`); + }); + + const { shieldsStatus } = await loadShieldsModule(); + expect(() => + shieldsStatus(sandboxName, true, { + verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [ + "installed state lock plan differs from the current agent manifest", + ], + resolveConfig: () => ({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + }), + }), + ).toThrow("exit 2"); + + const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); + expect(errors).toContain( + "state lock plan: installed state lock plan differs from the current agent manifest", + ); + expect(errors).toContain( + "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", + ); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + it("passes the persisted fileHashes seal to the verifier when present", async () => { const sandboxName = "openclaw"; const fileHashes = { @@ -779,6 +824,7 @@ describe("shields — unit logic", () => { const { shieldsStatus } = await loadShieldsModule(); shieldsStatus(sandboxName, true, { + verifyStateLockPlan: () => [], verifyLockState: ( _name: string, _target: unknown, @@ -816,6 +862,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -851,6 +898,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -881,6 +929,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -911,6 +960,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], resolveConfig: () => { throw new Error("agent config not found"); }, diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index cd3110b8d3f..e523736d25a 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -81,6 +81,7 @@ const { applyStateDirLockMode, preflightStateDirLock, restoreStateDirLockPosture, + stateLockPlanCompatibilityIssues, }: typeof import("./state-dir-lock") = require("./state-dir-lock"); const { OPENCLAW_CONFIG_DIR, @@ -98,6 +99,7 @@ const { type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConfigPermsInspection; type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult; type ProcessIdentity = import("./timer-control").ProcessIdentity; +type AgentStateLockPlan = import("../agent/definition-types").AgentStateLockPlan; const STATE_DIR = resolveNemoclawStateDir(); const SHIELDS_TRANSITION_POLL_MS = 50; @@ -489,9 +491,9 @@ function hermesShieldsGuardArgs( ]; } -type HermesShieldsProtocol = "sealed" | "legacy"; +type HermesShieldsProtocol = "sealed-plan-v1" | "sealed-v1" | "legacy"; -const HERMES_SEALED_SHIELDS_CONTRACT = [ +const HERMES_SEALED_V1_CONTRACT = [ "begin-shields-transition", "run-state-dir-transition", "apply-shields-transition", @@ -500,6 +502,10 @@ const HERMES_SEALED_SHIELDS_CONTRACT = [ "abort-shields-transition", "--rollback-shields-mode", ] as const; +const HERMES_SEALED_PLAN_V1_CONTRACT = [ + ...HERMES_SEALED_V1_CONTRACT, + "--state-lock-plan-json", +] as const; const HERMES_LEGACY_GUARD_CONTRACT = [ "ensure-api-key", "refresh-hashes", @@ -510,7 +516,7 @@ function inspectHermesShieldsProtocol( sandboxName: string, target: AgentConfigTarget, ): HermesShieldsProtocol { - if (target.agentName !== "hermes") return "sealed"; + if (target.agentName !== "hermes") return "sealed-plan-v1"; const help = privilegedSandboxExecCapture( sandboxName, [ @@ -525,8 +531,11 @@ function inspectHermesShieldsProtocol( ], HERMES_CONFIG_GUARD_TIMEOUT_MS, ); - if (HERMES_SEALED_SHIELDS_CONTRACT.every((entry) => help.includes(entry))) { - return "sealed"; + if (HERMES_SEALED_PLAN_V1_CONTRACT.every((entry) => help.includes(entry))) { + return "sealed-plan-v1"; + } + if (HERMES_SEALED_V1_CONTRACT.every((entry) => help.includes(entry))) { + return "sealed-v1"; } if (HERMES_LEGACY_GUARD_CONTRACT.every((entry) => help.includes(entry))) { return "legacy"; @@ -569,7 +578,7 @@ function resolveHermesShieldsProtocol( function supportsHermesSealedShieldsTransactions(sandboxName: string): boolean { validateName(sandboxName, "sandbox name"); const target = ensureConfigHashSensitiveFile(resolveAgentConfig(sandboxName)); - return inspectHermesShieldsProtocol(sandboxName, target) === "sealed"; + return inspectHermesShieldsProtocol(sandboxName, target) !== "legacy"; } function beginHermesConfigShields( @@ -678,13 +687,18 @@ function runHermesStateDirTransition( target: AgentConfigTarget, token: string, action: "lock" | "unlock", + protocol: HermesShieldsProtocol, ): void { + const planArgs = + protocol === "sealed-plan-v1" + ? ["--state-lock-plan-json", JSON.stringify(requireStateLockPlan(target))] + : []; privilegedSandboxExec( sandboxName, hermesShieldsGuardArgs( "run-state-dir-transition", target, - ["--state-action", action, "--lock-token", token], + ["--state-action", action, ...planArgs, "--lock-token", token], "13m", ), STATE_DIR_GUARD_TIMEOUT_MS, @@ -752,8 +766,19 @@ type AgentConfigTarget = { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; }; +function requireStateLockPlan(target: AgentConfigTarget): AgentStateLockPlan { + const plan = target.stateLockPlan; + if (!plan || plan.version !== 1) { + throw new Error( + `Agent '${target.agentName ?? "unknown"}' does not expose a supported state lock plan`, + ); + } + return plan; +} + function requiresProtectedSandboxParent(target: AgentConfigTarget): boolean { return ( target.configDir.startsWith("/sandbox/") && @@ -973,10 +998,9 @@ function isShieldsState(value: unknown): value is ShieldsState { // --------------------------------------------------------------------------- // State-dir lock — adapter between this module's privileged-exec helpers and -// the lock pipeline in ./state-dir-lock. The inventory of locked dirs, the -// preflight/mutation/verification logic, and the `agents/*/sessions` -// carve-out live in that sibling module so this file stays focused on -// shields state transitions. +// the lock pipeline in ./state-dir-lock. AgentDefinition supplies the path +// plan; the sibling module owns helper execution and output validation so this +// file stays focused on shields state transitions. // --------------------------------------------------------------------------- function stateDirLockExec(sandboxName: string) { @@ -1649,6 +1673,14 @@ function unlockAgentConfigUnderMutationLock( protocol: HermesShieldsProtocol, ): void { const target = ensureConfigHashSensitiveFile(rawTarget); + const compatibilityIssues = stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + ); + if (compatibilityIssues.length > 0) { + throw new Error(`Config not unlocked: ${compatibilityIssues.join(", ")}`); + } const errors: string[] = []; const filesToUnlock = [target.configPath, ...(target.sensitiveFiles || [])]; // Mutable-default mode for OpenClaw: group-writable + setgid on the @@ -1698,13 +1730,14 @@ function unlockAgentConfigUnderMutationLock( // the fresh-inode Hermes transaction. If the host Docker client dies, // a later locked takeover can observe and wait for the exact worker // identity instead of racing an orphaned unlock pass. - runHermesStateDirTransition(sandboxName, target, transaction.token, "unlock"); + runHermesStateDirTransition(sandboxName, target, transaction.token, "unlock", protocol); } else { const stateDirUnlockIssues = applyStateDirLockMode( stateDirLockExec(sandboxName), target.configDir, "sandbox:sandbox", false, + requireStateLockPlan(target), ); for (const issue of stateDirUnlockIssues) errors.push(`state dir unlock: ${issue}`); } @@ -1804,6 +1837,7 @@ function unlockAgentConfigUnderMutationLock( target, transaction.token, transaction.rollbackLocked ? "lock" : "unlock", + protocol, ); abortHermesConfigShields(sandboxName, target, transaction.token); } catch (abortError) { @@ -1832,6 +1866,7 @@ function unlockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), ), ); } catch (rollbackError) { @@ -1862,6 +1897,7 @@ function unlockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), ), ); } catch (rollbackError) { @@ -2008,6 +2044,14 @@ function lockAgentConfigUnderMutationLock( protocol: HermesShieldsProtocol, ): { chattrApplied: boolean; fileHashes: { [path: string]: string } } { const target = ensureConfigHashSensitiveFile(rawTarget); + const compatibilityIssues = stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + ); + if (compatibilityIssues.length > 0) { + throw new Error(`Config not locked: ${compatibilityIssues.join(", ")}`); + } const errors: string[] = []; const filesToLock = [target.configPath, ...(target.sensitiveFiles || [])]; const openClawProtocol = target.agentName === "openclaw"; @@ -2025,7 +2069,11 @@ function lockAgentConfigUnderMutationLock( // must revoke writes to their canonical config first: otherwise an agent can // plant one invalid nested entry and veto the auto-restore deadline forever. if (!openClawProtocol && (target.agentName !== "hermes" || legacyHermesProtocol)) { - const preflightIssues = preflightStateDirLock(stateDirLockExec(sandboxName), target.configDir); + const preflightIssues = preflightStateDirLock( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + ); if (preflightIssues.length > 0) { throw new Error(`Config not locked: ${preflightIssues.join(", ")}`); } @@ -2086,13 +2134,14 @@ function lockAgentConfigUnderMutationLock( } if (transaction) { - runHermesStateDirTransition(sandboxName, target, transaction.token, "lock"); + runHermesStateDirTransition(sandboxName, target, transaction.token, "lock", protocol); } else { const stateDirLockIssues = applyStateDirLockMode( stateDirLockExec(sandboxName), target.configDir, "root:sandbox", true, + requireStateLockPlan(target), ); if (stateDirLockIssues.length > 0) { throw new Error(`Config not locked: ${stateDirLockIssues.join(", ")}`); @@ -2173,6 +2222,7 @@ function lockAgentConfigUnderMutationLock( target, transaction.token, transaction.rollbackLocked ? "lock" : "unlock", + protocol, ); abortHermesConfigShields(sandboxName, target, transaction.token); } catch (abortError) { @@ -2199,6 +2249,7 @@ function lockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), ).map((message) => ({ message, readinessFailure: false })), ); } catch (rollbackError) { @@ -3228,6 +3279,7 @@ function shieldsUp( type ShieldsStatusDeps = { verifyLockState?: typeof verifyShieldsLockState; resolveConfig?: typeof resolveAgentConfig; + verifyStateLockPlan?: (sandboxName: string, target: AgentConfigTarget) => string[]; }; function shieldsStatusWithoutHostLock( @@ -3263,15 +3315,26 @@ function shieldsStatusWithoutHostLock( // protected perms back to a sandbox-writable state is surfaced as drift // instead of reported as a clean lockdown. let driftIssues: string[] = []; + let planIssues: string[] = []; try { const target = ensureConfigHashSensitiveFile(resolveConfig(sandboxName)); - driftIssues = verify(sandboxName, target, { - verifyChattr: state.chattrApplied === true, - verifyParentProtection: requiresProtectedSandboxParent(target), - exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), - assertLegacyLayout: assertNoLegacyStateLayout, - expectedHashes: state.fileHashes, - }).issues; + planIssues = deps.verifyStateLockPlan + ? deps.verifyStateLockPlan(sandboxName, target) + : stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + ); + driftIssues = [ + ...planIssues.map((issue) => `state lock plan: ${issue}`), + ...verify(sandboxName, target, { + verifyChattr: state.chattrApplied === true, + verifyParentProtection: requiresProtectedSandboxParent(target), + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + expectedHashes: state.fileHashes, + }).issues, + ]; } catch (err) { const msg = err instanceof Error ? err.message : String(err); driftIssues = [`unable to resolve agent config target: ${msg}`]; @@ -3299,12 +3362,16 @@ function shieldsStatusWithoutHostLock( ? [ ` Recovery: restore the original file content from a trusted source, or rebuild the sandbox, then run \`nemoclaw ${sandboxName} shields up\` to re-seal.`, ] - : hasMissingSeals + : planIssues.length > 0 ? [ - " Recovery: rebuild the sandbox for a known-good baseline,", - ` or set NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 and re-run \`nemoclaw ${sandboxName} shields up\` to seal the current bytes.`, + " Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", ] - : [` Recovery: nemoclaw ${sandboxName} shields up # re-lock and re-verify`]; + : hasMissingSeals + ? [ + " Recovery: rebuild the sandbox for a known-good baseline,", + ` or set NEMOCLAW_SHIELDS_ACCEPT_LEGACY_BASELINE=1 and re-run \`nemoclaw ${sandboxName} shields up\` to seal the current bytes.`, + ] + : [` Recovery: nemoclaw ${sandboxName} shields up # re-lock and re-verify`]; for (const line of recoveryLines) { console.error(line); } diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 5245400da45..199a6e6bc88 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -15,6 +15,18 @@ const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; const LOCK_TOKEN = "a".repeat(64); const OLD_GUARD_HELP = "usage: guard {ensure-api-key,refresh-hashes,provider-placeholders}"; const PARTIAL_GUARD_HELP = "begin-shields-transition --rollback-shields-mode"; +const PREVIOUS_SEALED_GUARD_HELP = [ + "begin-shields-transition", + "run-state-dir-transition", + "apply-shields-transition", + "finish-shields-transition", + "prepare-shields-abort", + "abort-shields-transition", + "--rollback-shields-mode", + "ensure-api-key", + "refresh-hashes", + "provider-placeholders", +].join(" "); const CURRENT_GUARD_HELP = [ "begin-shields-transition", "run-state-dir-transition", @@ -23,10 +35,20 @@ const CURRENT_GUARD_HELP = [ "prepare-shields-abort", "abort-shields-transition", "--rollback-shields-mode", + "--state-lock-plan-json", ].join(" "); type ShieldsModule = typeof import("./index"); +const STATE_LOCK_PLAN = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["pairing"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], +}; + function hermesTarget() { return { agentName: "hermes", @@ -35,6 +57,7 @@ function hermesTarget() { format: "yaml", configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + stateLockPlan: STATE_LOCK_PLAN, }; } @@ -108,6 +131,7 @@ describe("legacy Hermes shields compatibility", () => { applyStateDirLockModeSpy, vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]), vi.spyOn(stateDirLock, "restoreStateDirLockPosture").mockReturnValue([]), + vi.spyOn(stateDirLock, "stateLockPlanCompatibilityIssues").mockReturnValue([]), vi.spyOn(audit, "appendAuditEntry").mockImplementation(() => undefined), vi .spyOn(permissiveRuntime, "buildRuntimePermissivePolicy") @@ -260,6 +284,8 @@ describe("legacy Hermes shields compatibility", () => { isGuardAction(cmd, "run-state-dir-transition") && cmd.includes("--state-action") && cmd.includes("unlock") && + cmd.includes("--state-lock-plan-json") && + cmd.includes(JSON.stringify(STATE_LOCK_PLAN)) && cmd.includes(LOCK_TOKEN), ), ).toBe(true); @@ -268,6 +294,23 @@ describe("legacy Hermes shields compatibility", () => { expect(commands.some(isInlinePython)).toBe(false); }); + it("keeps the immediately previous sealed protocol token-owned without sending a plan argument", () => { + installExecResponses(PREVIOUS_SEALED_GUARD_HELP); + + expect(() => + shields.unlockAgentConfig("previous-hermes", hermesTarget(), true, true), + ).not.toThrow(); + + const commands = dockerExecSpy.mock.calls.map(commandFromCall); + expect(commands.some((cmd) => isGuardAction(cmd, "begin-shields-transition"))).toBe(true); + const transition = commands.find((cmd) => isGuardAction(cmd, "run-state-dir-transition")); + expect(transition).toEqual(expect.arrayContaining(["--state-action", "unlock", LOCK_TOKEN])); + expect(transition).not.toContain("--state-lock-plan-json"); + expect(commands.some((cmd) => isGuardAction(cmd, "apply-shields-transition"))).toBe(true); + expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(true); + expect(commands.some(isInlinePython)).toBe(false); + }); + it("delegates a private Hermes root to the sealed guard before completing unlock", () => { installExecResponses(CURRENT_GUARD_HELP, "700"); diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 4221fb4da27..08c16183ae9 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -13,6 +13,15 @@ const INDEX_MODULE = "./index.js"; type ShieldsModule = typeof import("./index"); +const STATE_LOCK_PLAN = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["credentials"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], +}; + function openClawTarget() { return { agentName: "openclaw", @@ -21,6 +30,7 @@ function openClawTarget() { format: "json", configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: STATE_LOCK_PLAN, }; } @@ -33,6 +43,7 @@ describe("OpenClaw shields top-config transaction", () => { let guardSpy: MockInstance; let applyStateSpy: MockInstance; let restoreStateSpy: MockInstance; + let compatibilitySpy: MockInstance; let events: string[]; beforeEach(() => { @@ -87,6 +98,9 @@ describe("OpenClaw shields top-config transaction", () => { privilegedExecSpy = vi .spyOn(privilegedExec, "privilegedSandboxExecArgv") .mockImplementation((_sandboxName: unknown, cmd: unknown) => cmd as string[]); + compatibilitySpy = vi + .spyOn(stateDirLock, "stateLockPlanCompatibilityIssues") + .mockReturnValue([]); spies.push( vi.spyOn(runner, "run").mockReturnValue({ status: 0 }), @@ -94,6 +108,7 @@ describe("OpenClaw shields top-config transaction", () => { vi.spyOn(agentConfig, "resolveAgentConfig").mockImplementation(() => openClawTarget()), privilegedExecSpy, dockerExecSpy, + compatibilitySpy, vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]), applyStateSpy, restoreStateSpy, @@ -120,6 +135,20 @@ describe("OpenClaw shields top-config transaction", () => { expect(commands.some((cmd) => cmd[0] === "stat" && cmd.at(-1) === "/sandbox")).toBe(true); }); + it("rejects an incompatible runtime plan before mutating the config tree", () => { + compatibilitySpy.mockReturnValueOnce([ + "installed state lock plan differs from the current agent manifest", + ]); + + expect(() => shields.lockAgentConfig("openclaw", openClawTarget(), false)).toThrow( + /installed state lock plan differs/, + ); + + expect(events).toEqual([]); + expect(guardSpy).not.toHaveBeenCalled(); + expect(applyStateSpy).not.toHaveBeenCalled(); + }); + it("preserves the sealed top after a partial recursive lock", () => { applyStateSpy.mockImplementationOnce((_exec, _dir, _owner, locking) => { events.push(`state:${locking ? "lock" : "unlock"}`); diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 50df05618fb..049f9f0708c 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -37,6 +37,14 @@ describe("shields policy transition", () => { configFile: "config.json", configPath: "/sandbox/.deepagents/config.json", format: "json", + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, }); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -103,6 +111,14 @@ describe("shields config lock without a shipped config hash", () => { configPath: CONFIG_PATH, format: "toml", sensitiveFiles: [HASH_PATH], + stateLockPlan: { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, }; } diff --git a/src/lib/shields/state-dir-lock.test.ts b/src/lib/shields/state-dir-lock.test.ts index 91ddd2dd303..8985c44a4f4 100644 --- a/src/lib/shields/state-dir-lock.test.ts +++ b/src/lib/shields/state-dir-lock.test.ts @@ -2,15 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; +import type { AgentStateLockPlan } from "../agent/definition-types"; import type { PrivilegedExec } from "./state-dir-lock"; import { applyStateDirLockMode, + CONTAINER_STATE_LOCK_PLAN, preflightStateDirLock, restoreStateDirLockPosture, + stateLockPlanCompatibilityIssues, } from "./state-dir-lock"; type RunCall = { cmd: string[]; input?: string }; +const PLAN: AgentStateLockPlan = { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: ["credentials"], + readOnlyPrefixes: ["workspace-"], + confidentialPrefixes: [], + writableSubpaths: ["agents/*/sessions"], +}; + function success(action: string): string { return JSON.stringify({ type: "result", @@ -24,21 +36,37 @@ function success(action: string): string { }); } -function createExec(installed = true): { calls: RunCall[]; privileged: PrivilegedExec } { +function createExec( + runtimePlan: "current" | "historical" = "current", + helperAvailable = true, +): { + calls: RunCall[]; + privileged: PrivilegedExec; +} { const calls: RunCall[] = []; return { calls, privileged: { run: (cmd, input) => { calls.push({ cmd, input }); - switch (cmd[0]) { - case "test": - return { - status: installed ? 0 : 1, - signal: null, - stdout: "", - stderr: "", - }; + if (cmd[0] === "test" && cmd.at(-1) === CONTAINER_STATE_LOCK_PLAN) { + return { + status: runtimePlan === "current" ? 0 : 1, + signal: null, + stdout: "", + stderr: "", + }; + } + if (cmd[0] === "cat" && cmd[1] === CONTAINER_STATE_LOCK_PLAN) { + return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; + } + if (cmd[0] === "test") { + return { + status: helperAvailable ? 0 : 1, + signal: null, + stdout: "", + stderr: "", + }; } const pythonIndex = cmd.indexOf("python3"); const action = cmd[pythonIndex + 3]; @@ -66,23 +94,71 @@ describe("recursive state-dir lock host wiring", () => { it("re-locks state directories when the interrupted transition began locked", () => { const { calls, privileged } = createExec(); - expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", true)).toEqual([]); + expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", true, PLAN)).toEqual([]); expect(actions(calls)).toEqual(["preflight", "lock"]); }); it("restores mutable state directories when the interrupted transition began mutable", () => { const { calls, privileged } = createExec(); - expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", false)).toEqual([]); + expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", false, PLAN)).toEqual([]); expect(actions(calls)).toEqual(["unlock"]); }); - it("injects the trusted host helper into old images instead of using recursive shell commands", () => { - const { calls, privileged } = createExec(false); + it("uses the current image's root-owned helper and generated plan", () => { + const { calls, privileged } = createExec(); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + ).toEqual([]); + const invocation = calls.find(({ cmd }) => cmd.includes("python3")); + expect(invocation?.cmd).toEqual([ + "timeout", + "--signal=TERM", + "--kill-after=5s", + "12m", + "python3", + "-I", + "/usr/local/lib/nemoclaw/state-dir-guard.py", + "lock", + "--config-dir", + "/sandbox/.openclaw", + "--plan-file", + CONTAINER_STATE_LOCK_PLAN, + ]); + expect(invocation?.input).toBeUndefined(); + }); + + it("uses a historical image's co-bundled helper when no generated plan is installed", () => { + const { calls, privileged } = createExec("historical"); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + ).toEqual([]); + + const invocation = calls.find(({ cmd }) => cmd.includes("python3")); + expect(invocation?.cmd).toEqual([ + "timeout", + "--signal=TERM", + "--kill-after=5s", + "12m", + "python3", + "-I", + "/usr/local/lib/nemoclaw/state-dir-guard.py", + "lock", + "--config-dir", + "/sandbox/.openclaw", + ]); + expect(invocation?.input).toBeUndefined(); + }); + + it("injects the host helper only for an image that predates both bundled artifacts", () => { + const { calls, privileged } = createExec("historical", false); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + ).toEqual([]); - expect(applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true)).toEqual( - [], - ); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); expect(invocation?.cmd).toEqual([ "timeout", @@ -95,16 +171,80 @@ describe("recursive state-dir lock host wiring", () => { "lock", "--config-dir", "/sandbox/.openclaw", + "--plan-json", + JSON.stringify(PLAN), ]); expect(invocation?.input).toContain("Descriptor-safe recursive state-directory"); }); + it("injects the host helper and plan for agents without an image recovery plan", () => { + const { calls, privileged } = createExec(); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.deepagents", "root:sandbox", true, PLAN), + ).toEqual([]); + + const invocation = calls.find(({ cmd }) => cmd.includes("python3")); + expect(invocation?.cmd).toEqual( + expect.arrayContaining(["python3", "-I", "-", "lock", "--plan-json", JSON.stringify(PLAN)]), + ); + expect(invocation?.input).toContain("Descriptor-safe recursive state-directory"); + }); + + it("rejects a plan-aware image whose installed helper is missing", () => { + const { calls, privileged } = createExec("current", false); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + ).toEqual([ + "state-dir guard is unavailable in an image that contains a generated state lock plan", + ]); + expect(actions(calls)).toEqual([]); + }); + + it.each([ + ["malformed JSON", "{"], + ["an unknown field", JSON.stringify({ ...PLAN, registry: [] })], + ["a different policy", JSON.stringify({ ...PLAN, readOnlyRoots: ["hooks"] })], + ])("rejects an installed plan with %s before mutation", (_case, payload) => { + const privileged: PrivilegedExec = { + run: (cmd) => { + if (cmd[0] === "test") { + return { status: 0, signal: null, stdout: "", stderr: "" }; + } + return { status: 0, signal: null, stdout: payload, stderr: "" }; + }, + }; + + expect(stateLockPlanCompatibilityIssues(privileged, "/sandbox/.openclaw", PLAN)).toEqual([ + expect.stringMatching(/installed state lock plan|differs from the current agent manifest/), + ]); + }); + + it("ignores SPDX metadata and JSON formatting when checking plan parity", () => { + const privileged: PrivilegedExec = { + run: (cmd) => + cmd[0] === "test" + ? { status: 0, signal: null, stdout: "", stderr: "" } + : { + status: 0, + signal: null, + stdout: JSON.stringify({ $comment: "SPDX metadata", ...PLAN }, null, 2), + stderr: "", + }, + }; + + expect(stateLockPlanCompatibilityIssues(privileged, "/sandbox/.openclaw", PLAN)).toEqual([]); + }); + it("surfaces structured helper findings and rejects contradictory exit contracts", () => { const privileged: PrivilegedExec = { run: (cmd) => { - switch (cmd[0]) { - case "test": - return { status: 0, signal: null, stdout: "", stderr: "" }; + if (cmd[0] === "test") { + return { status: 0, signal: null, stdout: "", stderr: "" }; + } + if (cmd[0] === "cat") { + return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; } return { status: 0, @@ -128,7 +268,7 @@ describe("recursive state-dir lock host wiring", () => { }, }; - expect(preflightStateDirLock(privileged, "/sandbox/.openclaw")).toEqual( + expect(preflightStateDirLock(privileged, "/sandbox/.openclaw", PLAN)).toEqual( expect.arrayContaining([ expect.stringContaining("[hardlinked-entry]"), expect.stringContaining("reported failure with a zero exit"), diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index 7f11a4a329f..2297aa82c52 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; +import type { AgentStateLockPlan } from "../agent/definition-types"; // State-dir lock fan-out for shields up/down. The actual traversal lives in a // root-only Python helper because shell `chown -R` / `chmod -R` cannot provide @@ -21,36 +22,18 @@ export interface PrivilegedExec { run(cmd: string[], input?: string): PrivilegedExecResult; } -// Keep this inventory exported for documentation/tests that compare the host -// contract with shipped agent manifests. The helper owns enforcement and must -// be updated in the same change when this inventory changes. -export const HIGH_RISK_STATE_DIRS = [ - "skills", - "agent", - "hooks", - "cron", - "agents", - "extensions", - "plugins", - "workspace", - "memory", - "devices", - "canvas", - "telegram", - "wechat", - "whatsapp", - "platforms", - "weixin", - "profiles", - "skins", -]; - -export const CONFIDENTIALITY_STATE_DIRS = ["credentials", "identity", "pairing"]; -export const WRITABLE_RUNTIME_SUBPATHS = ["agents/*/sessions"]; - -const CONTAINER_HELPER = "/usr/local/lib/nemoclaw/state-dir-guard.py"; const HOST_HELPER = path.resolve(__dirname, "../../../scripts/state-dir-guard.py"); +const CONTAINER_HELPER = "/usr/local/lib/nemoclaw/state-dir-guard.py"; +export const CONTAINER_STATE_LOCK_PLAN = "/usr/local/share/nemoclaw/state-lock-plan.json"; const CONTAINER_TIMEOUT = ["timeout", "--signal=TERM", "--kill-after=5s", "12m"]; +const PLAN_ARRAY_FIELDS = [ + "readOnlyRoots", + "confidentialRoots", + "readOnlyPrefixes", + "confidentialPrefixes", + "writableSubpaths", +] as const; +const PLAN_FIELDS = new Set(["$comment", "version", ...PLAN_ARRAY_FIELDS]); type GuardAction = "preflight" | "lock" | "unlock"; @@ -77,6 +60,101 @@ function resultFailure(label: string, result: PrivilegedExecResult): string { return `${label} (${termination})${details ? `: ${details}` : ""}`; } +function successful(result: PrivilegedExecResult): boolean { + return result.status === 0 && result.signal === null && !result.error; +} + +function parseInstalledPlan(payload: string): AgentStateLockPlan | string { + let value: unknown; + try { + value = JSON.parse(payload); + } catch (error) { + return `installed state lock plan is not valid JSON: ${error instanceof Error ? error.message : String(error)}`; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return "installed state lock plan must be an object"; + } + const record = value as Record; + const unknown = Object.keys(record).filter((key) => !PLAN_FIELDS.has(key)); + if (unknown.length > 0) { + return `installed state lock plan has unknown fields: ${unknown.join(", ")}`; + } + if (record.$comment !== undefined && typeof record.$comment !== "string") { + return "installed state lock plan has a non-string $comment"; + } + if (record.version !== 1) return "installed state lock plan version must be exactly 1"; + + const parsed = { version: 1 } as AgentStateLockPlan; + for (const field of PLAN_ARRAY_FIELDS) { + const entries = record[field]; + if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string")) { + return `installed state lock plan field '${field}' must be a string array`; + } + if (new Set(entries).size !== entries.length) { + return `installed state lock plan field '${field}' contains duplicates`; + } + parsed[field] = entries as string[]; + } + return parsed; +} + +function plansMatch(actual: AgentStateLockPlan, expected: AgentStateLockPlan): boolean { + return PLAN_ARRAY_FIELDS.every( + (field) => JSON.stringify(actual[field]) === JSON.stringify(expected[field]), + ); +} + +type RuntimePlanInspection = + | { kind: "image-current" } + | { kind: "host-current" } + | { kind: "historical" } + | { kind: "error"; issue: string }; + +function hasImageRecoveryPlan(configDir: string): boolean { + return configDir === "/sandbox/.openclaw" || configDir === "/sandbox/.hermes"; +} + +function inspectRuntimePlan( + privileged: PrivilegedExec, + configDir: string, + expected: AgentStateLockPlan, +): RuntimePlanInspection { + if (!hasImageRecoveryPlan(configDir)) return { kind: "host-current" }; + const capability = privileged.run(["test", "-r", CONTAINER_STATE_LOCK_PLAN]); + if (capability.status === 1 && capability.signal === null && !capability.error) { + return { kind: "historical" }; + } + if (!successful(capability)) { + return { + kind: "error", + issue: resultFailure("state lock plan capability probe failed", capability), + }; + } + const read = privileged.run(["cat", CONTAINER_STATE_LOCK_PLAN]); + if (!successful(read) || read.stderr.trim()) { + return { kind: "error", issue: resultFailure("installed state lock plan read failed", read) }; + } + const parsed = parseInstalledPlan(read.stdout); + if (typeof parsed === "string") return { kind: "error", issue: parsed }; + if (!plansMatch(parsed, expected)) { + return { + kind: "error", + issue: + "installed state lock plan differs from the current agent manifest; rebuild the sandbox before changing Shields", + }; + } + return { kind: "image-current" }; +} + +export function stateLockPlanCompatibilityIssues( + privileged: PrivilegedExec, + configDir: string, + expected: AgentStateLockPlan, +): string[] { + const inspection = inspectRuntimePlan(privileged, configDir, expected); + return inspection.kind === "error" ? [inspection.issue] : []; +} + function parseGuardOutput(action: GuardAction, result: PrivilegedExecResult): string[] { const issues: GuardIssue[] = []; const summaries: GuardSummary[] = []; @@ -166,45 +244,75 @@ function runStateDirGuard( privileged: PrivilegedExec, action: GuardAction, configDir: string, + plan: AgentStateLockPlan, ): string[] { - const capability = privileged.run(["test", "-r", CONTAINER_HELPER]); - let command: string[]; - let input: string | undefined; - if (capability.status === 0 && capability.signal === null && !capability.error) { - command = [ - ...CONTAINER_TIMEOUT, - "python3", - "-I", - CONTAINER_HELPER, - action, - "--config-dir", - configDir, - ]; - } else if (capability.status === 1 && capability.signal === null && !capability.error) { - // New CLIs must still be able to rebuild an old sandbox image. Inject the - // exact trusted helper shipped with this CLI over docker exec stdin rather - // than falling back to symlink-following recursive shell commands. - try { - input = readHostHelper(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const runtimePlan = inspectRuntimePlan(privileged, configDir, plan); + if (runtimePlan.kind === "error") return [runtimePlan.issue]; + + if (runtimePlan.kind !== "host-current") { + const capability = privileged.run(["test", "-r", CONTAINER_HELPER]); + if (successful(capability)) { + const planArgs = + runtimePlan.kind === "image-current" ? ["--plan-file", CONTAINER_STATE_LOCK_PLAN] : []; + return parseGuardOutput( + action, + privileged.run([ + ...CONTAINER_TIMEOUT, + "python3", + "-I", + CONTAINER_HELPER, + action, + "--config-dir", + configDir, + ...planArgs, + ]), + ); + } + if (!(capability.status === 1 && capability.signal === null && !capability.error)) { + return [resultFailure("state-dir guard capability probe failed", capability)]; + } + if (runtimePlan.kind === "image-current") { return [ - `state-dir guard is absent in the sandbox and host helper cannot be read: ${message}`, + "state-dir guard is unavailable in an image that contains a generated state lock plan", ]; } - command = [...CONTAINER_TIMEOUT, "python3", "-I", "-", action, "--config-dir", configDir]; - } else { - return [resultFailure("state-dir guard capability probe failed", capability)]; } + let input: string; + try { + input = readHostHelper(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return [`trusted host state-dir guard cannot be read: ${message}`]; + } + + // Agents without an image recovery plan, plus images predating the helper, + // use the bounded host-injection path. Plan-aware images always use their + // root-owned helper so host transitions and PID 1 recovery share one + // immutable implementation. + const command = [ + ...CONTAINER_TIMEOUT, + "python3", + "-I", + "-", + action, + "--config-dir", + configDir, + "--plan-json", + JSON.stringify(plan), + ]; return parseGuardOutput(action, privileged.run(command, input)); } // Read-only recursive validation. Call this before top-level config mutation so // a hostile nested link, hardlink, special entry, or cross-device mount fails // without partially changing the protected tree. -export function preflightStateDirLock(privileged: PrivilegedExec, configDir: string): string[] { - return runStateDirGuard(privileged, "preflight", configDir); +export function preflightStateDirLock( + privileged: PrivilegedExec, + configDir: string, + plan: AgentStateLockPlan, +): string[] { + return runStateDirGuard(privileged, "preflight", configDir, plan); } // Apply and independently verify the complete recursive state-dir posture. @@ -215,6 +323,7 @@ export function applyStateDirLockMode( configDir: string, highRiskOwner: string, isLocking: boolean, + plan: AgentStateLockPlan, ): string[] { const expectedOwner = isLocking ? "root:sandbox" : "sandbox:sandbox"; if (highRiskOwner !== expectedOwner) { @@ -222,18 +331,19 @@ export function applyStateDirLockMode( `state-dir guard owner contract mismatch: ${highRiskOwner} (expected ${expectedOwner})`, ]; } - return runStateDirGuard(privileged, isLocking ? "lock" : "unlock", configDir); + return runStateDirGuard(privileged, isLocking ? "lock" : "unlock", configDir, plan); } export function restoreStateDirLockPosture( privileged: PrivilegedExec, configDir: string, originallyLocked: boolean, + plan: AgentStateLockPlan, ): string[] { if (!originallyLocked) { - return applyStateDirLockMode(privileged, configDir, "sandbox:sandbox", false); + return applyStateDirLockMode(privileged, configDir, "sandbox:sandbox", false, plan); } - const preflightIssues = preflightStateDirLock(privileged, configDir); + const preflightIssues = preflightStateDirLock(privileged, configDir, plan); if (preflightIssues.length > 0) return preflightIssues; - return applyStateDirLockMode(privileged, configDir, "root:sandbox", true); + return applyStateDirLockMode(privileged, configDir, "root:sandbox", true, plan); } diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 05fed8025a5..dbf0e380998 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -743,6 +743,7 @@ function _log(msg: string): void { const VERSION_SELECTOR_RE = /^v(\d+)$/i; const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/; +const SAFE_DYNAMIC_STATE_DIR_RE = /^[A-Za-z0-9._-]+$/; export function validateSnapshotName(name: string): string | null { if (!NAME_RE.test(name)) { @@ -778,6 +779,38 @@ function isSafeStateDirPath(dirPath: string): boolean { ); } +function isAllowedDiscoveredStateDir( + candidate: string, + exactDirectories: readonly string[], + directoryPrefixes: readonly string[], +): boolean { + if (exactDirectories.includes(candidate)) return true; + return ( + SAFE_DYNAMIC_STATE_DIR_RE.test(candidate) && + directoryPrefixes.some((prefix) => candidate.startsWith(prefix)) + ); +} + +function describeStateDirDiscoveryFailure( + result: ReturnType, + invalidDirectories: readonly string[], +): { log: string; unreachable: boolean; error?: string } | null { + if (result.status !== 0) { + return { + log: `FAILED: SSH dir check exited ${String(result.status)} — cannot determine which dirs exist`, + unreachable: isSshTransportFailure(result), + }; + } + if (invalidDirectories.length > 0) { + return { + log: `SECURITY: State directory discovery returned undeclared or unsafe entries: ${invalidDirectories.map((entry) => JSON.stringify(entry)).join(", ")}`, + unreachable: false, + error: "State directory discovery returned undeclared or unsafe entries", + }; + } + return null; +} + function isStateDirArray(value: unknown): value is string[] { return isStringArray(value) && value.every(isSafeStateDirPath); } @@ -1117,14 +1150,11 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const agentName = sb?.agent || "openclaw"; const agent = loadAgent(agentName); const dir = agent.configPaths.dir; - // Runtime auth state (device identity keypairs, paired-device tokens) is - // never captured: sanitizeBackupDirectory scrubs its key/token fields, so a - // backup copy could only ever restore as corrupt auth state (#6852). - const runtimeAuthStateDirs = new Set(agent.runtimeAuthStateDirs); - const stateDirs = agent.stateDirs.filter((d) => !runtimeAuthStateDirs.has(d)); + const stateDirs = agent.backupStateDirs; + const stateDirPrefixes = agent.backupStateDirPrefixes; const stateFiles = normalizeStateFileSpecs(agent.stateFiles); _log( - `backupSandboxState: agent=${agentName}, dir=${dir}, stateDirs=[${stateDirs.join(",")}], stateFiles=[${stateFiles.map((f) => f.path).join(",")}]`, + `backupSandboxState: agent=${agentName}, dir=${dir}, stateDirs=[${stateDirs.join(",")}], stateDirPrefixes=[${stateDirPrefixes.join(",")}], stateFiles=[${stateFiles.map((f) => f.path).join(",")}]`, ); const snapshotAuthority = normalizeSnapshotBackupAuthority(options); @@ -1251,7 +1281,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const failedFiles: string[] = []; let unreachable = false; - if (stateDirs.length === 0 && stateFiles.length === 0) { + if (stateDirs.length === 0 && stateDirPrefixes.length === 0 && stateFiles.length === 0) { _log("WARNING: Agent manifest declares no state_dirs or state_files — nothing to back up"); const publicationError = validateSnapshotPublication(backupPath, options.validateBeforePublish); if (publicationError) { @@ -1292,18 +1322,29 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; try { - if (stateDirs.length > 0) { + if (stateDirs.length > 0 || stateDirPrefixes.length > 0) { // Build tar command that only includes existing directories. // First, check which declared state dirs actually exist in the sandbox, - // then additionally discover per-agent `workspace-*` directories produced - // by multi-agent OpenClaw deployments (see issue #1260) so they get - // snapshotted alongside the manifest-declared dirs. `awk '!seen[$0]++'` - // dedupes while preserving order. - const existCheckCmd = stateDirs - .map((d) => `[ -d ${shellQuote(`${dir}/${d}`)} ] && printf '%s\\n' ${shellQuote(d)}`) - .join("; "); - const workspaceGlobCmd = `for d in ${shellQuote(dir)}/workspace-*/; do [ -d "$d" ] && basename "$d"; done 2>/dev/null`; - const fullCheckCmd = `{ ${existCheckCmd}; ${workspaceGlobCmd}; } 2>/dev/null | awk '!seen[$0]++'`; + // then discover directories matching prefixes declared by the same agent + // contract. Quote each literal prefix and leave only the appended `*` + // unquoted for expansion. Reject non-canonical basenames in the sandbox + // before emitting newline-delimited output, then independently validate + // every result on the host. + const discoveryCommands = [ + ...stateDirs.map( + (d) => `[ -d ${shellQuote(`${dir}/${d}`)} ] && printf '%s\\n' ${shellQuote(d)}`, + ), + ...stateDirPrefixes.map( + (prefix) => + `for d in ${shellQuote(`${dir}/${prefix}`)}*/; do [ -d "$d" ] || continue; d=\${d%/}; candidate=\${d##*/}; case "$candidate" in *[!A-Za-z0-9._-]*|'') exit 65 ;; esac; printf '%s\\n' "$candidate"; done`, + ), + ]; + // Exact directory probes are optional and return 1 when absent. End the + // group with a successful no-op so an absent final declaration does not + // turn ordinary discovery into a transport failure. An unsafe dynamic + // basename still uses `exit 65`, which terminates the remote shell before + // this no-op can run. + const fullCheckCmd = `{ ${discoveryCommands.join("; ")}; :; } 2>/dev/null`; _log(`Checking existing dirs via SSH: ${fullCheckCmd.substring(0, 100)}...`); const existResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), fullCheckCmd], { encoding: "utf-8", @@ -1313,28 +1354,34 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = _log( `Dir check: exit=${existResult.status}, stdout=${(existResult.stdout || "").trim().substring(0, 200)}, stderr=${(existResult.stderr || "").trim().substring(0, 200)}`, ); - const existingDirs = (existResult.stdout || "") - .trim() - .split("\n") - .filter((d) => d.length > 0); - _log( - `Existing dirs in sandbox: [${existingDirs.join(",")}] (${existingDirs.length}/${stateDirs.length})`, + const existingDirs = [ + ...new Set( + (existResult.stdout || "") + .trim() + .split("\n") + .filter((d) => d.length > 0), + ), + ]; + const invalidExistingDirs = existingDirs.filter( + (candidate) => !isAllowedDiscoveredStateDir(candidate, stateDirs, stateDirPrefixes), ); - - if (existResult.status !== 0) { - _log( - `FAILED: SSH dir check exited ${existResult.status} — cannot determine which dirs exist`, - ); + const discoveryFailure = describeStateDirDiscoveryFailure(existResult, invalidExistingDirs); + if (discoveryFailure) { + _log(discoveryFailure.log); return { success: false, - unreachable: isSshTransportFailure(existResult), + unreachable: discoveryFailure.unreachable, manifest, backedUpDirs, failedDirs: [...stateDirs], backedUpFiles, failedFiles: stateFiles.map((f) => f.path), + error: discoveryFailure.error, }; } + _log( + `Existing dirs in sandbox: [${existingDirs.join(",")}] (${existingDirs.length}/${stateDirs.length})`, + ); if (existingDirs.length === 0) { _log("No state dirs found in sandbox (all empty)"); @@ -1580,19 +1627,16 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = // SECURITY: Strip credentials from the local backup sanitizeBackupDirectory(backupPath); - // Record any discovered per-agent workspace-* directories in the manifest - // alongside the manifest-declared state dirs, so restoreSandboxState() - // finds them when filtering backupPath contents. Preserve declared order - // and append newly-discovered workspace-* names that weren't already in - // stateDirs. See issue #1260. - const discoveredWorkspaces = backedUpDirs.filter( - (d) => d.startsWith("workspace-") && !stateDirs.includes(d), + // Record dynamically discovered directories in the manifest alongside the + // exact declarations so restoreSandboxState() can find them in backupPath. + // Preserve exact declaration order, followed by prefix-discovery order. + const discoveredStateDirs = backedUpDirs.filter( + (dirName) => + !stateDirs.includes(dirName) && stateDirPrefixes.some((prefix) => dirName.startsWith(prefix)), ); - if (discoveredWorkspaces.length > 0) { - manifest.stateDirs = [...stateDirs, ...discoveredWorkspaces]; - _log( - `Manifest stateDirs extended with multi-agent workspaces: [${discoveredWorkspaces.join(",")}]`, - ); + if (discoveredStateDirs.length > 0) { + manifest.stateDirs = [...stateDirs, ...discoveredStateDirs]; + _log(`Manifest stateDirs extended with prefix matches: [${discoveredStateDirs.join(",")}]`); } manifest.backedUpDirs = backedUpDirs; manifest.failedBackupDirs = failedDirs.filter((failedDir) => @@ -1905,16 +1949,29 @@ function restoreSandboxStateInternal( `Backup state directory '${normalizedBackupDir}' does not match target directory '${normalizedTargetDir}'`, ); } - // Runtime auth state is never restored: its backup copies are - // credential-scrubbed and would replace the sandbox's working device - // identity and pairing tokens with corrupt files (#6852). The current - // target manifest is authoritative here so legacy backups whose embedded - // manifests still list these dirs are also skipped. - const targetRuntimeAuthDirs = new Set(targetAgent.runtimeAuthStateDirs); - const skippedRuntimeAuthDirs = localDirs.filter((d) => targetRuntimeAuthDirs.has(d)); - if (skippedRuntimeAuthDirs.length > 0) { - _log(`Skipping runtime auth state dirs from restore: [${skippedRuntimeAuthDirs.join(",")}]`); - for (const d of skippedRuntimeAuthDirs) { + // The current target manifest remains authoritative for non-backup state, + // including legacy snapshots whose embedded manifests still list it. + const targetNonBackupDirs = targetAgent.nonBackupStateDirs; + const targetNonBackupPrefixes = targetAgent.nonBackupStateDirPrefixes; + const isTargetNonBackupDir = (dirName: string): boolean => + isAllowedDiscoveredStateDir(dirName, targetNonBackupDirs, targetNonBackupPrefixes); + const targetBackupDirs = targetAgent.backupStateDirs; + const targetBackupPrefixes = targetAgent.backupStateDirPrefixes; + const isTargetBackupDir = (dirName: string): boolean => + !isTargetNonBackupDir(dirName) && + isAllowedDiscoveredStateDir(dirName, targetBackupDirs, targetBackupPrefixes); + const undeclaredSnapshotDirs = manifest.stateDirs.filter( + (dirName) => !isTargetBackupDir(dirName) && !isTargetNonBackupDir(dirName), + ); + if (undeclaredSnapshotDirs.length > 0) { + return failRestoreContract( + `Backup state directories are not declared by target agent '${options.targetAgentType}': ${undeclaredSnapshotDirs.join(", ")}`, + ); + } + const skippedNonBackupDirs = localDirs.filter(isTargetNonBackupDir); + if (skippedNonBackupDirs.length > 0) { + _log(`Skipping non-backup state dirs from restore: [${skippedNonBackupDirs.join(",")}]`); + for (const d of skippedNonBackupDirs) { localDirs.splice(localDirs.indexOf(d), 1); } } @@ -1928,7 +1985,8 @@ function restoreSandboxStateInternal( ? [] : manifest.stateDirs.filter( (stateDir) => - !targetRuntimeAuthDirs.has(stateDir) && + isTargetBackupDir(stateDir) && + !isTargetNonBackupDir(stateDir) && !localDirSet.has(stateDir) && !failedBackupDirs.has(stateDir), ); diff --git a/src/lib/state/user-managed-files-probe.test.ts b/src/lib/state/user-managed-files-probe.test.ts index 52b3aba931b..1ec4b322c0a 100644 --- a/src/lib/state/user-managed-files-probe.test.ts +++ b/src/lib/state/user-managed-files-probe.test.ts @@ -57,6 +57,7 @@ function makeFakeAgent(declared: string[]): ReturnType configFile: "config.toml", envFile: null, format: "toml", + shieldsFiles: [], }, inferenceProviderOptions: [], stateDirs: [], diff --git a/test/destroy-wipe-sandbox-state.test.ts b/test/destroy-wipe-sandbox-state.test.ts index eedf6feb5ac..2f35254e32c 100644 --- a/test/destroy-wipe-sandbox-state.test.ts +++ b/test/destroy-wipe-sandbox-state.test.ts @@ -31,6 +31,7 @@ function buildDeps(overrides: Partial> = {}) { loadAgent: vi.fn(() => ({ configPaths: { dir: "/sandbox/.openclaw" }, stateDirs: ["agents", "extensions", "workspace", "skills", "hooks", "identity"], + stateDirPrefixes: ["workspace-"], stateFiles: [], })), runOpenshell, @@ -69,13 +70,21 @@ describe("wipeSandboxState (#5449)", () => { expect(script).toMatch(/rm\s+-rf/); }); - it("also removes multi-agent workspace-* dirs (#1260)", () => { - const { deps, runOpenshell } = buildDeps(); + it("also removes directories matching an agent-declared prefix (#1260)", () => { + const { deps, runOpenshell } = buildDeps({ + loadAgent: vi.fn(() => ({ + configPaths: { dir: "/sandbox/.openclaw" }, + stateDirs: ["workspace"], + stateDirPrefixes: ["worker-"], + stateFiles: [], + })), + }); destroy.wipeSandboxState("test-sb", deps as never); const { script } = execCommand(runOpenshell); - expect(script).toContain("workspace-*"); + expect(script).toContain("'worker-'*"); + expect(script).not.toContain("workspace-*"); }); it("passes ignoreError so a wipe failure never aborts destroy", () => { @@ -133,6 +142,7 @@ describe("wipeSandboxState (#5449)", () => { loadAgent: vi.fn(() => ({ configPaths: { dir: "/sandbox/.openclaw" }, stateDirs: ["workspace", "../etc", "/etc/passwd"], + stateDirPrefixes: [], stateFiles: [], })), }); @@ -165,6 +175,7 @@ describe("wipeSandboxState (#5449)", () => { loadAgent: vi.fn(() => ({ configPaths: { dir: "/sandbox/.openclaw" }, stateDirs: [], + stateDirPrefixes: [], stateFiles: [ { path: "agents.json" }, { path: "../../../etc/shadow" }, @@ -185,6 +196,28 @@ describe("wipeSandboxState (#5449)", () => { } }); + it("skips a state_dir prefix that escapes the agent config dir", () => { + const warnings: string[] = []; + const { deps, runOpenshell } = buildDeps({ + loadAgent: vi.fn(() => ({ + configPaths: { dir: "/sandbox/.openclaw" }, + stateDirs: [], + stateDirPrefixes: ["workspace-", "../escape-", "/tmp/escape-"], + stateFiles: [], + })), + warn: (message: string) => warnings.push(message), + }); + + destroy.wipeSandboxState("test-sb", deps as never); + + const { script } = execCommand(runOpenshell); + expect(script).toContain("'workspace-'*"); + expect(script).not.toContain("../escape-"); + expect(script).not.toContain("/tmp/escape-"); + expect(warnings.join("\n")).toContain("../escape-"); + expect(warnings.join("\n")).toContain("/tmp/escape-"); + }); + // PRA-3 on #5455: an accepted manifest path containing shell metacharacters // (single quote, backtick, dollar sign, space) must reach the destructive // script intact, single-quoted, with no expansion or word-splitting risk. @@ -198,6 +231,7 @@ describe("wipeSandboxState (#5449)", () => { // each carries a shell metacharacter that an unsafe construction // would let the shell interpret. stateDirs: ["state with space", "state'with'quote", "state`with`backtick"], + stateDirPrefixes: ["prefix'with'quote-"], stateFiles: [{ path: "file$with$dollar" }], })), }); @@ -211,6 +245,7 @@ describe("wipeSandboxState (#5449)", () => { expect(script).toContain("'state'\\''with'\\''quote'"); expect(script).toContain("'state`with`backtick'"); expect(script).toContain("'file$with$dollar'"); + expect(script).toContain("'prefix'\\''with'\\''quote-'*"); }); // PRA-2 on #5455 (round 4): a manifest declaring an unsafe top-level config @@ -236,6 +271,7 @@ describe("wipeSandboxState (#5449)", () => { loadAgent: vi.fn(() => ({ configPaths: { dir }, stateDirs: ["workspace"], + stateDirPrefixes: ["workspace-"], stateFiles: [], })), }); @@ -329,6 +365,7 @@ describe("wipeSandboxState (#5449)", () => { loadAgent: vi.fn(() => ({ configPaths: { dir: fakeConfigDir }, stateDirs: ["workspace"], + stateDirPrefixes: ["workspace-"], stateFiles: [], })), runOpenshell, @@ -348,6 +385,7 @@ describe("wipeSandboxState (#5449)", () => { deps.loadAgent = vi.fn(() => ({ configPaths: { dir: simulatedConfigDir }, stateDirs: ["workspace"], + stateDirPrefixes: ["workspace-"], stateFiles: [], })); runOpenshell.mockImplementation((args: string[]): { status: number | null } => @@ -384,6 +422,7 @@ describe("wipeSandboxState (#5449)", () => { agent: "openclaw", configDir: "/sandbox/.openclaw", stateDirs: ["agents", "extensions", "workspace", "skills", "hooks", "identity"], + stateDirPrefixes: ["workspace-"], stateFiles: [], label: "openclaw", }, @@ -402,6 +441,7 @@ describe("wipeSandboxState (#5449)", () => { "workspace", "profiles", ], + stateDirPrefixes: [], stateFiles: [{ path: "SOUL.md" }, { path: ".hermes_history" }], label: "hermes", }, @@ -409,6 +449,7 @@ describe("wipeSandboxState (#5449)", () => { agent: "langchain-deepagents-code", configDir: "/sandbox/.deepagents", stateDirs: [".state", "skills", "agent/skills"], + stateDirPrefixes: [], stateFiles: [{ path: "config.toml" }], label: "langchain-deepagents-code", }, @@ -416,35 +457,42 @@ describe("wipeSandboxState (#5449)", () => { agent, configDir, stateDirs, + stateDirPrefixes, stateFiles, }) => { const { deps, runOpenshell } = buildDeps({ getSandbox: vi.fn(() => ({ agent }) as never), - loadAgent: vi.fn(() => ({ configPaths: { dir: configDir }, stateDirs, stateFiles })), + loadAgent: vi.fn(() => ({ + configPaths: { dir: configDir }, + stateDirs, + stateDirPrefixes, + stateFiles, + })), }); destroy.wipeSandboxState("test-sb", deps as never); const { script } = execCommand(runOpenshell); expect(script).toContain(`cd '${configDir}'`); - expect(script).toContain("workspace-*"); for (const dir of stateDirs) { expect(script).toContain(`'${dir}'`); } + for (const prefix of stateDirPrefixes) { + expect(script).toContain(`'${prefix}'*`); + } for (const file of stateFiles) { expect(script).toContain(`'${file.path}'`); } }); - // Ultra advisor PRA-2 on #5455 (empty state dirs): a manifest with empty - // state_dirs and state_files must still issue the wipe so the multi-agent - // `workspace-*` glob runs, but the `rm -rf --` argv must not collapse into - // a syntactically broken command. - it("issues a syntactically valid wipe with empty state_dirs and state_files for Ultra PRA-2 (#5455)", () => { + // Ultra advisor PRA-2 on #5455 (empty exact state dirs): a manifest with + // only a declared prefix must still issue a syntactically valid wipe. + it("issues a syntactically valid wipe with only a declared state_dir prefix (#5455)", () => { const { deps, runOpenshell } = buildDeps({ loadAgent: vi.fn(() => ({ configPaths: { dir: "/sandbox/.openclaw" }, stateDirs: [], + stateDirPrefixes: ["workspace-"], stateFiles: [], })), }); @@ -452,9 +500,9 @@ describe("wipeSandboxState (#5449)", () => { destroy.wipeSandboxState("test-sb", deps as never); const { script } = execCommand(runOpenshell); - // The script still cd's and runs rm -rf with only the workspace-* glob. + // The script still cd's and runs rm -rf with only the declared prefix. expect(script).toContain("cd '/sandbox/.openclaw'"); - expect(script).toMatch(/rm\s+-rf\s+--\s+workspace-\*/); + expect(script).toMatch(/rm\s+-rf\s+--\s+'workspace-'\*/); // No empty quoted argument that would expand to nothing in sh -c. expect(script).not.toMatch(/rm\s+-rf\s+--\s*''/); }); diff --git a/test/e2e/live/state-dir-guard-metadata.test.ts b/test/e2e/live/state-dir-guard-metadata.test.ts index 903676f3c77..dacf7cfadd5 100644 --- a/test/e2e/live/state-dir-guard-metadata.test.ts +++ b/test/e2e/live/state-dir-guard-metadata.test.ts @@ -19,8 +19,10 @@ import { type TreeMeasurement, treeDirectories, } from "./state-dir-guard-metadata-helpers.ts"; +import { loadAgent } from "../../../src/lib/agent/defs.ts"; const GUARD_PATH = "/usr/local/lib/nemoclaw/state-dir-guard.py"; +const STATE_LOCK_PLAN_PATH = "/usr/local/share/nemoclaw/state-lock-plan.json"; const ACL_EXTRA_UID = 65_534; const MARKER_XATTR = "user.nemoclaw_e2e_marker"; const TEST_TIMEOUT_MS = 10 * 60_000; @@ -31,19 +33,31 @@ const AGENTS = [ id: "openclaw", image: process.env.NEMOCLAW_OPENCLAW_TEST_IMAGE ?? "nemoclaw-production", configDir: "/sandbox/.openclaw", + stateRoots: { readOnly: "extensions", confidential: "credentials" }, }, { id: "hermes", image: process.env.NEMOCLAW_HERMES_TEST_IMAGE ?? "nemoclaw-hermes-production", configDir: "/sandbox/.hermes", + stateRoots: { readOnly: "plugins", confidential: "pairing" }, }, ] as const; type AgentCase = (typeof AGENTS)[number]; type GuardAction = "preflight" | "lock" | "unlock"; -type GuardTargets = Record<"plugins" | "credentials", string>; +type GuardTargets = Record<"readOnly" | "confidential", string>; type AccessResult = { read: boolean; write: boolean }; +interface InstalledStateLockPlan { + $comment?: string; + version: number; + readOnlyRoots: string[]; + confidentialRoots: string[]; + readOnlyPrefixes: string[]; + confidentialPrefixes: string[]; + writableSubpaths: string[]; +} + interface GuardLimits { maxEntries: number; maxLogicalBytes: number; @@ -161,27 +175,44 @@ async function installedGuardLimits(host: HostCliClient, agent: AgentCase): Prom return JSON.parse(result.stdout.trim()) as GuardLimits; } -function seedTree(fixtureRoot: string, marker: string): GuardTargets { +async function installedStateLockPlan( + host: HostCliClient, + agent: AgentCase, +): Promise { + const result = await expectCommand( + host, + "docker", + ["run", "--rm", "--user", "0", "--entrypoint", "cat", agent.image, STATE_LOCK_PLAN_PATH], + `${agent.id}-installed-state-lock-plan`, + ); + return JSON.parse(result.stdout) as InstalledStateLockPlan; +} + +function seedTree(agent: AgentCase, fixtureRoot: string, marker: string): GuardTargets { const targets = { - plugins: path.join(fixtureRoot, "plugins", "nemoclaw-e2e", "state", "index.json"), - credentials: path.join( + readOnly: path.join( + fixtureRoot, + agent.stateRoots.readOnly, + "nemoclaw-e2e", + "state", + "index.json", + ), + confidential: path.join( fixtureRoot, - "credentials", - "providers", - "nvidia", - "profiles", - "default.json", + agent.stateRoots.confidential, + "nemoclaw-e2e", + "state", + "index.json", ), }; - for (const [rootName, target] of Object.entries(targets)) { + for (const [policy, target] of Object.entries(targets)) { fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, JSON.stringify({ marker, rootName, kind: "metadata-target" })); + fs.writeFileSync(target, JSON.stringify({ marker, policy, kind: "metadata-target" })); fs.chmodSync(target, 0o666); for (let index = 0; index < 24; index += 1) { const shard = String(index % 4).padStart(2, "0"); const file = path.join( - fixtureRoot, - rootName, + path.dirname(path.dirname(target)), "production-shaped", `shard-${shard}`, "cache", @@ -276,7 +307,14 @@ async function runGuard( const result = await command( host, "docker", - [...mountArgs(agent, fixtureRoot, GUARD_PATH), action, "--config-dir", agent.configDir], + [ + ...mountArgs(agent, fixtureRoot, GUARD_PATH), + action, + "--config-dir", + agent.configDir, + "--plan-file", + STATE_LOCK_PLAN_PATH, + ], `${agent.id}-guard-${action}`, ); const elapsedMs = performance.now() - started; @@ -493,17 +531,41 @@ async function runAgentProbe( ); expect(installedMode.stdout.trim()).toBe("root:root 500"); + const installedPlanMode = await expectCommand( + host, + "docker", + [ + "run", + "--rm", + "--user", + "0", + "--entrypoint", + "stat", + agent.image, + "-c", + "%U:%G %a", + STATE_LOCK_PLAN_PATH, + ], + `${agent.id}-installed-state-lock-plan-mode`, + ); + expect(installedPlanMode.stdout.trim()).toBe("root:root 444"); + + const stateLockPlan = await installedStateLockPlan(host, agent); + const { $comment, ...installedPlan } = stateLockPlan; + expect(typeof $comment).toBe("string"); + expect(installedPlan).toEqual(loadAgent(agent.id).stateLockPlan); + const identity = await dockerIdentity(host, agent); const limits = await installedGuardLimits(host, agent); const marker = `nemoclaw-${agent.id}-${crypto.randomBytes(8).toString("hex")}`; - const targets = seedTree(fixtureRoot, marker); + const targets = seedTree(agent, fixtureRoot, marker); await configureMetadata(host, fixtureRoot, targets, marker, agent.id, skip); const bindProbe = await proveExactBindMount( host, agent, fixtureRoot, - targets.plugins, - `${marker}-plugins`, + targets.readOnly, + `${marker}-readOnly`, ); requireEnvironment( bindProbe.exitCode === 0, @@ -519,8 +581,8 @@ async function runAgentProbe( `${agent.id}-seed-ownership`, ); const seeded = { - plugins: await readMetadata(host, targets.plugins, `${agent.id}-seeded-plugins`), - credentials: await readMetadata(host, targets.credentials, `${agent.id}-seeded-credentials`), + readOnly: await readMetadata(host, targets.readOnly, `${agent.id}-seeded-read-only`), + confidential: await readMetadata(host, targets.confidential, `${agent.id}-seeded-confidential`), }; for (const metadata of Object.values(seeded)) { expect(metadata).toMatchObject({ @@ -531,19 +593,23 @@ async function runAgentProbe( }); } await expectNamedUserAccessState(host, agent, fixtureRoot, targets, "seeded", { - plugins: { read: true, write: true }, - credentials: { read: true, write: true }, + readOnly: { read: true, write: true }, + confidential: { read: true, write: true }, }); const preflight = await runGuard(host, agent, fixtureRoot, "preflight"); const preflightMetadata = { - plugins: await readMetadata(host, targets.plugins, `${agent.id}-preflight-plugins`), - credentials: await readMetadata(host, targets.credentials, `${agent.id}-preflight-credentials`), + readOnly: await readMetadata(host, targets.readOnly, `${agent.id}-preflight-read-only`), + confidential: await readMetadata( + host, + targets.confidential, + `${agent.id}-preflight-confidential`, + ), }; expect(preflightMetadata).toEqual(seeded); await expectNamedUserAccessState(host, agent, fixtureRoot, targets, "preflight", { - plugins: { read: true, write: true }, - credentials: { read: true, write: true }, + readOnly: { read: true, write: true }, + confidential: { read: true, write: true }, }); const lock = await runGuard(host, agent, fixtureRoot, "lock"); @@ -552,28 +618,28 @@ async function runAgentProbe( files: tree.files, }); const locked = { - plugins: await readMetadata(host, targets.plugins, `${agent.id}-locked-plugins`), - credentials: await readMetadata(host, targets.credentials, `${agent.id}-locked-credentials`), + readOnly: await readMetadata(host, targets.readOnly, `${agent.id}-locked-read-only`), + confidential: await readMetadata(host, targets.confidential, `${agent.id}-locked-confidential`), }; - expectPreserved(locked.plugins, seeded.plugins, `${marker}-plugins`); - expectPreserved(locked.credentials, seeded.credentials, `${marker}-credentials`); - expect(locked.plugins).toMatchObject({ + expectPreserved(locked.readOnly, seeded.readOnly, `${marker}-readOnly`); + expectPreserved(locked.confidential, seeded.confidential, `${marker}-confidential`); + expect(locked.readOnly).toMatchObject({ uid: 0, gid: identity.gid, mode: "0644", acl: { rawNamedUser: "rwx", effectiveNamedUser: "r--", mask: "r--" }, }); - expect(locked.credentials).toMatchObject({ + expect(locked.confidential).toMatchObject({ uid: 0, gid: 0, mode: "0600", acl: { rawNamedUser: "rwx", effectiveNamedUser: "---", mask: "---" }, }); - expect(locked.plugins.inode).not.toBe(seeded.plugins.inode); - expect(locked.credentials.inode).not.toBe(seeded.credentials.inode); + expect(locked.readOnly.inode).not.toBe(seeded.readOnly.inode); + expect(locked.confidential.inode).not.toBe(seeded.confidential.inode); await expectNamedUserAccessState(host, agent, fixtureRoot, targets, "locked", { - plugins: { read: true, write: false }, - credentials: { read: false, write: false }, + readOnly: { read: true, write: false }, + confidential: { read: false, write: false }, }); const unlock = await runGuard(host, agent, fixtureRoot, "unlock"); @@ -582,8 +648,12 @@ async function runAgentProbe( files: tree.files, }); const unlocked = { - plugins: await readMetadata(host, targets.plugins, `${agent.id}-unlocked-plugins`), - credentials: await readMetadata(host, targets.credentials, `${agent.id}-unlocked-credentials`), + readOnly: await readMetadata(host, targets.readOnly, `${agent.id}-unlocked-read-only`), + confidential: await readMetadata( + host, + targets.confidential, + `${agent.id}-unlocked-confidential`, + ), }; for (const [name, metadata] of Object.entries(unlocked)) { const original = seeded[name as keyof typeof seeded]; @@ -598,8 +668,8 @@ async function runAgentProbe( expect(metadata.inode).toBe(lockedMetadata.inode); } await expectNamedUserAccessState(host, agent, fixtureRoot, targets, "unlocked", { - plugins: { read: true, write: true }, - credentials: { read: true, write: true }, + readOnly: { read: true, write: true }, + confidential: { read: true, write: true }, }); const elapsed = { @@ -611,6 +681,11 @@ async function runAgentProbe( await artifacts.writeJson(`${agent.id}-budget-evidence.json`, { agent: agent.id, image: agent.image, + stateLockPlan: { + path: STATE_LOCK_PLAN_PATH, + readOnlyRoot: agent.stateRoots.readOnly, + confidentialRoot: agent.stateRoots.confidential, + }, fixture: tree, guardLimits: limits, estimatedPeakEntriesPerMutationBudget: tree.entries * 2, @@ -627,7 +702,7 @@ async function runAgentProbe( // biome-ignore format: preserve legacy live-test body formatting so phase-only changes stay reviewable. test( - "installed state-dir guard preserves xattrs and clamps effective ACLs for OpenClaw and Hermes (#6059)", + "installed state-dir guard applies each agent's generated plan without losing metadata (#6059)", { ...testTimeoutOptions(TEST_TIMEOUT_MS), meta: { @@ -644,9 +719,10 @@ test( id: "state-dir-guard-metadata", boundary: "prebuilt-production-images-exact-bind-mount", contracts: [ - "the installed root-owned guard handles preflight, lock, and unlock for OpenClaw and Hermes", - "plugins and credentials preserve content and user xattrs across fresh-inode locking", - "numeric ownership, mode, raw ACL, mask, and effective named-user access match each policy", + "the installed root-owned guard loads each image's generated AgentDefinition state-lock plan", + "one declared read-only root and one declared confidential root exercise each agent's lifecycle", + "content and user xattrs survive fresh-inode locking for both declared policies", + "numeric ownership, mode, raw ACL, mask, and effective named-user access match each declared policy", "representative-tree entry, byte, depth, copy, and wall-time evidence stays within shipped limits", ], }); @@ -718,6 +794,8 @@ test( id: "state-dir-guard-metadata", agents: AGENTS.map((agent) => agent.id), assertions: { + installedGeneratedPlanLoaded: true, + agentSpecificPolicyRootsExercised: true, exactBindMountCapabilities: true, preflightNonMutating: true, lockReplacesInodes: true, diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index f95102a3747..d33d8270786 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -40,14 +40,28 @@ export function makeAgent(overrides: Partial = {}): AgentDefini configFile: "config.yaml", envFile: ".env", format: "yaml", + shieldsFiles: [".env"], }, inferenceProviderOptions: [], mcpCapability: { support: "disabled", reason: "test fixture", }, + stateDirectories: [], stateDirs: [], - runtimeAuthStateDirs: [], + stateDirPrefixes: [], + backupStateDirs: [], + backupStateDirPrefixes: [], + nonBackupStateDirs: [], + nonBackupStateDirPrefixes: [], + stateLockPlan: { + version: 1, + readOnlyRoots: [], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, stateFiles: [], userManagedFiles: [], versionCommand: "hermes --version", diff --git a/test/helpers/shell-source.ts b/test/helpers/shell-source.ts new file mode 100644 index 00000000000..c00833f8325 --- /dev/null +++ b/test/helpers/shell-source.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function extractShellFunctionFromSource(src: string, name: string): string { + const header = `${name}() {`; + const start = src.indexOf(header); + if (start === -1) { + throw new Error(`Expected ${name} in shell source`); + } + const bodyStart = start + header.length; + const lines = src.slice(bodyStart).split(/(?<=\n)/); + let offset = 0; + let heredocEnd: string | undefined; + for (const line of lines) { + const bareLine = line.replace(/\r?\n$/, ""); + if (heredocEnd) { + offset += line.length; + if (bareLine === heredocEnd) { + heredocEnd = undefined; + } + continue; + } + const heredoc = line.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/); + if (heredoc) { + heredocEnd = heredoc[1]; + } + if (bareLine === "}") { + return `${name}() {${src.slice(bodyStart, bodyStart + offset)}\n}`; + } + offset += line.length; + } + throw new Error(`Expected closing brace for ${name} in shell source`); +} diff --git a/test/hermes-config-transaction-wiring.test.ts b/test/hermes-config-transaction-wiring.test.ts index 5d64e6728e8..5cbf79a7943 100644 --- a/test/hermes-config-transaction-wiring.test.ts +++ b/test/hermes-config-transaction-wiring.test.ts @@ -35,7 +35,12 @@ installMock(source("state", "registry.js"), { }); installMock(source("agent", "defs.js"), { loadAgent: () => ({ - configPaths: { dir: "/sandbox/.hermes", configFile: "config.yaml", format: "yaml" }, + configPaths: { + dir: "/sandbox/.hermes", + configFile: "config.yaml", + format: "yaml", + shieldsFiles: [".env"], + }, }), }); installMock(source("adapters", "openshell", "client.js"), { diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 27d4e1016cf..019b9de94a0 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -250,6 +250,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py", "COPY src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.85.json /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json", "COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py", + "COPY agents/hermes/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json", "COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/", ], }, @@ -341,6 +342,7 @@ describe("Hermes final image layout", () => { "/usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755'", "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755'", "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", + "/usr/local/share/nemoclaw/state-lock-plan.json 'root:root 444'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444'", "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", "/scripts/checks/node-tar-image-scan.mts 'root:root 755'", diff --git a/test/hermes-runtime-config-guard.test.ts b/test/hermes-runtime-config-guard.test.ts index 4b1606c9b11..081c2ef1161 100644 --- a/test/hermes-runtime-config-guard.test.ts +++ b/test/hermes-runtime-config-guard.test.ts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { loadAgent } from "../src/lib/agent/defs"; const RUNTIME_CONFIG_GUARD = path.join( import.meta.dirname, @@ -30,6 +31,31 @@ sys.modules[spec.name] = guard spec.loader.exec_module(guard) `; +describe("Hermes sealed configuration contract", () => { + it("keeps the root guard in parity with the host manifest projection (#8006)", () => { + const result = runPythonHarness(String.raw` +import importlib.util +import json +import sys +import types + +# This contract check reads a constant only. Avoid requiring the optional host +# PyYAML package while loading the image-owned module for that narrow purpose. +sys.modules["yaml"] = types.ModuleType("yaml") +spec = importlib.util.spec_from_file_location("runtime_config_guard", sys.argv[1]) +guard = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = guard +spec.loader.exec_module(guard) +print(json.dumps(guard.SEALED_FILE_NAMES)) +`); + expect(result.status, result.stderr).toBe(0); + + const config = loadAgent("hermes").configPaths; + const manifestFiles = [config.configFile, ...config.shieldsFiles, ".config-hash"].sort(); + expect((JSON.parse(result.stdout) as string[]).sort()).toEqual(manifestFiles); + }); +}); + describe("Hermes runtime config hash refresh race protection", () => { it("creates an absent private runtime directory through its pinned parent", () => { const result = runPythonHarness(`${loadGuardModule} @@ -616,7 +642,18 @@ with tempfile.TemporaryDirectory() as tmp: }); describe("Hermes shields outer namespace containment", () => { - it("keeps the exact state worker PID alive as the in-container timeout owner", () => { + it.each([ + { + label: "the installed image plan during startup recovery", + planJson: "", + planArgs: ["--plan-file", "/usr/local/share/nemoclaw/state-lock-plan.json"], + }, + { + label: "the explicit host plan during a live transition", + planJson: '{"version":1}', + planArgs: ["--plan-json", '{"version":1}'], + }, + ])("keeps the exact state worker PID alive with $label", ({ planJson, planArgs }) => { const result = runPythonHarness(`${loadGuardModule} import json @@ -636,6 +673,7 @@ try: "/run/nemoclaw/hermes-restart-seal.json", "a" * 64, "lock", + ${JSON.stringify(planJson)}, ) except RuntimeError as exc: captured["error"] = str(exc) @@ -661,6 +699,7 @@ print(json.dumps(captured)) "lock", "--config-dir", "/sandbox/.hermes", + ...planArgs, ]); }); diff --git a/test/nemoclaw-start-locked-migration.test.ts b/test/nemoclaw-start-locked-migration.test.ts new file mode 100644 index 00000000000..e83a11c162e --- /dev/null +++ b/test/nemoclaw-start-locked-migration.test.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { extractShellFunctionFromSource } from "./helpers/shell-source"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +describe("legacy migration with Shields active", () => { + const source = fs.readFileSync(START_SCRIPT, "utf-8"); + const migrationFunctions = [ + "path_has_immutable_bit", + "ensure_mutable_for_migration", + "chown_tree_no_symlink_follow", + "legacy_symlinks_exist", + "assert_no_legacy_layout", + "migrate_legacy_layout", + ] + .map((name) => extractShellFunctionFromSource(source, name)) + .join("\n"); + + function runLockedMigration( + configDir: string, + dataDir: string, + relockLog: string, + options: { configGuardStatus?: number; stateGuardStatus?: number } = {}, + ) { + const script = path.join(path.dirname(configDir), "locked-migration.sh"); + fs.writeFileSync( + script, + `#!/usr/bin/env bash +set -euo pipefail +id() { if [ "\${1:-}" = "-u" ]; then echo 0; else command id "$@"; fi; } +stat() { + if [ "\${1:-}" = "-c" ] && [ "\${2:-}" = "%U" ] && [ "\${3:-}" = ${JSON.stringify(configDir)} ]; then + echo root + return 0 + fi + command stat "$@" +} +_OPENCLAW_STATE_DIR_GUARD=/usr/local/lib/nemoclaw/state-dir-guard.py +run_openclaw_config_guard() { + printf 'config:%s\\n' "$*" >>${JSON.stringify(relockLog)} + return ${options.configGuardStatus ?? 0} +} +timeout() { + printf 'state:%s\\n' "$*" >>${JSON.stringify(relockLog)} + return ${options.stateGuardStatus ?? 0} +} +${migrationFunctions} +migrate_legacy_layout ${JSON.stringify(configDir)} ${JSON.stringify(dataDir)} openclaw +`, + { mode: 0o700 }, + ); + try { + return spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000 }); + } finally { + fs.rmSync(script, { force: true }); + } + } + + it("reapplies the canonical config and state-dir guards after a locked migration (#8006)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-migrate-locked-")); + const configDir = path.join(tempDir, ".openclaw"); + const dataDir = path.join(tempDir, ".openclaw-data"); + const relockLog = path.join(tempDir, "relock.log"); + fs.mkdirSync(configDir); + fs.mkdirSync(path.join(dataDir, "skills"), { recursive: true }); + fs.writeFileSync(path.join(dataDir, "skills", "skill.txt"), "legacy skill"); + + try { + const result = runLockedMigration(configDir, dataDir, relockLog); + + expect(result.status).toBe(0); + expect(fs.readFileSync(relockLog, "utf-8").trim().split("\n")).toEqual([ + "config:recover --startup-owner", + `state:--signal=TERM --kill-after=5s 12m python3 -I /usr/local/lib/nemoclaw/state-dir-guard.py lock --config-dir ${configDir} --plan-file /usr/local/share/nemoclaw/state-lock-plan.json`, + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it.each([ + { + guard: "config", + options: { configGuardStatus: 1 }, + error: "canonical config guard refused", + expectedCalls: 1, + }, + { + guard: "state-dir", + options: { stateGuardStatus: 1 }, + error: "canonical state-dir guard refused", + expectedCalls: 2, + }, + ])("keeps the legacy data retryable when the canonical $guard guard refuses relock (#8006)", (testCase) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-migrate-relock-fail-")); + const configDir = path.join(tempDir, ".openclaw"); + const dataDir = path.join(tempDir, ".openclaw-data"); + const relockLog = path.join(tempDir, "relock.log"); + fs.mkdirSync(configDir); + fs.mkdirSync(path.join(dataDir, "skills"), { recursive: true }); + + try { + const result = runLockedMigration(configDir, dataDir, relockLog, testCase.options); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(testCase.error); + expect(fs.readFileSync(relockLog, "utf-8").trim().split("\n")).toHaveLength( + testCase.expectedCalls, + ); + expect(fs.existsSync(dataDir)).toBe(true); + expect(fs.existsSync(path.join(configDir, ".migration-complete"))).toBe(false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index b2fd8fb9a70..9670490b916 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -9,6 +9,8 @@ import path from "node:path"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; +import { extractShellFunctionFromSource } from "./helpers/shell-source"; + const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); const INSTALLED_APPROVAL_POLICY = "/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py"; @@ -188,37 +190,6 @@ def _nemoclaw_test_sleep(seconds): _nemoclaw_test_clock.__setitem__(0, _nemoclaw ); } -function extractShellFunctionFromSource(src: string, name: string): string { - const header = `${name}() {`; - const start = src.indexOf(header); - if (start === -1) { - throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); - } - const bodyStart = start + header.length; - const lines = src.slice(bodyStart).split(/(?<=\n)/); - let offset = 0; - let heredocEnd: string | undefined; - for (const line of lines) { - const bareLine = line.replace(/\r?\n$/, ""); - if (heredocEnd) { - offset += line.length; - if (bareLine === heredocEnd) { - heredocEnd = undefined; - } - continue; - } - const heredoc = line.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/); - if (heredoc) { - heredocEnd = heredoc[1]; - } - if (bareLine === "}") { - return `${name}() {${src.slice(bodyStart, bodyStart + offset)}\n}`; - } - offset += line.length; - } - throw new Error(`Expected closing brace for ${name} in scripts/nemoclaw-start.sh`); -} - function runEmbeddedPreload( script: string, argv1: string, @@ -2265,7 +2236,6 @@ describe("NC-2227-01: legacy migration behavior", () => { return [ "path_has_immutable_bit", "ensure_mutable_for_migration", - "restore_immutable_if_possible", "chown_tree_no_symlink_follow", "legacy_symlinks_exist", "assert_no_legacy_layout", @@ -2278,7 +2248,11 @@ describe("NC-2227-01: legacy migration behavior", () => { function runMigration( configDir: string, dataDir: string, - opts: { fakeRoot?: boolean; fakeSandboxOwner?: boolean; fakeRootConfigOwner?: boolean } = {}, + opts: { + fakeRoot?: boolean; + fakeSandboxOwner?: boolean; + fakeRootConfigOwner?: boolean; + } = {}, ) { const script = path.join(path.dirname(configDir), `migration-${Date.now()}.sh`); const prelude = [ diff --git a/test/openclaw-config-transaction-wiring.test.ts b/test/openclaw-config-transaction-wiring.test.ts index 703caf4a01c..dfdec5d94ac 100644 --- a/test/openclaw-config-transaction-wiring.test.ts +++ b/test/openclaw-config-transaction-wiring.test.ts @@ -36,7 +36,12 @@ installMock(source("state", "registry.js"), { }); installMock(source("agent", "defs.js"), { loadAgent: () => ({ - configPaths: { dir: "/sandbox/.openclaw", configFile: "openclaw.json", format: "json" }, + configPaths: { + dir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", + shieldsFiles: [], + }, }), }); installMock(source("adapters", "openshell", "client.js"), { diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 384c9a54863..2d827ee8493 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -88,6 +88,7 @@ describe("OpenClaw final image layout", () => { "COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py", "COPY scripts/lib/normalize_mutable_config_perms.py /usr/local/lib/nemoclaw/normalize_mutable_config_perms.py", "COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py", + "COPY agents/openclaw/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json", "COPY scripts/openclaw-config-guard.py /usr/local/lib/nemoclaw/openclaw-config-guard.py", "COPY scripts/managed-gateway-control.py /usr/local/lib/nemoclaw/managed-gateway-control.py", "COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start", @@ -134,6 +135,7 @@ describe("OpenClaw final image layout", () => { "/usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts 'root:root:755'", "/usr/local/bin/nemoclaw-gateway-control 'root:root:700'", "/usr/local/lib/nemoclaw/state-dir-guard.py 'root:root:500'", + "/usr/local/share/nemoclaw/state-lock-plan.json 'root:root:444'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root:644'", "/scripts/checks/node-tar-image-scan.mts 'root:root:755'", ]) { diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index 5d24e319db3..c0591305797 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -212,6 +212,11 @@ describe("OpenShell policy boundary package contract", () => { expect(validation).toEqual([true, false]); }); + it("ships agent manifests and generated state lock plans (#8006)", () => { + expect(packageFiles(repoRoot)).toContain("agents/*/manifest.yaml"); + expect(packageFiles(repoRoot)).toContain("agents/*/state-lock-plan.json"); + }); + it("ships an out-of-tree runtime sandbox-policy schema validator", { timeout: 90_000 }, () => { const productionDependencyTree = spawnSync( "npm", diff --git a/test/rebuild-shields-auto-unlock.test.ts b/test/rebuild-shields-auto-unlock.test.ts index c7504cecc4b..c86261c5ebd 100644 --- a/test/rebuild-shields-auto-unlock.test.ts +++ b/test/rebuild-shields-auto-unlock.test.ts @@ -14,10 +14,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { loadAgent } from "../src/lib/agent/defs"; import { killTimer } from "../src/lib/shields/timer-control"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const NODE_BIN = path.dirname(process.execPath); +const OPENCLAW_STATE_LOCK_PLAN = loadAgent("openclaw").stateLockPlan; const tmpFixtures: string[] = []; afterEach(() => { @@ -234,6 +236,18 @@ function readLockState() { function writeLockState(state) { fs.writeFileSync(lockStatePath, state); } +function completeStateDirGuard(action, consumeInput) { + if (consumeInput) fs.readFileSync(0, "utf8"); + if (action === "lock") writeLockState("locked"); + if (action === "unlock") writeLockState("unlocked"); + process.stdout.write(JSON.stringify({ + type: "result", + action, + status: "ok", + issueCount: 0, + }) + "\\n"); + process.exit(0); +} if (a[0]==="info") { process.stdout.write(JSON.stringify({ServerVersion:"27.0.0", OperatingSystem:"Docker Engine", NCPU:8, MemTotal:17179869184}) + "\\n"); process.exit(0); @@ -286,6 +300,9 @@ if (a[0]==="exec") { if (pythonIndex >= 0) { const helper = cmd[pythonIndex + 2]; const action = cmd[pythonIndex + 3]; + if (cmd[pythonIndex + 1] === "-I" && helper === "-" && cmd.includes("--plan-json")) { + completeStateDirGuard(action, true); + } if (helper === "/usr/local/lib/nemoclaw/openclaw-config-guard.py") { if (action === "lock") writeLockState("locked"); if (action === "unlock") writeLockState("unlocked"); @@ -300,17 +317,14 @@ if (a[0]==="exec") { process.exit(0); } if (helper === "/usr/local/lib/nemoclaw/state-dir-guard.py") { - if (action === "lock") writeLockState("locked"); - if (action === "unlock") writeLockState("unlocked"); - process.stdout.write(JSON.stringify({ - type: "result", - action, - status: "ok", - issueCount: 0, - }) + "\\n"); - process.exit(0); + completeStateDirGuard(action, false); } } + if (cmd[0]==="test" && cmd[1]==="-r") { process.exit(0); } + if (cmd[0]==="cat" && cmd[1]==="/usr/local/share/nemoclaw/state-lock-plan.json") { + process.stdout.write(JSON.stringify(${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}) + "\\n"); + process.exit(0); + } // Verification reads: // stat -c '%a %U:%G' → expect "660 sandbox:sandbox" or "2770 sandbox:sandbox" // lsattr -d → "----i------" (locked) or no immutable bit (unlocked) diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index f8c42b22cf6..0d8d3857d6a 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -17,6 +17,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { AgentStateLockPlan } from "../src/lib/agent/definition-types"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const MUTABLE_CONFIG_NORMALIZER = path.join( @@ -28,8 +29,25 @@ const MUTABLE_CONFIG_NORMALIZER = path.join( ); const OPENCLAW_CONFIG_GUARD = "/usr/local/lib/nemoclaw/openclaw-config-guard.py"; const STATE_DIR_GUARD = "/usr/local/lib/nemoclaw/state-dir-guard.py"; +const STATE_LOCK_PLAN = "/usr/local/share/nemoclaw/state-lock-plan.json"; const HERMES_RUNTIME_CONFIG_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"; const HERMES_LOCK_TOKEN = "a".repeat(64); +const OPENCLAW_STATE_LOCK_PLAN = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["credentials"], + readOnlyPrefixes: ["workspace-"], + confidentialPrefixes: [], + writableSubpaths: ["agents/*/sessions"], +}; +const HERMES_STATE_LOCK_PLAN = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: ["pairing"], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], +}; const HERMES_SEALED_GUARD_HELP = [ "begin-shields-transition", "run-state-dir-transition", @@ -38,8 +56,18 @@ const HERMES_SEALED_GUARD_HELP = [ "prepare-shields-abort", "abort-shields-transition", "--rollback-shields-mode", + "--state-lock-plan-json", ].join(" "); +function stateDirGuardAction(command: string[]): string | null { + const installedIndex = command.indexOf(STATE_DIR_GUARD); + if (installedIndex >= 0) return command[installedIndex + 1] ?? null; + const pythonIndex = command.indexOf("python3"); + return pythonIndex >= 0 && command[pythonIndex + 2] === "-" + ? (command[pythonIndex + 3] ?? null) + : null; +} + function extractShellFunctionFromSource(src: string, name: string): string { const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); if (!match) { @@ -125,7 +153,11 @@ function runMutableConfigNormalizer(configDir: string, ownedPaths: string[]) { function withMockedDockerExecFileSync( calls: string[][], run: () => T, - options: { hermesLockedTransaction?: boolean; symlinkedPaths?: ReadonlySet } = {}, + options: { + hermesLockedTransaction?: boolean; + installedStateLockPlan?: AgentStateLockPlan; + symlinkedPaths?: ReadonlySet; + } = {}, ): T { // eslint-disable-next-line @typescript-eslint/no-require-imports const dockerExecModule = require("../src/lib/adapters/docker/exec.js") as { @@ -136,7 +168,13 @@ function withMockedDockerExecFileSync( const originalDockerSpawnSync = dockerExecModule.dockerSpawnSync; const shieldsModulePath = require.resolve("../src/lib/shields/index.js"); const privilegedExecPath = require.resolve("../src/lib/sandbox/privileged-exec.js"); + const transitionLockPath = require.resolve("../src/lib/shields/transition-lock.js"); const priorPrivilegedExec = require.cache[privilegedExecPath]; + const priorTransitionLock = require.cache[transitionLockPath]; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const transitionLock = require( + transitionLockPath, + ) as typeof import("../src/lib/shields/transition-lock"); delete require.cache[shieldsModulePath]; require.cache[privilegedExecPath] = { id: privilegedExecPath, @@ -146,6 +184,16 @@ function withMockedDockerExecFileSync( privilegedSandboxExecArgv: (_sandboxName: string, cmd: readonly string[]) => [...cmd], }, } as any; + require.cache[transitionLockPath] = { + id: transitionLockPath, + filename: transitionLockPath, + loaded: true, + exports: { + ...transitionLock, + withShieldsTransitionLock: (_sandboxName: string, _command: string, fn: () => T): T => + fn(), + }, + } as any; let hermesFinished = false; dockerExecModule.dockerExecFileSync = vi.fn((args: readonly string[]) => { @@ -224,7 +272,7 @@ function withMockedDockerExecFileSync( calls.push(command); const openClawGuardIndex = command.indexOf(OPENCLAW_CONFIG_GUARD); - const stateDirGuardIndex = command.indexOf(STATE_DIR_GUARD); + const stateDirAction = stateDirGuardAction(command); switch (true) { case command[0] === "test" && command[1] === "-r" && @@ -237,6 +285,15 @@ function withMockedDockerExecFileSync( pid: 0, output: [], } as never; + case command[0] === "cat" && command[1] === STATE_LOCK_PLAN: + return { + status: 0, + signal: null, + stdout: JSON.stringify(options.installedStateLockPlan ?? OPENCLAW_STATE_LOCK_PLAN), + stderr: "", + pid: 0, + output: [], + } as never; case openClawGuardIndex >= 0: { const action = command[openClawGuardIndex + 1]; const symlinkedTarget = [...(options.symlinkedPaths ?? [])].find((target) => @@ -272,8 +329,8 @@ function withMockedDockerExecFileSync( output: [], } as never; } - case stateDirGuardIndex >= 0: { - const action = command[stateDirGuardIndex + 1]; + case stateDirAction !== null: { + const action = stateDirAction; return { status: 0, signal: null, @@ -308,6 +365,8 @@ function withMockedDockerExecFileSync( delete require.cache[shieldsModulePath]; if (priorPrivilegedExec) require.cache[privilegedExecPath] = priorPrivilegedExec; else delete require.cache[privilegedExecPath]; + if (priorTransitionLock) require.cache[transitionLockPath] = priorTransitionLock; + else delete require.cache[transitionLockPath]; } } @@ -447,6 +506,7 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; }, ) => void; }; @@ -456,6 +516,7 @@ describe("mutable agent config permissions", () => { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, }); }); @@ -464,13 +525,12 @@ describe("mutable agent config permissions", () => { .map((command) => command[command.indexOf(OPENCLAW_CONFIG_GUARD) + 1]) .filter((action): action is string => typeof action === "string"); const stateDirActions = commands - .filter((command) => command.includes(STATE_DIR_GUARD)) - .map((command) => command[command.indexOf(STATE_DIR_GUARD) + 1]) - .filter((action): action is string => typeof action === "string"); + .map(stateDirGuardAction) + .filter((action): action is string => action !== null); expect(openClawActions).toEqual(["preflight", "unlock"]); expect(stateDirActions).toEqual(["unlock"]); expect(commands).toContainEqual(["test", "-r", OPENCLAW_CONFIG_GUARD]); - expect(commands).toContainEqual(["test", "-r", STATE_DIR_GUARD]); + expect(commands.some((command) => stateDirGuardAction(command) === "unlock")).toBe(true); expect(commands).toContainEqual(["stat", "-c", "%a %U:%G", "/sandbox/.openclaw/openclaw.json"]); expect(commands).toContainEqual(["stat", "-c", "%a %U:%G", "/sandbox/.openclaw/.config-hash"]); expect( @@ -499,6 +559,7 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; }, ) => void; }; @@ -508,6 +569,7 @@ describe("mutable agent config permissions", () => { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, }); }, { @@ -531,7 +593,7 @@ describe("mutable agent config permissions", () => { ]), ); expect(commands).toContainEqual(["test", "-r", OPENCLAW_CONFIG_GUARD]); - expect(commands.some((command) => command.includes(STATE_DIR_GUARD))).toBe(false); + expect(commands.some((command) => stateDirGuardAction(command) !== null)).toBe(false); expect( commands.some( (command) => @@ -545,27 +607,33 @@ describe("mutable agent config permissions", () => { it("shields-down restores Hermes sticky group-writable config root without group-writable config files", () => { const commands: string[][] = []; - withMockedDockerExecFileSync(commands, () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { unlockAgentConfig } = require("../src/lib/shields/index.js") as { - unlockAgentConfig: ( - sandboxName: string, - target: { - agentName?: string; - configPath: string; - configDir: string; - sensitiveFiles?: string[]; - }, - ) => void; - }; + withMockedDockerExecFileSync( + commands, + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { unlockAgentConfig } = require("../src/lib/shields/index.js") as { + unlockAgentConfig: ( + sandboxName: string, + target: { + agentName?: string; + configPath: string; + configDir: string; + sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + }, + ) => void; + }; - unlockAgentConfig("sandbox-pod", { - agentName: "hermes", - configPath: "/sandbox/.hermes/config.yaml", - configDir: "/sandbox/.hermes", - sensitiveFiles: ["/sandbox/.hermes/.env"], - }); - }); + unlockAgentConfig("sandbox-pod", { + agentName: "hermes", + configPath: "/sandbox/.hermes/config.yaml", + configDir: "/sandbox/.hermes", + sensitiveFiles: ["/sandbox/.hermes/.env"], + stateLockPlan: HERMES_STATE_LOCK_PLAN, + }); + }, + { installedStateLockPlan: HERMES_STATE_LOCK_PLAN }, + ); const hermesActions = commands .filter((command) => command.includes(HERMES_RUNTIME_CONFIG_GUARD)) @@ -607,6 +675,7 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; }, ) => void; }; @@ -616,9 +685,13 @@ describe("mutable agent config permissions", () => { configPath: "/sandbox/.hermes/config.yaml", configDir: "/sandbox/.hermes", sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + stateLockPlan: HERMES_STATE_LOCK_PLAN, }); }, - { hermesLockedTransaction: true }, + { + hermesLockedTransaction: true, + installedStateLockPlan: HERMES_STATE_LOCK_PLAN, + }, ); const finishIndex = commands.findIndex((command) => @@ -643,7 +716,26 @@ const originalLoad = Module._load; const calls = []; const OPENCLAW_CONFIG_GUARD = ${JSON.stringify(OPENCLAW_CONFIG_GUARD)}; const STATE_DIR_GUARD = ${JSON.stringify(STATE_DIR_GUARD)}; +const STATE_LOCK_PLAN = ${JSON.stringify(STATE_LOCK_PLAN)}; +const INSTALLED_STATE_LOCK_PLAN = ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}; +function stateDirGuardAction(command) { + const installedIndex = command.indexOf(STATE_DIR_GUARD); + if (installedIndex >= 0) return command[installedIndex + 1] || null; + const pythonIndex = command.indexOf("python3"); + return pythonIndex >= 0 && command[pythonIndex + 2] === "-" + ? (command[pythonIndex + 3] || null) + : null; +} Module._load = function patchedLoad(request, parent, isMain) { + if (request === "./transition-lock") { + const transitionLock = originalLoad.call(this, request, parent, isMain); + return { + ...transitionLock, + withShieldsTransitionLock(_sandboxName, _command, fn) { + return fn(); + }, + }; + } if (request === "../adapters/docker/exec") { return { dockerExecFileSync(args) { @@ -678,6 +770,16 @@ Module._load = function patchedLoad(request, parent, isMain) { ) { return { status: 0, signal: null, stdout: "", stderr: "", pid: 0, output: [] }; } + if (command[0] === "cat" && command[1] === STATE_LOCK_PLAN) { + return { + status: 0, + signal: null, + stdout: JSON.stringify(INSTALLED_STATE_LOCK_PLAN), + stderr: "", + pid: 0, + output: [], + }; + } const openClawGuardIndex = command.indexOf(OPENCLAW_CONFIG_GUARD); if (openClawGuardIndex >= 0) { const action = command[openClawGuardIndex + 1]; @@ -697,9 +799,9 @@ Module._load = function patchedLoad(request, parent, isMain) { output: [], }; } - const stateDirGuardIndex = command.indexOf(STATE_DIR_GUARD); - if (stateDirGuardIndex >= 0) { - const action = command[stateDirGuardIndex + 1]; + const stateDirAction = stateDirGuardAction(command); + if (stateDirAction) { + const action = stateDirAction; return { status: 0, signal: null, @@ -733,6 +835,7 @@ lockAgentConfig("sandbox-pod", { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, }); process.stdout.write(JSON.stringify(calls)); `, @@ -749,12 +852,8 @@ process.stdout.write(JSON.stringify(calls)); const guardIndex = command.indexOf(OPENCLAW_CONFIG_GUARD); return guardIndex >= 0 && command[guardIndex + 1] === "lock"; }); - const stateDirCapabilityIndex = commands.findIndex( - (command) => command.join("\0") === ["test", "-r", STATE_DIR_GUARD].join("\0"), - ); const stateDirLockIndex = commands.findIndex((command) => { - const guardIndex = command.indexOf(STATE_DIR_GUARD); - return guardIndex >= 0 && command[guardIndex + 1] === "lock"; + return stateDirGuardAction(command) === "lock"; }); const verificationIndex = commands.findIndex( (command, index) => @@ -765,9 +864,8 @@ process.stdout.write(JSON.stringify(calls)); ); expect(openClawCapabilityIndex).toBeGreaterThan(-1); expect(openClawLockIndex).toBeGreaterThan(openClawCapabilityIndex); - expect(stateDirCapabilityIndex).toBeGreaterThan(openClawLockIndex); expect(stateDirLockIndex).toBeGreaterThan(-1); - expect(stateDirLockIndex).toBeGreaterThan(stateDirCapabilityIndex); + expect(stateDirLockIndex).toBeGreaterThan(openClawLockIndex); expect(verificationIndex).toBeGreaterThan(stateDirLockIndex); expect(commands).not.toContainEqual(["chmod", "g-s", "/sandbox/.openclaw"]); expect(commands).not.toContainEqual(["chmod", "755", "/sandbox/.openclaw"]); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index ada24769f72..a9f812e378e 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -35,6 +35,7 @@ describe("sandbox build context staging", () => { writeFixture("Dockerfile"); writeFixture("tsconfig.runtime-preloads.json", "{}\n"); + writeFixture(path.join("agents", "openclaw", "state-lock-plan.json"), "{}\n"); writeFixture( path.join("ci", "npm-audit-exceptions.json"), `${JSON.stringify({ schemaVersion: 1, exceptions: [] })}\n`, @@ -510,6 +511,9 @@ describe("sandbox build context staging", () => { true, ); expect(fs.existsSync(path.join(buildCtx, "scripts", "state-dir-guard.py"))).toBe(true); + expect(fs.existsSync(path.join(buildCtx, "agents", "openclaw", "state-lock-plan.json"))).toBe( + true, + ); expect(fs.existsSync(path.join(buildCtx, "scripts", "openclaw-config-guard.py"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe( diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index cca7ac5f0ac..b36c64b59ac 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -3,14 +3,11 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; -import { - CONFIDENTIALITY_STATE_DIRS, - HIGH_RISK_STATE_DIRS, - WRITABLE_RUNTIME_SUBPATHS, -} from "../src/lib/shields/state-dir-lock"; +import { loadAgent } from "../src/lib/agent/defs"; const OPENCLAW_GUARD = "/usr/local/lib/nemoclaw/openclaw-config-guard.py"; const STATE_DIR_GUARD = "/usr/local/lib/nemoclaw/state-dir-guard.py"; +const OPENCLAW_STATE_LOCK_PLAN = loadAgent("openclaw").stateLockPlan; type GuardProbeResult = { calls: string[][]; @@ -73,6 +70,12 @@ Module._load = function patchedLoad(request, parent, isMain) { const command = commandFromArgs(args); calls.push(command); if (command[0] === "test" && command[1] === "-r") return completed(); + if ( + command[0] === "cat" && + command[1] === "/usr/local/share/nemoclaw/state-lock-plan.json" + ) { + return completed(JSON.stringify(${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}) + "\n"); + } const openClawAction = guardAction(command, ${JSON.stringify(OPENCLAW_GUARD)}); if (openClawAction) { @@ -142,6 +145,7 @@ try { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, }, false, ); @@ -171,7 +175,7 @@ function helperCalls(calls: string[][], helper: string, action?: string): string } describe("shields-up state-dir lock preserves sandbox-group access + runtime sessions writable", () => { - it("uses the installed descriptor-safe guards for top-level and recursive lockdown", () => { + it("uses descriptor-safe guards for top-level and recursive lockdown", () => { const result = runLockAgentConfigProbe(); expect(result.status, result.stderr).toBe(0); @@ -199,12 +203,12 @@ describe("shields-up state-dir lock preserves sandbox-group access + runtime ses expect(stateLockIndex).toBeGreaterThan(configLockIndex); }); - it("keeps the complete protected inventory and writable sessions carve-out", () => { - expect(HIGH_RISK_STATE_DIRS).toEqual( - expect.arrayContaining(["skills", "agent", "hooks", "agents", "extensions", "workspace"]), + it("uses the OpenClaw manifest plan and writable sessions carve-out", () => { + expect(OPENCLAW_STATE_LOCK_PLAN.readOnlyRoots).toEqual( + expect.arrayContaining(["skills", "hooks", "agents", "extensions", "workspace"]), ); - expect(CONFIDENTIALITY_STATE_DIRS).toEqual(["credentials", "identity", "pairing"]); - expect(WRITABLE_RUNTIME_SUBPATHS).toEqual(["agents/*/sessions"]); + expect(OPENCLAW_STATE_LOCK_PLAN.confidentialRoots).toEqual(["credentials", "identity"]); + expect(OPENCLAW_STATE_LOCK_PLAN.writableSubpaths).toEqual(["agents/*/sessions"]); }); it.each([ diff --git a/test/snapshot-runtime-auth-state.test.ts b/test/snapshot-runtime-auth-state.test.ts index 2d14911ad19..828156604ca 100644 --- a/test/snapshot-runtime-auth-state.test.ts +++ b/test/snapshot-runtime-auth-state.test.ts @@ -79,8 +79,9 @@ function readStdin() { } return Buffer.concat(chunks); } -// Backup: state-dir existence probe (piped through awk '!seen[$0]++'). -if (cmd.includes("!seen[$0]++")) { +// Backup: state-dir existence probe. Dynamic names are validated remotely and +// all emitted names are independently checked by the host implementation. +if (cmd.startsWith("{ ") && cmd.includes("printf")) { const probes = [...cmd.matchAll(/\\[ -d '([^']+)' \\] && printf '%s\\\\n' '([^']+)'/g)]; for (const m of probes) { if (fs.existsSync(mapPath(m[1]))) process.stdout.write(m[2] + "\\n"); diff --git a/test/snapshot-state-directory-contract.test.ts b/test/snapshot-state-directory-contract.test.ts new file mode 100644 index 00000000000..d51b41292df --- /dev/null +++ b/test/snapshot-state-directory-contract.test.ts @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snapshot-state-contract-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +type SandboxStateModule = typeof import("../src/lib/state/sandbox.js"); +const loadedSandboxState: unknown = await import( + pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href +); +if ( + typeof loadedSandboxState !== "object" || + loadedSandboxState === null || + !("backupSandboxState" in loadedSandboxState) || + !("restoreSandboxState" in loadedSandboxState) +) { + throw new Error("Expected sandbox-state module exports to be available"); +} +const sandboxState = loadedSandboxState as SandboxStateModule; +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +function writeBackup( + sandboxName: string, + dirName: string, + overrides: Record, +): Record { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + const manifest = { + version: 1, + sandboxName, + timestamp: dirName, + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + ...overrides, + }; + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify(manifest, null, 2), + ); + return manifest; +} + +function writeAgentRegistry(sandboxName: string, agent: string): void { + const registryDir = path.join(TMP_HOME, ".nemoclaw"); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent, + }, + }, + }), + ); +} + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeFakeOpenshell(binDir: string): string { + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + return openshell; +} + +function restoreEnv(name: string, value: string | undefined): void { + value === undefined + ? Reflect.deleteProperty(process.env, name) + : Reflect.set(process.env, name, value); +} + +afterAll(() => { + restoreEnv("HOME", ORIGINAL_HOME); + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +beforeEach(() => { + fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); +}); + +describe("snapshot state-directory authorization", () => { + it("refuses a snapshot directory removed from the current agent contract (#8006)", () => { + const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { + stateDirs: ["retired-state"], + backedUpDirs: ["retired-state"], + }); + fs.mkdirSync(path.join(String(manifest.backupPath), "retired-state")); + writeAgentRegistry("test-sandbox", "openclaw"); + + const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); + + expect(restore).toMatchObject({ + success: false, + restoredDirs: [], + failedDirs: ["retired-state"], + error: "Backup state directories are not declared by target agent 'openclaw': retired-state", + }); + }); + + it.each([ + ["workspace-research", true], + ["workspace-research/nested", false], + ])("authorizes only a top-level concrete match for a dynamic state prefix: %s (#8006)", (stateDir, accepted) => { + const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { + stateDirs: [stateDir], + backedUpDirs: [], + failedBackupDirs: [stateDir], + }); + writeAgentRegistry("test-sandbox", "openclaw"); + + const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); + + expect(restore.success).toBe(accepted); + if (!accepted) { + expect(restore.error).toContain("not declared by target agent 'openclaw'"); + } + }); + + it.each([ + ["hermes", "hermes"], + ["deepagents", "langchain-deepagents-code"], + ])("keeps optional exact-directory discovery successful for %s when no state directory exists (#8006)", (sandboxName, agentName) => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exact-dir-discovery-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const discoveryLog = path.join(fixture, "discovery.json"); + fs.mkdirSync(binDir, { recursive: true }); + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const cmd = process.argv[process.argv.length - 1] || ""; +if (cmd.startsWith("{ ")) { + const result = spawnSync("/bin/sh", ["-c", cmd], { stdio: "ignore" }); + const status = result.status ?? 127; + fs.writeFileSync(${JSON.stringify(discoveryLog)}, JSON.stringify({ cmd, status })); + process.exit(status); +} +process.exit(1); +`, + ); + writeAgentRegistry(sandboxName, agentName); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}:${oldPath || ""}`; + + sandboxState.backupSandboxState(sandboxName); + + const discovery = JSON.parse(fs.readFileSync(discoveryLog, "utf8")) as { + cmd: string; + status: number; + }; + expect(discovery.status).toBe(0); + expect(discovery.cmd).toMatch(/; :; } 2>\/dev\/null$/); + } finally { + restoreEnv("PATH", oldPath); + restoreEnv("NEMOCLAW_OPENSHELL_BIN", oldOpenshell); + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 26f7f05ee33..c374822ceb5 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -555,7 +555,7 @@ describe("sandbox directory backup semantics", () => { expect(fs.existsSync(path.join(BACKUPS_ROOT, "custom-openclaw"))).toBe(false); }); - it("treats empty state directories as backed up when tar exits cleanly", () => { + it("backs up declared empty and dynamic directories without trusting undeclared discovery output (#8006)", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-empty-dirs-")); const oldPath = process.env.PATH; const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; @@ -564,7 +564,17 @@ describe("sandbox directory backup semantics", () => { const binDir = path.join(fixture, "bin"); const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); const stagingRoot = path.join(fixture, "staging"); - const existingDirs = ["agents", "extensions", "workspace", "skills", "hooks", "cron"]; + const sshLog = path.join(fixture, "ssh-log.jsonl"); + const unsafeDiscoveryMarker = path.join(fixture, "unsafe-discovery"); + const existingDirs = [ + "agents", + "extensions", + "workspace", + "skills", + "hooks", + "cron", + "workspace-research", + ]; fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(stagingRoot); for (const dirName of existingDirs) { @@ -580,7 +590,12 @@ const { spawnSync } = require("node:child_process"); const fs = require("node:fs"); const cmd = process.argv[process.argv.length - 1] || ""; const existingDirs = ${JSON.stringify(existingDirs)}; +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); if (cmd.includes("[ -d ")) { + if (fs.existsSync(${JSON.stringify(unsafeDiscoveryMarker)})) { + process.stdout.write("workspace-research\\nidentity\\n"); + process.exit(0); + } process.stdout.write(existingDirs.join("\\n") + "\\n"); process.exit(0); } @@ -629,8 +644,16 @@ process.exit(0); expect(backup.failedDirs).toEqual([]); expect(backup.backedUpDirs).toEqual(existingDirs); expect(backup.manifest?.backedUpDirs).toEqual(existingDirs); + expect(backup.manifest?.stateDirs.at(-1)).toBe("workspace-research"); expect(backup.manifest?.reconcileOpenClawImagePluginProvenance).toBe(true); expect(backup.manifest?.openclawImagePluginInstalls).toEqual([]); + const discoveryCommand = fs + .readFileSync(sshLog, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line).cmd as string) + .find((command) => command.includes("[ -d ")); + expect(discoveryCommand).toContain("'/sandbox/.openclaw/workspace-'*/"); expect(fs.readdirSync(stagingRoot)).toEqual([]); const rejected = sandboxState.backupSandboxState("alpha", { @@ -644,6 +667,13 @@ process.exit(0); "Snapshot authority changed during backup: runtime generation changed", ), }); + fs.writeFileSync(unsafeDiscoveryMarker, "hostile newline discovery\n"); + const unsafeDiscovery = sandboxState.backupSandboxState("alpha"); + expect(unsafeDiscovery).toMatchObject({ + success: false, + error: "State directory discovery returned undeclared or unsafe entries", + backedUpDirs: [], + }); const published = fs .readdirSync(path.join(BACKUPS_ROOT, "alpha")) .filter((entry) => diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 5dc23787d70..035c487ff54 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -10,6 +10,15 @@ import { testTimeoutOptions } from "./helpers/timeouts"; const GUARD_PATH = path.resolve("scripts/state-dir-guard.py"); const fixtures: string[] = []; +const DEFAULT_PLAN = { + version: 1, + readOnlyRoots: ["agents", "cron", "extensions", "plugins", "skills"], + confidentialRoots: ["credentials"], + readOnlyPrefixes: ["workspace-"], + confidentialPrefixes: [], + writableSubpaths: ["agents/*/sessions"], +}; +const PLAN_JSON = JSON.stringify(DEFAULT_PLAN); const PYTHON_HAS_DESCRIPTOR_XATTR = spawnSync("python3", [ "-c", @@ -21,7 +30,7 @@ import importlib.util import os import sys -guard_path, action, config_dir = sys.argv[1:4] +guard_path, action, config_dir, plan_flag, plan_value = sys.argv[1:6] spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard", guard_path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -38,6 +47,27 @@ if os.environ.get("NEMOCLAW_TEST_MAX_ENTRIES"): module.MAX_ENTRIES_PER_PASS = int(os.environ["NEMOCLAW_TEST_MAX_ENTRIES"]) if os.environ.get("NEMOCLAW_TEST_MAX_COPY_BYTES"): module.MAX_COPIED_BYTES_PER_PASS = int(os.environ["NEMOCLAW_TEST_MAX_COPY_BYTES"]) +raise SystemExit(module.main([ + action, "--config-dir", config_dir, plan_flag, plan_value, +])) +`; + +const RUN_BUNDLED_GUARD_AS_CURRENT_USER = String.raw` +import importlib.util +import os +import sys + +guard_path, action, config_dir = sys.argv[1:4] +spec = importlib.util.spec_from_file_location("nemoclaw_bundled_state_dir_guard", guard_path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +identity = module.Identity( + root_uid=os.getuid(), root_gid=os.getgid(), + sandbox_uid=os.getuid(), sandbox_gid=os.getgid(), +) +module.os.geteuid = lambda: 0 +module._production_identity = lambda: identity raise SystemExit(module.main([action, "--config-dir", config_dir])) `; @@ -48,7 +78,7 @@ import os import struct import sys -guard_path, config_dir, file_path = sys.argv[1:4] +guard_path, config_dir, file_path, plan_json = sys.argv[1:5] spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard_flags", guard_path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -57,6 +87,7 @@ identity = module.Identity( root_uid=os.getuid(), root_gid=os.getgid(), sandbox_uid=os.getuid(), sandbox_gid=os.getgid(), ) +plan = module.parse_agent_state_lock_plan(plan_json) flags = {} initial = os.stat(file_path) flags[(initial.st_dev, initial.st_ino)] = module.FS_IMMUTABLE_FL @@ -72,10 +103,10 @@ def fake_ioctl(fd, operation, payload): raise AssertionError(operation) module.fcntl.ioctl = fake_ioctl -locked = module.run_guard("lock", config_dir, identity) +locked = module.run_guard("lock", config_dir, identity, plan) locked_stat = os.stat(file_path) locked_flags = flags.get((locked_stat.st_dev, locked_stat.st_ino), 0) -unlocked = module.run_guard("unlock", config_dir, identity) +unlocked = module.run_guard("unlock", config_dir, identity, plan) unlocked_stat = os.stat(file_path) unlocked_flags = flags.get((unlocked_stat.st_dev, unlocked_stat.st_ino), 0) print(json.dumps({ @@ -142,7 +173,7 @@ import json import os import sys -guard_path, config_dir = sys.argv[1:3] +guard_path, config_dir, plan_json = sys.argv[1:4] spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard_carveout", guard_path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -151,12 +182,13 @@ identity = module.Identity( root_uid=os.getuid(), root_gid=os.getgid(), sandbox_uid=os.getuid(), sandbox_gid=os.getgid(), ) +plan = module.parse_agent_state_lock_plan(plan_json) def racing_mkdir(*args, **kwargs): raise FileExistsError(17, "File exists", "sessions") module.os.mkdir = racing_mkdir -result = module.run_guard("lock", config_dir, identity) +result = module.run_guard("lock", config_dir, identity, plan) print(json.dumps({ "ok": result.ok, "issues": [issue.as_json() for issue in result.issues], @@ -230,14 +262,16 @@ function fixture(configDirName = ".agent"): { root: string; configDir: string } return { root, configDir: fs.realpathSync(configDir) }; } -function runGuard( +function runGuardWithPlanSource( action: "preflight" | "lock" | "unlock", configDir: string, + planFlag: "--plan-json" | "--plan-file", + planValue: string, env: Record = {}, ) { const result = spawnSync( "python3", - ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, action, configDir], + ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, action, configDir, planFlag, planValue], { encoding: "utf-8", timeout: 15_000, env: { ...process.env, ...env } }, ); const lines = result.stdout @@ -248,6 +282,15 @@ function runGuard( return { ...result, lines }; } +function runGuard( + action: "preflight" | "lock" | "unlock", + configDir: string, + env: Record = {}, + plan: unknown = DEFAULT_PLAN, +) { + return runGuardWithPlanSource(action, configDir, "--plan-json", JSON.stringify(plan), env); +} + function mode(filePath: string): number { return fs.lstatSync(filePath).mode & 0o7777; } @@ -264,6 +307,218 @@ afterEach(() => { }); describe("state-dir-guard", () => { + it("requires an explicit plan outside the bundled helper layout and rejects multiple sources", () => { + const { root, configDir } = fixture(); + const planFile = path.join(root, "plan.json"); + fs.writeFileSync(planFile, PLAN_JSON); + + const missing = spawnSync("python3", [GUARD_PATH, "preflight", "--config-dir", configDir], { + encoding: "utf-8", + }); + const repeated = spawnSync( + "python3", + [ + GUARD_PATH, + "preflight", + "--config-dir", + configDir, + "--plan-json", + PLAN_JSON, + "--plan-file", + planFile, + ], + { encoding: "utf-8" }, + ); + + expect(missing.status).toBe(1); + expect(missing.stderr).toBe(""); + expect(missing.stdout).toContain('"code":"invalid-plan"'); + expect(repeated.status).toBe(2); + expect(repeated.stderr).toContain("not allowed with argument"); + }); + + it("supports the previous CLI wire form only from a co-bundled generated plan", () => { + const { root, configDir } = fixture(); + const helperPath = path.join(root, "image", "lib", "nemoclaw", "state-dir-guard.py"); + const planPath = path.join(root, "image", "share", "nemoclaw", "state-lock-plan.json"); + const pluginsDir = path.join(configDir, "plugins"); + fs.mkdirSync(path.dirname(helperPath), { recursive: true }); + fs.mkdirSync(path.dirname(planPath), { recursive: true }); + fs.mkdirSync(pluginsDir); + fs.copyFileSync(GUARD_PATH, helperPath); + fs.writeFileSync(planPath, PLAN_JSON); + fs.chmodSync(pluginsDir, 0o2770); + + const result = spawnSync( + "python3", + ["-c", RUN_BUNDLED_GUARD_AS_CURRENT_USER, helperPath, "lock", configDir], + { encoding: "utf-8", timeout: 15_000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(mode(pluginsDir)).toBe(0o755); + }); + + it("loads an SPDX-annotated plan file", () => { + const { root, configDir } = fixture(); + const pluginsDir = path.join(configDir, "plugins"); + const planFile = path.join(root, "state-lock-plan.json"); + fs.mkdirSync(pluginsDir); + fs.writeFileSync( + planFile, + JSON.stringify({ + $comment: + "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0", + ...DEFAULT_PLAN, + }), + ); + + const result = runGuardWithPlanSource("preflight", configDir, "--plan-file", planFile); + + expect(result.status, result.stderr).toBe(0); + expect(result.lines.at(-1)).toEqual( + expect.objectContaining({ type: "result", status: "ok", roots: 1 }), + ); + }); + + it("rejects missing, non-UTF-8, and oversized plan files", () => { + const { root, configDir } = fixture(); + const cases: Array<[string, Buffer | null]> = [ + ["missing.json", null], + ["non-utf8.json", Buffer.from([0xff])], + ["oversized.json", Buffer.alloc(1024 * 1024 + 1, 0x20)], + ]; + + for (const [fileName, contents] of cases) { + const planFile = path.join(root, fileName); + if (contents) fs.writeFileSync(planFile, contents); + + const result = runGuardWithPlanSource("preflight", configDir, "--plan-file", planFile); + + expect(result.status, fileName).toBe(1); + expect(result.lines, fileName).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "issue", code: "invalid-plan" }), + expect.objectContaining({ type: "result", status: "failed", issueCount: 1 }), + ]), + ); + } + }); + + it.each([ + ["malformed JSON", "{"], + ["duplicate JSON keys", PLAN_JSON.replace('"version":1', '"version":1,"version":1')], + ["missing keys", "{}"], + ["unknown keys", JSON.stringify({ ...DEFAULT_PLAN, registry: [] })], + ["non-string comment", JSON.stringify({ ...DEFAULT_PLAN, $comment: 1 })], + ["null comment", JSON.stringify({ ...DEFAULT_PLAN, $comment: null })], + ["wrong version", JSON.stringify({ ...DEFAULT_PLAN, version: true })], + ["unsafe top-level root", JSON.stringify({ ...DEFAULT_PLAN, readOnlyRoots: ["../plugins"] })], + [ + "unsafe top-level prefix", + JSON.stringify({ ...DEFAULT_PLAN, readOnlyPrefixes: ["workspace/"] }), + ], + ["duplicate root", JSON.stringify({ ...DEFAULT_PLAN, readOnlyRoots: ["plugins", "plugins"] })], + [ + "conflicting root policy", + JSON.stringify({ + ...DEFAULT_PLAN, + confidentialRoots: ["plugins"], + }), + ], + [ + "overlapping prefixes", + JSON.stringify({ + ...DEFAULT_PLAN, + readOnlyPrefixes: ["workspace-", "workspace-dev-"], + }), + ], + [ + "partial-component wildcard", + JSON.stringify({ ...DEFAULT_PLAN, writableSubpaths: ["agents/a*/sessions"] }), + ], + ["final wildcard", JSON.stringify({ ...DEFAULT_PLAN, writableSubpaths: ["agents/*"] })], + [ + "overlapping writable subpaths", + JSON.stringify({ + ...DEFAULT_PLAN, + writableSubpaths: ["agents/*/sessions", "agents/main/sessions"], + }), + ], + [ + "writable path under a confidential root", + JSON.stringify({ + ...DEFAULT_PLAN, + writableSubpaths: ["credentials/runtime"], + }), + ], + ])("rejects a plan with %s", (_case, planJson) => { + const { configDir } = fixture(); + + const result = runGuardWithPlanSource("preflight", configDir, "--plan-json", planJson); + + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + expect(result.lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "issue", code: "invalid-plan" }), + expect.objectContaining({ type: "result", status: "failed", issueCount: 1 }), + ]), + ); + }); + + it("selects exact roots and prefixes only from the supplied plan", () => { + const { configDir } = fixture(); + const selectedRoot = path.join(configDir, "custom"); + const selectedPrefix = path.join(configDir, "project-blue"); + const unselectedRoot = path.join(configDir, "plugins"); + fs.mkdirSync(selectedRoot); + fs.mkdirSync(selectedPrefix); + fs.mkdirSync(unselectedRoot); + fs.chmodSync(selectedRoot, 0o2770); + fs.chmodSync(selectedPrefix, 0o2770); + fs.chmodSync(unselectedRoot, 0o2770); + const plan = { + version: 1, + readOnlyRoots: ["custom"], + confidentialRoots: [], + readOnlyPrefixes: ["project-"], + confidentialPrefixes: [], + writableSubpaths: [], + }; + + const result = runGuard("lock", configDir, {}, plan); + + expect(result.status, result.stderr).toBe(0); + expect(mode(selectedRoot)).toBe(0o755); + expect(mode(selectedPrefix)).toBe(0o755); + expect(mode(unselectedRoot)).toBe(0o2770); + }); + + it("creates and preserves a generic wildcard writable subpath", () => { + const { configDir } = fixture(); + const workerDir = path.join(configDir, "workers", "main"); + const runsDir = path.join(workerDir, "runs"); + fs.mkdirSync(workerDir, { recursive: true }); + const plan = { + version: 1, + readOnlyRoots: ["workers"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: ["workers/*/runs"], + }; + + const locked = runGuard("lock", configDir, {}, plan); + fs.writeFileSync(path.join(runsDir, "live.log"), "runtime\n", { mode: 0o660 }); + const relocked = runGuard("lock", configDir, {}, plan); + + expect(locked.status, locked.stderr).toBe(0); + expect(relocked.status, relocked.stderr).toBe(0); + expect(mode(runsDir)).toBe(0o2770); + expect(fs.readFileSync(path.join(runsDir, "live.log"), "utf-8")).toBe("runtime\n"); + }); + it("rejects a config root reached through a symlinked ancestor", () => { const rawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-dir-guard-")); fixtures.push(rawRoot); @@ -791,7 +1046,7 @@ describe("state-dir-guard", () => { }; const unlock = spawn( "python3", - ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, "unlock", configDir], + ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, "unlock", configDir, "--plan-json", PLAN_JSON], { env: { ...commonEnv, @@ -812,7 +1067,7 @@ describe("state-dir-guard", () => { const startedAt = Date.now(); const locked = spawnSync( "python3", - ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, "lock", configDir], + ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, "lock", configDir, "--plan-json", PLAN_JSON], { env: commonEnv, encoding: "utf-8", timeout: 10_000 }, ); @@ -851,7 +1106,7 @@ describe("state-dir-guard", () => { const result = spawnSync( "python3", - ["-c", RUN_FAKE_IMMUTABLE_TRANSITION, GUARD_PATH, configDir, pluginPath], + ["-c", RUN_FAKE_IMMUTABLE_TRANSITION, GUARD_PATH, configDir, pluginPath, PLAN_JSON], { encoding: "utf-8", timeout: 15_000 }, ); @@ -964,10 +1219,14 @@ describe("state-dir-guard", () => { const agentDir = path.join(configDir, "agents", "main"); fs.mkdirSync(agentDir, { recursive: true }); - const result = spawnSync("python3", ["-c", RUN_CARVEOUT_MKDIR_RACE, GUARD_PATH, configDir], { - encoding: "utf-8", - timeout: 15_000, - }); + const result = spawnSync( + "python3", + ["-c", RUN_CARVEOUT_MKDIR_RACE, GUARD_PATH, configDir, PLAN_JSON], + { + encoding: "utf-8", + timeout: 15_000, + }, + ); expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout.trim())).toEqual( From 622dc27637a2d11c3ae9004bcaade58710adec37 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 13:59:29 -0400 Subject: [PATCH 02/25] refactor(agent): address state definition review Signed-off-by: Julie Yaunches --- agents/hermes/manifest.yaml | 1 + .../langchain-deepagents-code/manifest.yaml | 1 + agents/openclaw/manifest.yaml | 1 + docs/security/tcb-boundary.mdx | 3 +- .../lib/generate-agent-state-lock-plans.mts | 20 ++- scripts/state-dir-guard.py | 37 +++--- src/lib/actions/inference-set.test-support.ts | 2 + src/lib/actions/onboard.ts | 5 +- .../gateway-restart-hermes-drift.test.ts | 15 ++- src/lib/agent/definition-types.ts | 2 + src/lib/agent/defs.test.ts | 16 +++ src/lib/agent/defs.ts | 7 ++ .../hermes-recovery-boundary-fixtures.ts | 1 + src/lib/agent/manifest-readers.ts | 9 ++ src/lib/agent/onboard.test.ts | 1 + src/lib/agent/runtime.test.ts | 1 + .../agent/state-directory-contract.test.ts | 13 +- src/lib/agent/state-directory-contract.ts | 2 +- src/lib/onboard/command-support.ts | 2 +- ...ckerfile-remote-dashboard-bind-contract.ts | 2 +- src/lib/sandbox/agent-config.test.ts | 10 +- src/lib/sandbox/agent-config.ts | 4 + .../sandbox/hermes-dashboard-reseed.test.ts | 1 + src/lib/shields/flow.test.ts | 67 +++++----- src/lib/shields/index.test.ts | 40 ++++++ src/lib/shields/index.ts | 55 ++++++--- src/lib/shields/legacy-hermes-compat.test.ts | 1 + src/lib/shields/openclaw-transition.test.ts | 1 + src/lib/shields/policy-transition.test.ts | 2 + src/lib/shields/state-dir-lock.test.ts | 114 +++++++++++------- src/lib/shields/state-dir-lock.ts | 58 ++++++--- src/lib/shields/timer.ts | 2 + src/lib/state/sandbox.ts | 12 +- src/lib/tunnel/allowed-origins.test.ts | 1 + test/e2e/live/gateway-guard-recovery.test.ts | 24 ++++ test/e2e/live/hermes-shields-config.test.ts | 5 + test/e2e/live/rebuild-hermes.test.ts | 14 +-- test/e2e/live/sandbox-survival.test.ts | 11 +- test/e2e/live/snapshot-commands.test.ts | 17 ++- test/e2e/live/state-backup-restore.test.ts | 3 +- test/helpers/base-image-test-harness.ts | 1 + test/hermes-doctor-config-hash.test.ts | 42 ++++++- test/repro-2681-group-writable.test.ts | 39 +++--- ...ox-provisioning-helper-permissions.test.ts | 3 + test/sandbox-provisioning.test.ts | 14 ++- test/sandbox-rlimit-hooks.test.ts | 4 + test/shields-up-runtime-perms.test.ts | 1 + .../snapshot-state-directory-contract.test.ts | 31 ++--- test/state-dir-guard.test.ts | 15 ++- test/support/connect-flow-test-harness.ts | 1 + 50 files changed, 509 insertions(+), 225 deletions(-) diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 0ac8b19d343..79f4fd04369 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -63,6 +63,7 @@ config: # ── State directories ────────────────────────────────────────── # All state dirs live under the single config dir now that # immutable/writable split has been removed. +state_lock_plan_in_image: true state_dirs: - memories - sessions diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 7cf9aaad5d8..ff83edadc70 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -41,6 +41,7 @@ config: # /sandbox/.deepagents. The built-in skill-creator writes user skills to # agent/skills (per its init_skill.py), so it must be backed up alongside # .state and skills — otherwise skills are silently lost on rebuild. +state_lock_plan_in_image: false state_dirs: - .state - path: skills diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 5fc03e37587..6d4d7d6c874 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -40,6 +40,7 @@ config: format: json # ── State directories ────────────────────────────────────────── +state_lock_plan_in_image: true state_dirs: - path: agents shields: read-only diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 736f635f59e..1ac97aad8cd 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -136,7 +136,8 @@ The following conditions govern current compatibility code and architecture work - Decompose the host shields coordinator only with behavior-preserving changes that keep policy, config, timer, rollback, state, and audit ordering under one typed transaction contract. - Keep the managed controller's source-path and fake-root overrides disabled unless the explicit source test flag is present, and keep installed helpers bound to fixed production paths. +The agent manifest declaration drives both generated image-plan selection and the derived `AgentDefinition` used by host wiring. Final-image validation must cover the recovery-capable OpenClaw and Hermes images. It checks helper owners and modes, root and gateway supplementary groups, root execution of the read-only `probe` path, and refusal before helper entry when the sandbox user attempts execution. Deep Agents uses the host-injected helper path instead of an installed recovery artifact. -Host wiring tests validate that selection and plan handoff, while the shared helper tests validate the descriptor-safe behavior; installed-helper metadata checks do not cover Deep Agents. +Host wiring tests validate helper selection and plan handoff, while the shared helper tests validate the descriptor-safe behavior; installed-helper metadata checks do not cover Deep Agents. diff --git a/scripts/lib/generate-agent-state-lock-plans.mts b/scripts/lib/generate-agent-state-lock-plans.mts index e3ea6c887b7..965c1349b99 100644 --- a/scripts/lib/generate-agent-state-lock-plans.mts +++ b/scripts/lib/generate-agent-state-lock-plans.mts @@ -4,28 +4,22 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import YAML from "yaml"; -const { buildStateLockPlan, readStateDirectories } = await import( - "../../src/lib/agent/state-directory-contract" -); +const { listAgents, loadAgent } = await import("../../src/lib/agent/defs"); const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const IMAGE_AGENTS = ["openclaw", "hermes"] as const; const SPDX_COMMENT = "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0"; -for (const agentName of IMAGE_AGENTS) { - const manifestPath = path.join(REPO_ROOT, "agents", agentName, "manifest.yaml"); - const manifest = YAML.parse(fs.readFileSync(manifestPath, "utf8")) as unknown; - if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) { - throw new Error(`Agent manifest must be an object: ${manifestPath}`); - } - const outputPath = path.join(REPO_ROOT, "agents", agentName, "state-lock-plan.json"); +for (const agentName of listAgents()) { + const agent = loadAgent(agentName); + if (!agent.stateLockPlanInImage) continue; + + const outputPath = path.join(agent.agentDir, "state-lock-plan.json"); const output = `${JSON.stringify( { $comment: SPDX_COMMENT, - ...buildStateLockPlan(readStateDirectories(manifest as Record)), + ...agent.stateLockPlan, }, null, 2, diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 7148c9d1771..f6770d6b8f7 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -303,7 +303,8 @@ def _validate_writable_subpath(value: str, field: str) -> tuple[str, ...]: def _patterns_overlap(first: tuple[str, ...], second: tuple[str, ...]) -> bool: return all( left == "*" or right == "*" or left == right - for left, right in zip(first, second) + # A matching shorter pattern is a shared-prefix overlap. + for left, right in zip(first, second, strict=False) ) @@ -2247,34 +2248,32 @@ def _load_plan(args: argparse.Namespace) -> AgentStateLockPlan: def main(argv: list[str] | None = None) -> int: args = _parse_args(sys.argv[1:] if argv is None else argv) - result: GuardResult | None = None try: plan = _load_plan(args) except PlanValidationError as exc: result = GuardResult(action=args.action) result.issues.append(Issue("invalid-plan", args.config_dir, str(exc))) - if result is None and os.geteuid() != 0: - result = GuardResult(action=args.action) - result.issues.append( - Issue("root-required", args.config_dir, "state-dir guard must run as root") - ) - elif result is None: - try: - identity = _production_identity() - except KeyError as exc: + else: + if os.geteuid() != 0: result = GuardResult(action=args.action) result.issues.append( - Issue( - "identity-unavailable", - args.config_dir, - f"required sandbox account is unavailable: {exc}", - ) + Issue("root-required", args.config_dir, "state-dir guard must run as root") ) else: - result = run_guard(args.action, args.config_dir, identity, plan) + try: + identity = _production_identity() + except KeyError as exc: + result = GuardResult(action=args.action) + result.issues.append( + Issue( + "identity-unavailable", + args.config_dir, + f"required sandbox account is unavailable: {exc}", + ) + ) + else: + result = run_guard(args.action, args.config_dir, identity, plan) - if result is None: # All branches above assign a result. - raise RuntimeError("state-dir guard did not produce a result") for issue in result.issues: print(json.dumps(issue.as_json(), sort_keys=True, separators=(",", ":"))) print(json.dumps(result.summary_json(), sort_keys=True, separators=(",", ":"))) diff --git a/src/lib/actions/inference-set.test-support.ts b/src/lib/actions/inference-set.test-support.ts index ee842492749..45336d20297 100644 --- a/src/lib/actions/inference-set.test-support.ts +++ b/src/lib/actions/inference-set.test-support.ts @@ -17,6 +17,7 @@ export const OPENCLAW_TARGET: AgentConfigTarget = { format: "json", configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlanInImage: true, }; export const HERMES_TARGET: AgentConfigTarget = { @@ -26,6 +27,7 @@ export const HERMES_TARGET: AgentConfigTarget = { format: "yaml", configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + stateLockPlanInImage: true, }; export function baseSession(overrides: Partial = {}): Session { diff --git a/src/lib/actions/onboard.ts b/src/lib/actions/onboard.ts index bd28a10ebcf..545e7db67d8 100644 --- a/src/lib/actions/onboard.ts +++ b/src/lib/actions/onboard.ts @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { listAgents } from "../agent/defs"; import type { GooglechatTunnelRuntimeDeps } from "../messaging/channels/googlechat/hooks/tunnel-runtime"; import { type OnboardCommandOptions, runOnboardCommand } from "../onboard/command"; -import type { OnboardFlags } from "../onboard/command-support"; +import { type OnboardFlags, readAgentRegistryNames } from "../onboard/command-support"; import type { OnboardOptions } from "../onboard/types"; export interface OnboardActionRuntimeDeps { @@ -28,7 +27,7 @@ function buildOnboardCommandDeps(flags: OnboardFlags, runtimeDeps: OnboardAction flags, env: process.env, runOnboard: (options: OnboardCommandOptions) => runOnboard(options, runtimeDeps), - listAgents, + listAgents: () => [...readAgentRegistryNames()], log: console.log, error: console.error, exit: (code: number) => process.exit(code), diff --git a/src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts b/src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts index 93aa2638562..3469b311add 100644 --- a/src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts +++ b/src/lib/actions/sandbox/gateway-restart-hermes-drift.test.ts @@ -13,6 +13,17 @@ import { type GatewayRestartDeps, restartSandboxGatewayWithDeps } from "./gatewa const REPO_ROOT = path.resolve(import.meta.dirname, "../../../.."); const HERMES_GUARD = path.join(REPO_ROOT, "agents/hermes/runtime-config-guard.py"); const HERMES_TRANSACTION = path.join(REPO_ROOT, "agents/hermes/mcp-config-transaction.py"); +const YAML_STUB_PYTHON = String.raw` +import json, sys, types + +yaml = types.ModuleType("yaml") +class YAMLError(Exception): + pass +yaml.YAMLError = YAMLError +yaml.safe_load = json.loads +yaml.safe_dump = lambda value, **_kwargs: json.dumps(value) +sys.modules["yaml"] = yaml +`; function fixtureSnapshot(paths: readonly string[]): Record { return Object.fromEntries( @@ -33,7 +44,8 @@ it("detects real Hermes config/hash drift without mutating the inspected fixture [ "-c", String.raw` -import importlib.util, json, os, sys, yaml +${YAML_STUB_PYTHON} +import importlib.util, json, os, sys def load(name, file_path): spec = importlib.util.spec_from_file_location(name, file_path) @@ -87,6 +99,7 @@ print(json.dumps(payload, sort_keys=True)) [ "-c", String.raw` +${YAML_STUB_PYTHON} import importlib.util, os, sys spec = importlib.util.spec_from_file_location("gateway_drift_inspection", sys.argv[1]) diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index e2896065b50..663dc314835 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -149,6 +149,7 @@ export interface AgentDefinition { config?: ManifestRecord; inference?: AgentInference; mcp?: AgentMcpCapability; + state_lock_plan_in_image?: boolean; state_files?: AgentStateFile[]; user_managed_files?: string[]; _legacy_paths?: StringMap; @@ -171,6 +172,7 @@ export interface AgentDefinition { readonly nonBackupStateDirs: string[]; readonly nonBackupStateDirPrefixes: string[]; readonly stateLockPlan: AgentStateLockPlan; + readonly stateLockPlanInImage: boolean; readonly stateFiles: AgentStateFile[]; readonly userManagedFiles: string[]; readonly versionCommand: string; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 349841cc95b..74b4d4bec2d 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -128,6 +128,22 @@ describe("agent definitions", () => { expect(loadAgent("langchain-deepagents-code").configPaths.shieldsFiles).toEqual([]); }); + it("derives image state-lock-plan support from each agent manifest (#8006)", () => { + expect(loadAgent("openclaw").stateLockPlanInImage).toBe(true); + expect(loadAgent("hermes").stateLockPlanInImage).toBe(true); + expect(loadAgent("langchain-deepagents-code").stateLockPlanInImage).toBe(false); + }); + + it("rejects a non-boolean image state-lock-plan declaration (#8006)", () => { + const agentName = `invalid-image-plan-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "state_lock_plan_in_image: yes-please"].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_lock_plan_in_image.*boolean/); + }); + it.each([ ["a scalar", " shields_files: .env"], ["a non-string entry", " shields_files:\n - 42"], diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index 6ed3033d3b8..aeaeb13cbb6 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -39,6 +39,7 @@ import { readObject, readPortArray, readStateFiles, + readStateLockPlanInImage, readString, readStringArray, readStringMap, @@ -183,6 +184,7 @@ export function loadAgent(name: string): AgentDefinition { const nonBackupStateDirs = stateDirectoryPaths(stateDirectories, { backup: false }); const nonBackupStateDirPrefixes = stateDirectoryPrefixes(stateDirectories, { backup: false }); const stateLockPlan = buildStateLockPlan(stateDirectories); + const stateLockPlanInImage = readStateLockPlanInImage(raw); const stateFiles = readStateFiles(raw); const userManagedFiles = readUserManagedFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); @@ -207,6 +209,7 @@ export function loadAgent(name: string): AgentDefinition { config, inference, mcp, + state_lock_plan_in_image: stateLockPlanInImage, state_files: stateFiles, user_managed_files: userManagedFiles, _legacy_paths: legacyPathConfig, @@ -299,6 +302,10 @@ export function loadAgent(name: string): AgentDefinition { return stateLockPlan; }, + get stateLockPlanInImage(): boolean { + return stateLockPlanInImage; + }, + get stateFiles(): AgentStateFile[] { return stateFiles ?? []; }, diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index 2cad88be1cf..db4ee60d5f8 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -45,6 +45,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: false, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index 8d284a4876b..22b3d36c7e9 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -46,6 +46,15 @@ export function readBoolean(record: ManifestRecord, key: string): boolean | unde return typeof value === "boolean" ? value : undefined; } +export function readStateLockPlanInImage(record: ManifestRecord): boolean { + const value = record.state_lock_plan_in_image; + if (value === undefined) return false; + if (typeof value !== "boolean") { + throw new Error("Agent manifest field 'state_lock_plan_in_image' must be a boolean"); + } + return value; +} + export function readVersionScheme(record: ManifestRecord): AgentVersionScheme | undefined { const value = record.version_scheme; if (value === "semver" || value === "calendar") return value; diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index ccf6fa7638c..a5aa5633df3 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -47,6 +47,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: false, stateFiles: [], userManagedFiles: [], versionCommand: "agent --version", diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 32d18daf6aa..d34b96713b2 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -40,6 +40,7 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: false, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", diff --git a/src/lib/agent/state-directory-contract.test.ts b/src/lib/agent/state-directory-contract.test.ts index b28d1ae465d..daa09981707 100644 --- a/src/lib/agent/state-directory-contract.test.ts +++ b/src/lib/agent/state-directory-contract.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { loadAgent } from "./defs"; +import { listAgents, loadAgent } from "./defs"; import { buildStateLockPlan, readStateDirectories, @@ -134,7 +134,10 @@ describe("agent state directory contract", () => { // source-shape-contract: security -- Generated image plans must match the reviewed AgentDefinition projection it("keeps generated image plans equal to their AgentDefinition projections (#8006)", () => { - for (const agentName of ["openclaw", "hermes"]) { + const imagePlanAgents = listAgents().filter( + (agentName) => loadAgent(agentName).stateLockPlanInImage, + ); + for (const agentName of imagePlanAgents) { const generated = JSON.parse( fs.readFileSync( path.join(process.cwd(), "agents", agentName, "state-lock-plan.json"), @@ -165,6 +168,12 @@ describe("agent state directory contract", () => { }, /complete path component/, ], + [ + { + state_dirs: [{ path: "state", shields: "read-only", writable_subpaths: ["*"] }], + }, + /literal directory name/, + ], [ { state_dirs: [{ path: "state", shields: "read-only", writable_subpaths: ["runtime/*"] }], diff --git a/src/lib/agent/state-directory-contract.ts b/src/lib/agent/state-directory-contract.ts index 74eacc6d503..a0cd3739d3f 100644 --- a/src/lib/agent/state-directory-contract.ts +++ b/src/lib/agent/state-directory-contract.ts @@ -67,7 +67,7 @@ function readWritableSubpaths(value: unknown, field: string): string[] { throw new Error(`Agent manifest field '${entryField}' must be a string`); } assertCanonicalPath(entry, entryField, true); - if (entry.endsWith("/*")) { + if (entry.split("/").at(-1) === "*") { throw new Error( `Agent manifest field '${entryField}' must end with a literal directory name`, ); diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index 1f0ee3d418d..ce27f5b8f6f 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -14,7 +14,7 @@ export function setAgentRegistryReaderForTest(reader: AgentRegistryReader | null agentRegistryReaderForTest = reader; } -function readAgentRegistryNames(): readonly string[] { +export function readAgentRegistryNames(): readonly string[] { if (agentRegistryReaderForTest) return agentRegistryReaderForTest(); const { listAgents } = require("../agent/defs") as typeof import("../agent/defs"); return listAgents(); diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index a716a0c07ec..0a4b00fdefe 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -52,7 +52,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", - "5cc53ef9c588470f325c5df8189a2eb1525140d947332ae2c0a80fccb2f36ccb", + "e6183a55510d81cd4d8b39d985571112aaea67001f3eb395ee7e8e660e96bdf8", "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", "ca1f7b1cb9dd5d467f806792c4072a84ef1e6402c3e8650b6325b95cc186ccdf", "7e6a6879382f833f17be02ca7d287685b6afa1c423b1e087b3b05dd677d6e325", diff --git a/src/lib/sandbox/agent-config.test.ts b/src/lib/sandbox/agent-config.test.ts index 316765e92ac..f8fbd1b8e1f 100644 --- a/src/lib/sandbox/agent-config.test.ts +++ b/src/lib/sandbox/agent-config.test.ts @@ -24,6 +24,7 @@ function openClawAgent() { shieldsFiles: [], }, stateLockPlan: PLAN, + stateLockPlanInImage: true, }; } @@ -49,6 +50,7 @@ describe("agent config resolution", () => { configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: PLAN, + stateLockPlanInImage: true, }); expect(loadAgent).toHaveBeenCalledWith("openclaw"); }); @@ -108,7 +110,7 @@ describe("agent config resolution", () => { ])("rejects %s before constructing privileged paths", (_case, configPaths, expected) => { const deps = dependencies({ getSandbox: vi.fn(() => ({ agent: "hermes" })), - loadAgent: vi.fn(() => ({ configPaths, stateLockPlan: PLAN })), + loadAgent: vi.fn(() => ({ configPaths, stateLockPlan: PLAN, stateLockPlanInImage: true })), }); expect(() => resolveAgentConfig("alpha", deps)).toThrow(expected); @@ -126,6 +128,7 @@ describe("agent config resolution", () => { shieldsFiles: [".secrets"], }, stateLockPlan: PLAN, + stateLockPlanInImage: true, })), }); @@ -137,6 +140,7 @@ describe("agent config resolution", () => { configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.secrets"], stateLockPlan: PLAN, + stateLockPlanInImage: true, }); }); @@ -152,6 +156,7 @@ describe("agent config resolution", () => { shieldsFiles: [], }, stateLockPlan: PLAN, + stateLockPlanInImage: false, })), }); @@ -179,6 +184,7 @@ describe("agent config resolution", () => { shieldsFiles, }, stateLockPlan: PLAN, + stateLockPlanInImage: true, })), }); @@ -200,6 +206,7 @@ describe("agent config resolution", () => { shieldsFiles: shieldsFiles as unknown as string[], }, stateLockPlan: PLAN, + stateLockPlanInImage: true, })), }); @@ -218,6 +225,7 @@ describe("agent config resolution", () => { shieldsFiles: [".env"], }, stateLockPlan: PLAN, + stateLockPlanInImage: false, })), }); diff --git a/src/lib/sandbox/agent-config.ts b/src/lib/sandbox/agent-config.ts index 02e09614341..c81a24a17b6 100644 --- a/src/lib/sandbox/agent-config.ts +++ b/src/lib/sandbox/agent-config.ts @@ -15,6 +15,7 @@ export interface AgentConfigTarget { configFile: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; } export interface AgentConfigDependencies { @@ -28,6 +29,7 @@ export interface AgentConfigDependencies { shieldsFiles: readonly string[]; }; stateLockPlan: AgentStateLockPlan; + stateLockPlanInImage: boolean; }; } @@ -38,6 +40,7 @@ export const DEFAULT_AGENT_CONFIG: AgentConfigTarget = { format: "json", configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlanInImage: true, }; function defaultDependencies(): AgentConfigDependencies { @@ -124,5 +127,6 @@ export function resolveAgentConfig( configFile: cfg.configFile, sensitiveFiles, stateLockPlan: agent.stateLockPlan, + stateLockPlanInImage: agent.stateLockPlanInImage, }; } diff --git a/src/lib/sandbox/hermes-dashboard-reseed.test.ts b/src/lib/sandbox/hermes-dashboard-reseed.test.ts index 0fa3dfea8c5..9de04c1feea 100644 --- a/src/lib/sandbox/hermes-dashboard-reseed.test.ts +++ b/src/lib/sandbox/hermes-dashboard-reseed.test.ts @@ -20,6 +20,7 @@ const TARGET: AgentConfigTarget = { configDir: "/sandbox/.hermes", format: "yaml", configFile: "config.yaml", + stateLockPlanInImage: true, }; const PYTHON = "/opt/hermes/.venv/bin/python3"; const SEEDER = "/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py"; diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index f753d4457d8..a93cb3e349f 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -89,6 +89,14 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { const stateDirLock = requireDist("./state-dir-lock.js"); const childProcess = requireDist("node:child_process"); let openClawPosture: "locked" | "mutable" = "mutable"; + const stateLockPlan = { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }; vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies:\n test: {}\n"); @@ -109,14 +117,8 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { configFile: "openclaw.json", configPath: "/sandbox/.openclaw/openclaw.json", format: "json", - stateLockPlan: { - version: 1, - readOnlyRoots: ["skills"], - confidentialRoots: [], - readOnlyPrefixes: [], - confidentialPrefixes: [], - writableSubpaths: [], - }, + stateLockPlan, + stateLockPlanInImage: true, }); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "openclaw", openshellDriver: "docker" }); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [{ name: "openclaw" }] }); @@ -140,23 +142,8 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { ); vi.spyOn(dockerExec, "dockerSpawnSync").mockImplementation((argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; - if (args.includes("cat") && args.includes("/usr/local/share/nemoclaw/state-lock-plan.json")) { - return { - status: 0, - signal: null, - stdout: `${JSON.stringify({ - version: 1, - readOnlyRoots: ["skills"], - confidentialRoots: [], - readOnlyPrefixes: [], - confidentialPrefixes: [], - writableSubpaths: [], - })}\n`, - stderr: "", - pid: 0, - output: [], - } as never; - } + const readsStateLockPlan = + args.includes("cat") && args.includes("/usr/local/share/nemoclaw/state-lock-plan.json"); const action = ["preflight", "lock", "unlock"].find((candidate) => args.includes(candidate)); const openClawGuard = args.some((arg) => arg.endsWith("openclaw-config-guard.py")); const shouldFailOpenClawGuard = Boolean( @@ -191,20 +178,22 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { const successResult = { status: 0, signal: null, - stdout: action - ? `${JSON.stringify({ - type: "result", - action, - status: "ok", - ...(openClawGuard - ? { - configDir: "/sandbox/.openclaw", - files: ["openclaw.json", ".config-hash"], - chattrApplied: action === "lock", - } - : { issueCount: 0 }), - })}\n` - : "", + stdout: readsStateLockPlan + ? `${JSON.stringify(stateLockPlan)}\n` + : action + ? `${JSON.stringify({ + type: "result", + action, + status: "ok", + ...(openClawGuard + ? { + configDir: "/sandbox/.openclaw", + files: ["openclaw.json", ".config-hash"], + chattrApplied: action === "lock", + } + : { issueCount: 0 }), + })}\n` + : "", stderr: "", pid: 0, output: [], diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 3e59be9c189..0811f8cb61e 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -51,6 +51,7 @@ vi.mock("../sandbox/agent-config", () => ({ confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: true, })), })); @@ -723,6 +724,7 @@ describe("shields — unit logic", () => { agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + stateLockPlanInImage: true, }), }), ).toThrow("exit 2"); @@ -797,6 +799,44 @@ describe("shields — unit logic", () => { expect(exitSpy).toHaveBeenCalledWith(2); }); + it("reports confirmed plan drift when filesystem verification also throws", async () => { + const sandboxName = "openclaw"; + writeSealedLockedState(sandboxName); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => { + throw new Error(`exit ${String(code)}`); + }); + + const { shieldsStatus } = await loadShieldsModule(); + expect(() => + shieldsStatus(sandboxName, true, { + verifyLockState: () => { + throw new Error("filesystem verification failed"); + }, + verifyStateLockPlan: () => [ + "installed state lock plan differs from the current agent manifest", + ], + resolveConfig: () => ({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + stateLockPlanInImage: true, + }), + }), + ).toThrow("exit 2"); + + const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); + expect(errors).toContain( + "state lock plan: installed state lock plan differs from the current agent manifest", + ); + expect(errors).toContain( + "unable to verify agent config target: filesystem verification failed", + ); + expect(errors).toContain( + "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", + ); + }); + it("passes the persisted fileHashes seal to the verifier when present", async () => { const sandboxName = "openclaw"; const fileHashes = { diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index e523736d25a..790652e79c0 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -767,6 +767,7 @@ type AgentConfigTarget = { configDir: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }; function requireStateLockPlan(target: AgentConfigTarget): AgentStateLockPlan { @@ -1675,8 +1676,8 @@ function unlockAgentConfigUnderMutationLock( const target = ensureConfigHashSensitiveFile(rawTarget); const compatibilityIssues = stateLockPlanCompatibilityIssues( stateDirLockExec(sandboxName), - target.configDir, requireStateLockPlan(target), + target.stateLockPlanInImage, ); if (compatibilityIssues.length > 0) { throw new Error(`Config not unlocked: ${compatibilityIssues.join(", ")}`); @@ -1738,6 +1739,7 @@ function unlockAgentConfigUnderMutationLock( "sandbox:sandbox", false, requireStateLockPlan(target), + target.stateLockPlanInImage, ); for (const issue of stateDirUnlockIssues) errors.push(`state dir unlock: ${issue}`); } @@ -1867,6 +1869,7 @@ function unlockAgentConfigUnderMutationLock( target.configDir, rollbackLocked, requireStateLockPlan(target), + target.stateLockPlanInImage, ), ); } catch (rollbackError) { @@ -1898,6 +1901,7 @@ function unlockAgentConfigUnderMutationLock( target.configDir, rollbackLocked, requireStateLockPlan(target), + target.stateLockPlanInImage, ), ); } catch (rollbackError) { @@ -2046,8 +2050,8 @@ function lockAgentConfigUnderMutationLock( const target = ensureConfigHashSensitiveFile(rawTarget); const compatibilityIssues = stateLockPlanCompatibilityIssues( stateDirLockExec(sandboxName), - target.configDir, requireStateLockPlan(target), + target.stateLockPlanInImage, ); if (compatibilityIssues.length > 0) { throw new Error(`Config not locked: ${compatibilityIssues.join(", ")}`); @@ -2073,6 +2077,7 @@ function lockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, requireStateLockPlan(target), + target.stateLockPlanInImage, ); if (preflightIssues.length > 0) { throw new Error(`Config not locked: ${preflightIssues.join(", ")}`); @@ -2142,6 +2147,7 @@ function lockAgentConfigUnderMutationLock( "root:sandbox", true, requireStateLockPlan(target), + target.stateLockPlanInImage, ); if (stateDirLockIssues.length > 0) { throw new Error(`Config not locked: ${stateDirLockIssues.join(", ")}`); @@ -2250,6 +2256,7 @@ function lockAgentConfigUnderMutationLock( target.configDir, rollbackLocked, requireStateLockPlan(target), + target.stateLockPlanInImage, ).map((message) => ({ message, readinessFailure: false })), ); } catch (rollbackError) { @@ -3318,23 +3325,33 @@ function shieldsStatusWithoutHostLock( let planIssues: string[] = []; try { const target = ensureConfigHashSensitiveFile(resolveConfig(sandboxName)); - planIssues = deps.verifyStateLockPlan - ? deps.verifyStateLockPlan(sandboxName, target) - : stateLockPlanCompatibilityIssues( - stateDirLockExec(sandboxName), - target.configDir, - requireStateLockPlan(target), - ); - driftIssues = [ - ...planIssues.map((issue) => `state lock plan: ${issue}`), - ...verify(sandboxName, target, { - verifyChattr: state.chattrApplied === true, - verifyParentProtection: requiresProtectedSandboxParent(target), - exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), - assertLegacyLayout: assertNoLegacyStateLayout, - expectedHashes: state.fileHashes, - }).issues, - ]; + try { + planIssues = deps.verifyStateLockPlan + ? deps.verifyStateLockPlan(sandboxName, target) + : stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + requireStateLockPlan(target), + target.stateLockPlanInImage, + ); + driftIssues.push(...planIssues.map((issue) => `state lock plan: ${issue}`)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + driftIssues.push(`unable to verify state lock plan: ${msg}`); + } + try { + driftIssues.push( + ...verify(sandboxName, target, { + verifyChattr: state.chattrApplied === true, + verifyParentProtection: requiresProtectedSandboxParent(target), + exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd), + assertLegacyLayout: assertNoLegacyStateLayout, + expectedHashes: state.fileHashes, + }).issues, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + driftIssues.push(`unable to verify agent config target: ${msg}`); + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); driftIssues = [`unable to resolve agent config target: ${msg}`]; diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 199a6e6bc88..16b3353f57a 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -58,6 +58,7 @@ function hermesTarget() { configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], stateLockPlan: STATE_LOCK_PLAN, + stateLockPlanInImage: true, }; } diff --git a/src/lib/shields/openclaw-transition.test.ts b/src/lib/shields/openclaw-transition.test.ts index 08c16183ae9..faf6e9e6ebb 100644 --- a/src/lib/shields/openclaw-transition.test.ts +++ b/src/lib/shields/openclaw-transition.test.ts @@ -31,6 +31,7 @@ function openClawTarget() { configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: STATE_LOCK_PLAN, + stateLockPlanInImage: true, }; } diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 049f9f0708c..a873f166bbb 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -45,6 +45,7 @@ describe("shields policy transition", () => { confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: false, }); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -119,6 +120,7 @@ describe("shields config lock without a shipped config hash", () => { confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: false, }; } diff --git a/src/lib/shields/state-dir-lock.test.ts b/src/lib/shields/state-dir-lock.test.ts index 8985c44a4f4..b47a8f83b5f 100644 --- a/src/lib/shields/state-dir-lock.test.ts +++ b/src/lib/shields/state-dir-lock.test.ts @@ -49,24 +49,23 @@ function createExec( privileged: { run: (cmd, input) => { calls.push({ cmd, input }); - if (cmd[0] === "test" && cmd.at(-1) === CONTAINER_STATE_LOCK_PLAN) { - return { - status: runtimePlan === "current" ? 0 : 1, - signal: null, - stdout: "", - stderr: "", - }; - } - if (cmd[0] === "cat" && cmd[1] === CONTAINER_STATE_LOCK_PLAN) { - return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; - } - if (cmd[0] === "test") { - return { - status: helperAvailable ? 0 : 1, - signal: null, - stdout: "", - stderr: "", - }; + switch (cmd[0]) { + case "test": + return { + status: + cmd.at(-1) === CONTAINER_STATE_LOCK_PLAN + ? runtimePlan === "current" + ? 0 + : 1 + : helperAvailable + ? 0 + : 1, + signal: null, + stdout: "", + stderr: "", + }; + case "cat": + return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; } const pythonIndex = cmd.indexOf("python3"); const action = cmd[pythonIndex + 3]; @@ -94,14 +93,18 @@ describe("recursive state-dir lock host wiring", () => { it("re-locks state directories when the interrupted transition began locked", () => { const { calls, privileged } = createExec(); - expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", true, PLAN)).toEqual([]); + expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", true, PLAN, true)).toEqual( + [], + ); expect(actions(calls)).toEqual(["preflight", "lock"]); }); it("restores mutable state directories when the interrupted transition began mutable", () => { const { calls, privileged } = createExec(); - expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", false, PLAN)).toEqual([]); + expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", false, PLAN, true)).toEqual( + [], + ); expect(actions(calls)).toEqual(["unlock"]); }); @@ -109,7 +112,7 @@ describe("recursive state-dir lock host wiring", () => { const { calls, privileged } = createExec(); expect( - applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN, true), ).toEqual([]); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); expect(invocation?.cmd).toEqual([ @@ -133,7 +136,7 @@ describe("recursive state-dir lock host wiring", () => { const { calls, privileged } = createExec("historical"); expect( - applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN, true), ).toEqual([]); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); @@ -156,7 +159,7 @@ describe("recursive state-dir lock host wiring", () => { const { calls, privileged } = createExec("historical", false); expect( - applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN, true), ).toEqual([]); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); @@ -181,7 +184,7 @@ describe("recursive state-dir lock host wiring", () => { const { calls, privileged } = createExec(); expect( - applyStateDirLockMode(privileged, "/sandbox/.deepagents", "root:sandbox", true, PLAN), + applyStateDirLockMode(privileged, "/sandbox/.deepagents", "root:sandbox", true, PLAN, false), ).toEqual([]); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); @@ -195,7 +198,7 @@ describe("recursive state-dir lock host wiring", () => { const { calls, privileged } = createExec("current", false); expect( - applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN), + applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true, PLAN, true), ).toEqual([ "state-dir guard is unavailable in an image that contains a generated state lock plan", ]); @@ -203,21 +206,23 @@ describe("recursive state-dir lock host wiring", () => { }); it.each([ - ["malformed JSON", "{"], - ["an unknown field", JSON.stringify({ ...PLAN, registry: [] })], - ["a different policy", JSON.stringify({ ...PLAN, readOnlyRoots: ["hooks"] })], - ])("rejects an installed plan with %s before mutation", (_case, payload) => { + ["malformed JSON", "{", /not valid JSON/], + ["an unknown field", JSON.stringify({ ...PLAN, registry: [] }), /unknown fields: registry/], + [ + "a different policy", + JSON.stringify({ ...PLAN, readOnlyRoots: ["hooks"] }), + /differs from the current agent manifest/, + ], + ])("rejects an installed plan with %s before mutation", (_case, payload, expected) => { const privileged: PrivilegedExec = { - run: (cmd) => { - if (cmd[0] === "test") { - return { status: 0, signal: null, stdout: "", stderr: "" }; - } - return { status: 0, signal: null, stdout: payload, stderr: "" }; - }, + run: (cmd) => + cmd[0] === "test" + ? { status: 0, signal: null, stdout: "", stderr: "" } + : { status: 0, signal: null, stdout: payload, stderr: "" }, }; - expect(stateLockPlanCompatibilityIssues(privileged, "/sandbox/.openclaw", PLAN)).toEqual([ - expect.stringMatching(/installed state lock plan|differs from the current agent manifest/), + expect(stateLockPlanCompatibilityIssues(privileged, PLAN, true)).toEqual([ + expect.stringMatching(expected), ]); }); @@ -234,17 +239,38 @@ describe("recursive state-dir lock host wiring", () => { }, }; - expect(stateLockPlanCompatibilityIssues(privileged, "/sandbox/.openclaw", PLAN)).toEqual([]); + expect(stateLockPlanCompatibilityIssues(privileged, PLAN, true)).toEqual([]); + }); + + it("treats installed plan arrays as unordered sets", () => { + const reordered = { + ...PLAN, + readOnlyRoots: ["workspace", ...PLAN.readOnlyRoots], + writableSubpaths: ["workspace/*/sessions", ...PLAN.writableSubpaths], + }; + const expected = { + ...PLAN, + readOnlyRoots: [...reordered.readOnlyRoots].reverse(), + writableSubpaths: [...reordered.writableSubpaths].reverse(), + }; + const privileged: PrivilegedExec = { + run: (cmd) => + cmd[0] === "test" + ? { status: 0, signal: null, stdout: "", stderr: "" } + : { status: 0, signal: null, stdout: JSON.stringify(reordered), stderr: "" }, + }; + + expect(stateLockPlanCompatibilityIssues(privileged, expected, true)).toEqual([]); }); it("surfaces structured helper findings and rejects contradictory exit contracts", () => { const privileged: PrivilegedExec = { run: (cmd) => { - if (cmd[0] === "test") { - return { status: 0, signal: null, stdout: "", stderr: "" }; - } - if (cmd[0] === "cat") { - return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; + switch (cmd[0]) { + case "test": + return { status: 0, signal: null, stdout: "", stderr: "" }; + case "cat": + return { status: 0, signal: null, stdout: JSON.stringify(PLAN), stderr: "" }; } return { status: 0, @@ -268,7 +294,7 @@ describe("recursive state-dir lock host wiring", () => { }, }; - expect(preflightStateDirLock(privileged, "/sandbox/.openclaw", PLAN)).toEqual( + expect(preflightStateDirLock(privileged, "/sandbox/.openclaw", PLAN, true)).toEqual( expect.arrayContaining([ expect.stringContaining("[hardlinked-entry]"), expect.stringContaining("reported failure with a zero exit"), diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index 2297aa82c52..1815815c2ff 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -100,7 +100,8 @@ function parseInstalledPlan(payload: string): AgentStateLockPlan | string { function plansMatch(actual: AgentStateLockPlan, expected: AgentStateLockPlan): boolean { return PLAN_ARRAY_FIELDS.every( - (field) => JSON.stringify(actual[field]) === JSON.stringify(expected[field]), + (field) => + JSON.stringify([...actual[field]].sort()) === JSON.stringify([...expected[field]].sort()), ); } @@ -110,16 +111,12 @@ type RuntimePlanInspection = | { kind: "historical" } | { kind: "error"; issue: string }; -function hasImageRecoveryPlan(configDir: string): boolean { - return configDir === "/sandbox/.openclaw" || configDir === "/sandbox/.hermes"; -} - function inspectRuntimePlan( privileged: PrivilegedExec, - configDir: string, expected: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): RuntimePlanInspection { - if (!hasImageRecoveryPlan(configDir)) return { kind: "host-current" }; + if (!stateLockPlanInImage) return { kind: "host-current" }; const capability = privileged.run(["test", "-r", CONTAINER_STATE_LOCK_PLAN]); if (capability.status === 1 && capability.signal === null && !capability.error) { return { kind: "historical" }; @@ -148,10 +145,10 @@ function inspectRuntimePlan( export function stateLockPlanCompatibilityIssues( privileged: PrivilegedExec, - configDir: string, expected: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { - const inspection = inspectRuntimePlan(privileged, configDir, expected); + const inspection = inspectRuntimePlan(privileged, expected, stateLockPlanInImage); return inspection.kind === "error" ? [inspection.issue] : []; } @@ -245,8 +242,9 @@ function runStateDirGuard( action: GuardAction, configDir: string, plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { - const runtimePlan = inspectRuntimePlan(privileged, configDir, plan); + const runtimePlan = inspectRuntimePlan(privileged, plan, stateLockPlanInImage); if (runtimePlan.kind === "error") return [runtimePlan.issue]; if (runtimePlan.kind !== "host-current") { @@ -286,10 +284,9 @@ function runStateDirGuard( return [`trusted host state-dir guard cannot be read: ${message}`]; } - // Agents without an image recovery plan, plus images predating the helper, - // use the bounded host-injection path. Plan-aware images always use their - // root-owned helper so host transitions and PID 1 recovery share one - // immutable implementation. + // Agent definitions without an image plan, plus images predating the helper, + // use the bounded host-injection path. The historical branch remains while + // sandboxes built before the generated-plan artifact are supported. const command = [ ...CONTAINER_TIMEOUT, "python3", @@ -311,8 +308,9 @@ export function preflightStateDirLock( privileged: PrivilegedExec, configDir: string, plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { - return runStateDirGuard(privileged, "preflight", configDir, plan); + return runStateDirGuard(privileged, "preflight", configDir, plan, stateLockPlanInImage); } // Apply and independently verify the complete recursive state-dir posture. @@ -324,6 +322,7 @@ export function applyStateDirLockMode( highRiskOwner: string, isLocking: boolean, plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { const expectedOwner = isLocking ? "root:sandbox" : "sandbox:sandbox"; if (highRiskOwner !== expectedOwner) { @@ -331,7 +330,13 @@ export function applyStateDirLockMode( `state-dir guard owner contract mismatch: ${highRiskOwner} (expected ${expectedOwner})`, ]; } - return runStateDirGuard(privileged, isLocking ? "lock" : "unlock", configDir, plan); + return runStateDirGuard( + privileged, + isLocking ? "lock" : "unlock", + configDir, + plan, + stateLockPlanInImage, + ); } export function restoreStateDirLockPosture( @@ -339,11 +344,26 @@ export function restoreStateDirLockPosture( configDir: string, originallyLocked: boolean, plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { if (!originallyLocked) { - return applyStateDirLockMode(privileged, configDir, "sandbox:sandbox", false, plan); + return applyStateDirLockMode( + privileged, + configDir, + "sandbox:sandbox", + false, + plan, + stateLockPlanInImage, + ); } - const preflightIssues = preflightStateDirLock(privileged, configDir, plan); + const preflightIssues = preflightStateDirLock(privileged, configDir, plan, stateLockPlanInImage); if (preflightIssues.length > 0) return preflightIssues; - return applyStateDirLockMode(privileged, configDir, "root:sandbox", true, plan); + return applyStateDirLockMode( + privileged, + configDir, + "root:sandbox", + true, + plan, + stateLockPlanInImage, + ); } diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index 1b3ff66938f..7cdf8c3b263 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -331,6 +331,7 @@ async function runRestoreTimer(args: TimerArgs): Promise { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlanInImage: boolean; } | null = null; try { // Always prefer the resolved target — even DEFAULT_AGENT_CONFIG @@ -348,6 +349,7 @@ async function runRestoreTimer(args: TimerArgs): Promise { configPath: args.configPath, configDir: args.configDir, sensitiveFiles: [`${args.configDir}/.config-hash`], + stateLockPlanInImage: false, }; } else { lockVerified = false; diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index dbf0e380998..ef283e08f95 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -791,6 +791,13 @@ function isAllowedDiscoveredStateDir( ); } +function hasStateDirectorySources( + exactDirectories: readonly string[], + directoryPrefixes: readonly string[], +): boolean { + return exactDirectories.length > 0 || directoryPrefixes.length > 0; +} + function describeStateDirDiscoveryFailure( result: ReturnType, invalidDirectories: readonly string[], @@ -1152,6 +1159,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const dir = agent.configPaths.dir; const stateDirs = agent.backupStateDirs; const stateDirPrefixes = agent.backupStateDirPrefixes; + const hasBackupDirectories = hasStateDirectorySources(stateDirs, stateDirPrefixes); const stateFiles = normalizeStateFileSpecs(agent.stateFiles); _log( `backupSandboxState: agent=${agentName}, dir=${dir}, stateDirs=[${stateDirs.join(",")}], stateDirPrefixes=[${stateDirPrefixes.join(",")}], stateFiles=[${stateFiles.map((f) => f.path).join(",")}]`, @@ -1281,7 +1289,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const failedFiles: string[] = []; let unreachable = false; - if (stateDirs.length === 0 && stateDirPrefixes.length === 0 && stateFiles.length === 0) { + if (!hasBackupDirectories && stateFiles.length === 0) { _log("WARNING: Agent manifest declares no state_dirs or state_files — nothing to back up"); const publicationError = validateSnapshotPublication(backupPath, options.validateBeforePublish); if (publicationError) { @@ -1322,7 +1330,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; try { - if (stateDirs.length > 0 || stateDirPrefixes.length > 0) { + if (hasBackupDirectories) { // Build tar command that only includes existing directories. // First, check which declared state dirs actually exist in the sandbox, // then discover directories matching prefixes declared by the same agent diff --git a/src/lib/tunnel/allowed-origins.test.ts b/src/lib/tunnel/allowed-origins.test.ts index c25c490a7a5..b7e630c5ef5 100644 --- a/src/lib/tunnel/allowed-origins.test.ts +++ b/src/lib/tunnel/allowed-origins.test.ts @@ -22,6 +22,7 @@ const OPENCLAW_TARGET: AgentConfigTarget = { configDir: "/sandbox/.openclaw", format: "json", configFile: "openclaw.json", + stateLockPlanInImage: true, }; /** diff --git a/test/e2e/live/gateway-guard-recovery.test.ts b/test/e2e/live/gateway-guard-recovery.test.ts index a09741a332a..0a5f70c8a69 100644 --- a/test/e2e/live/gateway-guard-recovery.test.ts +++ b/test/e2e/live/gateway-guard-recovery.test.ts @@ -111,6 +111,16 @@ uid_line=next(line for line in rows[0][1].splitlines() if line.startswith("Uid:" assert uid_line.split()[1:] == [expected_uid] * 4, uid_line print("MANAGED_SUPERVISOR=" + rows[0][0] + ":PPID1")`; +const OPENCLAW_STATE_LOCK_PLAN_PROBE = String.raw`import json, os +path="/usr/local/share/nemoclaw/state-lock-plan.json" +metadata=os.stat(path, follow_symlinks=False) +assert metadata.st_uid == 0 and metadata.st_gid == 0, metadata +assert metadata.st_mode & 0o022 == 0, oct(metadata.st_mode) +plan=json.load(open(path, encoding="utf-8")) +assert "workspace" in plan["readOnlyRoots"], plan +assert "workspace-" in plan["readOnlyPrefixes"], plan +print("OPENCLAW_STATE_LOCK_PLAN=installed")`; + async function findSandboxContainer(host: HostCliClient, artifactName: string): Promise { const result = await host.command( "docker", @@ -331,6 +341,13 @@ test("gateway recovery restores /tmp guard chain after pod-recreate wipe (#2701) expect(resultText(trustedRecovery)).toMatch( /Probe complete: (?:recovered OpenClaw gateway|OpenClaw gateway is running)/, ); + const restartStateLockPlan = await sandbox.exec( + instance.sandboxName, + ["python3", "-c", OPENCLAW_STATE_LOCK_PLAN_PROBE], + { artifactName: "restart-installed-state-lock-plan", env: buildAvailabilityProbeEnv() }, + ); + expect(restartStateLockPlan.exitCode, resultText(restartStateLockPlan)).toBe(0); + expect(restartStateLockPlan.stdout).toContain("OPENCLAW_STATE_LOCK_PLAN=installed"); const recoveredContainerId = await findSandboxContainer(host, "restart-container-after"); expect(recoveredContainerId).toBe(originalContainerId); @@ -434,6 +451,13 @@ test("gateway recovery restores /tmp guard chain after pod-recreate wipe (#2701) expect(legacyRecovery.timedOut, resultText(legacyRecovery)).toBe(false); expect(legacyRecovery.exitCode, resultText(legacyRecovery)).toBe(0); expect(resultText(legacyRecovery)).toContain("Probe complete: recovered OpenClaw gateway"); + const legacyStateLockPlan = await sandbox.exec( + instance.sandboxName, + ["python3", "-c", OPENCLAW_STATE_LOCK_PLAN_PROBE], + { artifactName: "legacy-restart-installed-state-lock-plan", env: buildAvailabilityProbeEnv() }, + ); + expect(legacyStateLockPlan.exitCode, resultText(legacyStateLockPlan)).toBe(0); + expect(legacyStateLockPlan.stdout).toContain("OPENCLAW_STATE_LOCK_PLAN=installed"); const legacyRecoveredContainerId = await findSandboxContainer( host, diff --git a/test/e2e/live/hermes-shields-config.test.ts b/test/e2e/live/hermes-shields-config.test.ts index 3d2b192fdb5..66ca650848f 100644 --- a/test/e2e/live/hermes-shields-config.test.ts +++ b/test/e2e/live/hermes-shields-config.test.ts @@ -26,6 +26,7 @@ const COMPATIBLE_API_KEY = "hermes-shields-e2e-key"; const COMPATIBLE_MODEL = "hermes-shields-e2e-model"; const CONFIG_PATH = "/sandbox/.hermes/config.yaml"; const HERMES_DIR = "/sandbox/.hermes"; +const STATE_LOCK_PLAN_PATH = "/usr/local/share/nemoclaw/state-lock-plan.json"; const COMMAND_TIMEOUT_MS = 120_000; validateSandboxName(SANDBOX_NAME); @@ -170,6 +171,7 @@ test("hermes-shields-config: fresh non-root Hermes sandbox completes two shields boundary: "fresh CPU-only Hermes onboard plus two real shields down/up transitions", contracts: [ "fresh OpenShell-managed non-root Hermes startup mints its API key", + "the installed state lock plan keeps skills read-only and pairing confidential", "the first shields-down reconciles the startup hash anchor", "shields-up establishes the root-owned locked posture", "a second down/up cycle completes without corrupting config state", @@ -277,6 +279,7 @@ test("hermes-shields-config: fresh non-root Hermes sandbox completes two shields "test ! -e /run/nemoclaw/hermes-root-lifecycle", `grep -Eq '^API_SERVER_KEY=[0-9a-fA-F]{64}$' ${HERMES_DIR}/.env`, `stat -c '%a %U:%G' ${HERMES_DIR}`, + `python3 -c 'import json; p=json.load(open("${STATE_LOCK_PLAN_PATH}", encoding="utf-8")); assert "skills" in p["readOnlyRoots"]; assert "pairing" in p["confidentialRoots"]; print("STATE_LOCK_PLAN_MODES=skills:read-only,pairing:confidential")'`, `sha256sum ${CONFIG_PATH} | awk '{print $1}'`, ].join("\n"), "fresh-nonroot-trigger", @@ -284,6 +287,7 @@ test("hermes-shields-config: fresh non-root Hermes sandbox completes two shields assertExitZero(trigger, "prove fresh non-root Hermes startup trigger"); const triggerLines = trigger.stdout.trim().split(/\r?\n/); expect(triggerLines[0]).toMatch(/^(700|3770) sandbox:sandbox$/); + expect(trigger.stdout).toContain("STATE_LOCK_PLAN_MODES=skills:read-only,pairing:confidential"); const configHashBefore = triggerLines.at(-1) ?? ""; expect(configHashBefore).toMatch(/^[0-9a-f]{64}$/); @@ -316,6 +320,7 @@ test("hermes-shields-config: fresh non-root Hermes sandbox completes two shields assertions: { configPreserved: true, freshNonrootTrigger: true, + stateLockPlanModes: true, firstCycle: true, secondCycle: true, }, diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index 806691cc585..6fb796a5bdd 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -93,7 +93,7 @@ const MARKER_FILE = "/sandbox/.hermes/memories/rebuild-marker.txt"; const MARKER_CONTENT = `REBUILD_HM_E2E_${Date.now()}`; const KANBAN_FILE = "/sandbox/.hermes/kanban.db"; const KANBAN_TASK_TITLE = `NEMOCLAW_REBUILD_KANBAN_${Date.now()}`; -const EXCLUDED_KANBAN_FILE = "/sandbox/.hermes/kanban/excluded-rebuild-marker.txt"; +const EXCLUDED_HOOKS_FILE = "/sandbox/.hermes/hooks/excluded-rebuild-marker.txt"; const DISCORD_PLACEHOLDER = "openshell:resolve:env:DISCORD_BOT_TOKEN"; const DISCORD_FAKE_TOKEN = "test-fake-discord-token-rebuild-e2e"; const PRE_REBUILD_API_SERVER_KEY = createHash("sha256").update(MARKER_CONTENT).digest("hex"); @@ -1093,8 +1093,8 @@ test(STALE_BASE_REBUILD "sh", "-c", [ - `mkdir -p ${shellQuote(path.dirname(EXCLUDED_KANBAN_FILE))}`, - `printf '%s' ${shellQuote(MARKER_CONTENT)} > ${shellQuote(EXCLUDED_KANBAN_FILE)}`, + `mkdir -p ${shellQuote(path.dirname(EXCLUDED_HOOKS_FILE))}`, + `printf '%s' ${shellQuote(MARKER_CONTENT)} > ${shellQuote(EXCLUDED_HOOKS_FILE)}`, ].join(" && "), ], { @@ -1345,17 +1345,17 @@ test(STALE_BASE_REBUILD expectExitZero(restoredKanban, "list Hermes kanban tasks after rebuild"); expect(resultText(restoredKanban)).toContain(KANBAN_TASK_TITLE); - const excludedKanbanState = await host.command( + const excludedHooksState = await host.command( activeOpenshellBin, - ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "test", "!", "-e", EXCLUDED_KANBAN_FILE], + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "test", "!", "-e", EXCLUDED_HOOKS_FILE], { - artifactName: "phase-7-verify-excluded-kanban-state", + artifactName: "phase-7-verify-excluded-hermes-hooks-state", env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, }, ); - expectExitZero(excludedKanbanState, "verify excluded Hermes kanban state was not restored"); + expectExitZero(excludedHooksState, "verify backup:false Hermes hooks state was not restored"); const restoredEnv = await host.command( activeOpenshellBin, diff --git a/test/e2e/live/sandbox-survival.test.ts b/test/e2e/live/sandbox-survival.test.ts index 2ae366ba860..8a1b9b759d8 100644 --- a/test/e2e/live/sandbox-survival.test.ts +++ b/test/e2e/live/sandbox-survival.test.ts @@ -218,7 +218,7 @@ test( "OpenShell version supports gateway resume and state persistence", "sandbox exec/SSH-equivalent access works before and after gateway restart", "inference.local returns a live PONG before and after gateway restart", - "markers under /sandbox/.openclaw survive the gateway stop/start cycle", + "declared workspace, session, and memory markers survive the gateway stop/start cycle", "final destroy removes the sandbox from NemoClaw registry/list state", ], }); @@ -392,12 +392,15 @@ test( const markerValue = `nemoclaw-survival-${Date.now()}`; const markers: SandboxMarker[] = [ { - path: "/sandbox/.openclaw/.survival-marker-workspace", + path: "/sandbox/.openclaw/workspace/.survival-workspace-marker", value: markerValue, }, - { path: "/sandbox/.openclaw/.survival-marker", value: markerValue }, { - path: "/sandbox/.openclaw/test-data/nested-marker.txt", + path: "/sandbox/.openclaw/agents/main/sessions/.survival-session-marker", + value: markerValue, + }, + { + path: "/sandbox/.openclaw/memory/.survival-memory-marker", value: markerValue, }, ]; diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index d13efbca540..030e8cd7d0c 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -43,6 +43,7 @@ if (!BACKUP_DIR.startsWith(`${path.resolve(BACKUP_ROOT)}${path.sep}`)) { } const MARKER_FILE = "/sandbox/.openclaw/workspace/snapshot-marker.txt"; const SECOND_MARKER = "/sandbox/.openclaw/workspace/snapshot-marker-2.txt"; +const PREFIX_MARKER = "/sandbox/.openclaw/workspace-research/snapshot-marker.txt"; const BASELINE_EXCLUSION_KEY = "openclaw_docs"; const LIVE_TIMEOUT_MS = 36 * 60_000; const INFERENCE_API_KEY = "nvapi-snapshot-commands-fixture-credential"; @@ -330,6 +331,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- "snapshot restore --to returns only after restored gateway pairing is authenticated", "post-restore verification stores its unique session only in the clone and sends one authenticated inference request", "latest snapshot restore recovers latest workspace state", + "snapshot restore recovers state from workspace-* prefix directories", "timestamp-targeted restore recovers the first snapshot state", "snapshot directory excludes credential-bearing env/json files", "snapshot help advertises create/list/restore", @@ -484,7 +486,7 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- [ "sh", "-lc", - `mkdir -p /sandbox/.openclaw/workspace && printf '%s' '${markerContent}' > ${MARKER_FILE}`, + `mkdir -p /sandbox/.openclaw/workspace /sandbox/.openclaw/workspace-research && printf '%s' '${markerContent}' > ${MARKER_FILE} && printf '%s' '${markerContent}' > ${PREFIX_MARKER}`, ], { artifactName: "phase-2-write-marker", @@ -622,7 +624,11 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- const perturb = await sandbox.exec( SANDBOX_NAME, - ["sh", "-lc", `rm -f ${SECOND_MARKER} && printf '%s' 'BROKEN' > ${MARKER_FILE}`], + [ + "sh", + "-lc", + `rm -f ${SECOND_MARKER} ${PREFIX_MARKER} && printf '%s' 'BROKEN' > ${MARKER_FILE}`, + ], { artifactName: "phase-5-perturb-workspace", env: commandEnv(), @@ -645,6 +651,13 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- secondContent, "phase-6-read-second-marker-after-latest-restore", ); + await expectSandboxFileContent( + sandbox, + SANDBOX_NAME, + PREFIX_MARKER, + markerContent, + "phase-6-read-prefix-marker-after-latest-restore", + ); const firstGoneAfterLatest = await sandbox.exec( SANDBOX_NAME, ["sh", "-lc", `test ! -e ${MARKER_FILE}`], diff --git a/test/e2e/live/state-backup-restore.test.ts b/test/e2e/live/state-backup-restore.test.ts index 9dacea83146..440e6414204 100644 --- a/test/e2e/live/state-backup-restore.test.ts +++ b/test/e2e/live/state-backup-restore.test.ts @@ -125,7 +125,7 @@ async function destroySandboxUntilAbsent( ); } -test("state-backup-restore: backup-workspace.sh restores workspace files and memory directory", { +test("state-backup-restore: backup-workspace.sh restores workspace files and memory directory (#8006)", { timeout: TEST_TIMEOUT_MS, meta: { e2ePhases: [ @@ -179,6 +179,7 @@ test("state-backup-restore: backup-workspace.sh restores workspace files and mem "real scripts/backup-workspace.sh backup host process", "real nemoclaw destroy --yes", "real scripts/backup-workspace.sh restore host process", + "legacy workspace backup and restore remains compatible with AgentDefinition state migration", ], }); diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index d33d8270786..f8b1a58be33 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -62,6 +62,7 @@ export function makeAgent(overrides: Partial = {}): AgentDefini confidentialPrefixes: [], writableSubpaths: [], }, + stateLockPlanInImage: true, stateFiles: [], userManagedFiles: [], versionCommand: "hermes --version", diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index ff32a19fabf..fd6cd8ce2d5 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -13,6 +13,35 @@ const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); const HERMES_BUILD_MCP_DIGEST = path.join(ROOT, "agents", "hermes", "build-mcp-digest.py"); const HERMES_RUNTIME_CONFIG_GUARD = path.join(ROOT, "agents", "hermes", "runtime-config-guard.py"); +function writeYamlStubPython(root: string): string { + const bootstrap = path.join(root, "python-yaml-bootstrap.py"); + const wrapper = path.join(root, "python-with-yaml-stub"); + fs.writeFileSync( + bootstrap, + String.raw`import runpy +import sys +import types + +yaml = types.ModuleType("yaml") +class YAMLError(Exception): + pass +yaml.YAMLError = YAMLError +yaml.safe_load = lambda _text: {} +sys.modules["yaml"] = yaml + +script, *args = sys.argv[1:] +sys.argv = [script, *args] +runpy.run_path(script, run_name="__main__") +`, + ); + fs.writeFileSync( + wrapper, + `#!/usr/bin/env bash\nset -euo pipefail\n[[ "\${1:-}" != "-I" ]] || shift\nexec python3 -I ${JSON.stringify(bootstrap)} "$@"\n`, + { mode: 0o700 }, + ); + return wrapper; +} + describe("Hermes doctor and config hash boundary", () => { it("detects a remaining session preview patcher during Hermes upgrades (#5254)", () => { const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); @@ -80,6 +109,7 @@ describe("Hermes doctor and config hash boundary", () => { libDir, "openshell-child-visible-credentials.v0.0.85.json", ); + const stateLockPlanPath = path.join(tmp, "state-lock-plan.json"); const nestedDir = path.join(preloadsDir, "nested"); const profileDir = path.join(tmp, "etc-profile.d"); const bashrcPath = path.join(tmp, "bash.bashrc"); @@ -107,6 +137,7 @@ describe("Hermes doctor and config hash boundary", () => { mcpConfigTransactionPath, mcpCredentialBoundaryPath, path.join(libDir, "state-dir-guard.py"), + stateLockPlanPath, path.join(libDir, "managed-gateway-control.py"), path.join(libDir, "sandbox-rlimits.sh"), path.join(preloadsDir, "gateway-safety-net.js"), @@ -124,6 +155,7 @@ describe("Hermes doctor and config hash boundary", () => { ) .replaceAll("/usr/local/bin", binDir) .replaceAll("/usr/local/lib/nemoclaw", libDir) + .replaceAll("/usr/local/share/nemoclaw/state-lock-plan.json", stateLockPlanPath) .replaceAll("/etc/profile.d", profileDir) .replaceAll("/etc/bash.bashrc", bashrcPath); const script = [ @@ -138,11 +170,11 @@ describe("Hermes doctor and config hash boundary", () => { timeout: 5000, }); - expect(result.status).toBe(0); + expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); expect(fs.readFileSync(chownLogPath, "utf-8")).toBe( [ - `root:root ${path.join(binDir, "nemoclaw-gateway-control")} ${path.join(libDir, "gateway-supervisor.sh")} ${path.join(libDir, "state-dir-guard.py")} ${path.join(libDir, "managed-gateway-control.py")} ${buildMcpDigestPath} ${mcpCredentialBoundaryPath}`, + `root:root ${path.join(binDir, "nemoclaw-gateway-control")} ${path.join(libDir, "gateway-supervisor.sh")} ${path.join(libDir, "state-dir-guard.py")} ${stateLockPlanPath} ${path.join(libDir, "managed-gateway-control.py")} ${buildMcpDigestPath} ${mcpCredentialBoundaryPath}`, `-R 0:0 ${preloadsDir}`, "", ].join("\n"), @@ -157,6 +189,7 @@ describe("Hermes doctor and config hash boundary", () => { expect(mode(buildMcpDigestPath)).toBe("444"); expect(mode(path.join(libDir, "gateway-supervisor.sh"))).toBe("444"); expect(mode(path.join(libDir, "state-dir-guard.py"))).toBe("500"); + expect(mode(stateLockPlanPath)).toBe("444"); expect(mode(path.join(libDir, "managed-gateway-control.py"))).toBe("500"); expect(mode(preloadsDir)).toBe("755"); expect(mode(nestedDir)).toBe("755"); @@ -177,6 +210,7 @@ describe("Hermes doctor and config hash boundary", () => { const fakeHermes = path.join(tmp, "hermes"); const orderLogPath = path.join(tmp, "doctor-generate-order.log"); const etcDir = path.join(tmp, "etc", "nemoclaw"); + const hermesPython = writeYamlStubPython(tmp); const mode = (entry: string) => (fs.statSync(entry).mode & 0o777).toString(8); const fakeGenerateCommand = [ `printf 'generate\\n' >>${JSON.stringify(orderLogPath)}`, @@ -222,7 +256,7 @@ describe("Hermes doctor and config hash boundary", () => { "# Backward-compatible marker", ) .replaceAll("/etc/nemoclaw", etcDir) - .replaceAll("/opt/hermes/.venv/bin/python", "python3") + .replaceAll("/opt/hermes/.venv/bin/python", JSON.stringify(hermesPython)) .replaceAll( "/usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", JSON.stringify(HERMES_BUILD_MCP_DIGEST), @@ -255,7 +289,7 @@ describe("Hermes doctor and config hash boundary", () => { expect([mode(configPath), mode(envPath)]).toEqual(["640", "640"]); const hash = runDockerShell(hashCommand, sandboxRoot); - expect(hash.result.status).toBe(0); + expect(hash.result.status, hash.result.stderr).toBe(0); expect(hash.result.stderr).toBe(""); expect(mode(path.join(etcDir, "hermes.config-hash"))).toBe("444"); const verifyHash = spawnSync("sha256sum", ["-c", path.join(etcDir, "hermes.config-hash")], { diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 0d8d3857d6a..b0f24434a4e 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -61,11 +61,12 @@ const HERMES_SEALED_GUARD_HELP = [ function stateDirGuardAction(command: string[]): string | null { const installedIndex = command.indexOf(STATE_DIR_GUARD); - if (installedIndex >= 0) return command[installedIndex + 1] ?? null; const pythonIndex = command.indexOf("python3"); - return pythonIndex >= 0 && command[pythonIndex + 2] === "-" - ? (command[pythonIndex + 3] ?? null) - : null; + return installedIndex >= 0 + ? (command[installedIndex + 1] ?? null) + : pythonIndex >= 0 && command[pythonIndex + 2] === "-" + ? (command[pythonIndex + 3] ?? null) + : null; } function extractShellFunctionFromSource(src: string, name: string): string { @@ -82,6 +83,12 @@ function replaceRequired(source: string, target: string, replacement: string): s return `${parts[0]}${replacement}${parts[1]}`; } +function restoreCachedModule(modulePath: string, previous: NodeJS.Module | undefined): boolean { + return previous === undefined + ? Reflect.deleteProperty(require.cache, modulePath) + : Reflect.set(require.cache, modulePath, previous); +} + function normalizeMutableConfigPermsFor(configDir: string): string { const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); const normalizeFunction = replaceRequired( @@ -363,10 +370,8 @@ function withMockedDockerExecFileSync( dockerExecModule.dockerExecFileSync = originalDockerExecFileSync; dockerExecModule.dockerSpawnSync = originalDockerSpawnSync; delete require.cache[shieldsModulePath]; - if (priorPrivilegedExec) require.cache[privilegedExecPath] = priorPrivilegedExec; - else delete require.cache[privilegedExecPath]; - if (priorTransitionLock) require.cache[transitionLockPath] = priorTransitionLock; - else delete require.cache[transitionLockPath]; + restoreCachedModule(privilegedExecPath, priorPrivilegedExec); + restoreCachedModule(transitionLockPath, priorTransitionLock); } } @@ -507,6 +512,7 @@ describe("mutable agent config permissions", () => { configDir: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -517,6 +523,7 @@ describe("mutable agent config permissions", () => { configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }); @@ -560,6 +567,7 @@ describe("mutable agent config permissions", () => { configDir: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -570,6 +578,7 @@ describe("mutable agent config permissions", () => { configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }, { @@ -620,6 +629,7 @@ describe("mutable agent config permissions", () => { configDir: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -630,6 +640,7 @@ describe("mutable agent config permissions", () => { configDir: "/sandbox/.hermes", sensitiveFiles: ["/sandbox/.hermes/.env"], stateLockPlan: HERMES_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }, { installedStateLockPlan: HERMES_STATE_LOCK_PLAN }, @@ -676,6 +687,7 @@ describe("mutable agent config permissions", () => { configDir: string; sensitiveFiles?: string[]; stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -686,6 +698,7 @@ describe("mutable agent config permissions", () => { configDir: "/sandbox/.hermes", sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], stateLockPlan: HERMES_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }, { @@ -718,14 +731,7 @@ const OPENCLAW_CONFIG_GUARD = ${JSON.stringify(OPENCLAW_CONFIG_GUARD)}; const STATE_DIR_GUARD = ${JSON.stringify(STATE_DIR_GUARD)}; const STATE_LOCK_PLAN = ${JSON.stringify(STATE_LOCK_PLAN)}; const INSTALLED_STATE_LOCK_PLAN = ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}; -function stateDirGuardAction(command) { - const installedIndex = command.indexOf(STATE_DIR_GUARD); - if (installedIndex >= 0) return command[installedIndex + 1] || null; - const pythonIndex = command.indexOf("python3"); - return pythonIndex >= 0 && command[pythonIndex + 2] === "-" - ? (command[pythonIndex + 3] || null) - : null; -} +${stateDirGuardAction.toString()} Module._load = function patchedLoad(request, parent, isMain) { if (request === "./transition-lock") { const transitionLock = originalLoad.call(this, request, parent, isMain); @@ -836,6 +842,7 @@ lockAgentConfig("sandbox-pod", { configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, + stateLockPlanInImage: true, }); process.stdout.write(JSON.stringify(calls)); `, diff --git a/test/sandbox-provisioning-helper-permissions.test.ts b/test/sandbox-provisioning-helper-permissions.test.ts index 8b89bdd5c4c..01cfede86f3 100644 --- a/test/sandbox-provisioning-helper-permissions.test.ts +++ b/test/sandbox-provisioning-helper-permissions.test.ts @@ -130,6 +130,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const gatewayControlPath = path.join(localBin, "nemoclaw-gateway-control"); const gatewaySupervisorPath = path.join(localLib, "gateway-supervisor.sh"); const stateDirGuardPath = path.join(localLib, "state-dir-guard.py"); + const stateLockPlanPath = path.join(localShare, "state-lock-plan.json"); const configGuardPath = path.join(localLib, "openclaw-config-guard.py"); const managedGatewayControlPath = path.join(localLib, "managed-gateway-control.py"); const files = [ @@ -140,6 +141,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localLib, "sandbox-rlimits.sh"), gatewaySupervisorPath, stateDirGuardPath, + stateLockPlanPath, configGuardPath, managedGatewayControlPath, path.join(localLib, "openclaw_device_approval_policy.py"), @@ -201,6 +203,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () expect((fs.statSync(gatewayControlPath).mode & 0o777).toString(8)).toBe("700"); expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); + expect((fs.statSync(stateLockPlanPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(configGuardPath).mode & 0o777).toString(8)).toBe("500"); expect((fs.statSync(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); } finally { diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 1b1769b0b93..22950b0595d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1114,6 +1114,14 @@ describe("Hermes sandbox provisioning", () => { ); const mcpManifest = path.join(localLib, "openshell-child-visible-credentials.v0.0.85.json"); const stateDirGuardPath = path.join(localLib, "state-dir-guard.py"); + const stateLockPlanPath = path.join( + tmp, + "usr", + "local", + "share", + "nemoclaw", + "state-lock-plan.json", + ); const managedGatewayControlPath = path.join(localLib, "managed-gateway-control.py"); const files = [ path.join(localBin, "nemoclaw-start"), @@ -1132,6 +1140,7 @@ describe("Hermes sandbox provisioning", () => { mcpManifest, gatewaySupervisorPath, stateDirGuardPath, + stateLockPlanPath, managedGatewayControlPath, path.join(localLib, "sandbox-rlimits.sh"), ]; @@ -1142,11 +1151,13 @@ describe("Hermes sandbox provisioning", () => { ) .replaceAll("/usr/local/bin", localBin) .replaceAll("/usr/local/lib/nemoclaw", localLib) + .replaceAll("/usr/local/share/nemoclaw/state-lock-plan.json", stateLockPlanPath) .replaceAll("/etc/profile.d", profileDir) .replaceAll("/etc/bash.bashrc", bashrcPath); try { fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(localLib, { recursive: true }); + fs.mkdirSync(path.dirname(stateLockPlanPath), { recursive: true }); fs.mkdirSync(etcDir, { recursive: true }); fs.writeFileSync(bashrcPath, "# fixture\n", { mode: 0o600 }); for (const file of files) fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); @@ -1156,7 +1167,7 @@ describe("Hermes sandbox provisioning", () => { expect(result.status, result.stderr).toBe(0); expect(calls).toContain( - `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${mcpManifest}`, + `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${stateLockPlanPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${mcpManifest}`, ); expect((fs.statSync(gatewayControlPath).mode & 0o777).toString(8)).toBe("700"); expect((fs.statSync(mcpConfigTransactionPath).mode & 0o777).toString(8)).toBe("755"); @@ -1165,6 +1176,7 @@ describe("Hermes sandbox provisioning", () => { expect((fs.statSync(buildMcpDigestPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); + expect((fs.statSync(stateLockPlanPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index 0b8fbb74e40..6ea231c9937 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -574,6 +574,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { const ciaoGuard = path.join(preloadDir, "ciao-network-guard.js"); const gatewaySupervisor = path.join(localLib, "gateway-supervisor.sh"); const stateDirGuard = path.join(localLib, "state-dir-guard.py"); + const stateLockPlan = path.join(tmp, "state-lock-plan.json"); const managedGatewayControl = path.join(localLib, "managed-gateway-control.py"); const startBin = path.join(tmp, "nemoclaw-start"); const gatewayControl = path.join(tmp, "nemoclaw-gateway-control"); @@ -604,6 +605,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { fs.chmodSync(ciaoGuard, 0o666); fs.writeFileSync(gatewaySupervisor, "# gateway supervisor fixture\n"); fs.writeFileSync(stateDirGuard, "# state-dir guard fixture\n"); + fs.writeFileSync(stateLockPlan, "{}\n"); fs.writeFileSync(managedGatewayControl, "# managed gateway control fixture\n"); fs.writeFileSync(startBin, "#!/usr/bin/env bash\n"); fs.writeFileSync(gatewayControl, "#!/usr/bin/env sh\n"); @@ -648,6 +650,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { .replaceAll("/usr/local/lib/nemoclaw/preloads/ciao-network-guard.js", ciaoGuard) .replaceAll("/usr/local/lib/nemoclaw/preloads", preloadDir) .replaceAll("/usr/local/lib/nemoclaw/state-dir-guard.py", stateDirGuard) + .replaceAll("/usr/local/share/nemoclaw/state-lock-plan.json", stateLockPlan) .replaceAll("/usr/local/lib/nemoclaw/managed-gateway-control.py", managedGatewayControl) .replaceAll("/usr/local/lib/nemoclaw/sandbox-rlimits.sh", rlimitLib) .replaceAll("/etc/profile.d/nemoclaw-rlimits.sh", profileHook) @@ -675,6 +678,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(fs.statSync(langfuseCredentialPatcher).mode & 0o777).toBe(0o444); expect(fs.statSync(mcpCredentialBoundary).mode & 0o777).toBe(0o444); expect(fs.statSync(buildMcpDigest).mode & 0o777).toBe(0o444); + expect(fs.statSync(stateLockPlan).mode & 0o777).toBe(0o444); expect(hardenedDir.uid).toBe(fixtureOwner.uid); expect(hardenedDir.gid).toBe(fixtureOwner.gid); expect(hardenedSafetyNet.uid).toBe(fixtureOwner.uid); diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index b36c64b59ac..e4f549f18e1 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -146,6 +146,7 @@ try { configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, + stateLockPlanInImage: true, }, false, ); diff --git a/test/snapshot-state-directory-contract.test.ts b/test/snapshot-state-directory-contract.test.ts index d51b41292df..b9b37c0d3b4 100644 --- a/test/snapshot-state-directory-contract.test.ts +++ b/test/snapshot-state-directory-contract.test.ts @@ -13,18 +13,9 @@ process.env.HOME = TMP_HOME; const REPO_ROOT = path.join(import.meta.dirname, ".."); type SandboxStateModule = typeof import("../src/lib/state/sandbox.js"); -const loadedSandboxState: unknown = await import( +const sandboxState = (await import( pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href -); -if ( - typeof loadedSandboxState !== "object" || - loadedSandboxState === null || - !("backupSandboxState" in loadedSandboxState) || - !("restoreSandboxState" in loadedSandboxState) -) { - throw new Error("Expected sandbox-state module exports to be available"); -} -const sandboxState = loadedSandboxState as SandboxStateModule; +)) as SandboxStateModule; const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); function writeBackup( @@ -130,9 +121,16 @@ describe("snapshot state-directory authorization", () => { }); it.each([ - ["workspace-research", true], - ["workspace-research/nested", false], - ])("authorizes only a top-level concrete match for a dynamic state prefix: %s (#8006)", (stateDir, accepted) => { + ["workspace-research", { success: true }], + [ + "workspace-research/nested", + { + success: false, + error: + "Backup state directories are not declared by target agent 'openclaw': workspace-research/nested", + }, + ], + ])("authorizes only a top-level concrete match for a dynamic state prefix: %s (#8006)", (stateDir, expected) => { const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { stateDirs: [stateDir], backedUpDirs: [], @@ -142,10 +140,7 @@ describe("snapshot state-directory authorization", () => { const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); - expect(restore.success).toBe(accepted); - if (!accepted) { - expect(restore.error).toContain("not declared by target agent 'openclaw'"); - } + expect(restore).toMatchObject(expected); }); it.each([ diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 035c487ff54..2a5468e7b30 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -383,15 +383,18 @@ describe("state-dir-guard", () => { it("rejects missing, non-UTF-8, and oversized plan files", () => { const { root, configDir } = fixture(); - const cases: Array<[string, Buffer | null]> = [ - ["missing.json", null], - ["non-utf8.json", Buffer.from([0xff])], - ["oversized.json", Buffer.alloc(1024 * 1024 + 1, 0x20)], + const cases: Array<[string, (planFile: string) => void]> = [ + ["missing.json", () => undefined], + ["non-utf8.json", (planFile) => fs.writeFileSync(planFile, Buffer.from([0xff]))], + [ + "oversized.json", + (planFile) => fs.writeFileSync(planFile, Buffer.alloc(1024 * 1024 + 1, 0x20)), + ], ]; - for (const [fileName, contents] of cases) { + for (const [fileName, writePlan] of cases) { const planFile = path.join(root, fileName); - if (contents) fs.writeFileSync(planFile, contents); + writePlan(planFile); const result = runGuardWithPlanSource("preflight", configDir, "--plan-file", planFile); diff --git a/test/support/connect-flow-test-harness.ts b/test/support/connect-flow-test-harness.ts index b7c4ed7fc52..d6ea90204ba 100644 --- a/test/support/connect-flow-test-harness.ts +++ b/test/support/connect-flow-test-harness.ts @@ -224,6 +224,7 @@ export function createConnectHarness(options: ConnectHarnessOptions = {}): Conne format: "yaml", configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + stateLockPlanInImage: true, }; const resolveAgentConfigSpy = vi .spyOn(sandboxConfig, "resolveAgentConfig") From f6f0085d5ec601baa9c89d0ecb852d4b6301eeb5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 14:44:14 -0400 Subject: [PATCH 03/25] test(state): validate Hermes config digest fixture Signed-off-by: Julie Yaunches --- src/lib/shields/state-dir-lock.ts | 4 ++-- test/hermes-doctor-config-hash.test.ts | 22 +++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index 1815815c2ff..97badd6056e 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -285,8 +285,8 @@ function runStateDirGuard( } // Agent definitions without an image plan, plus images predating the helper, - // use the bounded host-injection path. The historical branch remains while - // sandboxes built before the generated-plan artifact are supported. + // use the bounded host-injection path. The #8006 compatibility branch ends + // when every sandbox image supported for rebuild contains CONTAINER_STATE_LOCK_PLAN. const command = [ ...CONTAINER_TIMEOUT, "python3", diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index fd6cd8ce2d5..b331f9f9bbd 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -18,7 +18,8 @@ function writeYamlStubPython(root: string): string { const wrapper = path.join(root, "python-with-yaml-stub"); fs.writeFileSync( bootstrap, - String.raw`import runpy + String.raw`import json +import runpy import sys import types @@ -26,7 +27,15 @@ yaml = types.ModuleType("yaml") class YAMLError(Exception): pass yaml.YAMLError = YAMLError -yaml.safe_load = lambda _text: {} +def safe_load(text): + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise YAMLError("fixture must contain valid JSON-compatible YAML") from exc + if not isinstance(parsed, dict) or not isinstance(parsed.get("mcp_servers"), dict): + raise YAMLError("fixture must contain an mcp_servers mapping") + return parsed +yaml.safe_load = safe_load sys.modules["yaml"] = yaml script, *args = sys.argv[1:] @@ -212,9 +221,16 @@ describe("Hermes doctor and config hash boundary", () => { const etcDir = path.join(tmp, "etc", "nemoclaw"); const hermesPython = writeYamlStubPython(tmp); const mode = (entry: string) => (fs.statSync(entry).mode & 0o777).toString(8); + const generatedConfig = JSON.stringify({ + model: "trusted", + custom_providers: [], + mcp_servers: { + fixture: { command: "/bin/true", args: [] }, + }, + }); const fakeGenerateCommand = [ `printf 'generate\\n' >>${JSON.stringify(orderLogPath)}`, - `printf 'model: trusted\\ncustom_providers: []\\n' >${JSON.stringify(configPath)}`, + `printf '%s\\n' ${JSON.stringify(generatedConfig)} >${JSON.stringify(configPath)}`, `printf 'API_SERVER_HOST=127.0.0.1\\nAPI_SERVER_PORT=18642\\n' >${JSON.stringify(envPath)}`, `chmod 600 ${JSON.stringify(configPath)} ${JSON.stringify(envPath)}`, ].join("; "); From 6bec1a87dc9056e633be5b07e57c0e0c17bee493 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 17:30:21 -0400 Subject: [PATCH 04/25] docs(security): clarify state guard boundary Signed-off-by: Julie Yaunches --- docs/security/tcb-boundary.mdx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 1ac97aad8cd..b0c346b3982 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -5,8 +5,8 @@ title: "Trusted Computing Base for Lifecycle and Shields Control" sidebar-title: "Trusted Computing Base" description: "Defines the trusted computing base, privilege boundaries, and review invariants for NemoClaw lifecycle and Shields operations." description-agent: >- - Maps NemoClaw gateway lifecycle and shields components to their trust boundaries, threats, privilege levels, and verification requirements. - Use when reviewing privileged gateway restart, config mutation, or shields changes. + Maps NemoClaw lifecycle and Shields components to their trust boundaries, threats, privilege levels, and verification requirements. + Use when reviewing privileged gateway restart, config mutation, state-directory locking, or Shields changes. keywords: ["nemoclaw trusted computing base", "gateway lifecycle security", "shields trust boundary"] content: type: "reference" @@ -25,11 +25,14 @@ Host root compromise and replacement of root-owned image files are outside this The lifecycle boundary maintains these invariants. - Only a registry-selected sandbox can receive a host lifecycle request. -- Privileged lifecycle transitions use root-owned installed helpers. Filesystem transitions use an installed helper when the image provides one; otherwise, the trusted host CLI injects its own helper through authenticated root execution without writing it to mutable sandbox state. +- Privileged lifecycle transitions use root-owned installed helpers. +- Filesystem transitions use the installed helper for recovery-capable images and historical images that include it. + Agents without an image recovery artifact, and historical images that predate both artifacts, receive the host helper and manifest-derived plan through authenticated root execution without writing them to mutable sandbox state. +- A plan-aware image with a missing helper, or an installed plan that differs from the current agent manifest, fails closed. - A mutable path, status file, process ID, command line, or listener alone never grants authority. - Process decisions bind the observed process ID to its start identity, parent chain, user identity, PID namespace, executable shape, and listener ownership where the topology exposes those signals. - Filesystem transitions open trusted parents by descriptor, reject symlinks and unsafe hard links, bound traversal and input size, and verify the resulting inode state. -- A failed or ambiguous proof stops the operation without reporting recovery or a locked shields posture. +- A failed or ambiguous proof stops the operation without reporting recovery or a successful Shields up transition. - The OpenShell-managed topology authenticates the host action but does not create gateway and agent UID isolation. @@ -79,7 +82,7 @@ Both paths prove the exact replacement gateway and health state before the host Terminal agents do not run these gateway branches; their Shields transitions use the state and verification branches. Shields mutations acquire the host transition lock before changing network policy, config posture, timer authority, or host state. -The coordinator invokes the agent-specific config guard and `state-dir-guard.py`, verifies the resulting posture, then commits host state and audit output. +The coordinator applies the selected agent's config transition, invokes `state-dir-guard.py`, verifies the resulting posture, then commits host state and audit output. Rollback keeps the same lock and transaction token so a stale callback cannot adopt the transition. ## Filesystem and Descriptor Proofs From 410e4b203cbe03237a199a879f0e6ffcc4753953 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 12:39:24 -0400 Subject: [PATCH 05/25] fix(shields): authorize startup state repair Signed-off-by: Julie Yaunches --- scripts/state-dir-guard.py | 12 +++++++- src/lib/shields/index.ts | 6 +++- src/lib/shields/state-dir-lock.test.ts | 38 +++++++++++++++++++++++++- src/lib/shields/state-dir-lock.ts | 16 +++++++++-- test/state-dir-guard.test.ts | 26 ++++++++++++++++++ 5 files changed, 93 insertions(+), 5 deletions(-) diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index 62e1a8db4d2..bf91dbfabf6 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -1936,6 +1936,7 @@ def _restore_empty_credentials_startup_access( config_dir: str, identity: Identity, deadline: float, + plan: AgentStateLockPlan, ) -> GuardResult: """Restore only the empty credentials traversal needed during startup.""" @@ -1943,6 +1944,15 @@ def _restore_empty_credentials_startup_access( config_fd = -1 credentials_fd = -1 path = _display_path(config_dir, "credentials") + if "credentials" not in plan.confidential_roots: + result.issues.append( + Issue( + "startup-plan-mismatch", + path, + "state lock plan must declare credentials as a confidential root", + ) + ) + return result try: config_fd = _open_absolute_dir_nofollow(config_dir) config_st = os.fstat(config_fd) @@ -2057,7 +2067,7 @@ def _run_guard_unserialized( normalized_config = posixpath.normpath(config_dir) if action == "startup": return _restore_empty_credentials_startup_access( - normalized_config, identity, deadline + normalized_config, identity, deadline, plan ) fail_closed_config_root = action == "lock" and ( normalized_config in PRODUCTION_FAIL_CLOSED_CONFIG_DIRS diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index be43534f031..4b121e61604 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -2177,7 +2177,11 @@ function restoreLockedStateDirStartupAccess(sandboxName: string): void { const posture = getShieldsPostureWithoutHostLock(sandboxName, true); if (!posture.locked) return; const target = ensureConfigHashSensitiveFile(resolveAgentConfig(sandboxName)); - const issues = restoreStateDirStartupAccess(stateDirLockExec(sandboxName), target.configDir); + const issues = restoreStateDirStartupAccess( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + ); if (issues.length > 0) { throw new Error(`Locked startup access could not be restored: ${issues.join(", ")}`); } diff --git a/src/lib/shields/state-dir-lock.test.ts b/src/lib/shields/state-dir-lock.test.ts index 8cdacc83779..cdef49e9573 100644 --- a/src/lib/shields/state-dir-lock.test.ts +++ b/src/lib/shields/state-dir-lock.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import type { AgentStateLockPlan } from "../agent/definition-types"; import type { PrivilegedExec } from "./state-dir-lock"; @@ -184,7 +185,7 @@ describe("recursive state-dir lock host wiring", () => { it("uses the current host guard for the narrow startup repair (#8112)", () => { const { calls, privileged } = createExec(); - expect(restoreStateDirStartupAccess(privileged, "/sandbox/.openclaw")).toEqual([]); + expect(restoreStateDirStartupAccess(privileged, "/sandbox/.openclaw", PLAN)).toEqual([]); expect(calls).toHaveLength(1); expect(calls[0]?.cmd).toEqual([ "timeout", @@ -197,10 +198,45 @@ describe("recursive state-dir lock host wiring", () => { "startup", "--config-dir", "/sandbox/.openclaw", + "--plan-json", + JSON.stringify(PLAN), ]); expect(calls[0]?.input).toContain('choices=("preflight", "lock", "unlock", "startup")'); }); + it("hands the manifest plan to an injected startup helper (#8006)", () => { + const startupPlan: AgentStateLockPlan = { ...PLAN, readOnlyRoots: ["agents", "skills"] }; + let rawOutput = ""; + let spawnError: Error | undefined; + const issues = restoreStateDirStartupAccess( + { + run: (cmd, input) => { + const result = spawnSync(cmd[0]!, cmd.slice(1), { + encoding: "utf-8", + input, + timeout: 15_000, + }); + rawOutput = String(result.stdout) + String(result.stderr); + spawnError = result.error; + return { + status: result.status, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr, + ...(result.error ? { error: result.error.message } : {}), + }; + }, + }, + "/sandbox/.nemoclaw-startup-plan-test-" + String(process.pid), + startupPlan, + ); + + expect(spawnError).toBeUndefined(); + expect(rawOutput).toContain('"action":"startup"'); + expect(rawOutput).not.toContain('"code":"invalid-plan"'); + expect(issues.join("\n")).not.toContain("[invalid-plan]"); + }); + it("injects the host helper and plan for agents without an image recovery plan", () => { const { calls, privileged } = createExec(); diff --git a/src/lib/shields/state-dir-lock.ts b/src/lib/shields/state-dir-lock.ts index 786a027edae..b17302d4b20 100644 --- a/src/lib/shields/state-dir-lock.ts +++ b/src/lib/shields/state-dir-lock.ts @@ -308,6 +308,7 @@ function runHostStateDirGuard( privileged: PrivilegedExec, action: GuardAction, configDir: string, + plan: AgentStateLockPlan, ): string[] { let input: string; try { @@ -316,7 +317,17 @@ function runHostStateDirGuard( const message = error instanceof Error ? error.message : String(error); return [`host state-dir helper cannot be read: ${message}`]; } - const command = [...CONTAINER_TIMEOUT, "python3", "-I", "-", action, "--config-dir", configDir]; + const command = [ + ...CONTAINER_TIMEOUT, + "python3", + "-I", + "-", + action, + "--config-dir", + configDir, + "--plan-json", + JSON.stringify(plan), + ]; return parseGuardOutput(action, privileged.run(command, input)); } @@ -393,6 +404,7 @@ export function restoreStateDirLockPosture( export function restoreStateDirStartupAccess( privileged: PrivilegedExec, configDir: string, + plan: AgentStateLockPlan, ): string[] { - return runHostStateDirGuard(privileged, "startup", configDir); + return runHostStateDirGuard(privileged, "startup", configDir, plan); } diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 753f1126055..64ba78ed5ba 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -1215,6 +1215,32 @@ describe("state-dir-guard", () => { expect(mode(path.join(credentialsDir, "token.json"))).toBe(0o600); }); + it("refuses startup traversal when the plan omits the confidential credentials root (#8006)", () => { + const { configDir } = fixture(); + const credentialsDir = path.join(configDir, "credentials"); + fs.mkdirSync(credentialsDir); + fs.chmodSync(credentialsDir, 0o700); + + const result = runGuard( + "startup", + configDir, + {}, + { + ...DEFAULT_PLAN, + confidentialRoots: [], + }, + ); + + expect(result.status, JSON.stringify(result.lines)).toBe(1); + expect(result.lines).toContainEqual( + expect.objectContaining({ + code: "startup-plan-mismatch", + path: credentialsDir, + }), + ); + expect(mode(credentialsDir)).toBe(0o700); + }); + it("creates a missing sessions carveout during lock so a first-boot agent can write sessions (#7545)", () => { const { configDir } = fixture(".openclaw"); const agentDir = path.join(configDir, "agents", "main"); From fa97f8bfa01ff38ff316ff94a5810444115cc3f6 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 13:19:59 -0400 Subject: [PATCH 06/25] chore(ci): retry transient PR gate Signed-off-by: Julie Yaunches From 478243b2f7645679b9d3890be6dacb577e0ab6f9 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 13:27:51 -0400 Subject: [PATCH 07/25] refactor(state): avoid plan variable shadowing Signed-off-by: Julie Yaunches --- scripts/state-dir-guard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/state-dir-guard.py b/scripts/state-dir-guard.py index bf91dbfabf6..25781398166 100755 --- a/scripts/state-dir-guard.py +++ b/scripts/state-dir-guard.py @@ -386,8 +386,8 @@ def parse_agent_state_lock_plan(payload: str) -> AgentStateLockPlan: ) writable_subpaths = tuple( - _validate_writable_subpath(value, f"writableSubpaths[{index}]") - for index, value in enumerate(writable_values) + _validate_writable_subpath(entry, f"writableSubpaths[{index}]") + for index, entry in enumerate(writable_values) ) for index, components in enumerate(writable_subpaths): if components[0] not in read_only_roots: From fa5ae3ea9d1de86fb638e95ee6033da7bc15e5b7 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 14:35:29 -0400 Subject: [PATCH 08/25] test(e2e): accept managed startup paths Signed-off-by: Julie Yaunches --- test/e2e/live/gateway-guard-recovery.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/gateway-guard-recovery.test.ts b/test/e2e/live/gateway-guard-recovery.test.ts index 0a5f70c8a69..2ba89b24414 100644 --- a/test/e2e/live/gateway-guard-recovery.test.ts +++ b/test/e2e/live/gateway-guard-recovery.test.ts @@ -312,7 +312,7 @@ test("gateway recovery restores /tmp guard chain after pod-recreate wipe (#2701) originalContainerId, "restart-command-before", ); - expect(originalStartupCommand).toMatch(/(?:^| )nemoclaw-start$/); + expect(originalStartupCommand).toMatch(/(?:^| )(?:\/usr\/local\/bin\/)?nemoclaw-start$/); await host.cleanupForward(18789, { artifactName: "restart-stop-dashboard-forward", env: buildAvailabilityProbeEnv(), @@ -356,7 +356,7 @@ test("gateway recovery restores /tmp guard chain after pod-recreate wipe (#2701) recoveredContainerId, "restart-command-after", ); - expect(recoveredStartupCommand).toMatch(/(?:^| )nemoclaw-start$/); + expect(recoveredStartupCommand).toMatch(/(?:^| )(?:\/usr\/local\/bin\/)?nemoclaw-start$/); expect(recoveredStartupCommand).not.toContain("CUSTOM_PROVIDER_CREDENTIAL"); expect(recoveredStartupCommand).not.toContain(credentialCanary); @@ -469,7 +469,7 @@ test("gateway recovery restores /tmp guard chain after pod-recreate wipe (#2701) legacyRecoveredContainerId, "legacy-restart-command-after", ); - expect(legacyRecoveredStartupCommand).toMatch(/(?:^| )nemoclaw-start$/); + expect(legacyRecoveredStartupCommand).toMatch(/(?:^| )(?:\/usr\/local\/bin\/)?nemoclaw-start$/); expect(legacyRecoveredStartupCommand).not.toContain("CUSTOM_PROVIDER_CREDENTIAL"); expect(legacyRecoveredStartupCommand).not.toContain(legacyCredentialCanary); From 4151bd7df133a20acc4a6ae540523d0e30b5f3c9 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 15:37:03 -0400 Subject: [PATCH 09/25] test(e2e): retry transient DCode status Signed-off-by: Julie Yaunches --- ...12-deepagents-code-thread-auto-approval.sh | 27 +++++++- ...platform-parity-cloud-experimental.test.ts | 64 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh b/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh index 36a649e62ab..c1db74ee93f 100755 --- a/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh +++ b/test/e2e/e2e-cloud-experimental/checks/12-deepagents-code-thread-auto-approval.sh @@ -108,9 +108,30 @@ assert_capability_projection() { assert_status_mode() { local expected_mode="$1" - local status_json - status_json="$("$CLI" "$SANDBOX_NAME" status --json)" \ - || fail "nemoclaw status failed while checking '$expected_mode'" + local attempt attempts retry_delay_seconds status status_json + attempts="${NEMOCLAW_E2E_DCODE_STATUS_ATTEMPTS:-3}" + retry_delay_seconds="${NEMOCLAW_E2E_DCODE_STATUS_RETRY_DELAY_SECONDS:-3}" + is_positive_integer "$attempts" \ + || fail "status attempt count must be a positive integer" + [[ "$retry_delay_seconds" =~ ^[0-9]+$ ]] \ + || fail "status retry delay must be a non-negative integer" + + status=1 + status_json="" + for ((attempt = 1; attempt <= attempts; attempt++)); do + if status_json="$("$CLI" "$SANDBOX_NAME" status --json)"; then + status=0 + break + else + status=$? + fi + if [ "$attempt" -lt "$attempts" ]; then + info "Retrying NemoClaw status after a non-success health probe (attempt $attempt/$attempts)" >&2 + sleep "$retry_delay_seconds" + fi + done + [ "$status" -eq 0 ] \ + || fail "nemoclaw status failed while checking '$expected_mode' after $attempts attempts: ${status_json:-}" STATUS_JSON="$status_json" EXPECTED_MODE="$expected_mode" SANDBOX_NAME="$SANDBOX_NAME" node -e ' const status = JSON.parse(process.env.STATUS_JSON); if (status.name !== process.env.SANDBOX_NAME || diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 24ef86c0617..362cd8506ca 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -507,6 +507,70 @@ describe("P0-E cloud-experimental parity guardrails", () => { } }); + it.each([ + ["retries a transient status health failure", "failure-then-success", 0, 2, 1], + ["fails after the bounded status health retries", "failure-always", 1, 3, 2], + ] as const)("%s before checking the DCode capability", (_label, mode, expectedStatus, expectedAttempts, expectedRetryMessages) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-status-retry-")); + const mockCli = path.join(tempDir, "nemoclaw"); + const counterFile = path.join(tempDir, "attempts"); + try { + fs.writeFileSync( + mockCli, + [ + "#!/bin/bash", + "set -euo pipefail", + "count=0", + 'if [ -f "$MOCK_STATUS_COUNTER_FILE" ]; then', + ' read -r count <"$MOCK_STATUS_COUNTER_FILE"', + "fi", + "count=$((count + 1))", + `printf '%s\\n' "$count" >"$MOCK_STATUS_COUNTER_FILE"`, + `printf '{"name":"deepagents-sandbox","agent":"langchain-deepagents-code","dcodeAutoApprovalMode":"disabled","attempt":%s,"inferenceHealth":{"ok":false,"probed":false}}\\n' "$count"`, + 'if [ "$MOCK_STATUS_MODE" = "failure-always" ] || { [ "$MOCK_STATUS_MODE" = "failure-then-success" ] && [ "$count" -eq 1 ]; }; then', + " exit 1", + "fi", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync( + "/bin/bash", + [ + "-c", + 'source "$1"; CLI="$2"; SANDBOX_NAME="deepagents-sandbox"; NEMOCLAW_E2E_DCODE_STATUS_RETRY_DELAY_SECONDS=0; assert_status_mode disabled', + "bash", + dcodeApprovalCheck, + mockCli, + ], + { + encoding: "utf8", + env: { + ...process.env, + MOCK_STATUS_COUNTER_FILE: counterFile, + MOCK_STATUS_MODE: mode, + }, + }, + ); + + expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); + expect(Number(fs.readFileSync(counterFile, "utf8").trim())).toBe(expectedAttempts); + expect( + result.stderr.match(/Retrying NemoClaw status after a non-success health probe/gu) ?? [], + ).toHaveLength(expectedRetryMessages); + if (expectedStatus !== 0) { + expect(result.stderr).toContain( + "nemoclaw status failed while checking 'disabled' after 3 attempts", + ); + expect(result.stderr).toContain('"dcodeAutoApprovalMode":"disabled"'); + expect(result.stderr).toContain('"attempt":3'); + } + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + it("registers executable Deep Agents cloud-experimental checks in execution order", () => { expect(DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS).toEqual([ "test/e2e/e2e-cloud-experimental/checks/03-deepagents-code-nemotron-ultra-profile.sh", From 62006d5658225b970817299ec66da09ed82044a5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 15:57:44 -0400 Subject: [PATCH 10/25] test(e2e): keep retry assertions linear Signed-off-by: Julie Yaunches --- .../platform-parity-cloud-experimental.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 362cd8506ca..aa1fab91b00 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -559,13 +559,14 @@ describe("P0-E cloud-experimental parity guardrails", () => { expect( result.stderr.match(/Retrying NemoClaw status after a non-success health probe/gu) ?? [], ).toHaveLength(expectedRetryMessages); - if (expectedStatus !== 0) { - expect(result.stderr).toContain( - "nemoclaw status failed while checking 'disabled' after 3 attempts", - ); - expect(result.stderr).toContain('"dcodeAutoApprovalMode":"disabled"'); - expect(result.stderr).toContain('"attempt":3'); - } + const expectFailureDiagnostics = expectedStatus !== 0; + expect( + result.stderr.includes("nemoclaw status failed while checking 'disabled' after 3 attempts"), + ).toBe(expectFailureDiagnostics); + expect(result.stderr.includes('"dcodeAutoApprovalMode":"disabled"')).toBe( + expectFailureDiagnostics, + ); + expect(result.stderr.includes('"attempt":3')).toBe(expectFailureDiagnostics); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); } From cc396e6620938aebae8ee66e330764e410748e86 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 16:18:38 -0400 Subject: [PATCH 11/25] test(e2e): avoid dynamic shell sourcing Signed-off-by: Julie Yaunches --- ...platform-parity-cloud-experimental.test.ts | 77 +++++++++++-------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index aa1fab91b00..6432c89e5ed 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -26,6 +26,10 @@ import { const cloudChecksDir = path.join(process.cwd(), "test/e2e/e2e-cloud-experimental/checks"); const dcodeTavilyCheck = path.join(cloudChecksDir, "09-deepagents-code-tavily-opt-in.sh"); const dcodeApprovalCheck = path.join(cloudChecksDir, "12-deepagents-code-thread-auto-approval.sh"); +const dcodeApprovalMainEntrypoint = `if [[ "\${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi +`; const dcodeFreshReonboardCheck = path.join(cloudChecksDir, "04-deepagents-code-fresh-reonboard.sh"); const DEFAULT_TEST_PATH = process.env.PATH ?? "/usr/bin:/bin"; const tavilyBlocked = "BLOCKED:policy denied"; @@ -53,6 +57,13 @@ function shellResult(exitCode: number, stdout: string, stderr = ""): ShellProbeR }; } +function writeDcodeApprovalTestDriver(driverPath: string, testEntrypoint: string): void { + const checkSource = fs.readFileSync(dcodeApprovalCheck, "utf8"); + const testDriverSource = checkSource.replace(dcodeApprovalMainEntrypoint, testEntrypoint); + expect(testDriverSource).not.toBe(checkSource); + fs.writeFileSync(driverPath, testDriverSource, { mode: 0o755 }); +} + describe("P0-E cloud-experimental parity guardrails", () => { it("skips the destructive fresh re-onboard check outside a Deep Agents sandbox", () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-openshell-")); @@ -438,6 +449,7 @@ describe("P0-E cloud-experimental parity guardrails", () => { ] as const)("%s during a named DCode rebuild", (_label, mode, expectedStatus, expectedAttempts, expectedRetryMessages) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-rebuild-retry-")); const mockCli = path.join(tempDir, "nemoclaw"); + const testDriver = path.join(tempDir, "rebuild-named-sandbox"); const counterFile = path.join(tempDir, "attempts"); try { fs.writeFileSync( @@ -476,24 +488,22 @@ describe("P0-E cloud-experimental parity guardrails", () => { { mode: 0o755 }, ); - const result = spawnSync( - "/bin/bash", - [ - "-c", - 'source "$1"; CLI="$2"; SANDBOX_NAME="deepagents-sandbox"; NEMOCLAW_E2E_DCODE_REBUILD_RETRY_DELAY_SECONDS=0; rebuild_named_sandbox disabled', - "bash", - dcodeApprovalCheck, - mockCli, - ], - { - encoding: "utf8", - env: { - ...process.env, - MOCK_REBUILD_COUNTER_FILE: counterFile, - MOCK_REBUILD_MODE: mode, - }, - }, + writeDcodeApprovalTestDriver( + testDriver, + `CLI="$1" +SANDBOX_NAME="deepagents-sandbox" +NEMOCLAW_E2E_DCODE_REBUILD_RETRY_DELAY_SECONDS=0 +rebuild_named_sandbox disabled +`, ); + const result = spawnSync("/bin/bash", [testDriver, mockCli], { + encoding: "utf8", + env: { + ...process.env, + MOCK_REBUILD_COUNTER_FILE: counterFile, + MOCK_REBUILD_MODE: mode, + }, + }); expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); expect(Number(fs.readFileSync(counterFile, "utf8").trim())).toBe(expectedAttempts); @@ -513,6 +523,7 @@ describe("P0-E cloud-experimental parity guardrails", () => { ] as const)("%s before checking the DCode capability", (_label, mode, expectedStatus, expectedAttempts, expectedRetryMessages) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-status-retry-")); const mockCli = path.join(tempDir, "nemoclaw"); + const testDriver = path.join(tempDir, "assert-status-mode"); const counterFile = path.join(tempDir, "attempts"); try { fs.writeFileSync( @@ -535,24 +546,22 @@ describe("P0-E cloud-experimental parity guardrails", () => { { mode: 0o755 }, ); - const result = spawnSync( - "/bin/bash", - [ - "-c", - 'source "$1"; CLI="$2"; SANDBOX_NAME="deepagents-sandbox"; NEMOCLAW_E2E_DCODE_STATUS_RETRY_DELAY_SECONDS=0; assert_status_mode disabled', - "bash", - dcodeApprovalCheck, - mockCli, - ], - { - encoding: "utf8", - env: { - ...process.env, - MOCK_STATUS_COUNTER_FILE: counterFile, - MOCK_STATUS_MODE: mode, - }, - }, + writeDcodeApprovalTestDriver( + testDriver, + `CLI="$1" +SANDBOX_NAME="deepagents-sandbox" +NEMOCLAW_E2E_DCODE_STATUS_RETRY_DELAY_SECONDS=0 +assert_status_mode disabled +`, ); + const result = spawnSync("/bin/bash", [testDriver, mockCli], { + encoding: "utf8", + env: { + ...process.env, + MOCK_STATUS_COUNTER_FILE: counterFile, + MOCK_STATUS_MODE: mode, + }, + }); expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); expect(Number(fs.readFileSync(counterFile, "utf8").trim())).toBe(expectedAttempts); From 5a261753f6ee056395f2f50e59463398fe15ecf4 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 16:40:46 -0400 Subject: [PATCH 12/25] test(e2e): bound generated driver processes Signed-off-by: Julie Yaunches --- test/e2e/support/platform-parity-cloud-experimental.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 6432c89e5ed..49c85b871e8 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -503,6 +503,8 @@ rebuild_named_sandbox disabled MOCK_REBUILD_COUNTER_FILE: counterFile, MOCK_REBUILD_MODE: mode, }, + killSignal: "SIGKILL", + timeout: 30_000, }); expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); @@ -561,6 +563,8 @@ assert_status_mode disabled MOCK_STATUS_COUNTER_FILE: counterFile, MOCK_STATUS_MODE: mode, }, + killSignal: "SIGKILL", + timeout: 30_000, }); expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); From c8bbb5f553cd3f8fb4bdb8450fa3fc47405e1956 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 17:40:22 -0400 Subject: [PATCH 13/25] test(tunnel): allow coverage-load startup time Signed-off-by: Julie Yaunches --- src/lib/tunnel/services.test.ts | 38 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index c2d666e016b..37b135d2bc6 100644 --- a/src/lib/tunnel/services.test.ts +++ b/src/lib/tunnel/services.test.ts @@ -16,6 +16,8 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { testTimeoutOptions } from "../../../test/helpers/timeouts"; + // Import source directly so tests cannot pass against a stale build. import { registerTunnelOrigin } from "./allowed-origins"; import { resolveDefaultSandboxName } from "./service-command"; @@ -501,23 +503,29 @@ describe("stopAll", () => { return { control, signals }; } - it("does not signal a live PID recycled to a non-cloudflared process", () => { - const { control, signals } = scriptedControl({ - alive: [true], - cmdlines: ["/usr/bin/node vitest"], - }); - writeFileSync(join(pidDir, "cloudflared.pid"), "4242", { mode: 0o600 }); + // The first stopAll call instruments the lazily loaded Ollama proxy dependency + // graph. Loaded coverage shards can exceed the unit-test default here. + it( + "does not signal a live PID recycled to a non-cloudflared process", + testTimeoutOptions(15_000), + () => { + const { control, signals } = scriptedControl({ + alive: [true], + cmdlines: ["/usr/bin/node vitest"], + }); + writeFileSync(join(pidDir, "cloudflared.pid"), "4242", { mode: 0o600 }); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - stopAll({ pidDir, processControl: control }); - } finally { - logSpy.mockRestore(); - } + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + stopAll({ pidDir, processControl: control }); + } finally { + logSpy.mockRestore(); + } - expect(signals).toEqual([]); - expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); - }); + expect(signals).toEqual([]); + expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); + }, + ); it("does not escalate to SIGKILL when the PID is recycled during the poll", () => { const { control, signals } = scriptedControl({ From ba8141c4a77f1aa546ee46e74c67b89b259225f7 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 20:16:55 -0400 Subject: [PATCH 14/25] docs(security): correct OpenClaw confidential state Signed-off-by: Julie Yaunches --- docs/security/best-practices.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index dbd3bc21773..54f1e911ec7 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -298,7 +298,7 @@ After the top-level config binding is frozen, lockdown makes containment monoton It removes unsafe symlinks, special entries, and protected-root names that are not directories through descriptor-relative operations without following their targets. For protected regular files, lockdown publishes a fresh inode, severing hardlinks while preserving file content, read/execute mode, timestamps, and supported extended attributes; this also revokes write authority held through a descriptor opened before `shields up`. The OpenClaw gateway (a member of the `sandbox` group) keeps read access to plugin and agent code; the sandbox user can no longer write them. -The same workflow locks `identity`, `pairing`, and a non-empty `credentials` directory to `root:root 0700`. +The same workflow locks `identity` and a non-empty `credentials` directory to `root:root 0700`. An empty `credentials` directory uses `root:sandbox 0710` so OpenClaw can confirm that optional credential files are absent during startup. The sandbox group cannot list, create, or remove entries, and it cannot read credential files. Neither the sandbox user nor the gateway can read stored secrets while the lock is active. From 5c44a4d0ace2cca8822fe3b1104398cf89cb2a82 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 21:50:41 -0400 Subject: [PATCH 15/25] test(shields): isolate recovery policy command Signed-off-by: Julie Yaunches --- src/lib/shields/index.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index f93b7cd355c..ccea9e78371 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -616,6 +616,14 @@ describe("shields — unit logic", () => { const sandboxName = "openclaw"; const processToken = "d".repeat(32); const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); + const openshellArgvPath = path.join(tmpDir, "openshell-argv.txt"); + fs.writeFileSync( + path.join(tmpDir, "openshell"), + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$OPENSHELL_TEST_ARGV_PATH"\n', + { mode: 0o700 }, + ); + vi.stubEnv("PATH", `${tmpDir}${path.delimiter}${process.env.PATH ?? ""}`); + vi.stubEnv("OPENSHELL_TEST_ARGV_PATH", openshellArgvPath); fs.mkdirSync(stateDir(), { recursive: true }); fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"); writeState(sandboxName, { @@ -632,7 +640,6 @@ describe("shields — unit logic", () => { }); vi.spyOn(process, "kill").mockImplementation(routeProcessKill); const { applyShieldsPolicySnapshot } = await loadShieldsModule(); - const { buildPolicySetCommand } = await import("../policy"); const createTempDirectory = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { throw Object.assign(new Error("ENOSPC: simulated temporary storage full"), { code: "ENOSPC", @@ -647,7 +654,14 @@ describe("shields — unit logic", () => { expect(result.status).toBe(0); expect(createTempDirectory).not.toHaveBeenCalled(); - expect(buildPolicySetCommand).toHaveBeenCalledWith(snapshotPath, sandboxName); + expect(fs.readFileSync(openshellArgvPath, "utf-8").trim().split("\n")).toEqual([ + "policy", + "set", + "--policy", + snapshotPath, + "--wait", + sandboxName, + ]); }); it("shieldsStatus warns and stays DOWN when inline recovery fails", async () => { From c6b6201366b13659c17882d2c366a5fd176a4c8d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 22:06:32 -0400 Subject: [PATCH 16/25] test(shields): pin recovery command fixture Signed-off-by: Julie Yaunches --- src/lib/shields/index.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index ccea9e78371..0a7d2209bcc 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -616,13 +616,15 @@ describe("shields — unit logic", () => { const sandboxName = "openclaw"; const processToken = "d".repeat(32); const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); + const openshellPath = path.join(tmpDir, "openshell"); const openshellArgvPath = path.join(tmpDir, "openshell-argv.txt"); fs.writeFileSync( - path.join(tmpDir, "openshell"), + openshellPath, '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$OPENSHELL_TEST_ARGV_PATH"\n', { mode: 0o700 }, ); vi.stubEnv("PATH", `${tmpDir}${path.delimiter}${process.env.PATH ?? ""}`); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", openshellPath); vi.stubEnv("OPENSHELL_TEST_ARGV_PATH", openshellArgvPath); fs.mkdirSync(stateDir(), { recursive: true }); fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"); From fd9886933fa73cce575aa9259342b8de32aeaf33 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 09:26:15 -0400 Subject: [PATCH 17/25] test(state): align refreshed fixtures with current contracts Signed-off-by: Julie Yaunches --- src/lib/shields/index.test.ts | 73 ----------- .../shields/status-state-lock-plan.test.ts | 120 ++++++++++++++++++ test/helpers/shields-flow-harness.ts | 22 ++-- .../snapshot-state-discovery-fixture.ts | 65 ++++++++++ test/snapshot.test.ts | 52 ++------ test/state-dir-guard.test.ts | 6 +- 6 files changed, 208 insertions(+), 130 deletions(-) create mode 100644 src/lib/shields/status-state-lock-plan.test.ts create mode 100644 test/helpers/snapshot-state-discovery-fixture.ts diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index d6c37e1bc1b..9eca3723796 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -1017,79 +1017,6 @@ describe("shields — unit logic", () => { expect(errorSpy).not.toHaveBeenCalled(); }); - it("reports a mismatched installed state lock plan as drift", async () => { - const sandboxName = "openclaw"; - writeSealedLockedState(sandboxName); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi - .spyOn(process, "exit") - .mockImplementation((code?: string | number | null) => { - throw new Error(`exit ${String(code)}`); - }); - - const { shieldsStatus } = await loadShieldsModule(); - expect(() => - shieldsStatus(sandboxName, true, { - verifyLockState: () => ({ ok: true, issues: [] }), - verifyStateLockPlan: () => [ - "installed state lock plan differs from the current agent manifest", - ], - resolveConfig: () => ({ - agentName: "openclaw", - configPath: "/sandbox/.openclaw/openclaw.json", - configDir: "/sandbox/.openclaw", - }), - }), - ).toThrow("exit 2"); - - const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); - expect(errors).toContain( - "state lock plan: installed state lock plan differs from the current agent manifest", - ); - expect(errors).toContain( - "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", - ); - expect(exitSpy).toHaveBeenCalledWith(2); - }); - - it("reports confirmed plan drift when filesystem verification also throws", async () => { - const sandboxName = "openclaw"; - writeSealedLockedState(sandboxName); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => { - throw new Error(`exit ${String(code)}`); - }); - - const { shieldsStatus } = await loadShieldsModule(); - expect(() => - shieldsStatus(sandboxName, true, { - verifyLockState: () => { - throw new Error("filesystem verification failed"); - }, - verifyStateLockPlan: () => [ - "installed state lock plan differs from the current agent manifest", - ], - resolveConfig: () => ({ - agentName: "openclaw", - configPath: "/sandbox/.openclaw/openclaw.json", - configDir: "/sandbox/.openclaw", - stateLockPlanInImage: true, - }), - }), - ).toThrow("exit 2"); - - const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); - expect(errors).toContain( - "state lock plan: installed state lock plan differs from the current agent manifest", - ); - expect(errors).toContain( - "unable to verify agent config target: filesystem verification failed", - ); - expect(errors).toContain( - "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", - ); - }); - it("passes the persisted fileHashes seal to the verifier when present", async () => { const sandboxName = "openclaw"; const fileHashes = { diff --git a/src/lib/shields/status-state-lock-plan.test.ts b/src/lib/shields/status-state-lock-plan.test.ts new file mode 100644 index 00000000000..193e703580e --- /dev/null +++ b/src/lib/shields/status-state-lock-plan.test.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-plan-status-test-")); + vi.stubEnv("HOME", tmpDir); + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeSealedLockedState(sandboxName: string): void { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify( + { + shieldsDown: false, + chattrApplied: true, + fileHashes: { + "/sandbox/.openclaw/openclaw.json": + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + { mode: 0o600 }, + ); +} + +async function loadShieldsModule() { + return import(path.join(process.cwd(), "src", "lib", "shields", "index.ts")); +} + +describe("Shields status state lock plan drift", () => { + it("reports a mismatched installed state lock plan as drift", async () => { + const sandboxName = "openclaw"; + writeSealedLockedState(sandboxName); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); + + const { shieldsStatus } = await loadShieldsModule(); + expect(() => + shieldsStatus(sandboxName, true, { + verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [ + "installed state lock plan differs from the current agent manifest", + ], + resolveConfig: () => ({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + }), + }), + ).toThrow("exit 2"); + + const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); + expect(errors).toContain( + "state lock plan: installed state lock plan differs from the current agent manifest", + ); + expect(errors).toContain( + "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", + ); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + + it("reports confirmed plan drift when filesystem verification also throws", async () => { + const sandboxName = "openclaw"; + writeSealedLockedState(sandboxName); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); + + const { shieldsStatus } = await loadShieldsModule(); + expect(() => + shieldsStatus(sandboxName, true, { + verifyLockState: () => { + throw new Error("filesystem verification failed"); + }, + verifyStateLockPlan: () => [ + "installed state lock plan differs from the current agent manifest", + ], + resolveConfig: () => ({ + agentName: "openclaw", + configPath: "/sandbox/.openclaw/openclaw.json", + configDir: "/sandbox/.openclaw", + stateLockPlanInImage: true, + }), + }), + ).toThrow("exit 2"); + + const errors = errorSpy.mock.calls.map((args) => args[0]).join("\n"); + expect(errors).toContain( + "state lock plan: installed state lock plan differs from the current agent manifest", + ); + expect(errors).toContain( + "unable to verify agent config target: filesystem verification failed", + ); + expect(errors).toContain( + "Recovery: rebuild the sandbox so its generated state lock plan matches the current agent manifest.", + ); + }); +}); diff --git a/test/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index c764c10ad9b..4291a452b75 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -279,18 +279,18 @@ export function createShieldsFlowHarness( : args.includes("lsattr") && options.confirmOpenClawInodeFlags ? `${openClawPosture === "locked" ? "----i---------e-----" : "----------------------"} ${String(args.at(-1))}\n` : args.includes("stat") - ? args.at(-1) === "/sandbox" - ? openClawPosture === "locked" - ? "1775 root:sandbox\n" - : "755 sandbox:sandbox\n" - : args.at(-1) === "/sandbox/.openclaw" + ? args.at(-1) === "/sandbox" ? openClawPosture === "locked" - ? "755 root:root\n" - : "2770 sandbox:sandbox\n" - : openClawPosture === "locked" - ? "444 root:root\n" - : "660 sandbox:sandbox\n" - : ""; + ? "1775 root:sandbox\n" + : "755 sandbox:sandbox\n" + : args.at(-1) === "/sandbox/.openclaw" + ? openClawPosture === "locked" + ? "755 root:root\n" + : "2770 sandbox:sandbox\n" + : openClawPosture === "locked" + ? "444 root:root\n" + : "660 sandbox:sandbox\n" + : ""; }); const auditSpy = vi.spyOn(audit, "appendAuditEntry").mockImplementation(() => undefined); if (options.timerAuthorityRevokedSequence) { diff --git a/test/helpers/snapshot-state-discovery-fixture.ts b/test/helpers/snapshot-state-discovery-fixture.ts new file mode 100644 index 00000000000..d4778b73ecb --- /dev/null +++ b/test/helpers/snapshot-state-discovery-fixture.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type StateDirectoryDiscoveryFixture = { + existingDirs: string[]; + openclawDir: string; + sshLog: string; + stagingRoot: string; + unsafeDiscoveryMarker: string; +}; + +export function stateDirectoryDiscoverySshSource({ + existingDirs, + openclawDir, + sshLog, + stagingRoot, + unsafeDiscoveryMarker, +}: StateDirectoryDiscoveryFixture): string { + return `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const cmd = process.argv[process.argv.length - 1] || ""; +const existingDirs = ${JSON.stringify(existingDirs)}; +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); +if (cmd.includes("[ -d ")) { + if (fs.existsSync(${JSON.stringify(unsafeDiscoveryMarker)})) { + process.stdout.write("workspace-research\\nidentity\\n"); + process.exit(0); + } + process.stdout.write(existingDirs.join("\\n") + "\\n"); + process.exit(0); +} +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + process.exit(2); +} +if (cmd.includes("find ")) { + process.exit(0); +} +if (cmd.includes("tar -cf -")) { + const stagingDirs = fs.readdirSync(${JSON.stringify(stagingRoot)}); + const archivePaths = stagingDirs + .map((entry) => require("node:path").join(${JSON.stringify(stagingRoot)}, entry, "archive.tar")) + .filter((candidate) => fs.existsSync(candidate)); + const archivePath = archivePaths.length === 1 ? archivePaths[0] : ""; + if ( + !fs.fstatSync(1).isFile() || + !archivePath || + !fs.existsSync(archivePath) || + fs.statSync(archivePath).ino !== fs.fstatSync(1).ino + ) { + process.stderr.write("backup tar stdout must stream to a file\\n"); + process.exit(64); + } + const result = spawnSync( + "tar", + ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.stdout) fs.writeSync(1, result.stdout); + if (result.stderr) fs.writeSync(2, result.stderr); + process.exit(result.status || 0); +} +process.exit(0); +`; +} diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 8ba5be9b100..cf206968ebc 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -15,6 +15,7 @@ import { pathToFileURL } from "node:url"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { managedStartupE2eProfile } from "../scripts/checks/generate-managed-startup-profile-fixture.mts"; import { encodeManagedStartupProfile } from "../src/lib/onboard/managed-startup/profile"; +import { stateDirectoryDiscoverySshSource } from "./helpers/snapshot-state-discovery-fixture"; // Override HOME BEFORE importing sandbox-state — it reads process.env.HOME // at module-load time to compute REBUILD_BACKUPS_DIR. Captured original is @@ -618,50 +619,13 @@ describe("sandbox directory backup semantics", () => { const openshell = writeFakeOpenshell(binDir); writeExecutable( path.join(binDir, "ssh"), - `#!/usr/bin/env node -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const cmd = process.argv[process.argv.length - 1] || ""; -const existingDirs = ${JSON.stringify(existingDirs)}; -fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); -if (cmd.includes("[ -d ")) { - if (fs.existsSync(${JSON.stringify(unsafeDiscoveryMarker)})) { - process.stdout.write("workspace-research\\nidentity\\n"); - process.exit(0); - } - process.stdout.write(existingDirs.join("\\n") + "\\n"); - process.exit(0); -} -if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { - process.exit(2); -} -if (cmd.includes("find ")) { - process.exit(0); -} -if (cmd.includes("tar -cf -")) { - const stagingDirs = fs.readdirSync(${JSON.stringify(stagingRoot)}); - const archivePaths = stagingDirs - .map((entry) => require("node:path").join(${JSON.stringify(stagingRoot)}, entry, "archive.tar")) - .filter((candidate) => fs.existsSync(candidate)); - const archivePath = archivePaths.length === 1 ? archivePaths[0] : ""; - if ( - !fs.fstatSync(1).isFile() || - !archivePath || - !fs.existsSync(archivePath) || - fs.statSync(archivePath).ino !== fs.fstatSync(1).ino - ) { - process.stderr.write("backup tar stdout must stream to a file\\n"); - process.exit(64); - } - const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { - stdio: ["ignore", "pipe", "pipe"], - }); - if (r.stdout) fs.writeSync(1, r.stdout); - if (r.stderr) fs.writeSync(2, r.stderr); - process.exit(r.status || 0); -} -process.exit(0); -`, + stateDirectoryDiscoverySshSource({ + existingDirs, + openclawDir, + sshLog, + stagingRoot, + unsafeDiscoveryMarker, + }), ); writeOpenClawRegistry("alpha", { diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index 502035290be..038129c965a 100644 --- a/test/state-dir-guard.test.ts +++ b/test/state-dir-guard.test.ts @@ -186,7 +186,7 @@ import json import os import sys -guard_path, config_dir, sandbox_gid = sys.argv[1:4] +guard_path, config_dir, sandbox_gid, plan_json = sys.argv[1:5] spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard_gid", guard_path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module @@ -195,7 +195,8 @@ identity = module.Identity( root_uid=os.getuid(), root_gid=os.getgid(), sandbox_uid=os.getuid(), sandbox_gid=int(sandbox_gid), ) -result = module.run_guard("lock", config_dir, identity) +plan = module.parse_agent_state_lock_plan(plan_json) +result = module.run_guard("lock", config_dir, identity, plan) def group_of(path): @@ -1260,6 +1261,7 @@ describe("state-dir-guard", () => { GUARD_PATH, configDir, String(SUPPLEMENTARY_GID), + PLAN_JSON, ], { encoding: "utf-8", timeout: 15_000 }, ); From bbe50fbaf7510727d37aca680b6c71c82c99c96b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 09:45:16 -0400 Subject: [PATCH 18/25] docs(security): clarify Hermes state protection Signed-off-by: Julie Yaunches --- docs/security/best-practices.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 33368660c89..4c04204b5a6 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -353,8 +353,8 @@ Historical Hermes images that have a bundled helper but no generated plan use th It locks `cron`, `hooks`, `platforms`, `plugins`, `profiles`, `skills`, `skins`, `weixin`, and `workspace` to `root:sandbox`, and locks the `pairing` confidentiality root to `root:sandbox 0710`. Hermes runtime directories without a Shields declaration remain mutable. -When Shields locks the tree, the shared state-directory guard treats any present `credentials`, `identity`, or `pairing` directory as a confidentiality root. -For Hermes, this normally applies to `pairing`. +The shared state-directory guard applies the manifest declaration to `pairing` on current Hermes images. +On historical images, the reviewed legacy inventory also treats present `credentials` and `identity` directories as confidentiality roots. The guard sets the root to `root:sandbox 710`, keeps it traversable but unlistable to the sandbox group, and sets every descendant to `root:root` with no group or world permission bits. As a result, a known-name probe for a missing direct child returns `ENOENT`, while directory listing, nested traversal, and protected-file reads return `EACCES`. From e8910b0031d1544bd563bf5b80bd12c23c32459a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 10:22:00 -0400 Subject: [PATCH 19/25] test(e2e): wait for sandbox readiness after restart Signed-off-by: Julie Yaunches --- test/e2e/fixtures/phases/lifecycle.ts | 15 +++++++++++---- test/e2e/live/sandbox-survival.test.ts | 4 ++-- test/e2e/support/e2e-phase-lifecycle.test.ts | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/test/e2e/fixtures/phases/lifecycle.ts b/test/e2e/fixtures/phases/lifecycle.ts index d5c69bb121f..9dcb98debd6 100644 --- a/test/e2e/fixtures/phases/lifecycle.ts +++ b/test/e2e/fixtures/phases/lifecycle.ts @@ -206,7 +206,7 @@ export interface RebuildSandboxOptions { verbose?: boolean; } -export interface SandboxReadyAfterRebuildOptions { +export interface SandboxReadyOptions { attempts?: number; delayMs?: number; env?: NodeJS.ProcessEnv; @@ -282,15 +282,22 @@ export class LifecyclePhaseFixture { async assertSandboxReadyAfterRebuild( instance: NemoClawInstance | string, - options: SandboxReadyAfterRebuildOptions = {}, + options: SandboxReadyOptions = {}, ): Promise { return await this.waitForSandboxReady(instance, options, "after rebuild"); } + async assertSandboxReadyAfterGatewayRestart( + instance: NemoClawInstance | string, + options: SandboxReadyOptions = {}, + ): Promise { + return await this.waitForSandboxReady(instance, options, "after gateway restart"); + } + private async waitForSandboxReady( instance: NemoClawInstance | string, - options: SandboxReadyAfterRebuildOptions, - transition: "after rebuild" | "after the boot restart", + options: SandboxReadyOptions, + transition: "after rebuild" | "after gateway restart" | "after the boot restart", ): Promise { const sandboxName = instanceName(instance); const attempts = options.attempts ?? SANDBOX_READY_ATTEMPTS; diff --git a/test/e2e/live/sandbox-survival.test.ts b/test/e2e/live/sandbox-survival.test.ts index 8a1b9b759d8..46c2b24b031 100644 --- a/test/e2e/live/sandbox-survival.test.ts +++ b/test/e2e/live/sandbox-survival.test.ts @@ -420,8 +420,8 @@ test( }); progress.phase("recheck state and inference after restart"); - await sandbox.expectListed(SANDBOX_NAME, { - artifactName: "post-restart-openshell-sandbox-list", + await lifecycle.assertSandboxReadyAfterGatewayRestart(instance, { + artifactNamePrefix: "post-restart-openshell-sandbox-ready", }); stateValidation.expectLocalRegistryContains(SANDBOX_NAME); await host.expectListed(SANDBOX_NAME, { diff --git a/test/e2e/support/e2e-phase-lifecycle.test.ts b/test/e2e/support/e2e-phase-lifecycle.test.ts index 11479ff1019..6389ddc3786 100644 --- a/test/e2e/support/e2e-phase-lifecycle.test.ts +++ b/test/e2e/support/e2e-phase-lifecycle.test.ts @@ -389,6 +389,21 @@ describe("LifecyclePhaseFixture rebuild helpers", () => { expect(result.stdout).toContain("e2e-x Ready"); expect(runner.calls).toHaveLength(2); }); + + it("waits for Ready before checking a sandbox after gateway restart", async () => { + const runner = new FakeRunner(); + runner.enqueue(shellResult(0, "NAME PHASE\ne2e-x Provisioning\n")); + runner.enqueue(shellResult(0, "NAME PHASE\ne2e-x Ready\n")); + const cleanup = new FakeCleanup(); + + const result = await fixture(runner, cleanup).assertSandboxReadyAfterGatewayRestart("e2e-x", { + attempts: 2, + delayMs: 0, + }); + + expect(result.stdout).toContain("e2e-x Ready"); + expect(runner.calls).toHaveLength(2); + }); }); describe("LifecyclePhaseFixture gateway runtime restart helpers", () => { From 820292ccfcefd624ca78dcae28304e38c4df8375 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 11:03:41 -0400 Subject: [PATCH 20/25] test(state): align merged target fixtures Signed-off-by: Julie Yaunches --- src/lib/actions/sandbox/rebuild-restore-phase.test.ts | 3 +++ src/lib/shields/policy-transition.test.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 2f54d729022..02dee18624e 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -57,6 +57,7 @@ describe("rebuild policy restore fidelity", () => { configPath: "/sandbox/.hermes/config.yaml", configFile: "config.yaml", format: "yaml", + stateLockPlanInImage: true, } as const; vi.spyOn(sandboxConfig, "resolveAgentConfig").mockReturnValue(target); const seedDashboard = vi @@ -96,6 +97,7 @@ describe("rebuild policy restore fidelity", () => { configPath: "/sandbox/.hermes/config.yaml", configFile: "config.yaml", format: "yaml", + stateLockPlanInImage: true, }); vi.spyOn(sandboxConfig, "restoreHermesDashboardConfig").mockReturnValue("failed"); @@ -132,6 +134,7 @@ describe("rebuild policy restore fidelity", () => { configPath: "/sandbox/.openclaw/openclaw.json", configFile: "openclaw.json", format: "json", + stateLockPlanInImage: true, }); const seedDashboard = vi.spyOn(sandboxConfig, "restoreHermesDashboardConfig"); diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 053808aa483..6e9b6f394bd 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -213,6 +213,8 @@ describe("shields down policy rejection", () => { agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", stateLockPlanInImage: true, }), }); @@ -280,6 +282,8 @@ describe("shields down policy rejection", () => { agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", stateLockPlanInImage: true, }), }), From 29564c9a5060b998af7e5e79d0d6207c2aed7b08 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 11:14:41 -0400 Subject: [PATCH 21/25] docs(security): align state plan inventory Signed-off-by: Julie Yaunches --- docs/security/best-practices.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 4c04204b5a6..b05ecba339f 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -291,7 +291,7 @@ Writable agent state such as plugins, skills, hooks, and workspace metadata live By default, this directory starts writable so the agent can manage its own config, install skills, and write to standard home-directory paths natively. For sensitive workloads, use a reviewed host-side immutability workflow after initial setup so the sandbox user cannot change config or high-risk state entry points. The immutability workflow derives its path plan from the selected agent manifest. -For OpenClaw, it locks `agents`, `canvas`, `cron`, `devices`, `extensions`, `hooks`, `memory`, `plugins`, `skills`, `telegram`, `wechat`, `whatsapp`, `workspace`, and `workspace-*` directories to `root:sandbox` and removes group and world write access. +For OpenClaw, it locks `agents`, `canvas`, `cron`, `devices`, `extensions`, `hooks`, `memory`, `plugins`, `profiles`, `skills`, `telegram`, `wechat`, `whatsapp`, `workspace`, and `workspace-*` directories to `root:sandbox` and removes group and world write access. The root-only helper traverses from opened directory descriptors with no-follow semantics instead of using recursive pathname `chown` or `chmod`. Read-only preflight and unlock operations reject unsafe external symlinks, hardlinks, special files, cross-device entries, and entries that race the traversal without modifying them. After the top-level config binding is frozen, lockdown makes containment monotonic. @@ -350,7 +350,9 @@ Hermes also stores runtime state such as `state.db`, logs, and platform sessions Messaging sessions such as WhatsApp pairing can remain mutable by design so they survive rebuilds. For plan-aware current images, the Shields workflow derives the Hermes lock plan from its agent manifest. Historical Hermes images that have a bundled helper but no generated plan use the helper's reviewed legacy inventory until the sandbox is rebuilt. -It locks `cron`, `hooks`, `platforms`, `plugins`, `profiles`, `skills`, `skins`, `weixin`, and `workspace` to `root:sandbox`, and locks the `pairing` confidentiality root to `root:sandbox 0710`. +It locks `cron`, `hooks`, `platforms`, `plugins`, `profiles`, `skills`, `skins`, `weixin`, and `workspace` to `root:sandbox`. +The `profiles/dashboard-home` carve-out remains `sandbox:sandbox 0700`, and the guard does not traverse or rewrite its descendants. +It locks the `pairing` confidentiality root to `root:sandbox 0710`. Hermes runtime directories without a Shields declaration remain mutable. The shared state-directory guard applies the manifest declaration to `pairing` on current Hermes images. From 15ede40218010710755be23030eea10e82340956 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 11:59:12 -0400 Subject: [PATCH 22/25] test(e2e): support flat Hermes cron state Signed-off-by: Julie Yaunches --- test/e2e/live/rebuild-hermes-cron-restore.ts | 22 +++++++--------- test/e2e/live/rebuild-hermes-cron-state.ts | 9 +++++++ .../support/rebuild-hermes-cron-state.test.ts | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 test/e2e/live/rebuild-hermes-cron-state.ts create mode 100644 test/e2e/support/rebuild-hermes-cron-state.test.ts diff --git a/test/e2e/live/rebuild-hermes-cron-restore.ts b/test/e2e/live/rebuild-hermes-cron-restore.ts index 96346d0c549..c389bf89c0f 100644 --- a/test/e2e/live/rebuild-hermes-cron-restore.ts +++ b/test/e2e/live/rebuild-hermes-cron-restore.ts @@ -9,6 +9,7 @@ import { resolveDirectSandboxContainer } from "../../../src/lib/sandbox/privileg import { assertExitZero as expectExitZero } from "../fixtures/clients/command.ts"; import { type HostCliClient, resultText } from "../fixtures/clients/index.ts"; import { expect } from "../fixtures/e2e-test.ts"; +import { hermesCronRuntimeFields } from "./rebuild-hermes-cron-state.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; const HERMES_HOME = "/sandbox/.hermes"; @@ -121,12 +122,8 @@ function parseCronJob(text: string, jobId: string, label: string): JsonObject { ); } -function cronJobState(job: JsonObject, label: string): JsonObject { - return requireObject(job.state, `${label} state`); -} - function completedRuns(job: JsonObject, label: string): number { - const repeat = requireObject(cronJobState(job, label).repeat, `${label} repeat state`); + const repeat = requireObject(hermesCronRuntimeFields(job, label).repeat, `${label} repeat state`); return typeof repeat.completed === "number" ? repeat.completed : fail(`${label} completed run count is unavailable`); @@ -140,13 +137,14 @@ function assertFutureCronJob(job: JsonObject, seed: SeededCronJob): void { no_agent: true, schedule: { kind: "interval" }, script: seed.scriptName, + state: "scheduled", }); - const state = cronJobState(job, `cron job ${seed.id}`); - expect(state.last_run_at ?? null).toBeNull(); - expect(state.last_status ?? null).toBeNull(); + const runtime = hermesCronRuntimeFields(job, `cron job ${seed.id}`); + expect(runtime.last_run_at ?? null).toBeNull(); + expect(runtime.last_status ?? null).toBeNull(); expect(completedRuns(job, `cron job ${seed.id}`)).toBe(0); expect( - normalizeTimestampMs(state.next_run_at, `cron job ${seed.id} next run`), + normalizeTimestampMs(runtime.next_run_at, `cron job ${seed.id} next run`), "seeded recurring cron job must remain well in the future during rebuild", ).toBeGreaterThan(Date.now() + 60 * 60_000); } @@ -305,13 +303,13 @@ export function createRebuildHermesCronRestoreFixture({ for (let attempt = 1; attempt <= EXECUTION_POLL_ATTEMPTS; attempt += 1) { const job = await readCronJob(seed.id, `${artifactPrefix}-job-attempt-${attempt}`); const count = await executionCount(seed, `${artifactPrefix}-marker-attempt-${attempt}`); - const state = cronJobState(job, `cron job ${seed.id}`); + const runtime = hermesCronRuntimeFields(job, `cron job ${seed.id}`); const completed = completedRuns(job, `cron job ${seed.id}`); - lastEvidence = JSON.stringify({ completed, count, last_status: state.last_status }); + lastEvidence = JSON.stringify({ completed, count, last_status: runtime.last_status }); if (completed > 1 || count > 1) { fail(`Hermes cron job ${seed.id} executed more than once: ${lastEvidence}`); } - if (completed === 1 && count === 1 && state.last_status === "ok") return; + if (completed === 1 && count === 1 && runtime.last_status === "ok") return; await sleep(POLL_INTERVAL_MS); } fail(`Hermes cron job ${seed.id} did not complete exactly once: ${lastEvidence}`); diff --git a/test/e2e/live/rebuild-hermes-cron-state.ts b/test/e2e/live/rebuild-hermes-cron-state.ts new file mode 100644 index 00000000000..772caa90689 --- /dev/null +++ b/test/e2e/live/rebuild-hermes-cron-state.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type JsonObject = Record; + +export function hermesCronRuntimeFields(job: JsonObject, label: string): JsonObject { + if (typeof job.state !== "string") throw new Error(`${label} state is not a status string`); + return job; +} diff --git a/test/e2e/support/rebuild-hermes-cron-state.test.ts b/test/e2e/support/rebuild-hermes-cron-state.test.ts new file mode 100644 index 00000000000..2dacb982e48 --- /dev/null +++ b/test/e2e/support/rebuild-hermes-cron-state.test.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { hermesCronRuntimeFields } from "../live/rebuild-hermes-cron-state.ts"; + +describe("Hermes rebuild cron state", () => { + it("reads runtime fields from a historical flat cron job", () => { + const job = { + state: "scheduled", + repeat: { completed: 0 }, + next_run_at: "2026-08-06T15:39:18.552123+00:00", + last_run_at: null, + last_status: null, + }; + + expect(hermesCronRuntimeFields(job, "historical cron job")).toBe(job); + }); + + it.each([null, [], {}, 1, true, undefined])("rejects an unsupported cron state: %s", (state) => { + expect(() => hermesCronRuntimeFields({ state }, "invalid cron job")).toThrow( + "invalid cron job state is not a status string", + ); + }); +}); From b95ca422558d6d98a2c07a53251e2bf8b571ee89 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:49:16 -0400 Subject: [PATCH 23/25] test(e2e): validate redacted Hermes cron receipt Signed-off-by: Julie Yaunches --- test/e2e/live/rebuild-hermes-cron-receipt.ts | 22 +++++++++++++ test/e2e/live/rebuild-hermes-cron-restore.ts | 28 ++++------------ .../rebuild-hermes-cron-receipt.test.ts | 33 +++++++++++++++++++ test/hermes-cron-restore-control.test.ts | 1 + 4 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 test/e2e/live/rebuild-hermes-cron-receipt.ts create mode 100644 test/e2e/support/rebuild-hermes-cron-receipt.test.ts diff --git a/test/e2e/live/rebuild-hermes-cron-receipt.ts b/test/e2e/live/rebuild-hermes-cron-receipt.ts new file mode 100644 index 00000000000..8122f30cc35 --- /dev/null +++ b/test/e2e/live/rebuild-hermes-cron-receipt.ts @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type JsonObject = Record; + +export interface HermesCronBeginIdentity { + pid: number; + start_time: number; +} + +export function hermesCronBeginIdentity(payload: JsonObject): HermesCronBeginIdentity { + if ( + !Number.isSafeInteger(payload.pid) || + Number(payload.pid) <= 0 || + !Number.isSafeInteger(payload.start_time) || + Number(payload.start_time) < 0 || + payload.drain_token !== "" + ) { + throw new Error("Hermes cron begin receipt identity is invalid"); + } + return { pid: Number(payload.pid), start_time: Number(payload.start_time) }; +} diff --git a/test/e2e/live/rebuild-hermes-cron-restore.ts b/test/e2e/live/rebuild-hermes-cron-restore.ts index c389bf89c0f..4f943629bc9 100644 --- a/test/e2e/live/rebuild-hermes-cron-restore.ts +++ b/test/e2e/live/rebuild-hermes-cron-restore.ts @@ -9,6 +9,10 @@ import { resolveDirectSandboxContainer } from "../../../src/lib/sandbox/privileg import { assertExitZero as expectExitZero } from "../fixtures/clients/command.ts"; import { type HostCliClient, resultText } from "../fixtures/clients/index.ts"; import { expect } from "../fixtures/e2e-test.ts"; +import { + type HermesCronBeginIdentity, + hermesCronBeginIdentity, +} from "./rebuild-hermes-cron-receipt.ts"; import { hermesCronRuntimeFields } from "./rebuild-hermes-cron-state.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; @@ -55,18 +59,6 @@ interface GatewayEvidence { start_time: number; } -interface CronControlReceipt { - action: "begin"; - active_agents: number; - disposition: string; - drain_acquired: boolean; - drain_token: string; - operator_drain_active: boolean; - pid: number; - start_time: number; - version: number; -} - function fail(message: string): never { throw new Error(message); } @@ -387,7 +379,7 @@ export function createRebuildHermesCronRestoreFixture({ fail(`Hermes gateway did not reach ${state}: ${lastEvidence}`); } - function parseBeginReceipt(text: string): CronControlReceipt { + function parseBeginReceipt(text: string): HermesCronBeginIdentity { const lines = text.split(/\r?\n/u).filter((line) => line.startsWith(RECEIPT_PREFIX)); if (lines.length !== 1) fail(`Hermes cron begin returned ${lines.length} receipts`); const payload = parseJsonObject(lines[0].slice(RECEIPT_PREFIX.length), "cron begin receipt"); @@ -399,15 +391,7 @@ export function createRebuildHermesCronRestoreFixture({ operator_drain_active: false, version: 1, }); - if ( - !Number.isSafeInteger(payload.pid) || - !Number.isSafeInteger(payload.start_time) || - typeof payload.drain_token !== "string" || - payload.drain_token.length !== 32 - ) { - fail("Hermes cron begin receipt identity is invalid"); - } - return payload as unknown as CronControlReceipt; + return hermesCronBeginIdentity(payload); } async function exerciseStateRootSubstitutionAttack( diff --git a/test/e2e/support/rebuild-hermes-cron-receipt.test.ts b/test/e2e/support/rebuild-hermes-cron-receipt.test.ts new file mode 100644 index 00000000000..688c1b1dab6 --- /dev/null +++ b/test/e2e/support/rebuild-hermes-cron-receipt.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { redactString } from "../fixtures/redaction.ts"; +import { hermesCronBeginIdentity } from "../live/rebuild-hermes-cron-receipt.ts"; + +describe("Hermes rebuild cron begin receipt", () => { + it("reads identity only after the fixture redacts the drain token", () => { + const raw = { + drain_token: "a".repeat(32), + pid: 263, + start_time: 27_160, + }; + const redacted = JSON.parse(redactString(JSON.stringify(raw))); + + expect(redacted.drain_token).toBe(""); + expect(hermesCronBeginIdentity(redacted)).toEqual({ pid: 263, start_time: 27_160 }); + }); + + it.each([ + ["raw token", { drain_token: "a".repeat(32), pid: 263, start_time: 27_160 }], + ["alternate sentinel", { drain_token: "[REDACTED]", pid: 263, start_time: 27_160 }], + ["missing token", { pid: 263, start_time: 27_160 }], + ["zero pid", { drain_token: "", pid: 0, start_time: 27_160 }], + ["fractional start time", { drain_token: "", pid: 263, start_time: 1.5 }], + ])("rejects an invalid %s", (_label, payload) => { + expect(() => hermesCronBeginIdentity(payload)).toThrow( + "Hermes cron begin receipt identity is invalid", + ); + }); +}); diff --git a/test/hermes-cron-restore-control.test.ts b/test/hermes-cron-restore-control.test.ts index ed5cf364955..4311b707116 100644 --- a/test/hermes-cron-restore-control.test.ts +++ b/test/hermes-cron-restore-control.test.ts @@ -378,6 +378,7 @@ describe("Hermes in-sandbox cron restore validator", () => { "restore-validated", "dispatch-reactivated", ]); + expect(receipts[0].drain_token).toMatch(/^[A-Za-z0-9_-]{32}$/u); expect(receipts).toEqual( expect.arrayContaining([ expect.objectContaining({ pid: 41, start_time: 902 }), From c8c71c7f40923a17bcb64718bab1aed4fb079726 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 13:56:19 -0400 Subject: [PATCH 24/25] test(e2e): exercise Hermes cron scheduler drain Signed-off-by: Julie Yaunches --- .../e2e/live/rebuild-hermes-cron-execution.ts | 41 ++++++++ test/e2e/live/rebuild-hermes-cron-restore.ts | 93 +++++++++++++++---- test/e2e/live/rebuild-hermes-cron-schedule.ts | 12 +++ .../rebuild-hermes-cron-execution.test.ts | 53 +++++++++++ .../rebuild-hermes-cron-schedule.test.ts | 17 ++++ 5 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 test/e2e/live/rebuild-hermes-cron-execution.ts create mode 100644 test/e2e/live/rebuild-hermes-cron-schedule.ts create mode 100644 test/e2e/support/rebuild-hermes-cron-execution.test.ts create mode 100644 test/e2e/support/rebuild-hermes-cron-schedule.test.ts diff --git a/test/e2e/live/rebuild-hermes-cron-execution.ts b/test/e2e/live/rebuild-hermes-cron-execution.ts new file mode 100644 index 00000000000..195d665f9d1 --- /dev/null +++ b/test/e2e/live/rebuild-hermes-cron-execution.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type HermesOneShotExecutionState = "pending" | "completed"; + +function fail(message: string): never { + throw new Error(message); +} + +export function hermesOneShotExecutionState( + payload: unknown, + expectedJobId: string, +): HermesOneShotExecutionState { + if (!Array.isArray(payload)) fail("Hermes cron execution history is not an array"); + if (payload.length > 1) fail("Hermes cron execution history contains multiple attempts"); + if (payload.length === 0) return "pending"; + + const record = payload[0]; + if (!record || typeof record !== "object" || Array.isArray(record)) { + return fail("Hermes cron execution history contains an invalid record"); + } + const execution = record as Record; + if (execution.job_id !== expectedJobId) { + return fail("Hermes cron execution history contains the wrong job"); + } + if (execution.source !== "builtin") { + return fail("Hermes cron execution did not use the built-in scheduler"); + } + switch (execution.status) { + case "claimed": + case "running": + return "pending"; + case "completed": + return "completed"; + case "failed": + case "unknown": + return fail(`Hermes cron execution reached ${execution.status}`); + default: + return fail("Hermes cron execution history contains an invalid status"); + } +} diff --git a/test/e2e/live/rebuild-hermes-cron-restore.ts b/test/e2e/live/rebuild-hermes-cron-restore.ts index 4f943629bc9..711011be2de 100644 --- a/test/e2e/live/rebuild-hermes-cron-restore.ts +++ b/test/e2e/live/rebuild-hermes-cron-restore.ts @@ -9,10 +9,12 @@ import { resolveDirectSandboxContainer } from "../../../src/lib/sandbox/privileg import { assertExitZero as expectExitZero } from "../fixtures/clients/command.ts"; import { type HostCliClient, resultText } from "../fixtures/clients/index.ts"; import { expect } from "../fixtures/e2e-test.ts"; +import { hermesOneShotExecutionState } from "./rebuild-hermes-cron-execution.ts"; import { type HermesCronBeginIdentity, hermesCronBeginIdentity, } from "./rebuild-hermes-cron-receipt.ts"; +import { buildHermesRecoveryCronSchedule } from "./rebuild-hermes-cron-schedule.ts"; import { hermesCronRuntimeFields } from "./rebuild-hermes-cron-state.ts"; import { buildHermesRuntimeExecArgs } from "./rebuild-hermes-runtime-exec.ts"; @@ -121,13 +123,17 @@ function completedRuns(job: JsonObject, label: string): number { : fail(`${label} completed run count is unavailable`); } -function assertFutureCronJob(job: JsonObject, seed: SeededCronJob): void { +function assertPristineCronJob( + job: JsonObject, + seed: SeededCronJob, + expectedRunAtMs?: number, +): void { expect(job).toMatchObject({ enabled: true, id: seed.id, name: seed.name, no_agent: true, - schedule: { kind: "interval" }, + schedule: { kind: expectedRunAtMs === undefined ? "interval" : "once" }, script: seed.scriptName, state: "scheduled", }); @@ -135,10 +141,21 @@ function assertFutureCronJob(job: JsonObject, seed: SeededCronJob): void { expect(runtime.last_run_at ?? null).toBeNull(); expect(runtime.last_status ?? null).toBeNull(); expect(completedRuns(job, `cron job ${seed.id}`)).toBe(0); - expect( - normalizeTimestampMs(runtime.next_run_at, `cron job ${seed.id} next run`), - "seeded recurring cron job must remain well in the future during rebuild", - ).toBeGreaterThan(Date.now() + 60 * 60_000); + const nextRunAtMs = normalizeTimestampMs(runtime.next_run_at, `cron job ${seed.id} next run`); + if (expectedRunAtMs === undefined) { + expect( + nextRunAtMs, + "seeded recurring cron job must remain well in the future during rebuild", + ).toBeGreaterThan(Date.now() + 60 * 60_000); + } else { + expect(requireObject(runtime.repeat, `cron job ${seed.id} repeat state`)).toMatchObject({ + completed: 0, + times: 1, + }); + expect(nextRunAtMs, "seeded recovery cron job must retain its one-shot time").toBe( + expectedRunAtMs, + ); + } } export function hermesRuntimeExecArgs(sandboxName: string, command: string[]): string[] { @@ -217,7 +234,11 @@ export function createRebuildHermesCronRestoreFixture({ return parseCronJob(result.stdout, jobId, artifactName); } - async function seedCronJob(label: string): Promise { + async function seedCronJob( + label: string, + schedule = "every 1d", + expectedRunAtMs?: number, + ): Promise { const pending = uniqueSeed(label); const scriptPath = `${CRON_SCRIPTS_ROOT}/${pending.scriptName}`; const writeScript = await dockerRoot( @@ -242,7 +263,7 @@ export function createRebuildHermesCronRestoreFixture({ "hermes", "cron", "create", - "every 1d", + schedule, "--no-agent", "--script", pending.scriptName, @@ -259,7 +280,7 @@ export function createRebuildHermesCronRestoreFixture({ evidence: await readCronJob(id, `phase-${label}-read-seeded-hermes-cron-job`), id, }; - assertFutureCronJob(seed.evidence, seed); + assertPristineCronJob(seed.evidence, seed, expectedRunAtMs); await assertExecutionMarkerAbsent(seed, `phase-${label}-verify-cron-not-yet-executed`); return seed; } @@ -285,9 +306,20 @@ export function createRebuildHermesCronRestoreFixture({ return read.stdout.split(/\r?\n/u).filter((line) => line === seed.executionToken).length; } - async function enqueueManualRun(seed: SeededCronJob, artifactName: string): Promise { + async function oneShotExecutionState(seed: SeededCronJob, artifactName: string) { + const script = [ + "import json, sys", + "from cron.executions import list_executions", + "print(json.dumps(list_executions(job_id=sys.argv[1], limit=2), sort_keys=True))", + ].join("\n"); + const read = await dockerRoot([HERMES_PYTHON, "-I", "-c", script, seed.id], artifactName); + expectExitZero(read, `read execution history for ${seed.name}`); + return hermesOneShotExecutionState(JSON.parse(read.stdout), seed.id); + } + + async function runCronNow(seed: SeededCronJob, artifactName: string): Promise { const run = await dockerSandbox(["hermes", "cron", "run", seed.id], artifactName); - expectExitZero(run, `enqueue manual Hermes cron run ${seed.id}`); + expectExitZero(run, `run Hermes cron job ${seed.id} now`); } async function waitForOneExecution(seed: SeededCronJob, artifactPrefix: string): Promise { @@ -307,6 +339,27 @@ export function createRebuildHermesCronRestoreFixture({ fail(`Hermes cron job ${seed.id} did not complete exactly once: ${lastEvidence}`); } + async function waitForOneShotExecution( + seed: SeededCronJob, + artifactPrefix: string, + ): Promise { + let lastEvidence = "no scheduler evidence"; + for (let attempt = 1; attempt <= EXECUTION_POLL_ATTEMPTS; attempt += 1) { + const state = await oneShotExecutionState( + seed, + `${artifactPrefix}-history-attempt-${attempt}`, + ); + const count = await executionCount(seed, `${artifactPrefix}-marker-attempt-${attempt}`); + lastEvidence = JSON.stringify({ count, state }); + if (count > 1) { + fail(`Hermes cron job ${seed.id} executed more than once: ${lastEvidence}`); + } + if (state === "completed" && count === 1) return; + await sleep(POLL_INTERVAL_MS); + } + fail(`Hermes cron job ${seed.id} did not complete exactly once: ${lastEvidence}`); + } + async function assertControlMarker(present: boolean, artifactName: string): Promise { const command = present ? ["stat", "-c", "%u:%g %a %s", CRON_CONTROL_MARKER] @@ -509,7 +562,7 @@ export function createRebuildHermesCronRestoreFixture({ await waitForGatewayState("running", "phase-7-verify-gateway-running-after-cron-restore"); await assertExecutionMarkerAbsent(seed, "phase-7-verify-restored-cron-not-auto-executed"); - await enqueueManualRun(seed, "phase-7-run-restored-hermes-cron-job"); + await runCronNow(seed, "phase-7-run-restored-hermes-cron-job"); await waitForOneExecution(seed, "phase-7-wait-restored-hermes-cron-execution"); }, @@ -522,9 +575,17 @@ export function createRebuildHermesCronRestoreFixture({ const receipt = parseBeginReceipt(begin.stdout); await assertControlMarker(true, "phase-8-verify-cron-restore-marker-before-restart"); - const recoverySeed = await seedCronJob("recovery"); - await enqueueManualRun(recoverySeed, "phase-8-queue-due-hermes-cron-during-drain"); - await sleep(POLL_INTERVAL_MS); + const recoverySchedule = buildHermesRecoveryCronSchedule(); + const recoverySeed = await seedCronJob( + "recovery", + recoverySchedule.runAt, + recoverySchedule.runAtMs, + ); + await sleep(Math.max(0, recoverySchedule.runAtMs - Date.now() + 1)); + expect( + Date.now(), + "recovery cron job must be due before the stranded-gate restart", + ).toBeGreaterThan(recoverySchedule.runAtMs); await assertExecutionMarkerAbsent( recoverySeed, "phase-8-verify-due-cron-blocked-before-restart", @@ -567,7 +628,7 @@ export function createRebuildHermesCronRestoreFixture({ ); await assertControlMarker(false, "phase-8-verify-recovered-cron-restore-marker-released"); await waitForGatewayState("running", "phase-8-verify-gateway-running-after-recovery"); - await waitForOneExecution(recoverySeed, "phase-8-wait-recovered-hermes-cron-execution"); + await waitForOneShotExecution(recoverySeed, "phase-8-wait-recovered-hermes-cron-execution"); }, }; } diff --git a/test/e2e/live/rebuild-hermes-cron-schedule.ts b/test/e2e/live/rebuild-hermes-cron-schedule.ts new file mode 100644 index 00000000000..5cb6265cf03 --- /dev/null +++ b/test/e2e/live/rebuild-hermes-cron-schedule.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const RECOVERY_CRON_DELAY_MS = 10_000; + +export function buildHermesRecoveryCronSchedule(nowMs = Date.now()): { + runAt: string; + runAtMs: number; +} { + const runAtMs = nowMs + RECOVERY_CRON_DELAY_MS; + return { runAt: new Date(runAtMs).toISOString(), runAtMs }; +} diff --git a/test/e2e/support/rebuild-hermes-cron-execution.test.ts b/test/e2e/support/rebuild-hermes-cron-execution.test.ts new file mode 100644 index 00000000000..d3d8d6e728c --- /dev/null +++ b/test/e2e/support/rebuild-hermes-cron-execution.test.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { hermesOneShotExecutionState } from "../live/rebuild-hermes-cron-execution.ts"; + +const jobId = "job-1"; + +function execution(status: string, overrides: Record = {}) { + return { job_id: jobId, source: "builtin", status, ...overrides }; +} + +describe("Hermes rebuild one-shot cron execution", () => { + it("reports a pending job before the scheduler claims it", () => { + expect(hermesOneShotExecutionState([], jobId)).toBe("pending"); + }); + + it.each(["claimed", "running"])("reports a %s scheduler attempt as pending", (status) => { + expect(hermesOneShotExecutionState([execution(status)], jobId)).toBe("pending"); + }); + + it("reports a completed scheduler attempt", () => { + expect(hermesOneShotExecutionState([execution("completed")], jobId)).toBe("completed"); + }); + + it.each(["failed", "unknown"])("rejects a %s scheduler attempt", (status) => { + expect(() => hermesOneShotExecutionState([execution(status)], jobId)).toThrow( + `Hermes cron execution reached ${status}`, + ); + }); + + it("rejects a direct manual execution", () => { + expect(() => + hermesOneShotExecutionState([execution("completed", { source: "direct" })], jobId), + ).toThrow("Hermes cron execution did not use the built-in scheduler"); + }); + + it("rejects duplicate attempts", () => { + expect(() => + hermesOneShotExecutionState([execution("completed"), execution("completed")], jobId), + ).toThrow("Hermes cron execution history contains multiple attempts"); + }); + + it.each([ + ["non-array payload", {}, "is not an array"], + ["non-object record", [null], "invalid record"], + ["wrong job", [execution("completed", { job_id: "job-2" })], "wrong job"], + ["unsupported status", [execution("cancelled")], "invalid status"], + ])("rejects %s", (_label, payload, message) => { + expect(() => hermesOneShotExecutionState(payload, jobId)).toThrow(message); + }); +}); diff --git a/test/e2e/support/rebuild-hermes-cron-schedule.test.ts b/test/e2e/support/rebuild-hermes-cron-schedule.test.ts new file mode 100644 index 00000000000..723a41d9b04 --- /dev/null +++ b/test/e2e/support/rebuild-hermes-cron-schedule.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { buildHermesRecoveryCronSchedule } from "../live/rebuild-hermes-cron-schedule.ts"; + +describe("Hermes rebuild cron recovery schedule", () => { + it("makes the recovery job due during the stranded-gate probe", () => { + const nowMs = Date.UTC(2026, 7, 5, 17, 27, 59); + + expect(buildHermesRecoveryCronSchedule(nowMs)).toEqual({ + runAt: "2026-08-05T17:28:09.000Z", + runAtMs: nowMs + 10_000, + }); + }); +}); From d89caf67af98d4bf3c8c77174acbaba6f1876a25 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 14:43:13 -0400 Subject: [PATCH 25/25] test(e2e): tolerate Hermes restart status transient Signed-off-by: Julie Yaunches --- test/e2e/live/rebuild-hermes-cron-restore.ts | 25 +++++---- .../rebuild-hermes-gateway-evidence.test.ts | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 test/e2e/support/rebuild-hermes-gateway-evidence.test.ts diff --git a/test/e2e/live/rebuild-hermes-cron-restore.ts b/test/e2e/live/rebuild-hermes-cron-restore.ts index 711011be2de..536f77a6f15 100644 --- a/test/e2e/live/rebuild-hermes-cron-restore.ts +++ b/test/e2e/live/rebuild-hermes-cron-restore.ts @@ -57,7 +57,7 @@ interface GatewayEvidence { active_agents: number; gateway_state: string; pid: number; - running_pid: number; + running_pid: number | null; start_time: number; } @@ -158,6 +158,18 @@ function assertPristineCronJob( } } +export function parseHermesGatewayEvidence(text: string): GatewayEvidence { + const payload = parseJsonObject(text, "Hermes gateway status"); + for (const field of ["active_agents", "pid", "start_time"] as const) { + if (!Number.isSafeInteger(payload[field])) fail(`Hermes gateway ${field} is invalid`); + } + if (payload.running_pid !== null && !Number.isSafeInteger(payload.running_pid)) { + fail("Hermes gateway running_pid is invalid"); + } + if (typeof payload.gateway_state !== "string") fail("Hermes gateway state is invalid"); + return payload as unknown as GatewayEvidence; +} + export function hermesRuntimeExecArgs(sandboxName: string, command: string[]): string[] { // `openshell sandbox exec` intentionally runs inside Landlock, which cannot // read the immutable `/opt/hermes` runtime. These checks need the managed @@ -369,15 +381,6 @@ export function createRebuildHermesCronRestoreFixture({ if (present) expect(result.stdout.trim()).toMatch(/^0:0 400 [1-9]\d*$/u); } - function parseGatewayEvidence(text: string): GatewayEvidence { - const payload = parseJsonObject(text, "Hermes gateway status"); - for (const field of ["active_agents", "pid", "running_pid", "start_time"] as const) { - if (!Number.isSafeInteger(payload[field])) fail(`Hermes gateway ${field} is invalid`); - } - if (typeof payload.gateway_state !== "string") fail("Hermes gateway state is invalid"); - return payload as unknown as GatewayEvidence; - } - async function gatewayEvidence(artifactName: string): Promise { const script = [ "import json", @@ -397,7 +400,7 @@ export function createRebuildHermesCronRestoreFixture({ ].join("\n"); const result = await dockerRoot([HERMES_PYTHON, "-I", "-c", script], artifactName); if (result.exitCode !== 0) return null; - return parseGatewayEvidence(result.stdout.trim()); + return parseHermesGatewayEvidence(result.stdout.trim()); } async function waitForGatewayState( diff --git a/test/e2e/support/rebuild-hermes-gateway-evidence.test.ts b/test/e2e/support/rebuild-hermes-gateway-evidence.test.ts new file mode 100644 index 00000000000..0e111287243 --- /dev/null +++ b/test/e2e/support/rebuild-hermes-gateway-evidence.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { parseHermesGatewayEvidence } from "../live/rebuild-hermes-cron-restore.ts"; + +const liveEvidence = { + active_agents: 0, + gateway_state: "draining", + pid: 263, + running_pid: 263, + start_time: 28_765, +}; + +describe("Hermes rebuild gateway evidence", () => { + it("accepts a live gateway identity", () => { + expect(parseHermesGatewayEvidence(JSON.stringify(liveEvidence))).toEqual(liveEvidence); + }); + + it("accepts stale status while the restarted gateway process is unavailable", () => { + const transient = { ...liveEvidence, running_pid: null }; + + expect(parseHermesGatewayEvidence(JSON.stringify(transient))).toEqual(transient); + }); + + it.each([ + ["missing", undefined], + ["string", "263"], + ["fractional", 263.5], + ])("rejects a %s running process identity", (_label, runningPid) => { + expect(() => + parseHermesGatewayEvidence(JSON.stringify({ ...liveEvidence, running_pid: runningPid })), + ).toThrow("Hermes gateway running_pid is invalid"); + }); + + it.each([ + "active_agents", + "pid", + "start_time", + ] as const)("keeps %s strict while accepting the restart transient", (field) => { + expect(() => + parseHermesGatewayEvidence(JSON.stringify({ ...liveEvidence, [field]: null })), + ).toThrow(`Hermes gateway ${field} is invalid`); + }); + + it("rejects an invalid gateway state", () => { + expect(() => + parseHermesGatewayEvidence(JSON.stringify({ ...liveEvidence, gateway_state: null })), + ).toThrow("Hermes gateway state is invalid"); + }); +});