T09 · Insecure Skill Coding Practices
- Location
scripts/step-sequencer-check.py:108- Finding
Concurrent Heartbeat Invocations Can Execute the Same Agent Step Multiple Times
- Content
View full analysis
Vulnerability Details
File Location:
scripts/step-sequencer-check.py:108-110; related state transition inscripts/step-sequencer-runner.py:141-150
Vulnerability Type: Race condition and missing execution locking
Risk Level: High
Category: T09: Insecure Skill Coding PracticesVulnerable code in
scripts/step-sequencer-check.py:108-110:python # PENDING or IN_PROGRESS: invoke runner save_state(state_path, state) invoke_runner(state_path, scripts_dir)Related code in
scripts/step-sequencer-runner.py:141-150:python now = datetime.now(timezone.utc).isoformat() step_runs[step_id] = { "status": "IN_PROGRESS", "tries": tries + 1, "lastRunIso": now, } state["stepRuns"] = step_runs save_state(state_path, state) agent_cmd = get_agent_cmd() + [prompt]Technical Analysis
The heartbeat check treats
IN_PROGRESSsteps the same asPENDINGsteps and unconditionally invokes another runner. No exclusive file lock, process lock, atomic claim operation, execution lease, process identifier, or unique run identifier prevents two check processes from launching the same step concurrently.The runner writes
IN_PROGRESSbefore launching the configured agent, but this state does not prevent another heartbeat or manual check invocation from starting a second runner. The JSON state file is also loaded and rewritten without synchronization or atomic replacement. Concurrent processes can therefore execute the same instruction and overwrite each other's state updates.This behavior is especially dangerous because step instructions may authorize externally visible or non-idempotent actions, such as sending messages, making API transactions, modifying files, or deleting resources. The race does not independently grant privileges beyond those already available to the configured agent, but it can duplicate actions using all of that agent's existing permissions.
...[truncated 1840 chars]
- Remediation
View remediation
Remediation Suggestions
-
Serialize state access and execution claims. Acquire an exclusive lock associated with the state file before reading, changing, or launching a step. Keep the lock through the atomic claim operation, and use a separate execution lease if holding the lock for the entire agent run is undesirable.
-
Do not immediately relaunch active steps. Treat a valid
IN_PROGRESSstate as “already running.” Relaunch it only when a recorded lease has expired and no matching live execution remains. -
Add execution identity fields. Store a cryptographically random
runId, start time, lease expiry, and optionally the runner PID. Require each runner to include the samerunIdwhen committingDONEorFAILED, rejecting stale updates from older processes. -
Use atomic state writes. Write updated JSON to a temporary file in the same directory, flush and synchronize it, and atomically replace the original state file with
os.replace. Combine this with locking because atomic replacement alone does not prevent lost updates. -
Use compare-and-set semantics. Under the lock, transition only
PENDINGtoIN_PROGRESS. If the observed state or run identifier changed after it was read, abort rather than launching another agent. -
Implement stale-run recovery carefully. A heartbeat should recover an
IN_PROGRESSstep only after a configurable timeout and verification that its lease is stale. Record the recovery reason and allocate a newrunId. -
Add concurrency tests. Launch multiple check processes against one state file and assert that the agent command runs exactly once, the JSON remains valid, retry counts are accurate, and stale runners cannot overwrite the active run's result.
-
