From 3cfd478575ae9ca8a01d54ca38aeff0bd8c57d2c Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 12 Aug 2026 03:00:12 +0000 Subject: [PATCH 01/13] fix(hermes): persist lazy memory dependencies Signed-off-by: Yimo Jiang --- agents/hermes/Dockerfile | 15 +++- agents/hermes/Dockerfile.base | 6 ++ agents/hermes/manifest.yaml | 3 + agents/hermes/start.sh | 1 + agents/hermes/state-lock-plan.json | 1 + agents/hermes/validate-env-secret-boundary.py | 7 ++ .../install-plugins-hermes.mdx | 77 ++++++++++++++++++- .../policies/presets/local-memory.yaml | 24 ++++++ nemoclaw-blueprint/policies/tiers.yaml | 1 + .../agent/state-directory-contract.test.ts | 2 +- test/effective-policy-contracts.test.ts | 20 +++++ test/helpers/vitest-watch-triggers.ts | 4 + test/hermes-dependency-review.test.ts | 2 + ...rmes-env-secret-boundary-hardening.test.ts | 20 +++++ test/hermes-start.test.ts | 3 + test/sandbox-provisioning.test.ts | 23 ++++++ test/shields-up-runtime-perms.test.ts | 4 +- 17 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 nemoclaw-blueprint/policies/presets/local-memory.yaml diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index dd8fd896ed4..0b3a6c20769 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -162,6 +162,10 @@ FROM hermes-managed-teams-${NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION}-wheels AS h # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +# Keep the stock-image lazy dependency target when a published base image lags +# Dockerfile.base. The final stage also creates and verifies the directory. +ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages + # Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When # onboard detects an operator-supplied corporate CA on the host it bakes it # here; the RUN below decodes it to a root-owned file that the entrypoint @@ -619,7 +623,7 @@ RUN node --experimental-strip-types \ ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=240ed41e79d8ab4239bbd8f01dcff6ffc7ad0d9329f703704198ceb8a48cb904 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 # hadolint ignore=DL4006 @@ -1025,6 +1029,7 @@ RUN set -eu; \ "$config_dir/weixin" \ "$config_dir/weixin/accounts" \ "$config_dir/runtime" \ + "$config_dir/lazy-packages" \ "$config_dir/profiles/dashboard-home"; \ if [ -e "$data_dir" ] || [ -L "$data_dir" ]; then \ echo "ERROR: legacy data dir still exists after cleanup: $data_dir" >&2; \ @@ -1079,6 +1084,7 @@ RUN set -eu; \ /sandbox/.hermes/weixin/accounts \ /sandbox/.hermes/runtime \ /sandbox/.hermes/profiles/dashboard-home \ + && chmod 700 /sandbox/.hermes/lazy-packages \ && chmod 2770 \ /sandbox/.hermes/logs \ /sandbox/.hermes/logs/curator \ @@ -1100,6 +1106,13 @@ RUN set -eu; \ && chown sandbox:sandbox /sandbox/.hermes/.hermes_history \ && chmod 660 /sandbox/.hermes/.hermes_history +# The Hermes lazy installer uses this sandbox-owned directory instead of the +# sealed root venv. The directory is not part of the sealed configuration. +RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 700" \ + && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /opt/hermes/.venv/bin/python -I -c \ + "from pathlib import Path; target = Path('/sandbox/.hermes/lazy-packages'); marker = target / '.nemoclaw-write-probe'; marker.write_text('ok'); assert marker.read_text() == 'ok'; marker.unlink()" + # Prove the cron execution ledger contract across the real image identities. # Hermes' gateway-side scheduler creates the live WAL-backed database in the # writable runtime boundary, sandbox reads and online-copies it before diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index f3a28216028..bc28b70ca8b 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -323,6 +323,7 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/platforms/whatsapp/session \ /sandbox/.hermes/gateway \ /sandbox/.hermes/runtime \ + /sandbox/.hermes/lazy-packages \ && chown -R sandbox:sandbox /sandbox/.hermes \ && chown gateway:sandbox \ /sandbox/.hermes/cron \ @@ -351,6 +352,7 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/platforms/whatsapp/session \ /sandbox/.hermes/gateway \ /sandbox/.hermes/runtime \ + && chmod 700 /sandbox/.hermes/lazy-packages \ && chmod 2770 \ /sandbox/.hermes/logs \ /sandbox/.hermes/logs/curator \ @@ -368,6 +370,10 @@ RUN mkdir -p /sandbox/.hermes/memories \ && chown sandbox:sandbox /sandbox/.hermes/.hermes_history \ && chmod 660 /sandbox/.hermes/.hermes_history +# Hermes installs opt-in dependencies here instead of the sealed root venv. +# Hermes appends this directory after its trusted site-packages at runtime. +ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages + # Pre-create shell init files for the sandbox user. # The Hermes entrypoint writes proxy vars and # HERMES_HOME to /tmp/nemoclaw-proxy-env.sh (mode 444, root-owned when the diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 49d25887a0f..96a03d67f84 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -71,6 +71,9 @@ config: state_lock_plan_in_image: true state_dirs: - memories + # Hermes lazy dependency installs persist here outside sealed configuration. + - path: lazy-packages + shields: confidential - sessions - path: skills shields: read-only diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index fe40e6c728c..f83a1a04347 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1820,6 +1820,7 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" export HERMES_HOME="${HERMES_DIR}" +export HERMES_LAZY_INSTALL_TARGET="/sandbox/.hermes/lazy-packages" PROXYEOF cat <<'TUIENVEOF' if [ -f /opt/hermes/ui-tui/dist/entry.js ]; then diff --git a/agents/hermes/state-lock-plan.json b/agents/hermes/state-lock-plan.json index 46c8cbd3af7..1e83c661f20 100644 --- a/agents/hermes/state-lock-plan.json +++ b/agents/hermes/state-lock-plan.json @@ -13,6 +13,7 @@ "workspace" ], "confidentialRoots": [ + "lazy-packages", "pairing" ], "readOnlyPrefixes": [], diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index 2fdc82061b6..89ab22f29bc 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -499,6 +499,13 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int: if len(violations) < MAX_VIOLATIONS: violations.append(key) continue + if key == "HERMES_LAZY_INSTALL_TARGET": + if value == "/sandbox/.hermes/lazy-packages": + continue + violation_count += 1 + if len(violations) < MAX_VIOLATIONS: + violations.append(key) + continue if key in RUNTIME_ALLOWED_NONSECRET_KEYS: continue if key in RUNTIME_ALLOWED_RAW_SECRET_KEYS and is_allowed_raw_secret_value( diff --git a/docs/manage-sandboxes/install-plugins-hermes.mdx b/docs/manage-sandboxes/install-plugins-hermes.mdx index d9043f8666f..529ee454a73 100644 --- a/docs/manage-sandboxes/install-plugins-hermes.mdx +++ b/docs/manage-sandboxes/install-plugins-hermes.mdx @@ -3,9 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 title: "Install Hermes Plugins" sidebar-title: "Install Hermes Plugins" -description: "Install Hermes plugins for NemoClaw-managed sandboxes." -description-agent: "Explains how to install Hermes plugins in NemoClaw-managed sandboxes, including custom Dockerfile build-directory layout and `.dockerignore` handling. Use when users ask how to install, build, or configure Hermes plugins under NemoClaw." -keywords: ["install hermes plugins", "hermes plugins nemoclaw", "nemoclaw hermes plugins", "nemohermes dockerignore"] +description: "Install Hermes plugins and configure the bundled Hindsight memory plugin in NemoClaw-managed sandboxes." +description-agent: "Explains how to configure the bundled Hindsight memory plugin in a stock sealed Hermes sandbox or install a custom Hermes plugin. Use when users ask about Hindsight, lazy plugin dependencies, or custom Hermes plugins." +keywords: ["install hermes plugins", "hermes hindsight memory", "hermes lazy dependencies", "nemoclaw hermes plugins", "nemohermes dockerignore"] content: type: "how_to" skill: @@ -24,6 +24,76 @@ NemoClaw uses the same mechanism for its built-in Hermes integration, which the The built-in NemoClaw Hermes plugin provides sandbox status tools, skill reload support, managed-tool broker patches, and runtime grounding for the OpenShell sandbox. Do not replace or remove `/sandbox/.hermes/plugins/nemoclaw` when you add your own plugin. +## Configure the Bundled Hindsight Plugin + +The stock Hermes image provides a durable lazy-install directory at `/sandbox/.hermes/lazy-packages`. +Hermes installs the bundled Hindsight plugin dependency in this directory as the sandbox user. +The image keeps the root virtual environment and sealed configuration unchanged. + +NemoClaw supports `hindsight-client==0.6.1` for this workflow. +Hermes checks this exact version before it loads the plugin. +This version uses the OpenShell proxy path. +Do not replace it with a `0.8.x` client because those releases do not use the proxy environment. + +Start the self-hosted Hindsight service on the host before you configure the plugin. +The `local-memory` preset permits the Hermes Python runtime to call `host.openshell.internal` on port `8888`. +The preset does not permit another private host or port. + +Add the preset to the sandbox: + +```bash +nemohermes policy add local-memory --yes +``` + +Run the Hermes setup command through the NemoClaw CLI: + +```bash +nemohermes exec --tty -- hermes memory setup hindsight +``` + +Select `local_external` when Hermes asks for the connection mode. +Set the API URL to `http://host.openshell.internal:8888`. +Set the memory bank name for your workload. +Accept the `hermes` default if you do not need a workload-specific name. +After the setup command finishes, restart the gateway: + +```bash +nemohermes gateway restart +``` + +The first plugin load installs `hindsight-client==0.6.1` under `/sandbox/.hermes/lazy-packages`. +The directory is outside the integrity-sealed `.env`, `config.yaml`, and `.config-hash` files. +It is part of the Hermes durable state plan, so the dependency remains available after a gateway restart. + +Verify the provider and dependency after the restart: + +```bash +nemohermes exec -- hermes memory status +nemohermes exec -- python -c 'import os, sys; sys.path.insert(0, os.environ["HERMES_LAZY_INSTALL_TARGET"]); import hindsight_client; print(hindsight_client.__version__)' +nemohermes status +``` + +The version command must print `0.6.1`. +The status command must report a running Hermes gateway without an integrity failure or quarantine. + +If an earlier root install changed `/opt/hermes/.venv`, rebuild the sandbox before you use the lazy-install path: + +```bash +nemohermes rebuild --yes +``` + +A rebuild recreates the lazy-install directory. +The first Hindsight plugin load after the rebuild reinstalls the supported client in that directory. + +Do not edit `/sandbox/.hermes/.env` to set `HERMES_LAZY_INSTALL_TARGET`. +Do not install the dependency into `/opt/hermes/.venv`. +Both paths change sealed inputs and can make gateway reconciliation fail. + +The preset permits one local host route only. +To use another approved Hindsight host or port, create a custom preset and name the exact endpoint. +The `--from-file` and `--from-dir` paths accept `--trusted-private-host` for a private host. +Refer to [Create Custom Policy Presets](../network-policy/configure-policies/create-custom-policy-presets) for that procedure. + ## Choose an Install Path The supported path for custom Hermes plugins is to bake the plugin into a custom sandbox image and onboard from that Dockerfile. @@ -148,6 +218,7 @@ The following places commonly mix Hermes plugin installation with other NemoClaw - Do not put the Dockerfile in a broad directory unless you intend to send that whole directory as the Docker build context. - Do not rely on `.dockerignore` to include credential-like paths; NemoClaw excludes those from staged custom build contexts for safety. - Do not assume OpenShell policy allows Python package downloads during runtime by default. +- Do not install a bundled lazy dependency into `/opt/hermes/.venv` or declare its target in the sealed `.env` file. ## Next Steps diff --git a/nemoclaw-blueprint/policies/presets/local-memory.yaml b/nemoclaw-blueprint/policies/presets/local-memory.yaml new file mode 100644 index 00000000000..1152776fae1 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/local-memory.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: local-memory + description: "Local Hindsight memory service access through the OpenShell gateway" + +network_policies: + local_memory: + name: local_memory + endpoints: + - host: host.openshell.internal + port: 8888 + protocol: rest + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 97d321887ab..1f9d494555f 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -69,6 +69,7 @@ tiers: - { name: outlook, access: read-write } - { name: claude-code, access: read-write } - { name: local-inference, access: read-write } + - { name: local-memory, access: read-write } - { name: openclaw-pricing, access: read-write } - { name: openclaw-diagnostics-otel-local, access: read-write } - { name: observability-otlp-local, access: read-write } diff --git a/src/lib/agent/state-directory-contract.test.ts b/src/lib/agent/state-directory-contract.test.ts index 0ddb5a44fae..e8f5eda7f07 100644 --- a/src/lib/agent/state-directory-contract.test.ts +++ b/src/lib/agent/state-directory-contract.test.ts @@ -117,7 +117,7 @@ describe("agent state directory contract", () => { "weixin", "workspace", ], - confidentialRoots: ["pairing"], + confidentialRoots: ["lazy-packages", "pairing"], readOnlyPrefixes: [], confidentialPrefixes: [], writableSubpaths: ["profiles/dashboard-home"], diff --git a/test/effective-policy-contracts.test.ts b/test/effective-policy-contracts.test.ts index 019b8f30bac..e5bc208864a 100644 --- a/test/effective-policy-contracts.test.ts +++ b/test/effective-policy-contracts.test.ts @@ -348,6 +348,26 @@ describe("effective built-in policy contracts", () => { ); }); + it("allows only the approved local Hindsight endpoint (#8613)", () => { + const effective = composePresets(["local-memory"], "hermes"); + const localMemory = requireNetworkPolicy(effective, "local_memory"); + const endpoint = requireEndpoint(localMemory, "host.openshell.internal"); + const privateRanges = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]; + + expect(endpoint).toMatchObject({ + port: 8888, + protocol: "rest", + enforcement: "enforce", + allowed_ips: privateRanges, + }); + expect(rules(endpoint)).toEqual([ + { method: "GET", path: "/**" }, + { method: "POST", path: "/**" }, + ]); + expect(binaries(localMemory)).toEqual(["/opt/hermes/.venv/bin/python"]); + expect((localMemory.endpoints ?? []).some((entry) => entry.host === "10.0.0.1")).toBe(false); + }); + it("keeps host-local inference and managed tools on their broker boundaries", () => { const matrix = loadManagedToolGatewayMatrix(); const managedPresetNames = Object.keys(matrix); diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 757ba125ccc..32e226592e1 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -76,6 +76,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ "src/lib/onboard/inference-providers/compatible-endpoint-gateway-route.test.ts", ), }, + { + pattern: /(?:^|\/)nemoclaw-blueprint\/policies\/presets\/local-memory\.yaml$/, + testsToRun: runTests("test/effective-policy-contracts.test.ts"), + }, { pattern: /(?:^|\/)nemoclaw-blueprint\/policies\/presets\/claude-code\.yaml$/, testsToRun: runTests("test/effective-policy-contracts.test.ts"), diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 9835200b8e0..02de681c41b 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -150,6 +150,8 @@ describe("Hermes 0.19.0 dependency review", () => { expect(dockerfileBase).toContain("uv pip check --python /opt/hermes/.venv/bin/python"); expect(arg("NODE_VERSION")).toBe("24.18.1"); expect(arg("UV_VERSION")).toBe("0.11.33"); + expect(securityDependenciesPatch).toContain('hindsight = ["hindsight-client==0.6.1"]'); + expect(securityDependenciesPatch).not.toContain("hindsight-client==0.8."); for (const selection of [ '"aiohttp==3.14.3"', '"cryptography==50.0.0"', diff --git a/test/hermes-env-secret-boundary-hardening.test.ts b/test/hermes-env-secret-boundary-hardening.test.ts index b02223e3fd8..1f915941a7d 100644 --- a/test/hermes-env-secret-boundary-hardening.test.ts +++ b/test/hermes-env-secret-boundary-hardening.test.ts @@ -430,6 +430,26 @@ wait "$child" }); }); +describe("Hermes durable lazy-install target", () => { + it("accepts the image-owned lazy target in the runtime environment (#8613)", () => { + const result = runRuntimeEnvValidation({ + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + }); + + it("rejects a lazy target override that could import sandbox code into the gateway (#8613)", () => { + const result = runRuntimeEnvValidation({ + HERMES_LAZY_INSTALL_TARGET: "/tmp/untrusted-packages", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("HERMES_LAZY_INSTALL_TARGET"); + }); +}); + describe("Hermes env secret-boundary value-shape discriminator", () => { it("accepts the same secret-shaped key once its value is an openshell resolver placeholder", () => { // The reject path aborts on DEVTEST_API_TOKEN=. Pin the other side of diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index 4f94c8426db..0826e55afa0 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -802,6 +802,9 @@ describe("agents/hermes/start.sh runtime shell env", () => { expect(run.result.status).toBe(0); expect(run.envFileMode).toBe("444"); expect(run.envFileContent).toContain(`export HERMES_HOME="${run.hermesHome}"`); + expect(run.envFileContent).toContain( + 'export HERMES_LAZY_INSTALL_TARGET="/sandbox/.hermes/lazy-packages"', + ); expect(run.envFileContent).toContain('export HERMES_TUI_DIR="/opt/hermes/ui-tui"'); expect(run.envFileContent).not.toContain("AWS_EC2_METADATA_DISABLED"); expect(run.envFileContent).not.toContain('HERMES_TUI_DIR="${HERMES_TUI_DIR:-'); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 1469c2be250..dd570c37a75 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1334,6 +1334,29 @@ describe("Hermes sandbox provisioning", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + it("keeps Hermes lazy dependencies sandbox-owned outside the sealed configuration (#8613)", () => { + const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); + const layout = runHermesLayoutBlock( + HERMES_DOCKERFILE_BASE, + "# Create .hermes with mutable integration dirs", + "# Pre-create shell init files", + ); + try { + expect(layout.result.status, layout.result.stderr).toBe(0); + const lazyPackages = path.join(layout.sandboxRoot, ".hermes", "lazy-packages"); + const metadata = fs.statSync(lazyPackages); + expect(metadata.mode & 0o777).toBe(0o700); + expect(layout.calls).toContain( + `chown -R sandbox:sandbox ${path.join(layout.sandboxRoot, ".hermes")}`, + ); + fs.writeFileSync(path.join(lazyPackages, "restart-marker"), "durable\n"); + expect(fs.readFileSync(path.join(lazyPackages, "restart-marker"), "utf-8")).toBe("durable\n"); + expect(dockerfile).toContain("ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages"); + } finally { + fs.rmSync(layout.tmp, { recursive: true, force: true }); + } + }); + it("grants the Hermes gateway group write access to runtime state directories", () => { const runs = [ runHermesLayoutBlock( diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index e0b61f5bf03..53de05d0a23 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -213,8 +213,8 @@ describe("shields-up state-dir lock preserves sandbox-group access + runtime ses 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"]); + it("uses the Hermes manifest plan for lazy dependencies, pairing, and the private dashboard profile", () => { + expect(HERMES_STATE_LOCK_PLAN.confidentialRoots).toEqual(["lazy-packages", "pairing"]); expect(HERMES_STATE_LOCK_PLAN.writableSubpaths).toEqual(["profiles/dashboard-home"]); }); From 6b4bdafa5a2c53e1275bca8d7e406472e4131a49 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 12 Aug 2026 03:35:01 +0000 Subject: [PATCH 02/13] fix(hermes): enforce lazy package boundaries Signed-off-by: Yimo Jiang --- agents/hermes/Dockerfile | 23 +++++++--- agents/hermes/Dockerfile.base | 2 +- agents/hermes/validate-env-secret-boundary.py | 14 +++--- ...rmes-env-secret-boundary-hardening.test.ts | 43 ++++++++++++++----- test/hermes-start.test.ts | 1 + test/sandbox-provisioning.test.ts | 4 +- 6 files changed, 62 insertions(+), 25 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3b7cfc320ea..db405a46e4c 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -632,7 +632,7 @@ RUN node --experimental-strip-types \ ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=240ed41e79d8ab4239bbd8f01dcff6ffc7ad0d9329f703704198ceb8a48cb904 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a56ef2dcb9b704b2afa1e4522bb846890d72de4f563e07d1aa452ecbd71ad60a ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 # hadolint ignore=DL4006 @@ -1093,7 +1093,7 @@ RUN set -eu; \ /sandbox/.hermes/weixin/accounts \ /sandbox/.hermes/runtime \ /sandbox/.hermes/profiles/dashboard-home \ - && chmod 700 /sandbox/.hermes/lazy-packages \ + && chmod 750 /sandbox/.hermes/lazy-packages \ && chmod 2770 \ /sandbox/.hermes/logs \ /sandbox/.hermes/logs/curator \ @@ -1116,11 +1116,22 @@ RUN set -eu; \ && chmod 660 /sandbox/.hermes/.hermes_history # The Hermes lazy installer uses this sandbox-owned directory instead of the -# sealed root venv. The directory is not part of the sealed configuration. -RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 700" \ +# sealed root venv. Sandbox can install packages; gateway can import them through +# its supplementary sandbox group but cannot modify the dependency tree. +RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + sh -c 'umask 0027; printf "%s\n" "VALUE = 8613" > /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py' \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py)" = "sandbox:sandbox 640" \ + && /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \ /opt/hermes/.venv/bin/python -I -c \ - "from pathlib import Path; target = Path('/sandbox/.hermes/lazy-packages'); marker = target / '.nemoclaw-write-probe'; marker.write_text('ok'); assert marker.read_text() == 'ok'; marker.unlink()" + "import sys; sys.path.append('/sandbox/.hermes/lazy-packages'); import nemoclaw_lazy_probe; assert nemoclaw_lazy_probe.VALUE == 8613" \ + && if /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \ + sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe' 2>/dev/null; then \ + echo "ERROR: gateway can modify Hermes lazy packages" >&2; exit 1; \ + fi \ + && test ! -e /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe \ + && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + rm /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py # Prove the cron execution ledger contract across the real image identities. # Hermes' gateway-side scheduler creates the live WAL-backed database in the @@ -1210,6 +1221,8 @@ RUN chown root:root /sandbox/.nemoclaw \ RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \ chmod -R a+rwX /sandbox/.hermes; \ find /sandbox/.hermes -type d -exec chmod a+rwx {} +; \ + find /sandbox/.hermes/lazy-packages -type d -exec chmod 750 {} +; \ + find /sandbox/.hermes/lazy-packages -type f -exec chmod 640 {} +; \ for p in /sandbox/.nemoclaw/state /sandbox/.nemoclaw/migration /sandbox/.nemoclaw/snapshots /sandbox/.nemoclaw/staging; do \ chmod a+rwx "$p"; \ done; \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index bc28b70ca8b..9660b28f7ed 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -352,7 +352,7 @@ RUN mkdir -p /sandbox/.hermes/memories \ /sandbox/.hermes/platforms/whatsapp/session \ /sandbox/.hermes/gateway \ /sandbox/.hermes/runtime \ - && chmod 700 /sandbox/.hermes/lazy-packages \ + && chmod 750 /sandbox/.hermes/lazy-packages \ && chmod 2770 \ /sandbox/.hermes/logs \ /sandbox/.hermes/logs/curator \ diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index 89ab22f29bc..2d88b5aea9a 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -463,6 +463,11 @@ def validate_env_file(path: str) -> int: if len(violations) < MAX_VIOLATIONS: violations.append(f"{key} (line {lineno})") continue + if key == "HERMES_LAZY_INSTALL_TARGET": + violation_count += 1 + if len(violations) < MAX_VIOLATIONS: + violations.append(f"{key} (line {lineno})") + continue if key in ENV_FILE_ALLOWED_NONSECRET_KEYS: continue if key in ENV_FILE_ALLOWED_RAW_SECRET_KEYS and is_allowed_raw_secret_value( @@ -493,6 +498,10 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int: source = os.environ if env is None else env violations: list[str] = [] violation_count = 0 + if source.get("HERMES_LAZY_INSTALL_TARGET") != "/sandbox/.hermes/lazy-packages": + violation_count += 1 + if len(violations) < MAX_VIOLATIONS: + violations.append("HERMES_LAZY_INSTALL_TARGET") for key, value in sorted(source.items()): if key in OPENSHELL_SUPERVISOR_ONLY_ENV_KEYS: violation_count += 1 @@ -500,11 +509,6 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int: violations.append(key) continue if key == "HERMES_LAZY_INSTALL_TARGET": - if value == "/sandbox/.hermes/lazy-packages": - continue - violation_count += 1 - if len(violations) < MAX_VIOLATIONS: - violations.append(key) continue if key in RUNTIME_ALLOWED_NONSECRET_KEYS: continue diff --git a/test/hermes-env-secret-boundary-hardening.test.ts b/test/hermes-env-secret-boundary-hardening.test.ts index 1f915941a7d..d4204d7c2a0 100644 --- a/test/hermes-env-secret-boundary-hardening.test.ts +++ b/test/hermes-env-secret-boundary-hardening.test.ts @@ -75,7 +75,7 @@ function runStartEnvValidation(hermesDir: string) { } } -function runRuntimeEnvValidation(envOverrides: Record) { +function runRuntimeEnvValidation(envOverrides: Record) { const source = fs.readFileSync(START_SCRIPT, "utf-8"); const runDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-env-check-")); const script = path.join(runDir, "run.sh"); @@ -98,15 +98,20 @@ function runRuntimeEnvValidation(envOverrides: Record) { ].join("\n"), { mode: 0o700 }, ); + const env: NodeJS.ProcessEnv = { + HOME: os.tmpdir(), + PATH: process.env.PATH ?? "", + _HERMES_BOUNDARY_VALIDATOR: VALIDATOR, + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", + ...envOverrides, + }; + for (const [key, value] of Object.entries(envOverrides)) { + if (value === undefined) delete env[key]; + } return spawnSync("bash", [script], { encoding: "utf-8", timeout: 5000, - env: { - HOME: os.tmpdir(), - PATH: process.env.PATH ?? "", - _HERMES_BOUNDARY_VALIDATOR: VALIDATOR, - ...envOverrides, - }, + env, }); } finally { fs.rmSync(runDir, { recursive: true, force: true }); @@ -440,14 +445,30 @@ describe("Hermes durable lazy-install target", () => { expect(result.stderr).toBe(""); }); - it("rejects a lazy target override that could import sandbox code into the gateway (#8613)", () => { - const result = runRuntimeEnvValidation({ - HERMES_LAZY_INSTALL_TARGET: "/tmp/untrusted-packages", - }); + it.each([ + ["missing", undefined], + ["overridden", "/tmp/untrusted-packages"], + ])("rejects a %s lazy target that could mutate or import through the sealed gateway (#8613)", (_case, value) => { + const result = runRuntimeEnvValidation({ HERMES_LAZY_INSTALL_TARGET: value }); expect(result.status).toBe(1); expect(result.stderr).toContain("HERMES_LAZY_INSTALL_TARGET"); }); + + it("rejects the lazy target in the sealed env file even when its value is canonical (#8613)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-lazy-target-")); + const envPath = path.join(root, ".env"); + try { + fs.writeFileSync(envPath, "HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages\n", { + mode: 0o640, + }); + const result = runValidator(envPath); + expect(result.status).toBe(1); + expect(result.stderr).toContain("HERMES_LAZY_INSTALL_TARGET"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); describe("Hermes env secret-boundary value-shape discriminator", () => { diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index 988d7b67a98..d53895012f3 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -235,6 +235,7 @@ function runHermesRuntimeEnvSecretBoundary(envOverrides: Record) HOME: tmpDir, PATH: process.env.PATH ?? "", _HERMES_BOUNDARY_VALIDATOR: SECRET_BOUNDARY_VALIDATOR_SCRIPT, + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", ...envOverrides, }, }); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 9dfd2166c7c..a3d25204d8d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1372,7 +1372,6 @@ describe("Hermes sandbox provisioning", () => { } }); it("keeps Hermes lazy dependencies sandbox-owned outside the sealed configuration (#8613)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); const layout = runHermesLayoutBlock( HERMES_DOCKERFILE_BASE, "# Create .hermes with mutable integration dirs", @@ -1382,13 +1381,12 @@ describe("Hermes sandbox provisioning", () => { expect(layout.result.status, layout.result.stderr).toBe(0); const lazyPackages = path.join(layout.sandboxRoot, ".hermes", "lazy-packages"); const metadata = fs.statSync(lazyPackages); - expect(metadata.mode & 0o777).toBe(0o700); + expect(metadata.mode & 0o777).toBe(0o750); expect(layout.calls).toContain( `chown -R sandbox:sandbox ${path.join(layout.sandboxRoot, ".hermes")}`, ); fs.writeFileSync(path.join(lazyPackages, "restart-marker"), "durable\n"); expect(fs.readFileSync(path.join(lazyPackages, "restart-marker"), "utf-8")).toBe("durable\n"); - expect(dockerfile).toContain("ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages"); } finally { fs.rmSync(layout.tmp, { recursive: true, force: true }); } From 354839046ee0e985088f569970d1222b0175f3ee Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 12 Aug 2026 04:06:52 +0000 Subject: [PATCH 03/13] test(hermes): keep boundary harness linear Signed-off-by: Yimo Jiang --- test/hermes-env-secret-boundary-hardening.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/hermes-env-secret-boundary-hardening.test.ts b/test/hermes-env-secret-boundary-hardening.test.ts index d4204d7c2a0..b7cb310d591 100644 --- a/test/hermes-env-secret-boundary-hardening.test.ts +++ b/test/hermes-env-secret-boundary-hardening.test.ts @@ -105,8 +105,10 @@ function runRuntimeEnvValidation(envOverrides: Record envOverrides[name] === undefined, + )) { + delete env[key]; } return spawnSync("bash", [script], { encoding: "utf-8", From 2667a769c1470125a15a6ba8fe321f45ce957098 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 12 Aug 2026 04:45:50 +0000 Subject: [PATCH 04/13] test(hermes): supply managed lazy install target Signed-off-by: Yimo Jiang --- test/helpers/hermes-wrapper-harness.ts | 7 ++++++- test/hermes-openshell-runtime-env-boundary.test.ts | 1 + test/hermes-secret-boundary-api-key.test.ts | 1 + test/inference-provider-id-rename.test.ts | 1 + test/managed-gateway-control.test.ts | 10 +++++++++- 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index ee90ce9836a..c0c360562ae 100644 --- a/test/helpers/hermes-wrapper-harness.ts +++ b/test/helpers/hermes-wrapper-harness.ts @@ -139,7 +139,12 @@ export function runWrapper( const result = spawnSync(path.join(dir, "hermes"), args, { encoding: "utf-8", timeout: 10000, - env: { PATH: `${pathPrefix}${process.env.PATH ?? ""}`, HOME: dir, ...env }, + env: { + PATH: `${pathPrefix}${process.env.PATH ?? ""}`, + HOME: dir, + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", + ...env, + }, }); const realInvoked = fs.existsSync(marker); diff --git a/test/hermes-openshell-runtime-env-boundary.test.ts b/test/hermes-openshell-runtime-env-boundary.test.ts index 90c0fb58fc8..636a54adf2c 100644 --- a/test/hermes-openshell-runtime-env-boundary.test.ts +++ b/test/hermes-openshell-runtime-env-boundary.test.ts @@ -23,6 +23,7 @@ function runRuntimeEnvValidator(envOverrides: Record) { env: { HOME: os.tmpdir(), PATH: process.env.PATH ?? "", + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", ...envOverrides, }, }); diff --git a/test/hermes-secret-boundary-api-key.test.ts b/test/hermes-secret-boundary-api-key.test.ts index f760660e17f..8fe16fba56d 100644 --- a/test/hermes-secret-boundary-api-key.test.ts +++ b/test/hermes-secret-boundary-api-key.test.ts @@ -46,6 +46,7 @@ function runRuntimeEnvValidator(envOverrides: Record) { env: { HOME: os.tmpdir(), PATH: process.env.PATH ?? "", + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", ...envOverrides, }, }); diff --git a/test/inference-provider-id-rename.test.ts b/test/inference-provider-id-rename.test.ts index bb387273f13..b08b7503175 100644 --- a/test/inference-provider-id-rename.test.ts +++ b/test/inference-provider-id-rename.test.ts @@ -201,6 +201,7 @@ describe("Hermes runtime provider route identifier boundary (#7177)", () => { env: { HOME: os.tmpdir(), PATH: process.env.PATH ?? "", + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", ...env, }, }); diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index f31ee4d5fa0..87d1a540db7 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -505,7 +505,11 @@ with tempfile.TemporaryDirectory() as root: try: control._validate_runtime_environment( sys.argv[2], - {"LD_PRELOAD": "/tmp/attacker.so", "SAFE": "1"}, + { + "LD_PRELOAD": "/tmp/attacker.so", + "SAFE": "1", + "HERMES_LAZY_INSTALL_TARGET": "/sandbox/.hermes/lazy-packages", + }, ) runtime_validation = "in-process" finally: @@ -1295,6 +1299,10 @@ describe("managed gateway root control", () => { const result = spawnSync("python3", ["-c", PROCESS_HARNESS, HELPER, BOUNDARY_VALIDATOR], { encoding: "utf-8", timeout: 10_000, + env: { + ...process.env, + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", + }, }); expect(result.status, result.stderr).toBe(0); From 7dca30101cb1afcb2e0a8c5b47ea78e45ae67b66 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 03:03:37 -0700 Subject: [PATCH 05/13] fix(hermes): secure lazy dependency lifecycle Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 68 +++++++++++++--- agents/hermes/manifest.yaml | 2 +- agents/hermes/security-dependencies.patch | 35 +++++++++ agents/hermes/start.sh | 53 ++++++++++++- agents/hermes/state-lock-plan.json | 2 +- agents/hermes/validate-env-secret-boundary.py | 7 +- .../install-plugins-hermes.mdx | 30 ++++++-- .../agent/state-directory-contract.test.ts | 3 +- test/hermes-dependency-review.test.ts | 25 ++++++ test/hermes-lazy-dependency-lifecycle.test.ts | 77 +++++++++++++++++++ test/hermes-start.test.ts | 4 + test/sandbox-provisioning.test.ts | 4 +- test/shields-up-runtime-perms.test.ts | 5 +- 13 files changed, 289 insertions(+), 26 deletions(-) create mode 100644 test/hermes-lazy-dependency-lifecycle.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index db405a46e4c..26504f729e0 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -170,6 +170,30 @@ FROM ${BASE_IMAGE} # Dockerfile.base. The final stage also creates and verifies the directory. ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages +# A published base can lag the source patch in Dockerfile.base. Apply only the +# two Hindsight compatibility hunks when needed, then verify the final source +# contract. This remains idempotent once the published base contains them. +COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch +RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ + git -C /opt/hermes apply --check \ + --include=hermes_cli/memory_setup.py \ + /tmp/hermes-security-dependencies.patch; \ + git -C /opt/hermes apply \ + --include=hermes_cli/memory_setup.py \ + /tmp/hermes-security-dependencies.patch; \ + fi \ + && if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \ + git -C /opt/hermes apply --check \ + --include=plugins/memory/hindsight/plugin.yaml \ + /tmp/hermes-security-dependencies.patch; \ + git -C /opt/hermes apply \ + --include=plugins/memory/hindsight/plugin.yaml \ + /tmp/hermes-security-dependencies.patch; \ + fi \ + && grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \ + && grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \ + && rm /tmp/hermes-security-dependencies.patch + # Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When # onboard detects an operator-supplied corporate CA on the host it bakes it # here; the RUN below decodes it to a root-owned file that the entrypoint @@ -632,7 +656,7 @@ RUN node --experimental-strip-types \ ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a56ef2dcb9b704b2afa1e4522bb846890d72de4f563e07d1aa452ecbd71ad60a +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=a0c87387c0a00e7aad5892375303115577d72293ea6af7d15d1861e6a02c62c6 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 # hadolint ignore=DL4006 @@ -1115,23 +1139,47 @@ RUN set -eu; \ && chown sandbox:sandbox /sandbox/.hermes/.hermes_history \ && chmod 660 /sandbox/.hermes/.hermes_history -# The Hermes lazy installer uses this sandbox-owned directory instead of the -# sealed root venv. Sandbox can install packages; gateway can import them through -# its supplementary sandbox group but cannot modify the dependency tree. +# Exercise the real reviewed lazy installer under the sandbox identity, then +# perform the same read-only state-guard transition used by Shields Up. The +# gateway must retain import access after the lock while both runtime identities +# remain unable to mutate the dependency tree. Remove the probe install so the +# published image still performs installation only when Hindsight is selected. RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" \ - && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ - sh -c 'umask 0027; printf "%s\n" "VALUE = 8613" > /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py' \ - && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py)" = "sandbox:sandbox 640" \ + && HOME=/sandbox \ + UV_CACHE_DIR=/sandbox/.hermes/cache/uv \ + UV_NO_CACHE=1 \ + HERMES_HOME=/sandbox/.hermes \ + HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \ + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /opt/hermes/.venv/bin/python -I -c \ + "from tools.lazy_deps import ensure; ensure('memory.hindsight', prompt=False)" \ + && HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \ + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /opt/hermes/.venv/bin/python -I -c \ + "from tools.lazy_deps import activate_durable_lazy_target; activate_durable_lazy_target(); import importlib.metadata as m; assert m.version('hindsight-client') == '0.6.1'" \ + && lazy_plan='{"version":1,"readOnlyRoots":["lazy-packages"],"confidentialRoots":[],"readOnlyPrefixes":[],"confidentialPrefixes":[],"writableSubpaths":[]}' \ + && /usr/local/lib/nemoclaw/state-dir-guard.py lock \ + --config-dir /sandbox/.hermes --plan-json "$lazy_plan" \ && /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \ + env HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages \ /opt/hermes/.venv/bin/python -I -c \ - "import sys; sys.path.append('/sandbox/.hermes/lazy-packages'); import nemoclaw_lazy_probe; assert nemoclaw_lazy_probe.VALUE == 8613" \ + "from tools.lazy_deps import activate_durable_lazy_target; activate_durable_lazy_target(); import hindsight_client" \ && if /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- \ sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe' 2>/dev/null; then \ - echo "ERROR: gateway can modify Hermes lazy packages" >&2; exit 1; \ + echo "ERROR: gateway can modify locked Hermes lazy packages" >&2; exit 1; \ + fi \ + && if /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + sh -c ': > /sandbox/.hermes/lazy-packages/.python-abi' 2>/dev/null; then \ + echo "ERROR: sandbox can modify locked Hermes lazy packages" >&2; exit 1; \ fi \ && test ! -e /sandbox/.hermes/lazy-packages/.nemoclaw-gateway-write-probe \ + && /usr/local/lib/nemoclaw/state-dir-guard.py unlock \ + --config-dir /sandbox/.hermes --plan-json "$lazy_plan" \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ - rm /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe.py + find /sandbox/.hermes/lazy-packages -mindepth 1 -delete \ + && test -z "$(find /sandbox/.hermes/lazy-packages -mindepth 1 -print -quit)" \ + && chmod 750 /sandbox/.hermes/lazy-packages \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" # Prove the cron execution ledger contract across the real image identities. # Hermes' gateway-side scheduler creates the live WAL-backed database in the diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 96a03d67f84..84cdb30b022 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -73,7 +73,7 @@ state_dirs: - memories # Hermes lazy dependency installs persist here outside sealed configuration. - path: lazy-packages - shields: confidential + shields: read-only - sessions - path: skills shields: read-only diff --git a/agents/hermes/security-dependencies.patch b/agents/hermes/security-dependencies.patch index 6733e1a932f..aebd6cc48bc 100644 --- a/agents/hermes/security-dependencies.patch +++ b/agents/hermes/security-dependencies.patch @@ -90,6 +90,41 @@ index c630b3c..fd28f6a 100644 all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every +diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py +index f6345d2..5bf65a4 100644 +--- a/hermes_cli/memory_setup.py ++++ b/hermes_cli/memory_setup.py +@@ -123,1 +123,17 @@ def _install_dependencies(provider_name: str) -> None: ++ # NemoClaw seals the Hermes venv and redirects this reviewed dependency ++ # to a durable sandbox-owned target. Reuse the allowlisted lazy installer ++ # so setup never falls back to mutating /opt/hermes or resolving a looser ++ # plugin.yaml range. The setup command itself runs as the sandbox user. ++ if provider_name == "hindsight": ++ from tools.lazy_deps import FeatureUnavailable, ensure ++ ++ try: ++ ensure("memory.hindsight", prompt=False) ++ except FeatureUnavailable as exc: ++ raise RuntimeError( ++ "Hindsight dependency installation failed through the managed lazy target" ++ ) from exc ++ print(" Installed hindsight-client==0.6.1") ++ return ++ + print(f"\n Installing dependencies: {', '.join(missing)}") +diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml +index 5b37014..f763fd5 100644 +--- a/plugins/memory/hindsight/plugin.yaml ++++ b/plugins/memory/hindsight/plugin.yaml +@@ -1,7 +1,7 @@ + name: hindsight + version: 1.0.0 + description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval." + pip_dependencies: +- - "hindsight-client>=0.6.1" ++ - "hindsight-client==0.6.1" + requires_env: [] + hooks: diff --git a/uv.lock b/uv.lock index 257f7d6..d2f7607 100644 --- a/uv.lock diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 6bb51757cd2..4b869c59b54 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -458,6 +458,54 @@ verify_hermes_config_integrity() { fi } +prepare_hermes_lazy_dependencies() { + local -a installer=( + env + HOME=/sandbox + UV_CACHE_DIR=/sandbox/.hermes/cache/uv + UV_NO_CACHE=1 + HERMES_HOME="$HERMES_DIR" + HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages + ) + + # The separated gateway identity deliberately has no write access to the + # durable dependency tree. Route the allowlisted installer through sandbox; + # same-UID OpenShell startup is already running under that identity. + if [ "$(id -u)" -eq 0 ]; then + installer+=("${STEP_DOWN_PREFIX_SANDBOX[@]}") + fi + installer+=("$_HERMES_PYTHON" -I -c) + + "${installer[@]}" ' +import os +from pathlib import Path + +import yaml + +config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml" +try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} +except Exception as exc: + raise SystemExit(f"[SECURITY] Unable to inspect Hermes memory configuration: {exc}") from exc + +memory = config.get("memory") if isinstance(config, dict) else None +provider = memory.get("provider") if isinstance(memory, dict) else None +if provider != "hindsight": + raise SystemExit(0) + +from tools.lazy_deps import activate_durable_lazy_target, ensure + +activate_durable_lazy_target() +try: + ensure("memory.hindsight", prompt=False) +except Exception as exc: + raise SystemExit( + "[SECURITY] Unable to prepare the approved Hindsight dependency " + f"under the sandbox-owned lazy-install target: {exc}" + ) from exc +' +} + # configure_messaging_channels is provided by sandbox-init.sh (shared). print_dashboard_urls() { @@ -2333,7 +2381,8 @@ prepare_hermes_gateway_restart() { # sandboxes instead of chowning attacker-controlled paths or adopting a new # hash here. HERMES_RESTART_FAILURE_CODE=hash-mismatch - verify_hermes_config_integrity + verify_hermes_config_integrity || return 1 + prepare_hermes_lazy_dependencies } hermes_restart_unseal_on_exit() { @@ -3029,6 +3078,7 @@ prepare_hermes_nonroot_runtime() { # startup mutations below so their outputs remain covered as well. validate_hermes_env_secret_boundary || return 1 inspect_hermes_mcp_integrity "${HERMES_DIR}/.config-hash" || return 1 + prepare_hermes_lazy_dependencies || return 1 ensure_hermes_runtime_api_server_key compat || return 1 apply_shields_up_runtime_env || return 1 validate_hermes_env_secret_boundary || return 1 @@ -3042,6 +3092,7 @@ prepare_hermes_nonroot_runtime() { prepare_hermes_root_runtime() { verify_hermes_config_integrity || return 1 + prepare_hermes_lazy_dependencies || return 1 ensure_hermes_config_root_mode || return 1 ensure_hermes_runtime_api_server_key both || return 1 apply_shields_up_runtime_env || return 1 diff --git a/agents/hermes/state-lock-plan.json b/agents/hermes/state-lock-plan.json index 1e83c661f20..0cfc9d25a47 100644 --- a/agents/hermes/state-lock-plan.json +++ b/agents/hermes/state-lock-plan.json @@ -4,6 +4,7 @@ "readOnlyRoots": [ "cron", "hooks", + "lazy-packages", "platforms", "plugins", "profiles", @@ -13,7 +14,6 @@ "workspace" ], "confidentialRoots": [ - "lazy-packages", "pairing" ], "readOnlyPrefixes": [], diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index 2d88b5aea9a..e1f43f0d6a7 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -486,7 +486,9 @@ def validate_env_file(path: str) -> int: _emit_violations( "[SECURITY] Refusing Hermes startup because /sandbox/.hermes/.env " "contains raw secret-shaped values or OpenShell supervisor-only identity " - "variables. Store credentials in OpenShell providers and keep only " + "variables, or declares HERMES_LAZY_INSTALL_TARGET. Store credentials " + "in OpenShell providers, keep the managed lazy-install target outside " + "the sealed env file, and keep only " "openshell resolver placeholders in the sandbox.", violations, violation_count - len(violations), @@ -530,7 +532,8 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int: _emit_violations( "[SECURITY] Refusing Hermes startup because the process environment " "contains raw secret-shaped values or OpenShell supervisor-only identity " - "variables. Store credentials in OpenShell providers and keep only " + "variables, or does not use the managed HERMES_LAZY_INSTALL_TARGET. " + "Store credentials in OpenShell providers and keep only " "openshell resolver placeholders in the sandbox.", violations, violation_count - len(violations), diff --git a/docs/manage-sandboxes/install-plugins-hermes.mdx b/docs/manage-sandboxes/install-plugins-hermes.mdx index 529ee454a73..770128bb7af 100644 --- a/docs/manage-sandboxes/install-plugins-hermes.mdx +++ b/docs/manage-sandboxes/install-plugins-hermes.mdx @@ -28,7 +28,7 @@ Do not replace or remove `/sandbox/.hermes/plugins/nemoclaw` when you add your o The stock Hermes image provides a durable lazy-install directory at `/sandbox/.hermes/lazy-packages`. Hermes installs the bundled Hindsight plugin dependency in this directory as the sandbox user. -The image keeps the root virtual environment and sealed configuration unchanged. +The dependency does not modify the root virtual environment, and the managed target is not declared in the sealed `.env` file. NemoClaw supports `hindsight-client==0.6.1` for this workflow. Hermes checks this exact version before it loads the plugin. @@ -36,7 +36,7 @@ This version uses the OpenShell proxy path. Do not replace it with a `0.8.x` client because those releases do not use the proxy environment. Start the self-hosted Hindsight service on the host before you configure the plugin. -The `local-memory` preset permits the Hermes Python runtime to call `host.openshell.internal` on port `8888`. +The `local-memory` preset permits only `GET` and `POST` requests from the Hermes Python runtime to `host.openshell.internal` on port `8888`. The preset does not permit another private host or port. Add the preset to the sandbox: @@ -45,6 +45,18 @@ Add the preset to the sandbox: nemohermes policy add local-memory --yes ``` +Check the current Shields posture: + +```bash +nemohermes shields status +``` + +If Shields are up, open a timed maintenance window before setup changes the Hermes configuration and lazy dependency directory: + +```bash +nemohermes shields down --timeout 15m --reason "Configure Hindsight memory" +``` + Run the Hermes setup command through the NemoClaw CLI: ```bash @@ -61,7 +73,15 @@ After the setup command finishes, restart the gateway: nemohermes gateway restart ``` -The first plugin load installs `hindsight-client==0.6.1` under `/sandbox/.hermes/lazy-packages`. +If you lowered Shields for setup, restore them after the restart: + +```bash +nemohermes shields up +``` + +The setup command installs `hindsight-client==0.6.1` under `/sandbox/.hermes/lazy-packages` as the sandbox user. +When `memory.provider` is `hindsight`, gateway startup repairs a missing approved dependency before it launches. +After Shields lock the directory, the gateway can import the client, but neither the gateway nor the sandbox user can modify the dependency tree. The directory is outside the integrity-sealed `.env`, `config.yaml`, and `.config-hash` files. It is part of the Hermes durable state plan, so the dependency remains available after a gateway restart. @@ -82,8 +102,8 @@ If an earlier root install changed `/opt/hermes/.venv`, rebuild the sandbox befo nemohermes rebuild --yes ``` -A rebuild recreates the lazy-install directory. -The first Hindsight plugin load after the rebuild reinstalls the supported client in that directory. +A rebuild replaces the root virtual environment and preserves any captured lazy-install directory as declared Hermes state. +If the dependency is absent from the restored directory, the next setup or gateway start reinstalls the supported client as the sandbox user. Do not edit `/sandbox/.hermes/.env` to set `HERMES_LAZY_INSTALL_TARGET`. Do not install the dependency into `/opt/hermes/.venv`. diff --git a/src/lib/agent/state-directory-contract.test.ts b/src/lib/agent/state-directory-contract.test.ts index e8f5eda7f07..9478bdca0a8 100644 --- a/src/lib/agent/state-directory-contract.test.ts +++ b/src/lib/agent/state-directory-contract.test.ts @@ -109,6 +109,7 @@ describe("agent state directory contract", () => { readOnlyRoots: [ "cron", "hooks", + "lazy-packages", "platforms", "plugins", "profiles", @@ -117,7 +118,7 @@ describe("agent state directory contract", () => { "weixin", "workspace", ], - confidentialRoots: ["lazy-packages", "pairing"], + confidentialRoots: ["pairing"], readOnlyPrefixes: [], confidentialPrefixes: [], writableSubpaths: ["profiles/dashboard-home"], diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 02de681c41b..7e4900a7bc9 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -12,6 +12,7 @@ const dockerfileBase = fs.readFileSync( path.join(root, "agents", "hermes", "Dockerfile.base"), "utf8", ); +const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); const config = fs.readFileSync( path.join(root, "agents", "hermes", "config", "managed-policy.ts"), "utf8", @@ -144,14 +145,38 @@ describe("Hermes 0.19.0 dependency review", () => { expect(dockerfileBase).toContain( "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", ); + expect(dockerfile).toContain( + "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", + ); expect(dockerfileBase).toContain( "git -C /opt/hermes apply --check /tmp/hermes-security-dependencies.patch", ); + expect(dockerfile).toContain("--include=hermes_cli/memory_setup.py"); + expect(dockerfile).toContain("--include=plugins/memory/hindsight/plugin.yaml"); + expect(dockerfile).toContain( + "grep -Fq 'ensure(\"memory.hindsight\", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py", + ); + expect(dockerfile).toContain( + "grep -Fqx ' - \"hindsight-client==0.6.1\"' /opt/hermes/plugins/memory/hindsight/plugin.yaml", + ); + expect(dockerfile).toContain( + "from tools.lazy_deps import ensure; ensure('memory.hindsight', prompt=False)", + ); + expect(dockerfile).toContain("state-dir-guard.py lock"); + expect(dockerfile).toContain("--reuid=gateway --regid=gateway --init-groups"); + expect(dockerfile).toContain("gateway can modify locked Hermes lazy packages"); + expect(dockerfile).toContain("sandbox can modify locked Hermes lazy packages"); + expect(dockerfile).toContain( + `test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750"`, + ); expect(dockerfileBase).toContain("uv pip check --python /opt/hermes/.venv/bin/python"); expect(arg("NODE_VERSION")).toBe("24.18.1"); expect(arg("UV_VERSION")).toBe("0.11.33"); expect(securityDependenciesPatch).toContain('hindsight = ["hindsight-client==0.6.1"]'); expect(securityDependenciesPatch).not.toContain("hindsight-client==0.8."); + expect(securityDependenciesPatch).toContain('ensure("memory.hindsight", prompt=False)'); + expect(securityDependenciesPatch).toContain('- - "hindsight-client>=0.6.1"'); + expect(securityDependenciesPatch).toContain('+ - "hindsight-client==0.6.1"'); for (const selection of [ '"aiohttp==3.14.3"', '"cryptography==50.0.0"', diff --git a/test/hermes-lazy-dependency-lifecycle.test.ts b/test/hermes-lazy-dependency-lifecycle.test.ts new file mode 100644 index 00000000000..9a398f6baf5 --- /dev/null +++ b/test/hermes-lazy-dependency-lifecycle.test.ts @@ -0,0 +1,77 @@ +// 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 { shellQuote } from "../src/lib/core/shell-quote"; +import { extractShellFunction } from "./support/hermes-shell-harness"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); + +function runLazyDependencyPreparation(root: boolean) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-lazy-prep-")); + const pythonPath = path.join(tmpDir, "python3"); + const handoffPath = path.join(tmpDir, "sandbox-handoff"); + const scriptPath = path.join(tmpDir, "run.sh"); + const source = fs.readFileSync(START_SCRIPT, "utf-8"); + + fs.writeFileSync( + pythonPath, + [ + "#!/usr/bin/env sh", + 'printf "identity=%s\\n" "${NEMOCLAW_INSTALL_IDENTITY:-current}"', + 'printf "home=%s\\n" "$HOME"', + 'printf "target=%s\\n" "$HERMES_LAZY_INSTALL_TARGET"', + 'case "$*" in *\'ensure("memory.hindsight", prompt=False)\'*) printf "installer=reviewed\\n" ;; *) exit 9 ;; esac', + ].join("\n"), + { mode: 0o700 }, + ); + fs.writeFileSync( + handoffPath, + ["#!/usr/bin/env sh", "export NEMOCLAW_INSTALL_IDENTITY=sandbox", 'exec "$@"'].join("\n"), + { mode: 0o700 }, + ); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + extractShellFunction(source, "prepare_hermes_lazy_dependencies"), + `id() { [ "\${1:-}" = "-u" ] && printf "${root ? "0" : "1000"}\\n" || command id "$@"; }`, + `HERMES_DIR=${shellQuote(path.join(tmpDir, ".hermes"))}`, + `_HERMES_PYTHON=${shellQuote(pythonPath)}`, + `STEP_DOWN_PREFIX_SANDBOX=(${shellQuote(handoffPath)})`, + "prepare_hermes_lazy_dependencies", + ].join("\n"), + { mode: 0o700 }, + ); + + try { + return spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: process.env, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("Hermes lazy dependency lifecycle", () => { + it.each([ + ["root-separated", true, "sandbox"], + ["same-identity", false, "current"], + ] as const)("runs approved preparation under the sandbox owner (%s) (#8613)", (_mode, root, identity) => { + const result = runLazyDependencyPreparation(root); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain(`identity=${identity}`); + expect(result.stdout).toContain("home=/sandbox"); + expect(result.stdout).toContain("target=/sandbox/.hermes/lazy-packages"); + expect(result.stdout).toContain("installer=reviewed"); + }); +}); diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index d53895012f3..d3f1a4e9cb9 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -337,6 +337,7 @@ function runHermesRootStartupMutableRootPreflight() { 'chmod() { if [ "${1:-}" = "3770" ] && [ "${2:-}" = "$HERMES_DIR" ]; then printf "%s\\n" "$1" > "$CHMOD_LOG"; HERMES_DIR_MODE=770; command chmod 770 "$2"; return 0; fi; command chmod "$@"; }', 'dir_mode() { printf "%s\\n" "$HERMES_DIR_MODE"; }', 'verify_hermes_config_integrity() { printf "verify mode=%s\\n" "$(dir_mode)"; }', + 'prepare_hermes_lazy_dependencies() { printf "lazy mode=%s\\n" "$(dir_mode)"; }', 'ensure_hermes_runtime_api_server_key() { printf "api-key mode=%s\\n" "$(dir_mode)"; }', "apply_shields_up_runtime_env() { :; }", "validate_hermes_env_secret_boundary() { :; }", @@ -1002,6 +1003,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "verify_config_integrity_if_locked() { trace integrity; }", "validate_hermes_env_secret_boundary() { trace env-boundary; }", "inspect_hermes_mcp_integrity() { trace mcp-integrity; }", + "prepare_hermes_lazy_dependencies() { trace lazy-dependencies; }", "ensure_hermes_runtime_api_server_key() { trace api-key; }", "apply_shields_up_runtime_env() { trace shields-env; }", "validate_hermes_runtime_env_secret_boundary() { trace runtime-boundary; }", @@ -1022,6 +1024,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { "integrity", "env-boundary", "mcp-integrity", + "lazy-dependencies", "api-key", "shields-env", "env-boundary", @@ -1453,6 +1456,7 @@ describe("agents/hermes/start.sh Tirith marker bootstrap", () => { expect(run.result.status).toBe(0); expect(run.result.stdout).toContain("verify mode=750"); + expect(run.result.stdout).toContain("lazy mode=750"); expect(run.result.stdout).toContain("api-key mode=770"); expect(run.result.stdout).toContain("tirith-state=0"); expect(run.hermesDirMode).toBe("3770"); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index a3d25204d8d..109636d9633 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1371,7 +1371,7 @@ describe("Hermes sandbox provisioning", () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("keeps Hermes lazy dependencies sandbox-owned outside the sealed configuration (#8613)", () => { + it("creates the Hermes lazy dependency target with sandbox ownership (#8613)", () => { const layout = runHermesLayoutBlock( HERMES_DOCKERFILE_BASE, "# Create .hermes with mutable integration dirs", @@ -1385,8 +1385,6 @@ describe("Hermes sandbox provisioning", () => { expect(layout.calls).toContain( `chown -R sandbox:sandbox ${path.join(layout.sandboxRoot, ".hermes")}`, ); - fs.writeFileSync(path.join(lazyPackages, "restart-marker"), "durable\n"); - expect(fs.readFileSync(path.join(lazyPackages, "restart-marker"), "utf-8")).toBe("durable\n"); } finally { fs.rmSync(layout.tmp, { recursive: true, force: true }); } diff --git a/test/shields-up-runtime-perms.test.ts b/test/shields-up-runtime-perms.test.ts index 71d4c09e883..209ee1507b3 100644 --- a/test/shields-up-runtime-perms.test.ts +++ b/test/shields-up-runtime-perms.test.ts @@ -216,8 +216,9 @@ describe("shields-up state-dir lock preserves sandbox-group access + runtime ses expect(OPENCLAW_STATE_LOCK_PLAN.writableSubpaths).toEqual(["agents/*/sessions"]); }); - it("uses the Hermes manifest plan for lazy dependencies, pairing, and the private dashboard profile", () => { - expect(HERMES_STATE_LOCK_PLAN.confidentialRoots).toEqual(["lazy-packages", "pairing"]); + it("keeps Hermes lazy dependencies gateway-readable while locking writes (#8613)", () => { + expect(HERMES_STATE_LOCK_PLAN.readOnlyRoots).toContain("lazy-packages"); + expect(HERMES_STATE_LOCK_PLAN.confidentialRoots).toEqual(["pairing"]); expect(HERMES_STATE_LOCK_PLAN.writableSubpaths).toEqual(["profiles/dashboard-home"]); }); From dd69505b5d30783a2668bf15c51e82a4b642c491 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 02:40:25 -0700 Subject: [PATCH 06/13] test(cli): include shared gateway launcher in startup fixture Signed-off-by: Apurv Kumaria --- test/openclaw-2026-7-startup-compat.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/openclaw-2026-7-startup-compat.test.ts b/test/openclaw-2026-7-startup-compat.test.ts index 7310d07400c..142470791bf 100644 --- a/test/openclaw-2026-7-startup-compat.test.ts +++ b/test/openclaw-2026-7-startup-compat.test.ts @@ -153,6 +153,10 @@ describe("OpenClaw 2026.7 startup compatibility", () => { { mode: 0o700 }, ); const source = fs.readFileSync(START_SCRIPT, "utf-8"); + const launchProcess = extractShellFunction( + source, + "launch_openclaw_gateway_process", + ).replaceAll("/tmp/gateway.log", gatewayLog); const launch = extractShellFunction(source, "launch_openclaw_gateway").replaceAll( "/tmp/gateway.log", gatewayLog, @@ -171,6 +175,7 @@ describe("OpenClaw 2026.7 startup compatibility", () => { "mark_in_container_gateway() { :; }", "capture_openclaw_pid_start_identity() { printf -v \"$2\" '%s' test-identity; }", "record_gateway_pid() { :; }", + launchProcess, launch, "launch_openclaw_gateway", 'wait "$GATEWAY_PID"', From 4c4aebb43395aa6f2f7888397a297be85a3100d3 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 01:14:58 -0700 Subject: [PATCH 07/13] test(sandbox): include shared gateway launcher in fixtures Signed-off-by: Apurv Kumaria --- test/nemoclaw-start-gateway-health.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/nemoclaw-start-gateway-health.test.ts b/test/nemoclaw-start-gateway-health.test.ts index 13ac5899a73..acb76d83b20 100644 --- a/test/nemoclaw-start-gateway-health.test.ts +++ b/test/nemoclaw-start-gateway-health.test.ts @@ -32,6 +32,10 @@ function rootGatewayLifecycleFunctions(src: string, gatewayLog: string): string return [ pidIdentityFunctions(src), extractShellFunction(src, "arm_openclaw_gateway_supervisor_cleanup"), + extractShellFunction(src, "launch_openclaw_gateway_process").replaceAll( + "/tmp/gateway.log", + gatewayLog, + ), extractShellFunction(src, "launch_openclaw_gateway").replaceAll("/tmp/gateway.log", gatewayLog), extractShellFunction(src, "openclaw_supervised_aux_pid_is_live"), extractShellFunction(src, "stop_openclaw_supervised_gateway"), From de08d9c7787846ab4793069ae9fe9753eb1541e4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 03:24:24 -0700 Subject: [PATCH 08/13] fix(hermes): restore lazy dependency probe state Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 5 ++++- test/hermes-gateway-supervisor-recovery.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 26504f729e0..bae77d1069f 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -1176,8 +1176,11 @@ RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandb && /usr/local/lib/nemoclaw/state-dir-guard.py unlock \ --config-dir /sandbox/.hermes --plan-json "$lazy_plan" \ && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ - find /sandbox/.hermes/lazy-packages -mindepth 1 -delete \ + sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe' \ + && test -f /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe \ + && find /sandbox/.hermes/lazy-packages -mindepth 1 -delete \ && test -z "$(find /sandbox/.hermes/lazy-packages -mindepth 1 -print -quit)" \ + && chown sandbox:sandbox /sandbox/.hermes/lazy-packages \ && chmod 750 /sandbox/.hermes/lazy-packages \ && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" diff --git a/test/hermes-gateway-supervisor-recovery.test.ts b/test/hermes-gateway-supervisor-recovery.test.ts index 0e5f23988ba..81f6c999cf5 100644 --- a/test/hermes-gateway-supervisor-recovery.test.ts +++ b/test/hermes-gateway-supervisor-recovery.test.ts @@ -27,7 +27,7 @@ function runHermesHealthyGatewayRecovery(integrityStatus: 0 | 1) { 'gateway_control_pid_is_live() { trace "pid-live:$1"; return 0; }', "hermes_gateway_healthy() { trace gateway-healthy; return 0; }", "validate_running_hermes_boundary() { trace boundary-validation; return 0; }", - `verify_hermes_config_integrity() { trace strict-integrity; return ${integrityStatus}; }`, + `verify_hermes_config_integrity() { trace strict-integrity; return ${integrityStatus}; }\nprepare_hermes_lazy_dependencies() { return 0; }`, "hermes_auxiliaries_need_recovery() { trace auxiliaries-needed; return 0; }", "seal_hermes_restart_inputs() { trace seal-inputs; return 0; }", "unseal_hermes_restart_inputs() { trace unseal-inputs; return 0; }", From cb3adf3de73ef1d4e4e6f15c82521f71f4c90d7c Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 03:43:10 -0700 Subject: [PATCH 09/13] fix(hermes): complete lazy dependency validation Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 8 +-- test/hermes-final-image-layout.test.ts | 1 + test/hermes-lazy-dependency-lifecycle.test.ts | 59 +++++++++++++++++-- test/hermes-tirith-retry-finalization.test.ts | 1 + 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index bae77d1069f..1c680a314ed 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -72,6 +72,7 @@ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts +COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch FROM scratch AS hermes-agent-payload @@ -173,7 +174,6 @@ ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages # A published base can lag the source patch in Dockerfile.base. Apply only the # two Hindsight compatibility hunks when needed, then verify the final source # contract. This remains idempotent once the published base contains them. -COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ git -C /opt/hermes apply --check \ --include=hermes_cli/memory_setup.py \ @@ -1178,10 +1178,8 @@ RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandb && /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ sh -c ': > /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe' \ && test -f /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe \ - && find /sandbox/.hermes/lazy-packages -mindepth 1 -delete \ - && test -z "$(find /sandbox/.hermes/lazy-packages -mindepth 1 -print -quit)" \ - && chown sandbox:sandbox /sandbox/.hermes/lazy-packages \ - && chmod 750 /sandbox/.hermes/lazy-packages \ + && rm -rf /sandbox/.hermes/lazy-packages \ + && install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages \ && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" # Prove the cron execution ledger contract across the real image identities. diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index c5643b4f692..ed48b5b8cb2 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -247,6 +247,7 @@ describe("Hermes final image layout", () => { "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts", "COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts", "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", + "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", ], }, { diff --git a/test/hermes-lazy-dependency-lifecycle.test.ts b/test/hermes-lazy-dependency-lifecycle.test.ts index 9a398f6baf5..8995520ecb3 100644 --- a/test/hermes-lazy-dependency-lifecycle.test.ts +++ b/test/hermes-lazy-dependency-lifecycle.test.ts @@ -12,21 +12,62 @@ import { extractShellFunction } from "./support/hermes-shell-harness"; const START_SCRIPT = path.join(import.meta.dirname, "..", "agents", "hermes", "start.sh"); -function runLazyDependencyPreparation(root: boolean) { +function runLazyDependencyPreparation(root: boolean, provider = "hindsight") { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-lazy-prep-")); const pythonPath = path.join(tmpDir, "python3"); + const pythonHarnessPath = path.join(tmpDir, "python-harness.py"); const handoffPath = path.join(tmpDir, "sandbox-handoff"); const scriptPath = path.join(tmpDir, "run.sh"); + const hermesDir = path.join(tmpDir, ".hermes"); const source = fs.readFileSync(START_SCRIPT, "utf-8"); + fs.mkdirSync(hermesDir); + fs.writeFileSync(path.join(hermesDir, "config.yaml"), `memory:\n provider: ${provider}\n`); + fs.writeFileSync( + pythonHarnessPath, + [ + "import sys", + "import types", + "", + "yaml = types.ModuleType('yaml')", + "def safe_load(text):", + " provider = next(", + " (line.split(':', 1)[1].strip() for line in text.splitlines() if line.strip().startswith('provider:')),", + " None,", + " )", + " return {'memory': {'provider': provider}}", + "yaml.safe_load = safe_load", + "sys.modules['yaml'] = yaml", + "", + "tools = types.ModuleType('tools')", + "tools.__path__ = []", + "lazy_deps = types.ModuleType('tools.lazy_deps')", + "def activate_durable_lazy_target():", + " print('activated=durable')", + "def ensure(name, prompt=False):", + " if name != 'memory.hindsight' or prompt is not False:", + " raise AssertionError('unexpected lazy dependency request')", + " print('installer=reviewed')", + "lazy_deps.activate_durable_lazy_target = activate_durable_lazy_target", + "lazy_deps.ensure = ensure", + "sys.modules['tools'] = tools", + "sys.modules['tools.lazy_deps'] = lazy_deps", + "", + "program = sys.argv[1]", + "exec(compile(program, '', 'exec'), {'__name__': '__main__'})", + ].join("\n"), + ); + fs.writeFileSync( pythonPath, [ - "#!/usr/bin/env sh", + "#!/usr/bin/env bash", + "set -euo pipefail", 'printf "identity=%s\\n" "${NEMOCLAW_INSTALL_IDENTITY:-current}"', 'printf "home=%s\\n" "$HOME"', 'printf "target=%s\\n" "$HERMES_LAZY_INSTALL_TARGET"', - 'case "$*" in *\'ensure("memory.hindsight", prompt=False)\'*) printf "installer=reviewed\\n" ;; *) exit 9 ;; esac', + 'program="${@: -1}"', + `exec ${shellQuote(process.env.PYTHON || "python3")} -I ${shellQuote(pythonHarnessPath)} "$program"`, ].join("\n"), { mode: 0o700 }, ); @@ -42,7 +83,7 @@ function runLazyDependencyPreparation(root: boolean) { "set -euo pipefail", extractShellFunction(source, "prepare_hermes_lazy_dependencies"), `id() { [ "\${1:-}" = "-u" ] && printf "${root ? "0" : "1000"}\\n" || command id "$@"; }`, - `HERMES_DIR=${shellQuote(path.join(tmpDir, ".hermes"))}`, + `HERMES_DIR=${shellQuote(hermesDir)}`, `_HERMES_PYTHON=${shellQuote(pythonPath)}`, `STEP_DOWN_PREFIX_SANDBOX=(${shellQuote(handoffPath)})`, "prepare_hermes_lazy_dependencies", @@ -72,6 +113,16 @@ describe("Hermes lazy dependency lifecycle", () => { expect(result.stdout).toContain(`identity=${identity}`); expect(result.stdout).toContain("home=/sandbox"); expect(result.stdout).toContain("target=/sandbox/.hermes/lazy-packages"); + expect(result.stdout).toContain("activated=durable"); expect(result.stdout).toContain("installer=reviewed"); }); + + it("skips dependency preparation for a non-Hindsight provider (#8613)", () => { + const result = runLazyDependencyPreparation(false, "local"); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("identity=current"); + expect(result.stdout).not.toContain("activated=durable"); + expect(result.stdout).not.toContain("installer=reviewed"); + }); }); diff --git a/test/hermes-tirith-retry-finalization.test.ts b/test/hermes-tirith-retry-finalization.test.ts index 0eeb2d4d3bf..968035d880c 100644 --- a/test/hermes-tirith-retry-finalization.test.ts +++ b/test/hermes-tirith-retry-finalization.test.ts @@ -170,6 +170,7 @@ describe("agents/hermes/start.sh Tirith retry finalization", () => { it("runs reset-aware retry preparation in the root startup path", () => { const run = runTirithFinalizer([ "verify_hermes_config_integrity() { :; }", + "prepare_hermes_lazy_dependencies() { :; }", "ensure_hermes_config_root_mode() { :; }", "ensure_hermes_runtime_api_server_key() { :; }", "apply_shields_up_runtime_env() { :; }", From c292e9b590dd99d632f4a2d59f7d0d88d9cee269 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 03:48:07 -0700 Subject: [PATCH 10/13] fix(hermes): order grouped patch payload Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1c680a314ed..0bf7cd2d552 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -171,6 +171,10 @@ FROM ${BASE_IMAGE} # Dockerfile.base. The final stage also creates and verifies the directory. ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages +# Cross-stage root copies are accepted by Docker's legacy builder and create +# one final-image layer while preserving metadata on existing parent paths. +COPY --from=hermes-npm-patch-payload / / + # A published base can lag the source patch in Dockerfile.base. Apply only the # two Hindsight compatibility hunks when needed, then verify the final source # contract. This remains idempotent once the published base contains them. @@ -231,10 +235,6 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # with the merged OpenShell and corporate bundle. ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem -# Cross-stage root copies are accepted by Docker's legacy builder and create -# one final-image layer while preserving metadata on existing parent paths. -COPY --from=hermes-npm-patch-payload / / - # The final Hermes image owns the shipped dependency boundary independently of # base freshness. Reassert the idempotent npm-private node-tar fix here. When # onboarding supplied a corporate CA, use it for the registry-backed download. From 06d0ef8f64f3c5744696a28fcc69e8486c82bd2e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 03:53:10 -0700 Subject: [PATCH 11/13] fix(hermes): preserve temporary directory mode Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 12 ++++++------ test/hermes-dependency-review.test.ts | 3 ++- test/hermes-final-image-layout.test.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 0bf7cd2d552..d92874dee73 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -72,7 +72,7 @@ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts -COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch +COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch FROM scratch AS hermes-agent-payload @@ -181,22 +181,22 @@ COPY --from=hermes-npm-patch-payload / / RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ git -C /opt/hermes apply --check \ --include=hermes_cli/memory_setup.py \ - /tmp/hermes-security-dependencies.patch; \ + /scripts/hermes-security-dependencies.patch; \ git -C /opt/hermes apply \ --include=hermes_cli/memory_setup.py \ - /tmp/hermes-security-dependencies.patch; \ + /scripts/hermes-security-dependencies.patch; \ fi \ && if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \ git -C /opt/hermes apply --check \ --include=plugins/memory/hindsight/plugin.yaml \ - /tmp/hermes-security-dependencies.patch; \ + /scripts/hermes-security-dependencies.patch; \ git -C /opt/hermes apply \ --include=plugins/memory/hindsight/plugin.yaml \ - /tmp/hermes-security-dependencies.patch; \ + /scripts/hermes-security-dependencies.patch; \ fi \ && grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \ && grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \ - && rm /tmp/hermes-security-dependencies.patch + && rm /scripts/hermes-security-dependencies.patch # Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When # onboard detects an operator-supplied corporate CA on the host it bakes it diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 7e4900a7bc9..8e6c6f37a99 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -146,8 +146,9 @@ describe("Hermes 0.19.0 dependency review", () => { "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", ); expect(dockerfile).toContain( - "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", + "COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch", ); + expect(dockerfile).toContain("/scripts/hermes-security-dependencies.patch"); expect(dockerfileBase).toContain( "git -C /opt/hermes apply --check /tmp/hermes-security-dependencies.patch", ); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index ed48b5b8cb2..34b08e33cbb 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -247,7 +247,7 @@ describe("Hermes final image layout", () => { "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts", "COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts", "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", - "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", + "COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch", ], }, { From 026a492d648d687267ef6e5a6e4bf12dc80e5ef2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 04:12:11 -0700 Subject: [PATCH 12/13] fix(hermes): restore corporate CA payload order Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 54 ++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index d92874dee73..6538f8338b7 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -171,33 +171,6 @@ FROM ${BASE_IMAGE} # Dockerfile.base. The final stage also creates and verifies the directory. ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages -# Cross-stage root copies are accepted by Docker's legacy builder and create -# one final-image layer while preserving metadata on existing parent paths. -COPY --from=hermes-npm-patch-payload / / - -# A published base can lag the source patch in Dockerfile.base. Apply only the -# two Hindsight compatibility hunks when needed, then verify the final source -# contract. This remains idempotent once the published base contains them. -RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ - git -C /opt/hermes apply --check \ - --include=hermes_cli/memory_setup.py \ - /scripts/hermes-security-dependencies.patch; \ - git -C /opt/hermes apply \ - --include=hermes_cli/memory_setup.py \ - /scripts/hermes-security-dependencies.patch; \ - fi \ - && if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \ - git -C /opt/hermes apply --check \ - --include=plugins/memory/hindsight/plugin.yaml \ - /scripts/hermes-security-dependencies.patch; \ - git -C /opt/hermes apply \ - --include=plugins/memory/hindsight/plugin.yaml \ - /scripts/hermes-security-dependencies.patch; \ - fi \ - && grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \ - && grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \ - && rm /scripts/hermes-security-dependencies.patch - # Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When # onboard detects an operator-supplied corporate CA on the host it bakes it # here; the RUN below decodes it to a root-owned file that the entrypoint @@ -235,6 +208,33 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # with the merged OpenShell and corporate bundle. ENV NODE_EXTRA_CA_CERTS=/usr/local/share/nemoclaw/corporate-ca.pem +# Cross-stage root copies are accepted by Docker's legacy builder and create +# one final-image layer while preserving metadata on existing parent paths. +COPY --from=hermes-npm-patch-payload / / + +# A published base can lag the source patch in Dockerfile.base. Apply only the +# two Hindsight compatibility hunks when needed, then verify the final source +# contract. This remains idempotent once the published base contains them. +RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ + git -C /opt/hermes apply --check \ + --include=hermes_cli/memory_setup.py \ + /scripts/hermes-security-dependencies.patch; \ + git -C /opt/hermes apply \ + --include=hermes_cli/memory_setup.py \ + /scripts/hermes-security-dependencies.patch; \ + fi \ + && if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \ + git -C /opt/hermes apply --check \ + --include=plugins/memory/hindsight/plugin.yaml \ + /scripts/hermes-security-dependencies.patch; \ + git -C /opt/hermes apply \ + --include=plugins/memory/hindsight/plugin.yaml \ + /scripts/hermes-security-dependencies.patch; \ + fi \ + && grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \ + && grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \ + && rm /scripts/hermes-security-dependencies.patch + # The final Hermes image owns the shipped dependency boundary independently of # base freshness. Reassert the idempotent npm-private node-tar fix here. When # onboarding supplied a corporate CA, use it for the registry-backed download. From c7474d1c2fea83ebf92bd481ff6be98dd59dae2e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 12 Aug 2026 04:30:20 -0700 Subject: [PATCH 13/13] fix(hermes): restore lazy probe parent permissions Signed-off-by: Apurv Kumaria --- agents/hermes/Dockerfile | 5 ++++- test/hermes-dependency-review.test.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 6538f8338b7..7ed83c4b637 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -1180,7 +1180,10 @@ RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandb && test -f /sandbox/.hermes/lazy-packages/.nemoclaw-sandbox-unlock-probe \ && rm -rf /sandbox/.hermes/lazy-packages \ && install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages \ - && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" + && test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750" \ + && chown sandbox:sandbox /sandbox/.hermes \ + && chmod 3770 /sandbox/.hermes \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770" # Prove the cron execution ledger contract across the real image identities. # Hermes' gateway-side scheduler creates the live WAL-backed database in the diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 8e6c6f37a99..aff2218d1fe 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -170,6 +170,9 @@ describe("Hermes 0.19.0 dependency review", () => { expect(dockerfile).toContain( `test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750"`, ); + expect(dockerfile).toContain( + `test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770"`, + ); expect(dockerfileBase).toContain("uv pip check --python /opt/hermes/.venv/bin/python"); expect(arg("NODE_VERSION")).toBe("24.18.1"); expect(arg("UV_VERSION")).toBe("0.11.33");