Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
60 changes: 58 additions & 2 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2941,6 +2941,32 @@ def _verify_locked_files(
)


def _is_resealable_config_hash_permissions_drift(
snapshots: tuple[FileSnapshot, FileSnapshot], identity: Identity
) -> bool:
config, hash_record = snapshots
blocking_flags = FS_IMMUTABLE_FL | FS_APPEND_FL
config_stays_locked = (
config.uid == identity.root_uid
and config.gid == identity.root_gid
and config.mode == 0o444
and not (
config.inode_flags is not None
and config.inode_flags & blocking_flags
)
)
hash_has_known_reconciler_posture = (
hash_record.uid == identity.sandbox_uid
and hash_record.gid == identity.sandbox_gid
and hash_record.mode == 0o660
and not (
hash_record.inode_flags is not None
and hash_record.inode_flags & blocking_flags
)
)
return config_stays_locked and hash_has_known_reconciler_posture


def _verify_locked_posture(
opened: OpenConfig,
snapshots: tuple[FileSnapshot, FileSnapshot],
Expand Down Expand Up @@ -3266,6 +3292,12 @@ def _preflight_restart(opened: OpenConfig, identity: Identity) -> None:

_hash_synthesized = False

# Set True when a lock transition re-seals a perms-only drift on an already-locked
# config (an in-sandbox reconciler re-permissioned a canonical file after the
# lock: #4663 / #7985). Surfaced in the result JSON so the host can report the
# self-heal instead of leaving it invisible.
_resealed_drift = False


def _write_hash_record(opened: OpenConfig, config_data: bytes, identity: Identity) -> None:
digest = hashlib.sha256(config_data).hexdigest()
Expand Down Expand Up @@ -3426,6 +3458,7 @@ def _transition(
*,
quarantine_untrusted: bool = False,
) -> None:
global _resealed_drift
if action == "lock":
if _has_clamped_locked_dir_posture(opened, identity):
pair = _snapshot_pair(opened)
Expand All @@ -3435,8 +3468,30 @@ def _transition(
# A restart journal may still need rootfs-authenticated cleanup.
_settle_pending_transaction_for_lock(opened, identity)
pair = _snapshot_pair(opened)
_verify_locked_files(opened, pair, identity)
return
try:
_verify_locked_files(opened, pair, identity)
return
except GuardError as verify_error:
if verify_error.code != "config-not-locked" or not (
_is_resealable_config_hash_permissions_drift(pair, identity)
):
raise
# The config remains root-owned and read-only, while the
# in-sandbox reconciler re-permissioned only .config-hash after
# the lock (#4663 / #7985). Re-seal below instead of failing
# closed: the root-owned directory prevents replacement, the
# config cannot be written, and _snapshot_pair verified that the
# sidecar still authenticates those config bytes.
# Source: the writer is the upstream OpenClaw in-sandbox gateway/
# doctor perm-normalizer, which NemoClaw does not own, so the
# correct fix is this host-authenticated relock re-seal, not a
# change to that writer, which this repo cannot make. Removal
# condition: delete this path once the lock is durably immutable on
# every platform (chattr +i, unavailable on overlayfs today) or the
# upstream reconciler stops re-permissioning an already-locked
# config; either fully closes the #4663 relock settle-window race
# that this branch only narrows.
_resealed_drift = True
freeze_started = False
try:
freeze_started = True
Expand Down Expand Up @@ -4273,6 +4328,7 @@ def main(argv: list[str] | None = None) -> int:
"chattrApplied": False,
**({"configSha256": new_digest} if new_digest is not None else {}),
**({"hashSynthesized": True} if _hash_synthesized else {}),
**({"resealedDrift": True} if _resealed_drift else {}),
**({"recovery": recovery} if recovery is not None else {}),
**(
{"originalLocked": original_locked}
Expand Down
9 changes: 9 additions & 0 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,15 @@ function transitionOpenClawTopConfig(
`Config not ${action === "unlock" ? "unlocked" : "locked"}: ${result.issues.join(", ")}`,
);
}
if (result.resealedDrift) {
// The guard found an already-locked config whose canonical file had drifted
// perms-only (a reconciler re-permissioned it after the lock) and re-sealed
// it in place instead of failing closed (#4663 / #7985). Surface the
// self-heal so a rebuild/relock does not fix drift invisibly.
console.log(
" Re-sealed a perms-only config-lock drift (config dir stays root-owned; contents intact).",
);
}
return result.chattrApplied;
}

Expand Down
29 changes: 29 additions & 0 deletions src/lib/shields/openclaw-config-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,33 @@ describe("OpenClaw top-config guard host wiring", () => {
expect(parsed.hashSynthesized).toBe(true);
expect(parseOpenClawConfigGuardOutput("lock", plain).hashSynthesized).toBeUndefined();
});

it("propagates the guard's re-sealed-drift marker on a successful lock", () => {
const resealed: PrivilegedExecResult = {
status: 0,
signal: null,
stdout: `${JSON.stringify({
type: "result",
action: "lock",
status: "ok",
configDir: OPENCLAW_CONFIG_DIR,
files: ["openclaw.json", ".config-hash"],
chattrApplied: false,
resealedDrift: true,
})}\n`,
stderr: "",
};
const plain: PrivilegedExecResult = {
status: 0,
signal: null,
stdout: `${success("lock")}\n`,
stderr: "",
};

const parsed = parseOpenClawConfigGuardOutput("lock", resealed);

expect(parsed.issues).toEqual([]);
expect(parsed.resealedDrift).toBe(true);
expect(parseOpenClawConfigGuardOutput("lock", plain).resealedDrift).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions src/lib/shields/openclaw-config-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type GuardSummary = {
chattrApplied?: boolean;
configSha256?: string;
hashSynthesized?: boolean;
resealedDrift?: boolean;
recovery?: string;
originalLocked?: boolean;
};
Expand All @@ -74,6 +75,7 @@ export type OpenClawConfigGuardResult = {
chattrApplied: boolean;
configSha256?: string;
hashSynthesized?: boolean;
resealedDrift?: boolean;
recovery?: string;
originalLocked?: boolean;
};
Expand Down Expand Up @@ -230,6 +232,7 @@ export function parseOpenClawConfigGuardOutput(
(record.chattrApplied === undefined || typeof record.chattrApplied === "boolean") &&
(record.configSha256 === undefined || typeof record.configSha256 === "string") &&
(record.hashSynthesized === undefined || typeof record.hashSynthesized === "boolean") &&
(record.resealedDrift === undefined || typeof record.resealedDrift === "boolean") &&
(record.recovery === undefined || typeof record.recovery === "string") &&
(record.originalLocked === undefined || typeof record.originalLocked === "boolean")
) {
Expand Down Expand Up @@ -302,6 +305,7 @@ export function parseOpenClawConfigGuardOutput(
...(summary?.status === "ok" && summary.hashSynthesized === true
? { hashSynthesized: true }
: {}),
...(summary?.status === "ok" && summary.resealedDrift === true ? { resealedDrift: true } : {}),
...(summary?.status === "ok" && summary.recovery ? { recovery: summary.recovery } : {}),
...(summary?.status === "ok" && typeof summary.originalLocked === "boolean"
? { originalLocked: summary.originalLocked }
Expand Down
52 changes: 52 additions & 0 deletions test/e2e/live/shields-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ test("shields-config: live shields up/down locks config and detects drift", {
"establish the mutable unified OpenClaw config",
"lock config and workspace and inspect redaction",
"detect host-root config drift and refuse resealing",
"re-seal a perms-only .config-hash drift instead of failing closed",
"unlock shields and inspect the audit trail",
"recover shields after a dead restore timer",
"reject duplicate shields transitions",
Expand All @@ -246,6 +247,7 @@ test("shields-config: live shields up/down locks config and detects drift", {
"documented nemoclaw exec doctor path preserves 2770/660 and gateway writes",
"shields up locks config/workspace and config get redacts secrets",
"host-root chmod-write-chmod tamper is detected as content drift",
"a perms-only .config-hash drift is re-sealed by shields up, not failed closed",
"shields down restores mutable modes and records audit JSONL",
"dead auto-restore timer inline recovery re-locks config and .config-hash",
"double shields-up/down operations are rejected",
Expand Down Expand Up @@ -578,6 +580,55 @@ test("shields-config: live shields up/down locks config and detects drift", {
expect(statusRestored.exitCode, resultText(statusRestored)).toBe(0);
expect(statusRestored.stdout).toContain("Shields: UP (lockdown active)");

progress.phase("re-seal a perms-only .config-hash drift instead of failing closed");
// #7985/#4663: an in-sandbox privileged reconciler (OpenClaw gateway / doctor
// perm-normalization) can re-permission .config-hash back to group-writable
// AFTER the lock without touching its bytes. That perms-only drift must be
// re-sealed by the drift-repair `shields up`, not fail closed with
// "restart seal requires the exact shields-locked file posture" (which
// stranded host state UNLOCKED while the tree stayed root-locked). The bytes
// are untouched here, so it is a launderable perms drift, not content drift.
const permsDrift = await host.command(
"bash",
[
"-lc",
`docker exec -u 0 ${containerId} sh -c 'chattr -i ${CONFIG_HASH_PATH} 2>/dev/null || true; chmod 660 ${CONFIG_HASH_PATH} && chown sandbox:sandbox ${CONFIG_HASH_PATH}'`,
],
{
artifactName: "phase-5c-config-hash-perms-only-drift",
env: commandEnv(),
timeoutMs: 30_000,
},
);
expect(permsDrift.exitCode, resultText(permsDrift)).toBe(0);

const hashDrifted = await statPath(sandbox, CONFIG_HASH_PATH, "phase-5c-hash-perms-after-drift");
expect(hashDrifted).toMatchObject({ mode: "660", owner: "sandbox:sandbox" });

// The drift-repair relock reaches the OpenClaw config guard's already-locked
// branch. The fix re-seals the perms-only drift; before it, the guard
// rejected with config-not-locked and the relock could never re-apply.
const reseal = await runNemoclaw(host, [SANDBOX_NAME, "shields", "up"], {
artifactName: "phase-5c-shields-up-reseals-perms-drift",
});
expect(reseal.exitCode, resultText(reseal)).toBe(0);
// The guard surfaces the self-heal so a relock/rebuild does not fix a
// perms-only drift invisibly (#4663 / #7985 observability).
expect(resultText(reseal)).toContain("Re-sealed a perms-only config-lock drift");

const hashResealed = await statPath(
sandbox,
CONFIG_HASH_PATH,
"phase-5c-hash-perms-after-reseal",
);
expect(hashResealed).toMatchObject({ mode: "444", owner: "root:root" });

const statusResealed = await runNemoclaw(host, [SANDBOX_NAME, "shields", "status"], {
artifactName: "phase-5c-shields-status-after-reseal",
});
expect(statusResealed.exitCode, resultText(statusResealed)).toBe(0);
expect(statusResealed.stdout).toContain("Shields: UP (lockdown active)");

progress.phase("unlock shields and inspect the audit trail");
const shieldsDown = await runNemoclaw(
host,
Expand Down Expand Up @@ -730,6 +781,7 @@ test("shields-config: live shields up/down locks config and detects drift", {
shieldsUpLock: true,
configGetRedaction: true,
contentDriftDetection: true,
permsOnlyDriftReseal: true,
shieldsDownMutableRestore: true,
auditTrail: true,
deadTimerInlineAutoRestore: true,
Expand Down
Loading
Loading