Skip to content

fix(agents): harden cli runner hook followups#70747

Merged
vincentkoc merged 6 commits into
mainfrom
fix/cli-runner-hook-followups-2
Apr 23, 2026
Merged

fix(agents): harden cli runner hook followups#70747
vincentkoc merged 6 commits into
mainfrom
fix/cli-runner-hook-followups-2

Conversation

@vincentkoc

Copy link
Copy Markdown
Member

summary

  • emit llm_input once for a logical CLI run and always emit agent_end when the session-expired retry fails
  • load CLI session history through a canonical, size-bounded helper so hook payloads only read trusted transcript files
  • cover the retry lifecycle and session-history hardening with regression tests

validation

  • pnpm exec vitest run --config test/vitest/vitest.agents.config.ts src/agents/cli-runner/session-history.test.ts
  • pnpm exec vitest run --config test/vitest/vitest.agents.config.ts src/agents/cli-runner.reliability.test.ts
  • pnpm exec vitest run --config test/vitest/vitest.agents.config.ts src/agents/cli-runner/prepare.test.ts
  • pnpm build

note

  • this keeps the existing hook trust model intact; it hardens transcript loading and fixes the retry lifecycle bugs without changing the shared embedded-runner payload contract

@vincentkoc
vincentkoc marked this pull request as ready for review April 23, 2026 19:29
@vincentkoc vincentkoc self-assigned this Apr 23, 2026
@openclaw-barnacle openclaw-barnacle Bot added the agents Agent runtime and tooling label Apr 23, 2026
@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 Symlink/hardlink traversal allows reading arbitrary local files via CLI session history loader
2 🟠 High Cross-agent session transcript disclosure via user-controlled agentId/sessionKey in CLI session history loader
1. 🟠 Symlink/hardlink traversal allows reading arbitrary local files via CLI session history loader
Property Value
Severity High
CWE CWE-59
Location src/agents/cli-runner/session-history.ts:42-51

Description

loadCliSessionHistoryMessages() resolves a session transcript path under the state sessions directory and then validates it using fs.statSync().

  • fs.statSync() follows symlinks, so a transcript path that is a symlink to another file will pass the isFile() and size checks.
  • The file is then opened via SessionManager.open(sessionFile).getEntries(), which will read and parse the file.
  • The resulting messages are forwarded into hook payloads (llm_input, before_prompt_build, agent_end), enabling exfiltration of the target file’s contents if an attacker can create/replace the transcript path within the sessions directory.

Vulnerable code:

const stat = fs.statSync(sessionFile);
if (!stat.isFile() || stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) {
  return [];
}
const entries = SessionManager.open(sessionFile).getEntries();

Recommendation

Harden transcript file access against symlink/hardlink attacks.

Minimum mitigation (symlink refusal):

  • Use lstatSync() and reject isSymbolicLink().
  • Optionally, also verify the resolved realpath stays within the expected sessions directory.

Example:

import path from "node:path";

const st = fs.lstatSync(sessionFile);
if (!st.isFile() || st.isSymbolicLink()) return [];

const real = fs.realpathSync(sessionFile);
const sessionsDir = path.resolve(path.dirname(sessionFile)); // or the resolved sessions root
if (!real.startsWith(sessionsDir + path.sep)) return [];

Stronger mitigation (TOCTOU-resistant):

  • Open the file with O_NOFOLLOW (where supported) and then fstat the opened fd before reading, to prevent swap-after-check.
  • Ensure the sessions directory is created with restrictive permissions (e.g., 0700) so untrusted users/processes cannot plant links.
2. 🟠 Cross-agent session transcript disclosure via user-controlled agentId/sessionKey in CLI session history loader
Property Value
Severity High
CWE CWE-200
Location src/agents/cli-runner/session-history.ts:20-31

Description

loadCliSessionHistoryMessages() resolves the session transcript file path using an agentId derived from untrusted caller-controlled inputs (params.agentId and/or params.sessionKey). Because resolveSessionAgentIds() prefers the explicit agentId over the parsed sessionKey, a caller who can influence these parameters can cause the code to read history from a different agent’s session directory.

Impact:

  • If RunCliAgentParams.agentId or sessionKey can be set by an external requester (e.g., HTTP/API parameter), an attacker can request history for another agent by specifying that agent’s id.
  • The loaded messages are then:
    • included in lifecycle hook events (llmInputEvent.historyMessages, agentEndHook.messages), and
    • used during prompt-build hooks in prepare.ts.
      This can leak prior conversation content (transcripts) across agents/sessions.

Vulnerable code:

const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
  sessionKey: params.sessionKey,
  config: params.config,
  agentId: params.agentId,
});
...
resolveSessionFilePath(..., resolveSessionFilePathOptions({
  agentId: sessionAgentId ?? defaultAgentId,
}))

Recommendation

Bind transcript resolution to an authenticated/authorized agent scope rather than caller-provided identifiers.

Options:

  1. Do not accept agentId for history loading from external callers. Derive the agent id solely from trusted server-side session metadata.
  2. If sessionKey is the intended scoping mechanism, require it to parse as a valid agent session key and ignore/forbid explicit agentId overrides for transcript reads.
  3. Add an explicit authorization check that the resolved sessionAgentId is allowed for the current requester.

Example hardening (prefer sessionKey-derived scope and disallow override):

const parsed = params.sessionKey ? parseAgentSessionKey(params.sessionKey) : null;
if (!parsed) throw new Error("Missing/invalid sessionKey");
const sessionAgentId = normalizeAgentId(parsed.agentId);

return resolveSessionFilePath(
  params.sessionId,
  { sessionFile: params.sessionFile },
  resolveSessionFilePathOptions({ agentId: sessionAgentId }),
);

If you must support agentId, only accept it from trusted internal code paths and validate it against the authenticated principal/session ownership.


Analyzed PR: #70747 at commit c8edc26

Last updated on: 2026-04-23T19:32:24Z

@openclaw-barnacle openclaw-barnacle Bot added size: L maintainer Maintainer-authored PR labels Apr 23, 2026
@vincentkoc
vincentkoc merged commit f40f8a6 into main Apr 23, 2026
102 of 104 checks passed
@vincentkoc
vincentkoc deleted the fix/cli-runner-hook-followups-2 branch April 23, 2026 19:32

@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: c8edc265dc

ℹ️ 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 +28 to +30
resolveSessionFilePathOptions({
agentId: sessionAgentId ?? defaultAgentId,
}),

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 Badge Respect custom session store when resolving CLI history

When cfg.session.store points to a non-default location, session transcripts are resolved relative to that store directory, but this helper builds path options with only agentId and omits storePath. In that case resolveSessionFilePath(...) treats the real sessionFile as out-of-scope and falls back to the default state transcript path, so hook history becomes empty or stale for before_prompt_build, llm_input, and agent_end events in custom-store deployments.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two hook-lifecycle bugs in the CLI runner (duplicate llm_input emission during session-expired retries; missing agent_end when the retry itself fails) and hardens transcript loading by routing all session-history reads through a canonical, size-bounded helper (loadCliSessionHistoryMessages) that ignores caller-supplied paths outside the state directory.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 observations.

The two targeted bugs are correctly fixed and well-covered by new regression tests. The one flagged concern (structural-path fallback in resolveSessionFilePath potentially bypassing canonical-path selection) is a P2 gap in test coverage rather than a present defect — the caller-supplied sessionFile comes from trusted PreparedCliRunContext, not from user input.

src/agents/cli-runner/session-history.ts — verify behavior of resolveStructuralSessionFallbackPath for external paths with agents//sessions/ structure

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

Comment:
**Structural-path fallback may bypass canonical-path restriction**

`resolveSessionFilePath` (in `paths.ts`) includes a `resolveStructuralSessionFallbackPath` branch that returns the caller-supplied `sessionFile` as-is when it has the directory shape `…/agents/<agentId>/sessions/<filename>` — even if that path lives outside `OPENCLAW_STATE_DIR`. This means a caller-supplied path like `/attacker/agents/main/sessions/stolen.jsonl` would satisfy the structural check and be returned verbatim, defeating the canonical-path hardening introduced here.

The existing tests cover the case where the external path lacks that directory structure (a flat `stolen.jsonl`). Consider adding a test where the external path *does* match the `agents/<id>/sessions/<file>` shape to confirm the restriction holds.

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

Reviews (1): Last reviewed commit: "fix(agents): harden cli runner hook foll..." | Re-trigger Greptile

Comment on lines +46 to +52
const stat = fs.statSync(sessionFile);
if (!stat.isFile() || stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) {
return [];
}
const entries = SessionManager.open(sessionFile).getEntries();
const history: unknown[] = [];
for (let index = entries.length - 1; index >= 0; index -= 1) {

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 Structural-path fallback may bypass canonical-path restriction

resolveSessionFilePath (in paths.ts) includes a resolveStructuralSessionFallbackPath branch that returns the caller-supplied sessionFile as-is when it has the directory shape …/agents/<agentId>/sessions/<filename> — even if that path lives outside OPENCLAW_STATE_DIR. This means a caller-supplied path like /attacker/agents/main/sessions/stolen.jsonl would satisfy the structural check and be returned verbatim, defeating the canonical-path hardening introduced here.

The existing tests cover the case where the external path lacks that directory structure (a flat stolen.jsonl). Consider adding a test where the external path does match the agents/<id>/sessions/<file> shape to confirm the restriction holds.

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

Comment:
**Structural-path fallback may bypass canonical-path restriction**

`resolveSessionFilePath` (in `paths.ts`) includes a `resolveStructuralSessionFallbackPath` branch that returns the caller-supplied `sessionFile` as-is when it has the directory shape `…/agents/<agentId>/sessions/<filename>` — even if that path lives outside `OPENCLAW_STATE_DIR`. This means a caller-supplied path like `/attacker/agents/main/sessions/stolen.jsonl` would satisfy the structural check and be returned verbatim, defeating the canonical-path hardening introduced here.

The existing tests cover the case where the external path lacks that directory structure (a flat `stolen.jsonl`). Consider adding a test where the external path *does* match the `agents/<id>/sessions/<file>` shape to confirm the restriction holds.

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
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups

* fix(agents): harden cli runner hook followups
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: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant