Skip to content
Closed
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
33 changes: 31 additions & 2 deletions agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2653,7 +2653,19 @@ def seal_restart(
try:
_verify_strict_hash(hermes_dir, hash_file)
except StrictHashMismatchError:
if purpose != "config-write" or expected_config_sha256 is None:
# The one legitimate strict-hash drift on a managed non-root sandbox
# is the per-sandbox API bearer token the OpenShell-launched startup
# minted into .env, which that non-root process could not append to
# the root-owned strict anchor. The first root-privileged config
# transaction reconciles it. `shields-mutable` (shields down) is the
# first such transaction on a fresh sandbox, so it must be able to
# reconcile exactly like `config-write`; the reconciliation itself is
# gated to the mutable, never-locked posture and refuses every other
# config or env difference.
if (
purpose not in ("config-write", "shields-mutable")
or expected_config_sha256 is None
):
raise
_reconcile_nonroot_startup_api_key_hash(
hermes_dir,
Expand Down Expand Up @@ -3413,6 +3425,7 @@ def begin_shields_transition(
state_file: str,
mode: str,
rollback_mode: str = "",
expected_config_sha256: str | None = None,
) -> tuple[str, bool]:
if mode not in ("locked", "mutable"):
raise UnsafePathError(f"refusing unsupported Hermes shields transition: {mode}")
Expand Down Expand Up @@ -3442,7 +3455,11 @@ def begin_shields_transition(
)

original_locked = seal_restart(
hermes_dir, hash_file, state_file, purpose="shields-mutable"
hermes_dir,
hash_file,
state_file,
purpose="shields-mutable",
expected_config_sha256=expected_config_sha256,
)
try:
state_data = _load_restart_state(state_file)
Expand Down Expand Up @@ -4775,12 +4792,24 @@ def main() -> int:
raise UnsafePathError(
"begin-shields-transition requires --hash-file, --state-file, and --shields-mode"
)
# Optional: lets the mutable transition reconcile the one non-root
# startup API-key append into the root-owned strict anchor (see
# seal_restart). The arg defaults to "" (absent); normalize that to
# None so a stale strict anchor still fails closed exactly as before.
begin_expected_config_sha256 = args.expected_config_sha256 or None
if begin_expected_config_sha256 is not None and not re.fullmatch(
r"[0-9a-f]{64}", begin_expected_config_sha256
):
raise UnsafePathError(
"begin-shields-transition --expected-config-sha256 must be a 64-char hex digest"
)
lock_token, original_locked = begin_shields_transition(
args.hermes_dir,
args.hash_file,
args.state_file,
args.shields_mode,
args.rollback_shields_mode,
begin_expected_config_sha256,
)
print(f"lock_token={lock_token} original_locked={int(original_locked)}")
elif args.action == "apply-shields-transition":
Expand Down
47 changes: 44 additions & 3 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,30 @@ function beginHermesConfigShields(
mode: "locked" | "mutable",
rollbackMode: "locked" | "mutable",
): { token: string; originalLocked: boolean; rollbackLocked: boolean } {
// A freshly built, OpenShell-managed (non-root) Hermes sandbox mints a
// per-sandbox API bearer token into .env at startup, but the non-root
// startup cannot advance the root-owned strict config-hash anchor to match.
// The first mutable transition (shields down) runs as root inside the
// sandbox and reconciles that exact append; pass the config digest we
// observe now so the guard can prove config.yaml itself did not change while
// it reconciles the .env API-key line. The guard treats this as advisory:
// when it is absent or the drift is anything but that single minted key, the
// strict verification still fails closed.
const extraArgs = ["--hash-file", HERMES_CONFIG_HASH];
if (mode === "mutable") {
const rawConfigHash = privilegedSandboxExecCapture(sandboxName, [
"sha256sum",
target.configPath,
]);
const configSha = parseSha256Output(rawConfigHash);
if (configSha) {
extraArgs.push("--expected-config-sha256", configSha);
}
}
const output = privilegedSandboxExecCapture(
sandboxName,
hermesShieldsGuardArgs("begin-shields-transition", target, [
"--hash-file",
HERMES_CONFIG_HASH,
...extraArgs,
"--shields-mode",
mode,
"--rollback-shields-mode",
Expand Down Expand Up @@ -1996,9 +2015,20 @@ function lockAgentConfigUnderMutationLock(
chattrSucceeded = applyHermesConfigShields(sandboxName, target, transaction.token);
}

// For the sealed Hermes transaction the parent (`/sandbox`) posture —
// `1775 root:sandbox` — is deliberately the last persistent change and is
// applied by finishHermesConfigShields, which runs after this check (the
// guard uses root parent ownership as its crash-consistency orphan marker
// until finish). Verifying parent protection now would therefore always see
// the frozen `755 root:root` posture and falsely report
// "parent dir mode=755 (expected 1775)" on a fresh sandbox, so defer that
// dimension to a post-finish re-verify. Everything else (locked files,
// config dir, chattr) is already established by apply and is checked here.
const deferParentProtectionToFinish = transaction != null;
const { issues } = verifyShieldsLockState(sandboxName, target, {
verifyChattr: chattrSucceeded,
verifyParentProtection: target.agentName === "hermes" || openClawProtocol,
verifyParentProtection:
!deferParentProtectionToFinish && (target.agentName === "hermes" || openClawProtocol),
exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd),
assertLegacyLayout: assertNoLegacyStateLayout,
});
Expand All @@ -2007,6 +2037,17 @@ function lockAgentConfigUnderMutationLock(
const fileHashes = captureSealHashes(sandboxName, filesToLock);
if (transaction) {
finishHermesConfigShields(sandboxName, target, transaction.token);
// finish committed the lock and released the transaction; it can no longer
// be aborted, so clear it before the post-finish re-verify keeps a
// verification failure from re-entering the transaction rollback path.
transaction = null;
const { issues: parentIssues } = verifyShieldsLockState(sandboxName, target, {
verifyChattr: chattrSucceeded,
verifyParentProtection: true,
exec: (cmd: string[]) => privilegedSandboxExecCapture(sandboxName, cmd),
assertLegacyLayout: assertNoLegacyStateLayout,
});
if (parentIssues.length > 0) throw new Error(`Config not locked: ${parentIssues.join(", ")}`);
}
return { chattrApplied: chattrSucceeded, fileHashes };
} catch (error) {
Expand Down
109 changes: 109 additions & 0 deletions test/hermes-nonroot-strict-hash-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,50 @@ raise SystemExit(module.main())
);
}

function beginShieldsArgs(fixture: ReconciliationFixture, expectedDigest?: string): string[] {
return [
"begin-shields-transition",
"--hermes-dir",
fixture.hermesDir,
"--hash-file",
fixture.hashPath,
"--state-file",
fixture.statePath,
"--shields-mode",
"mutable",
"--rollback-shields-mode",
"mutable",
// Ternary spread (not an `if` statement) keeps the changed test file within
// the "no added if statements" budget while leaving the digest optional.
...(expectedDigest === undefined ? [] : ["--expected-config-sha256", expectedDigest]),
];
}

function runManagedNonrootBegin(fixture: ReconciliationFixture, expectedDigest?: string) {
const wrapper = String.raw`
import importlib.util
import os
import sys

source = sys.argv[1]
spec = importlib.util.spec_from_file_location("nemoclaw_runtime_config_guard_begin", source)
if spec is None or spec.loader is None:
raise SystemExit("could not load runtime guard fixture")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
module._managed_nonroot_reconciliation_is_allowed = lambda: True
module._sandbox_identity = lambda: (os.geteuid(), os.getegid())
sys.argv = [source, *sys.argv[2:]]
raise SystemExit(module.main())
`;
return spawnSync(
"python3",
["-c", wrapper, RUNTIME_CONFIG_GUARD, ...beginShieldsArgs(fixture, expectedDigest)],
{ encoding: "utf-8", timeout: 5000 },
);
}

function refreshCompatOnly(fixture: ReconciliationFixture): void {
fs.writeFileSync(fixture.compatHashPath, hashInputs(fixture));
}
Expand Down Expand Up @@ -490,5 +534,70 @@ print(json.dumps([private_live, canonical_mutable, foreign_private, unexpected_m
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});

// #6381 — the mutable transition (shields down) is the first root-privileged
// config transaction on a freshly built OpenShell-managed Hermes sandbox, so
// it must reconcile the startup API-key append into the strict anchor exactly
// like write-config. These lock in the new wiring; the reconciliation's own
// refusals are already covered by the write-config cases above.
it("reconciles the startup API key on the first shields-down and completes the transition (#6381)", () => {
const fixture = createFixture(0o700);
const generatedKey = "a".repeat(64);
fs.appendFileSync(fixture.envPath, `API_SERVER_KEY=${generatedKey}\n`);
refreshCompatOnly(fixture);
try {
// Reproduces the reported state: startup refreshed only the in-tree
// compat anchor, leaving the root-owned strict anchor stale.
expect(strictHashIsValid(fixture)).toBe(false);
const result = runManagedNonrootBegin(fixture, expectedConfigDigest(fixture));
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toMatch(/^lock_token=[0-9a-f]{64} original_locked=0$/mu);
// Strict anchor advanced to the frozen current inputs and now verifies.
expect(strictHashIsValid(fixture)).toBe(true);
expect(fs.readFileSync(fixture.hashPath, "utf-8")).toBe(
fs.readFileSync(fixture.compatHashPath, "utf-8"),
);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});

it("still fails closed on a stale strict anchor when no config digest is supplied (#6381)", () => {
const fixture = createFixture(0o700);
fs.appendFileSync(fixture.envPath, `API_SERVER_KEY=${"9".repeat(64)}\n`);
refreshCompatOnly(fixture);
const strictBefore = fs.readFileSync(fixture.hashPath, "utf-8");
try {
// No --expected-config-sha256: reconciliation is not opted into, so the
// stale strict anchor must refuse exactly as it did before the fix.
const result = runManagedNonrootBegin(fixture);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("strict hash verification failed for Hermes restart seal");
expect(fs.readFileSync(fixture.hashPath, "utf-8")).toBe(strictBefore);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});

it("refuses config drift on the shields-down reconciliation path (#6381)", () => {
const fixture = createFixture(0o700);
const driftedConfig = "model:\n default: attacker-model\n";
fs.writeFileSync(fixture.configPath, driftedConfig);
fs.appendFileSync(fixture.envPath, `API_SERVER_KEY=${"a".repeat(64)}\n`);
refreshCompatOnly(fixture);
const strictBefore = fs.readFileSync(fixture.hashPath, "utf-8");
try {
const result = runManagedNonrootBegin(
fixture,
createHash("sha256").update(driftedConfig).digest("hex"),
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("refusing config drift");
// Strict anchor is never advanced when the drift is anything but the key.
expect(fs.readFileSync(fixture.hashPath, "utf-8")).toBe(strictBefore);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
});
},
);
Loading