diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1872cb..1aca6b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: - os: macos-latest python: "3.12" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - run: python -m pip install --upgrade pip pytest @@ -43,31 +43,45 @@ jobs: # executed on Windows: the parsers were tested against captured output and the # registry plumbing against an injected fake `winreg`. This job runs the real # thing on a real Windows kernel. + # + # Process spawning is far more expensive here than on POSIX, which is why + # Windows is always the last leg to report and why work gets called done + # while it is still running. + # + # The job cap was cut 60->30 once on a guess ("observed healthy ~13 min") + # that turned out to be stale — the suite had grown substantially since that + # number was taken and nobody re-measured. The very next run proved it wrong: + # py3.12's Test suite step alone measured 1206s (20:06) and got cut off + # mid-report by a 20-minute STEP cap sized on the same stale guess, turning a + # clean pass (427 passed, 145 skipped, zero failures) into a reported failure + # via KeyboardInterrupt. + # + # And it kept growing: measured 2026-08-23, Test suite 1424-1467s (23:44 / + # 24:27) against that 35-minute step cap. Nothing announced the growth — the + # comment asking the next person to "re-measure rather than nudge the + # constant" is not a mechanism, and the only signal a cap is outgrown is a + # KeyboardInterrupt that reads exactly like a test failure. + # + # `--durations=25` is NOT a fix for that. Be precise about what it buys, + # because crediting it with more is how the next person skips building the + # real signal: it publishes the slowest CASES, not the step's total against + # its cap, and it prints only when pytest finishes — so on the one run that + # actually hits the cap it prints nothing at all. What it does buy is a + # per-body cost profile that did not exist before. Its first run already paid + # for itself: the top two cases measured 189.41s and 188.25s here against + # 35.48s and 35.74s on macOS, a uniform ~5.2x that makes the (cheap, local) + # macOS profile a usable model for this (expensive, remote) one. windows: name: windows-latest / py${{ matrix.python }} runs-on: windows-latest - # Process spawning is far more expensive here than on POSIX, and the live - # harness additionally runs two full scans against the real machine (which - # classify real binaries through real PowerShell). - # - # This value was cut 60->30 once on a guess ("observed healthy ~13 min") - # that turned out to be stale — the suite had grown substantially since - # that number was taken and nobody re-measured. The very next run proved - # it wrong: py3.12's Test suite step alone measured 1206s (20:06) and got - # cut off mid-report by a 20-minute STEP cap sized on the same stale - # guess, turning a clean pass (427 passed, 145 skipped, zero failures) into - # a reported failure via KeyboardInterrupt. Re-measured from that run's own - # logs: Test suite ~1176-1206s, harness ~681-767s. Sized below with real - # headroom over MEASURED numbers, not a guess — and if this suite keeps - # growing, re-measure again rather than nudge the constant. timeout-minutes: 45 strategy: fail-fast: false matrix: python: ["3.9", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - run: python -m pip install --upgrade pip pytest @@ -79,16 +93,46 @@ jobs: # KeyboardInterrupt failure four seconds past an undersized cap. - name: Test suite timeout-minutes: 35 - run: python -m pytest tests/ -q + run: python -m pytest tests/ -q --durations=25 - name: selftest.py (real detection logic on this machine) timeout-minutes: 5 run: python selftest.py + + # The live harness is its own JOB, not a step of the one above. + # + # It was a trailing step guarded by `if: always()` — the guard existed + # because this harness is the only evidence the Windows code works on + # Windows, so a suite failure must not hide it. But a step cannot start + # before the 24-minute suite ahead of it finishes, so its measured ~12.6 min + # was pure addition: 37-39 minutes before Windows said anything at all. + # + # As a job it runs CONCURRENTLY with the suite on a second runner (free for + # public repos), which cuts the Windows critical path to roughly max(25, 13) + # instead of their sum, and gets the `always()` semantics structurally rather + # than by a guard — a failing suite cannot delay or mask it, because they no + # longer share a runner. It needs no pytest: the harness is stdlib + aegis. + windows-live: + name: windows-live / py${{ matrix.python }} + runs-on: windows-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + # Measured in THIS position on the first run after the split (2026-08-24): + # 625s on py3.9 and 816s on py3.12. That 816s is outside the 681-767s + # range this step used to be sized against — those numbers were taken + # while it ran third, behind a 24-minute suite, on a warm runner. Sizing + # a cap from measurements taken in a different position is the exact + # mistake this file has already made twice, so the cap stays at 25 and + # the new numbers are recorded here instead of being trimmed toward. - name: Live Windows harness (real registry, schtasks, CIM, Authenticode) timeout-minutes: 25 - # `always()`: this harness is the only evidence that the Windows code - # works on Windows, so a suite failure must not hide it. It reports its - # own pass/fail and still fails the job on its own terms. - if: always() env: AEGIS_WIN_LIVE: "1" run: python tests/win_live_harness.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 80df561..052e7d1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ Three rules keep this honest rather than merely portable: | Pre-commit | Latched persistence surfaces (`chflags uchg` / deny-write ACE) and FIFO credential decoys, both placed **before** any attack | Makes the attacker's write fail rather than reporting it afterwards; a cleared latch or a read decoy is attack-defined evidence | | Contain | Manual process action, **reversible freeze**, and transactional file/app quarantine | Stops a reviewed threat while retaining reversible evidence | | Prove detection | Positive-control assay per detector, with an efficacy half-life; every lane asserts **both** poles, and the delegate/session tier is covered too | Distinguishes "nothing found" from "no longer able to find"; unproven coverage is reported as unproven. A hostile-pole-only lane passes against a detector hardwired to say yes, and a benign-pole-only lane passes against a dead one — so a lane that checks one pole proves nothing | -| Delegate-surface | Agent config discovered by **shape** (a `command`+`args` pair under an agent directory), hashed by **resolved target** rather than by config line, plus a semantic imperative detector for instruction files and git-derived provenance for each added line | An AI agent runs with the operator's full authority and takes instruction from files; an MCP registration is exec-on-start, a hook body is exec-per-tool-call, and a natural-language imperative is an execution primitive with no shell syntax for any grammar to match | +| Delegate-surface | Agent config discovered by **shape** (a `command`+`args` pair under an agent directory), hashed by **resolved target** rather than by config line, plus a semantic imperative detector for instruction files and a **chain-of-custody grade** for each structural change (signed intent ledger, then git provenance, then signer stability) | An AI agent runs with the operator's full authority and takes instruction from files; an MCP registration is exec-on-start, a hook body is exec-per-tool-call, and a natural-language imperative is an execution primitive with no shell syntax for any grammar to match | | Session | Browser automation aimed at the **live** profile (debug port, sideloaded extension, real `--user-data-dir`), plus session-binding posture | Post-App-Bound-Encryption, cookie theft is the browser being driven against itself rather than a jar being copied; live cookies defeat MFA and their revocation belongs to the counterparty | | Recover-plan | Dependency-ordered revocation derived from the credential artifacts actually present on disk | The question after a theft is not "what happened" but "which accounts are theirs, in what order do I take them back" — and rotating in the wrong order hands over the reset link | | Witness | Hash-chained state anchored into the OS's root-owned log store | An attacker who tampers, or who stops the monitor, cannot do so silently | @@ -198,11 +198,346 @@ guards because they answer the same adversarial pressures: footer on the active listing, and one `reopen` both re-alerts and revokes the tolerance (reopening deletes the dismissal rows the count was built on). +### Chain of custody (all sensors) + +The dominant benign churn on the delegate surface is not vendor updates — it is +the operator's *own* agent tooling registering hooks, MCP servers, and skills, +often through the operator's own git remote. Provenance that only asks "is this +commit on a remote?" labels all of it with the poisoned-repo warning, and nine +self-inflicted HIGHs in one day is how the one foreign HIGH eventually gets +dismissed unread. Custody grading answers the question that actually +discriminates: **can this machine claim authorship of this change?** + +Custody began on the delegate surface and now grades every sensor. Two +families of rung, in order; the first that vouches sets the grade. The +**authorship** rungs (1-4) answer *did this machine make this change?*; the +**origin** rungs (5-7) answer the weaker but far more common question *did +this arrive through something the operator set up?* + +1. **Signed intent ledger** (`~/.aegis/intent.jsonl`). The agent harness calls + `aegis.py intent hook ` after each file-writing tool call; Aegis + appends one HMAC'd `{ts, path, sha256, tool}` record. A change whose content + hash matches a valid record is `self-attested` → **LOW**. This covers what + git cannot: untracked files and binaries outside any repo. +2. **Git self-vs-foreign**. A commit is `self-committed` → **LOW** only when two + independent records agree: its author email equals the repo's configured + `user.email`, *and* the HEAD reflog remembers it being **created** here (a + local commit enters the reflog as `commit:`; a pulled one as + `pull:`/`merge:`/`clone:`, never `commit:`). Reachable from a remote with no + local authorship record is `remote-foreign` → **HIGH** with the + poisoned-repo warning — pushing your own commit does not make it foreign, + and pulling someone else's never becomes yours. +3. **Fleet signature** (the multi-device rung). A commit that arrived from + elsewhere but carries an SSH signature verifying against the **pinned** + device roster (`~/.aegis/allowed_signers`) is `fleet-signed` → **LOW**: it + was made on one of the operator's own machines, and a signature is the one + custody evidence that survives transport. The roster is written only by the + explicit `signers pin` command — a roster synced or tracked through the repo + itself is merely the *source* the operator pins from, so a poisoned remote + that adds an attacker key to the tracked copy changes nothing until a human + re-pins. Verification is asymmetric: this machine holds nothing that can + *make* a trusted signature, only what checks one. Only an exact `G` verdict + vouches; unsigned, bad, unknown-key, expired, and error are all non-matches. +4. **Signer stability** (changed-target findings only). A resolved target + re-signed by the **same team** that signed its baselined content is the + exact shape of a vendor updating its own binary → **MEDIUM** (recorded, can + corroborate, opens no incident alone). The team is captured at snapshot + time, so an old baseline without one fails toward HIGH, never toward quiet. + +The origin rungs exist because the delegate surface was never where the volume +was. `persistence.diff`, `process`, `net-listener`, `net-outbound` and +`net-beacon` scored on code signature plus path writability alone — two axes on +which a Homebrew daemon, a VSCode extension helper and a dropped payload are +indistinguishable, because ad-hoc signing in a user-writable path describes all +three. A single directory migration could therefore produce sixty HIGHs beside +a genuine intrusion. These rungs demote **one step only** (never to LOW, with +the one exception noted): origin is not authorship, and a package can be +malicious, a publisher can ship a bad build, a stolen certificate signs cleanly. + +5. **Relocated** (changed persistence item) → **LOW**, the one origin rung that + goes that far, because it is a proof about *content* rather than about + provenance: the program bytes and the payload script's own hash are both + byte-identical to the baseline and only the directory changed. Nothing new + executes, so there is nothing to grade. It requires proof on **both** halves + of what a job runs — requiring only the program hash would be worthless for + the dominant `