fix(runtime-witness): record the execution state, and attribute a failure to the stage it happened at - #333
fix(runtime-witness): record the execution state, and attribute a failure to the stage it happened at#333PhysShell wants to merge 3 commits into
Conversation
…urvives the process
The exit-code contract keeps three states apart on purpose — 0 evaluated /
witness absent, 1 evaluated / witness present, 2 not evaluated — because
*not looking* and *looking and finding nothing* are different outcomes, and
collapsing them is how a monitoring pipeline learns to report health it never
measured.
Persistence then collapsed exactly that distinction. A refused attach wrote no
`runtime.json` at all, so after the process exited the durable record read:
artifact present -> some evaluated outcome
artifact absent -> not evaluated OR never invoked OR runner died before
invocation OR persistence failed OR lost in transit
OR an older format nothing reads any more
Absence had too many preimages to carry meaning. Now it carries none:
Absence of a record means no durable knowledge, never a semantic outcome.
Every attempted evaluation writes a record when `--out` is given, stating what
happened in `execution.state`: `observed` / `clean` (with the observation
scope), `not_evaluated` (with a reason code), `error` (with a classification).
What does NOT come back is a verdict nobody earned. A `not_evaluated` or
`error` record carries no `verdict` and no `retained` key at all — not even
`retained: []`, which downstream reads as "looked, found nothing" and would
re-create the collapse one layer up.
Two distinctions the record refuses to guess at:
* `refused-attach` is a claim about permission, so it is made only where a
refusing policy can be named (`reason.policy`, e.g. the Yama scope). Every
other unreadable target gets the weaker, true `unreadable-target`. The human
advice and the record now read one shared observation, so a diagnostic that
blames the kernel can no longer sit beside a record that blames the target.
* `not_evaluated` vs `error` splits on where the failure landed: before the
heap was readable nothing was looked at and the target is not implicated;
after it, the walk broke and the target is not exonerated.
`scope` is required for an evaluated state, not decorative: a `clean` that does
not say what it looked at cannot mean "nothing was there", and consumers must
route it down the schema-violation path. It names the population the verdict
covered separately from the budgets that bounded only the display, so a reader
can tell a number that constrained the verdict from one that did not.
CI moves with the contract. The denied-attach gate asserted "no artifact
written" — the behaviour being fixed — and now asserts the refusal is recorded,
names the policy, and carries no verdict. Two target-free assertions run on
every platform, and the flagship demo checks that both its variants record a
scope.
Verified: witness selftest (classifier, verdict and record contracts), both
flagship demo variants against a live net8 process (observed with a
1000-instance scope; clean), all three not-evaluated codes, ingest selftest
26/26, tests/run_tests.py wpf 28/28.
Refs #331.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
Review of d16f47d found the refused-attach fix stopped one level short of its own proof, and in two places. **stderr could still blame Yama after a successful attach.** The catch block called AttachAdvice unconditionally and only then checked whether the walker existed, so any exception from Census/Roots on Linux under a restricting ptrace_scope printed "PERMISSION failure, Yama refused it" while the record correctly said `execution.state=error`. The claimed invariant — human diagnostic and record read one observation — was still broken, just in the other branch. **One bool could not carry the state it was asked to.** `attached` was set after RetentionWalker's constructor, which does DataTarget.AttachToProcess AND ClrVersions.FirstOrDefault AND CreateRuntime. A live process that opened fine and turned out not to be managed threw with attached still false, so under ptrace_scope=1/2/3 it came back labelled `refused-attach` — a permission claim about a step where permission had already been granted. The stages are now explicit — open-target, create-runtime, walk — and RetentionWalker splits accordingly (OpenLiveTarget/OpenDumpTarget, then Create taking ownership). Attribution follows the stage: only open-target may yield `refused-attach` and only it prints the ptrace advice; create-runtime is `unreadable-target`; walk is `error`. `reason.stage` is recorded so a reader can check the attribution rather than trust it. And the last substitution the previous round left standing: Yama being active is not Yama having caused this failure. The kernel does not tell a tracer which check rejected it, and a live process under scope 1 can fail to open for unrelated reasons. The field is now `policy_in_force` — what was observed — and the stderr wording says the same, naming a likely cause and its limit rather than a refuser. Also correcting two overclaims from d16f47d rather than leaving them to be found again. `verdict` is command-specific (roots has a vocabulary, census does not), so the doc no longer lists it among what every evaluated state carries — the code never wrote one for census. And the record invariant is stated as it is implemented: every evaluation *for which persistence was requested* leaves a record; a run without --out asked for no durable output, and a standalone tool has no other publication point. CI: the denied-attach gate now also asserts reason.stage, and a second case runs under the SAME restricting policy — an unreadable dump — proving a non-attach failure does not borrow the policy or the lecture. Verified: selftest (now pinning stage attribution as a pure function, including create-runtime never citing a policy), both flagship variants on a live net8 process, live unmanaged process -> create-runtime/unreadable-target with no PERMISSION line on stderr, dead pid -> open-target/unreadable-target, ingest 26/26, run_tests.py wpf 28/28. The live Yama refusal itself is unexercised here (no Yama in this kernel) and remains CI's to prove. Refs #331. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
…roves The behaviour was already honest — the code is reserved for an open-target failure with a policy observed in force, and `policy_in_force` is worded as an observation — but the comment above it still opened with "a claim about PERMISSION". A later reader would take the enum name as the finding and put the causal claim back by hand. Comment only; no behaviour change. Selftest and build unchanged. Refs #331. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lmcv3X9PoELp8CGDfNc9m
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Base advanced; no base-sync commit introducedCI was green against the previous base. Since then Observed, so the gap is stated rather than papered over:
What this does and does not establish. A clean merge proves the absence of a textual conflict. Disjoint paths make a semantic regression unlikely but do not reduce it to zero — a build/test graph can couple files through configuration and dependencies. What is genuinely unproven is the narrow claim "this exact merged tree passed CI", which is a much smaller statement than "the PR is now suspect". Re-running CI on a base-sync commit would buy that narrow claim at the cost of an extra SHA and a run that re-tests the same diff against docs-only movement. Deferred rather than skipped: before this leaves draft, the base gets checked again, and the branch is updated if branch protection requires up-to-date, or if Generated by Claude Code |
The producer half of #331. Consumer PR: PhysShell/OwnAudit#61 — land that one first, so the consumer is fail-closed before this collector starts publishing the final contract shape.
Что и зачем
docs/runtime-witness-operations.mdдержит три состояния врозь в exit-кодах (0 — смотрели, свидетеля нет; 1 — смотрели, свидетель есть; 2 — не смотрели), потому что не смотреть и посмотреть и ничего не найти — разные исходы. Слой персистентности схлопывал ровно это различие: отказанный attach не писалruntime.jsonвовсе, и отсутствие файла означало «не оценивали» или «не запускали» или «раннер умер» или «запись не удалась» или «потеряно при передаче». Теперь каждая evaluation, для которой запрошена persistence, оставляет запись состояния — при этом невзятый вердикт по-прежнему не пишется.The defect
The exit-code contract keeps three states apart on purpose. The persistence layer then collapsed exactly that distinction, because a refused attach wrote nothing at all:
Absence had too many preimages to carry meaning. Now it carries none:
What changed
1. The durable record. Every run given
--outwrites one, stating what happened:execution.stateobservedscope,retainedcleanscope,retainednot_evaluatedreason.code,reason.stage,reason.detail, optionalreason.policy_in_forceerrorerror.classification,error.stageWhat must not come back is a verdict nobody earned:
not_evaluatedanderrorcarry noverdictand noretainedkey at all — not evenretained: [], which downstream reads as "looked, found nothing" and would re-create the collapse one layer up.scopeis required for an evaluated state, not decorative: acleanthat does not say what it looked at cannot mean "nothing was there", and consumers route it down the schema-violation path. It names the population the verdict covered (instances_on_heap,instances_reachable,instances_durably_retained) separately from the budgets that bounded only the display (sample_budget,max_hops_budget), so a reader can tell a number that constrained the verdict from one that did not.2. Stage attribution — the second commit, and the one worth reading. The first cut set a single
attachedbool afterRetentionWalker's constructor, which doesAttachToProcessandClrVersions.FirstOrDefaultandCreateRuntime. Two consequences, both real:attachedstill false, so under a restrictingptrace_scopeit came back labelledrefused-attach— a permission claim about a step where permission had already been granted;AttachAdvicewas called unconditionally in the catch block, so any exception from the walk printed "PERMISSION failure, Yama refused it" on stderr while the record correctly saidexecution.state=error.The stages are now explicit — open-target → create-runtime → walk — and
RetentionWalkersplits accordingly (OpenLiveTarget/OpenDumpTarget, thenCreatetaking ownership;finallydisposes a target whose ownership never transferred). Onlyopen-targetmay yieldrefused-attachand only it prints the ptrace advice;create-runtimeisunreadable-target;walkiserror.reason.stageis recorded so a reader can check the attribution instead of trusting it.3. The claim is bounded. Yama being active is not Yama having caused this failure — the kernel does not tell a tracer which check rejected it, and a live process under
ptrace_scope=1can fail to open for unrelated reasons. The field ispolicy_in_force: what was observed, not a culprit. The stderr wording and the code comment say the same, so the enum cannot be read as smarter than the evidence behind it.4. Two overclaims from the first commit, corrected in place.
verdictis command-specific —rootshas a vocabulary (RETAINED/OBSERVED_ONLY/ABSENT),censusasks a different question and has none — so the doc no longer lists it among what every evaluated state carries; the code never wrote one forcensus, and inventing one for schema symmetry would add a word nobody measured. And the invariant is documented as implemented: every evaluation for which persistence was requested leaves a record. A run without--outasked for no durable output, and a standalone tool has no other publication point.Как проверено
Installed the .NET 8 SDK and ran everything; nothing here was accepted by reading.
python tests/run_tests.py→ exit 0, wpf 28/28RetentionPath selftest→ classifier, verdict, record and stage-attribution contracts; 0 warnings, 0 errorsscripts/flagship-demo.sh badandokagainst a live net8 process →observedwith a 1000-instance scope;cleannot_evaluatedcodes exercised live: usage-error, dead pid →open-target/unreadable-target, live unmanaged process →create-runtime/unreadable-targetwith zeroPERMISSIONlines on stderrpython audit/runtime/ingest.py --selftest→ 26/26Not verified locally, deliberately stated: a real Yama-denied attach. This kernel has no Yama, so the live refusal path first executes in this PR's CI — which is part of why it is open now rather than later. The deterministic half (create-runtime never citing a policy; a dump read never citing one) is pinned as a pure function in the selftest.
CI moves with the contract. The denied-attach gate previously asserted "no artifact written" — the behaviour being fixed — and now asserts the refusal is recorded, names the stage and the policy in force, and carries no verdict. Two target-free assertions run on every platform, a second case under the same restricting policy proves a non-attach failure does not borrow it, and the flagship demo checks that both variants record a
scope.Тип изменения
Связанные issue
Refs #331. Consumer: PhysShell/OwnAudit#61.
Не
Closes. Половина контракта живёт в другом репозитории; #331 закрывается только после merge обоих PR и зелёного Yama-гейта здесь.Чеклист
docs/runtime-witness-operations.mdобновлёнGenerated by Claude Code