Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
13 changes: 13 additions & 0 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3461,6 +3461,19 @@ def _transition(
raise GuardError(exc.code, exc.path, detail) from exc
raise GuardError("transition-failed", opened.config_path, detail) from exc

if _is_mutable_dir_posture(opened, identity):
# Unlock is idempotent, mirroring the already-locked short-circuits in
# the lock branch above. A config already in the exact mutable posture
# is the unlock target: on some platforms (DGX Spark/Station, macOS) an
# in-sandbox OpenClaw reconciler re-permissions the config back to
# sandbox-owned mutable *after* a host lock returns, and a freshly
# onboarded or snapshot-restored sandbox boots mutable before the lock
# settles. Requiring the locked posture there rejected a legitimate
# `shields down` with config-not-locked even though the config already
# holds the mutable target posture. Verify the exact mutable file
# posture and treat the transition as a no-op instead of failing.
_verify_mutable_files(opened, _snapshot_raw_pair(opened), identity)
return
Comment on lines +3464 to +3477

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The directory check happens before _snapshot_raw_pair(), but this tree is already sandbox-writable. If a sandbox process changes the parent or config directory during capture, _verify_mutable_files() still passes and this branch returns success without rechecking the directory. I reproduced _transition("unlock", ...) succeeding with final config-directory mode 0700. Capture the pair, then validate the full mutable posture (for example _verify_mutable_posture(opened, pair, identity)) and add a directory-drift regression.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed at exact signed/Verified head 32c1b9698. The already-mutable unlock path now captures with _snapshot_raw_pair() and then calls _verify_mutable_posture(opened, pair, identity) before returning. The regression mutates the config directory to 0700 during capture and requires fail-closed config-not-mutable; the companion rejected-mode coverage also proves a 0600 file remains unchanged. The focused guard suite passes 45/45 and exact-range hooks pass. Leaving this thread open for your re-review.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact head update: aec2516 is now current-main synced and GitHub Verified; the feature diff is unchanged. The full mutable-posture revalidation and directory-drift regression remain in place. Exact-head guard tests pass 45/45 under the repository Python 3.11 environment, exact-range hooks pass, and the fresh WSL, macOS, CLI, CodeQL, and E2E gates are green. Leaving the thread open for your re-review.

pair = _snapshot_pair(opened)
_verify_locked_posture(opened, pair, identity, allow_blocking_flags=True)
snapshots: list[FileSnapshot] = []
Expand Down
8 changes: 8 additions & 0 deletions test/openclaw-config-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,14 @@ describe("openclaw-config-guard", () => {
expect(fs.readFileSync(hashPath)).toEqual(hashBytes);
});

it("unlocks idempotently when the config already holds the mutable posture (#7430)", () => {
// An in-sandbox reconciler can leave the config mutable before shields-down; unlock must be a no-op.
const { configDir, configPath, hashPath } = fixture();
const r = runGuard("unlock", configDir);
expect(r.status, JSON.stringify(r.lines)).toBe(0);
expect([mode(configDir), mode(configPath), mode(hashPath)]).toEqual([0o2770, 0o660, 0o660]);
});

it("fresh-replaces both files on lock and unlock while preserving bytes, times, and xattrs", () => {
const { root, configDir, configPath, hashPath } = fixture();
const preservedTime = new Date("2025-01-02T03:04:05.000Z");
Expand Down
Loading