Skip to content

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

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

feat(aegis): chain-of-custody grading + the noise-reduction tier#8
opencdlee-dotcom wants to merge 12 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 2 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>
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