{"record":{"id":"d1d41d1b116f6290","repo":"abhigyanpatwari/GitNexus","slug":"plan-artifact-changed-while-opening-path","errorCode":null,"errorMessage":"plan artifact changed while opening: {path}","messagePattern":"plan artifact changed while opening: (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"eval/workflow_bench/runner_artifacts.py","lineNumber":276,"sourceCode":"    if not plans.exists():\n        return {}\n    if plans.is_symlink() or not plans.is_dir():\n        raise ValueError(f\"plan directory must be a real directory: {plans}\")\n\n    snapshot: dict[Path, str] = {}\n    for path in sorted(plans.iterdir()):\n        if path.suffix.lower() not in {\".md\", \".html\"}:\n            continue\n        metadata = path.lstat()\n        if stat.S_ISLNK(metadata.st_mode):\n            raise ValueError(f\"plan artifact cannot be a symlink: {path}\")\n        if not stat.S_ISREG(metadata.st_mode):\n            raise ValueError(f\"plan artifact must be a regular file: {path}\")\n        descriptor = os.open(path, os.O_RDONLY | getattr(os, \"O_NOFOLLOW\", 0))\n        try:\n            opened = os.fstat(descriptor)\n            if not stat.S_ISREG(opened.st_mode) or opened.st_dev != metadata.st_dev or opened.st_ino != metadata.st_ino:\n                raise ValueError(f\"plan artifact changed while opening: {path}\")\n            with os.fdopen(descriptor, \"rb\", closefd=False) as handle:\n                snapshot[path] = hashlib.file_digest(handle, \"sha256\").hexdigest()\n            after = os.fstat(descriptor)\n            if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):\n                raise ValueError(f\"plan artifact changed while hashing: {path}\")\n        finally:\n            os.close(descriptor)\n    return snapshot\n\n\ndef new_plan_doc(worktree: Path, before: dict[Path, str]) -> Path:\n    \"\"\"Return the sole new or modified plan, rejecting ambiguous evidence.\"\"\"\n\n    after = snapshot_plan_docs(worktree)\n    deleted = sorted(path for path in before if path not in after)\n    if deleted:\n        raise ValueError(\"planning deleted existing plan artifact(s): \" + \", \".join(str(path) for path in deleted))\n    changed = sorted(path for path, digest in after.items() if before.get(path) != digest)","sourceCodeStart":258,"sourceCodeEnd":294,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/d540b00184d71a896261ee02670da9a92d59d8f7/eval/workflow_bench/runner_artifacts.py#L258-L294","documentation":"Thrown by snapshot_plan_docs during its TOCTOU hardening of each plan artifact. The code lstat's the path, then re-opens it with O_NOFOLLOW and fstat's the resulting file descriptor; if the device id (st_dev) or inode (st_ino) differ between the two stats, the file at that path was swapped between the check and the open. The harness treats plan evidence as untrusted input, so a mismatch (race, symlink swap, concurrent rewrite) aborts the snapshot rather than hashing attacker-controlled bytes.","triggerScenarios":"A concurrent process replaces a docs/plans/*.md or *.html file in the small window between path.lstat() and os.fstat(descriptor). Concretely: metadata.st_dev != opened.st_dev or metadata.st_ino != opened.st_ino after os.open(path, O_RDONLY | O_NOFOLLOW).","commonSituations":"An agent under evaluation rewrites its plan file mid-snapshot; a filesystem (some network/FUSE mounts) that does not report stable inodes; two benchmark arms sharing a worktree; an external watcher regenerating plans on save.","solutions":["Stop any concurrent writer of docs/plans/ during the run; the harness snapshots serially and assumes quiescence.","Run on a local filesystem (ext4/xfs/apfs) that guarantees stable st_dev/st_ino across the open window.","If the swap is legitimate (the agent legitimately rewrites the plan), ensure the snapshot is taken only after the agent process has exited, not while it is still running.","Re-run the benchmark arm; transient races against a finishing agent usually clear once the agent is joined."],"exampleFix":"// before: snapshot while agent still running\nsnapshot = snapshot_plan_docs(worktree)  # agent may rewrite plan concurrently\n\n// after: join the agent first, then snapshot a quiescent tree\nagent_proc.wait()\nsnapshot = snapshot_plan_docs(worktree)","handlingStrategy":"validation","validationCode":"import os, stat\nfrom pathlib import Path\n\ndef is_quiescent(path: Path) -> bool:\n    \"\"\"True if the path's stat is stable across two reads (no concurrent writer).\"\"\"\n    a = path.lstat()\n    import time; time.sleep(0.01)\n    b = path.lstat()\n    return a.st_dev == b.st_dev and a.st_ino == b.st_ino and (a.st_size, a.st_mtime_ns) == (b.st_size, b.st_mtime_ns)","typeGuard":"from pathlib import Path\n\ndef is_safe_plan_path(path: Path) -> bool:\n    \"\"\"A plan path safe to snapshot: regular file, not a symlink.\"\"\"\n    import stat\n    try:\n        st = path.lstat()\n    except OSError:\n        return False\n    return stat.S_ISREG(st.st_mode)","tryCatchPattern":"from eval.workflow_bench.runner_artifacts import snapshot_plan_docs\ntry:\n    snap = snapshot_plan_docs(worktree)\nexcept ValueError as e:\n    if 'changed while opening' in str(e):\n        # TOCTOU race: re-run after confirming no writer is active\n        raise SystemExit('plan file swapped mid-snapshot; quiesce agents and retry')\n    raise","preventionTips":["Join the agent process before calling snapshot_plan_docs so no writer is live.","Keep the worktree on a local filesystem with stable inodes.","Never run two arms against the same worktree concurrently.","Treat these integrity ValueErrors as fatal signal, not retryable noise, unless the writer is confirmed stopped."],"tags":["toctou","plan-artifacts","filesystem","integrity","workflow-bench"],"backgroundTag":null,"analyzedSha":"d540b00184d71a896261ee02670da9a92d59d8f7","analyzedAt":"2026-08-12T19:50:25.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}