feat(hooks): add sessionSaveRedirectPath to session memory handler [claude, human developer oversight]#38162
Conversation
Greptile SummaryThis PR adds Confidence Score: 5/5Safe to merge — security boundaries are solid and all previous review concerns are addressed; only a missing test for one new code path remains. All identified P0/P1 issues from previous rounds are addressed (readFileWithinRoot containment, embedded-.. traversal guard, memoryDir conditional creation, symlink retraction via realpath, etc.). The only new finding is a P2: the late-unblock redirect persistence feature (code at post-hook lines ~909–926) has no test. No correctness or security defects were found in the new code paths. src/hooks/bundled/session-memory/handler.test.ts — missing test for the late-unblock redirect persistence feature Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/hooks/bundled/session-memory/handler.test.ts
Line: 909-926
Comment:
**Missing test for late-unblock redirect persistence (new feature)**
The PR description explicitly calls out "Late-unblock redirect persistence" as a new feature: when `blockSessionSave` is pre-set (skipping the inline write) and a later hook clears the block and sets `sessionSaveContent`, the content should be written to the redirect path. The implementing code (the `writtenEntry === null && blockSessionSave !== true && sessionSaveContent` branch in the post-hook) has no test.
The two redirect post-hook tests that exist cover the inverse scenarios (`late-set blockSessionSave retracts a redirected write` and `late-set sessionSaveContent does NOT override redirected write content`), but neither exercises the case where the inline write was skipped and is later enabled.
Consider adding a test along the lines of:
```ts
it("pre-set blockSessionSave cleared by later hook writes sessionSaveContent to redirect path", async () => {
const tempDir = await createCaseWorkspace("late-unblock-redirect");
const quarantine = path.join(tempDir, "quarantine");
await fs.mkdir(quarantine, { recursive: true });
// ...set up session file...
const redirectFile = path.join(quarantine, "unblocked.md");
const event = createHookEvent("command", "new", "agent:main:main", {
cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig,
previousSessionEntry: { sessionId: "s1", sessionFile },
});
event.context.blockSessionSave = true; // pre-set block
event.context.sessionSaveRedirectPath = redirectFile;
await handler(event);
// Inline write was skipped. Now a later hook unblocks + sets content:
event.context.blockSessionSave = false;
event.context.sessionSaveContent = "Unblocked content";
await drainPostHookActions(event);
const content = await fs.readFile(redirectFile, "utf-8");
expect(content).toBe("Unblocked content");
});
```
Without a regression test, a future refactor of the post-hook branch could silently break this path.
How can I resolve this? If you propose a fix, please make it concise.Reviews (16): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
d7f0d0d to
cc6f097
Compare
|
@greptile-apps: We changed direction and removed these tests -- so long as it's understood that they DO exist in 38161 (which they do), we feel the net clarity of removing them here is decisive. Please re-score. |
|
@greptile-apps Please rescore. |
e2db383 to
6f7f35c
Compare
|
@greptile-apps Please rescore. |
|
@greptile-apps Please rescore. |
|
@darfaz Tagging you in the split! |
3a15213 to
460e087
Compare
Review from @darfaz (ClawMoat security)@zeroaltitude 👋 Thanks for the tag on the split. Security analysisThe core question here is: can Answer: No. The containment model is sound:
SuggestionsTwo things I'd strengthen:
On the test splitThe comment in the test file noting that LGTM ✅ — security model is correct. The two suggestions above are hardening, not blockers. |
460e087 to
06f10f3
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06f10f3419
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@darfaz Thanks for the review! Will address those hardening measures! |
06f10f3 to
4162654
Compare
|
@greptile-apps Please rescore. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4281eec928
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@greptile-apps Please rescore. |
|
Codex review: needs real behavior proof before merge. Summary Reproducibility: yes. for the PR review findings by source inspection: the exported session-memory hook fire-and-forgets async work while Real behavior proof Next step before merge Security Review findings
Review detailsBest possible solution: Land or factor the shared #38161 mechanism first, then keep this PR as a narrow redirect layer whose deferred policy work runs on the real hook timeline, whose symlink behavior matches Do we have a high-confidence way to reproduce the issue? Yes, for the PR review findings by source inspection: the exported session-memory hook fire-and-forgets async work while Is this the best way to solve the issue? No. The branch needs to register and drain deferred policy work on the actual hook-runner timeline, align redirect symlink behavior with the current safe-writer contract, add the required changelog, and provide real-run proof before merge. Full review comments:
Overall correctness: patch is incorrect Security concerns:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against b32d4c5255c5. |
Resolved 3 conflicts in src/hooks/bundled/session-memory/handler.ts: - Adopted mainline's localTimestamp.timeSlug helper inside our redirect- aware filename block (kept the random suffix for collision avoidance, kept the isRedirected branching for redirect path handling) - Adopted mainline's timeStr/timeZoneSuffix from localTimestamp helper alongside our memoryDir/memoryFilePath redirect-only setup - Kept our hasCustomContent dispatch but adopted mainline's improved heading format with timeZoneSuffix
…inst self-append loops Addresses three reviewer comments on PR openclaw#38162: 1. Aisle security analysis (CWE-834): Unbounded postHookActions drain allowed self-appending action loops because the for..of iterated a live array. A buggy/malicious post-hook action could push another action onto event.postHookActions during drain and cause the loop to run indefinitely. Switch the post-listener drain to the same snapshot+clear pattern already used in the no-listener branch: const pending = [...(event.postHookActions ?? [])]; event.postHookActions.length = 0; for (const action of pending) { ... } Newly pushed actions are queued for a subsequent triggerInternalHook call but cannot extend this drain cycle. 2. Greptile P2 (Math.random fallback can produce trailing-dash slug): The pre-existing fallback used Math.random().toString(36).slice(2, 6) which can return '' at the exact value 0. Replace with crypto.randomUUID().replace(/-/g, '').slice(0, 4) to match the approach already standardized in the block-content branch and eliminate the edge case. Also unify so the random suffix is appended to filenames on ALL paths (LLM slug AND fallback) — the previous code only applied it to the fallback path, leaving LLM-slug collisions possible for two same-day similar sessions. 3. New test coverage: - 'prevents self-appending action loops by snapshotting before draining (CWE-834 guard)' — pushes a self-appending action and verifies the drain terminates after exactly one execution. - 'snapshots actions registered after the drain begins (multi-handler stacking)' — verifies actions added by an action during drain are queued for a future cycle, not extended into the current one. Plus: the existing 'uses local timezone date and fallback time' test updated to match the new filename format that includes the 4-char suffix on all paths. transcript.ts is intentionally NOT deleted: the test file imports findPreviousSessionFile / getRecentSessionContent / etc. from it. The in-handler private copies have diverged slightly from transcript.ts's exports (different content extraction strategies); reconciling those into a single canonical implementation is a separate cleanup task and out of scope for this PR.
…stHookActions drainPostHookActions has used the snapshot+clear pattern since the postHookActions feature was extracted into a shared helper, so the self-append DoS surface flagged by Aisle on PR openclaw#38162 (CWE-834, unbounded for..of over a live array) is already not present on this branch. This commit adds explicit regression coverage so the boundary is exercised by tests rather than left implicit: - 'prevents self-appending action loops by snapshotting before draining (CWE-834 guard)' — pushes a self-appending action and verifies the drain runs exactly once and the re-pushed action is queued for a subsequent triggerInternalHook call (not extended into the current cycle). Has a SAFETY_CAP of 1000 iterations so a broken drainer fails the assertion rather than hanging the test runner. - 'snapshots actions registered after the drain begins (multi-handler stacking)' — verifies actions added by an action during drain are queued for a future cycle, not extended into the current one. Also: the 'uses local timezone date and fallback time' test asserted the pre-uniqueness-suffix filename format. Updated to match the current format that includes a 4-char hex random suffix on all paths (LLM and fallback) — the production behaviour was already correct, the test was lagging.
Two Codex review fixes for PR openclaw#38162: 1. Codex P2 (id=3128555543): Guard content blocks before reading .type in the inline transcript parser. The inlined private getRecentSessionContent in handler.ts reads c.type directly on every array item, so a null/undefined or non-object entry before a valid text block throws a TypeError, which the per-line catch swallows, dropping the entire message from the memory transcript. The shared transcript.ts helper handled this defensively; the inlined copy did not. Add typeof-object guard to the .find() predicate. 2. Codex P2 (id=3037871646 on PR openclaw#38161): Increase filename suffix entropy from 16 to 32 bits. The 4-char (~65k possibility) suffix gave ~50% birthday-paradox collision probability for 256 same-day same-slug saves — inadequate for the rapid-/new + multi-channel workloads this is meant to protect against. 8 hex chars (~4.3B possibilities) drops collision probability for realistic same-day same-slug saves to < 1 in 1e8. New tests: - 'survives malformed content blocks (null/non-object entries) without dropping the message': pushes content arrays containing null and a raw string before the text block; verifies both user and assistant messages still make it through to the memory transcript. Existing 'uses local timezone date and fallback time' test regex updated from {4} to {8} hex chars to match the new filename format. Tests: 78/78 pass.
…e docs Address remaining clawsweeper concerns on PR openclaw#38162: 1. **Public hook-runtime surface (postHookActions on InternalHookEvent).** The PR adds postHookActions to InternalHookEvent which is re-exported through src/plugin-sdk/hook-runtime.ts. clawsweeper flagged that bounded drain behavior + compatibility for manually-constructed events need to be settled. The drainer already does ??= [] before iteration (so manually-constructed legacy events without the field work fine), but lacked an explicit regression test. Added: - 'tolerates manually-constructed events that omit postHookActions (SDK compat)' — builds a hand-rolled event without the postHookActions field and triggers it. Verifies the drain doesn't throw, the registered handler runs, and no crash. Plus replaced the previous one-line JSDoc on InternalHookEvent's postHookActions field with comprehensive docs covering when to use it, snapshot+clear drain semantics with explicit CWE-834 callout, per-action error isolation, optional/additive nature, and the contract that actions pushed during drain are queued for the next cycle (not extended into the current one). Plugin authors and maintainers reviewing API compatibility now have the full contract in one place. 2. **Late-blockSessionSave privacy semantics.** Mirror the privacy warning improvement from openclaw#38161 onto this branch: when a late hook sets blockSessionSave AND the transcript was already loaded for slug generation, emit a WARN-level log with PRIVACY: prefix that includes the transcript byte count, the file path being retracted, and explicit acknowledgement that retraction does NOT un-send the data from the slug-generation provider's logs / training pipeline. 3. **Premature save logging.** Renamed inline log lines to make staging vs final disposition explicit: - debug: 'Memory file written inline (pre-post-hook, may be retracted/replaced)' - info: 'Session context staged at <path> (final disposition decided by post-hook drain)' Both redirect and non-redirect paths get the same treatment so operators grepping for save success filter on post-drain lines. Tests: 76/76 pass (was 75; +1 for SDK compat).
|
@clawsweeper — addressed the maintainer-level questions you raised. Recent commits: 994373b — public-surface SDK compat, privacy posture, log clarity
994373b + 30843b2 — earlier-pushed defensive fixes
On the open product/security questions you flagged:
Ready for fresh review. |
The user asked me to revisit items I had marked as 'follow-up' or 'could be added if you want' on PR openclaw#38162 and address them inline rather than carry them as future work. Three items qualified. 1) Aisle medium #2 — Unbounded transcript read for slug generation. getRecentSessionContent() called fs.readFile() on the entire session JSONL file. Long-running sessions can produce multi-hundred-MB transcripts (verbose tool I/O, base64 image content, etc.) and reading them whole into memory is a real OOM / DoS surface. Fix: cap the read at MAX_SESSION_FILE_TAIL_BYTES (8 MiB) using fs.open + fs.read with a trailing-window offset. Drop the (likely partial) first line after slicing so JSON.parse only sees whole records. The handler still produces correct slug input because we only use the last 15 user/assistant messages anyway. Test: synthesises a 12 MiB JSONL file with tail markers and verifies (a) no OOM, (b) the markers reach the resulting memory file (proving we read the END, not the beginning). 2) Aisle low #3 — Cross-platform path traversal hardening. The pre-flight guard rejected '..' and '../' and , but on POSIX hosts path.sep === '/' so backslash-style traversal ('..\') from a redirect path that originated on a Windows client slipped through. Same gap for UNC paths ('\\server\share', '\\?\C:\...') and Windows-style absolute paths ('C:\...'). These don't escape on Linux, but they create unpredictable cross-platform behavior and the snapshot-vs-realtime check is the classic Windows snapshot-bypass pattern. Fix: extended the pre-flight guard with explicit checks for backslash traversal, drive-letter prefixes, and UNC patterns. All are rejected with a structured log line that callouts the new reasons (windowsTraversal, unc) for diagnostics. Test: 3 candidates ('..\..\Windows\System32\stolen.md', '\\evil-server\share\stolen.md', '\\?\C:\Windows\stolen.md') all fail closed with no memory file produced. 3) Symlink rollback contract test (deferred when darfaz raised it). The retraction path resolves writtenFilePath via fs.realpath before touching the file, but there was no test that exercised this code path through a symlink. Added a test that: - Creates an in-workspace symlink (quarantine/redirected.md -> real/redirected.md) - Pre-populates the canonical target with 'pre-existing' - Drives the handler with a late blockSessionSave - Verifies the canonical target's pre-existing content is restored (not deleted, not clobbered, not escaped) and the workspace tree stays self-contained The test gracefully skips on filesystems that refuse fs.symlink so CI runners without symlink support don't block the PR. Tests: 39/39 pass on session-memory handler.test.ts (4 new tests). Net diff: +66/-9 handler.ts, +154/-1 handler.test.ts.
gpt-5.5 second-pass review on PR openclaw#38162 surfaced one real DoS vector plus a handful of operability gaps. All actionable items addressed: P0-1 — Failing symlink-rollback test Already fixed in a6458dd (the previous push). The reviewer was looking at a pre-fix snapshot; current branch is green. P1-1 + P2-2 — Pre-existing content snapshot was unbounded and followed symlinks. A hook could point sessionSaveRedirectPath at a multi-GB log inside the workspace and force the retraction-snapshot read to OOM the gateway. Even worse, an in-workspace symlink to a large external file would let fs.readFile follow the link before writeFileWithinRoot validates the write target. Fix: snapshot via fs.lstat first to (a) skip symlinks entirely (no link-following read), (b) skip non-regular files (directories, sockets, devices), (c) cap regular-file snapshots at MAX_PRE_EXISTING_SNAPSHOT_BYTES = 4 MiB. When the file is too large to snapshot, log at debug level and fall back to unlink-on-retraction rather than crashing. P2-1 — Log paths leaked workspace structure Three log.warn sites (resolves-outside-workspace, redirect-rejected, privacy-egress) embedded raw absolute paths into logs. Aggregated log streams from multi-tenant deployments could reveal home directory structure to operators outside the host. Fix: added sanitizePathForLog() that replaces os.homedir() with '~' (idempotent, safe on non-string inputs). Applied at all three sites. P2-3 — Late-set sessionSaveRedirectPath was undocumented in code Unlike blockSessionSave / sessionSaveContent (which support postHookActions late-set), sessionSaveRedirectPath is read once at handler entry. Plugin authors had no way to discover this from the code. Added a long comment block at the read site explaining the design constraint and the two supported workarounds (FIFO ordering via internal hooks, priority < 0 via typed plugin hooks). P2-5 — Privacy warning improved with concrete remediation The PRIVACY: late-block warning explained the data-egress problem but didn't tell operators how to avoid it in the future. Added the two recommended patterns (typed plugin hook priority < 0 / earlier internal hook) so the warning is self-contained. P3-5 — isRedirected accepted whitespace-only strings ' ' would pass length > 0 and resolve as a relative path. Changed to .trim().length > 0 so whitespace strings short-circuit cleanly. Skipped from the report: P2-4 (Windows real-environment test — infrastructure work, separate PR), P3-1 (test helper drain semantics — cosmetic), P3-2 (canonicalizeViaAncestor extraction — refactor), P3-3 (transcript.ts inline duplication — explicit design choice), P3-4 (redirect-to-directory test — overlaps with existing coverage). Tests: 39/39 session-memory + 40/40 internal-hooks pass. Net diff handler.ts: +71/-12. No test changes needed (existing tests exercise all changed paths; the lstat path falls back to readFile for the common case).
Second-round review fixes (commits a6458dd + edac131)After the first deep-review pass, I went back through review threads to find anything I had marked as "follow-up" / "could be added if you want" / "out of scope" and fixed those inline rather than carrying them forward. Then I ran a fresh independent deep-review pass and applied its findings too. Net result: Previously deferred → now landed (commit a6458dd)
Second-round deep review → now landed (commit edac131)
Reviewer's checklist (now all addressed)
Skipped (with reason)
Tests: 39/39 session-memory + 40/40 internal-hooks pass after both rounds. Ready for fresh review. Re-review progress:
|
…ex P2) Codex flagged this two weeks ago and I marked it as 'filed as a follow-up' \u2014 then never landed it. Fixing inline now. The pre-existing-content snapshot used to swallow every fs.readFile (and after the lstat refactor, fs.lstat) error. The intent was 'no prior file' \u2192 ENOENT, but in practice ANY error went through the same path. EACCES on a hardened redirect file, EROFS on a read-only mount, EIO on a flaky disk, EPERM on a privilege boundary \u2014 all returned silently with preExistingContent = null. A later blockSessionSave=true rollback would then take the null-snapshot branch and UNLINK the file, permanently destroying prior content the handler couldn't see. Fix: * Distinguish ENOENT (the legitimate 'no prior file' signal) from all other errors at both the lstat call and the readFile call. * On non-ENOENT errors, log a structured warning and ABORT the handler (return without writing). Fail-closed: the unreadable file stays intact and the operator sees the real problem in the log. * Inner readFile race: if lstat succeeded but readFile then returns ENOENT (file deleted between syscalls), treat as no prior content and proceed normally. Test: stubs fs.lstat to throw EACCES for the redirect target, verifies the handler aborts before writing, the pre-existing content is intact with its original bytes, and the default memory directory stays empty. Tests: 40/40 session-memory pass. Codex P2 ref: PR openclaw#38162 review comment from 2026-04-13.
CI lint failed with 4 oxlint errors after the recent hardening
commits. All four are mine, all in code I added in this PR series:
1. eslint(curly) at handler.ts:170 — single-line if without braces in
the new sanitizePathForLog() helper. Wrapped in { }.
2. typescript-eslint(no-unnecessary-type-assertion) at handler.ts:597
and 760 — 'redirectPath as string' casts. The type-aware analysis
correctly narrows redirectPath to string here (control flow inside
the isRedirected branch), so the cast is redundant. Removed both.
3. Unused eslint-disable directive at handler.test.ts:1100 — left over
from a draft of the symlink-rollback test. The no-console rule
isn't active in this scope, so the disable comment is dead code.
Removed.
These don't surface on local 'pnpm lint' because the local script
fails fast on pre-existing pi-ai dts boundary errors before the core
oxlint shard runs. CI runs the shards independently. Reproduced
locally with the exact CI flags:
npx oxlint --type-aware \
--tsconfig tsconfig.oxlint.core.json \
--report-unused-disable-directives-severity error \
--threads=8 src ui packages
Tests still 40/40 pass. Lint now reports 'Found 0 warnings and 0 errors.'
… P1)
When sessionSaveRedirectPath points at a symlinked file and the link
gets retargeted, removed, or replaced between the inline write and
the post-hook drain, the rollback restoration was writing
preExistingContent through writeRelativePath (the lexical redirect
path) instead of writtenFilePath (the realpath captured immediately
after the inline write).
That meant a late blockSessionSave could:
- Restore old content via the now-retargeted symlink to a DIFFERENT
file than the one we actually overwrote
- Leave the original target with the redirected transcript still on
disk
Net result: the handler's documented "no persistence anywhere"
guarantee for blockSessionSave silently broke for symlink-backed
redirects.
Fix: when isRedirected, compute the restore relativePath from the
realpath we captured at write time (writtenFilePath), not the
lexical writeRelativePath. The realpath was in-workspace by
construction (writeFileWithinRoot validated containment for the
original write), so writeFileWithinRoot's containment check still
applies. Non-redirected writes are unaffected (still use the original
filename relative to memoryDir).
Regression test:
* Initial state: linkPath -> realA/redirected.md ("original-A")
* realB/redirected.md exists with "original-B-untouched"
* Post-hook retargets linkPath -> realB, then sets blockSessionSave
* With the fix: rollback writes through realA (realpath at write time),
restoring "original-A"; realB is never touched.
* Without the fix: rollback writes through the now-retargeted symlink,
clobbering realB with the wrong content.
Tests: 41/41 (one new). Lint clean.
Codex P1 ref: PR openclaw#38162 review comments r2971126306 + r3104652193
(both filed the same issue; one was originally filed against the
March-22 snapshot, the other against April-18). Both now closed.
|
@greptile-apps: please re-review, and thank you |
…ory-redirect # Conflicts: # src/hooks/bundled/session-memory/handler.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0337c1d9b8
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const preStat = await fs.lstat(writtenFilePath); | ||
| if ( | ||
| preStat.isFile() && | ||
| !preStat.isSymbolicLink() && | ||
| preStat.size <= MAX_PRE_EXISTING_SNAPSHOT_BYTES |
There was a problem hiding this comment.
Validate redirect path before snapshotting target file
Fresh evidence in this commit: the pre-write snapshot now calls raw fs.lstat/fs.readFile on writtenFilePath before writeFileWithinRoot containment checks run, and the prefix-only traversal guard still misses inputs like legit/../../etc/passwd. In that case the handler can read arbitrary host files (up to the snapshot cap) even though the later write is rejected, which reintroduces the outside-workspace read primitive that was previously called out.
Useful? React with 👍 / 👎.
| } else if (preStat.isSymbolicLink()) { | ||
| log.debug( | ||
| "Pre-existing target is a symlink — skipping content snapshot to avoid following the link for an unbounded read", | ||
| { path: sanitizePathForLog(writtenFilePath) }, | ||
| ); |
There was a problem hiding this comment.
Restore symlink-backed targets instead of deleting them
When the redirect target is a symlink to an existing file, this branch explicitly skips capturing preExistingContent; if a later hook sets blockSessionSave = true, rollback falls through to the unlink path and deletes the real target file rather than restoring its previous bytes. That makes late-block retraction destructive for symlink-backed quarantine paths and can erase pre-existing data instead of undoing only this run’s write.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0337c1d9b8
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const preStat = await fs.lstat(writtenFilePath); | ||
| if ( |
There was a problem hiding this comment.
Validate redirect target before pre-write snapshot
Fresh evidence in this commit: the new rollback snapshot path calls fs.lstat/fs.readFile on writtenFilePath before containment validation. For redirected relative paths like quarantine/../../../etc/passwd, the prefix guard does not fire, so this branch can read metadata/content outside the workspace before the later writeFileWithinRoot check rejects the write. That reintroduces an out-of-root read primitive (and potential large-file memory load) on /new//reset even when persistence ultimately fails closed.
Useful? React with 👍 / 👎.
| } else if (preStat.isSymbolicLink()) { | ||
| log.debug( | ||
| "Pre-existing target is a symlink — skipping content snapshot to avoid following the link for an unbounded read", | ||
| { path: sanitizePathForLog(writtenFilePath) }, |
There was a problem hiding this comment.
Preserve symlink target content for relative redirect rollback
When the redirect path is relative and points to an existing symlinked file, this branch skips preExistingContent capture entirely; if a later hook sets blockSessionSave = true, the rollback path falls through to unlink(writtenFilePath) and can delete the real target file instead of restoring its prior contents. This makes late-block rollback destructive for symlink-backed relative redirects (e.g., fixed quarantine links with historical data).
Useful? React with 👍 / 👎.
|
Closing without merge — superseded by the upstream session-memory rework on If redirect-to-arbitrary-workspace-path semantics are still desired, they can be re-proposed against the new handler shape in a fresh PR. No downstream consumers in our ecosystem depend on |
Depends on #38161
Summary
Adds
sessionSaveRedirectPath— filesystem write redirection for session memory saves.Split from #35567 per review feedback — this piece widens file-write policy and requires separate security review.
Related to: #38161 (the first split)
sessionSaveRedirectPath
Allows upstream hooks to redirect session memory writes to an alternate path (e.g. quarantine directory for tainted sessions).
Security Surface
writeFileWithinRootvalidates containment within workspace../../prefix (not just any..substring —..quarantine/is a valid path)Symlink-aware retraction
When a redirect path is a symlink (e.g.
quarantine/sensitive.md → /data/audit-log/session.md),writeFileWithinRootfollows the symlink and writes to the real target. If a later hook setsblockSessionSave = true, retraction must remove the real file (where the data landed), not just the symlink entry. Without this, the transcript remains persisted at the symlink target, violating the "no persistence anywhere" guarantee.The fix resolves
writtenFilePathviafs.realpath()after a successful redirect write, so retraction always targets the actual data. The symlink itself is left intact (it's infrastructure the admin set up; removing it would break future quarantine writes). A dangling symlink is harmless — the target will be recreated if a future session writes through it.Late-unblock redirect persistence
When
blockSessionSaveis pre-set (skipping the inline write) and a later hook clears the block + setssessionSaveContent, the content is now written to the redirect path. Previously, the post-hook early-returned onisRedirectedwithout checking whether the block had been lifted, causing data loss in multi-hook pipelines.Testing
33 tests total (base + redirect-specific + late-block/unblock scenarios)