eos-bug-audit
Hunt correctness/concurrency bugs in EmptyOS the way an external auditor would — run the deterministic static scanners, triage findings with false-positive discipline, do a browser UI walk, fix at roo
SKILL.md.claude/skills/eos-bug-audit/SKILL.md
GitHubEmptyOS Bug Audit
Find and fix real correctness bugs — not style, not feature gaps. The two veins that pay out in this codebase (proven across a 19-fix audit run):
- Sync-in-async event-loop wedges — a blocking call (
subprocess/requests/urlopen/time.sleep/sqlite) on the event loop freezes the whole daemon for its duration. Caught the/topologygit freeze, capture/promote/drain stalls. - Vault read-modify-write races —
read(X)→ mutate →write(X)on a shared file with nowrite_locksilently drops a concurrent writer's change. Caught capture / canvas-node / expense-row data loss.
Everything else (contract drift, input-coercion 500s, swallowed exceptions) is mostly noise floor here — the codebase is well-validated. Don't manufacture fixes from it.
The loop (run phases in order; stop when the well is dry)
0. Read the prior backlog FIRST (loop memory — load-bearing)
This skill is built to be re-run (/loop, /schedule). Before scanning, read
data/audit-loop/backlog.md if it exists — it carries prior FIXED / WONTFIX /
DEFER findings + rejected false-positives. Two reasons it's load-bearing:
- Don't re-litigate settled FPs/deferrals. Wasted tokens, and the codebase converges fast — a prior run may have already swept everything, so a fresh cold scan mostly reproduces known-noise.
- Deferrals are where the next bug hides. The highest-yield move on a re-run
is to re-examine the prior run's "per-entity / low-concurrency" deferrals
against the actual write target — a shared file mislabelled "per-entity" is
exactly the missed-bug shape (the 2026-06-16 shared-inbox
add_task_to_projectrace was found this way, after a 21-iteration prior run had deferred it). - Run the partial-lock discriminator per APP DIRECTORY, never per file. The
discriminator: does this app have
write_lock/note_lockwriters of a note AND an unlocked writer of the same note? >0 locks + an unlocked writer = a real partial-lock bug (the lock is false safety). 0 locks anywhere = consistently unlocked per-entity = defer. Apps are multi-module now, so a per-file grep reports 0 locks for a sibling module and the file reads as benign — that is how the 2026-07-18 publish hole (scheduling.py locked; media.py's 4 writers unlocked, so a scheduled release'spublish: truewas silently clobbered and the post never went live) survived FIVE runs classified as safe. Count over the whole app dir:grep -rc "write_lock\|note_lock" apps/<track>/<app>/*.py.
If no backlog exists, this is a first run — proceed to phase 1.
1. Baseline (cheap, offline)
python -m pytest tests/ -k "sdk or unit" -q— pure-logic layer (~1900 tests).curl -s :9000/api/health+ verb-drift sweepGET /agent/api/verbs/sweep?load=1(needsAuthorization: Bearer <auth_token>fromemptyos.toml [network]).- HTTP page-load smoke: fetch every
/{app}/(GET :9000/api/appsfor the list), flag non-200 / Traceback bodies. 180+/181 should be clean.
2. Run the deterministic scanners (the durable, graduated checks)
python scripts/preflight.py --scope always,vault # runs the registry below
python scripts/check-asyncio-blocking.py # 4 rules; see blocking-in-async
python scripts/check-vault-rmw-race.py # shared-file RMW races
check-asyncio-blocking.pyrule blocking-in-async = direct + intra-file transitive blocking primitive reached from anasync def. ADVISORY.check-vault-rmw-race.py= read→write on the same path with no lock. Recognizeswrite_lock,note_lock, and any*_lockhelper that RETURNS one (apps wrap a canonical key that way —task._file_lockborrows the projects lock). Tolerance is FUNCTION-scoped, so an unlocked sibling of a locked writer is still flagged — do not widen it to file scope or the partial-lock vein goes dark. ADVISORY (per-entity files are benign; shared-file writers are the real ones). Both directions are pinned bytests/test_unit_check_vault_rmw_race.py.- Prefer
self.note_lock(path)for a vault note, notwrite_lock: it is kernel-wide (shared across app instances) and normalizes abs/rel spellings, so it excludes writerswrite_lockwould miss. Don't hand-roll a per-app lock helper for a note — that primitive already exists. - These graduated from one-off scans (audits.md). To add a class, extend the scanner, don't re-roll a throwaway.
3. Triage — FALSE-POSITIVE DISCIPLINE (the load-bearing step)
A prior agent reported 5 settings + 30 CLI + 376 coercion = 37 false positives. Before fixing ANYTHING:
- Verify both sides yourself against committed
HEAD(git show HEAD:<file>), never trust a sub-agent's grep. (audits.md) - Run the heuristic against 3 known-healthy targets (hub/task/journal). Anything that fires on healthy code is noise — tune or drop it.
- Skip files with uncommitted edits (
git status --short) — they're likely a parallel session's in-progress work; editing them collides. Audit committed code. - Severity = does it hurt in NORMAL use? all-users-every-action (capture, /topology) >> explicit-dev-operation, low-concurrency (fix-agent, model-bench). Document-and-defer the latter; don't mass-edit working dev tools for marginal gain.
4. Fix at root cause (debugging.md)
- Wedge fix:
x = await asyncio.to_thread(blocking_fn, ...). For a sync helper chain, wrap at the async caller (or add anasync def *_asyncwrapper). - Race fix:
async with self.write_lock(f"<ns>:{key}"):around read→mutate→write; emit stays OUTSIDE the lock (handler recursion would deadlock). Reference: journal_daily_lock, quick-action capture lock. - DEADLOCK CHECK before locking: a lock is only safe if the locked methods
don't call each other with the same key (
asyncio.Lockis not reentrant). expenseset_fieldcalled the lockedadd()→ had to inline a non-locking leaf (_append_row) instead. Verify the call graph first. - Write a failing test first where practical (
tests/test_unit_*.py), then fix.
5. Verify
- Offline:
python -m py_compile <file>+ the new/existing unit tests. - Live (for kernel/runtime/core fixes): lease a sandbox, never touch :9000.
POST :9000/sandbox/api/lease→ it boots fresh from the working tree (picks up edits) → probe<host>→DELETE .../lease/{id}. For a race fix, fire ~10 concurrent writes and confirm all persisted. For a wedge fix, run the heavy op and confirm/api/healthstays responsive (sub-second) concurrently — that is the proof the loop didn't freeze. See.claude/rules/sandbox-driven-testing.md.
6. Record
Keep a running backlog (e.g. data/audit-loop/backlog.md): findings, status
(NEW/FIXED/WONTFIX/DEFER), verification, and FPs rejected (so the next run doesn't
re-litigate them — this is what phase 0 reads). Append a new dated section per
run; never overwrite — the carried history is what makes the loop compound
instead of restarting from a cold scan each time.
Hard rules
- Never restart/kill :9000 or :9001, never touch
data/*.db(daemon-handling.md). Verify Python fixes on a leased sandbox; ask the user torestart.batfor :9000. - Never
git add -A/ commit unless asked — keep audit fixes separate from any parallel-session changes. When you do commit, commit by explicit pathspec (git commit -- <paths>), never a baregit commiteven aftergit add <paths>: a parallel session can stage its own files between youraddandcommit, and a pathspec-less commit sweeps them in. Verifygit show HEAD --statlists only your files afterward; if not,git reset --soft HEAD~1and re-commit with the pathspec. - Don't write test fixtures into the real vault — concurrency/write tests run on the sandbox (its own throwaway vault), not :9000.
Cross-references
scripts/check-asyncio-blocking.py,scripts/check-vault-rmw-race.py,scripts/check_call_app_declared.py— the graduated scanners (inscripts/preflight.py)..claude/rules/audits.md— FP discipline + graduation;.claude/rules/debugging.md— root-cause + the async-wedge catalog;.claude/rules/sandbox-driven-testing.md— the lease/verify loop;.claude/rules/daemon-handling.md— hands off :9000.- CLAUDE.md § Development Gotchas — vault RMW race; § async-wedge catalog.