Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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; \
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
&& chmod 2770 \
/sandbox/.hermes/logs \
/sandbox/.hermes/logs/curator \
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/state-lock-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"workspace"
],
"confidentialRoots": [
"lazy-packages",
"pairing"
],
"readOnlyPrefixes": [],
Expand Down
7 changes: 7 additions & 0 deletions agents/hermes/validate-env-secret-boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if key in RUNTIME_ALLOWED_NONSECRET_KEYS:
continue
if key in RUNTIME_ALLOWED_RAW_SECRET_KEYS and is_allowed_raw_secret_value(
Expand Down
77 changes: 74 additions & 3 deletions docs/manage-sandboxes/install-plugins-hermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <name> policy add local-memory --yes
```

Run the Hermes setup command through the NemoClaw CLI:

```bash
nemohermes <name> 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 <name> 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 <name> exec -- hermes memory status
nemohermes <name> exec -- python -c 'import os, sys; sys.path.insert(0, os.environ["HERMES_LAZY_INSTALL_TARGET"]); import hindsight_client; print(hindsight_client.__version__)'
nemohermes <name> 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 <name> 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.
Expand Down Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions nemoclaw-blueprint/policies/presets/local-memory.yaml
Original file line number Diff line number Diff line change
@@ -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 }
1 change: 1 addition & 0 deletions nemoclaw-blueprint/policies/tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agent/state-directory-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe("agent state directory contract", () => {
"weixin",
"workspace",
],
confidentialRoots: ["pairing"],
confidentialRoots: ["lazy-packages", "pairing"],
readOnlyPrefixes: [],
confidentialPrefixes: [],
writableSubpaths: ["profiles/dashboard-home"],
Expand Down
20 changes: 20 additions & 0 deletions test/effective-policy-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions test/helpers/vitest-watch-triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions test/hermes-dependency-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
20 changes: 20 additions & 0 deletions test/hermes-env-secret-boundary-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<raw>. Pin the other side of
Expand Down
3 changes: 3 additions & 0 deletions test/hermes-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:-');
Expand Down
23 changes: 23 additions & 0 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions test/shields-up-runtime-perms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});

Expand Down
Loading