Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2be209c
fix(shields): preserve mutable-default rollback
HOYALIM Jul 26, 2026
4f05209
test(shields): prove fail-closed containment
HOYALIM Jul 26, 2026
fba7def
fix(shields): preserve corrupt state during rollback
apurvvkumaria Jul 26, 2026
5317f88
Merge remote-tracking branch 'origin/main' into codex/pr7568-maintain…
apurvvkumaria Jul 26, 2026
3e721d7
test(shields): assert corrupt rollback posture
apurvvkumaria Jul 26, 2026
fb6e689
fix(shields): reject corrupt state before mutation
HOYALIM Jul 26, 2026
dc205a5
fix(shields): report rollback outcome accurately
HOYALIM Jul 26, 2026
62d149d
fix(shields): revoke timer authority before rollback
prekshivyas Jul 27, 2026
d3c8bdb
test(shields): keep rollback fixtures linear
prekshivyas Jul 27, 2026
de1e819
test(shields): snapshot guard files atomically
HOYALIM Jul 27, 2026
fa46cf8
merge(main): refresh PR #7568
prekshivyas Jul 27, 2026
382ef92
chore(ci): refresh protected E2E authorization
jyaunches Jul 27, 2026
4773dbb
merge(main): refresh PR #7568
HOYALIM Jul 27, 2026
5eb82ac
Merge branch 'main' into codex/issue-7538-shields-mutable-default
cjagwani Jul 28, 2026
557469a
fix(shields): revalidate mutable unlock posture
cjagwani Jul 28, 2026
e1a14db
Merge branch 'main' into codex/issue-7538-shields-mutable-default
cjagwani Jul 30, 2026
4552920
merge(main): sync mutable rollback after idempotent unlock
HOYALIM Jul 31, 2026
d474b2c
Merge branch 'main' into codex/issue-7538-shields-mutable-default
senthilr-nv Jul 31, 2026
b2a5511
Merge branch 'main' into codex/issue-7538-shields-mutable-default
senthilr-nv Jul 31, 2026
54efeca
Merge branch 'main' into codex/issue-7538-shields-mutable-default
prekshivyas Aug 1, 2026
a785005
Merge branch 'main' into codex/issue-7538-shields-mutable-default
prekshivyas Aug 1, 2026
83b0dd3
fix(shields): revoke stale timer authority
prekshivyas Aug 1, 2026
849c69f
Merge remote-tracking branch 'origin/main' into codex/pr-7568-ci-fix
prekshivyas Aug 1, 2026
995bc0a
test(shields): keep timer harness linear
prekshivyas Aug 1, 2026
4531dfa
merge: refresh from main
prekshivyas Aug 1, 2026
89e2124
docs(shields): describe preserved rollback posture
prekshivyas Aug 1, 2026
032c294
Merge branch 'main' into codex/issue-7538-shields-mutable-default
cv Aug 2, 2026
c5ced91
merge: refresh from main
prekshivyas Aug 2, 2026
bc9986a
test(shields): cover malformed mutable unlock config
prekshivyas Aug 2, 2026
e03001a
test(shields): quote guard fixture node path
prekshivyas Aug 2, 2026
ba9cf03
merge(main): refresh PR #7568
cv Aug 3, 2026
729ee14
Merge branch 'main' into codex/issue-7538-shields-mutable-default
prekshivyas Aug 4, 2026
3344632
Merge branch 'main' into codex/issue-7538-shields-mutable-default
apurvvkumaria Aug 4, 2026
b0fb1f2
test(shields): extract flow test harness
HOYALIM Aug 4, 2026
79a6f04
merge(main): refresh mutable rollback contract
HOYALIM Aug 4, 2026
37e6925
Merge branch 'main' into codex/issue-7538-shields-mutable-default
apurvvkumaria Aug 4, 2026
44e9daf
merge: refresh PR #7568 with current main
cv Aug 5, 2026
9425eb8
test(shields): restore rollback regression coverage
cv Aug 5, 2026
9709392
fix(shields): report revoked timer authority
cv Aug 5, 2026
8fe62bf
merge(main): refresh PR #7568
cv Aug 5, 2026
8f81d28
test(shields): simplify rollback failure setup
cv Aug 5, 2026
57fedfd
merge: synchronize main into PR #7568
cv Aug 5, 2026
65083b3
merge(main): refresh shields rollback fix
apurvvkumaria Aug 5, 2026
31a74a2
merge(main): refresh launch workflow changes
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
4 changes: 4 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,10 @@ Follow [Deep Agents Config Lock Failure Recovery](troubleshooting#deep-agents-co
</AgentOnly>

Host-side config and inference writes, snapshot mutation, sandbox destruction, and shields transitions serialize per sandbox.
Before `shields down` opens a new window, NemoClaw must revoke any stale auto-restore timer authority.
If marker cleanup fails, the command reports `Cannot revoke stale auto-restore timer authority` and stops before policy capture, state writes, config unlock, replacement-timer startup, or audit writes.
The sandbox retains its existing configuration and policy posture, and the stale timer authority remains.
Resolve the reported timer-marker error on the trusted host, then retry `shields down`.
When a timed shields-down window reaches its deadline, auto-restore closes the per-sandbox lifecycle deadline gate.
The gate blocks new mutations and waits for the recorded live owner to release its exact lock generation before auto-restore restores lockdown.
NemoClaw does not signal that process because portable process inspection cannot prove that all descendants stopped.
Expand Down
6 changes: 6 additions & 0 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3529,6 +3529,12 @@ def _transition(
# and treat the transition as a no-op instead of failing.
pair = _snapshot_raw_pair(opened)
_verify_mutable_posture(opened, pair, identity)
_validate_runtime_config_json5(
pair[0].data,
posixpath.join(opened.config_path, "openclaw.json"),
identity,
)
_assert_config_binding(opened)
return
pair = _snapshot_pair(opened)
_verify_locked_posture(opened, pair, identity, allow_blocking_flags=True)
Expand Down
45 changes: 45 additions & 0 deletions src/lib/shields/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ function withDefaultNodeExecFileSync(
return defaultNodeExecFileSync(file, argv) || fallback();
}

function throwErrno(message: string, code: string): never {
const error = new Error(message) as NodeJS.ErrnoException;
error.code = code;
throw error;
}
function throwRegistryPermissionDenied(): never {
throw Object.assign(new Error("registry permission denied"), { code: "EACCES" });
}
Expand Down Expand Up @@ -1163,6 +1168,7 @@ describe("NC-2227-05: shields timer marker behavior", () => {
const result = killTimer("openclaw");

expect(result).toEqual({
authorityRevoked: true,
markerFound: true,
markerPid: 7331,
wasAlive: true,
Expand Down Expand Up @@ -1254,6 +1260,7 @@ describe("NC-2227-05: shields timer marker behavior", () => {

const result = killTimer("openclaw");
expect(result).toEqual({
authorityRevoked: true,
markerFound: true,
markerPid: 7331,
wasAlive: false,
Expand All @@ -1264,6 +1271,44 @@ describe("NC-2227-05: shields timer marker behavior", () => {
expect(fs.existsSync(markerPath)).toBe(false);
});

it("killTimer reports active authority when the marker cannot be cleared", async () => {
const sourceModulePath = path.join(process.cwd(), "src", "lib", "shields", "timer-control.ts");
const { killTimer } = await import(sourceModulePath);
const stateDir = path.join(tmpDir, ".nemoclaw", "state");
fs.mkdirSync(stateDir, { recursive: true });
const markerPath = path.join(stateDir, "shields-timer-openclaw.json");
fs.writeFileSync(
markerPath,
JSON.stringify({
pid: 7331,
sandboxName: "openclaw",
snapshotPath: "/tmp/snap.yaml",
restoreAt: new Date(Date.now() + 60_000).toISOString(),
}),
);

const processKillSpy = vi
.spyOn(process, "kill")
.mockImplementation((pid: number, signal?: string | number) =>
pid === 7331 && signal === 0 ? throwErrno("gone", "ESRCH") : true,
);
const originalUnlinkSync = fs.unlinkSync.bind(fs);
vi.spyOn(fs, "unlinkSync").mockImplementation((filePath: fs.PathLike) =>
String(filePath) === markerPath
? throwErrno("permission denied", "EACCES")
: originalUnlinkSync(filePath),
);

const result = killTimer("openclaw");

expect(result.authorityRevoked).toBe(false);
expect(result.warnings).toEqual([
expect.stringContaining("Failed to remove shields timer marker"),
]);
expect(processKillSpy).toHaveBeenCalledWith(7331, 0);
expect(fs.existsSync(markerPath)).toBe(true);
});

it("isShieldsDown and shieldsDown fail closed when shields state is corrupt", async () => {
const sourceModulePath = path.join(process.cwd(), "src", "lib", "shields", "index.ts");
const { isShieldsDown, shieldsDown } = await import(sourceModulePath);
Expand Down
133 changes: 119 additions & 14 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,8 @@ function loadShieldsState(sandboxName: string): LoadedShieldsState {
const filePath = stateFilePath(sandboxName);
if (!fs.existsSync(filePath)) return { _hasStateFile: false };
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const contents = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(contents);
if (!isShieldsState(parsed)) {
return {
_hasStateFile: true,
Expand Down Expand Up @@ -1220,6 +1221,25 @@ function saveShieldsState(sandboxName: string, patch: ShieldsState): ShieldsStat
return updated;
}

function restoreShieldsStateSnapshot(sandboxName: string, state: LoadedShieldsState): void {
const {
_hasStateFile: hasStateFile,
_isCorrupt: isCorrupt,
_corruptError: _corruptError,
...persisted
} = state;
const filePath = stateFilePath(sandboxName);
if (!hasStateFile) {
fs.rmSync(filePath, { force: true });
return;
}
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
if (isCorrupt) {
throw new Error("Cannot restore a corrupt shields state snapshot");
}
fs.writeFileSync(filePath, JSON.stringify(persisted, null, 2), { mode: 0o600 });
}

function isOptionalBoolean(value: unknown): value is boolean | undefined {
return value === undefined || typeof value === "boolean";
}
Expand Down Expand Up @@ -2866,6 +2886,25 @@ function lockAgentConfig(
});
}

type ShieldsDownRollbackOutcome =
| "mutable_default_restored"
| "lockdown_restored"
| "manual_intervention_required";

type ShieldsDownRollbackResult = {
outcome: ShieldsDownRollbackOutcome;
timerAuthorityRevoked: boolean;
};

function describeRollbackTimerAuthority(
hadScheduledTimer: boolean,
timerAuthorityRevoked: boolean,
): string {
if (!hadScheduledTimer) return "";
return timerAuthorityRevoked
? " Auto-restore timer authority was revoked."
: " The scheduled auto-restore remains authoritative.";
}
function resolveExactManagedMcpPolicies(
sandboxName: string,
livePolicyYaml?: string,
Expand Down Expand Up @@ -3063,9 +3102,11 @@ function rollbackShieldsDown(
sandboxName: string,
target: AgentConfigTarget,
snapshotPath: string,
initialMode: ShieldsMode,
initialState: LoadedShieldsState,
allowLegacyHermesProtocol = false,
cachedProtocol?: HermesShieldsProtocol,
): void {
): ShieldsDownRollbackResult {
console.error(" Rolling back — restoring policy from snapshot...");
let rollbackResult: ReturnType<typeof run> | null = null;
try {
Expand All @@ -3074,9 +3115,30 @@ function rollbackShieldsDown(
const message = error instanceof Error ? error.message : String(error);
console.error(` Warning: Policy restore preparation failed during rollback: ${message}`);
}
let timerAuthorityRevoked = false;
let rollbackChattrApplied: boolean | null = null;
let rollbackFileHashes: { [path: string]: string } | null = null;
if (rollbackResult?.status === 0) {
if (initialMode === "mutable_default" && target.agentName === "openclaw") {
try {
unlockAgentConfig(sandboxName, target, false, allowLegacyHermesProtocol, cachedProtocol);
const timerCancellation = killTimer(sandboxName);
timerAuthorityRevoked = timerCancellation.authorityRevoked;
if (!timerCancellation.authorityRevoked) {
throw new Error(
`Cannot revoke auto-restore timer authority: ${timerCancellation.warnings.join("; ")}`,
);
}
restoreShieldsStateSnapshot(sandboxName, initialState);
console.error(" Original mutable-default posture restored.");
return { outcome: "mutable_default_restored", timerAuthorityRevoked };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.error(
` Warning: Could not verify the original mutable-default posture; applying fail-closed lockdown. ${detail}`,
);
}
}
// Re-confirm after the settle window so a reconciler revert cannot leave
// the rolled-back config DRIFTED — same fail-closed treatment as the
// auto-restore path. Leaves the hashes null (→ "manual intervention"
Expand All @@ -3089,7 +3151,7 @@ function rollbackShieldsDown(
rollbackFileHashes = relock.lastResult.fileHashes;
} else {
console.error(
" Warning: Rollback re-lock could not be re-confirmed. Check config manually.",
` Warning: Rollback re-lock could not be re-confirmed. Check config manually. ${relock.error ?? ""}`.trimEnd(),
);
}
} else {
Expand All @@ -3105,11 +3167,16 @@ function rollbackShieldsDown(
chattrApplied: rollbackChattrApplied,
fileHashes: rollbackFileHashes,
});
console.error(" Lockdown restored. Config was never left unguarded.");
} else {
console.error(" Config remains unlocked — manual intervention required.");
printManualRelockRecoveryHint(sandboxName);
console.error(
initialMode === "mutable_default"
? " Fail-closed lockdown applied; the original mutable-default posture was not restored."
: " Lockdown restored. Config was never left unguarded.",
);
return { outcome: "lockdown_restored", timerAuthorityRevoked };
}
console.error(" Config remains unlocked — manual intervention required.");
printManualRelockRecoveryHint(sandboxName);
return { outcome: "manual_intervention_required", timerAuthorityRevoked };
}

interface LockdownActivationResult {
Expand Down Expand Up @@ -3358,6 +3425,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
);
return failShieldsCommand(`Shields state is corrupt for ${sandboxName}`, opts.throwOnError);
}
const initialMode = deriveShieldsMode(state, state._hasStateFile);
if (state.shieldsDown) {
console.error(
` Config is already unlocked for ${sandboxName} (since ${state.shieldsDownAt}).`,
Expand All @@ -3381,7 +3449,15 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
// Kill stale auto-restore markers only when this command will actually
// transition into shields-down. A repeated shields-down must not cancel the
// active timer and leave the sandbox unlocked indefinitely.
killTimer(sandboxName);
const timerCancellation = killTimer(sandboxName);
if (!timerCancellation.authorityRevoked) {
const detail = timerCancellation.warnings.join("; ");
console.error(` Cannot revoke stale auto-restore timer authority: ${detail}`);
return failShieldsCommand(
`Cannot revoke stale auto-restore timer authority for ${sandboxName}`,
opts.throwOnError,
);
}

const timeoutSeconds = parseDuration(opts.timeout || `${DEFAULT_TIMEOUT_SECONDS}`);
const reason = opts.reason || null;
Expand Down Expand Up @@ -3596,24 +3672,40 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
unlockAgentConfig(
sandboxName,
target,
deriveShieldsMode(state, state._hasStateFile) === "locked",
initialMode === "locked",
opts.allowLegacyHermesProtocol === true,
protocol,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
rollbackShieldsDown(
const rollback = rollbackShieldsDown(
sandboxName,
target,
snapshotPath,
initialMode,
state,
opts.allowLegacyHermesProtocol === true,
protocol,
);
if (transition) clearShieldsDownTransition(sandboxName, transition.processToken);
console.error(` ERROR: ${message}`);
console.error(
" Config did not reach the mutable-default state; the scheduled auto-restore remains authoritative.",
const timerAuthority = describeRollbackTimerAuthority(
transition !== null,
rollback.timerAuthorityRevoked,
);
if (rollback.outcome === "mutable_default_restored") {
console.error(
` Config mutation failed; the original mutable-default posture was restored.${timerAuthority}`,
);
} else if (rollback.outcome === "lockdown_restored") {
console.error(
` Config did not reach the mutable-default state; fail-closed lockdown was restored.${timerAuthority}`,
);
} else {
console.error(
` Config rollback is incomplete.${timerAuthority} Manual intervention is required.`,
);
}
console.error(
` Re-run \`nemoclaw ${sandboxName} shields down\` after correcting file ownership.`,
);
Expand All @@ -3626,16 +3718,29 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
writeShieldsDownTransition(transition, "preparing");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
rollbackShieldsDown(
const rollback = rollbackShieldsDown(
sandboxName,
target,
snapshotPath,
initialMode,
state,
opts.allowLegacyHermesProtocol === true,
protocol,
);
clearShieldsDownTransition(sandboxName, transition.processToken);
console.error(` ERROR: ${message}`);
console.error(" Auto-restore handoff failed; lockdown was restored.");
const timerAuthority = describeRollbackTimerAuthority(true, rollback.timerAuthorityRevoked);
if (rollback.outcome === "mutable_default_restored") {
console.error(
` Auto-restore handoff failed; the original mutable-default posture was restored.${timerAuthority}`,
);
} else if (rollback.outcome === "lockdown_restored") {
console.error(` Auto-restore handoff failed; lockdown was restored.${timerAuthority}`);
} else {
console.error(
` Auto-restore handoff failed; rollback is incomplete.${timerAuthority} Manual intervention is required.`,
);
}
return failShieldsCommand(message, opts.throwOnError);
}
}
Expand Down
Loading
Loading