fix(acp): persist spawned child session history#40137
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Prototype pollution via unvalidated sessionKey used as plain-object key in session store writes
DescriptionThe session store is represented as a plain JavaScript object ( Because JavaScript objects treat certain property names specially (notably Why this is exploitable:
Vulnerable code: const persisted = await updateSessionStore(params.storePath, (store) => {
const merged = mergeSessionEntry(store[params.sessionKey], params.entry);
// ...
store[params.sessionKey] = merged;
return merged;
});Impact depends on surrounding usage, but prototype mutation can lead to:
RecommendationHarden the session store against unsafe keys. Prefer a null-prototype dictionary (or Option A: Use a null-prototype store objectConvert parsed stores to function toNullProtoStore<T>(obj: Record<string, T>): Record<string, T> {
const out: Record<string, T> = Object.create(null);
for (const [k, v] of Object.entries(obj)) out[k] = v;
return out;
}
// after JSON.parse(raw)
const parsed = JSON.parse(raw);
if (isSessionStoreRecord(parsed)) {
store = toNullProtoStore(parsed);
}Option B: Validate/normalize keys at boundariesReject keys that can mutate prototypes when used as property names: const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function assertSafeSessionKey(key: string) {
const k = key.trim().toLowerCase();
if (FORBIDDEN_KEYS.has(k)) throw new Error("invalid sessionKey");
}
assertSafeSessionKey(params.sessionKey);Additionally, consider canonicalizing explicit session keys into the safe 2. 🔵 TOCTOU + symlink-following in ACP transcript persistence can overwrite/truncate arbitrary files
DescriptionThe ACP transcript persistence flow performs a time-of-check/time-of-use pattern and then writes to the transcript path without defending against symlinks.
Because the path is opened/written by pathname and no Vulnerable code (TOCTOU + unsafe open-by-path): const hadSessionFile = await fs
.access(sessionFile)
.then(() => true)
.catch(() => false);
const sessionManager = SessionManager.open(sessionFile);
await prepareSessionManagerForRun({ sessionManager, sessionFile, hadSessionFile, ... });RecommendationEliminate the pre-check/open TOCTOU and add symlink-resistant file handling for transcript paths. Recommended defense-in-depth:
import fs from "node:fs/promises";
async function assertNotSymlink(p: string) {
const st = await fs.lstat(p).catch(() => null);
if (st?.isSymbolicLink()) throw new Error(`Refusing to write to symlink: ${p}`);
}
await assertNotSymlink(sessionFile);
import fs from "node:fs/promises";
import fssync from "node:fs";
const flags = fssync.constants.O_CREAT | fssync.constants.O_APPEND | fssync.constants.O_WRONLY |
(typeof fssync.constants.O_NOFOLLOW === "number" ? fssync.constants.O_NOFOLLOW : 0);
const fh = await fs.open(sessionFile, flags, 0o600);
try {
// write using fh.write / fh.appendFile
} finally {
await fh.close();
}
Analyzed PR: #40137 at commit Last updated on: 2026-03-08T20:00:21Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2303a042d1
ℹ️ 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".
| method: "sessions.patch", | ||
| params: { | ||
| key: sessionKey, | ||
| spawnedBy: requesterInternalKey, |
There was a problem hiding this comment.
Drop spawnedBy from ACP session patch payload
Adding spawnedBy to the ACP sessions.patch request makes every ACP spawn fail at runtime: spawnAcpDirect creates keys like agent:<id>:acp:<uuid>, but applySessionsPatchToStore currently rejects spawnedBy unless the key is a subagent:* session (src/gateway/sessions-patch.ts:100-102). In environments using the real gateway (not mocked tests), this patch call returns an error and the spawn exits through the error path before initialization.
Useful? React with 👍 / 👎.
| if (sessionId) { | ||
| sessionEntry = await persistAcpSpawnSessionFileBestEffort({ | ||
| sessionId, | ||
| sessionKey, | ||
| sessionStore, |
There was a problem hiding this comment.
Avoid persisting transcript path before ACP thread binding
For thread=true ACP spawns, this early persistence writes a non-thread session file before the child thread ID is known; the later thread-bind pass cannot correct it because resolveSessionTranscriptFile only applies the thread-based fallback when sessionEntry.sessionFile is absent (src/config/sessions/transcript.ts:106-112). The result is that thread-bound ACP sessions keep the generic transcript file instead of a thread-scoped path, which breaks the intended per-thread transcript partitioning.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR fixes a reliability gap where ACP child session transcripts were not persisted after successful runs, causing Confidence Score: 5/5
Last reviewed commit: bb12350 |
c136435 to
f0ea774
Compare
2257d2c to
62de5d5
Compare
|
Merged via squash.
Thanks @mbelinky! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 62de5d5669
ℹ️ 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".
| method: "sessions.patch", | ||
| params: { | ||
| key: sessionKey, | ||
| spawnedBy: requesterInternalKey, |
There was a problem hiding this comment.
Remove spawnedBy from ACP sessions.patch payload
This call now always sends spawnedBy for ACP session keys (agent:<id>:acp:<uuid>), but applySessionsPatchToStore rejects spawnedBy unless the key is subagent:* (src/gateway/sessions-patch.ts), so sessions.patch returns an invalid-request error and the spawn falls into the catch/cleanup path before initialization. In practice this makes ACP spawns fail in real gateway environments (the unit test mocks don’t enforce the server-side validator).
Useful? React with 👍 / 👎.
| sessionEntry = await persistAcpSpawnSessionFileBestEffort({ | ||
| sessionId, | ||
| sessionKey, | ||
| sessionStore, | ||
| storePath, | ||
| sessionEntry, | ||
| agentId: targetAgentId, | ||
| stage: "spawn", |
There was a problem hiding this comment.
Avoid persisting ACP transcript path before thread binding
For thread=true ACP spawns, this pre-bind persistence writes a session file path before the child thread ID exists, and the later thread-bind pass cannot correct it because resolveSessionTranscriptFile only applies a thread-based fallback when sessionEntry.sessionFile is absent (src/config/sessions/transcript.ts:106-112). That leaves thread-bound ACP sessions on the generic transcript file instead of a thread-scoped file.
Useful? React with 👍 / 👎.
PR openclaw#40137 added spawnedBy to ACP session spawn but the validation in sessions-patch.ts only allowed these fields for subagent:* sessions. This caused "spawnedBy is only supported for subagent:* sessions" errors when spawning ACP sessions. Update validation to allow spawnedBy and spawnDepth for both subagent:* and acp:* session keys. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
PR openclaw#40137 added spawnedBy to ACP session spawn but the validation in sessions-patch.ts only allowed these fields for subagent:* sessions. This caused "spawnedBy is only supported for subagent:* sessions" errors when spawning ACP sessions. Update validation to allow spawnedBy and spawnDepth for both subagent:* and acp:* session keys. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
sessions_historycould come back empty.spawnedByfor ACP children, and share transcript-path/session-file resolution with best-effort spawn-time persistence.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
sessions_historyfor successful ACP child runs now returns persisted transcript history instead of an empty result.spawnedBy.Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
mb-serverpnpmacp.enabled=true,backend="acpx"Steps
sessions_historyfor the child session.Expected
Actual
Evidence
Human Verification (required)
pnpm exec vitest run src/commands/agent.acp.test.ts src/agents/acp-spawn.test.ts src/agents/tools/sessions-spawn-tool.test.ts --maxWorkers=1NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc -p tsconfig.json --noEmitmb-server:pnpm exec vitest run src/commands/agent.acp.test.ts src/agents/acp-spawn.test.ts src/agents/tools/sessions-spawn-tool.test.ts --maxWorkers=3mb-server:NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc -p tsconfig.json --noEmitReview Conversations
Compatibility / Migration
Yes)No)No)Failure Recovery (if this breaks)
src/commands/agent.ts,src/agents/acp-spawn.ts,src/config/sessions/transcript.tssessions_history, or ACP spawn unexpectedly failing on transcript-path storage issues.Risks and Mitigations