feat(aegis): chain-of-custody grading + the noise-reduction tier - #8
Open
opencdlee-dotcom wants to merge 12 commits into
Open
feat(aegis): chain-of-custody grading + the noise-reduction tier#8opencdlee-dotcom wants to merge 12 commits into
opencdlee-dotcom wants to merge 12 commits into
Conversation
Nine HIGH incidents opened in one day on the author's machine; every one was the operator's own agent tooling arriving through the operator's own git remote, stamped with the poisoned-repo warning because provenance only asked "is this commit on a remote?" and never "did this machine create it?". Self-inflicted alert fatigue is how the one foreign HIGH eventually gets dismissed unread. Custody grading answers the discriminating question — can this machine claim authorship? — with three rungs, first vouch wins: 1. Signed intent ledger (~/.aegis/intent.jsonl): the agent harness calls `aegis.py intent hook <tool>` after each file-writing tool call; a change whose content hash matches a valid HMAC'd record grades self-attested -> LOW. Covers what git cannot (untracked files, binaries outside any repo). 2. Git self-vs-foreign: self-committed -> LOW only when author email matches the repo's user.email AND the HEAD reflog remembers the commit being CREATED here (`commit:`, never `pull:`/`clone:`). Remote-reachable without local authorship stays the poisoned-repo HIGH. 3. Signer stability: a changed target re-signed by the same team as its baseline (recorded at snapshot time) is vendor-updater shape -> MEDIUM. Guards: grades never mute (finding still recorded, still accumulates risk, writes no dismissal, cannot feed tolerance); attack-defined content (conceal imperatives) never downgrades even when self-attested; every rung fails toward suspicion (bad MAC, expired reflog, identity mismatch, absent signer -> HIGH). Forgeability is stated, not hidden: the ledger and reflog are same-uid-writable, which is the wrong threat for a surface that exists to catch hostile instructions at ARRIVAL — before the attacker has local execution, the only moment forging is impossible. New: `intent record|hook|list` CLI; provenance values self-attested/ self-committed/remote-foreign; target_team/target_trust in the agent snapshot; tests/test_custody.py (14 tests: created-here vs pulled, identity mismatch never vouches, MAC tamper is a non-match, fingerprint stability across grades, conceal never downgrades, signer ladder, ledger prune). Suite: 893 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…signatures A commit made on another of the operator's own machines arrives here by fetch: no local reflog, no local authorship, so custody v1 honestly graded it remote-foreign — one HIGH per structural change per receiving device. Signatures are the custody evidence that survives transport. New rung between self-committed and remote-foreign: a commit whose SSH signature verifies (exact %G? == G) against the PINNED device roster (~/.aegis/allowed_signers) grades fleet-signed -> LOW. The roster is written only by the explicit `signers pin` command; the repo-tracked copy devices sync around is merely the source a human pins from, so a poisoned remote that adds an attacker key to the tracked roster changes nothing here until the operator re-pins. Verification is asymmetric: the receiving machine holds nothing that can MAKE a trusted signature, only what checks one. Unsigned, bad, unknown-key, expired, error: all non-matches, all HIGH. New: `signers pin <file>` / `signers status`; provenance value fleet-signed (joins _SELF_CUSTODY); two tests (pulled+signed grades fleet-signed and reverts to remote-foreign without the pin; a wrong key or unsigned arrival stays foreign). Suite: 895 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner
Author
|
Added in 9eba4ec — the multi-device rung (per the cross-session design review): Cross-device custody now rides SSH commit signatures instead of an HMAC keyring — asymmetric, enforced by git itself, and the receiving machine holds nothing that can make a trusted signature:
🤖 Generated with Claude Code |
…gling-HEAD fallback The custody fixtures inited bare origins without -b main and inherited the developer's global git config. Locally (git 2.50, defaultBranch=main via global config) every clone checked out; on ubuntu/windows runners the bare HEAD pointed at nonexistent master, clones came out EMPTY, and the three victim-clone tests read provenance None. Bare init now pins -b main and all direct init/clone subprocess calls run under a cleaned env, so the fixture behaves identically on every machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the interpreter
Custody grading existed and answered exactly the right question — "can this
machine claim authorship?" — but only diff_agent_surface() ever called it.
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.
Measured on this machine before the change: 78 HIGH findings, of which ~60 were
one directory migration, nine were Microsoft/Zoom shipping ordinary
auto-updates, and both CRITICAL correlation chains were a single Homebrew
Syncthing install.
Three origin rungs, grounded in evidence already on disk, no new collection and
no network. They demote ONE step (origin is not authorship) except `relocated`,
which is a proof about content rather than provenance:
relocated program bytes AND payload hash byte-identical, only the
directory moved -> LOW
publisher-stable binary changed in place, same signing authority as its
baseline -> MEDIUM (the vendor-updater shape)
package-managed binary owned by a package-manager transaction, proven by
its RECEIPT (Homebrew INSTALL_RECEIPT.json / editor
extensions.json / pipx_metadata.json) -> MEDIUM
Never a path prefix: "/opt/homebrew/..." as a trust rule would vouch for
anything dropped into a directory the user can write to, which is precisely the
file being graded. A hand-installed binary has no receipt and keeps its
severity — the unpacked CI runner here correctly stayed HIGH.
Also fixes two weak git rungs the ladder named and then ignored: `worktree` and
`local-commit` printed "routine if you made it" while the finding stayed HIGH.
One step down now, not to LOW — an uncommitted local edit is also what a local
attacker's change looks like.
Closing the blind spot the relocation rung forced open:
A launchd job or systemd unit is overwhelmingly `<interpreter> <script>`, and
the snapshot recorded only `program` — hashing /bin/bash and saying nothing
about the file carrying the behaviour. Rewriting that script left program, args
and env identical and check_persistence emitted NO FINDING AT ALL. Snapshots now
carry script_target/target_sha via the shared record helper (so launchd, systemd
and Run keys all inherit it), a change in that hash is reported and rated with a
swapped binary, and requiring the hash on BOTH sides is what separates a genuine
relocation from a substitution — and stops an old baseline rolling forward from
alerting on every job at once.
Invariants held and now pinned by tests: grading demotes and never suppresses
(findings went 80 -> 98 on the real machine, redistributed downward, none
dropped); no rung can raise severity; attack-defined evidence is never demoted,
whoever authored it — dylib injection and hostile argv survive a perfect
relocation, and the curl|bash shell-history entries stayed HIGH throughout.
Measured after: HIGH 78 -> 25 in the report header; 38 -> 18 by per-sensor
count. Remaining persistence HIGHs are migration items whose baseline predates
payload hashing, which the rung refuses to grade rather than guess at.
22 new tests, each verified to FAIL against the pre-change file.
918 passed / 4 skipped; selftest green; assay 19/19.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`aegis.py baseline` crashed with `TypeError: Object of type object is not JSON serializable` and reset nothing at all. SURFACE_PRIVILEGED is a bare object() sentinel meaning "permanent, OS-imposed coverage gap" — deliberately NOT None, so a privilege wall can be told apart from a transient non-answer (the two need opposite health handling: one is a named permanent condition, the other escalates to coverage-degraded after three misses). The scan path tests for it by identity and treats it as a non-answer. _cmd_baseline_locked only skipped None. Being truthy and `is not None`, the sentinel went straight into the baseline dict and killed json.dump — so on macOS 26, which moved `sfltool dumpbtm` behind system.privilege.admin, the entire baseline command was dead. Not degraded, not partial: no baseline reset possible on an affected machine, which is also the machine most likely to need one after a migration. Pre-existing; surfaced by running `baseline` to clear migration churn now that persistence snapshots carry payload hashes. Fix: omit a privileged surface exactly as a None one is omitted, matching the scan path's contract that it is never diffed and never adopted. Regression test asserts both halves — that the sentinel is truthy and unserializable (so `if snap:` remains a trap for the next consumer), and that baseline omits it while still writing the surfaces that answered. Verified on the affected machine: baseline now resets 73 persistence items + 15 surfaces, btm correctly absent, 30 payload hashes captured. 920 passed / 4 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sensor
`sfltool dumpbtm` needs interactive admin authorization on macOS 26, and the
SAME OS condition reached the sensor two different ways depending on timing:
* prompt auto-cancelled fast -> stderr carries "authorization failed" -> the
marker matches -> SURFACE_PRIVILEGED -> a named permanent gap, no incident;
* prompt left sitting -> the command blocks to the 30s timeout -> stderr is
EMPTY -> no marker -> None -> DEGRADED -> after three consecutive misses, a
HIGH "Security coverage degraded" incident.
So a machine whose surface is permanently walled off intermittently opened HIGH
incidents about it. Not hypothetical: #246, #258 and #279 on this machine, each
opened and then auto-resolved when a later scan happened to catch the marker.
The sensor's own docstring already states the governing fact — the refusal
"will fail identically on every scan this OS ever runs" — which is exactly what
makes ONE observation sufficient to classify later non-answers from the same
command. A new surface_walls.json records a proven wall; a non-answer from a
surface with a proven wall is that wall.
Fail-toward-suspicion is preserved at both ends: a machine that has NEVER proven
a wall still degrades on a non-answer (the coverage alarm still exists for
sensors that should be answering), and any SUCCESS clears the memory, so a
failure after the wall comes down is treated as new rather than silently
absorbed. The memory only ever moves a sensor-HEALTH verdict; it cannot suppress
a finding, because a walled surface is not diffed at all.
Verified live on the affected machine: the 30s-timeout path reproduced, then
classified PRIVILEGED on every subsequent scan, and #279 closed with the honest
privileged-only resolution.
Two test-quality fixes this exposed, both real:
* TestSnapshotBtmOutcomes patched only aegis.run, so it read the REAL
~/.aegis state — its verdict depended on whether the developer's own Mac
happened to be walled. Now isolated, which is also what makes its
"generic failure still degrades" case mean what it says.
* SURFACE_WALLS registered in the regression Sandbox, per the existing
guard that every module-level ~/.aegis path must be redirected in tests.
Also: uv-managed interpreters get a package receipt.
uv ships its own Pythons under <data>/uv/python/<dist>/ with a BUILD stamp it
writes on install. Without that probe every script run by a uv-managed
interpreter scored as an unvouched ad-hoc binary in a user-writable path — on a
Python developer's machine, most of them. Proven by the receipt, never by the
path, same as the other three.
927 passed / 4 skipped; selftest green; assay 19/19.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on this machine before this change: 281 incidents lifetime, 131 adjudicated FALSE_POSITIVE, 129 still OPEN, and no true positive that a test fixture had not planted. 1,336 findings, 53 of them in one day. The loudest recurring HIGH was Aegis alerting on its own upgrade. That is the failure that silences every future alert, so five mechanisms address it — and each one that suppresses something proves what it still says. Exec identity is what runs, not where it sits. An exec-capable config entry was keyed on its position (hooks.SessionStart[4].hooks[0]), so inserting one hook renumbered every later sibling and re-alerted the whole list: 55 of 67 un-generalizable open incidents were that cascade. Key on command+args instead; normalize BOTH sides of the diff so an upgrade from a legacy baseline is silent rather than presenting every baselined entry as new. A new command still fires. Rotating endpoints generalize only on evidence. A beacon's address is a fact and a new endpoint alerts, but a load-balanced service re-opens forever. The endpoint class factors the address out of binary+port, and is only usable once the operator has dismissed 3 DISTINCT addresses on it. Hostnames never generalize; attack-defined prefixes never do. Aegis's own upgrade is attested, not exempted. `install` writes one ordinary intent record for the runtime copy, so the same custody ladder every other surface uses grades it (HIGH -> LOW, still reported). A payload swapped by anything that did not come through `install` records nothing and stays HIGH. Persistence changes now also grade the PAYLOAD against the ledger, not only the config file naming it -- the dominant `<interpreter> <script>` job mutates by having its script rewritten. Incidents age out. An OPEN signal/risk incident with no new evidence in 7d closes as ambient, reopened by recurrence. Never CRITICAL, never a correlation chain, never attack-defined -- a quiet week is not an acquittal for a tripped decoy. Machine verdicts write no dismissals row. The report leads with a verdict. 208 lines and ninety red bullets -> 15 lines: one verdict, what is new, open CRITICALs, degraded coverage. Full detail moves to `report --full`, rendered from the same latest.json. And because the report is a summariser, it asserts its headline against the findings it summarizes every run and PUBLISHES the result -- a contradiction prints at the TOP, above the evidence. That is not decorative: it caught a real defect in the verdict logic on its first run against live data, a green "Nothing new" printed over two open CRITICAL chains. An open CRITICAL now outranks both a quiet scan and the learning period. Also adds the learning period (`aegis.py learn`): a fresh install records and correlates everything for 14d but opens non-CRITICAL signals pre-closed instead of alerting, because a detector's first weeks are its worst -- every ordinary thing it has not yet seen is new by construction. Measured on the live store: open incidents 129 -> 55, un-generalizable open 67 -> 6, positional-exec incidents 55 -> 0, report 208 -> 15 lines (27,202 -> 710 bytes). Suite 953 passed / 0 failed, +28 new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught in production, by the sensor doing its job: running `aegis.py learn` opened a HIGH incident, "Aegis baseline modified out-of-band" (#282 on the reference machine). The detection was right. `_set_learning_period` writes baseline.json, which is a watched trust store, and it wrote it outside the scan path — so the next scan saw an unexplained change to the file that decides what counts as known-good. That is exactly what the sensor exists to catch, and it must keep catching it. What was missing is the other half of the contract every other out-of-band writer here already honours: re-watermark what you just wrote. `_record_baseline_watermark` mirrors `_record_canary_watermark`, including its deliberate narrowness — only the baseline keys are refreshed, because rewriting the whole selfstate would silently re-bless a tampered allowlist or canary record at the same time. An unexplained baseline edit still alerts. Only Aegis's own documented command is explained. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TL;DR still ended at dismissal weighting and per-sensor tuning queues. It said nothing about the tier that actually made the output readable: the learning period, command-based exec identity, incident age-out, the attested self-upgrade, or the verdict-first report and its runtime self-check. A README that describes a noisier tool than the one shipping is a defect in the part users read first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dicts
Caught by checking whether six human benign-positive verdicts on Syncthing
actually taught the mechanism built to consume them. They taught nothing, for
two independent reasons.
IPv6 was mis-parsed. A beacon fingerprint is `beacon:<path>:<ip>:<port>` and an
IPv6 address contains colons, so splitting on ':' and taking [-2] folded
`fd7a:115c:a1e0::` into the PATH and read the empty tail as the address. Every
IPv6 beacon was structurally un-generalizable, and the endpoint class was
polluted with the address it exists to factor out. Parse from the right against
an anchored pattern instead.
And the port made the whole mechanism useless for peer-to-peer software. A
fixed-port class fits a CDN or an update channel, but a P2P client varies
address AND port together by design: Syncthing's dismissed endpoints here
spanned five addresses across five ports, so no class could reach the
three-address threshold.
So there are now two widths, earned by different evidence:
<path>:#ip:<port> a service on a fixed port answering from rotating
addresses -> 3 distinct addresses
<path>:#ip:#port a peer-to-peer client -> 3 distinct address:port pairs
spanning >=2 distinct ports
The second is strictly harder to earn — it demands breadth in two dimensions,
so it cannot be reached by a program that merely moved once. Everything else
holds unchanged: never CRITICAL, never above the reviewed severity, never a
disputed identity, never attack-defined, and hostnames never generalize.
Verified against the live dismissal ledger: Syncthing now earns the
port-agnostic class on 6 verdicts, while claude-code correctly keeps the
narrower fixed-port one. Six new tests pin both defects, including the exact
IPv6 fingerprint that exposed the parse bug. Suite 961 passed / 0 failed, and
34/34 of the noise-reduction tests also pass on Python 3.9.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Battle-tested six reproducible defects across endpoint parsing, partial process coverage, repeated state reads, and the human response boundary. Adds fail-before regressions and a durable authorization-channel audit that fails closed before mutation. Verified: 968 passed, 4 skipped, 11 subtests; selftest 7/7; two consecutive dry defensive-duel rounds. Co-Authored-By: OpenAI Codex <noreply@openai.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Nine HIGH incidents in one day, all of them the operator's own agent tooling (mnemosyne hooks, claude-go, improver, ai-sync, tldr) arriving through the operator's own git remote — each stamped "This is the poisoned-repo case" because
_git_provenanceonly asked is this commit reachable from a remote? and never did this machine create it?. Self-inflicted alert fatigue trains the operator to dismiss the one HIGH that will someday be real.Solution: chain of custody
Grade every structural delegate-surface change by whether this machine can claim authorship. Three rungs, first vouch wins:
{ts, path, sha256, tool}record written by a harness post-write hook matches the change's content hashself-attested→ LOWuser.emailand HEAD reflog recorded the commit being created here (commit:, neverpull:/clone:)self-committed→ LOW; remote-reachable without local authorship →remote-foreign→ HIGH (poisoned-repo warning kept)target_teamnow captured at snapshot time)Guards
New surface
aegis.py intent record <path> [tool]/intent hook <tool>(stdin JSON, always exit 0) /intent list [n]self-attested,self-committed,remote-foreign(replacesremote)tests/test_custody.py: 14 tests — created-here vs pulled-in, identity mismatch never vouches, MAC tamper is a non-match, fingerprint stability across grades, conceal never downgrades, signer ladder, ledger prune, hook mode never fails.Verification
INTENT_FILEneeding registration — added)._git_provenance(~/.claude/settings.json)→self-committed. All seven false-alarm entries would have graded LOW; the ChatGPTnode_replrewrite grades MEDIUM once its team is baselined; a genuinely pulled foreign hook still opens the HIGH.🤖 Generated with Claude Code