Harden config backup restore permissions (#77488)

* Harden config backup restore permissions

* docs(changelog): credit config restore mode hardening

Adds the user-facing Unreleased Fixes entry for the suspicious-read backup
restore chmod hardening shipped in this PR.
This commit is contained in:
Devin Robison
2026-05-05 09:39:31 -06:00
committed by GitHub
parent a387068694
commit 91879ac442
3 changed files with 37 additions and 0 deletions

View File

@@ -325,6 +325,7 @@ Docs: https://docs.openclaw.ai
- Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf.
- Agents/auth-profiles: do not record request-shape (`format`) rejections as auth-profile health failures, so a single per-session transcript-shape error (such as a prefill-strict 400 "conversation must end with a user message") no longer triggers a profile-wide cooldown that blocks every other healthy session sharing the same auth profile. Refs #77228. (#77280) Thanks @openperf.
- CLI/update: stop dev-channel source updates immediately when `git fetch` fails, so tag conflicts cannot keep preflight, rebase, or build steps running against stale refs while the Gateway is still on the old runtime. (#77845) Thanks @obviyus.
- Config/recovery: chmod restored `openclaw.json` back to owner-only (`0600`) after suspicious-read backup recovery on POSIX hosts, so a previously world-readable config mode cannot persist into a freshly restored credential-bearing config. (#77488) Thanks @drobison00.
## 2026.5.3-1

View File

@@ -275,6 +275,22 @@ describe("config observe recovery", () => {
});
});
it("hardens async backup restores to owner-only config permissions", async () => {
if (process.platform === "win32") {
return;
}
await withSuiteHome(async (home) => {
const { deps, configPath } = makeDeps(home);
await seedConfigBackup(configPath, recoverableTelegramConfig);
await writeClobberedUpdateChannel(configPath);
await fsp.chmod(configPath, 0o644);
await recoverClobberedUpdateChannel({ deps, configPath });
expect((await fsp.stat(configPath)).mode & 0o777).toBe(0o600);
});
});
it("auto-restores after a large size drop against last-good config", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath } = makeDeps(home);
@@ -456,6 +472,22 @@ describe("config observe recovery", () => {
});
});
it("hardens sync backup restores to owner-only config permissions", async () => {
if (process.platform === "win32") {
return;
}
await withSuiteHome(async (home) => {
const { deps, configPath } = makeDeps(home);
await seedConfigBackup(configPath, recoverableTelegramConfig);
await writeClobberedUpdateChannel(configPath);
await fsp.chmod(configPath, 0o644);
recoverClobberedUpdateChannelSync({ deps, configPath });
expect((await fsp.stat(configPath)).mode & 0o777).toBe(0o600);
});
});
it("logs async health-state write failures", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, warn } = makeDeps(home);

View File

@@ -640,6 +640,7 @@ export async function maybeRecoverSuspiciousConfigRead(params: {
let restoreError: unknown;
try {
await params.deps.fs.promises.copyFile(backupPath, params.configPath);
await params.deps.fs.promises.chmod?.(params.configPath, 0o600).catch(() => {});
restoredFromBackup = true;
} catch (error) {
restoreError = error;
@@ -747,6 +748,9 @@ export function maybeRecoverSuspiciousConfigReadSync(params: {
let restoreError: unknown;
try {
params.deps.fs.copyFileSync(backupPath, params.configPath);
try {
params.deps.fs.chmodSync?.(params.configPath, 0o600);
} catch {}
restoredFromBackup = true;
} catch (error) {
restoreError = error;