Skip to content

Commit 2af9d2a

Browse files
authored
fix(shields): self-heal root-owned mutable OpenClaw config on boot (NVIDIA#6307)
Restore the mutable OpenClaw config ownership contract at startup while keeping sealed and ambiguous states fail-closed. Retains Tinson Lai's original contribution and PR authorship. Co-authored-by: Tinson Lai <tinsonl@nvidia.com> Signed-off-by: Apurv Kumaria <akumaria@nvidia.com>
1 parent 358fc4c commit 2af9d2a

8 files changed

Lines changed: 921 additions & 58 deletions

docs/reference/troubleshooting.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,8 @@ $$nemoclaw <sandbox> doctor --fix
13661366

13671367
`$$nemoclaw <sandbox> doctor` reports the drift as a `Config permissions` warning, and `--fix` restores `2770/660`.
13681368
Restarting the sandbox repairs the same drift automatically when the config tree passes its safety checks, and NemoClaw's own `rebuild` re-applies the contract after its post-upgrade `openclaw doctor --fix` step.
1369+
For a persisted root-owned `700/600` tree, startup reclaims ownership only when both fixed config files have that exact posture under the expected sandbox-owned parent.
1370+
Other root-owned layouts, links, mounts, and ambiguous metadata fail closed so startup cannot mistake a shields-locked or unsafe tree for mutable drift.
13691371
If startup reports `[SECURITY] Refusing mutable config permission normalization`, NemoClaw stops startup without following or modifying the unsafe target; safe permission repairs completed before detection are not rolled back.
13701372
Rebuild with the current image and trusted host-side configuration instead of repairing the tree recursively.
13711373

docs/security/tcb-boundary.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ A successful build does not replace review of privilege, process identity, descr
4141
| Component | Execution and privilege | Trusted input | Security responsibility |
4242
|---|---|---|---|
4343
| `scripts/state-dir-guard.py` | The installed copy is root-owned and mode `0500`; the host reaches it through the shields transaction. | Fixed paths, a bounded action contract, and a lock token from the host coordinator. | Applies descriptor-rooted state-directory posture changes, rejects link and mount substitution, bounds traversal, and verifies the committed modes and ownership. |
44+
| `scripts/lib/normalize_mutable_config_perms.py` | The installed copy is root-owned and mode `0555`; startup invokes it under the entrypoint identity, and only root can reclaim a root-owned tree. | The fixed OpenClaw config path, the resolved sandbox identity, and an exact `root:root 0700/0600` mutable-drift signature under the expected sandbox-owned parent. | Restores the mutable `2770/660` contract, pins every privileged handoff by descriptor, and rejects ambiguous posture, links, mount substitution, metadata races, and sealed config. |
4445
| `scripts/openclaw-config-guard.py` | The installed copy is root-owned and mode `0500`; direct root PID 1 or the authenticated host transaction invokes it. | Bounded strict JSON for writes, stable captured config bytes for restart validation, and fixed installed parser paths for existing JSON5 config. | Seals and unseals OpenClaw config with no-follow descriptors, stable inode checks, atomic replacement, hash coherence, and recoverable transaction journals. |
4546
| `scripts/managed-gateway-control.py` | The installed copy is root-owned and mode `0500`; the host invokes it through sanitized registry-scoped direct-container execution. | A fixed action, a 64-character nonce, fixed installed helpers, and a live OpenShell process tree observed through `/proc`. | Authenticates the host action, proves the managed supervisor and gateway identity, holds a root-owned mode `0600` lifecycle lock, publishes one root-owned mode `0444` exact-exit authorization bound to the gateway and live root controller identities, signals through a pidfd, waits for the normal respawn loop, and verifies listener and HTTP health. |
4647
| `src/lib/shields/transition-lock.ts` | Runs in the host CLI under the operator account and owns the canonical per-sandbox transition lock. | Host state directory entries whose owner PID and start identity match the live lock owner. | Serializes shields mutations, rejects ambiguous or reused owners, and allows takeover only through the explicit recovery contract. |

scripts/lib/normalize_mutable_config_perms.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,279 @@ def normalize_dir(directory_fd: int, *, top_level: bool = False) -> None:
178178
os.close(child_fd)
179179

180180

181+
SEALED = 0
182+
UNSEALED = 1
183+
INDETERMINATE = 2
184+
185+
186+
def fd_mount_id(fd: int) -> int:
187+
"""Return Linux's mount ID for an open descriptor, failing closed."""
188+
189+
if not sys.platform.startswith("linux"):
190+
raise UnsafeTree()
191+
fdinfo_fd = -1
192+
try:
193+
fdinfo_fd = os.open(
194+
f"/proc/self/fdinfo/{fd}",
195+
os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0),
196+
)
197+
payload = os.read(fdinfo_fd, 4097)
198+
if len(payload) > 4096:
199+
raise UnsafeTree()
200+
mount_ids = [
201+
line.removeprefix(b"mnt_id:").strip()
202+
for line in payload.splitlines()
203+
if line.startswith(b"mnt_id:")
204+
]
205+
if len(mount_ids) != 1 or not mount_ids[0].isdigit():
206+
raise UnsafeTree()
207+
return int(mount_ids[0])
208+
except OSError as exc:
209+
raise UnsafeTree() from exc
210+
finally:
211+
if fdinfo_fd >= 0:
212+
os.close(fdinfo_fd)
213+
214+
215+
def open_fixed_files(
216+
root_fd: int,
217+
root_metadata: os.stat_result,
218+
*,
219+
uid: int,
220+
gid: int,
221+
mode: int,
222+
root_mount_id: int | None = None,
223+
) -> list[tuple[str, int, os.stat_result]]:
224+
opened_files: list[tuple[str, int, os.stat_result]] = []
225+
try:
226+
for name in FIXED_FILES:
227+
before = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
228+
if (
229+
not stat.S_ISREG(before.st_mode)
230+
or before.st_dev != root_metadata.st_dev
231+
or before.st_uid != uid
232+
or before.st_gid != gid
233+
or stat.S_IMODE(before.st_mode) != mode
234+
or before.st_nlink != 1
235+
):
236+
raise UnsafeTree()
237+
child_fd, opened = open_pinned(root_fd, name, file_flags(), before)
238+
try:
239+
current = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
240+
if (
241+
stable_file_key(opened) != stable_file_key(before)
242+
or stable_file_key(current) != stable_file_key(opened)
243+
or (
244+
root_mount_id is not None
245+
and fd_mount_id(child_fd) != root_mount_id
246+
)
247+
):
248+
raise UnsafeTree()
249+
except Exception:
250+
os.close(child_fd)
251+
raise
252+
opened_files.append((name, child_fd, opened))
253+
return opened_files
254+
except Exception:
255+
for _name, child_fd, _opened in opened_files:
256+
os.close(child_fd)
257+
raise
258+
259+
260+
def fixed_files_have_posture(
261+
root_fd: int,
262+
root_metadata: os.stat_result,
263+
*,
264+
uid: int,
265+
gid: int,
266+
mode: int,
267+
) -> bool:
268+
opened_files: list[tuple[str, int, os.stat_result]] = []
269+
try:
270+
opened_files = open_fixed_files(
271+
root_fd, root_metadata, uid=uid, gid=gid, mode=mode
272+
)
273+
return True
274+
except (OSError, UnsafeTree):
275+
return False
276+
finally:
277+
for _name, child_fd, _opened in opened_files:
278+
os.close(child_fd)
279+
280+
281+
def classify_seal(root_fd: int, root_metadata: os.stat_result) -> int:
282+
if (
283+
not stat.S_ISDIR(root_metadata.st_mode)
284+
or root_metadata.st_uid != 0
285+
or root_metadata.st_gid != 0
286+
):
287+
return INDETERMINATE
288+
root_mode = stat.S_IMODE(root_metadata.st_mode)
289+
if root_mode == 0o755 and fixed_files_have_posture(
290+
root_fd, root_metadata, uid=0, gid=0, mode=0o444
291+
):
292+
return SEALED
293+
if root_mode == 0o700 and fixed_files_have_posture(
294+
root_fd, root_metadata, uid=0, gid=0, mode=0o600
295+
):
296+
return UNSEALED
297+
return INDETERMINATE
298+
299+
300+
def open_config_binding(
301+
config_dir: str,
302+
) -> tuple[int, os.stat_result, int, os.stat_result, str]:
303+
normalized = os.path.normpath(config_dir)
304+
if not os.path.isabs(normalized) or normalized == os.path.sep:
305+
raise UnsafeTree()
306+
parent_fd = -1
307+
root_fd = -1
308+
try:
309+
parent_path = os.path.dirname(normalized)
310+
config_name = os.path.basename(normalized)
311+
parent_fd = os.open(parent_path, directory_flags())
312+
before = os.stat(config_name, dir_fd=parent_fd, follow_symlinks=False)
313+
root_fd, root_metadata = open_pinned(
314+
parent_fd, config_name, directory_flags(), before
315+
)
316+
return (
317+
parent_fd,
318+
os.fstat(parent_fd),
319+
root_fd,
320+
root_metadata,
321+
config_name,
322+
)
323+
except Exception:
324+
if root_fd >= 0:
325+
os.close(root_fd)
326+
if parent_fd >= 0:
327+
os.close(parent_fd)
328+
raise
329+
330+
331+
def mutable_parent_matches(
332+
parent_metadata: os.stat_result,
333+
sandbox_uid: int,
334+
sandbox_gid: int,
335+
) -> bool:
336+
return (
337+
stat.S_ISDIR(parent_metadata.st_mode)
338+
and parent_metadata.st_uid == sandbox_uid
339+
and parent_metadata.st_gid == sandbox_gid
340+
and stat.S_IMODE(parent_metadata.st_mode) == 0o755
341+
)
342+
343+
344+
def classify_config_path(
345+
config_dir: str,
346+
sandbox_uid: int,
347+
sandbox_gid: int,
348+
) -> int:
349+
parent_fd = -1
350+
root_fd = -1
351+
try:
352+
parent_fd, parent_metadata, root_fd, root_metadata, _name = (
353+
open_config_binding(config_dir)
354+
)
355+
state = classify_seal(root_fd, root_metadata)
356+
if state != UNSEALED:
357+
return state
358+
if (
359+
not mutable_parent_matches(parent_metadata, sandbox_uid, sandbox_gid)
360+
or root_metadata.st_dev != parent_metadata.st_dev
361+
or fd_mount_id(root_fd) != fd_mount_id(parent_fd)
362+
):
363+
return INDETERMINATE
364+
return UNSEALED
365+
except (OSError, UnsafeTree):
366+
return INDETERMINATE
367+
finally:
368+
if root_fd >= 0:
369+
os.close(root_fd)
370+
if parent_fd >= 0:
371+
os.close(parent_fd)
372+
373+
374+
def reclaim_fixed_files(
375+
root_fd: int,
376+
opened_files: list[tuple[str, int, os.stat_result]],
377+
sandbox_uid: int,
378+
sandbox_gid: int,
379+
) -> None:
380+
for name, child_fd, opened in opened_files:
381+
os.fchown(child_fd, sandbox_uid, sandbox_gid)
382+
os.fchmod(child_fd, 0o660)
383+
current = os.fstat(child_fd)
384+
if (
385+
current.st_uid != sandbox_uid
386+
or current.st_gid != sandbox_gid
387+
or stat.S_IMODE(current.st_mode) != 0o660
388+
or current.st_nlink != 1
389+
):
390+
raise UnsafeTree()
391+
verify_still_linked(root_fd, name, opened)
392+
393+
394+
def reclaim_if_unsealed(config_dir: str, sandbox_uid: int, sandbox_gid: int) -> int:
395+
"""Classify and, only if unsealed, reclaim a root-collapsed OpenClaw config.
396+
397+
Pins the parent, config directory, and both fixed files with O_NOFOLLOW so
398+
classification and reclaim act on the same inodes. The directory handoff
399+
occurs last, after every mutable file is ready for the sandbox identity.
400+
"""
401+
if os.geteuid() != 0 or sandbox_uid <= 0 or sandbox_gid <= 0:
402+
return 1
403+
parent_fd = -1
404+
root_fd = -1
405+
opened_files: list[tuple[str, int, os.stat_result]] = []
406+
try:
407+
parent_fd, parent_metadata, root_fd, root_metadata, config_name = (
408+
open_config_binding(config_dir)
409+
)
410+
seal_state = classify_seal(root_fd, root_metadata)
411+
if seal_state == SEALED:
412+
return 0
413+
if seal_state != UNSEALED:
414+
raise UnsafeTree()
415+
if (
416+
not mutable_parent_matches(parent_metadata, sandbox_uid, sandbox_gid)
417+
or root_metadata.st_dev != parent_metadata.st_dev
418+
or fd_mount_id(root_fd) != fd_mount_id(parent_fd)
419+
):
420+
raise UnsafeTree()
421+
opened_files = open_fixed_files(
422+
root_fd,
423+
root_metadata,
424+
uid=0,
425+
gid=0,
426+
mode=0o600,
427+
root_mount_id=fd_mount_id(root_fd),
428+
)
429+
reclaim_fixed_files(root_fd, opened_files, sandbox_uid, sandbox_gid)
430+
os.fchmod(root_fd, 0o000)
431+
os.fchown(root_fd, sandbox_uid, sandbox_gid)
432+
os.fchmod(root_fd, 0o2770)
433+
current = os.stat(config_name, dir_fd=parent_fd, follow_symlinks=False)
434+
final_root = os.fstat(root_fd)
435+
if (
436+
inode_key(current) != inode_key(final_root)
437+
or final_root.st_uid != sandbox_uid
438+
or final_root.st_gid != sandbox_gid
439+
or stat.S_IMODE(final_root.st_mode) != 0o2770
440+
):
441+
raise UnsafeTree()
442+
return 0
443+
except (OSError, UnsafeTree):
444+
return 1
445+
finally:
446+
for _name, child_fd, _opened in opened_files:
447+
os.close(child_fd)
448+
if root_fd >= 0:
449+
os.close(root_fd)
450+
if parent_fd >= 0:
451+
os.close(parent_fd)
452+
453+
181454
def config_dir_matches(
182455
root_fd: int,
183456
config_dir: str,
@@ -1358,6 +1631,28 @@ def run_root_supervisor(
13581631

13591632

13601633
def main() -> int:
1634+
if len(sys.argv) >= 2 and sys.argv[1] == "classify-seal":
1635+
if len(sys.argv) != 5:
1636+
return 1
1637+
config_dir = sys.argv[2]
1638+
try:
1639+
sandbox_uid = int(sys.argv[3])
1640+
sandbox_gid = int(sys.argv[4])
1641+
except ValueError:
1642+
return INDETERMINATE
1643+
return classify_config_path(config_dir, sandbox_uid, sandbox_gid)
1644+
1645+
if len(sys.argv) >= 2 and sys.argv[1] == "reclaim-if-unsealed":
1646+
if len(sys.argv) != 5:
1647+
return 1
1648+
config_dir = sys.argv[2]
1649+
try:
1650+
sandbox_uid = int(sys.argv[3])
1651+
sandbox_gid = int(sys.argv[4])
1652+
except ValueError:
1653+
return 1
1654+
return reclaim_if_unsealed(config_dir, sandbox_uid, sandbox_gid)
1655+
13611656
if len(sys.argv) not in {4, 5, 7}:
13621657
return 1
13631658
config_dir = sys.argv[1]

0 commit comments

Comments
 (0)