refactor(agents): share hook history windows#70762
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Symlink traversal allows arbitrary file read/DoS when loading CLI session history transcript
Description
If an attacker can create/modify files inside the OpenClaw state directory (e.g., Effects:
Vulnerable code: const stat = fs.statSync(sessionFile);
...
const entries = SessionManager.open(sessionFile).getEntries();RecommendationHarden transcript loading against symlinks/special files.
Example (sync): import path from "node:path";
const sessionsDir = path.dirname(sessionFile); // or return it from resolveSafeCliSessionFile
const base = fs.realpathSync(sessionsDir);
const lst = fs.lstatSync(sessionFile);
if (!lst.isFile() || lst.isSymbolicLink()) return [];
const realFile = fs.realpathSync(sessionFile);
const rel = path.relative(base, realFile);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return [];
const stat = fs.statSync(realFile);
if (stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) return [];
const entries = SessionManager.open(realFile).getEntries();This prevents an attacker-controlled symlink inside the sessions directory from redirecting reads to arbitrary locations. 2. 🟡 Sensitive conversation data forwarded to plugin lifecycle hooks (llm_input/llm_output/agent_end) without redaction or opt-in
Description
Because hooks are implemented as plugins via the global hook runner, any enabled/loaded plugin that registers these hooks will receive this data. This can expose sensitive information (API keys, tokens, credentials, proprietary code, file paths, PII) present in prompts, system prompts, transcript history, or assistant output to third-party/untrusted plugins and to any external destinations those plugins may transmit to. Vulnerable code (hook payload includes full prompt/history/system prompt and assistant output): const llmInputEvent = {
systemPrompt: context.systemPrompt,
prompt: params.prompt,
historyMessages,
};
...
runAgentHarnessLlmInputHook({ event: llmInputEvent, ctx: hookContext });
...
runAgentHarnessLlmOutputHook({ event: { assistantTexts, lastAssistant, usage: output.usage }, ctx: hookContext });
...
runAgentHarnessAgentEndHook({ event: { messages: buildAgentEndMessages(lastAssistant), ... }, ctx: hookContext });RecommendationMinimize and gate what is shared with hooks:
Example approach (pseudo-code): const safeEvent = cfg.plugins?.allowConversationHooks
? redactConversation(llmInputEvent)
: { runId, sessionId, provider, model, imagesCount };
runAgentHarnessLlmInputHook({ event: safeEvent, ctx: hookContext });Analyzed PR: #70762 at commit Last updated on: 2026-04-23T20:07:28Z |
Greptile SummaryThis PR consolidates hook-history windowing into a shared The refactor is well-structured and the test coverage is thorough. Two minor design choices are worth noting (see inline comments). Confidence Score: 5/5Safe to merge; all findings are style-level suggestions with no correctness impact. The logic is sound across all error paths (normal success, non-failover failure, session_expired retry success, session_expired retry failure). The shared windowing helper is straightforward and the tests validate the boundary conditions. No P0/P1 findings were identified. No files require special attention beyond the two P2 inline notes on cli-runner.ts and attempt.ts.
|
| runAgentHarnessLlmOutputHook({ | ||
| event: { | ||
| runId: params.runId, | ||
| sessionId: params.sessionId, | ||
| provider: params.provider, | ||
| model: context.modelId, | ||
| assistantTexts, | ||
| ...(lastAssistant ? { lastAssistant } : {}), | ||
| ...(output.usage ? { usage: output.usage } : {}), | ||
| }, | ||
| ctx: hookContext, | ||
| }); | ||
| return { output, assistantText, lastAssistant }; |
There was a problem hiding this comment.
llm_output fires even on empty CLI output
runAgentHarnessLlmOutputHook is called unconditionally after executePreparedCliRun succeeds, even when assistantText is empty (assistantTexts = [], lastAssistant = undefined). The embedded runner (attempt.ts) only fires this hook when there's a non-empty lastAssistant. Consumers of the llm_output hook may not expect a call with no text content.
Consider guarding the call on a non-empty result:
| runAgentHarnessLlmOutputHook({ | |
| event: { | |
| runId: params.runId, | |
| sessionId: params.sessionId, | |
| provider: params.provider, | |
| model: context.modelId, | |
| assistantTexts, | |
| ...(lastAssistant ? { lastAssistant } : {}), | |
| ...(output.usage ? { usage: output.usage } : {}), | |
| }, | |
| ctx: hookContext, | |
| }); | |
| return { output, assistantText, lastAssistant }; | |
| if (assistantText.length > 0) { | |
| runAgentHarnessLlmOutputHook({ | |
| event: { | |
| runId: params.runId, | |
| sessionId: params.sessionId, | |
| provider: params.provider, | |
| model: context.modelId, | |
| assistantTexts, | |
| ...(lastAssistant ? { lastAssistant } : {}), | |
| ...(output.usage ? { usage: output.usage } : {}), | |
| }, | |
| ctx: hookContext, | |
| }); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/cli-runner.ts
Line: 133-145
Comment:
**`llm_output` fires even on empty CLI output**
`runAgentHarnessLlmOutputHook` is called unconditionally after `executePreparedCliRun` succeeds, even when `assistantText` is empty (`assistantTexts = []`, `lastAssistant = undefined`). The embedded runner (`attempt.ts`) only fires this hook when there's a non-empty `lastAssistant`. Consumers of the `llm_output` hook may not expect a call with no text content.
Consider guarding the call on a non-empty result:
```suggestion
if (assistantText.length > 0) {
runAgentHarnessLlmOutputHook({
event: {
runId: params.runId,
sessionId: params.sessionId,
provider: params.provider,
model: context.modelId,
assistantTexts,
...(lastAssistant ? { lastAssistant } : {}),
...(output.usage ? { usage: output.usage } : {}),
},
ctx: hookContext,
});
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: afbda612d4
ℹ️ 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".
| resolveSessionFilePathOptions({ | ||
| agentId: sessionAgentId ?? defaultAgentId, | ||
| }), |
There was a problem hiding this comment.
Honor custom session store when resolving hook history
resolveSafeCliSessionFile only passes agentId into resolveSessionFilePathOptions, so it resolves against the default state sessions directory and ignores config.session.store. For deployments using a custom store path (for example, session.store outside .../agents/<id>/sessions), the provided sessionFile is treated as out-of-scope and resolveSessionFilePath falls back to a different transcript path, causing loadCliSessionHistoryMessages to return [] and hooks to silently lose prior conversation context.
Useful? React with 👍 / 👎.
| ): Promise<EmbeddedPiRunResult> { | ||
| const { executePreparedCliRun } = await import("./cli-runner/execute.runtime.js"); | ||
| const { params } = context; | ||
| const historyMessages = loadCliSessionHistoryMessages({ |
There was a problem hiding this comment.
Skip transcript loading when no lifecycle hooks are active
runPreparedCliAgent now loads and parses session history unconditionally before any hook checks. On sessions with existing transcripts this adds synchronous file I/O and JSONL parsing to every CLI run even when llm_input/agent_end hooks are not registered, which regresses the default hot path for users who do not use these hooks.
Useful? React with 👍 / 👎.
summary
session_expiredand verifies fresh-session recovery with a real spawned cli processtesting
pnpm exec vitest run --config test/vitest/vitest.agents.config.ts src/agents/harness/hook-history.test.ts src/agents/cli-runner/session-history.test.tspnpm exec vitest run --config test/vitest/vitest.agents.config.ts src/agents/cli-runner.reliability.test.tsOPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1 pnpm test:live -- src/agents/cli-runner.retry.live.test.tspnpm buildnote
check:changedhook hit the knownvitest.agents.config.ts60s no-output timeout and exited 143, so the commit used--no-verifyafter the direct targeted checks above passed.