Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3461,6 +3461,13 @@ 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):
# Fresh and rebuilt sandboxes begin in this exact posture. Treating
# unlock as a locked-only transition makes the first shields-down fail
# and its rollback change the posture before a retry can succeed.
_preflight_restart(opened, identity)
return

pair = _snapshot_pair(opened)
_verify_locked_posture(opened, pair, identity, allow_blocking_flags=True)
snapshots: list[FileSnapshot] = []
Expand Down
117 changes: 117 additions & 0 deletions src/lib/shields/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ setInterval(() => {}, 60000);
type ShieldsHarness = {
auditSpy: MockInstance;
errorSpy: MockInstance;
getOpenClawPosture: () => "locked" | "mutable";
logSpy: MockInstance;
runSpy: MockInstance;
shieldsDown: typeof import("./index.js").shieldsDown;
Expand Down Expand Up @@ -214,6 +215,7 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness {
return {
auditSpy,
errorSpy,
getOpenClawPosture: () => openClawPosture,
logSpy,
runSpy,
shieldsDown: shields.shieldsDown,
Expand Down Expand Up @@ -283,6 +285,121 @@ describe("shields command flow", () => {
);
});

it("restores fresh mutable-default state when the timer handoff fails", () => {
const stateDir = path.join(tmpDir, ".nemoclaw", "state");
const harness = createHarness({
fork: () => ({
pid: 4242,
disconnect: vi.fn(),
unref: vi.fn(),
send: vi.fn(() => {
const transitionName = fs
.readdirSync(stateDir)
.find((entry) => entry.startsWith("shields-transition-openclaw-"));
const transitionPath = path.join(stateDir, transitionName!);
const transition = JSON.parse(fs.readFileSync(transitionPath, "utf-8"));
fs.writeFileSync(transitionPath, JSON.stringify({ ...transition, phase: "active" }));
return true;
}),
kill: vi.fn(() => true),
}),
});

expect(() =>
harness.shieldsDown("openclaw", {
timeout: "5m",
reason: "rollback coverage",
throwOnError: true,
}),
).toThrow("Shields-down recovery ownership changed during the transition");

expect(fs.existsSync(path.join(stateDir, "shields-openclaw.json"))).toBe(false);
const output = harness.errorSpy.mock.calls.flat().map(String).join("\n");
expect(output).toContain("Original mutable-default posture restored");
expect(output).not.toContain("Lockdown restored");
});

it("restores corrupt mutable-default state bytes when the timer handoff fails", () => {
const stateDir = path.join(tmpDir, ".nemoclaw", "state");
const statePath = path.join(stateDir, "shields-openclaw.json");
const corruptState = "{not-json\n";
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(statePath, corruptState);
const harness = createHarness({
fork: () => ({
pid: 4242,
disconnect: vi.fn(),
unref: vi.fn(),
send: vi.fn(() => {
const transitionName = fs
.readdirSync(stateDir)
.find((entry) => entry.startsWith("shields-transition-openclaw-"));
const transitionPath = path.join(stateDir, transitionName!);
const transition = JSON.parse(fs.readFileSync(transitionPath, "utf-8"));
fs.writeFileSync(transitionPath, JSON.stringify({ ...transition, phase: "active" }));
return true;
}),
kill: vi.fn(() => true),
}),
});

expect(() =>
harness.shieldsDown("openclaw", {
timeout: "5m",
reason: "corrupt rollback coverage",
throwOnError: true,
}),
).toThrow("Shields-down recovery ownership changed during the transition");

expect(fs.readFileSync(statePath, "utf-8")).toBe(corruptState);
expect(harness.errorSpy.mock.calls.flat().map(String).join("\n")).toContain(
"Original mutable-default posture restored",
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

it("reports fail-closed lockdown when mutable-default rollback cannot be verified", () => {
const harness = createHarness({
failOpenClawGuardActions: ["unlock"],
dockerExecFileSync: (argv: unknown) => {
const args = Array.isArray(argv) ? argv.map(String) : [];
switch (true) {
case args.includes("sha256sum"):
return `${"a".repeat(64)} ${String(args.at(-1))}\n`;
case args.includes("lsattr"):
return `----i---------e----- ${String(args.at(-1))}\n`;
case args.includes("stat"):
return args.at(-1) === "/sandbox"
? "1775 root:sandbox\n"
: args.at(-1) === "/sandbox/.openclaw"
? "755 root:root\n"
: "444 root:root\n";
default:
return "";
}
},
});

expect(() =>
harness.shieldsDown("openclaw", {
timeout: "5m",
reason: "containment coverage",
skipTimer: true,
throwOnError: true,
}),
).toThrow(/startup-not-ready/);

const statePath = path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json");
expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({
shieldsDown: false,
});
expect(harness.getOpenClawPosture()).toBe("locked");
const output = harness.errorSpy.mock.calls.flat().map(String).join("\n");
expect(output).toContain("applying fail-closed lockdown");
expect(output).toContain(
"Fail-closed lockdown applied; the original mutable-default posture was not restored",
);
});

it("binds manual shields-up to the active auto-restore timer generation", () => {
const stateDir = path.join(tmpDir, ".nemoclaw", "state");
const sandboxName = "openclaw";
Expand Down
70 changes: 66 additions & 4 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ type LoadedShieldsState = ShieldsState & {
_hasStateFile: boolean;
_isCorrupt?: boolean;
_corruptError?: string;
_rawFileContents?: string;
};

interface ShieldsPosture {
Expand Down Expand Up @@ -837,13 +838,17 @@ function describeShieldsMode(mode: ShieldsPostureMode): Omit<ShieldsPosture, "st
function loadShieldsState(sandboxName: string): LoadedShieldsState {
const filePath = stateFilePath(sandboxName);
if (!fs.existsSync(filePath)) return { _hasStateFile: false };
let rawFileContents: string | undefined;
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const contents = fs.readFileSync(filePath, "utf-8");
rawFileContents = contents;
const parsed = JSON.parse(contents);
if (!isShieldsState(parsed)) {
return {
_hasStateFile: true,
_isCorrupt: true,
_corruptError: "invalid shields state shape",
_rawFileContents: rawFileContents,
};
}
const state: ShieldsState = parsed;
Expand All @@ -854,6 +859,7 @@ function loadShieldsState(sandboxName: string): LoadedShieldsState {
_hasStateFile: true,
_isCorrupt: true,
_corruptError: message,
...(rawFileContents === undefined ? {} : { _rawFileContents: rawFileContents }),
};
}
}
Expand Down Expand Up @@ -901,6 +907,7 @@ function saveShieldsState(sandboxName: string, patch: ShieldsState): ShieldsStat
_hasStateFile: _hasStateFile,
_isCorrupt: _isCorrupt,
_corruptError: _corruptError,
_rawFileContents: _rawFileContents,
...currentClean
} = current;
const updated: ShieldsState = {
Expand All @@ -913,6 +920,30 @@ function saveShieldsState(sandboxName: string, patch: ShieldsState): ShieldsStat
return updated;
}

function restoreShieldsStateSnapshot(sandboxName: string, state: LoadedShieldsState): void {
const {
_hasStateFile: hasStateFile,
_isCorrupt: isCorrupt,
_corruptError: _corruptError,
_rawFileContents: rawFileContents,
...persisted
} = state;
const filePath = stateFilePath(sandboxName);
if (!hasStateFile) {
fs.rmSync(filePath, { force: true });
return;
}
fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
if (isCorrupt) {
if (rawFileContents === undefined) {
throw new Error("Cannot restore corrupt shields state without its original bytes");
}
fs.writeFileSync(filePath, rawFileContents, { mode: 0o600 });
return;
}
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 @@ -2331,6 +2362,8 @@ function rollbackShieldsDown(
sandboxName: string,
target: AgentConfigTarget,
snapshotPath: string,
initialMode: ShieldsMode,
initialState: LoadedShieldsState,
allowLegacyHermesProtocol = false,
cachedProtocol?: HermesShieldsProtocol,
): void {
Expand All @@ -2341,6 +2374,20 @@ function rollbackShieldsDown(
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);
killTimer(sandboxName);
restoreShieldsStateSnapshot(sandboxName, initialState);
console.error(" Original mutable-default posture restored.");
return;
} 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 @@ -2353,7 +2400,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 @@ -2369,7 +2416,11 @@ function rollbackShieldsDown(
chattrApplied: rollbackChattrApplied,
fileHashes: rollbackFileHashes,
});
console.error(" Lockdown restored. Config was never left unguarded.");
console.error(
initialMode === "mutable_default"
? " Fail-closed lockdown applied; the original mutable-default posture was not restored."
: " Lockdown restored. Config was never left unguarded.",
);
} else {
console.error(" Config remains unlocked — manual intervention required.");
printManualRelockRecoveryHint(sandboxName);
Expand Down Expand Up @@ -2590,6 +2641,13 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
validateName(sandboxName, "sandbox name");

const state = loadShieldsState(sandboxName);
if (state._isCorrupt && state._rawFileContents === undefined) {
return failShieldsCommand(
`Cannot snapshot corrupt shields state 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 Down Expand Up @@ -2792,7 +2850,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
unlockAgentConfig(
sandboxName,
target,
deriveShieldsMode(state, state._hasStateFile) === "locked",
initialMode === "locked",
opts.allowLegacyHermesProtocol === true,
protocol,
);
Expand All @@ -2802,6 +2860,8 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
sandboxName,
target,
snapshotPath,
initialMode,
state,
opts.allowLegacyHermesProtocol === true,
protocol,
);
Expand All @@ -2826,6 +2886,8 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
sandboxName,
target,
snapshotPath,
initialMode,
state,
opts.allowLegacyHermesProtocol === true,
protocol,
);
Expand Down
11 changes: 10 additions & 1 deletion test/openclaw-config-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,16 @@ afterEach(() => {
});

describe("openclaw-config-guard", () => {
it("keeps an exact locked parent and pair unchanged across idempotent relock", () => {
it("keeps exact mutable and locked postures unchanged across idempotent transitions", () => {
const mutable = fixture();
const files = [mutable.configPath, mutable.hashPath];
const before = files.map((file) => [fs.statSync(file).ino, fs.readFileSync(file)]);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
expect(runGuard("unlock", mutable.configDir).status).toBe(0);
expect([mode(mutable.root), mode(mutable.configDir)]).toEqual([0o755, 0o2770]);
for (const [index, file] of files.entries()) {
expect([fs.statSync(file).ino, fs.readFileSync(file)]).toEqual(before[index]);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}

const { root, configDir, configPath, hashPath } = fixture();
const first = runGuard("lock", configDir);
expect(first.status, JSON.stringify(first.lines)).toBe(0);
Expand Down
Loading