Skip to content

feat(aegis): chain-of-custody grading + the noise-reduction tier - #8

Open
opencdlee-dotcom wants to merge 22 commits into
mainfrom
feat/custody-grading
Open

feat(aegis): chain-of-custody grading + the noise-reduction tier#8
opencdlee-dotcom wants to merge 22 commits into
mainfrom
feat/custody-grading

Conversation

@opencdlee-dotcom

Copy link
Copy Markdown
Owner

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_provenance only 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:

Rung Evidence Grade
Intent ledger HMAC'd {ts, path, sha256, tool} record written by a harness post-write hook matches the change's content hash self-attested → LOW
Git self-vs-foreign Author email == repo user.email and HEAD reflog recorded the commit being created here (commit:, never pull:/clone:) self-committed → LOW; remote-reachable without local authorship → remote-foreign → HIGH (poisoned-repo warning kept)
Signer stability Changed target re-signed by the same team as its baseline (target_team now captured at snapshot time) MEDIUM — vendor-updater shape

Guards

  • Grades, never mutes — the finding is still created, still in the report, still accumulates risk and correlates; custody writes no dismissal and cannot feed acquired tolerance.
  • Attack-defined content never downgrades — a conceal imperative stays HIGH even when self-attested (a prompt-injected agent attests its own hostile write). Custody grades churn-shaped structure, not content.
  • Fail toward suspicion — bad MAC, stale record, expired reflog, identity mismatch, git error, absent signer: every non-match keeps the pre-custody HIGH.
  • Forgeability stated, not hidden — ledger and reflog are same-uid-writable; that 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. Post-compromise silencing remains the witness layer's problem.

New surface

  • aegis.py intent record <path> [tool] / intent hook <tool> (stdin JSON, always exit 0) / intent list [n]
  • Provenance vocabulary: self-attested, self-committed, remote-foreign (replaces remote)
  • 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

  • Full suite: 893 passed, 4 skipped (the sandbox meta-test caught INTENT_FILE needing registration — added).
  • Live read-only check on the machine that motivated this: _git_provenance(~/.claude/settings.json)self-committed. All seven false-alarm entries would have graded LOW; the ChatGPT node_repl rewrite grades MEDIUM once its team is baselined; a genuinely pulled foreign hook still opens the HIGH.

🤖 Generated with Claude Code

opencdlee-dotcom and others added 3 commits August 12, 2026 17:41
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>
@opencdlee-dotcom

Copy link
Copy Markdown
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:

  • New provenance rung between self-committed and remote-foreign: signature verifies (exact %G? == G) against the pinned roster ~/.aegis/allowed_signersfleet-signed → LOW.
  • signers pin <file> / signers status. The roster devices sync around (tracked in claude-config) is only the source a human pins from — a poisoned remote adding a key to the tracked copy grants itself nothing until the operator re-pins.
  • Tests: pulled+signed grades fleet-signed and reverts to remote-foreign without the pin; wrong-key or unsigned arrivals stay foreign. Suite: 895 passed.
  • Live on the originating Mac: dedicated ed25519 signing key, ~/.claude commits now auto-signed, roster committed + pinned, git log --format=%G?G against the pinned copy. The Ryzen inherits the tracked roster at bootstrap; it needs its own key + signers pin on day one.

🤖 Generated with Claude Code

opencdlee-dotcom and others added 7 commits August 12, 2026 19:08
…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>
@opencdlee-dotcom opencdlee-dotcom changed the title feat(aegis): chain-of-custody grading — the monitor learns who "me" is feat(aegis): chain-of-custody grading + the noise-reduction tier Aug 20, 2026
opencdlee-dotcom and others added 12 commits August 20, 2026 17:14
…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>
283 incidents lifetime, 215 adjudicated FALSE_POSITIVE, zero true positives.
A live scan produced 52 findings of which exactly two were new fingerprints —
the other 50 were standing observations re-rendered as fresh. 118 of 120
dismissals were `benign-positive`: the rule fired correctly on a shape that is
benign here.

Four identity fixes, each measured on this machine:

  amfid       hashed the whole log MESSAGE, so one file rejected twice minted
              two fingerprints, and `path` never reached the custody ladder
              though 18 of 19 files sat under a Homebrew receipt the grader
              already understood.                          26 -> 8 findings
  ide-ext     identified an extension by its DIRECTORY, which carries the
              version; four claude-code and five chatgpt directories were
              nine entries for two extensions.             3 -> 0 findings
  persistence keyed the INCIDENT on path+content-hash, so one plist edited
              three times became three open HIGH incidents. 46 -> 37 open
  process/    keyed on the versioned path, so every editor-extension update
  beacon      minted fresh incidents forever.               38 -> 34 open

`case_fingerprint` is the mechanism: the fingerprint identifies THIS
observation (content-addressed, so a new change is still a new signal and
still notifies once), while the case identifies the thing the operator must
decide about. Safe against "dismissing the case mutes the subject" because
_upsert_incident already refuses to reattach a fingerprint a dismissed
incident has never seen — subject keying is the shape that guard was written
for. _merge_legacy_persistence_cases migrates existing incidents once
(verified on a copy of the live store: 9 folded, 17,908 evidence rows
preserved, idempotent).

Adds the vouch tier for the gap no existing rung could cover: a workload that
arrived by hand. Signed with a passphrase-protected key held outside ~/.aegis,
verified against a separately pinned roster — deliberately NOT the fleet
roster, whose key is passphrase-less by design and so could be minted by code
running as the operator. Binds to exact bytes, uid, and (for network scope)
the precise endpoint set; an identity-only vouch never widens into "may talk
anywhere". Fails closed: a malformed line, broken chain, rollback, foreign
signature or unpinned roster discards the ENTIRE set and raises CRITICAL.

Honest limit, documented: an attacker who can rewrite aegis.py, its verifier
and the pinned roster under the same uid defeats any local scheme. This buys
tamper evidence, not tamper-proofing.

Verified: 994 passed, 4 skipped, 11 subtests. Vouch tier signs and verifies
for real against a throwaway ed25519 key — no mocked verifier — and every
suppression test is paired with the escape test that keeps it from becoming a
blind spot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bind kill authorization to process creation identity and fail closed on PID
reuse. Retire only genuinely orphaned version-keyed incidents, batch vouched
endpoint rotations without demotion, and move CI actions to Node 24 releases.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
…ing it file by file

Two days after the amfid identity fix took the sensor 26 -> 8, it was back at
27. Not a regression in the grouping — new data. A `.venv` appeared under
~/Ai/001/ARC/Vaultkeeper and its 22 ad-hoc signed wheels each became their own
finding.

Same class as the Homebrew case the receipt tier already handles, but in a form
that tier structurally cannot see: pip and uv write wheels straight into
site-packages with no install record beside the file, so `_package_receipt`
finds nothing and MUST NOT pretend otherwise.

So the venv is grouped but never graded. The distinction is the whole point:
grouping is a statement about legibility ("this is one fact, said once"),
grading is a statement about provenance ("something vouches for this"). A
directory a package manager wrote earns the first and not the second, so the
group is reported at MEDIUM and stays fully visible.

The fingerprint carries a digest of the member set, not just the directory.
That closes the blind spot a plain directory-keyed group would have opened: a
malicious .so dropped into an already-reported venv would otherwise inherit
that group's identity and never alert. Adding a member changes the digest,
mints a new identity, and alerts once.

Measured on this machine: amfid 27 -> 6 findings (four Homebrew receipt groups
at LOW, the 22-file venv as one ungraded MEDIUM, one unreceipted binary solo).
Live scan 55 -> 37 findings, 21 incidents open, launchd exit 0.

Verified: 1018 passed, 4 skipped, 11 subtests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Match both path separators because amfid evidence is text and may not use the
host OS separator. Pin both POSIX and Windows path shapes.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
net-outbound keyed a finding on `<versioned path>:<ip>:<port>`, so one
`claude` binary at three Google frontends was three findings, syncthing's
relay pool had left 30+ stored fingerprints, and six extension updates of
one program were six more identities for it.

That was not only unreadable, it was wrong. `_accumulate_risk` sums one
weight per DISTINCT fingerprint on an entity, so endpoint rotation
manufactured risk score out of a single fact: measured on the live store,
the `claude` binary's "4 signals, score 5.2" was three-quarters one fact
and scores 3.1 under subject identity -- below the 4.0 threshold, so it
stops opening a HIGH incident at all. Syncthing's fell 7.4 -> 5.2.

The subject is the PROGRAM. Endpoints become evidence: carried on the
finding, all rendered with the count always stated, and graded on the
WORST of them -- custody is endpoint-scoped for network vouches, so one
uncovered endpoint still un-demotes the whole subject and still carries
its deviation case. Nothing is hidden and nothing is laundered.

Three things deliberately keep endpoint identity, because there the
endpoint IS the fact: net-beacon (its detection is persistence at one
fixed endpoint -- only version churn came out of its signal key), intel
(a catalogued C2 address is what the intel names), and the tolerance
layer.

No migration ships with it, and that is a finding rather than an
omission: net-outbound sits below the notify floor, so it has never
opened an incident of its own -- `signal:outbound:%` matched 0 incidents
of any status against 64 stored signals, while the HIGH beacon sensor
beside it had 36. A fold would have guarded an empty set; the 7-day
age-out tier already closes a stale signal case.

Measured against a copy of the live store, same captured row set:
net-outbound 8 -> 4 findings, 35 -> 32 total; stored identities
outbound 64 -> 7 (9.1x), beacon 47 -> 31. Suite 1033 passed / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…caught

Both Windows legs failed run 32729572632: 12 of 12 cases in
tests/test_outbound_subject.py, every one an unexplained `0 != 1` or an
IndexError on an empty list.

Cause: the file stubbed `classify_signature` to return `{"trust": "adhoc"}`
while its own class docstring promised the assertions held "on every OS". Ad-hoc
signing is a codesign concept with no Authenticode equivalent, so
`suspicious_sig` rightly rejects "adhoc" on Windows,
`_outbound_candidate_trust` returned None for every row, and the sensor minted
nothing. The product is correct; the fixture was macOS-shaped and read as
neutral. Linux was green only by accident — its branch keys on the structural
exec tell and never consults the verdict at all.

Fixing the twelve is one constant. What matters is that a Mac could not see it:
the platform branch was only ever executed by a Windows runner, so a
five-character vocabulary mismatch cost a full CI cycle to surface.

  - conftest gains `suspicious_trust_for(is_win, is_linux)` and
    `SUSPICIOUS_TRUST`: ask for the CONCEPT, never for one body's spelling. It
    asserts itself against `aegis.suspicious_sig` at collection, so a moved
    vocabulary fails in the words of the thing that changed rather than as 200
    silent zeroes.
  - test_cross_platform gains `StubbedTrustQualifiesOnEveryBody`, which drives
    the real sensor through each simulated body using the codebase's existing
    IS_WIN/IS_LINUX flip idiom. Verified by reintroducing the defect: it fails
    in 0.06s on a Mac, naming the mechanism ("the gate rejected the verdict this
    body actually uses") instead of `0 != 1`.

And the leg itself. The live harness was a trailing step guarded by `if:
always()` — the guard was right, but a step cannot start before the 24-minute
suite ahead of it, so its measured 12.6 min was pure addition and Windows said
nothing for 37-39 minutes. It is now its own job on its own runner (free on a
public repo), which cuts the Windows critical path to roughly max(25, 13) and
earns the `always()` semantics structurally: a failing suite can no longer
delay or mask it.

The step cap stays 35 min, but the suite has grown 20:06 -> 24:27 against it
with nothing announcing the growth — and an outgrown cap presents as a
KeyboardInterrupt indistinguishable from a test failure, which has already
turned one clean pass into a reported failure. The comment asking the next
person to "re-measure rather than nudge the constant" is not a mechanism, so
the step now prints `--durations=25`. The next sizing decision is a
measurement.

Not done on a guess: pytest-xdist `-n 4` was measured locally at identical
counts (1033 passed, 4 skipped) and 422s -> 238s, but that is Mac evidence for
a Windows profile dominated by process spawning. The durations output will
settle it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* The guard I shipped did not catch the thing it was written for

A 42-agent adversarial review reproduced, rather than argued, 18 defects. Three
were in the commit before this one.

The worst: StubbedTrustQualifiesOnEveryBody asserted that the conftest HELPER
returns a verdict suspicious_sig accepts. It never asserted that any test FILE
uses it — and it cannot, because the helper is per-body-correct by construction
while the defect is a body-specific LITERAL. Reverting all four
SUSPICIOUS_TRUST uses in test_outbound_subject.py back to "adhoc" left the whole
suite green on macOS: exactly the state that shipped 12 Windows failures. The
earlier demonstration that it "caught the defect in 0.06s" was real but tested
the wrong thing — it broke the helper, which the guard does consume.

The only honest detector for "a fixture hard-codes one body's spelling" is a
source scan, so NoTestHardCodesOneBodysTrustVocabulary is one. Reintroducing the
literal now names both sites with file:line:class while the old guard still
reports 2 passed.

It found nine more instances on its first run, all pre-existing, all in
test_regression.py, all ungated and stubbing macOS-only trusted verdicts on
green CI. Seven stub "apple" and convert mechanically (PUBLISHER_TRUST is
"apple" on macOS, so the macOS meaning is unchanged by construction). Two stub
"developer-id" and stay ratcheted: one is literally about vendor impersonation,
and a mechanical swap would silently change what a macOS assertion means, which
is a worse bug than the one being fixed. The ratchet may only shrink — a stale
entry fails the test.

And the same defect class, live in the product, in the file this branch is
named after: _custody_persistence inlined the macOS triple, so `publisher-stable`
was structurally unreachable on Windows and Linux and every off-mac host paid
full severity for a vendor's ordinary in-place update. publisher_sig() is now
the positive twin of suspicious_sig(), beside it, so there is one spelling of
each half of the vocabulary. Linux really does reach the rung: _classify_linux
sets authority to "dpkg:<pkg>", which is what the same-signer half compares.

Also from the review, each verified by reintroducing the defect:
  - the conftest self-assert vanished under `python -O`, taking with it exactly
    the "0 != N" noise it exists to prevent; it raises explicitly now
  - the skip lists had no dead-entry check. The file argues carefully that a
    RENAMED class fails loudly, and never mentions that an entry naming
    something deleted is inert and invisible. Both halves now hold.
  - suspicious_trust_for's Linux docstring claimed "broken" was representative.
    _classify_linux cannot emit it; the suspicious_sig arm is dead there by
    design and Linux keys on structure. Recorded rather than papered over.
  - --durations=25 was credited with closing the cap-growth gap. It does not:
    it reports the slowest CASES, not the step against its cap, and on the one
    run that hits the cap it prints nothing at all. What it does buy is a
    per-body cost profile, and it already paid for itself — the top two cases
    measured 189.41s/188.25s here against 35.48s/35.74s on macOS, a uniform
    ~5.2x that makes the cheap local profile a usable model for the expensive
    remote one.
  - the harness step cap was cut 25->20 using durations measured while it ran
    third behind a 24-minute suite. In its new position the first run measured
    625s and 816s — outside the range it was sized against. Restored to 25 and
    the real numbers recorded, because sizing a cap from another position is
    the mistake this file has now made three times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Verified a cross-body change on one body, again

PR #9's Linux legs failed on test_custody.py::PersistenceCustody::
test_vendor_rebuild_in_place_grades_medium, and the cause is the defect the
previous commit was written to remove.

The OLD custody gate compared trust against an inlined macOS triple with no
platform branch — platform-BLIND, not macOS-only. A record carrying
"developer-id" therefore earned `publisher-stable` on Linux and Windows too.
No real record on those bodies carries that word, so the rung was still
unreachable in production; but `_prec()` defaulted to it, so the fixture passed
on all three CI bodies by asserting behaviour no Linux host could produce. Making
the gate body-correct removed the only thing holding that test up.

The scan added in the previous commit did not catch it because it matched only
the dict-literal shape `"trust": "adhoc"`. `_prec(..., trust="developer-id")` is
a KEYWORD DEFAULT. Widened, the scan finds 19 more sites of the same class
across test_custody.py and test_regression.py; all are converted here, and
`_prec`'s default is now this body's own publisher verdict so its docstring's
claim to be "the shape every platform snapshot produces" is finally true.

The honest failure is the verification, not the fix. The macOS run that gated
the push proves nothing about this change BY CONSTRUCTION: PUBLISHER_TRUST is
"apple" on macOS, so every converted site is a no-op there. The previous commit
message says that outright and the push happened anyway. A platform-simulation
harness that reproduces this in fifteen seconds had already been written this
session, used once, and discarded as scratch.

So it is a real harness now, and this commit is gated on it: the whole suite run
under simulated linux and win, diffed against e0a350d's failure set. Both come
back with no new failures. macOS: 1040 passed, 4 skipped.

Also here: NoTestHardCodesOneBodysTrustVocabulary gains a _BY_DESIGN exemption
for this module's own PublisherStableIsReachableOnEveryBody, which names each
body's vocabulary in a table and flips the flags to match — the one place a
body-specific word is the point rather than an accident. CustodyGrading joins
the ratchet: `_target_change` defaults both sides to "developer-id" to compare
TEAM identity across a target swap, and PUBLISHER_TRUST is "apple" on macOS, so
a mechanical swap would change what the macOS assertion compares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* The harness the last commit said it was gated on was not in the repo

`42cfa3c` claimed "it is a real harness now, and this commit is gated on it".
The gating was true — the diffs were run and clean — but the harness itself
lived in a scratch directory and its diffstat touched three test files, none of
them it. Which is exactly the thing that commit message complains about, one
paragraph earlier, about the previous occurrence.

So here it is as `tests/simbody.py`, an opt-in `-p` plugin. It flips aegis's
platform flags BEFORE conftest binds its per-body mirrors (flipping them after
proves nothing, which is how the first attempt at this quietly measured
nothing), and makes conftest's platform gating agree.

Its docstring carries the two things that make it usable rather than
misleading:

  * the absolute failure count under simulation is NOT meaningful — cases fail
    here for reasons unrelated to any change (real macOS paths, a live
    `codesign`, /private/tmp firmlinks). Only the DIFF against the same run on
    your merge base is. The recipe is in the file.
  * what it does not simulate and never will: os.sep, path parsing, case
    sensitivity, file locking, subprocess behaviour. It covers a verdict, a
    flag, a branch. A green run here does not replace the Windows leg; it stops
    you sending the Windows leg something it will obviously reject.

`PYTHONPATH=tests` is in the usage line because pytest imports a `-p` plugin
before it puts anything on sys.path, and the version of this I first wrote
failed with "No module named 'simbody'".

CLAUDE.md points at it, and its stale test count (994) is corrected to 1040.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two deferred review findings, plus the debt the last commit left behind.

`_grade_binary` offers a sensor exactly two demotions: operator-vouched and
package-managed. The second consulted Homebrew, VS Code, pipx and uv — so an
apt/rpm/winget-installed binary, the ordinary shape of a developer's toolchain,
was scored at full severity with custody=None on Linux and Windows while its
Homebrew equivalent on macOS was demoted a step. Both rungs available off-mac
were narrower than on mac; the previous commit fixed one of them and this
fixes the other.

The Linux half needed no new machinery. `_classify_linux` had shelled out to
dpkg/rpm/pacman since forever to decide `os-managed`; custody simply never
asked. That query is now `_linux_pkg_owner` — one spelling, two callers, with a
test asserting they agree. It sits LAST in _PACKAGE_RECEIPTS because it can
cost three subprocesses while every probe above it is pure path arithmetic, and
it memoizes per resolved path because the custody layer asks about the same
handful of programs repeatedly within one scan (also tested: one query per
path, not four).

Windows gets `_winget_receipt` and `_choco_receipt`, path-shaped like the
portable probes beside them but normalizing separators instead of using os.sep,
so they are exercisable from any body — the same reason test_cross_platform
parses captured Windows output on a Mac. Measured on real files:

    before  [None, None, None]
    after   ['winget:Foo.Bar', 'winget:link', 'choco:ripgrep']

and on Linux, None -> 'dpkg:curl'. A path under Local/Temp still earns nothing;
a probe that demoted anything under a user-writable root would be a blind spot,
not a rung.

And the ratchet from the previous commit is EMPTY — by shrinking, not by
deletion, with the stale-entry check still guarding it. Its last three entries
were settled by mutation rather than by reading: swapping all four
"developer-id" sites to PUBLISHER_TRUST broke exactly one test,
TestHotDirAppBundle's, whose subject is an unnotarized Developer-ID .app — a
Gatekeeper concept with no analog on any other body, in a class conftest
already gates to macOS. That literal stays, with the reason recorded above the
class. The other three were inert: `target_trust` is written at aegis.py:11172
and read by no gate at all.

Verified: 1045 passed / 4 skipped on macOS, and the whole suite under simulated
linux and win diffed against 7279c68 — no new failures on either.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding #4, reported closed and not closed. I answered it by writing the
Linux caveat into suspicious_trust_for's docstring. A docstring is not a test,
and the finding was never about documentation.

StubbedTrustQualifiesOnEveryBody loops over three bodies asserting the outbound
sensor mints a finding on each. On Linux the gate is

    _exec_alert(path, trust) or is_risky_location(path)

and the test stubs is_risky_location True for every row, so the Linux row
short-circuits on the right-hand side and never consults the verdict it claims
to be checking. It passes whatever that verdict is. That is the same shape as
the fixture that shipped 12 Windows failures while reading as platform-neutral:
an assertion that looks like coverage and is structurally incapable of failing.

So each row now has a negative half that drives that body to the state where it
must mint NOTHING — macOS and Windows on a verdict suspicious_sig rejects,
Linux on a real non-volatile path with is_risky_location false, because on
Linux the verdict arm is dead by construction (_classify_linux emits only
os-managed and unmanaged) and structure is the whole signal.

Proven by mutation rather than by argument: with the Linux gate forced always
open, the new case fails on the Linux row —

    AssertionError: Lists differ: [{'schema_version': 1, ...}] != []

Verified: 1046 passed / 4 skipped on macOS, and the whole suite under simulated
linux and win diffed against 9d8cdbf — no new failures on either.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing gate (#12)

* refactor(aegis): fold superseded wrappers into their survivors

quarantine_origin and _process_names were one-line wrappers whose only
callers were the tests pinning them; their load-bearing docstrings (the
os.getxattr-is-Linux-only note, the ps comm-truncation constraint) move
onto _quarantine_fields and _process_owner_and_names so the knowledge
survives the wrapper. _VOUCH_NET_SCOPE was never read by anything.
diff_ext_caps now rides _diff_map like the other 19 surface diffs
instead of hand-rolling the same prior/cur walk.

* fix(aegis): writ enforcement now governs the primary persistence sensor

_apply_writ wrapped only the _scan_surfaces registry, so 'writ enforce on'
governed shellrc and browser extensions while launchd/systemd/Run-key
persistence — the flagship change-shaped sensor — bypassed enforcement
entirely. Same class as the original 'writ_covers had no callers' defect
that test class exists to pin; the new reproducer fails against the
unwrapped sensor.

* refactor(aegis): the surface registry carries its own writ scope and adopt policy

_SURFACE_WRIT_SCOPE and _NEVER_ADOPT_LIVE were two side-maps keyed on a
third spelling of the surface name (key vs finding category vs writ
scope), and that drift has already shipped one bug — benign notes that
silently never rendered. Each SURFACES row now carries (key, snap, diff
[, writ_scope[, never_adopt_live]]) and _surface_row normalizes, so a
bare 3-tuple — the shape tests patch in and the shape a hurried new
surface gets added as — is a fully governed row by construction.
_apply_writ takes the scope directly; nothing maps names to names.

ARCHITECTURE.md gains the cross-layer severity order (sensor ladder →
custody down-only → writ bidirectional → notify floor → incident ratchet
up-only), which previously existed only as call sequence.

* refactor(aegis): one runner for the one-time store migrations

Three incident-identity redesigns in four days each hand-rolled the same
scaffold: recognizer regex, retire/merge function, private meta key,
call-site try/except — ~70 lines apiece, none sharing a guard or a
retirement plan. _STORE_MIGRATIONS is the table that scaffold should
have been; _run_store_migrations stamps each entry once under the SAME
meta keys the shims already wrote, so live stores do not re-migrate.

The orphaned-program recognizer evaluated legacy keys through the LIVE
beacon/version regexes, so its meaning moved whenever detection did —
a restored backup would migrate differently from the first machine.
Its patterns are now frozen copies (_MIG_*), pinned by test.

The migration functions themselves lose their internal guards: they
were always naturally idempotent (a closed incident no longer matches
OPEN/ACK), which the existing second-pass tests already proved.

* fix(aegis): settle exec keys in the baseline store instead of re-hashing every scan

The exec-identity fix re-keyed BOTH sides of the agent-surface diff on
every scan, forever — a sha256 per entry per side — because the persisted
baseline was never rewritten, even though the project already owned the
right mechanism (BASELINE_SCHEMA_VERSION + _migrate_baseline) and had
simply never folded exec keys into it. Schema v3 re-keys agent_surface
once at load, under the same watermark guard v2 used, so a tampered file
stays byte-identical for evidence. schema_version is now the gate, so a
current baseline is returned without a record inspected.

diff_agent_surface is a plain compare in the steady state. The in-memory
re-key survives only for a legacy-shaped side (the watermark-mismatch
path), and re-keys both sides together so a hand-built legacy pair still
compares equal. Pinned: the steady state calls _migrate_exec_keys zero
times; a legacy prior still re-keys and still matches.

* test(aegis): cauterize is driven, not just redirected

cmd_cauterize had zero test invocations — two setUp blocks pointed
CAUTERIZE_FILE at a sandbox and nothing ever called the command. By this
repo's own doctrine (a row that cannot fail is not coverage) that made it
the one response verb that could break silently. Progress marks, usage
errors, the audit record, service grouping, rank ordering, ticked steps,
and incident narrowing through the real event store are now exercised.

* refactor(aegis): identity is declared as a subject, not parsed out of a string

Every one-time incident migration existed for one reason: identity lived
INSIDE the fingerprint string, and tolerance, the rotating-endpoint
classes and the dispute check each regexed it back apart (the IPv6
beacon bug was this failing silently — a colon-joined string cannot be
split on colons). Each time a sensor respelled a fingerprint the
operator's verdicts stopped attaching to anything and a closer was
written by hand.

The three sensors whose identity churned — persistence CHANGED, process
exec, net-beacon — now declare a subject (kind, version-normalized path,
raw path, content hash, ip/port) on the finding; _upsert_incident stores
it and backfills a legacy row from the first evidence that reattaches.
The identity strings the tolerance layer keys on are RENDERINGS of the
subject, pinned byte-identical to what the parsers derive, so rows with
and without one build one memory. The parsers remain only as the
fallback for rows that predate subjects.

incidents gains subject_json; an existing store is widened in place on
connect, since CREATE TABLE IF NOT EXISTS never widens a table and every
reader that selected the column would otherwise fail closed into "no
memory" without a word.

* fix(aegis): one routing gate, consulted by both the interrupt and incident tiers

The notify tier (emit: allowlist, seen-ledger, adoption, notify floor,
confidence) and the incident tier (acquired tolerance, the learning
period) were disjoint state machines coupled by one per-scan boolean.
Three things followed, each now pinned:

  * acquired tolerance never muted the desktop notification — a
    tolerated identity with a new content hash interrupted FIRST, then
    opened pre-closed; the learning period never muted it either,
    despite ARCHITECTURE.md's "instead of alerting";
  * an allowlisted fingerprint still opened and refreshed incidents and
    drove reminders, because emit skipped it while every finding flowed
    into the incident tier untouched;
  * initially_notified was bool(new_high) for the whole scan, so one
    genuine new HIGH marked every incident created that scan as already
    told to a human.

route_findings is the one place the order is written down (allowlisted →
seen → adopted → low-confidence → below-floor → tolerated/learning →
new). The scan path computes it once with the incident tier's memory
and hands the same verdicts to emit, to record_security_state (per-
finding notified; an allowlisted incident closes as 'allowlisted' with
no dismissal row), and to the report, whose headline no longer calls a
gate-closed finding "new and needs you". A caller without a routing
decides in place with the same _signal_decision over the same memory.

Two first-run tests pinned the popup half of the old inconsistency (a
HIGH on scan 1 both notified AND opened pre-closed as learning). They now
pin the documented contract: cased and never adopted on scan 1, and
interrupting the moment the learning window is off.

* docs(readme): allow also closes the incident under the routing gate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant