diff --git a/Dockerfile b/Dockerfile index e66a5f970c8..2f21057936d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -167,6 +167,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 @@ -1103,12 +1104,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/entrypoint-env-wrapper.sh \ /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ @@ -1830,6 +1833,7 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root:444' \ && 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 5d90e53b867..4ef5bf81b1d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -137,6 +137,7 @@ COPY agents/hermes/mcp-config-transaction.py /usr/local/lib/nemoclaw/hermes-mcp- COPY agents/hermes/cron-restore-control.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.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 @@ -359,10 +360,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/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /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/hermes-cron-restore-control.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/hermes-cron-restore-control.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/hermes-cron-restore-control.py \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ - && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ + && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/share/nemoclaw/state-lock-plan.json /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 \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ @@ -1146,6 +1147,7 @@ RUN check_metadata() { \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ && check_metadata /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root 444' \ && 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/hermes-cron-restore-control.py 'root:root 700' \ && 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' \ diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index f8950bc443c..49d25887a0f 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -57,40 +57,63 @@ 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 # ── 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 - - skills - - plugins - - cron + - path: skills + shields: read-only + - path: plugins + shields: read-only + - path: cron + shields: read-only # User-authored cron scripts are part of the job definition contract. Keep # them in the same rebuild backup so active jobs never outlive their inputs. - scripts + # 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 + writable_subpaths: + - dashboard-home - cache - - pairing + - path: pairing + shields: confidential # Legacy pre-#7200 dashboard profile location. Keep it in snapshots while # startup migrates existing state into profiles/dashboard-home. - dashboard-home # 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..46c8cbd3af7 --- /dev/null +++ b/agents/hermes/state-lock-plan.json @@ -0,0 +1,23 @@ +{ + "$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": [ + "profiles/dashboard-home" + ] +} diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index d6a2d1cf915..ff83edadc70 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -41,10 +41,13 @@ 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 - - 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 74093cf7a1d..369b15d159e 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -45,34 +45,56 @@ config: format: json # ── State directories ────────────────────────────────────────── +state_lock_plan_in_image: true 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 + # OpenClaw may create profile-scoped runtime state. Preserve its existing + # Shields boundary without newly treating profile data as portable state. + - path: profiles + 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..01df792407e --- /dev/null +++ b/agents/openclaw/state-lock-plan.json @@ -0,0 +1,31 @@ +{ + "$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", + "profiles", + "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 43043ec916f..c5d00279b51 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 596906e3c02..9e58aa3302f 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": 4817, + "test/nemoclaw-start.test.ts": 4791, "test/onboard-messaging.test.ts": 2043, "test/onboard-selection.test.ts": 4769 } diff --git a/docs/index.yml b/docs/index.yml index 88f3bfd6e86..8394439c30f 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -687,6 +687,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 2d4c8de57f8..4bb9caa60e1 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. @@ -81,10 +81,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 c06ff606279..b05ecba339f 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -290,25 +290,25 @@ 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`, `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. 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 `credentials`, `identity`, and `pairing` directories. +The same workflow locks the secret-bearing `credentials` and `identity` directories. The guard sets each confidentiality root to `root:sandbox 0710`, including a non-empty `credentials` directory. It sets every nested directory and file to `root:root` and removes all group and world permission bits. The sandbox group cannot list, create, or remove entries in a confidentiality root, and neither the sandbox user nor gateway can read stored secrets. They can inspect metadata for a direct child only when they already know its name. Probing a missing direct child, such as the legacy `credentials/oauth.json`, returns `ENOENT` instead of `EACCES`. 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. @@ -348,9 +348,15 @@ 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. - -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`. +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`. +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. +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`. @@ -371,6 +377,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 3ad81aa0b53..bb44615f3b1 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. + 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" -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,11 +25,17 @@ 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 `/usr/local/lib/nemoclaw/state-dir-guard.py` for current OpenClaw and Hermes images and historical images that include this helper. + An agent manifest with `state_lock_plan_in_image: false` selects authenticated root execution of the host helper and manifest-derived plan. + Historical images that contain neither installed path use the same execution path. + Neither case writes the helper or plan to mutable sandbox state. +- A filesystem transition fails closed if the image contains `/usr/local/share/nemoclaw/state-lock-plan.json` but `/usr/local/lib/nemoclaw/state-dir-guard.py` is missing. + It also fails closed if the installed plan differs from the current agent manifest. - 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. @@ -41,7 +47,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 install a root-owned, mode `0500` copy at `/usr/local/lib/nemoclaw/state-dir-guard.py` and a generated plan at `/usr/local/share/nemoclaw/state-lock-plan.json`. A historical image that has the helper but no plan uses its co-bundled helper. An agent manifest with `state_lock_plan_in_image: false` selects the trusted host CLI copy over standard input to `python3 -I -` and a manifest-derived plan through authenticated root execution. Historical images that contain neither installed file use the same execution path. | 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 filesystem transition for an image that contains the generated plan if the installed helper is missing or the plan differs from the current agent manifest. | | `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. | @@ -77,9 +83,10 @@ 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. +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 @@ -136,5 +143,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. -Final-image validation must cover both built-in images. +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 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/package.json b/package.json index 5647788d063..6de43ffcf79 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,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", @@ -109,6 +110,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..965c1349b99 --- /dev/null +++ b/scripts/lib/generate-agent-state-lock-plans.mts @@ -0,0 +1,29 @@ +// 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"; + +const { listAgents, loadAgent } = await import("../../src/lib/agent/defs"); + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const SPDX_COMMENT = + "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0"; + +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, + ...agent.stateLockPlan, + }, + 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 f23468359bd..d8b365fe8c1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -835,7 +835,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 } @@ -4182,16 +4183,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 @@ -4341,6 +4332,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 @@ -4350,33 +4361,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 87e378229f9..73704ef8e1b 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 @@ -68,7 +46,6 @@ PRODUCTION_FAIL_CLOSED_CONFIG_DIRS = frozenset( {"/sandbox/.openclaw", "/sandbox/.hermes", "/sandbox/.deepagents"} ) -DASHBOARD_PROFILE_RUNTIME_CARVEOUT = "profiles/dashboard-home" OPENCLAW_MUTATION_MUTEX_PATH = "/run/nemoclaw/openclaw-config-mutation.lock" # Keep this exact source/target contract aligned with # src/lib/state/openclaw-managed-extensions.ts. @@ -85,6 +62,20 @@ 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 +HERMES_PRIVATE_WRITABLE_SUBPATH = "profiles/dashboard-home" FS_IMMUTABLE_FL = 0x00000010 FS_APPEND_FL = 0x00000020 FS_IOC_GETFLAGS = 0x80086601 @@ -94,6 +85,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 @@ -222,44 +238,177 @@ def account_copy(self, path: str, size: int) -> None: ) -def _is_runtime_carveout(config_path: str, relative_path: str) -> bool: - """Return whether this agent path is a writable runtime-state 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 + +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 ( - posixpath.basename(config_path) == ".hermes" - and relative_path == DASHBOARD_PROFILE_RUNTIME_CARVEOUT + value in {"", ".", ".."} + or SAFE_TOP_LEVEL_RE.fullmatch(value) is None + or "/" in value + or "\\" in value ): - return True - parts = relative_path.split("/") - return ( - len(parts) == 3 - and parts[0] == "agents" - and parts[1] not in {"", ".", ".."} - and parts[2] == "sessions" - ) + raise PlanValidationError( + f"plan field {field!r} must contain one safe top-level name" + ) -def _is_under_runtime_carveout(config_path: str, relative_path: str) -> bool: - if posixpath.basename(config_path) == ".hermes" and ( - relative_path == DASHBOARD_PROFILE_RUNTIME_CARVEOUT - or relative_path.startswith(f"{DASHBOARD_PROFILE_RUNTIME_CARVEOUT}/") +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 ): - return True - parts = relative_path.split("/") - return ( - len(parts) >= 3 - and parts[0] == "agents" - and parts[1] not in {"", ".", ".."} - and parts[2] == "sessions" + 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 + # A matching shorter pattern is a shared-prefix overlap. + for left, right in zip(first, second, strict=False) ) -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(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: + 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: @@ -399,16 +548,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: @@ -417,7 +561,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: @@ -452,7 +596,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)) @@ -460,7 +604,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. @@ -472,7 +619,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) @@ -518,6 +665,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) @@ -528,6 +676,42 @@ 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_private_writable_root(self, relative_path: str) -> bool: + return ( + posixpath.basename(self.config_path) == ".hermes" + and relative_path == HERMES_PRIVATE_WRITABLE_SUBPATH + ) + + 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, @@ -608,14 +792,14 @@ def _resolve_internal_symlink( relative = _normalize_link_target( context, posixpath.dirname(link_relative_path), target ) - if _is_under_runtime_carveout( - context.config_path, link_relative_path - ) or _is_under_runtime_carveout(context.config_path, 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 a writable runtime carveout", + "symlinks may not enter or originate in a writable state subpath", ) ) seen: set[str] = set() @@ -690,12 +874,12 @@ def _resolve_internal_symlink( "expanded symlink target leaves protected roots", ) ) - if _is_under_runtime_carveout(context.config_path, relative): + if context.is_under_writable_root(relative): raise GuardOperationError( Issue( "symlink-crosses-runtime-carveout", context.display(link_relative_path), - "symlink chain enters a writable runtime carveout", + "symlink chain enters a writable state subpath", ) ) restart = True @@ -810,10 +994,11 @@ def _scan_dir( ) continue if stat.S_ISDIR(st.st_mode): - # Runtime carve-out contents are intentionally outside the shields - # integrity boundary and remain live while shields are up. Validate - # the root as an in-tree directory, but do not traverse mutable state. - if _is_runtime_carveout(context.config_path, 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) @@ -857,8 +1042,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, @@ -866,6 +1052,7 @@ def _preflight( config_dev, protected_roots, WorkBudget(deadline), + plan.writable_subpaths, ) for root in roots: path = context.display(root.name) @@ -946,14 +1133,20 @@ def _set_dir_metadata( os.fchmod(dir_fd, _expected_dir_mode(policy, action, is_confidentiality_root)) -def _set_runtime_carveout_metadata( - dir_fd: int, relative_path: str, policy: Policy, identity: Identity +def _set_writable_subpath_metadata( + context: TraversalContext, + dir_fd: int, + relative_path: str, + policy: Policy, + identity: Identity, ) -> None: - if relative_path == DASHBOARD_PROFILE_RUNTIME_CARVEOUT: + if context.is_private_writable_root(relative_path): os.fchown(dir_fd, identity.sandbox_uid, identity.sandbox_gid) os.fchmod(dir_fd, 0o700) return _set_dir_metadata(dir_fd, policy, "unlock", identity) + + def _set_empty_credentials_startup_metadata( dir_fd: int, identity: Identity, @@ -1293,30 +1486,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 @@ -1325,33 +1515,35 @@ 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 try: - _set_dir_metadata(child_fd, policy, "unlock", identity) + _set_writable_subpath_metadata( + context, child_fd, relative_path, policy, identity + ) except OSError as exc: raise GuardOperationError( _os_issue( "metadata-update-failed", path, - "prepare writable sessions carveout", + "prepare writable state subpath", exc, ) ) from exc @@ -1441,13 +1633,13 @@ def _mutate_dir( ) ) from exc try: - if _is_runtime_carveout(context.config_path, 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) - _set_runtime_carveout_metadata( - child_fd, relative_path, policy, identity + _set_writable_subpath_metadata( + context, child_fd, relative_path, policy, identity ) result.directories += 1 else: @@ -1543,10 +1735,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: @@ -1633,16 +1832,15 @@ def _verify_metadata( return None -def _verify_runtime_carveout_metadata( +def _verify_writable_subpath_metadata( + context: TraversalContext, path: str, relative_path: str, st: os.stat_result, policy: Policy, identity: Identity, ) -> Issue | None: - """Verify a writable runtime root without traversing its live contents.""" - - if relative_path != DASHBOARD_PROFILE_RUNTIME_CARVEOUT: + if not context.is_private_writable_root(relative_path): return _verify_metadata(path, st, "directory", policy, "unlock", identity) if st.st_uid != identity.sandbox_uid or st.st_gid != identity.sandbox_gid: return Issue( @@ -1734,16 +1932,17 @@ def _verify_dir( ) continue try: - if _is_runtime_carveout(context.config_path, relative_path): - carveout_issue = _verify_runtime_carveout_metadata( + if context.is_writable_root(relative_path): + writable_issue = _verify_writable_subpath_metadata( + context, path, relative_path, os.fstat(child_fd), policy, 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, @@ -1804,6 +2003,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.""" @@ -1811,6 +2011,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) @@ -1918,6 +2127,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.""" @@ -1926,7 +2136,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 @@ -1962,7 +2172,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( @@ -1971,6 +2181,7 @@ def _run_guard_unserialized( config_st.st_dev, deadline, action, + plan, ) result.roots = len(roots) result.issues.extend(issues) @@ -1980,7 +2191,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} @@ -2001,6 +2212,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: @@ -2044,7 +2256,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} @@ -2170,13 +2382,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: @@ -2189,7 +2402,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) @@ -2229,30 +2442,75 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: ) parser.add_argument("action", choices=("preflight", "lock", "unlock", "startup")) 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: + try: + plan = _load_plan(args) + except PlanValidationError as exc: result = GuardResult(action=args.action) - result.issues.append( - Issue("root-required", args.config_dir, "state-dir guard must run as root") - ) + result.issues.append(Issue("invalid-plan", args.config_dir, str(exc))) else: - try: - identity = _production_identity() - except KeyError as exc: + 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) + 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) for issue in result.issues: print(json.dumps(issue.as_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/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/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/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/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..663dc314835 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,7 @@ export interface AgentDefinition { config?: ManifestRecord; inference?: AgentInference; mcp?: AgentMcpCapability; - state_dirs?: string[]; - runtime_auth_state_dirs?: string[]; + state_lock_plan_in_image?: boolean; state_files?: AgentStateFile[]; user_managed_files?: string[]; _legacy_paths?: StringMap; @@ -135,8 +164,15 @@ 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 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 016dc59f92b..74b4d4bec2d 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -105,6 +105,65 @@ 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("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"], + ])("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..aeaeb13cbb6 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, @@ -36,6 +39,7 @@ import { readObject, readPortArray, readStateFiles, + readStateLockPlanInImage, readString, readStringArray, readStringMap, @@ -43,6 +47,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 +67,13 @@ export type { AgentMcpAdapter, AgentMcpCapability, AgentMcpSupport, + AgentStateDirectory, + AgentStateDirectoryPath, + AgentStateDirectoryPrefix, + AgentStateDirectoryShields, AgentStateFile, AgentStateFileStrategy, + AgentStateLockPlan, AgentVersionScheme, StateFileFreshHeader, StateFileKeyAllowlistRestoreOwnership, @@ -153,17 +168,23 @@ 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 stateLockPlanInImage = readStateLockPlanInImage(raw); const stateFiles = readStateFiles(raw); const userManagedFiles = readUserManagedFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); @@ -188,8 +209,7 @@ export function loadAgent(name: string): AgentDefinition { config, inference, mcp, - state_dirs: stateDirs, - runtime_auth_state_dirs: runtimeAuthStateDirs, + state_lock_plan_in_image: stateLockPlanInImage, state_files: stateFiles, user_managed_files: userManagedFiles, _legacy_paths: legacyPathConfig, @@ -238,6 +258,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 +270,40 @@ 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 stateLockPlan(): AgentStateLockPlan { + return stateLockPlan; }, - get runtimeAuthStateDirs(): string[] { - return runtimeAuthStateDirs ?? []; + get stateLockPlanInImage(): boolean { + return stateLockPlanInImage; }, 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..db4ee60d5f8 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -23,14 +23,29 @@ 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: [], + }, + stateLockPlanInImage: false, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", @@ -63,5 +78,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..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; @@ -63,6 +72,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..a5aa5633df3 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -25,14 +25,29 @@ 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: [], + }, + stateLockPlanInImage: false, 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 a1b06767de8..e49d7285dcb 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -21,11 +21,26 @@ 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: [], + }, + stateLockPlanInImage: false, stateFiles: [], userManagedFiles: [], versionCommand: "test-agent --version", @@ -58,6 +73,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..0ddb5a44fae --- /dev/null +++ b/src/lib/agent/state-directory-contract.test.ts @@ -0,0 +1,236 @@ +// 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 { listAgents, 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", "profiles", "identity", "devices"]); + expect(agent.backupStateDirs).not.toEqual( + expect.arrayContaining(["plugins", "profiles", "identity", "devices"]), + ); + expect(agent.stateDirs).toEqual( + expect.arrayContaining(["plugins", "profiles", "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", + "profiles", + "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: ["profiles/dashboard-home"], + }, + expectedMutablePaths: [ + "memories", + "sessions", + "scripts", + "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)", () => { + 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"), + "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: ["*"] }], + }, + /literal directory name/, + ], + [ + { + 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..a0cd3739d3f --- /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.split("/").at(-1) === "*") { + 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/command-support.ts b/src/lib/onboard/command-support.ts index 7f6382e82bf..958174b79aa 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -15,7 +15,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 350215a41bc..eeaf3d8975f 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -59,6 +59,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", "c0b409e1bf4d33a9e44f407c6bd9b0445b2ffd0b796823fe3cfa5989314d6603", + "9fcc674a44a152707380cdb09a67f8594f568288406c96f5354f1c87f5b939a6", "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", "ca1f7b1cb9dd5d467f806792c4072a84ef1e6402c3e8650b6325b95cc186ccdf", "4165899eb1f0f948f8883eddf4136136caac21cee1df39b12afea7672b23a378", 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..0c83a3eea85 100644 --- a/src/lib/sandbox/agent-config.test.ts +++ b/src/lib/sandbox/agent-config.test.ts @@ -2,12 +2,36 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import type { AgentStateLockPlan } from "../agent/definition-types"; import { type AgentConfigDependencies, - DEFAULT_AGENT_CONFIG, resolveAgentConfig, + resolveAgentStateLockContract, } 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, + stateLockPlanInImage: true, + }; +} + function dependencies(overrides: Partial = {}): AgentConfigDependencies { return { getSandbox: vi.fn(() => null), @@ -19,16 +43,30 @@ 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 state lock contract for an explicit agent", () => { + const loadAgent = vi.fn(() => openClawAgent()); + + expect(resolveAgentStateLockContract("openclaw", loadAgent)).toEqual({ + stateLockPlan: PLAN, + stateLockPlanInImage: true, + }); + expect(loadAgent).toHaveBeenCalledWith("openclaw"); + }); + + 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, + stateLockPlanInImage: true, + }); + expect(loadAgent).toHaveBeenCalledWith("openclaw"); }); it("propagates a registered agent load failure", () => { @@ -42,11 +80,69 @@ 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, stateLockPlanInImage: true })), + }); + + 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, + stateLockPlanInImage: true, })), }); @@ -56,7 +152,97 @@ 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, + stateLockPlanInImage: true, + }); + }); + + 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, + stateLockPlanInImage: false, + })), }); + + 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, + stateLockPlanInImage: true, + })), + }); + + 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, + stateLockPlanInImage: true, + })), + }); + + 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, + stateLockPlanInImage: false, + })), + }); + + 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..8bfed324527 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,22 @@ export interface AgentConfigTarget { format: string; configFile: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; } 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; + stateLockPlanInImage: boolean; }; } @@ -24,6 +40,7 @@ export const DEFAULT_AGENT_CONFIG: AgentConfigTarget = { format: "json", configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlanInImage: true, }; function defaultDependencies(): AgentConfigDependencies { @@ -32,26 +49,95 @@ function defaultDependencies(): AgentConfigDependencies { return { getSandbox: registry.getSandbox, loadAgent: agentDefs.loadAgent }; } +export function resolveAgentStateLockContract( + agentName: string, + loadAgent: AgentConfigDependencies["loadAgent"] = defaultDependencies().loadAgent, +): Pick { + const agent = loadAgent(agentName); + return { + stateLockPlan: agent.stateLockPlan, + stateLockPlanInImage: agent.stateLockPlanInImage, + }; +} + +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, + stateLockPlanInImage: agent.stateLockPlanInImage, }; } diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 28fb6e036f4..be647f6f52d 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/sandbox/hermes-dashboard-reseed.test.ts b/src/lib/sandbox/hermes-dashboard-reseed.test.ts index 0094ba4d904..afdad6c7fe5 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/auto-restore-target.test.ts b/src/lib/shields/auto-restore-target.test.ts index 8a1ef4ed3e6..fa0a04ecf93 100644 --- a/src/lib/shields/auto-restore-target.test.ts +++ b/src/lib/shields/auto-restore-target.test.ts @@ -13,6 +13,7 @@ describe("persisted auto-restore target resolution", () => { configFile: "openclaw.json", format: "json" as const, sensitiveFiles: ["/sandbox/.openclaw/credentials.json"], + stateLockPlanInImage: true, }; expect( @@ -37,6 +38,7 @@ describe("persisted auto-restore target resolution", () => { configDir: "/sandbox/.openclaw", configFile: "openclaw.json", format: "json" as const, + stateLockPlanInImage: true, }; expect( @@ -52,6 +54,7 @@ describe("persisted auto-restore target resolution", () => { configPath: "/sandbox/.legacy/config.json", configDir: "/sandbox/.legacy/", sensitiveFiles: ["/sandbox/.legacy/.config-hash"], + stateLockPlanInImage: false, }); }); @@ -62,6 +65,7 @@ describe("persisted auto-restore target resolution", () => { configDir: "/sandbox/.hermes/", configFile: "config.yaml", format: "yaml" as const, + stateLockPlanInImage: true, }; expect( @@ -79,6 +83,8 @@ describe("persisted auto-restore target resolution", () => { configPath: "/sandbox/.hermes/config.yaml", configDir: "/sandbox/.hermes/", sensitiveFiles: ["/sandbox/.hermes/.config-hash", "/sandbox/.hermes/.env"], + stateLockPlan: expect.any(Object), + stateLockPlanInImage: true, }); }); @@ -100,6 +106,7 @@ describe("persisted auto-restore target resolution", () => { configPath: "/sandbox/.legacy/config.json", configDir: "/sandbox/.legacy/", sensitiveFiles: ["/sandbox/.legacy/.config-hash"], + stateLockPlanInImage: false, }); }); }); diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index cdf9ca0f398..9eca3723796 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -37,12 +37,32 @@ vi.mock("../policy", () => ({ })); vi.mock("../sandbox/agent-config", () => ({ + resolveAgentStateLockContract: vi.fn(() => ({ + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + stateLockPlanInImage: true, + })), resolveAgentConfig: vi.fn(() => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", format: "json", configFile: "openclaw.json", + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + stateLockPlanInImage: true, })), })); @@ -597,6 +617,60 @@ describe("shields — unit logic", () => { expect(appliedPolicy).not.toContain("mcp_bridge_alpha"); }); + it("auto-restore applies a snapshot with no managed MCP entries when policy staging is unavailable (#7952)", async () => { + 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( + 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"); + writeState(sandboxName, { + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: [], + }); + writeMarker(sandboxName, { + pid: 2_147_483_647, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 1_000).toISOString(), + processToken, + }); + vi.spyOn(process, "kill").mockImplementation(routeProcessKill); + const { applyShieldsPolicySnapshot } = await loadShieldsModule(); + const createTempDirectory = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => { + throw Object.assign(new Error("ENOSPC: simulated temporary storage full"), { + code: "ENOSPC", + }); + }); + + const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + expiredTimerRecovery: true, + }); + + expect(result.status).toBe(0); + expect(createTempDirectory).not.toHaveBeenCalled(); + expect(fs.readFileSync(openshellArgvPath, "utf-8").trim().split("\n")).toEqual([ + "policy", + "set", + "--policy", + snapshotPath, + "--wait", + sandboxName, + ]); + }); + it("reuses the snapshot without staging when the snapshot and current policy have no managed MCP entries (#7952)", async () => { const snapshotPath = "/state/policy-snapshot-no-managed-mcp.yaml"; const snapshotYaml = "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n"; @@ -898,10 +972,12 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + stateLockPlanInImage: true, }), }), ).toThrow("exit 2"); @@ -928,6 +1004,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", @@ -967,6 +1044,7 @@ describe("shields — unit logic", () => { const { shieldsStatus } = await loadShieldsModule(); shieldsStatus(sandboxName, true, { + verifyStateLockPlan: () => [], verifyLockState: ( _name: string, _target: unknown, @@ -1004,6 +1082,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -1039,6 +1118,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -1069,6 +1149,7 @@ describe("shields — unit logic", () => { expect(() => shieldsStatus(sandboxName, true, { verifyLockState: () => ({ ok: false, issues: driftIssues }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", @@ -1099,6 +1180,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 e65175ce17d..d7681838775 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -54,7 +54,10 @@ const { } = require("./timer-control"); const { resolveNemoclawStateDir } = require("../state/paths"); const { appendAuditEntry } = require("./audit"); -const { resolveAgentConfig } = require("../sandbox/agent-config"); +const { + resolveAgentConfig, + resolveAgentStateLockContract, +}: typeof import("../sandbox/agent-config") = require("../sandbox/agent-config"); const { assertLegacyMcpPolicyRestoreSafe, buildDeadlineRuntimeManagedMcpPolicy, @@ -95,6 +98,7 @@ const { preflightStateDirLock, restoreStateDirLockPosture, restoreStateDirStartupAccess, + stateLockPlanCompatibilityIssues, }: typeof import("./state-dir-lock") = require("./state-dir-lock"); const { OPENCLAW_CONFIG_DIR, @@ -111,6 +115,7 @@ const { }: typeof import("./mutable-config-repair") = require("./mutable-config-repair"); type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConfigPermsInspection; type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult; +type AgentStateLockPlan = import("../agent/definition-types").AgentStateLockPlan; type ManagedMcpPolicyOmission = import("./permissive-runtime").ManagedMcpPolicyOmission; type TimerMarker = import("./timer-control").TimerMarker; const STATE_DIR = resolveNemoclawStateDir(); @@ -490,9 +495,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", @@ -501,6 +506,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", @@ -511,7 +520,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, [ @@ -526,8 +535,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"; @@ -570,7 +582,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( @@ -679,13 +691,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, @@ -755,8 +772,20 @@ type AgentConfigTarget = { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }; +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; +} + const DEEP_AGENTS_NAME = "langchain-deepagents-code"; const DEEP_AGENTS_CONFIG_DIR = "/sandbox/.deepagents"; const DEEP_AGENTS_CONFIG_PATH = `${DEEP_AGENTS_CONFIG_DIR}/config.toml`; @@ -801,6 +830,18 @@ function ensureConfigHashSensitiveFile(target: T): if (sensitiveFiles.includes(hashPath)) return target; return { ...target, sensitiveFiles: [...sensitiveFiles, hashPath] } as T; } +function loadMarkerAgentStateLockPlan( + agentName: string | undefined, +): Pick { + if (!agentName) return { stateLockPlanInImage: false }; + try { + return resolveAgentStateLockContract(agentName); + } catch { + // A marker path remains authoritative, but a missing agent definition + // cannot authorize a state-directory mutation. + return { stateLockPlanInImage: false }; + } +} function resolvePersistedAutoRestoreTarget( sandboxName: string, @@ -817,6 +858,7 @@ function resolvePersistedAutoRestoreTarget( configHashPath(marker.configDir), ...(marker.agentName === "hermes" ? [`${marker.configDir.replace(/\/+$/, "")}/.env`] : []), ], + ...loadMarkerAgentStateLockPlan(marker.agentName), }; try { @@ -1327,10 +1369,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) { @@ -2087,6 +2128,14 @@ function unlockAgentConfigUnderMutationLock( protocol: HermesShieldsProtocol, ): void { const target = ensureConfigHashSensitiveFile(rawTarget); + const compatibilityIssues = stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + requireStateLockPlan(target), + target.stateLockPlanInImage, + ); + 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 @@ -2136,13 +2185,15 @@ 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), + target.stateLockPlanInImage, ); for (const issue of stateDirUnlockIssues) errors.push(`state dir unlock: ${issue}`); } @@ -2242,6 +2293,7 @@ function unlockAgentConfigUnderMutationLock( target, transaction.token, transaction.rollbackLocked ? "lock" : "unlock", + protocol, ); abortHermesConfigShields(sandboxName, target, transaction.token); } catch (abortError) { @@ -2270,6 +2322,8 @@ function unlockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), + target.stateLockPlanInImage, ), ); } catch (rollbackError) { @@ -2300,6 +2354,8 @@ function unlockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), + target.stateLockPlanInImage, ), ); } catch (rollbackError) { @@ -2413,7 +2469,11 @@ function restoreLockedStateDirStartupAccess(sandboxName: string): void { const posture = getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery); 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(", ")}`); } @@ -2465,6 +2525,14 @@ function lockAgentConfigUnderMutationLock( protocol: HermesShieldsProtocol, ): { chattrApplied: boolean; fileHashes: { [path: string]: string } } { const target = ensureConfigHashSensitiveFile(rawTarget); + const compatibilityIssues = stateLockPlanCompatibilityIssues( + stateDirLockExec(sandboxName), + requireStateLockPlan(target), + target.stateLockPlanInImage, + ); + 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"; @@ -2489,7 +2557,12 @@ function lockAgentConfigUnderMutationLock( !deepAgentsProtocol && (target.agentName !== "hermes" || legacyHermesProtocol) ) { - const preflightIssues = preflightStateDirLock(stateDirLockExec(sandboxName), target.configDir); + const preflightIssues = preflightStateDirLock( + stateDirLockExec(sandboxName), + target.configDir, + requireStateLockPlan(target), + target.stateLockPlanInImage, + ); if (preflightIssues.length > 0) { throw new Error(`Config not locked: ${preflightIssues.join(", ")}`); } @@ -2555,13 +2628,15 @@ 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), + target.stateLockPlanInImage, ); if (stateDirLockIssues.length > 0) { throw new Error(`Config not locked: ${stateDirLockIssues.join(", ")}`); @@ -2642,6 +2717,7 @@ function lockAgentConfigUnderMutationLock( target, transaction.token, transaction.rollbackLocked ? "lock" : "unlock", + protocol, ); abortHermesConfigShields(sandboxName, target, transaction.token); } catch (abortError) { @@ -2668,6 +2744,8 @@ function lockAgentConfigUnderMutationLock( stateDirLockExec(sandboxName), target.configDir, rollbackLocked, + requireStateLockPlan(target), + target.stateLockPlanInImage, ).map((message) => ({ message, readinessFailure: false })), ); } catch (rollbackError) { @@ -2706,7 +2784,13 @@ function lockAgentConfigUnderMutationLock( if (rollbackLocked) { try { rollbackIssues.push( - ...restoreStateDirLockPosture(stateDirLockExec(sandboxName), target.configDir, true), + ...restoreStateDirLockPosture( + stateDirLockExec(sandboxName), + target.configDir, + true, + requireStateLockPlan(target), + target.stateLockPlanInImage, + ), ); } catch (rollbackError) { rollbackIssues.push( @@ -4186,6 +4270,7 @@ function shieldsUp( type ShieldsStatusDeps = { verifyLockState?: typeof verifyShieldsLockState; resolveConfig?: typeof resolveAgentConfig; + verifyStateLockPlan?: (sandboxName: string, target: AgentConfigTarget) => string[]; }; function shieldsStatusWithoutHostLock( @@ -4228,15 +4313,36 @@ 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; + 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}`]; @@ -4264,12 +4370,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..16b3353f57a 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,8 @@ function hermesTarget() { format: "yaml", configFile: "config.yaml", sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"], + stateLockPlan: STATE_LOCK_PLAN, + stateLockPlanInImage: true, }; } @@ -108,6 +132,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 +285,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 +295,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..faf6e9e6ebb 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,8 @@ function openClawTarget() { format: "json", configFile: "openclaw.json", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: STATE_LOCK_PLAN, + stateLockPlanInImage: true, }; } @@ -33,6 +44,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 +99,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 +109,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 +136,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 a0f6eadbbb6..6e9b6f394bd 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -56,6 +56,15 @@ describe("shields policy transition", () => { configFile: "config.json", configPath: "/sandbox/.deepagents/config.json", format: "json", + stateLockPlan: { + version: 1, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }, + stateLockPlanInImage: false, }); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -199,10 +208,14 @@ describe("shields down policy rejection", () => { harness.errorSpy.mockClear(); harness.shieldsStatus("openclaw", true, { verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], resolveConfig: () => ({ agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", + stateLockPlanInImage: true, }), }); @@ -269,6 +282,9 @@ describe("shields down policy rejection", () => { agentName: "openclaw", configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", + configFile: "openclaw.json", + format: "json", + stateLockPlanInImage: true, }), }), ).toThrow("process exit 1"); @@ -310,6 +326,15 @@ 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: [], + }, + stateLockPlanInImage: false, }; } @@ -697,7 +722,13 @@ describe("shields config lock without a shipped config hash", () => { expect(lockCalls[0].slice(4)).toEqual([CONFIG_DIR, CONFIG_PATH]); expect(unlockCalls).toHaveLength(0); - expect(restoreStateDirLockPostureSpy).toHaveBeenCalledWith(expect.anything(), CONFIG_DIR, true); + expect(restoreStateDirLockPostureSpy).toHaveBeenCalledWith( + expect.anything(), + CONFIG_DIR, + true, + target().stateLockPlan, + false, + ); expect(stateDirGuardActions).toEqual(["preflight", "lock"]); expect(entries.get(CONFIG_DIR)).toEqual({ mode: "755", owner: "root:root" }); expect(entries.get(CONFIG_PATH)).toEqual({ mode: "444", owner: "root:root" }); diff --git a/src/lib/shields/state-dir-lock.test.ts b/src/lib/shields/state-dir-lock.test.ts index 022333c6204..cdef49e9573 100644 --- a/src/lib/shields/state-dir-lock.test.ts +++ b/src/lib/shields/state-dir-lock.test.ts @@ -1,17 +1,30 @@ // 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"; import { applyStateDirLockMode, + CONTAINER_STATE_LOCK_PLAN, preflightStateDirLock, restoreStateDirLockPosture, restoreStateDirStartupAccess, + 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", @@ -25,7 +38,13 @@ 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, @@ -35,11 +54,20 @@ function createExec(installed = true): { calls: RunCall[]; privileged: Privilege switch (cmd[0]) { case "test": return { - status: installed ? 0 : 1, + 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]; @@ -67,23 +95,75 @@ 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, 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)).toEqual([]); + expect(restoreStateDirLockPosture(privileged, "/sandbox/.hermes", false, PLAN, true)).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, true), + ).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, true), + ).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, true), + ).toEqual([]); - expect(applyStateDirLockMode(privileged, "/sandbox/.openclaw", "root:sandbox", true)).toEqual( - [], - ); const invocation = calls.find(({ cmd }) => cmd.includes("python3")); expect(invocation?.cmd).toEqual([ "timeout", @@ -96,6 +176,8 @@ 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"); }); @@ -103,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", @@ -116,16 +198,136 @@ 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(); + + expect( + applyStateDirLockMode(privileged, "/sandbox/.deepagents", "root:sandbox", true, PLAN, false), + ).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, true), + ).toEqual([ + "state-dir guard is unavailable in an image that contains a generated state lock plan", + ]); + expect(actions(calls)).toEqual([]); + }); + + it.each([ + ["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) => + cmd[0] === "test" + ? { status: 0, signal: null, stdout: "", stderr: "" } + : { status: 0, signal: null, stdout: payload, stderr: "" }, + }; + + expect(stateLockPlanCompatibilityIssues(privileged, PLAN, true)).toEqual([ + expect.stringMatching(expected), + ]); + }); + + 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, 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) => { 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, @@ -149,7 +351,7 @@ describe("recursive state-dir lock host wiring", () => { }, }; - expect(preflightStateDirLock(privileged, "/sandbox/.openclaw")).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 76255747bda..b17302d4b20 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,37 +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"]; -// The dashboard profile entry applies only to the Hermes configuration root. -export const WRITABLE_RUNTIME_SUBPATHS = ["agents/*/sessions", "profiles/dashboard-home"]; - -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" | "startup"; @@ -78,6 +60,98 @@ 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]].sort()) === JSON.stringify([...expected[field]].sort()), + ); +} + +type RuntimePlanInspection = + | { kind: "image-current" } + | { kind: "host-current" } + | { kind: "historical" } + | { kind: "error"; issue: string }; + +function inspectRuntimePlan( + privileged: PrivilegedExec, + expected: AgentStateLockPlan, + stateLockPlanInImage: boolean, +): RuntimePlanInspection { + 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" }; + } + 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, + expected: AgentStateLockPlan, + stateLockPlanInImage: boolean, +): string[] { + const inspection = inspectRuntimePlan(privileged, expected, stateLockPlanInImage); + return inspection.kind === "error" ? [inspection.issue] : []; +} + function parseGuardOutput(action: GuardAction, result: PrivilegedExecResult): string[] { const issues: GuardIssue[] = []; const summaries: GuardSummary[] = []; @@ -170,37 +244,63 @@ function runStateDirGuard( privileged: PrivilegedExec, action: GuardAction, configDir: string, + plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): 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, plan, stateLockPlanInImage); + 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}`]; + } + + // Agent definitions without an image plan, plus images predating the helper, + // 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", + "-I", + "-", + action, + "--config-dir", + configDir, + "--plan-json", + JSON.stringify(plan), + ]; return parseGuardOutput(action, privileged.run(command, input)); } @@ -208,6 +308,7 @@ function runHostStateDirGuard( privileged: PrivilegedExec, action: GuardAction, configDir: string, + plan: AgentStateLockPlan, ): string[] { let input: string; try { @@ -216,15 +317,30 @@ 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)); } // 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, + stateLockPlanInImage: boolean, +): string[] { + return runStateDirGuard(privileged, "preflight", configDir, plan, stateLockPlanInImage); } // Apply and independently verify the complete recursive state-dir posture. @@ -235,6 +351,8 @@ export function applyStateDirLockMode( configDir: string, highRiskOwner: string, isLocking: boolean, + plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { const expectedOwner = isLocking ? "root:sandbox" : "sandbox:sandbox"; if (highRiskOwner !== expectedOwner) { @@ -242,20 +360,42 @@ 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, + stateLockPlanInImage, + ); } export function restoreStateDirLockPosture( privileged: PrivilegedExec, configDir: string, originallyLocked: boolean, + plan: AgentStateLockPlan, + stateLockPlanInImage: boolean, ): string[] { if (!originallyLocked) { - return applyStateDirLockMode(privileged, configDir, "sandbox:sandbox", false); + return applyStateDirLockMode( + privileged, + configDir, + "sandbox:sandbox", + false, + plan, + stateLockPlanInImage, + ); } - const preflightIssues = preflightStateDirLock(privileged, configDir); + const preflightIssues = preflightStateDirLock(privileged, configDir, plan, stateLockPlanInImage); if (preflightIssues.length > 0) return preflightIssues; - return applyStateDirLockMode(privileged, configDir, "root:sandbox", true); + return applyStateDirLockMode( + privileged, + configDir, + "root:sandbox", + true, + plan, + stateLockPlanInImage, + ); } // Existing sandboxes may contain an older in-image guard, so startup always @@ -264,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/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/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index bdca325465d..ef4dae77a15 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -69,6 +69,7 @@ describe("shields timer authorization", () => { ? [`${marker.configDir.replace(/\/+$/, "")}/.env`] : []), ], + stateLockPlanInImage: false, } : undefined, ); @@ -1184,6 +1185,7 @@ describe("shields timer authorization", () => { configPath, configDir, sensitiveFiles: [sensitiveHashPath], + stateLockPlanInImage: false, }; expect(shieldsIndexMock.resolvePersistedAutoRestoreTarget).toHaveBeenCalledWith( diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 0266b95dd86..2e8299d5186 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -764,6 +764,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)) { @@ -799,6 +800,45 @@ 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 hasStateDirectorySources( + exactDirectories: readonly string[], + directoryPrefixes: readonly string[], +): boolean { + return exactDirectories.length > 0 || directoryPrefixes.length > 0; +} + +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); } @@ -1138,14 +1178,12 @@ 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 hasBackupDirectories = hasStateDirectorySources(stateDirs, stateDirPrefixes); 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); @@ -1272,7 +1310,7 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const failedFiles: string[] = []; let unreachable = false; - if (stateDirs.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) { @@ -1313,18 +1351,29 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; try { - if (stateDirs.length > 0) { + if (hasBackupDirectories) { // 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", @@ -1334,28 +1383,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)"); @@ -1601,19 +1656,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) => @@ -1926,16 +1978,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); } } @@ -1949,7 +2014,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/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/src/lib/tunnel/services.test.ts b/src/lib/tunnel/services.test.ts index fcb1ca6a784..e1a36fb7293 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 { afterAll, afterEach, beforeAll, 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"; @@ -507,23 +509,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({ 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/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/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/gateway-guard-recovery.test.ts b/test/e2e/live/gateway-guard-recovery.test.ts index 1a4ad8ecf6b..abbc8c99f24 100644 --- a/test/e2e/live/gateway-guard-recovery.test.ts +++ b/test/e2e/live/gateway-guard-recovery.test.ts @@ -113,6 +113,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", @@ -362,6 +372,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); @@ -466,6 +483,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, @@ -477,7 +501,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); diff --git a/test/e2e/live/hermes-shields-config.test.ts b/test/e2e/live/hermes-shields-config.test.ts index 1287288b1be..344acd39767 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); @@ -205,6 +206,7 @@ test("hermes-shields-config: stopped Hermes restores under both Shields postures "fresh CPU-only Hermes onboard plus two real shields down/up transitions and stopped-sandbox recovery", 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", "start restores a stopped Hermes sandbox while shields are up", @@ -314,6 +316,7 @@ test("hermes-shields-config: stopped Hermes restores under both Shields postures "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", @@ -321,6 +324,7 @@ test("hermes-shields-config: stopped Hermes restores under both Shields postures 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}$/); @@ -373,6 +377,7 @@ test("hermes-shields-config: stopped Hermes restores under both Shields postures assertions: { configPreserved: true, freshNonrootTrigger: true, + stateLockPlanModes: true, firstCycle: true, shieldsDownStartRecovery: true, shieldsUpStartRecovery: true, 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-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 3f6afac1edf..d388a058ee7 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 { hermesOneShotExecutionState } from "./rebuild-hermes-cron-execution.ts"; +import { 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"; const HERMES_HOME = "/sandbox/.hermes"; @@ -50,7 +54,7 @@ interface GatewayEvidence { active_agents: number; gateway_state: string; pid: number; - running_pid: number; + running_pid: number | null; start_time: number; } @@ -152,30 +156,61 @@ export function parseHermesCronBeginReceipt(text: string): CronControlReceipt { operator_drain_active: false, version: 1, }); - if (!Number.isSafeInteger(payload.pid) || !Number.isSafeInteger(payload.start_time)) { - fail("Hermes cron begin receipt identity is invalid"); - } + hermesCronBeginIdentity(payload); return payload as unknown as CronControlReceipt; } -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", }); const runtime = hermesCronJobRuntimeState(job, `cron job ${seed.id}`); expect(runtime.state).toBe("scheduled"); expect(runtime.lastRunAt).toBeNull(); expect(runtime.lastStatus).toBeNull(); expect(runtime.completed).toBe(0); - expect( - normalizeTimestampMs(runtime.nextRunAt, `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.nextRunAt, `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( + hermesCronRuntimeFields(job, `cron job ${seed.id}`).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 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[] { @@ -254,7 +289,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( @@ -279,7 +318,7 @@ export function createRebuildHermesCronRestoreFixture({ "hermes", "cron", "create", - "every 1d", + schedule, "--no-agent", "--script", pending.scriptName, @@ -296,7 +335,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; } @@ -322,9 +361,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 { @@ -344,6 +394,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] @@ -353,15 +424,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", @@ -381,7 +443,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( @@ -531,7 +593,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"); }, @@ -544,9 +606,17 @@ export function createRebuildHermesCronRestoreFixture({ const receipt = parseHermesCronBeginReceipt(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", @@ -589,7 +659,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/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/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index b4bc6fb2dc1..26f49736367 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -95,7 +95,7 @@ SANDBOX_NAME.startsWith(TEST_SANDBOX_PREFIX) || ); 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 = REBUILD_HERMES_STATE.apiServerKey; @@ -1072,7 +1072,7 @@ test(STALE_BASE_REBUILD }, ); expectExitZero(writeMarker, "write Hermes marker"); - const writeExcludedKanbanMarker = await host.command( + const writeExcludedHooksMarker = await host.command( activeOpenshellBin, [ "sandbox", @@ -1083,18 +1083,18 @@ test(STALE_BASE_REBUILD "sh", "-c", [ - `mkdir -p ${shellQuote(path.dirname(EXCLUDED_KANBAN_FILE))}`, - `printf '%s' ${shellQuote(REBUILD_HERMES_STATE.markerContent)} > ${shellQuote(EXCLUDED_KANBAN_FILE)}`, + `mkdir -p ${shellQuote(path.dirname(EXCLUDED_HOOKS_FILE))}`, + `printf '%s' ${shellQuote(REBUILD_HERMES_STATE.markerContent)} > ${shellQuote(EXCLUDED_HOOKS_FILE)}`, ].join(" && "), ], { - artifactName: "phase-4-write-excluded-kanban-marker", + artifactName: "phase-4-write-excluded-hermes-hooks-marker", env: testEnv(apiKey), redactionValues, timeoutMs: OPENSHELL_TIMEOUT_MS, }, ); - expectExitZero(writeExcludedKanbanMarker, "write excluded Hermes kanban marker"); + expectExitZero(writeExcludedHooksMarker, "write backup:false Hermes hooks marker"); await cronRestore.seed(); const seededKanbanDb = await host.command("docker", inspectKanbanTaskArgs(SANDBOX_NAME), { artifactName: "phase-4-inspect-seeded-kanban-db", @@ -1337,17 +1337,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..46c2b24b031 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, }, ]; @@ -417,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/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index fe4200d1195..dc67e280121 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -44,6 +44,7 @@ if (!BACKUP_DIR.startsWith(`${path.resolve(BACKUP_ROOT)}${path.sep}`)) { const OPENCLAW_WORKSPACE_PATH = "/sandbox/.openclaw/workspace"; const MARKER_FILE = `${OPENCLAW_WORKSPACE_PATH}/snapshot-marker.txt`; const SECOND_MARKER = `${OPENCLAW_WORKSPACE_PATH}/snapshot-marker-2.txt`; +const PREFIX_MARKER = "/sandbox/.openclaw/workspace-research/snapshot-marker.txt"; const USER_FILE = `${OPENCLAW_WORKSPACE_PATH}/USER.md`; const SOUL_FILE = `${OPENCLAW_WORKSPACE_PATH}/SOUL.md`; const BASELINE_EXCLUSION_KEY = "openclaw_docs"; @@ -348,6 +349,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 clone verification sends one clone-fixture request, stores its unique session only in the clone, and sends no source-sandbox negative-control 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", @@ -506,8 +508,9 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- "-lc", `set -eu test "$OPENCLAW_WORKSPACE_DIR" = ${JSON.stringify(OPENCLAW_WORKSPACE_PATH)} -mkdir -p "$OPENCLAW_WORKSPACE_DIR" +mkdir -p "$OPENCLAW_WORKSPACE_DIR" /sandbox/.openclaw/workspace-research printf '%s' ${JSON.stringify(markerContent)} > ${JSON.stringify(MARKER_FILE)} +printf '%s' ${JSON.stringify(markerContent)} > ${JSON.stringify(PREFIX_MARKER)} printf '%s' ${JSON.stringify(userContent)} > "$OPENCLAW_WORKSPACE_DIR/USER.md" printf '%s' ${JSON.stringify(soulContent)} > "$OPENCLAW_WORKSPACE_DIR/SOUL.md"`, ], @@ -795,7 +798,11 @@ test ! -e ${JSON.stringify(MARKER_FILE)}`, 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(), @@ -818,6 +825,13 @@ test ! -e ${JSON.stringify(MARKER_FILE)}`, 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/e2e/live/state-dir-guard-metadata.test.ts b/test/e2e/live/state-dir-guard-metadata.test.ts index 83f1be7690c..5755af88439 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 ConfidentialityAccessEvidence { processUid: number; processGid: number; @@ -173,27 +187,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", @@ -288,7 +319,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; @@ -471,7 +509,7 @@ async function probeConfidentialityAccessContract( target: string, identity: { uid: number; gid: number }, ): Promise { - const credentialsRoot = path.join(fixtureRoot, "credentials"); + const credentialsRoot = path.join(fixtureRoot, agent.stateRoots.confidential); const containerRoot = containerTargetPath(agent, fixtureRoot, credentialsRoot); const containerTarget = containerTargetPath(agent, fixtureRoot, target); const script = [ @@ -495,7 +533,7 @@ async function probeConfidentialityAccessContract( " 'rootMode': stat.S_IMODE(root.st_mode),", " 'missingDirectChildErrno': errno_of(lambda: os.lstat(os.path.join(root_path, 'oauth.json'))),", " 'rootListingErrno': errno_of(lambda: os.listdir(root_path)),", - " 'nestedTraversalErrno': errno_of(lambda: os.lstat(os.path.join(root_path, 'providers', 'missing.json'))),", + " 'nestedTraversalErrno': errno_of(lambda: os.lstat(os.path.join(root_path, 'nemoclaw-e2e', 'missing.json'))),", " 'secretReadErrno': errno_of(read_secret),", "}))", ].join("\n"); @@ -555,17 +593,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, @@ -581,8 +643,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({ @@ -593,19 +655,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"); @@ -614,34 +680,34 @@ 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 confidentialityAccess = await probeConfidentialityAccessContract( host, agent, fixtureRoot, - targets.credentials, + targets.confidential, identity, ); expect(confidentialityAccess).toMatchObject({ @@ -663,8 +729,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]; @@ -679,8 +749,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 = { @@ -692,6 +762,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, @@ -709,7 +784,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: { @@ -726,9 +801,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 for preflight, lock, and unlock", + "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", "a distinct sandbox-group member sees ENOENT for a missing direct child while listing, nested traversal, and secret reads stay denied", "representative-tree entry, byte, depth, copy, and wall-time evidence stays within shipped limits", ], @@ -801,6 +877,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/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", () => { diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 6b8dba23e3e..e6da8556b61 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-")); @@ -496,6 +507,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( @@ -534,24 +546,24 @@ 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, + }, + killSignal: "SIGKILL", + timeout: 30_000, + }); expect(result.status, result.stdout + "\n" + result.stderr).toBe(expectedStatus); expect(Number(fs.readFileSync(counterFile, "utf8").trim())).toBe(expectedAttempts); @@ -565,6 +577,72 @@ 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 testDriver = path.join(tempDir, "assert-status-mode"); + 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 }, + ); + + 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, + }, + killSignal: "SIGKILL", + timeout: 30_000, + }); + + 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); + 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 }); + } + }); + 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", 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-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/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, + }); + }); +}); 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", + ); + }); +}); 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"); + }); +}); diff --git a/test/helpers/base-image-test-harness.ts b/test/helpers/base-image-test-harness.ts index f95102a3747..f8b1a58be33 100644 --- a/test/helpers/base-image-test-harness.ts +++ b/test/helpers/base-image-test-harness.ts @@ -40,14 +40,29 @@ 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: [], + }, + stateLockPlanInImage: true, 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/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index 9721ca324ff..a86037628d8 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -156,9 +156,18 @@ export function createShieldsFlowHarness( const dockerExec = requireDist("../adapters/docker/exec.js"); const audit = requireDist("./audit.js"); const tempFiles = requireDist("../onboard/temp-files.js"); + const stateDirLock = requireDist("./state-dir-lock.js"); const childProcess = requireDist("node:child_process"); const policySetBodies: string[] = []; let openClawPosture: "locked" | "mutable" = options.initialOpenClawPosture ?? "mutable"; + const stateLockPlan = { + version: 1 as const, + readOnlyRoots: ["skills"], + confidentialRoots: [], + readOnlyPrefixes: [], + confidentialPrefixes: [], + writableSubpaths: [], + }; vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); const runCaptureSpy = vi @@ -184,6 +193,8 @@ export function createShieldsFlowHarness( configFile: "openclaw.json", configPath: "/sandbox/.openclaw/openclaw.json", format: "json", + stateLockPlan, + stateLockPlanInImage: true, }); vi.spyOn(registry, "getSandbox").mockReturnValue( options.sandboxEntry ?? { name: "openclaw", openshellDriver: "docker" }, @@ -212,6 +223,8 @@ export function createShieldsFlowHarness( ); vi.spyOn(dockerExec, "dockerSpawnSync").mockImplementation((argv: unknown) => { const args = Array.isArray(argv) ? argv.map(String) : []; + 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( @@ -246,20 +259,22 @@ export function createShieldsFlowHarness( 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: [], @@ -307,6 +322,7 @@ export function createShieldsFlowHarness( }); } const cleanupTempDirSpy = vi.spyOn(tempFiles, "cleanupTempDir"); + vi.spyOn(stateDirLock, "stateLockPlanCompatibilityIssues").mockReturnValue([]); const prepareStateSaveFailure = options.failStateSave ? () => fs.mkdirSync(path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"), { 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/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-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 }), diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index 834587d2854..53d9e4fc83d 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -13,6 +13,44 @@ 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 json +import runpy +import sys +import types + +yaml = types.ModuleType("yaml") +class YAMLError(Exception): + pass +yaml.YAMLError = YAMLError +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:] +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 +118,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 hermesCronRestoreControlPath = path.join(libDir, "hermes-cron-restore-control.py"); const nestedDir = path.join(preloadsDir, "nested"); const profileDir = path.join(tmp, "etc-profile.d"); @@ -111,6 +150,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"), hermesCronRestoreControlPath, path.join(libDir, "sandbox-rlimits.sh"), @@ -129,6 +169,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 = [ @@ -143,11 +184,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} ${hermesCronRestoreControlPath} ${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} ${hermesCronRestoreControlPath} ${mcpCredentialBoundaryPath}`, `-R 0:0 ${preloadsDir}`, "", ].join("\n"), @@ -163,6 +204,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"); @@ -183,10 +225,18 @@ 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 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("; "); @@ -228,7 +278,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), @@ -261,7 +311,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/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 26bb92a582a..c98cb3b64d8 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -287,6 +287,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/cron-restore-control.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.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/", ], }, @@ -422,6 +423,7 @@ describe("Hermes final image layout", () => { "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", "/usr/local/lib/nemoclaw/hermes-cron-restore-control.py 'root:root 700'", "/sandbox/.nemoclaw 'root:root 1755'", + "/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'", "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py '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 f8446051898..f76e41bf1a5 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -10,6 +10,8 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; import { openClawBootstrapSnippet } from "./support/entrypoint-script-fixture"; +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"; @@ -189,37 +191,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, @@ -2263,7 +2234,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", @@ -2276,7 +2246,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 bfd4762d3cd..06fe3e67c8d 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -93,6 +93,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", @@ -147,6 +148,7 @@ describe("OpenClaw final image layout", () => { "/usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root:444'", "/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 f1605177955..1da61c34c86 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -213,6 +213,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: 240_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..b0f24434a4e 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,19 @@ 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); + const pythonIndex = command.indexOf("python3"); + return installedIndex >= 0 + ? (command[installedIndex + 1] ?? null) + : 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) { @@ -54,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( @@ -125,7 +160,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 +175,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 +191,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 +279,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 +292,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 +336,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, @@ -306,8 +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]; + restoreCachedModule(privilegedExecPath, priorPrivilegedExec); + restoreCachedModule(transitionLockPath, priorTransitionLock); } } @@ -447,6 +511,8 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -456,6 +522,8 @@ describe("mutable agent config permissions", () => { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }); @@ -464,13 +532,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 +566,8 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -508,6 +577,8 @@ describe("mutable agent config permissions", () => { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: OPENCLAW_STATE_LOCK_PLAN, + stateLockPlanInImage: true, }); }, { @@ -531,7 +602,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 +616,35 @@ 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; + stateLockPlanInImage: boolean; + }, + ) => 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, + stateLockPlanInImage: true, + }); + }, + { installedStateLockPlan: HERMES_STATE_LOCK_PLAN }, + ); const hermesActions = commands .filter((command) => command.includes(HERMES_RUNTIME_CONFIG_GUARD)) @@ -607,6 +686,8 @@ describe("mutable agent config permissions", () => { configPath: string; configDir: string; sensitiveFiles?: string[]; + stateLockPlan?: AgentStateLockPlan; + stateLockPlanInImage: boolean; }, ) => void; }; @@ -616,9 +697,14 @@ 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, + stateLockPlanInImage: true, }); }, - { hermesLockedTransaction: true }, + { + hermesLockedTransaction: true, + installedStateLockPlan: HERMES_STATE_LOCK_PLAN, + }, ); const finishIndex = commands.findIndex((command) => @@ -643,7 +729,19 @@ 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)}; +${stateDirGuardAction.toString()} 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 +776,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 +805,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 +841,8 @@ lockAgentConfig("sandbox-pod", { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, + stateLockPlanInImage: true, }); process.stdout.write(JSON.stringify(calls)); `, @@ -749,12 +859,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 +871,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 d118b82dbdf..4dee828d1cd 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`, @@ -582,6 +583,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/sandbox-provisioning-helper-permissions.test.ts b/test/sandbox-provisioning-helper-permissions.test.ts index 49038b8414e..5f8a36a7a78 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 = [ @@ -143,6 +144,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"), @@ -204,6 +206,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 83c08d10684..77e66a207e2 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 hermesCronRestoreControlPath = path.join(localLib, "hermes-cron-restore-control.py"); const files = [ @@ -1136,6 +1144,7 @@ describe("Hermes sandbox provisioning", () => { mcpManifest, gatewaySupervisorPath, stateDirGuardPath, + stateLockPlanPath, managedGatewayControlPath, hermesCronRestoreControlPath, path.join(localLib, "sandbox-rlimits.sh"), @@ -1147,11 +1156,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 }); @@ -1161,7 +1172,7 @@ describe("Hermes sandbox provisioning", () => { expect(result.status, result.stderr).toBe(0); expect(calls).toContain( - `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${hermesCronRestoreControlPath} ${mcpManifest}`, + `chown root:root ${gatewayControlPath} ${gatewaySupervisorPath} ${stateDirGuardPath} ${stateLockPlanPath} ${managedGatewayControlPath} ${buildMcpDigestPath} ${hermesCronRestoreControlPath} ${mcpManifest}`, ); expect((fs.statSync(gatewayControlPath).mode & 0o777).toString(8)).toBe("700"); expect((fs.statSync(hermesCronRestoreControlPath).mode & 0o777).toString(8)).toBe("700"); @@ -1171,6 +1182,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 4500a98dbda..83e5683e2cf 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 hermesCronRestoreControl = path.join(localLib, "hermes-cron-restore-control.py"); const startBin = path.join(tmp, "nemoclaw-start"); @@ -608,6 +609,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(hermesCronRestoreControl, "# Hermes cron restore control fixture\n"); fs.writeFileSync(startBin, "#!/usr/bin/env bash\n"); @@ -659,6 +661,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/hermes-cron-restore-control.py", @@ -690,6 +693,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(fs.statSync(hermesCronRestoreControl).mode & 0o777).toBe(0o700); expect(hardenedDir.uid).toBe(fixtureOwner.uid); expect(hardenedDir.gid).toBe(fixtureOwner.gid); diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index 3805dea7cc0..e0b61f5bf03 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -3,14 +3,12 @@ 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; +const HERMES_STATE_LOCK_PLAN = loadAgent("hermes").stateLockPlan; type GuardProbeResult = { calls: string[][]; @@ -73,6 +71,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 +146,8 @@ try { configPath: "/sandbox/.openclaw/openclaw.json", configDir: "/sandbox/.openclaw", sensitiveFiles: ["/sandbox/.openclaw/.config-hash"], + stateLockPlan: ${JSON.stringify(OPENCLAW_STATE_LOCK_PLAN)}, + stateLockPlanInImage: true, }, false, ); @@ -171,7 +177,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 +205,17 @@ 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", "profiles", "workspace"]), ); - expect(CONFIDENTIALITY_STATE_DIRS).toEqual(["credentials", "identity", "pairing"]); - expect(WRITABLE_RUNTIME_SUBPATHS).toEqual(["agents/*/sessions", "profiles/dashboard-home"]); + expect(OPENCLAW_STATE_LOCK_PLAN.confidentialRoots).toEqual(["credentials", "identity"]); + expect(OPENCLAW_STATE_LOCK_PLAN.writableSubpaths).toEqual(["agents/*/sessions"]); + }); + + it("uses the Hermes manifest plan for pairing and the private dashboard profile", () => { + expect(HERMES_STATE_LOCK_PLAN.confidentialRoots).toEqual(["pairing"]); + expect(HERMES_STATE_LOCK_PLAN.writableSubpaths).toEqual(["profiles/dashboard-home"]); }); 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..b9b37c0d3b4 --- /dev/null +++ b/test/snapshot-state-directory-contract.test.ts @@ -0,0 +1,191 @@ +// 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 sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href +)) 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", { 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: [], + failedBackupDirs: [stateDir], + }); + writeAgentRegistry("test-sandbox", "openclaw"); + + const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); + + expect(restore).toMatchObject(expected); + }); + + 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 c53916f28bc..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 @@ -588,7 +589,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; @@ -597,7 +598,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) { @@ -608,45 +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)}; -if (cmd.includes("[ -d ")) { - 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", { @@ -662,8 +641,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", { @@ -677,6 +664,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-dashboard-profile.test.ts b/test/state-dir-guard-dashboard-profile.test.ts new file mode 100644 index 00000000000..eb77b6e1bc2 --- /dev/null +++ b/test/state-dir-guard-dashboard-profile.test.ts @@ -0,0 +1,120 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; +import { loadAgent } from "../src/lib/agent/defs"; + +const GUARD_PATH = path.resolve("scripts/state-dir-guard.py"); +const OPENCLAW_STATE_LOCK_PLAN = loadAgent("openclaw").stateLockPlan; +const HERMES_STATE_LOCK_PLAN = loadAgent("hermes").stateLockPlan; +const fixtures: string[] = []; + +const RUN_GUARD_AS_CURRENT_USER = String.raw` +import importlib.util +import os +import sys + +guard_path, action, config_dir, plan_json = sys.argv[1:5] +spec = importlib.util.spec_from_file_location("nemoclaw_state_dir_guard_dashboard", 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, + "--plan-json", + plan_json, +])) +`; + +function fixture(configDirName: ".hermes" | ".openclaw"): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dashboard-guard-")); + fixtures.push(root); + const configDir = path.join(root, configDirName); + fs.mkdirSync(configDir, { recursive: true }); + return fs.realpathSync(configDir); +} + +function runGuard(action: "lock" | "unlock", configDir: string, plan: unknown) { + return spawnSync( + "python3", + ["-c", RUN_GUARD_AS_CURRENT_USER, GUARD_PATH, action, configDir, JSON.stringify(plan)], + { encoding: "utf-8", timeout: 15_000 }, + ); +} + +function mode(filePath: string): number { + return fs.lstatSync(filePath).mode & 0o7777; +} + +afterEach(() => { + for (const root of fixtures.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("state-dir guard dashboard profiles", () => { + it("keeps the Hermes dashboard profile writable and private while Shields are up (#7200)", () => { + const configDir = fixture(".hermes"); + const dashboardHome = path.join(configDir, "profiles", "dashboard-home"); + const dashboardMemory = path.join(dashboardHome, "MEMORY.md"); + fs.mkdirSync(dashboardHome, { recursive: true, mode: 0o700 }); + fs.chmodSync(dashboardHome, 0o700); + fs.writeFileSync(dashboardMemory, "dashboard runtime\n", { mode: 0o600 }); + const dashboardFd = fs.openSync(dashboardMemory, "a+"); + const oldMemoryInode = fs.fstatSync(dashboardFd).ino; + + try { + const locked = runGuard("lock", configDir, HERMES_STATE_LOCK_PLAN); + + expect(locked.status, locked.stderr).toBe(0); + expect(mode(path.join(configDir, "profiles"))).toBe(0o755); + expect(mode(dashboardHome)).toBe(0o700); + expect(mode(dashboardMemory)).toBe(0o600); + expect(fs.fstatSync(dashboardFd).ino).toBe(oldMemoryInode); + fs.writeSync(dashboardFd, "updated\n"); + + const relocked = runGuard("lock", configDir, HERMES_STATE_LOCK_PLAN); + expect(relocked.status, relocked.stderr).toBe(0); + expect(mode(dashboardHome)).toBe(0o700); + expect(fs.fstatSync(dashboardFd).ino).toBe(oldMemoryInode); + + const unlocked = runGuard("unlock", configDir, HERMES_STATE_LOCK_PLAN); + expect(unlocked.status, unlocked.stderr).toBe(0); + expect(mode(dashboardHome)).toBe(0o700); + const contents = Buffer.alloc(fs.fstatSync(dashboardFd).size); + fs.readSync(dashboardFd, contents, 0, contents.length, 0); + expect(contents.toString("utf-8")).toBe("dashboard runtime\nupdated\n"); + } finally { + fs.closeSync(dashboardFd); + } + }); + + it("keeps a dashboard-named OpenClaw profile inside the Shields boundary", () => { + const configDir = fixture(".openclaw"); + const dashboardHome = path.join(configDir, "profiles", "dashboard-home"); + const dashboardMemory = path.join(dashboardHome, "MEMORY.md"); + fs.mkdirSync(dashboardHome, { recursive: true, mode: 0o700 }); + fs.writeFileSync(dashboardMemory, "protected\n", { mode: 0o600 }); + const oldMemoryInode = fs.statSync(dashboardMemory).ino; + + const locked = runGuard("lock", configDir, OPENCLAW_STATE_LOCK_PLAN); + + expect(locked.status, locked.stderr).toBe(0); + expect(mode(dashboardHome)).toBe(0o755); + expect(fs.statSync(dashboardMemory).ino).not.toBe(oldMemoryInode); + }); +}); diff --git a/test/state-dir-guard.test.ts b/test/state-dir-guard.test.ts index fc8ace47cf5..0af047ea861 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", @@ -34,7 +43,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 @@ -51,6 +60,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])) `; @@ -61,7 +91,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 @@ -70,6 +100,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 @@ -85,10 +116,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({ @@ -155,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 @@ -164,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): @@ -186,7 +218,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 @@ -195,12 +227,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], @@ -274,14 +307,16 @@ function fixture(configDirName = ".agent"): { root: string; configDir: string } return { root, configDir: fs.realpathSync(configDir) }; } -function runGuard( +function runGuardWithPlanSource( action: "preflight" | "lock" | "unlock" | "startup", 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 @@ -292,6 +327,15 @@ function runGuard( return { ...result, lines }; } +function runGuard( + action: "preflight" | "lock" | "unlock" | "startup", + 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; } @@ -308,6 +352,221 @@ 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, (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, writePlan] of cases) { + const planFile = path.join(root, fileName); + writePlan(planFile); + + 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); @@ -855,7 +1114,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, @@ -876,7 +1135,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 }, ); @@ -915,7 +1174,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 }, ); @@ -986,59 +1245,6 @@ describe("state-dir-guard", () => { expect(mode(agentDir)).toBe(0o2770); }); - it("keeps the Hermes dashboard profile writable and private while Shields are up (#7200)", () => { - const { configDir } = fixture(".hermes"); - const dashboardHome = path.join(configDir, "profiles", "dashboard-home"); - const dashboardMemory = path.join(dashboardHome, "MEMORY.md"); - fs.mkdirSync(dashboardHome, { recursive: true, mode: 0o700 }); - fs.chmodSync(dashboardHome, 0o700); - fs.writeFileSync(dashboardMemory, "dashboard runtime\n", { mode: 0o600 }); - const dashboardFd = fs.openSync(dashboardMemory, "a+"); - const oldMemoryInode = fs.fstatSync(dashboardFd).ino; - - try { - const locked = runGuard("lock", configDir); - - expect(locked.status, locked.stderr).toBe(0); - expect(mode(path.join(configDir, "profiles"))).toBe(0o755); - expect(mode(dashboardHome)).toBe(0o700); - expect(mode(dashboardMemory)).toBe(0o600); - expect(fs.fstatSync(dashboardFd).ino).toBe(oldMemoryInode); - fs.writeSync(dashboardFd, "updated\n"); - - const relocked = runGuard("lock", configDir); - - expect(relocked.status, relocked.stderr).toBe(0); - expect(mode(dashboardHome)).toBe(0o700); - expect(fs.fstatSync(dashboardFd).ino).toBe(oldMemoryInode); - - const unlocked = runGuard("unlock", configDir); - - expect(unlocked.status, unlocked.stderr).toBe(0); - expect(mode(dashboardHome)).toBe(0o700); - const contents = Buffer.alloc(fs.fstatSync(dashboardFd).size); - fs.readSync(dashboardFd, contents, 0, contents.length, 0); - expect(contents.toString("utf-8")).toBe("dashboard runtime\nupdated\n"); - } finally { - fs.closeSync(dashboardFd); - } - }); - - it("keeps a dashboard-named OpenClaw profile inside the Shields boundary", () => { - const { configDir } = fixture(".openclaw"); - const dashboardHome = path.join(configDir, "profiles", "dashboard-home"); - const dashboardMemory = path.join(dashboardHome, "MEMORY.md"); - fs.mkdirSync(dashboardHome, { recursive: true, mode: 0o700 }); - fs.writeFileSync(dashboardMemory, "protected\n", { mode: 0o600 }); - const oldMemoryInode = fs.statSync(dashboardMemory).ino; - - const locked = runGuard("lock", configDir); - - expect(locked.status, locked.stderr).toBe(0); - expect(mode(dashboardHome)).toBe(0o755); - expect(fs.statSync(dashboardMemory).ino).not.toBe(oldMemoryInode); - }); - it.skipIf(SUPPLEMENTARY_GID < 0)( "assigns the sandbox group to the confidentiality root only, while nested entries keep the root group (#7545)", () => { @@ -1055,6 +1261,7 @@ describe("state-dir-guard", () => { GUARD_PATH, configDir, String(SUPPLEMENTARY_GID), + PLAN_JSON, ], { encoding: "utf-8", timeout: 15_000 }, ); @@ -1089,6 +1296,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"); @@ -1136,10 +1369,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( 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")