Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1b2495e
fix(state): keep the confidentiality root traversable by the sandbox …
Dongni-Yang Jul 27, 2026
2ee156c
merge(main): sync PR #7603 with current main
apurvvkumaria Jul 27, 2026
25bed87
test(shields): prove confidentiality root access contract
apurvvkumaria Jul 27, 2026
4bbfa71
merge: resolve conflicts with main
github-actions[bot] Jul 27, 2026
8ab6384
ci: retrigger PR gate after workflow approval
prekshivyas Jul 27, 2026
7d21e3d
merge(main): sync PR #7603 with current main
prekshivyas Jul 27, 2026
b946e51
merge(main): sync PR #7603 with current main
prekshivyas Jul 27, 2026
cc16413
merge(main): sync PR #7603 with current main
apurvvkumaria Jul 27, 2026
2e236bf
merge(main): sync PR #7603 with current main
apurvvkumaria Jul 28, 2026
42fefa3
merge(state): refresh confidentiality guard on current main
apurvvkumaria Jul 31, 2026
69cbb96
docs(security): clarify confidentiality root access
apurvvkumaria Jul 29, 2026
9f4510a
merge(state): refresh confidentiality guard on latest main
apurvvkumaria Jul 31, 2026
81dfb6b
Merge branch 'main' into fix/7545-confidentiality-dir-traversal
senthilr-nv Jul 31, 2026
c2553b7
merge: refresh confidentiality guard with main
apurvvkumaria Aug 3, 2026
3f8bba8
docs(security): document Hermes confidentiality roots
apurvvkumaria Aug 3, 2026
3f2847b
merge: resolve conflicts with main
github-actions[bot] Aug 4, 2026
7686d0d
Merge branch 'main' into fix/7545-confidentiality-dir-traversal
apurvvkumaria Aug 4, 2026
2dd194d
merge(main): refresh confidentiality traversal fix
apurvvkumaria Aug 5, 2026
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: 11 additions & 4 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,12 @@ After the top-level config binding is frozen, lockdown makes containment monoton
It removes unsafe symlinks, special entries, and protected-root names that are not directories through descriptor-relative operations without following their targets.
For protected regular files, lockdown publishes a fresh inode, severing hardlinks while preserving file content, read/execute mode, timestamps, and supported extended attributes; this also revokes write authority held through a descriptor opened before `shields up`.
The OpenClaw gateway (a member of the `sandbox` group) keeps read access to plugin and agent code; the sandbox user can no longer write them.
The same workflow locks `identity`, `pairing`, and a non-empty `credentials` directory to `root:root 0700`.
An empty `credentials` directory uses `root:sandbox 0710` so OpenClaw can confirm that optional credential files are absent during startup.
The sandbox group cannot list, create, or remove entries, and it cannot read credential files.
Neither the sandbox user nor the gateway can read stored secrets while the lock is active.
The same workflow also locks the secret-bearing `credentials`, `identity`, and `pairing` directories.
The guard sets each confidentiality root to `root:sandbox 0710`, including a non-empty `credentials` directory.
It sets every nested directory and file to `root:root` and removes all group and world permission bits.
The sandbox group cannot list, create, or remove entries in a confidentiality root, and neither the sandbox user nor gateway can read stored secrets.
They can inspect metadata for a direct child only when they already know its name.
Probing a missing direct child, such as the legacy `credentials/oauth.json`, returns `ENOENT` instead of `EACCES`.
Restoring the mutable-default posture returns those directories to `sandbox:sandbox 2770`.
The list is the union of state directories declared by every shipped agent manifest.
The lock helper silently skips dirs that are not present in a given agent's config tree.
Expand Down Expand Up @@ -347,6 +349,11 @@ Direct edits to these files can be overwritten when NemoClaw regenerates the ima
Hermes also stores runtime state such as `state.db`, logs, and platform sessions under the `.hermes` tree.
Messaging sessions such as WhatsApp pairing can remain mutable by design so they survive rebuilds.

When Shields locks the tree, the shared state-directory guard treats any present `credentials`, `identity`, or `pairing` directory as a confidentiality root.
For Hermes, this normally applies to `pairing`.
The guard sets the root to `root:sandbox 710`, keeps it traversable but unlistable to the sandbox group, and sets every descendant to `root:root` with no group or world permission bits.
As a result, a known-name probe for a missing direct child returns `ENOENT`, while directory listing, nested traversal, and protected-file reads return `EACCES`.

| Aspect | Detail |
|---|---|
| Default | The Hermes config tree contains NemoClaw-generated config plus mutable runtime state. |
Expand Down
72 changes: 45 additions & 27 deletions scripts/state-dir-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,19 +879,33 @@ def _preflight(


def _expected_ids(
policy: Policy, action: Action, identity: Identity
policy: Policy,
action: Action,
identity: Identity,
is_confidentiality_root: bool = False,
) -> tuple[int, int]:
if action == "unlock":
return identity.sandbox_uid, identity.sandbox_gid
if policy == "confidentiality":
if is_confidentiality_root:
return identity.root_uid, identity.sandbox_gid
return identity.root_uid, identity.root_gid
return identity.root_uid, identity.sandbox_gid


def _expected_dir_mode(policy: Policy, action: Action) -> int:
def _expected_dir_mode(
policy: Policy, action: Action, is_confidentiality_root: bool = False
) -> int:
if action == "unlock":
return 0o2770
return 0o700 if policy == "confidentiality" else 0o755
if policy == "confidentiality":
# The confidentiality root stays traversable (group execute, no read)
# so a sandbox probe for a missing name directly under the root, such
# as the legacy credentials/oauth.json, resolves as ENOENT instead of
# EACCES. Nested directories and every file keep the sealed posture,
# so contents and the subtree shape stay unreadable.
return 0o710 if is_confidentiality_root else 0o700
return 0o755


def _expected_file_mode(policy: Policy, action: Action, old_mode: int) -> int:
Expand All @@ -915,10 +929,11 @@ def _set_dir_metadata(
policy: Policy,
action: Action,
identity: Identity,
is_confidentiality_root: bool = False,
) -> None:
uid, gid = _expected_ids(policy, action, identity)
uid, gid = _expected_ids(policy, action, identity, is_confidentiality_root)
os.fchown(dir_fd, uid, gid)
os.fchmod(dir_fd, _expected_dir_mode(policy, action))
os.fchmod(dir_fd, _expected_dir_mode(policy, action, is_confidentiality_root))


def _set_empty_credentials_startup_metadata(
Expand Down Expand Up @@ -1354,7 +1369,9 @@ def _mutate_dir(
# mutation through directory descriptors opened by the sandbox
# before shields-up, and it happens before visiting descendants.
_freeze_dir_for_lock(dir_fd)
_set_dir_metadata(dir_fd, policy, action, identity)
_set_dir_metadata(
dir_fd, policy, action, identity, is_root and policy == "confidentiality"
)
elif is_root:
# Keep the subtree inaccessible while descendants are restored.
os.fchmod(dir_fd, 0o700)
Expand All @@ -1366,10 +1383,10 @@ def _mutate_dir(
if _is_empty_credentials_root(
relative_dir, policy, action, is_root, names
):
# OpenClaw probes optional credential paths while starting. An
# empty directory contains no secret metadata to expose, so allow
# the sandbox group to traverse it without granting list, read, or
# write access. Non-empty confidentiality roots remain root-only.
# The confidentiality-root contract already grants the sandbox
# group execute-only access (root:sandbox 0710). Keep this
# explicit empty-root case for compatibility with the startup
# recovery path; nested entries remain root-only.
_set_empty_credentials_startup_metadata(dir_fd, identity)
except OSError as exc:
raise GuardOperationError(
Expand Down Expand Up @@ -1544,12 +1561,11 @@ def _verify_metadata(
policy: Policy,
action: Action,
identity: Identity,
empty_credentials_root: bool = False,
is_confidentiality_root: bool = False,
) -> Issue | None:
if empty_credentials_root:
expected_uid, expected_gid = identity.root_uid, identity.sandbox_gid
else:
expected_uid, expected_gid = _expected_ids(policy, action, identity)
expected_uid, expected_gid = _expected_ids(
policy, action, identity, is_confidentiality_root
)
if st.st_uid != expected_uid or st.st_gid != expected_gid:
return Issue(
"verification-owner-mismatch",
Expand All @@ -1560,7 +1576,7 @@ def _verify_metadata(
return None
mode = stat.S_IMODE(st.st_mode)
if entry_type == "directory":
expected_mode = 0o710 if empty_credentials_root else _expected_dir_mode(policy, action)
expected_mode = _expected_dir_mode(policy, action, is_confidentiality_root)
if mode != expected_mode:
return Issue(
"verification-mode-mismatch",
Expand Down Expand Up @@ -1607,6 +1623,7 @@ def _verify_dir(
replaced_inodes: dict[str, int],
issues: list[Issue],
depth: int,
is_root: bool = False,
) -> None:
if depth > MAX_TRAVERSAL_DEPTH:
issues.append(
Expand Down Expand Up @@ -1635,9 +1652,7 @@ def _verify_dir(
policy,
action,
identity,
_is_empty_credentials_root(
relative_dir, policy, action, relative_dir == "credentials", names
),
is_root and policy == "confidentiality",
)
if dir_issue is not None:
issues.append(dir_issue)
Expand Down Expand Up @@ -1805,14 +1820,16 @@ def _restore_empty_credentials_startup_access(
)
)
return result
if names:
if startup_traversable:
_set_dir_metadata(
credentials_fd, "confidentiality", "lock", identity
)
os.fsync(credentials_fd)
return result
_set_empty_credentials_startup_metadata(credentials_fd, identity)
# All confidentiality roots are execute-only for the sandbox group,
# whether or not they currently contain credentials. This preserves
# known-name ENOENT probes without exposing names or descendants.
_set_dir_metadata(
credentials_fd,
"confidentiality",
"lock",
identity,
is_confidentiality_root=True,
)
after = os.fstat(credentials_fd)
if (
after.st_uid != identity.root_uid
Expand Down Expand Up @@ -2019,6 +2036,7 @@ def _run_guard_unserialized(
replaced_inodes,
result.issues,
1,
is_root=True,
)
finally:
os.close(root_fd)
Expand Down
84 changes: 84 additions & 0 deletions test/e2e/live/state-dir-guard-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ type GuardAction = "preflight" | "lock" | "unlock";
type GuardTargets = Record<"plugins" | "credentials", string>;
type AccessResult = { read: boolean; write: boolean };

interface ConfidentialityAccessEvidence {
processUid: number;
processGid: number;
rootUid: number;
rootGid: number;
rootMode: number;
missingDirectChildErrno: number;
rootListingErrno: number;
nestedTraversalErrno: number;
secretReadErrno: number;
}

interface GuardLimits {
maxEntries: number;
maxLogicalBytes: number;
Expand Down Expand Up @@ -452,6 +464,56 @@ async function expectNamedUserAccessState(
expect(actual).toEqual(expected);
}

async function probeConfidentialityAccessContract(
host: HostCliClient,
agent: AgentCase,
fixtureRoot: string,
target: string,
identity: { uid: number; gid: number },
): Promise<ConfidentialityAccessEvidence> {
const credentialsRoot = path.join(fixtureRoot, "credentials");
const containerRoot = containerTargetPath(agent, fixtureRoot, credentialsRoot);
const containerTarget = containerTargetPath(agent, fixtureRoot, target);
const script = [
"import json, os, stat, sys",
"root_path, secret_path = sys.argv[1:]",
"def errno_of(operation):",
" try:",
" operation()",
" except OSError as exc:",
" return exc.errno",
" return 0",
"def read_secret():",
" with open(secret_path, 'rb') as stream:",
" stream.read(1)",
"root = os.lstat(root_path)",
"print(json.dumps({",
" 'processUid': os.getuid(),",
" 'processGid': os.getgid(),",
" 'rootUid': root.st_uid,",
" 'rootGid': root.st_gid,",
" 'rootMode': stat.S_IMODE(root.st_mode),",
" 'missingDirectChildErrno': errno_of(lambda: os.lstat(os.path.join(root_path, 'oauth.json'))),",
" 'rootListingErrno': errno_of(lambda: os.listdir(root_path)),",
" 'nestedTraversalErrno': errno_of(lambda: os.lstat(os.path.join(root_path, 'providers', 'missing.json'))),",
" 'secretReadErrno': errno_of(read_secret),",
"}))",
].join("\n");
const result = await expectCommand(
host,
"docker",
[
...mountArgs(agent, fixtureRoot, "python3", `${identity.uid}:${identity.gid}`),
"-c",
script,
containerRoot,
containerTarget,
],
`${agent.id}-locked-confidentiality-access`,
);
return JSON.parse(result.stdout.trim()) as ConfidentialityAccessEvidence;
}

function assertBudgetEvidence(
tree: TreeMeasurement,
limits: GuardLimits,
Expand Down Expand Up @@ -575,6 +637,25 @@ async function runAgentProbe(
plugins: { read: true, write: false },
credentials: { read: false, write: false },
});
const confidentialityAccess = await probeConfidentialityAccessContract(
host,
agent,
fixtureRoot,
targets.credentials,
identity,
);
expect(confidentialityAccess).toMatchObject({
processUid: identity.uid,
processGid: identity.gid,
rootUid: 0,
rootGid: identity.gid,
rootMode: 0o710,
missingDirectChildErrno: os.constants.errno.ENOENT,
rootListingErrno: os.constants.errno.EACCES,
nestedTraversalErrno: os.constants.errno.EACCES,
secretReadErrno: os.constants.errno.EACCES,
});
expect(confidentialityAccess.processUid).not.toBe(confidentialityAccess.rootUid);

const unlock = await runGuard(host, agent, fixtureRoot, "unlock");
expect(unlock.summary).toMatchObject({
Expand Down Expand Up @@ -622,6 +703,7 @@ async function runAgentProbe(
lock: lock.summary,
unlock: unlock.summary,
},
confidentialityAccess,
});
}

Expand All @@ -647,6 +729,7 @@ test(
"the installed root-owned guard handles preflight, lock, and unlock for OpenClaw and Hermes",
"plugins and credentials preserve content and user xattrs across fresh-inode locking",
"numeric ownership, mode, raw ACL, mask, and effective named-user access match each policy",
"a distinct sandbox-group member sees ENOENT for a missing direct child while listing, nested traversal, and secret reads stay denied",
"representative-tree entry, byte, depth, copy, and wall-time evidence stays within shipped limits",
],
});
Expand Down Expand Up @@ -725,6 +808,7 @@ test(
contentXattrAclPreserved: true,
effectiveAclClamped: true,
effectiveAclEnforcedByKernel: true,
confidentialityRootAccessContract: true,
productionBudgetsRecorded: true,
},
});
Expand Down
Loading
Loading