Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ PY
# file has to also rewrite the Dockerfile-committed hash, which reviewers gate).
# Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,validate-env-secret-boundary.py,finalize-tirith-marker.py}`.
ARG NEMOCLAW_HERMES_WRAPPER_SHA256=cd851746da14162ac4701d56c274dac20024ea6a11f6ffcf2ce7fb89dff388a0
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=87c1b02e0fb2becd26f79ae5dec6e0d21f78c6b08862b880e926708faf46ecb3
ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1
ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819
# hadolint ignore=DL4006
RUN printf '%s %s\n' \
Expand Down
78 changes: 68 additions & 10 deletions agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,7 +1754,9 @@ def _mutable_nonroot_reconciliation_posture_is_allowed(
# OpenShell can present the live non-root home as private 0700 after the
# managed supervisor/dashboard has started. Shields-down transitions use
# the canonical set-id 03770 form. Both are sandbox-owned mutable roots;
# the root-owned 0755/0444 shields-up posture must never be reconciled.
# a shields-up posture must never be reconciled here. The locked root also
# carries 03770 since #7865, so the sandbox-owner check below — not the
# mode — is what keeps the two apart.
if (
hermes_meta.get("uid") != sandbox_uid
or hermes_meta.get("gid") != sandbox_gid
Expand Down Expand Up @@ -2914,6 +2916,27 @@ def _record_current_sealed_inodes(
_write_restart_state(state_file, state_data, create=False)


def _is_locked_hermes_root(uid: object, gid: object, mode: object) -> bool:
"""Report whether a recorded `.hermes` root is in the shields-locked posture.

The current locked root is root-owned in the sandbox group and keeps the
set-id/sticky shape so Hermes can still write its top-level runtime state
(#7865). Sandboxes locked before that change recorded a root:root 0755
root, so keep accepting it here: rollback and re-lock must classify an
already-locked sandbox correctly, and the host verifier reports the legacy
shape as drift so `shields up` repairs it.
"""
if uid != os.geteuid():
return False
if gid == os.getegid() and mode == 0o755:
return True
try:
_, sandbox_gid = _sandbox_identity()
except UnsafePathError:
return False
return gid == sandbox_gid and mode == 0o3770


def _restart_state_was_locked(state_data: dict[str, object]) -> bool:
recorded = state_data.get("original_locked")
if isinstance(recorded, bool):
Expand All @@ -2922,10 +2945,8 @@ def _restart_state_was_locked(state_data: dict[str, object]) -> bool:
files = state_data.get("files")
if not isinstance(hermes_meta, dict) or not isinstance(files, dict):
raise UnsafePathError("refusing malformed Hermes restart seal metadata")
if (
hermes_meta.get("uid") != os.geteuid()
or hermes_meta.get("gid") != os.getegid()
or hermes_meta.get("mode") != 0o755
if not _is_locked_hermes_root(
hermes_meta.get("uid"), hermes_meta.get("gid"), hermes_meta.get("mode")
):
return False
for name in ("config.yaml", ".env"):
Expand Down Expand Up @@ -3213,9 +3234,9 @@ def _seal_shields_locked(

original_locked = (
not unavailable_reasons
and hermes_meta["uid"] == os.geteuid()
and hermes_meta["gid"] == os.getegid()
and hermes_meta["mode"] == 0o755
and _is_locked_hermes_root(
hermes_meta["uid"], hermes_meta["gid"], hermes_meta["mode"]
)
and all(
initial_stats[name] is not None
and initial_stats[name].st_uid == os.geteuid()
Expand Down Expand Up @@ -3724,8 +3745,17 @@ def _configure_shields_target_metadata(
locked = mode == "locked"
desired_uid = os.geteuid() if locked else sandbox_uid
desired_gid = os.getegid() if locked else sandbox_gid
desired_dir_mode = 0o755 if locked else 0o3770
desired_file_mode = 0o444 if locked else 0o640
# The config root keeps one set-id/sticky shape in both postures; only its
# owner changes. Hermes writes its top-level runtime state directly here —
# auth.json, the drain request, and the temporary files that back every
# atomic gateway_state/pid replace — so a root-owned root without group
# write stops every gateway launch and the supervisor quarantines relaunch
# until the sandbox is recreated (#7865). Root ownership plus the sticky
# bit is what protects the sealed entries under lockdown: the sandbox
# identity manages its own runtime files but cannot unlink or rename the
# root-owned config, which is the same trade `/sandbox` already makes.
desired_dir_mode = 0o3770
# `/sandbox` must remain a usable home, but its sticky root-owned entry
# prevents the sandbox identity from renaming the root-owned `.hermes`
# lock root out from under the protected files.
Expand All @@ -3742,7 +3772,7 @@ def _configure_shields_target_metadata(
FS_IMMUTABLE_FL | FS_APPEND_FL
)
hermes_meta.update(
{"uid": desired_uid, "gid": desired_gid, "mode": desired_dir_mode}
{"uid": desired_uid, "gid": sandbox_gid, "mode": desired_dir_mode}
)
state_data["hermes"] = hermes_meta
state_data["hermes_flags"] = int(state_data.get("hermes_flags", 0)) & ~(
Expand Down Expand Up @@ -3898,6 +3928,34 @@ def apply_shields_transition(
)
os.fchown(parent_fd, os.geteuid(), os.getegid())
os.fchmod(parent_fd, 0o755)
# Re-apply the recorded config-root posture. The pending phase
# clamps this root to a transient root-only mode, so an interruption
# between publishing the applied phase and restoring the seal leaves
# the clamp in place; finish would then refuse the drifted root and
# wedge the transaction instead of converging. Repairing here keeps
# resume idempotent, and the inode pin below is what makes it safe.
hermes_meta = state_data.get("hermes")
if not isinstance(hermes_meta, dict):
raise UnsafePathError(
"refusing applied shields resume without .hermes metadata"
)
resumed_fd = _open_child_directory(
parent_fd, _split_path(hermes_dir)[1], hermes_dir
)
try:
if not _same_inode(os.fstat(resumed_fd), hermes_meta):
raise UnsafePathError(
"refusing applied shields resume because .hermes changed"
)
_set_inode_flags(
resumed_fd,
_get_inode_flags(resumed_fd) & ~(FS_IMMUTABLE_FL | FS_APPEND_FL),
)
# Chown can clear set-id bits, so the mode restore follows it.
os.fchown(resumed_fd, hermes_meta["uid"], hermes_meta["gid"])
os.fchmod(resumed_fd, hermes_meta["mode"])
finally:
os.close(resumed_fd)
finally:
os.close(parent_fd)
hash_file = str(state_data.get("hash_file", ""))
Expand Down
18 changes: 18 additions & 0 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,14 @@ hermes_config_root_is_locked() {
owner="$(stat -c '%U:%G' "$HERMES_DIR" 2>/dev/null || stat -f '%Su:%Sg' "$HERMES_DIR" 2>/dev/null || true)"
mode="$(stat -c '%a' "$HERMES_DIR" 2>/dev/null || stat -f '%Lp' "$HERMES_DIR" 2>/dev/null || true)"

# The locked root is root-owned in the sandbox group and keeps the set-id and
# sticky bits so the gateway can still write its top-level runtime state while
# the sticky bit protects the sealed entries (#7865) — the same shape
# hermes_locked_parent_is_protected expects one level up. `root:root 755` is
# the pre-#7865 posture; keep detecting it so an existing shields-up sandbox
# still takes the locked branches until `shields up` repairs the root.
case "${owner} ${mode}" in
"root:sandbox 3770" | "root:sandbox 03770") ;;
"root:root 755" | "root:root 0755") ;;
*) return 1 ;;
esac
Expand Down Expand Up @@ -1970,6 +1977,17 @@ refresh_hermes_provider_placeholders() {

refresh_hermes_runtime_config_hashes() {
local mode="${1:-strict}"
# A locked root seals config.yaml, .env, and .config-hash as root-owned, and
# the lock transaction already wrote a coherent hash for them. The compat
# refresh runs as the sandbox identity, which by design cannot replace a
# sealed hash: the sticky config root refuses the rename, so every launch
# under shields failed here and the supervisor stopped respawning (#7865).
# There is also nothing to refresh, because the sealed inputs cannot drift.
# The MCP integrity inspection that follows still validates the sealed hash,
# so a genuinely incoherent locked tree keeps failing closed.
if [ "$mode" = "compat" ] && hermes_config_root_is_locked; then
return 0
fi
local cmd=(
"$_HERMES_PYTHON" -I "$_HERMES_RUNTIME_CONFIG_GUARD" refresh-hashes
--hermes-dir "$HERMES_DIR"
Expand Down
6 changes: 6 additions & 0 deletions agents/hermes/validate-env-secret-boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ def _validate_directory_descriptor(path: str, fd: int) -> tuple[int, int, int, i
{
(sandbox_uid, sandbox_gid, 0o700),
(sandbox_uid, sandbox_gid, 0o3770),
# Shields-up config root: root-owned in the sandbox
# group with set-id/sticky, so Hermes keeps writing its
# top-level runtime state while the sticky bit stops the
# sandbox identity from unlinking the sealed root-owned
# config (#7865). Same shape `/sandbox` uses above.
(0, sandbox_gid, 0o3770),
}
)
if (st.st_uid, st.st_gid, mode) not in allowed:
Expand Down
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@
"test": "restores exact locked posture after root-separated repair and later failure (#7033)",
"category": "security"
},
{
"file": "test/hermes-runtime-config-guard-topology.test.ts",
"test": "allows the sandbox identity to create runtime state but refuses sealed configuration writes, unlinks, and renames (#7865)",
"category": "security"
},
{
"file": "test/hosted-runner-recovery-workflow.test.ts",
"test": "locks recovery identities to the source workflows' runtime names (#7140)",
Expand Down
45 changes: 45 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,51 @@ nemohermes <name> start
Then run `nemohermes inference get` and verify Dashboard Chat uses the selected model.
If the command succeeds because the dashboard profile is missing, the dashboard is disabled and no dashboard recovery is required.

### Shields Reports Drift for the Hermes Configuration Root

The Hermes configuration root holds the agent's top-level runtime state, not only its configuration: `auth.json`, the drain request, and the temporary files that back every atomic `gateway_state.json` and `gateway.pid` replace are created directly in `/sandbox/.hermes`.
Lockdown therefore moves that directory to `root:sandbox` mode `3770`, keeping the set-id and sticky bits, so the gateway can still manage its own runtime files while the sticky bit stops the sandbox identity from unlinking or renaming the sealed root-owned configuration.
Run the following command to inspect a locked root:

```bash
$$nemoclaw <name> exec -- stat -c '%a %U:%G' /sandbox/.hermes
```

Expected output:

```text
3770 root:sandbox
```

A sandbox locked by an older release carries a `755 root:root` root instead.
The gateway cannot write its runtime state there.
`$$nemoclaw <name> shields status` reports the stale posture as drift.
Before restarting the gateway, repair the posture:

```bash
$$nemoclaw <name> shields up
```

If the command refuses the repair, follow its recovery guidance and do not restart the gateway.
Otherwise, verify the repaired posture:

```bash
$$nemoclaw <name> shields status
$$nemoclaw <name> exec -- stat -c '%a %U:%G' /sandbox/.hermes
```

Continue only when status no longer reports drift and `stat` prints the expected output above.
Then restart the gateway:

```bash
$$nemoclaw <name> gateway restart
```

The restart must exit zero and report that the health check passed.
If restart or recovery already reports `relaunch quarantined`, repairing the directory cannot clear the supervisor quarantine.
Follow [Restart or recovery reports `relaunch quarantined`](#restart-or-recovery-reports-relaunch-quarantined) to rebuild the sandbox.
The sealed files are unchanged by the repair and stay `444 root:root`.

### Hermes restart reports `config hash mismatch`

A Hermes restart reports `config hash mismatch` when a strict root-owned hash is available and `/sandbox/.hermes/config.yaml` or `/sandbox/.hermes/.env` does not match it.
Expand Down
7 changes: 5 additions & 2 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1471,8 +1471,11 @@ try:
os.close(fd)

if action == "lock":
os.fchown(config_fd, 0, 0)
os.fchmod(config_fd, 0o755)
# Root-owned in the sandbox group with set-id/sticky: Hermes keeps
# writing its top-level runtime state while the sticky bit stops the
# sandbox identity from unlinking the sealed root-owned files (#7865).
os.fchown(config_fd, 0, sandbox_gid)
os.fchmod(config_fd, 0o3770)
os.fchown(parent_fd, 0, sandbox_gid)
os.fchmod(parent_fd, 0o1775)
else:
Expand Down
4 changes: 2 additions & 2 deletions src/lib/shields/legacy-hermes-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ describe("legacy Hermes shields compatibility", () => {
return cmd.at(-1) === "/sandbox"
? "1775 root:sandbox"
: cmd.at(-1) === "/sandbox/.hermes"
? "755 root:root"
? "3770 root:sandbox"
: "444 root:root";
case cmd[0] === "lsattr":
return `----i----------- ${cmd.at(-1)}`;
Expand Down Expand Up @@ -405,7 +405,7 @@ describe("legacy Hermes shields compatibility", () => {
return cmd.at(-1) === "/sandbox"
? "755 sandbox:sandbox"
: cmd.at(-1) === "/sandbox/.hermes"
? "755 root:root"
? "3770 root:sandbox"
: "444 root:root";
case cmd[0] === "lsattr":
return `----i----------- ${cmd.at(-1)}`;
Expand Down
8 changes: 6 additions & 2 deletions src/lib/shields/legacy-hermes-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ function legacyTransitionScriptForCurrentUser(): string {
.replace(
"os.fchown(parent_fd, 0, sandbox_gid)",
"os.fchown(parent_fd, os.geteuid(), os.getegid())",
)
.replace(
"os.fchown(config_fd, 0, sandbox_gid)",
"os.fchown(config_fd, os.geteuid(), os.getegid())",
);
}

Expand Down Expand Up @@ -144,7 +148,7 @@ describe("legacy Hermes config transition", () => {
expect(afterStaleWrite.inode).toBe(lockedSnapshot.inode);
expect(afterStaleWrite.bytes).toEqual(fixture.configBytes);
expect(mode(fixture.parentDir)).toBe(0o1775);
expect(mode(fixture.configDir)).toBe(0o755);
expect(mode(fixture.configDir)).toBe(0o3770);
expect(afterStaleWrite.mode).toBe(0o444);
expect(mode(fixture.envPath)).toBe(0o444);
expect(mode(fixture.compatPath)).toBe(0o444);
Expand All @@ -156,7 +160,7 @@ describe("legacy Hermes config transition", () => {
expect(refused.status).not.toBe(0);
expect(refused.stderr).toContain("strict hash verification failed");
expect(mode(fixture.parentDir)).toBe(0o1775);
expect(mode(fixture.configDir)).toBe(0o755);
expect(mode(fixture.configDir)).toBe(0o3770);
const afterRefusedUnlock = readRegularFileSnapshot(fixture.configPath);
expect(afterRefusedUnlock.inode).toBe(afterStaleWrite.inode);
expect(afterRefusedUnlock.bytes).toEqual(fixture.configBytes);
Expand Down
60 changes: 58 additions & 2 deletions src/lib/shields/verify-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ describe("verifyShieldsLockState", () => {
"/sandbox/.hermes/config.yaml": "444 root:root",
"/sandbox/.hermes/.env": "444 root:root",
"/sandbox/.hermes/.config-hash": "444 root:root",
"/sandbox/.hermes": "755 root:root",
// Root-owned in the sandbox group with set-id/sticky: Hermes keeps
// writing top-level runtime state while the sticky bit stops the sandbox
// identity unlinking the sealed root-owned files (#7865).
"/sandbox/.hermes": "3770 root:sandbox",
"/sandbox": "1775 root:sandbox",
});

Expand All @@ -90,7 +93,7 @@ describe("verifyShieldsLockState", () => {
"/sandbox/.hermes/config.yaml": "444 root:root",
"/sandbox/.hermes/.env": "444 root:root",
"/sandbox/.hermes/.config-hash": "444 root:root",
"/sandbox/.hermes": "755 root:root",
"/sandbox/.hermes": "3770 root:sandbox",
"/sandbox": "755 sandbox:sandbox",
});
const drifted = verifyShieldsLockState("hermes", hermesTarget, {
Expand All @@ -106,6 +109,59 @@ describe("verifyShieldsLockState", () => {
);
});

it("reports the previous root-owned Hermes config root as drift (#7865)", async () => {
const { verifyShieldsLockState } = await loadVerifier();
const hermesTarget = {
agentName: "hermes",
configPath: "/sandbox/.hermes/config.yaml",
configDir: "/sandbox/.hermes",
sensitiveFiles: ["/sandbox/.hermes/.env", "/sandbox/.hermes/.config-hash"],
};
// A Hermes root left at the old 755 root:root cannot run a gateway at all,
// so it must surface as drift for the caller's re-lock to repair rather
// than passing as a healthy lock.
const legacyLocked = makeExec({
"/sandbox/.hermes/config.yaml": "444 root:root",
"/sandbox/.hermes/.env": "444 root:root",
"/sandbox/.hermes/.config-hash": "444 root:root",
"/sandbox/.hermes": "755 root:root",
"/sandbox": "1775 root:sandbox",
});

const result = verifyShieldsLockState("hermes", hermesTarget, {
exec: legacyLocked,
verifyParentProtection: true,
});

expect(result.ok).toBe(false);
expect(result.issues).toEqual(
expect.arrayContaining([
"dir mode=755 (expected 3770)",
"dir owner=root:root (expected root:sandbox)",
]),
);
});

it("keeps the OpenClaw locked config root at 755 root:root", async () => {
const { verifyShieldsLockState } = await loadVerifier();
const openClawTarget = {
agentName: "openclaw",
configPath: "/sandbox/.openclaw/openclaw.json",
configDir: "/sandbox/.openclaw",
sensitiveFiles: ["/sandbox/.openclaw/.config-hash"],
};
const exec = makeExec({
"/sandbox/.openclaw/openclaw.json": "444 root:root",
"/sandbox/.openclaw/.config-hash": "444 root:root",
"/sandbox/.openclaw": "755 root:root",
});

expect(verifyShieldsLockState("openclaw", openClawTarget, { exec })).toEqual({
ok: true,
issues: [],
});
});

it("requires the same protected parent for OpenClaw but does not impose it on custom agents", async () => {
const { verifyShieldsLockState } = await loadVerifier();
const openClawTarget = { ...target, agentName: "openclaw" };
Expand Down
Loading
Loading