fix(agents): repair stale bootstrap completion#71230
Conversation
f8d7b22 to
5e7eea3
Compare
5e7eea3 to
58893bd
Compare
58893bd to
9c28c69
Compare
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Arbitrary file delete/write via exported reconcileWorkspaceBootstrapCompletion(dir) with unvalidated path
DescriptionThe exported
Vulnerable code (core sink): await fs.rm(params.bootstrapPath, { force: true });
await writeWorkspaceSetupState(params.statePath, repairedState);RecommendationRestrict operations to an application-controlled workspace root and defend against symlink/path escape.
Example mitigation: import fsSync from "node:fs";
import path from "node:path";
function assertUnderRoot(targetDir: string, rootDir: string) {
const rootReal = fsSync.realpathSync(rootDir);
const targetReal = fsSync.realpathSync(targetDir);
const rel = path.relative(rootReal, targetReal);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw new Error(`Unsafe workspace dir: ${targetDir}`);
}
}
export async function reconcileWorkspaceBootstrapCompletion(dir: string) {
const resolvedDir = resolveUserPath(dir);
assertUnderRoot(resolvedDir, DEFAULT_AGENT_WORKSPACE_DIR);
// ... proceed
}Additionally, avoid 2. 🟠 Symlink/hardlink clobber via workspace bootstrap repair and state persistence
DescriptionThe workspace bootstrap reconciliation and state persistence logic performs file deletion and file writes in a user-chosen workspace directory without protecting against symlink/hardlink attacks. Impacts:
Vulnerable code: await fs.rm(params.bootstrapPath, { force: true });
...
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(tmpPath, payload, { encoding: "utf-8" });
await fs.rename(tmpPath, statePath);This can be triggered during normal workspace startup paths, including legacy migration persistence ( RecommendationHarden workspace file operations against symlink/hardlink attacks:
Example hardening (POSIX-oriented, adjust for platform support): import { constants as FS } from "node:fs";
async function assertNoSymlink(p: string) {
const st = await fs.lstat(p).catch((e) => {
if (e?.code === "ENOENT") return null;
throw e;
});
if (st?.isSymbolicLink()) throw new Error(`Refusing to follow symlink: ${p}`);
}
await assertNoSymlink(path.join(dir, ".openclaw"));
await assertNoSymlink(statePath);
await assertNoSymlink(bootstrapPath);If you need to support pre-existing workspaces, treat symlinked 3. 🟡 Unbounded file reads in workspace bootstrap reconciliation can cause memory/CPU DoS
Description
Impact:
Vulnerable code: return (await fs.readFile(filePath, "utf-8")) !== template;RecommendationAdd a maximum size guard before reading, and avoid loading entire files when you only need to know whether they match the template. Example fix (size check + bounded read): const MAX_PROFILE_BYTES = 128 * 1024; // tune as appropriate
async function fileContentDiffersFromTemplate(filePath: string, template: string): Promise<boolean> {
try {
const st = await fs.stat(filePath);
if (!st.isFile()) return false;
if (st.size > MAX_PROFILE_BYTES) {
// treat as "configured" without reading whole file (or handle as error)
return true;
}
const content = await fs.readFile(filePath, "utf-8");
return content !== template;
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "ENOENT") throw err;
return false;
}
}Alternatively, compare via streaming/hash to avoid full buffering, and consider applying similar limits anywhere workspace files are read during bootstrap. Analyzed PR: #71230 at commit Last updated on: 2026-04-24T21:00:23Z |
Greptile SummaryThis PR fixes stale workspace bootstrap state where Confidence Score: 5/5Safe to merge; all remaining findings are minor style/documentation observations with no correctness impact. The repair logic is correct: state mutations are fully captured before the repair call, stateDirty=false after repair prevents double-writes, and the .git exclusion asymmetry between the repair and legacy paths is intentional and tested. The two P2 comments (thin wrapper, implicit write side-effect) do not affect behavior. No files require special attention.
|
| async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise<boolean> { | ||
| return await workspaceProfileLooksConfigured(params); | ||
| } |
There was a problem hiding this comment.
Thin wrapper adds no structural distinction
workspaceHasBootstrapCompletionEvidence is identical to calling workspaceProfileLooksConfigured(params) — the wrapper adds no logic, no option remapping, and no documentation beyond the name. Since repairStaleWorkspaceBootstrapCompletion is the only caller, the indirection quietly obscures that includeGitEvidence is intentionally omitted in the repair path. Inlining the call to workspaceProfileLooksConfigured directly (and noting the deliberate omission) would make the .git-exclusion intent explicit at the call site rather than hidden in a wrapper.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/workspace.ts
Line: 264-266
Comment:
**Thin wrapper adds no structural distinction**
`workspaceHasBootstrapCompletionEvidence` is identical to calling `workspaceProfileLooksConfigured(params)` — the wrapper adds no logic, no option remapping, and no documentation beyond the name. Since `repairStaleWorkspaceBootstrapCompletion` is the only caller, the indirection quietly obscures that `includeGitEvidence` is intentionally omitted in the repair path. Inlining the call to `workspaceProfileLooksConfigured` directly (and noting the deliberate omission) would make the `.git`-exclusion intent explicit at the call site rather than hidden in a wrapper.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c28c697f5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| profileFileDiffs.some(Boolean) || | ||
| (await hasWorkspaceUserContentEvidence(params.dir, { | ||
| includeGit: params.includeGitEvidence, | ||
| })) |
There was a problem hiding this comment.
Exclude memory evidence from stale-bootstrap repair
Treating memory/ or MEMORY.md as bootstrap-completion evidence causes false completion in normal /new and /reset flows: the bundled session-memory hook creates workspace/memory at the start of those commands (src/hooks/bundled/session-memory/handler.ts:80-82), and reset handling runs hooks before building the bare reset prompt (src/auto-reply/reply/get-reply.ts:497-563 then src/auto-reply/reply/get-reply-run.ts:358-374). With this change, isWorkspaceBootstrapPending() can immediately classify an actually-pending workspace as complete, delete BOOTSTRAP.md, and record setupCompletedAt without any onboarding/profile-file customization.
Useful? React with 👍 / 👎.
|
only thing that popped up for me was that bootstrap status resolution still mutates and can throw on read-only workspaces. |
|
Validation update:
|
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
* fix(agents): repair stale bootstrap completion * fix: reconcile stale workspace bootstrap explicitly * fix: keep bootstrap reconciliation in workspace lifecycle
Summary
BOOTSTRAP.mdis still present but onboarding evidence shows setup already completedsetupCompletedAtso future/new,/reset, Control UI/TUI, and embedded runs do not re-enter bootstrap.gitalone from counting as completion evidence, since OpenClaw can create it during workspace initializationRelated issues
Root cause
Workspace bootstrap completion depended on the model deleting
BOOTSTRAP.md. If onboarding updatedIDENTITY.md,USER.md, orSOUL.mdbut the model failed to deleteBOOTSTRAP.md, the harness never wrotesetupCompletedAt. After the workspace-truth routing change in #68000, those stale files caused new sessions and resets to keep injecting theBootstrap pendingwrapper.Validation
pnpm test src/agents/workspace.test.ts src/gateway/chat-sanitize.test.tspnpm check:changed