Skip to content

[Security] Stop resume hook can write outside session state directory via unvalidated goal ID #129

Description

@01022694274a-ai

Summary

LazyCodex 4.17.0's new ULW-loop Stop resume hook uses goal.id inside an auto-resume-* filename without validating the ID or checking that the resolved path remains inside the session state directory.

With a crafted .omo/ulw-loop/<session>/goals.json and a matching intermediate directory, the hook can create or truncate a .json or .stuck file outside the session state directory when the global Stop hook runs.

Environment

  • LazyCodex version: 4.17.0
  • Codex version: 0.144.1
  • OS: macOS
  • Install method: npx lazycodex-ai@4.17.0 install --no-tui --codex-autonomous
  • Model used for runtime smoke: gpt-5.6-sol

Repository Decision

  • Target repository: code-yeongyu/lazycodex
  • Why this belongs there: the affected implementation is the bundled LazyCodex ULW-loop component and its newly registered Stop hook.
  • LazyCodex source evidence: plugins/omo/components/ulw-loop/src/stop-resume-hook.ts, particularly consumeResumeBudget().
  • Upstream Codex source evidence: the behavior is implemented in the plugin hook, not Codex core.

Reproduction

  1. Create an isolated temporary workspace with session state at .omo/ulw-loop/s1.
  2. Create .omo/ulw-loop/s1/auto-resume-.. as an intermediate directory.
  3. Write a pending goal whose ID is ../../../escaped-marker to .omo/ulw-loop/s1/goals.json and make it the active goal.
  4. Invoke the packaged runStopResumeHook() with cwd pointing at the temporary workspace and session_id: "s1".
  5. Observe that .omo/ulw-loop/escaped-marker.json, outside the s1 state directory, is created.

Observed runtime evidence:

before=false
decision=block
after=true
content={"count":1,"ledgerLineCount":0}

The temporary workspace was deleted immediately after the reproduction.

Expected Behavior

Malformed or untrusted goal IDs should make the hook no-op or return a validation error. Every resume counter and stuck marker must remain inside the resolved session state directory.

Actual Behavior

consumeResumeBudget() constructs paths using:

join(stateDir, `auto-resume-${goalId}.json`)
join(stateDir, `auto-resume-${goalId}.stuck`)

There is no goal-ID schema validation or post-resolution containment check before writeFileSync().

Evidence

  • Installed manifest registers the new Stop hook and all 21 declared hooks load successfully.
  • The packaged 4.17.0 source and dist output contain the same unchecked path construction.
  • A real gpt-5.6-sol Codex smoke test loads the Stop hook successfully, so the affected surface is active.
  • The isolated reproduction confirms a write outside the per-session state directory.

Root Cause

readPlan() casts parsed JSON directly to UlwLoopPlan. A goal ID read from disk reaches consumeResumeBudget() without runtime validation. The filename prefix does not provide containment when the state tree contains an attacker-controlled intermediate directory.

Proposed Fix

  1. Validate loaded goal IDs with the canonical goal-ID schema before using them. For example, restrict IDs to the format generated by the CLI.
  2. Resolve the target path and verify it remains inside stateDir using the existing containment helper pattern (isWithinAttemptDir or an equivalent state-directory helper).
  3. No-op safely when a plan loaded from disk fails schema validation.
  4. Add a regression test covering traversal IDs and crafted intermediate directories for both .json and .stuck writes.

Verification Plan

  • Re-run the isolated reproduction and verify that no file is created outside stateDir.
  • Confirm normal generated goal IDs still create and update their counter files.
  • Confirm the two-strike stuck-marker behavior remains unchanged.
  • Run the packaged Stop-hook tests and plugin smoke tests.

This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions