Skip to content

Python 3.10: workbench timestamps fail to parse, so remediation claim leases never expire #35

Description

@mariohercules

Summary

On Python 3.10, workbench_db.py cannot parse the timestamps it writes itself, so remediation claim leases never expire and abandoned claims permanently block a finding.

Every workbench timestamp is written with a Z suffix (workbench_db.py line 122):

def now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

and read back with a bare datetime.fromisoformat (lines 132-144):

def remediation_claim_is_active(remediation: sqlite3.Row) -> bool:
    ...
    try:
        parsed = datetime.fromisoformat(claimed_at)
        if parsed.tzinfo is None:
            return True
    except ValueError:
        return True

datetime.fromisoformat only accepts the Z suffix from Python 3.11 onward. On 3.10 the call raises ValueError, the handler returns True, and the lease check becomes unconditional: a claim is always reported as active regardless of CLAIM_LEASE_SECONDS or DELIVERED_ACTION_LEASE_SECONDS.

The package supports Python 3.10 (README.md states 3.10 or later, and the interpreter probe in src/runtime.ts enforces >= 3.10), so this is a supported configuration.

The rest of the codebase already handles this correctly — finalize_scan_contract.py line 649 normalizes the suffix before parsing:

datetime.fromisoformat(value[:-1] + "+00:00" if value[-1] in "Zz" else value)

This call site was missed.

Affected version and environment

  • Released package: @openai/codex-security@0.1.1
  • Confirmed on current main at f22d4a36f26d16287bcdfd707b369116e02a08c3
  • macOS 26.5.2 (build 25F84)
  • Python 3.10.20 (affected) and Python 3.12.13 (not affected)

Steps to reproduce

Using the real workbench_db.py, with a claim far beyond the 120-second lease:

import sys
from datetime import datetime, timedelta, timezone

sys.path.insert(0, "sdk/typescript/_bundled_plugin/scripts")
import workbench_db as w

old = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat().replace("+00:00", "Z")

row = {
    "pending_action_claim_token": "tok_abandoned",
    "pending_action_delivered_at": None,
    "pending_action_claimed_at": old,
}

print(sys.version_info[:2], w.CLAIM_LEASE_SECONDS, w.remediation_claim_is_active(row))

Observed result:

python 3.10  CLAIM_LEASE_SECONDS=120  claimed_at=2026-06-29T01:32:39.732153Z (30 days old)
  remediation_claim_is_active -> True   BUG: stale claim reported as active

python 3.12  CLAIM_LEASE_SECONDS=120  claimed_at=2026-06-29T01:32:39.796613Z (30 days old)
  remediation_claim_is_active -> False   correct: lease expired

(On 3.10 this requires tomli, as documented, because workbench_db.py transitively imports deep_scan_config.)

The underlying parse failure in isolation:

python 3.10  stamp=2026-07-29T01:32:09.047781Z  -> ValueError: Invalid isoformat string: '2026-07-29T01:32:09.047781Z'
python 3.12  stamp=2026-07-29T01:32:09.072262Z  -> PARSED OK

Expected behavior

A remediation claim older than its lease should be treated as expired on every supported interpreter, so the stale-claim recovery paths work.

Actual behavior

On Python 3.10 the claim is always active. Both recovery paths that depend on lease expiry become unreachable:

  • Closing the finding is refused with "Wait for the pending remediation operation to finish before closing this finding." (line 1878)
  • Regenerating the remediation is refused with "Finish or retry the active remediation operation before regenerating." (line 1987)

Both guards are written so that a failed state plus an expired lease is the escape hatch, which is exactly the condition that can never become true here.

Impact

If a remediation worker dies while holding a claim, pending_action_claim_token stays set and the lease can never clear it. On Python 3.10 the finding is permanently stuck: it can be neither closed nor regenerated, and the only remaining path is manual database intervention.

Note that the SQL-based staleness checks elsewhere in the file compare timestamps as strings and are unaffected, so the two mechanisms disagree with each other on 3.10.

Suggested direction

Normalize the suffix before parsing at this call site, matching what finalize_scan_contract.py already does. It may be worth factoring that normalization into a single shared helper so a third call site cannot drift again.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions