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
- Create an isolated temporary workspace with session state at
.omo/ulw-loop/s1.
- Create
.omo/ulw-loop/s1/auto-resume-.. as an intermediate directory.
- Write a pending goal whose ID is
../../../escaped-marker to .omo/ulw-loop/s1/goals.json and make it the active goal.
- Invoke the packaged
runStopResumeHook() with cwd pointing at the temporary workspace and session_id: "s1".
- 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
- Validate loaded goal IDs with the canonical goal-ID schema before using them. For example, restrict IDs to the format generated by the CLI.
- Resolve the target path and verify it remains inside
stateDir using the existing containment helper pattern (isWithinAttemptDir or an equivalent state-directory helper).
- No-op safely when a plan loaded from disk fails schema validation.
- 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
Summary
LazyCodex 4.17.0's new ULW-loop Stop resume hook uses
goal.idinside anauto-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.jsonand a matching intermediate directory, the hook can create or truncate a.jsonor.stuckfile outside the session state directory when the global Stop hook runs.Environment
npx lazycodex-ai@4.17.0 install --no-tui --codex-autonomousgpt-5.6-solRepository Decision
code-yeongyu/lazycodexplugins/omo/components/ulw-loop/src/stop-resume-hook.ts, particularlyconsumeResumeBudget().Reproduction
.omo/ulw-loop/s1..omo/ulw-loop/s1/auto-resume-..as an intermediate directory.../../../escaped-markerto.omo/ulw-loop/s1/goals.jsonand make it the active goal.runStopResumeHook()withcwdpointing at the temporary workspace andsession_id: "s1"..omo/ulw-loop/escaped-marker.json, outside thes1state directory, is created.Observed runtime evidence:
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:There is no goal-ID schema validation or post-resolution containment check before
writeFileSync().Evidence
gpt-5.6-solCodex smoke test loads the Stop hook successfully, so the affected surface is active.Root Cause
readPlan()casts parsed JSON directly toUlwLoopPlan. A goal ID read from disk reachesconsumeResumeBudget()without runtime validation. The filename prefix does not provide containment when the state tree contains an attacker-controlled intermediate directory.Proposed Fix
stateDirusing the existing containment helper pattern (isWithinAttemptDiror an equivalent state-directory helper)..jsonand.stuckwrites.Verification Plan
stateDir.This issue or PR was generated by LazyCodex.
Tag: lazycodex-generated