Skip to content

Fix CLI transcript compaction lifecycle#71916

Merged
obviyus merged 6 commits into
mainfrom
fix/cli-compaction-lifecycle-clean
Apr 26, 2026
Merged

Fix CLI transcript compaction lifecycle#71916
obviyus merged 6 commits into
mainfrom
fix/cli-compaction-lifecycle-clean

Conversation

@obviyus

@obviyus obviyus commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #68329.
Supersedes #68388.

Summary:

  • compact persisted OpenClaw CLI transcripts when a CLI turn pushes the session over budget
  • clear external CLI resume state after compaction so the next turn cannot reload stale provider history
  • reseed fresh CLI sessions from the compacted OpenClaw transcript instead of writing provider-private history files

Tests:

  • OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD=1 pnpm test src/agents/command/cli-compaction.test.ts src/agents/cli-runner/session-history.test.ts src/agents/cli-runner.reliability.test.ts
  • CI=true OPENCLAW_LOCAL_CHECK=0 pnpm check:changed

@aisle-research-bot

aisle-research-bot Bot commented Apr 26, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Sensitive transcript reseeding into fresh CLI prompts without redaction/consent gating
2 🟠 High IDOR / cross-session transcript exposure via OpenClaw CLI reseed prompt
3 🟡 Medium Prompt delimiter injection in CLI session reseed prompt construction
4 🟡 Medium Potential compaction/maintenance DoS via stale session totalTokens snapshot
1. 🟠 Sensitive transcript reseeding into fresh CLI prompts without redaction/consent gating
Property Value
Severity High
CWE CWE-201
Location src/agents/cli-runner/session-history.ts:49-100

Description

Fresh CLI sessions are automatically reseeded with prior OpenClaw transcript content (compaction summary + post-compaction tail messages) and embedded into the next prompt sent to external CLI backends.

  • Source of sensitive data: loadCliSessionReseedMessages pulls a stored compaction summary and subsequent user/assistant messages from the session file.
  • No sanitization/redaction: buildCliSessionHistoryPrompt renders these messages verbatim (only truncates by length) and includes them in the prompt.
  • Sink / exfiltration path: In executePreparedCliRun, when there is no resumed CLI session (cliSessionIdToUse undefined), it prefers context.openClawHistoryPrompt over the user’s current prompt and sends it to the CLI backend.
  • Secondary exposure: The fully constructed prompt is returned as finalPromptText (and tests assert it contains reseeded content). Any telemetry/trace/export paths that store finalPromptText will now also store prior-session transcript content.

This can leak secrets from prior messages/compaction summaries (API keys, tokens, file contents, tool outputs quoted into assistant messages, etc.) to third-party model providers and/or logs/artifacts, without an explicit opt-in or redaction step.

Vulnerable code (history prompt construction):

return [
  "Continue this conversation using the OpenClaw transcript below as prior session history.",
  ...
  "<conversation_history>",
  renderedHistory,
  "</conversation_history>",
  ...
  "<next_user_message>",
  params.prompt,
  "</next_user_message>",
].join("\n");

Recommendation

Add explicit controls and redaction before reseeding stored history into prompts sent to external backends.

Suggested mitigations (combine as appropriate):

  1. Opt-in / config gate (default off) for transcript reseeding to third-party backends.

  2. Redact secrets in compaction summaries and tail messages before embedding:

function redactSecrets(text: string): string {
  return text
    .replace(/\b(sk-[A-Za-z0-9]{20,})\b/g, "sk-***")
    .replace(/\b(AKIA[0-9A-Z]{16})\b/g, "AKIA***")// add org-specific patterns + consider a dedicated secret-scanner
    ;
}// when rendering
const safeText = redactSecrets(text);
  1. Exclude risky content from reseed entirely (e.g., only include user messages, or only include compaction summaries that are produced with a “no secrets” policy).

  2. Avoid persisting full prompt artifacts by default; if finalPromptText is stored/exported, ensure it is redacted or behind a debug flag.

  3. User warning/consent when a fresh session will include prior transcript content in the outgoing prompt.

2. 🟠 IDOR / cross-session transcript exposure via OpenClaw CLI reseed prompt
Property Value
Severity High
CWE CWE-639
Location src/agents/cli-runner/prepare.ts:367-378

Description

A fresh CLI run (no reusable backend cliSessionId) is now reseeded by embedding prior OpenClaw transcript content (latest compaction summary + tail messages) into the prompt sent to the external CLI backend.

This creates a cross-session information disclosure risk if an attacker can cause sessionId/sessionKey/sessionFile to reference another user's session (e.g., by supplying/guessing a --session-id or otherwise influencing session resolution in a multi-tenant deployment):

  • prepareCliRunContext() builds openClawHistoryPrompt from loadCliSessionReseedMessages() (which reads the session file) when reusableCliSession.sessionId is absent.
  • executePreparedCliRun() uses that openClawHistoryPrompt as the effective prompt, forwarding the reseeded transcript to the CLI backend.
  • loadCliSessionReseedMessages() / loadCliSessionEntries() enforce path safety (no symlink/path traversal) but do not enforce ownership/authorization of the referenced session transcript.

Vulnerable flow (simplified):

  • input: sessionId / sessionKey / sessionFile chosen upstream (potentially attacker-controlled in some deployments)
  • read: OpenClaw session transcript is loaded from disk (SessionManager.open(...).getEntries())
  • exfil sink: transcript content is embedded into openClawHistoryPrompt and sent to external CLI backend as the prompt

Vulnerable code:

const openClawHistoryPrompt = reusableCliSession.sessionId
  ? undefined
  : buildCliSessionHistoryPrompt({
      messages: loadCliSessionReseedMessages({
        sessionId: params.sessionId,
        sessionFile: params.sessionFile,
        sessionKey: params.sessionKey,
        agentId: params.agentId,
        config: params.config,
      }),
      prompt: preparedPrompt,
    });
const basePrompt = cliSessionIdToUse
  ? params.prompt
  : (context.openClawHistoryPrompt ?? params.prompt);
return SessionManager.open(realSessionFile).getEntries();

Recommendation

Enforce session ownership/authorization before reading any transcript for reseeding.

Minimum fixes:

  1. Bind session files/entries to an account/owner identity (e.g., agentAccountId/sender) when created.
  2. When loading history/reseed messages, verify the caller is authorized to access the session:
    • sessionEntry.ownerId matches requester
    • or senderIsOwner === true for privileged operations
    • and deny cross-agent-store lookup unless explicitly allowed.
  3. Treat --session-id as a privileged/owner-only capability in multi-tenant contexts.

Example pattern (conceptual):

export function loadCliSessionReseedMessages(params: {
  sessionId: string;
  sessionFile: string;
  sessionKey?: string;
  requesterAccountId: string;
  sessionEntry: SessionEntry;// ...
}): unknown[] {
  if (params.sessionEntry.accountId !== params.requesterAccountId) {
    return []; // or throw
  }// then load entries...
}

Also consider disabling reseed-by-transcript entirely unless explicitly enabled in config for single-tenant/local CLI use.

3. 🟡 Prompt delimiter injection in CLI session reseed prompt construction
Property Value
Severity Medium
CWE CWE-74
Location src/agents/cli-runner/session-history.ts:49-100

Description

buildCliSessionHistoryPrompt() constructs a single plaintext prompt for a fresh CLI session by concatenating prior session transcript content (including user-controlled messages and compaction summaries) into a pseudo-XML structure:

  • Prior messages are rendered verbatim into <conversation_history> ... </conversation_history>
  • The new user prompt is embedded verbatim in <next_user_message> ... </next_user_message>
  • No escaping/encoding is applied to the embedded transcript or prompt

Because the inserted content can contain the same delimiter strings (e.g. </conversation_history>, <next_user_message>, or other instruction-like markers), an attacker can craft a prior message that breaks the intended boundaries and injects additional instructions into what appears to be the “outer” prompt. This is a form of delimiter/prompt injection that can change model behavior in the new session and potentially influence any downstream tool-using agent behavior.

Vulnerable code:

return [
  "<conversation_history>",
  renderedHistory,
  "</conversation_history>",
  "",
  "<next_user_message>",
  params.prompt,
  "</next_user_message>",
].join("\n");

Data-flow context:

  • Input: params.messages are loaded from the session file (SessionManager.open(...).getEntries()), containing prior user messages and compaction summaries.
  • Sink: concatenated into openClawHistoryPrompt, then used as basePrompt for the CLI backend invocation when starting a new session (executePreparedCliRun).

Recommendation

Treat prior transcript content as data, not as inline control text.

Recommended mitigations (pick one):

  1. Use structured chat messages rather than a single concatenated string (preferred):

    • Send history as separate user/assistant message objects (or provider-specific equivalents), and send the new prompt as the final user message.
  2. If you must keep a single string, escape/encode delimiters so embedded content cannot terminate or create tags:

function escapeXmlLike(s: string): string {
  return s
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}// When building:
"<conversation_history>",
escapeXmlLike(renderedHistory),
"</conversation_history>",
"<next_user_message>",
escapeXmlLike(params.prompt),
"</next_user_message>",
  1. Alternatively, wrap blocks with length-prefix framing (e.g., HISTORY_LEN=...) and instruct the model to treat it as opaque.

Also consider adding explicit instruction to ignore any “tag-like” content inside the history block and to treat it purely as quoted text.

4. 🟡 Potential compaction/maintenance DoS via stale session totalTokens snapshot
Property Value
Severity Medium
CWE CWE-400
Location src/agents/command/cli-compaction.ts:222-252

Description

runCliTurnCompactionLifecycle() uses sessionEntry.totalTokens as a token-count snapshot when totalTokensFresh !== false, and forces compaction when over budget.

However, recordCliCompactionInStore()—called after a successful compaction—does not invalidate or recompute totalTokens / totalTokensFresh. This can leave a previously-high totalTokens marked as fresh even after the transcript has been compacted, causing subsequent turns to compute an inflated currentTokenCount and repeatedly:

  • run contextEngine.compact(..., force: true)
  • run context engine maintenance

This creates an unnecessary repeated expensive maintenance/compaction loop (CPU/IO), which can be triggered by a corrupted/stale totalTokens value in the session store (e.g., bad persisted state) and then persists across turns.

Vulnerable flow:

  • source: persisted sessionEntry.totalTokens when totalTokensFresh !== false
  • sink: currentTokenCount -> compaction decision -> contextEngine.compact(force: true) every turn

Recommendation

Invalidate or bound the snapshot token count after compaction so stale/high values cannot force repeated compaction.

Options:

  1. Mark token snapshot stale when recording compaction:
// in recordCliCompactionInStore
next.totalTokensFresh = false;

(and/or clear next.totalTokens)

  1. Recompute totalTokens after compaction based on the actual compacted transcript/tokenizer and persist it.

  2. Add safety caps/backoff in runCliTurnCompactionLifecycle, e.g.:

const tokenSnapshot = resolveSessionTokenSnapshot(params.sessionEntry);
const boundedSnapshot = tokenSnapshot ? Math.min(tokenSnapshot, contextTokenBudget * 2) : 0;
const currentTokenCount = Math.max(preemptiveCompaction.estimatedPromptTokens, boundedSnapshot);

Also consider skipping compaction if compactionCount increased very recently (rate limit).


Analyzed PR: #71916 at commit e0cb96a

Last updated on: 2026-04-26T04:06:09Z

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: L maintainer Maintainer-authored PR labels Apr 26, 2026
@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the CLI transcript compaction lifecycle: it compacts OpenClaw transcripts after over-budget CLI turns, clears stale external CLI resume state post-compaction, and reseeds fresh CLI sessions from the compacted OpenClaw transcript rather than provider-private history files. The implementation correctly mirrors the existing clearCliSessionInStore pattern in recordCliCompactionInStore and the execute.ts branching on cliSessionIdToUse is correct.

Confidence Score: 4/5

Safe to merge; only P2 style/performance observations, no correctness or security defects found.

All findings are P2 (style and minor efficiency). Core logic for compaction triggering, CLI session clearing, and history prompt injection is correct and well-tested.

src/agents/cli-runner/prepare.ts (unnecessary history computation for native-resume sessions) and src/agents/command/cli-compaction.ts (empty-prompt comment clarification).

Comments Outside Diff (2)

  1. src/agents/cli-runner/prepare.ts, line 219-228 (link)

    P2 Unconditional history prompt construction for native-resume sessions

    buildCliSessionHistoryPrompt (and the underlying loadCliSessionHistoryMessages disk read) is computed for every session, including those with an active cliSessionIdToUse that will never consume openClawHistoryPrompt. In execute.ts the prompt is only used when cliSessionIdToUse is falsy, so the disk read and string construction are wasted work on every resumed-native-CLI turn. Consider guarding the computation with a check against whether a reusable CLI session exists.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/cli-runner/prepare.ts
    Line: 219-228
    
    Comment:
    **Unconditional history prompt construction for native-resume sessions**
    
    `buildCliSessionHistoryPrompt` (and the underlying `loadCliSessionHistoryMessages` disk read) is computed for every session, including those with an active `cliSessionIdToUse` that will never consume `openClawHistoryPrompt`. In `execute.ts` the prompt is only used when `cliSessionIdToUse` is falsy, so the disk read and string construction are wasted work on every resumed-native-CLI turn. Consider guarding the computation with a check against whether a reusable CLI session exists.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/agents/command/cli-compaction.ts, line 759-761 (link)

    P2 Empty prompt passed to shouldPreemptivelyCompactBeforePrompt

    prompt: "" is passed deliberately (this is post-turn, no incoming prompt yet), but estimatedPromptTokens returned by that call is then used as the floor for currentTokenCount. If the next user turn carries a large prompt, currentTokenCount will underestimate total context on the next execution's compaction check, not this one — which is the accepted trade-off here. Worth a brief comment so future readers don't mistake this for a bug.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/command/cli-compaction.ts
    Line: 759-761
    
    Comment:
    **Empty prompt passed to `shouldPreemptivelyCompactBeforePrompt`**
    
    `prompt: ""` is passed deliberately (this is post-turn, no incoming prompt yet), but `estimatedPromptTokens` returned by that call is then used as the floor for `currentTokenCount`. If the next user turn carries a large prompt, `currentTokenCount` will underestimate total context on the *next* execution's compaction check, not this one — which is the accepted trade-off here. Worth a brief comment so future readers don't mistake this for a bug.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/cli-runner/prepare.ts
Line: 219-228

Comment:
**Unconditional history prompt construction for native-resume sessions**

`buildCliSessionHistoryPrompt` (and the underlying `loadCliSessionHistoryMessages` disk read) is computed for every session, including those with an active `cliSessionIdToUse` that will never consume `openClawHistoryPrompt`. In `execute.ts` the prompt is only used when `cliSessionIdToUse` is falsy, so the disk read and string construction are wasted work on every resumed-native-CLI turn. Consider guarding the computation with a check against whether a reusable CLI session exists.

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/command/cli-compaction.ts
Line: 759-761

Comment:
**Empty prompt passed to `shouldPreemptivelyCompactBeforePrompt`**

`prompt: ""` is passed deliberately (this is post-turn, no incoming prompt yet), but `estimatedPromptTokens` returned by that call is then used as the floor for `currentTokenCount`. If the next user turn carries a large prompt, `currentTokenCount` will underestimate total context on the *next* execution's compaction check, not this one — which is the accepted trade-off here. Worth a brief comment so future readers don't mistake this for a bug.

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

Reviews (1): Last reviewed commit: "docs(changelog): note CLI compaction fix" | Re-trigger Greptile

@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: 253d5a6bdc

ℹ️ 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/agents/cli-runner/prepare.ts Outdated
@obviyus obviyus self-assigned this Apr 26, 2026
@obviyus
obviyus force-pushed the fix/cli-compaction-lifecycle-clean branch from 65a4988 to e0cb96a Compare April 26, 2026 04:03
@obviyus
obviyus merged commit f9c8a51 into main Apr 26, 2026
59 of 60 checks passed
@obviyus
obviyus deleted the fix/cli-compaction-lifecycle-clean branch April 26, 2026 04:04
@obviyus

obviyus commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Landed on main.

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.

cli-backend never triggers compaction (native or plugin) — context grows unbounded

1 participant