Skip to content

fix(hooks): harden CLI transcript loading and gate conversation hooks#70786

Merged
vincentkoc merged 1 commit into
mainfrom
fix/cli-session-history-and-retry-live
Apr 23, 2026
Merged

fix(hooks): harden CLI transcript loading and gate conversation hooks#70786
vincentkoc merged 1 commit into
mainfrom
fix/cli-session-history-and-retry-live

Conversation

@vincentkoc

Copy link
Copy Markdown
Member

Summary

  • Problem: CLI hook transcript loading still followed symlinked session transcripts, ignored custom session.store roots, and eagerly parsed transcript history even when no lifecycle hook needed it.
  • Why it matters: that left a local file-read/DoS hole in transcript loading, silently dropped hook history for custom stores, and exposed raw conversation payloads to non-bundled plugins without an explicit trust gate.
  • What changed: hardened loadCliSessionHistoryMessages, skipped transcript parsing unless llm_input or agent_end is active, stopped emitting empty llm_output, and added plugins.entries.<id>.hooks.allowConversationAccess as the explicit opt-in for non-bundled conversation hooks.
  • What did NOT change (scope boundary): bundled hook payloads remain unchanged by default, and this PR does not add content redaction or redesign the hook payload schema.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: CLI transcript loading trusted the resolved path too late and used statSync/SessionManager.open() without rejecting symlinks or confirming the real path stayed inside the allowed sessions root. At the same time, typed conversation hooks had no equivalent of the existing prompt-injection policy, so non-bundled plugins could subscribe to raw prompt/history/output payloads by default.
  • Missing detection / guardrail: we had bounded-history tests, but no symlink traversal regression, no custom session-store regression, and no policy test covering conversation-hook registration for non-bundled plugins.
  • Contributing context (if known): the CLI runner lifecycle hook path was added incrementally, so the transcript resolver and hook policy guardrails drifted from the stricter expectations we wanted.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/agents/cli-runner/session-history.test.ts, src/agents/cli-runner.reliability.test.ts, src/plugins/loader.test.ts, src/plugins/config-state.test.ts, src/plugins/status.test.ts
  • Scenario the test should lock in: symlinked transcripts are rejected, custom session.store roots still resolve hook history, transcript parsing is skipped when only llm_output is active, empty CLI output does not emit llm_output, and non-bundled plugins cannot register conversation hooks without explicit opt-in.
  • Why this is the smallest reliable guardrail: the risky behavior sits in the local transcript resolver and typed hook registration path, so focused unit/seam coverage catches it without needing a heavier full gateway e2e.
  • Existing test that already covers this (if any): src/agents/cli-runner.retry.live.test.ts continues to cover the live session-expired retry path.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

  • Non-bundled plugins now need plugins.entries.<plugin-id>.hooks.allowConversationAccess: true before they can register llm_input, llm_output, or agent_end typed hooks.
  • CLI lifecycle hooks no longer emit llm_output events for empty assistant output.
  • Deployments using custom session.store now keep prior CLI transcript history in hook payloads again.

Diagram (if applicable)

Before:
[CLI run] -> [always parse transcript file] -> [follow symlink / ignore custom store / emit raw conversation hooks]

After:
[CLI run] -> [check active hooks + trusted transcript path] -> [load bounded real transcript or skip]
         -> [conversation hooks only for bundled or explicitly trusted plugins]

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) Yes
  • If any Yes, explain risk + mitigation: non-bundled plugins now get less data by default because conversation hook registration is blocked unless explicitly trusted via allowConversationAccess: true. Transcript loading also now rejects symlinked/special-file tricks and enforces real-path containment under the allowed sessions root.

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 22 / local worktree
  • Model/provider: CLI fixture + local OpenClaw runtime
  • Integration/channel (if any): CLI runner lifecycle hooks
  • Relevant config (redacted): OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1; plugin hook policy defaults plus explicit hooks.allowConversationAccess

Steps

  1. Point CLI hook transcript loading at a symlinked transcript path or at a transcript under a custom session.store root.
  2. Run runPreparedCliAgent(...) with lifecycle hooks enabled or load a non-bundled plugin that registers llm_input / llm_output / agent_end.
  3. Observe whether transcript history loads safely and whether the plugin can register those conversation hooks without explicit opt-in.

Expected

  • Symlinked transcripts are rejected.
  • Custom session.store transcript roots still load bounded history.
  • llm_output is skipped when assistant text is empty.
  • Non-bundled plugins need explicit allowConversationAccess: true for conversation hooks.

Actual

  • Matches expected after this patch.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: targeted agent tests, targeted plugin tests, pnpm build, and the live CLI resume retry probe all passed after the fix.
  • Edge cases checked: symlinked transcript path, oversized transcript rejection, custom session.store, empty assistant output, and non-bundled conversation-hook registration without opt-in.
  • What you did not verify: I did not run a full repo-wide pnpm check && pnpm test, and I did not add a content-redaction layer for trusted bundled hooks.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes/No) Mostly
  • Config/env changes? (Yes/No) Yes
  • Migration needed? (Yes/No) Only for non-bundled plugins that rely on conversation hooks
  • If yes, exact upgrade steps: add plugins.entries.<plugin-id>.hooks.allowConversationAccess: true for any trusted non-bundled plugin that intentionally consumes llm_input, llm_output, or agent_end payloads.

Risks and Mitigations

  • Risk: trusted third-party plugins that relied on conversation hooks will stop receiving them until explicitly opted in.
    • Mitigation: the new policy is explicit, surfaced in plugin status, and covered by loader/config tests.
  • Risk: transcript loading could become overly strict for legitimate custom stores.
    • Mitigation: added custom session.store regression coverage and validated the live retry probe after the resolver change.

AI-assisted: yes
Testing: fully tested

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M maintainer Maintainer-authored PR labels Apr 23, 2026
@vincentkoc vincentkoc self-assigned this Apr 23, 2026
@vincentkoc
vincentkoc marked this pull request as ready for review April 23, 2026 21:25
@vincentkoc
vincentkoc merged commit 51f9f94 into main Apr 23, 2026
40 of 41 checks passed
@vincentkoc
vincentkoc deleted the fix/cli-session-history-and-retry-live branch April 23, 2026 21:25
@aisle-research-bot

aisle-research-bot Bot commented Apr 23, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Conversation access policy bypass via before_reset/before_compaction hooks still exposing session messages
2 🟡 Medium TOCTOU race allows symlink swap to bypass session transcript path checks
1. 🟠 Conversation access policy bypass via before_reset/before_compaction hooks still exposing session messages
Property Value
Severity High
CWE CWE-284
Location src/plugins/registry.ts:1215-1237

Description

The new hooks.allowConversationAccess policy only blocks typed hooks llm_input, llm_output, and agent_end, but other typed hooks still expose raw conversation history (messages) without any gating.

This creates a policy-bypass/data-exfiltration risk:

  • A non-bundled plugin that is not opted in (allowConversationAccess unset/false) is blocked from agent_end, llm_input, llm_output.
  • The same plugin can still register before_reset (and potentially other hooks such as before_compaction) and receive full session messages.
  • In emitGatewayBeforeResetPluginHook, the system reads the session transcript from disk and passes it directly to the plugin hook event.

Vulnerable behavior:

// registry.ts only gates llm_input/llm_output/agent_end via isConversationHookName
if (isConversationHookName(hookName)) {
  ...block unless allowConversationAccess...
}// session-reset-service.ts passes full messages to before_reset regardless of allowConversationAccess
messages = readSessionMessages(sessionId, params.storePath, sessionFile);
void hookRunner.runBeforeReset({ sessionFile, messages, reason }, ctx);

Impact:

  • Plugins can still observe/export conversation content via before_reset even when the operator expects conversation access to be disabled for that plugin.
  • This undermines the intent of allowConversationAccess as a privacy control.

Recommendation

Apply the conversation-access policy to all hooks that can expose conversation transcripts, not only llm_input/llm_output/agent_end.

Suggested approach:

  1. Expand the guarded hook set to include any hook whose event payload can include raw messages (e.g. before_reset, before_compaction, and any other hooks passing messages/history).
  2. Enforce the check in the same central place (registerTypedHook) so all registration paths are covered.

Example:

// hook-types.ts
export const CONVERSATION_HOOK_NAMES = [
  "llm_input",
  "llm_output",
  "agent_end",
  "before_reset",
  "before_compaction",// add others that can carry full transcripts
] as const;// registry.ts uses isConversationHookName(hookName) unchanged

Alternatively (more robust): tag hook types with metadata like { exposesConversation: true } and check that metadata instead of maintaining a name list.

Also consider adding tests proving that a non-bundled plugin without allowConversationAccess=true cannot receive messages via before_reset/before_compaction.

2. 🟡 TOCTOU race allows symlink swap to bypass session transcript path checks
Property Value
Severity Medium
CWE CWE-367
Location src/agents/cli-runner/session-history.ts:67-80

Description

loadCliSessionHistoryMessages attempts to harden transcript loading by rejecting symlinks (lstatSync) and ensuring realpathSync(sessionFile) is within sessionsDir. However, the checks are time-of-check/time-of-use (TOCTOU) and the code ultimately opens the transcript by path (SessionManager.open(realSessionFile)), not by a file descriptor tied to the checked inode.

An attacker who can write to the sessions directory can:

  • Create a legitimate regular file at sessionFile so that lstatSync(sessionFile) and realpathSync(sessionFile) pass.
  • After the checks (and even after statSync(realSessionFile)), replace that path with a symlink to an arbitrary file outside the sessions directory.
  • SessionManager.open(realSessionFile) will then follow the new symlink and read outside the intended trust boundary.

Vulnerable code:

const entryStat = fs.lstatSync(sessionFile);
...
const realSessionFile = safeRealpathSync(sessionFile);
...
const stat = fs.statSync(realSessionFile);
...
const entries = SessionManager.open(realSessionFile).getEntries();

Recommendation

Avoid path-based TOCTOU by opening and validating the file once, using a file descriptor, and then reading from that descriptor.

Options:

  1. Open with O_NOFOLLOW (POSIX) and validate inode via fstat:
import { constants as c } from "node:fs";

const fd = fs.openSync(sessionFile, c.O_RDONLY | c.O_NOFOLLOW);
try {
  const st = fs.fstatSync(fd);
  if (!st.isFile() || st.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) return [];// Derive the real path from the fd (or re-check with realpath + inode comparison)
  const real = fs.realpathSync.native(sessionFile);
  if (!isPathWithinBase(realSessionsDir, real)) return [];// Ensure the path didn't change since open: compare `fs.statSync(sessionFile).ino/dev` with `st.ino/dev`.

  const entries = SessionManager.openFromFd?.(fd) /* preferred */
    ?? SessionManager.open(real).getEntries();
} finally {
  fs.closeSync(fd);
}
  1. If SessionManager cannot read from an fd, do an inode/dev consistency check: capture {dev, ino} from lstatSync, then immediately before opening re-stat and ensure {dev, ino} match, and also lstatSync confirms it is still not a symlink.

This removes the race window where the checked file can be swapped for a symlink or different file between validation and use.


Analyzed PR: #70786 at commit 4f96cac

Last updated on: 2026-04-23T21:27:47Z

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

Copy link
Copy Markdown
Contributor

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: 4f96cac1f1

ℹ️ 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 +10 to +13
import {
limitAgentHookHistoryMessages,
MAX_AGENT_HOOK_HISTORY_MESSAGES,
} from "../harness/hook-history.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0 Badge Restore missing hook-history module

src/agents/cli-runner/session-history.ts now imports ../harness/hook-history.js, but that file is not present in this commit (and the same missing module is imported from src/agents/cli-runner.ts). This makes CLI runner modules fail at import time with Cannot find module '../harness/hook-history.js', so the reliability/session-history suites and runtime CLI path cannot execute at all. Please either include the new module in this change or point these imports at an existing helper.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens CLI transcript loading against symlink traversal and custom session-store omissions, gates conversation hooks (llm_input, llm_output, agent_end) behind an explicit allowConversationAccess opt-in for non-bundled plugins, and skips transcript parsing and llm_output emission when no relevant hooks are active.

  • P0src/agents/harness/hook-history.ts does not exist in the repository, but is imported by both src/agents/cli-runner/session-history.ts and src/agents/cli-runner.ts (for limitAgentHookHistoryMessages, MAX_AGENT_HOOK_HISTORY_MESSAGES, and buildAgentHookConversationMessages). The project will fail to build and all tests exercising the changed paths will error at import time. This file must be added before merge.

Confidence Score: 4/5

Not safe to merge: the PR imports from a source file (src/agents/harness/hook-history.ts) that does not exist anywhere in the repository, which will break the build.

All security hardening logic and policy gating are well-reasoned and test-covered, but the missing hook-history.ts module is a P0 build-breaking omission that must be resolved before this can land.

src/agents/harness/hook-history.ts (must be created), src/agents/cli-runner.ts and src/agents/cli-runner/session-history.ts (both import the missing module)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/cli-runner/session-history.ts
Line: 10-13

Comment:
**Missing source file for imported module**

`hook-history.js` is imported for `limitAgentHookHistoryMessages` and `MAX_AGENT_HOOK_HISTORY_MESSAGES`, but `src/agents/harness/hook-history.ts` does not exist anywhere in the repository (confirmed via `git log`, `git ls-files`, and a full filesystem search). The same missing module is also referenced in `src/agents/cli-runner.ts` (`buildAgentHookConversationMessages`). Without this file the project will fail to compile and all tests that exercise the changed paths will error at import time.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/cli-runner.ts
Line: 6

Comment:
**Same missing module referenced here**

`buildAgentHookConversationMessages` is imported from `./harness/hook-history.js`, which resolves to the same non-existent `src/agents/harness/hook-history.ts`. This import will cause a compilation failure.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/cli-runner/session-history.ts
Line: 26-29

Comment:
**`isPathWithinBase` silently rejects when paths are identical**

`path.relative(basePath, targetPath)` returns `""` when the two paths are equal, so `Boolean(relative)` is `false` and the function returns `false`. In the current call site a session *file* can never equal the sessions *directory*, so this is harmless today, but the function's contract is misleading and could cause subtle regressions if it's reused in other contexts. Consider using `relative !== null && !relative.startsWith("..") && !path.isAbsolute(relative)` to express the true intent, or add a comment explaining the deliberate exclusion of equal paths.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(hooks): harden cli transcript loadin..." | Re-trigger Greptile

Comment on lines +10 to +13
import {
limitAgentHookHistoryMessages,
MAX_AGENT_HOOK_HISTORY_MESSAGES,
} from "../harness/hook-history.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0 Missing source file for imported module

hook-history.js is imported for limitAgentHookHistoryMessages and MAX_AGENT_HOOK_HISTORY_MESSAGES, but src/agents/harness/hook-history.ts does not exist anywhere in the repository (confirmed via git log, git ls-files, and a full filesystem search). The same missing module is also referenced in src/agents/cli-runner.ts (buildAgentHookConversationMessages). Without this file the project will fail to compile and all tests that exercise the changed paths will error at import time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/cli-runner/session-history.ts
Line: 10-13

Comment:
**Missing source file for imported module**

`hook-history.js` is imported for `limitAgentHookHistoryMessages` and `MAX_AGENT_HOOK_HISTORY_MESSAGES`, but `src/agents/harness/hook-history.ts` does not exist anywhere in the repository (confirmed via `git log`, `git ls-files`, and a full filesystem search). The same missing module is also referenced in `src/agents/cli-runner.ts` (`buildAgentHookConversationMessages`). Without this file the project will fail to compile and all tests that exercise the changed paths will error at import time.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/agents/cli-runner.ts
import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";
import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";
import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P0 Same missing module referenced here

buildAgentHookConversationMessages is imported from ./harness/hook-history.js, which resolves to the same non-existent src/agents/harness/hook-history.ts. This import will cause a compilation failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/cli-runner.ts
Line: 6

Comment:
**Same missing module referenced here**

`buildAgentHookConversationMessages` is imported from `./harness/hook-history.js`, which resolves to the same non-existent `src/agents/harness/hook-history.ts`. This import will cause a compilation failure.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +26 to +29
function isPathWithinBase(basePath: string, targetPath: string): boolean {
const relative = path.relative(basePath, targetPath);
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 isPathWithinBase silently rejects when paths are identical

path.relative(basePath, targetPath) returns "" when the two paths are equal, so Boolean(relative) is false and the function returns false. In the current call site a session file can never equal the sessions directory, so this is harmless today, but the function's contract is misleading and could cause subtle regressions if it's reused in other contexts. Consider using relative !== null && !relative.startsWith("..") && !path.isAbsolute(relative) to express the true intent, or add a comment explaining the deliberate exclusion of equal paths.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/cli-runner/session-history.ts
Line: 26-29

Comment:
**`isPathWithinBase` silently rejects when paths are identical**

`path.relative(basePath, targetPath)` returns `""` when the two paths are equal, so `Boolean(relative)` is `false` and the function returns `false`. In the current call site a session *file* can never equal the sessions *directory*, so this is harmless today, but the function's contract is misleading and could cause subtle regressions if it's reused in other contexts. Consider using `relative !== null && !relative.startsWith("..") && !path.isAbsolute(relative)` to express the true intent, or add a comment explaining the deliberate exclusion of equal paths.

How can I resolve this? If you propose a fix, please make it concise.

ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
alexey-pelykh added a commit to remoteclaw/remoteclaw that referenced this pull request Jul 2, 2026
…guards (#2764) (#2767)

Re-port two medium-severity upstream security fixes missed in the
v2026.4.24 content-only sync — both apply to kept fork paths (gateway,
plugins) and were advertised/expected controls that were absent live.

- gateway: block webchat clients from sessions.compact. Extend
  rejectWebchatSessionMutation to the "compact" action and guard the
  handler (client + isWebchatConnect), mirroring sessions.patch/delete.
  Upstream 873dce1 (PR openclaw#70716). The fork has no sessions.restore
  handler, so only "compact" is ported (not upstream's "restore").

- plugins: enforce plugins.entries.*.hooks.allowConversationAccess. Add
  isConversationHookName and gate conversation hooks (llm_input,
  llm_output, agent_end) in registerTypedHook so non-bundled plugins
  must opt in explicitly; bundled plugins stay allowed unless they set
  it false. The config field was fully plumbed but inert. Upstream
  51f9f94 (PR openclaw#70786) — registry/hooks portion only.

Tests: extend the webchat-mutation gateway test to assert compact is
rejected; add a conversation-access gate suite to plugins
dead-hooks.test.ts (non-bundled block, opt-in allow, bundled default,
bundled opt-out, non-conversation-hook not gated).

Co-authored-by: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant