Skip to content

feat(hooks): add sessionSaveRedirectPath to session memory handler [claude, human developer oversight]#38162

Closed
zeroaltitude wants to merge 55 commits intoopenclaw:mainfrom
zeroaltitude:feat/hook-session-memory-redirect
Closed

feat(hooks): add sessionSaveRedirectPath to session memory handler [claude, human developer oversight]#38162
zeroaltitude wants to merge 55 commits intoopenclaw:mainfrom
zeroaltitude:feat/hook-session-memory-redirect

Conversation

@zeroaltitude
Copy link
Copy Markdown
Contributor

@zeroaltitude zeroaltitude commented Mar 6, 2026

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

  • Path canonicalization via nearest existing ancestor (handles macOS /tmp symlinks)
  • writeFileWithinRoot validates containment within workspace
  • Fails closed for outside-workspace paths (no fallback)
  • Both workspace and redirect paths canonicalized before comparison
  • Path traversal pre-flight: rejects .. / ../ 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), writeFileWithinRoot follows the symlink and writes to the real target. If a later hook sets blockSessionSave = 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 writtenFilePath via fs.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 blockSessionSave is pre-set (skipping the inline write) and a later hook clears the block + sets sessionSaveContent, the content is now written to the redirect path. Previously, the post-hook early-returned on isRedirected without 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)

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 6, 2026

Greptile Summary

This PR adds sessionSaveRedirectPath to the session memory handler, allowing hooks to redirect session memory writes to an alternate path (e.g. a quarantine directory), with symlink-aware retraction, late-unblock redirect persistence, and cross-platform path traversal hardening. The implementation is thorough and defensive, with extensive security commentary and test coverage for the redirect feature. One new code path — the late-unblock redirect write (post-hook branch where a pre-set blockSessionSave is cleared by a later hook) — is described in the PR but has no corresponding test.

Confidence Score: 5/5

Safe 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 AI
This 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

Comment thread src/hooks/bundled/session-memory/handler.ts
Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
Comment thread src/hooks/bundled/session-memory/handler.test.ts Outdated
@zeroaltitude zeroaltitude force-pushed the feat/hook-session-memory-redirect branch 2 times, most recently from d7f0d0d to cc6f097 Compare March 6, 2026 23:00
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@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.

Comment thread src/hooks/bundled/session-memory/handler.test.ts
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps Please rescore.

Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
Comment thread src/hooks/bundled/session-memory/handler.test.ts Outdated
@zeroaltitude zeroaltitude force-pushed the feat/hook-session-memory-redirect branch from e2db383 to 6f7f35c Compare March 7, 2026 02:17
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps Please rescore.

Comment thread src/hooks/bundled/session-memory/handler.ts
Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps Please rescore.

Comment thread src/hooks/bundled/session-memory/handler.test.ts
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@darfaz Tagging you in the split!

@zeroaltitude zeroaltitude force-pushed the feat/hook-session-memory-redirect branch from 3a15213 to 460e087 Compare March 7, 2026 17:56
@darfaz
Copy link
Copy Markdown

darfaz commented Mar 7, 2026

Review from @darfaz (ClawMoat security)

@zeroaltitude 👋 Thanks for the tag on the split.

Security analysis

The core question here is: can sessionSaveRedirectPath be used to write arbitrary files outside the workspace?

Answer: No. The containment model is sound:

  1. writeFileWithinRoot is the single authoritative boundary — it calls fs.realpath on the root, resolves the target, and uses isPathInside to reject escapes. This is called for ALL write paths (redirected and non-redirected).

  2. Fails closed — when writeFileWithinRoot throws SafeOpenError for an out-of-workspace redirect, the handler catches it and returns with no fallback write. No data leaks to the default memory/ dir either.

  3. canonicalizeViaAncestor is correctly scoped — the JSDoc already has the WARNING that it's a symlink-resolution helper only, not a security boundary. The Greptile bot flagged this but it's already addressed in the code.

  4. Path canonicalization handles macOS /tmp/private/tmp — the ancestor-walk approach is the right pattern for paths that don't exist yet.

Suggestions

Two things I'd strengthen:

  1. Add a relative path traversal escape test ("../sibling-dir/stolen.md"). The absolute escape test exists but relative paths take a different code path (skip canonicalizeViaAncestor entirely). The security boundary still holds via writeFileWithinRoot, but it should be exercised explicitly. Greptile flagged this too — I agree it's worth adding.

  2. Add a ..-prefix pre-flight guard before writeFileWithinRoot for absolute redirects. When path.relative() produces a ..-leading path, we already know it's out-of-bounds. Catching it early with a log.warn is cleaner than letting it fall through to SafeOpenError. Not a security issue (the boundary holds), but makes the intent explicit and the log output more useful for debugging.

On the test split

The comment in the test file noting that blockSessionSave/sessionSaveContent tests live in #38161 is clear. Makes sense to keep them there.

LGTM ✅ — security model is correct. The two suggestions above are hardening, not blockers.

@zeroaltitude zeroaltitude force-pushed the feat/hook-session-memory-redirect branch from 460e087 to 06f10f3 Compare March 7, 2026 18:24
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@darfaz Thanks for the review! Will address those hardening measures!

@zeroaltitude zeroaltitude force-pushed the feat/hook-session-memory-redirect branch from 06f10f3 to 4162654 Compare March 7, 2026 19:49
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps Please rescore.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/hooks/bundled/session-memory/handler.ts Outdated
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps Please rescore.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 26, 2026

Codex review: needs real behavior proof before merge.

Summary
The PR adds sessionSaveRedirectPath handling to session-memory, introduces/exposes postHookActions, hardens slug/session transcript handling, updates tests and the plugin SDK API baseline, and initializes one gateway hook event field.

Reproducibility: yes. for the PR review findings by source inspection: the exported session-memory hook fire-and-forgets async work while triggerInternalHook drains immediately, and current fs-safe rejects symlink targets. I did not run tests in this read-only pass.

Real behavior proof
Needs real behavior proof before merge: The PR discussion reports tests but does not include after-fix real OpenClaw output or artifacts demonstrating redirect behavior in a real setup.

Next step before merge
Contributor/human action is needed because the external PR still lacks real behavior proof and has security-sensitive correctness findings; ClawSweeper should not queue an automated repair while the proof gate is missing.

Security
Needs attention: The diff widens hook-controlled file persistence and still has concrete privacy/correctness gaps in deferred retraction and symlink-backed redirect semantics.

Review findings

  • [P1] Register policy drain before async save returns — src/hooks/bundled/session-memory/handler.ts:847-848
  • [P2] Normalize postHookActions before invoking handlers — src/hooks/internal-hooks.ts:336-338
  • [P2] Make the symlink redirect contract executable — src/hooks/bundled/session-memory/handler.ts:775-780
Review details

Best 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 fs-safe, and whose docs/changelog/proof cover the widened file-write policy.

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 triggerInternalHook drains immediately, and current fs-safe rejects symlink targets. I did not run tests in this read-only pass.

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:

  • [P1] Register policy drain before async save returns — src/hooks/bundled/session-memory/handler.ts:847-848
    In the real hook pipeline, saveSessionToMemory starts saveSessionMemoryNow() in the background and returns, so triggerInternalHook drains postHookActions before this async code reaches the new push. Late blockSessionSave or sessionSaveContent hooks are then left queued and never retract or replace the staged file; register the action synchronously before the first await or make the hook runner wait for the work it must drain.
    Confidence: 0.93
  • [P2] Normalize postHookActions before invoking handlers — src/hooks/internal-hooks.ts:336-338
    The compatibility fallback is only applied after all handlers have run. A manually constructed event that omits the optional field still makes any handler calling event.postHookActions.push(...) throw before its deferred policy action is registered, so initialize event.postHookActions ??= [] before dispatching handlers.
    Confidence: 0.9
  • [P2] Make the symlink redirect contract executable — src/hooks/bundled/session-memory/handler.ts:775-780
    The PR says symlink redirects write through to the real in-workspace target, but the current writeFileWithinRoot path rejects symlink targets via the existing safe-open checks. The added symlink tests can pass without any redirected write occurring, so either explicitly reject symlink redirects or add a deliberate safe write-through API and assert the target/link effects.
    Confidence: 0.88
  • [P3] Add the required changelog entry — src/hooks/internal-hook-types.ts:49
    This adds user-facing hook/session-memory behavior and public SDK surface, but the branch does not add an active-version CHANGELOG.md entry for sessionSaveRedirectPath or the new post-hook mechanism. OpenClaw policy requires changelog coverage for user-facing feature and security-hardening changes.
    Confidence: 0.84

Overall correctness: patch is incorrect
Overall confidence: 0.93

Security concerns:

  • [high] Late block/redaction can miss the post-hook drain — src/hooks/bundled/session-memory/handler.ts:847
    Because the deferred action is pushed after background async work starts, the hook runner can drain before it exists, leaving sensitive redirected or default memory writes persisted despite a later blockSessionSave or redaction hook.
    Confidence: 0.92
  • [medium] Symlink redirect semantics are not enforced — src/hooks/bundled/session-memory/handler.ts:775
    The PR documents symlink-backed redirects as write-through and rollback-aware, but the existing fs-safe writer rejects symlink targets; operators would not get the advertised quarantine/audit behavior.
    Confidence: 0.88
  • [low] Optional postHookActions can drop policy work — src/hooks/internal-hooks.ts:336
    Legacy/manual events that omit postHookActions can make handlers throw before they enqueue deferred privacy or security actions because normalization happens only at drain time.
    Confidence: 0.9

What I checked:

  • Current main lacks the requested surface: Current main has no sessionSaveRedirectPath, postHookActions, blockSessionSave, or sessionSaveContent matches in the relevant hook/gateway paths, so this PR is not already implemented. (b32d4c5255c5)
  • Post-hook action is registered after async work has already yielded: At PR head the session-memory handler pushes its deferred action inside saveSessionMemoryNow, while the exported hook starts that function as background work; triggerInternalHook can drain before this push happens. (src/hooks/bundled/session-memory/handler.ts:847, 0337c1d9b88b)
  • Manual-event compatibility fallback is after handler dispatch: The drain uses event.postHookActions ?? [], but only after all handlers have run, so handlers that push to the field still throw on legacy/manual events that omit it. (src/hooks/internal-hooks.ts:336, 0337c1d9b88b)
  • Current fs-safe writer rejects symlink targets by contract: resolvePinnedWriteTargetWithinRoot probes existing targets with openFileWithinRoot, whose default path verification rejects symlinks rather than providing the PR's advertised write-through behavior. (src/infra/fs-safe.ts:865, b32d4c5255c5)
  • Symlink tests do not prove a redirected write happened: The symlink rollback tests only assert the original target content remains restored/untouched, which also passes when the redirect write is rejected before any transcript is written. (src/hooks/bundled/session-memory/handler.test.ts:1290, 0337c1d9b88b)
  • No active changelog entry for the new feature: The PR head changelog contains unrelated session-memory entries but no sessionSaveRedirectPath, postHookActions, blockSessionSave, or sessionSaveContent entry for this user-facing hook change. (CHANGELOG.md:1, 0337c1d9b88b)

Likely related people:

  • vincentkoc: Recent current-main work changed the session-memory hot path and filename behavior that this PR builds on, including making session memory capture asynchronous. (role: recent maintainer; confidence: high; commits: 5a0d6c7ad86b, 61383aff4b8d; files: src/hooks/bundled/session-memory/handler.ts, src/gateway/server-methods/sessions.ts, CHANGELOG.md)
  • darfaz: Provided focused PR discussion review of the redirect containment model and requested hardening around relative traversal and pre-flight rejection. (role: security reviewer; confidence: medium; files: src/hooks/bundled/session-memory/handler.ts, src/infra/fs-safe.ts)

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.
zeroaltitude added a commit to zeroaltitude/openclaw that referenced this pull request Apr 27, 2026
…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).
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@clawsweeper — addressed the maintainer-level questions you raised. Recent commits:

994373b — public-surface SDK compat, privacy posture, log clarity

  1. Public hook-runtime surface (InternalHookEvent.postHookActions re-exported through src/plugin-sdk/hook-runtime.ts). You asked about bounded drain behavior + compatibility for manually-constructed events. The drainer already does ??= [] before iteration (so legacy events without the field are tolerated), 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, no crash. This is the public-surface compat test you asked for.

    Plus replaced the previous one-line JSDoc on InternalHookEvent.postHookActions with comprehensive docs covering: when to use the field, snapshot+clear drain semantics with explicit CWE-834 callout, per-action error isolation, optional/additive nature (existing handlers unchanged), and the contract that actions pushed during drain are queued for the next cycle, NOT extended into the current one.

  2. Late-blockSessionSave privacy semantics. Mirrored the privacy warning improvement from feat(hooks): postHookActions mechanism, blockSessionSave/sessionSaveContent, and slug-generation hardening #38161 onto this branch. When a late hook sets blockSessionSave AND the transcript was already loaded for slug generation, the handler now emits 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:

    • Memory file written inline (pre-post-hook, may be retracted/replaced) (debug)
    • Session context staged at <path> (final disposition decided by post-hook drain) (info)

    Both redirect and non-redirect paths get the same treatment so operators grepping for save success filter on post-drain Session save retracted / replaced lines instead of the inline-staging lines.

994373b + 30843b2 — earlier-pushed defensive fixes

  1. Filename suffix entropy bumped 16 → 32 bits (30843b2ee8).

  2. Defensive content-block parser — guard against null/non-object entries before reading .type in the inlined getRecentSessionContent. Was a regression from the shared transcript.ts helper. New test survives malformed content blocks (null/non-object entries) without dropping the message (30843b2ee8).

  3. CWE-834 self-append guard in the post-listener drain (snapshot+clear pattern), with explicit regression tests including a self-appending action test with safety cap (cefc860b34).


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).
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

zeroaltitude commented Apr 27, 2026

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)

Finding Source Fix
Transcript memory exhaustion (8 MiB cap) Aisle medium #2 Tail-window read via fs.open + fs.read; drops first partial line
Cross-platform path traversal Aisle low #3 Pre-flight guard now rejects backslash traversal (..\\), drive-letter prefixes (C:\...), and UNC patterns (\\server\share, \\?\C:\...)
Symlink-rollback contract test darfaz Test creates in-workspace symlink, drives a late blockSessionSave, verifies pre-existing content is restored at the canonical target with no workspace escape; gracefully skips on filesystems that refuse fs.symlink

Second-round deep review → now landed (commit edac131)

Finding Severity Fix
preExistingContent snapshot was unbounded and followed symlinks P1 (real DoS) Snapshot via fs.lstat first: skip symlinks (no link-following read), skip non-regular files, cap regular files at MAX_PRE_EXISTING_SNAPSHOT_BYTES = 4 MiB. Falls back to unlink-on-retraction when oversize.
Log warnings leaked workspace paths P2 Added sanitizePathForLog() that replaces os.homedir() with ~; applied at all three log.warn sites (outside-workspace, rejected, PRIVACY egress)
Late-set sessionSaveRedirectPath undocumented in code P2 Long comment block at the read site explaining the eager-evaluation design constraint plus the two supported workarounds (internal hook FIFO order, typed plugin hook priority < 0)
Privacy warning lacked concrete remediation P2 Extended the late-blockSessionSave warning with the same two recommended patterns so the message is actionable on its own
isRedirected accepted whitespace-only strings P3 Changed length check to .trim().length > 0

Reviewer's checklist (now all addressed)

  • ✅ Symlink-rollback contract test (darfaz)
  • ✅ Cross-platform / Windows path hardening (Aisle low WA business, groups & office hours  #3)
  • ✅ Transcript size cap (Aisle medium Login fails with 'WebSocket Error (socket hang up)' ECONNRESET #2)
  • postHookActions self-append CWE-834 guard (already in earlier commits)
  • disableTools: true on slug-generation LLM (already in earlier commits)
  • ✅ SDK compat for legacy events without postHookActions (already in earlier commits)
  • ✅ Pre-existing snapshot DoS bound (this round)
  • ✅ Log path sanitization (this round)
  • ✅ Late-set redirect doc (this round)

Skipped (with reason)

  • Windows real-environment test — needs CI infrastructure work, not a code change in this PR. The behavioral test already verifies fail-closed semantics on POSIX hosts; an actual Windows run would be a separate platform-test follow-up.
  • canonicalizeViaAncestor extraction to shared utility — pure refactor, doesn't change behavior, better as its own PR if it's reused elsewhere.
  • getRecentSessionContent inlined from transcript.ts — explicit design choice (avoids the import); the divergence risk is low since the inlined version now has more defensive guards than the shared helper anyway.

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.
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

@greptile-apps: please re-review, and thank you

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 5, 2026
…ory-redirect

# Conflicts:
#	src/hooks/bundled/session-memory/handler.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +705 to +709
const preStat = await fs.lstat(writtenFilePath);
if (
preStat.isFile() &&
!preStat.isSymbolicLink() &&
preStat.size <= MAX_PRE_EXISTING_SNAPSHOT_BYTES
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +735 to +739
} 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) },
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +705 to +706
const preStat = await fs.lstat(writtenFilePath);
if (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +735 to +738
} 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) },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@zeroaltitude zeroaltitude deleted the feat/hook-session-memory-redirect branch May 5, 2026 19:01
@zeroaltitude
Copy link
Copy Markdown
Contributor Author

Closing without merge — superseded by the upstream session-memory rework on main. The sessionSaveRedirectPath mechanism in this PR layered on top of the now-superseded #38161 (feat/hook-session-memory-block-content); both are being closed together.

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 sessionSaveRedirectPath. The branch is preserved as the annotated tag archive/feat/hook-session-memory-redirect on https://github.com/zeroaltitude/openclaw if recovery is ever needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation gateway Gateway runtime size: XL triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants