From 739c8695136f814e93af7352c7287a30cb232b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 21:12:36 +0000 Subject: [PATCH 1/7] fix(examples): measure the flagship hold deadline monotonically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deadline was `DateTime.UtcNow + seconds` — wall-clock arithmetic. A backwards system-clock adjustment extends it, so the documented guarantee ("a forgotten sample cannot outlive its job") held only while nobody touched the clock. A bound a clock adjustment can extend is not a bound. All four samples now measure it with a Stopwatch started at Announce(), and ShouldRelease() drops its deadline parameter — the callers were each carrying their own copy of the arithmetic, including the WPF drivers. The stdin-line and stop-file paths are untouched. Raised on PR #310 as a non-blocking tail and deliberately deferred out of it rather than restarting a green run; landing it first here so the claim in the README is true before the packaging arc quotes it. Verified locally: stdin at EOF with a 3s deadline exits at 3s; the stop file still releases in ~1s against a 60s deadline; the demo script still releases through its FIFO with both variants green; all four samples keep their owen verdicts (bad → 1, ok → 0). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- examples/flagship/README.md | 5 +++++ examples/flagship/console/bad/Hold.cs | 19 +++++++++++++------ examples/flagship/console/ok/Hold.cs | 19 +++++++++++++------ examples/flagship/wpf/bad/App.xaml.cs | 3 +-- examples/flagship/wpf/bad/Hold.cs | 16 +++++++++++----- examples/flagship/wpf/ok/App.xaml.cs | 3 +-- examples/flagship/wpf/ok/Hold.cs | 16 +++++++++++----- 7 files changed, 55 insertions(+), 26 deletions(-) diff --git a/examples/flagship/README.md b/examples/flagship/README.md index 4f265f40..1d4f4864 100644 --- a/examples/flagship/README.md +++ b/examples/flagship/README.md @@ -112,6 +112,11 @@ three release paths: | `OWEN_FLAGSHIP_STOP=`, then create that file | callers whose stdin is not a console — every CI runner | | `OWEN_FLAGSHIP_HOLD_SECONDS` (default 300) | the backstop: it applies to *every* path, so a forgotten sample cannot outlive its job | +The backstop is measured with a `Stopwatch`, not `DateTime.UtcNow` arithmetic. +A wall-clock deadline moves when the system clock does, and a bound a clock +adjustment can extend is not a bound — a small thing to get right, but "cannot +outlive its job" is either true or it is decoration. + Two details are load-bearing rather than incidental. Stdin is read on a **background** thread — a blocking read would ignore the deadline it claims to honour, and in the WPF samples it would also starve the dispatcher the hold diff --git a/examples/flagship/console/bad/Hold.cs b/examples/flagship/console/bad/Hold.cs index 7b65400d..9bc02872 100644 --- a/examples/flagship/console/bad/Hold.cs +++ b/examples/flagship/console/bad/Hold.cs @@ -10,13 +10,17 @@ // * THE STOP FILE (OWEN_FLAGSHIP_STOP) — for callers whose stdin is not a // console. Every CI runner is one of those. // * THE DEADLINE (OWEN_FLAGSHIP_HOLD_SECONDS, default 300) — applies to -// every path, so a stray sample can never outlive its job. +// every path, so a stray sample can never outlive its job. Measured with a +// Stopwatch, not wall-clock arithmetic: `DateTime.UtcNow + seconds` is a +// deadline the system clock can move, and a bound that a clock adjustment +// can extend is not a bound. // // A NULL read is deliberately not a release: with stdin closed or redirected // from nothing, `Console.ReadLine()` returns null immediately, and treating // that as "the user pressed Enter" would end the hold before a witness could // attach. using System; +using System.Diagnostics; using System.IO; using System.Threading; @@ -27,6 +31,7 @@ internal static class Hold private const int DefaultSeconds = 300; private static volatile bool _lineReceived; + private static readonly Stopwatch Elapsed = new(); public static bool Requested => Environment.GetEnvironmentVariable("OWEN_FLAGSHIP_HOLD") == "1"; @@ -45,24 +50,26 @@ public static void IfAsked() if (!Requested) return; Announce(); - DateTime deadline = DateTime.UtcNow.AddSeconds(Seconds); - while (!ShouldRelease(deadline)) Thread.Sleep(200); + while (!ShouldRelease()) Thread.Sleep(200); } /// The pid line is the orchestration contract: whoever launched - /// this process waits for it before attaching. + /// this process waits for it before attaching. Starts the deadline clock. public static void Announce() { string? stop = StopFile; Console.WriteLine($"holding (pid {Environment.ProcessId}) — send a line to exit" + (stop is null ? $", or wait {Seconds}s." : $", create {stop}, or wait {Seconds}s.")); Console.Out.Flush(); + Elapsed.Restart(); WatchStdin(); } - public static bool ShouldRelease(DateTime deadlineUtc) + /// True once ANY release path has fired: a line on stdin, the stop + /// file, or the deadline. + public static bool ShouldRelease() { - if (_lineReceived || DateTime.UtcNow >= deadlineUtc) return true; + if (_lineReceived || Elapsed.Elapsed >= TimeSpan.FromSeconds(Seconds)) return true; string? stop = StopFile; return stop != null && File.Exists(stop); } diff --git a/examples/flagship/console/ok/Hold.cs b/examples/flagship/console/ok/Hold.cs index 7b65400d..9bc02872 100644 --- a/examples/flagship/console/ok/Hold.cs +++ b/examples/flagship/console/ok/Hold.cs @@ -10,13 +10,17 @@ // * THE STOP FILE (OWEN_FLAGSHIP_STOP) — for callers whose stdin is not a // console. Every CI runner is one of those. // * THE DEADLINE (OWEN_FLAGSHIP_HOLD_SECONDS, default 300) — applies to -// every path, so a stray sample can never outlive its job. +// every path, so a stray sample can never outlive its job. Measured with a +// Stopwatch, not wall-clock arithmetic: `DateTime.UtcNow + seconds` is a +// deadline the system clock can move, and a bound that a clock adjustment +// can extend is not a bound. // // A NULL read is deliberately not a release: with stdin closed or redirected // from nothing, `Console.ReadLine()` returns null immediately, and treating // that as "the user pressed Enter" would end the hold before a witness could // attach. using System; +using System.Diagnostics; using System.IO; using System.Threading; @@ -27,6 +31,7 @@ internal static class Hold private const int DefaultSeconds = 300; private static volatile bool _lineReceived; + private static readonly Stopwatch Elapsed = new(); public static bool Requested => Environment.GetEnvironmentVariable("OWEN_FLAGSHIP_HOLD") == "1"; @@ -45,24 +50,26 @@ public static void IfAsked() if (!Requested) return; Announce(); - DateTime deadline = DateTime.UtcNow.AddSeconds(Seconds); - while (!ShouldRelease(deadline)) Thread.Sleep(200); + while (!ShouldRelease()) Thread.Sleep(200); } /// The pid line is the orchestration contract: whoever launched - /// this process waits for it before attaching. + /// this process waits for it before attaching. Starts the deadline clock. public static void Announce() { string? stop = StopFile; Console.WriteLine($"holding (pid {Environment.ProcessId}) — send a line to exit" + (stop is null ? $", or wait {Seconds}s." : $", create {stop}, or wait {Seconds}s.")); Console.Out.Flush(); + Elapsed.Restart(); WatchStdin(); } - public static bool ShouldRelease(DateTime deadlineUtc) + /// True once ANY release path has fired: a line on stdin, the stop + /// file, or the deadline. + public static bool ShouldRelease() { - if (_lineReceived || DateTime.UtcNow >= deadlineUtc) return true; + if (_lineReceived || Elapsed.Elapsed >= TimeSpan.FromSeconds(Seconds)) return true; string? stop = StopFile; return stop != null && File.Exists(stop); } diff --git a/examples/flagship/wpf/bad/App.xaml.cs b/examples/flagship/wpf/bad/App.xaml.cs index 41dc58c1..8d594a3c 100644 --- a/examples/flagship/wpf/bad/App.xaml.cs +++ b/examples/flagship/wpf/bad/App.xaml.cs @@ -63,14 +63,13 @@ private void ReportAndHold() // Hold with the message loop STILL RUNNING (see Hold.cs): a parked UI // thread is indistinguishable, to a witness, from a leak. Hold.Announce(); - DateTime deadline = DateTime.UtcNow.AddSeconds(Hold.Seconds); var timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(200), }; timer.Tick += (_, _) => { - if (!Hold.ShouldRelease(deadline)) return; + if (!Hold.ShouldRelease()) return; timer.Stop(); Shutdown(); }; diff --git a/examples/flagship/wpf/bad/Hold.cs b/examples/flagship/wpf/bad/Hold.cs index 37599aec..d07a0de2 100644 --- a/examples/flagship/wpf/bad/Hold.cs +++ b/examples/flagship/wpf/bad/Hold.cs @@ -14,8 +14,11 @@ // otherwise the sample would exit before the witness could attach. Only an // actual line counts. // * Any hold can be forgotten. The deadline applies to every release path, -// so a stray sample can never outlive its job. +// so a stray sample can never outlive its job — and it is measured with a +// Stopwatch, not wall-clock arithmetic: a bound that a system-clock +// adjustment can extend is not a bound. using System; +using System.Diagnostics; using System.IO; using System.Threading; @@ -26,6 +29,7 @@ internal static class Hold private const int DefaultSeconds = 300; private static volatile bool _lineReceived; + private static readonly Stopwatch Elapsed = new(); public static bool Requested => Environment.GetEnvironmentVariable("OWEN_FLAGSHIP_HOLD") == "1"; @@ -38,22 +42,24 @@ internal static class Hold out int s) && s > 0 ? s : DefaultSeconds; /// Print the pid line — the orchestration contract: whoever - /// launched this process waits for it before attaching — and start - /// watching stdin for the interactive release. + /// launched this process waits for it before attaching — start the + /// deadline clock, and start watching stdin for the interactive + /// release. public static void Announce() { string? stop = StopFile; Console.WriteLine($"holding (pid {Environment.ProcessId}) — send a line to exit" + (stop is null ? $", or wait {Seconds}s." : $", create {stop}, or wait {Seconds}s.")); Console.Out.Flush(); + Elapsed.Restart(); WatchStdin(); } /// True once ANY release path has fired: a line on stdin, the stop /// file, or the deadline. - public static bool ShouldRelease(DateTime deadlineUtc) + public static bool ShouldRelease() { - if (_lineReceived || DateTime.UtcNow >= deadlineUtc) return true; + if (_lineReceived || Elapsed.Elapsed >= TimeSpan.FromSeconds(Seconds)) return true; string? stop = StopFile; return stop != null && File.Exists(stop); } diff --git a/examples/flagship/wpf/ok/App.xaml.cs b/examples/flagship/wpf/ok/App.xaml.cs index 8b350a79..1fffdd28 100644 --- a/examples/flagship/wpf/ok/App.xaml.cs +++ b/examples/flagship/wpf/ok/App.xaml.cs @@ -62,14 +62,13 @@ private void ReportAndHold() // Hold with the message loop STILL RUNNING (see Hold.cs): a parked UI // thread is indistinguishable, to a witness, from a leak. Hold.Announce(); - DateTime deadline = DateTime.UtcNow.AddSeconds(Hold.Seconds); var timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(200), }; timer.Tick += (_, _) => { - if (!Hold.ShouldRelease(deadline)) return; + if (!Hold.ShouldRelease()) return; timer.Stop(); Shutdown(); }; diff --git a/examples/flagship/wpf/ok/Hold.cs b/examples/flagship/wpf/ok/Hold.cs index 37599aec..d07a0de2 100644 --- a/examples/flagship/wpf/ok/Hold.cs +++ b/examples/flagship/wpf/ok/Hold.cs @@ -14,8 +14,11 @@ // otherwise the sample would exit before the witness could attach. Only an // actual line counts. // * Any hold can be forgotten. The deadline applies to every release path, -// so a stray sample can never outlive its job. +// so a stray sample can never outlive its job — and it is measured with a +// Stopwatch, not wall-clock arithmetic: a bound that a system-clock +// adjustment can extend is not a bound. using System; +using System.Diagnostics; using System.IO; using System.Threading; @@ -26,6 +29,7 @@ internal static class Hold private const int DefaultSeconds = 300; private static volatile bool _lineReceived; + private static readonly Stopwatch Elapsed = new(); public static bool Requested => Environment.GetEnvironmentVariable("OWEN_FLAGSHIP_HOLD") == "1"; @@ -38,22 +42,24 @@ internal static class Hold out int s) && s > 0 ? s : DefaultSeconds; /// Print the pid line — the orchestration contract: whoever - /// launched this process waits for it before attaching — and start - /// watching stdin for the interactive release. + /// launched this process waits for it before attaching — start the + /// deadline clock, and start watching stdin for the interactive + /// release. public static void Announce() { string? stop = StopFile; Console.WriteLine($"holding (pid {Environment.ProcessId}) — send a line to exit" + (stop is null ? $", or wait {Seconds}s." : $", create {stop}, or wait {Seconds}s.")); Console.Out.Flush(); + Elapsed.Restart(); WatchStdin(); } /// True once ANY release path has fired: a line on stdin, the stop /// file, or the deadline. - public static bool ShouldRelease(DateTime deadlineUtc) + public static bool ShouldRelease() { - if (_lineReceived || DateTime.UtcNow >= deadlineUtc) return true; + if (_lineReceived || Elapsed.Elapsed >= TimeSpan.FromSeconds(Seconds)) return true; string? stop = StopFile; return stop != null && File.Exists(stop); } From edb2157ff8a3c2af9e63020bf4125115d2e9dec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:07:16 +0000 Subject: [PATCH 2/7] docs: lead with the proven lifetime-bug story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README opened with a capability list. It now opens with the one claim the repository can actually back end to end: Owen finds a lifetime bug statically and confirms it at runtime by naming the reference path that keeps the object alive. Under the hero, the two surfaces are shown separately and deliberately NOT merged into one button: `owen check` is the static half and prints the finding at the line that causes it; the runtime witness is a SEPARATE step you run when you want proof, not something that happens on every build. Conflating them would promise a product that does not exist. Every number and every line of output under the hero comes from the flagship sample as CI runs it, and the acceptance contract is stated in the same breath — bad → RETAINED / exit 1, ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots — so a reader can check the claim instead of trusting it. No internal vocabulary: no root kinds, no traversal order, no arc numbers. A first-time reader has not yet asked to care about any of that. Both language variants updated together. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- README.ru.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c8f13979..5f8043b8 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,55 @@ # Own.NET -> Own.NET finds lifetime/resource bugs that C# cannot express: WPF/event -> leaks, missing `Dispose`, DI lifetime mismatch, and pooled-buffer misuse. +> **Owen finds .NET lifetime bugs and confirms them at runtime — showing the +> reference path that keeps the object alive.** *Find leaks before the profiler.* GC collects unreachable objects; Own finds objects that should have become unreachable. `event +=` is acquire, `-=` is -release. +release. It also finds missing `Dispose`, DI lifetime mismatch, and +pooled-buffer misuse — the same ownership question in different skins. + +## Both halves, on one sample + +**The defect, at the line that causes it.** A window subscribes to a +process-lifetime settings hub; the unsubscribe exists, but the close path +never reaches it: + +```console +$ owen check examples/flagship/wpf/bad --fail-on-finding +DocumentWindow.xaml.cs:28: warning: [OWN001] event '_settings.PropertyChanged' + is subscribed (handler 'OnSettingsChanged') but never unsubscribed; its + source is an injected dependency whose lifetime is unknown, so it may + outlive and keep 'DocumentWindow' alive (possible leak) +``` + +**The same object at runtime, and what is actually holding it.** A separate +step — you run it when you want proof, not on every build: + +```text +verdict: RETAINED — DocumentWindow: 200 on the heap, 200 durably retained + +AppSettings + → PropertyChanged + → _invocationList + → handler + → DocumentWindow + +100% of them hang off ONE reference. +``` + +Both halves are held to a contract on every CI run, on Linux and Windows: + +```text +bad → RETAINED / exit 1 +ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots +``` + +The sample is real and runnable: [`examples/flagship/`](examples/flagship/) +(console and WPF, each a `bad`/`ok` pair whose only difference is where the +`-=` lives). What the runtime witness will and will not claim — and why +"reachable" is not "leaked" — is +[`docs/how-owen-proves-retention.md`](docs/how-owen-proves-retention.md). ## Run it in CI — 6 lines diff --git a/README.ru.md b/README.ru.md index 3efff573..c6bcdea8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -2,13 +2,57 @@ # Own.NET -> Own.NET находит баги времени жизни/ресурсов, которые C# не может выразить: -> WPF/event-лики, забытый `Dispose`, рассинхрон DI lifetime и неправильное -> использование pooled-буферов. +> **Owen находит ошибки времени жизни в .NET и подтверждает их во время +> выполнения — показывая ссылочный путь, который держит объект живым.** *Находи лики до профайлера.* GC собирает недостижимые объекты; Own находит объекты, которые должны были стать недостижимыми. `event +=` — это acquire, -`-=` — это release. +`-=` — это release. Он также находит забытый `Dispose`, рассинхрон DI +lifetime и неправильное использование pooled-буферов — тот же вопрос о +владении в разных обличьях. + +## Обе половины, на одном образце + +**Дефект — на строке, которая его порождает.** Окно подписывается на хаб +настроек, живущий всё время процесса; отписка существует, но путь закрытия до +неё не доходит: + +```console +$ owen check examples/flagship/wpf/bad --fail-on-finding +DocumentWindow.xaml.cs:28: warning: [OWN001] event '_settings.PropertyChanged' + is subscribed (handler 'OnSettingsChanged') but never unsubscribed; its + source is an injected dependency whose lifetime is unknown, so it may + outlive and keep 'DocumentWindow' alive (possible leak) +``` + +**Тот же объект во время выполнения — и то, что его действительно держит.** +Отдельный шаг: он запускается, когда нужно доказательство, а не на каждой +сборке: + +```text +verdict: RETAINED — DocumentWindow: 200 on the heap, 200 durably retained + +AppSettings + → PropertyChanged + → _invocationList + → handler + → DocumentWindow + +100% из них висят на ОДНОЙ ссылке. +``` + +Обе половины держатся контракта на каждом прогоне CI, под Linux и Windows: + +```text +bad → RETAINED / exit 1 +ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots +``` + +Образец настоящий и запускаемый: [`examples/flagship/`](examples/flagship/) +(консоль и WPF, каждый — пара `bad`/`ok`, отличающаяся только тем, где живёт +`-=`). Что свидетель времени выполнения утверждает, а что — нет, и почему +«достижим» не значит «утёк»: +[`docs/how-owen-proves-retention.md`](docs/how-owen-proves-retention.md). ## Запустить в CI — 6 строк From 6fb187080efadd730c40c47ca8e1addb99874fcf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:09:24 +0000 Subject: [PATCH 3/7] docs: explain how Owen proves durable retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The article the README hero points at. It walks from the user-visible defect to the boundary of what the proof covers: the guarded unsubscribe, what the static half can and cannot establish, why a static finding is not yet proof, why "reachable from a GC root" is the wrong question, and why durable roots must be walked to exhaustion before a stack frame gets to claim the answer. Two sections exist because the mistakes were real and are more instructive than the design. The first implementation let cost-bounding knobs change the verdict in four distinct ways — heap-order sampling, type-name-only grouping, a hop limit that erased the root kind, and a verdict taken from bare reachability — which is one principle stated four times: sampling and display limits affect presentation, never discovery, classification, aggregation, or the exit code. And the WPF round where a frozen dispatcher produced a real [gc-handle] snapshot of a sample that was leaking nothing: a witness is only as honest as the moment the picture is taken, and the narrow assertion that stayed green through it is its own lesson. Every paragraph is filtered: proven by the gate, observed in a named run, or an architectural rule — and the limits section says plainly what is NOT claimed, including that the witness reports retention rather than causation, repairs nothing, and does not ship inside the published CLI package today. Nothing aspirational is written in the present tense. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- docs/how-owen-proves-retention.md | 244 ++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/how-owen-proves-retention.md diff --git a/docs/how-owen-proves-retention.md b/docs/how-owen-proves-retention.md new file mode 100644 index 00000000..acd1b31f --- /dev/null +++ b/docs/how-owen-proves-retention.md @@ -0,0 +1,244 @@ +# How Owen proves retention + +A leak report is a claim about the future: *this object will never be +collected*. Most tools make that claim from source alone, and most of the time +they are guessing. This is how Owen makes it — what the static half can +establish, what only a running process can, and where the proof stops. + +Everything below is either **proven by CI** on every commit, **observed in a +specific run** (and named as such), or an **architectural rule** the code is +built around. Where something is not yet true, it says so. None of it is a +roadmap entry wearing the present tense. + +--- + +## 1. The bug: an unsubscribe that exists but never runs + +A settings hub lives for the whole process. Every document window subscribes +to it in its constructor so it can restyle itself. There *is* an unsubscribe — +right there in the class: + +```csharp +public void Cleanup(bool keepAlive) +{ + if (!keepAlive) + { + _settings.PropertyChanged -= OnSettingsChanged; + } +} +``` + +And the close path calls `Cleanup(keepAlive: true)`. + +Every closed window stays in the hub's delegate list, with its whole visual +tree behind it. The code review that would catch this has to notice that a +`-=` exists, that it is guarded by a parameter, and that the one caller passes +the value that skips it. Reviewers do not reliably do this. Neither do +checkers that pair each `+=` with any `-=` in the same class. + +This is not a hypothetical. It is issue #278 reduced to its bones — a real +production leak where a formally present unsubscribe sat behind a guard nobody +ever passed `false` to, and 66% of the process heap was retained. + +## 2. What the static half establishes + +`owen check` reads the subscription, the release, and the paths between them. +It flags the subscription when it cannot *prove* the release runs: + +```console +$ owen check examples/flagship/wpf/bad --fail-on-finding +DocumentWindow.xaml.cs:28: warning: [OWN001] event '_settings.PropertyChanged' + is subscribed (handler 'OnSettingsChanged') but never unsubscribed … +``` + +The word doing the work is *provably*. A release counts when it sits somewhere +the platform itself invokes at end of life — `Dispose`, `DisposeAsync`, +`OnClosed`, `OnClosing`, `OnUnloaded`, `OnFormClosed`, `OnFormClosing` — or in +a handler wired to this object's own lifecycle event (`Closed`, `Unloaded`, +`Disposed`, …), plus anything such a method provably calls, resolved by +symbol rather than by name. A `-=` inside `Cleanup(bool)` reached only through +a parameter that switches it off is not evidence, and neither is a same-named +overload nobody calls. + +The fix moves the release into `OnClosed`, unconditionally, and the finding +disappears. Same subscription, same handler — the only change is that teardown +became a teardown. + +**Proven by CI:** the `bad`/`ok` pair is checked on Linux *and* Windows on +every run; `bad` must exit 1 with OWN001, `ok` must exit 0. + +## 3. Why a static finding is not yet proof + +Static analysis answers "is this release provable?" It cannot answer "is this +object actually still held right now, and by what?" Those are different +questions, and the second one is what a developer is really being asked to +believe. + +So Owen has a second half: a runtime witness that attaches to a live process +(or reads a dump), finds the instances of a type, and reports the reference +path from a GC root to them: + +```text +verdict: RETAINED — DocumentWindow: 200 on the heap, 200 durably retained + +AppSettings → PropertyChanged → _invocationList → handler → DocumentWindow +``` + +That is no longer a claim about the future. It is a path you can read, walk +back to a field, and delete. + +Two honest notes. The witness is a **separate step** — it is not part of +`owen check` and does not run on your builds. And it currently lives in +[`audit/runtime/RetentionPath`](../audit/runtime/RetentionPath) as a +standalone tool, not inside the published `Owen.Cli` package. + +## 4. Why "reachable from a GC root" is the wrong question + +The naive version of a witness marks from every GC root and reports whatever +it reaches. That tool will call almost anything a leak, because *reachable +right now* and *durably retained* are not the same claim. + +A local variable in a frame that has not returned yet is a GC root. So is an +object sitting on the finalizer queue. Both mean **"visible at this instant"** +— neither means anything holds the object past this instant. Report them as +retention and every leak hunt drowns in noise; worse, the one real path is +buried among them. + +So roots are classified, not counted: + +| Kind | Means | +| --- | --- | +| `static-event`, `static-field`, `gc-handle` | **durable** — something outside this moment holds it | +| `stack`, `finalizer` | **transient** — alive right now, that is all | +| `unsupported-root:` | an honest refusal: this root kind has no mapping yet | + +The verdict follows from the classification, not from reachability: + +- **RETAINED** — at least one durable retainer. Exit 1. +- **OBSERVED_ONLY** — instances exist and are reachable, but only from + transient roots. Exit 0. Live right now; not established retention. +- **ABSENT** — no instance on the heap. Exit 0. + +An unknown root kind counts as *durable* on purpose. If the mapping is +incomplete, the failure should be loud and visible, never a quietly demoted +verdict. + +## 5. Durable-first, because a stack frame can steal the answer + +An object can be reachable from a durable root *and* be sitting in a register +at the same instant. A single-pass mark credits whichever root it happens to +reach first, and "first" is an implementation detail of iteration order. + +**Observed in a live run on .NET 8:** a `Main` local holding the static +publisher caused the whole retention chain to be attributed to `[stack]` — the +static-event path, the real answer, was invisible. + +The rule the traversal is built around: **durable roots are seeded and walked +to exhaustion before any transient root enters the graph.** Transient paths +then explain only what nothing durable can. The invariant that makes this +sound — a target reachable through a durably-claimed node is itself durably +claimed, so transient traversal can never bury a durable path — is written out +in the code next to the loop that depends on it. + +## 6. What the first implementation got wrong + +Four defects, all found in review, all of the same family: something meant to +bound *cost* silently changed the *answer*. + +1. **Sampling decided the verdict.** The walk took the first N instances in + heap-enumeration order. A type with older garbage ahead of one durably-held + instance read as clean. Fixed: the root-kind census covers *every* reachable + instance; `--sample` bounds only how many paths are resolved for display. +2. **Grouping merged unlike paths.** The signature was hop type names only, so + a stack-rooted and a durably-rooted instance sharing a type sequence + collapsed into one group and inherited whichever classification arrived + first — hiding a real retainer or inventing one. Fixed: the signature + carries the classification and the traversed fields. +3. **`--max-hops` erased the root.** Truncating a long path stopped the unwind + at an intermediate object, whose root kind was `None` → `unsupported-root` + → counted as durable → a long stack-only path reported as RETAINED. Fixed: + the parent chain is walked to the true root for the verdict even when the + rendered path stays short. +4. **Any reachability was a leak.** Before the classification was consulted, a + loop local still in a register read as RETAINED. + +The principle these converge on, and the one worth taking away: + +> Sampling and display limits affect evidence **presentation** — never +> discovery, classification, aggregation, or the exit code. + +## 7. The WPF experiment that lied, and what it taught + +**Observed in a specific CI round.** The fixed WPF sample reported `0 still +subscribed` — our release had run — and the witness simultaneously reported +200 windows retained through a `[gc-handle]` path at 41 hops. Two credible +readings: WPF retains closed windows, or the measurement was wrong. + +It was the measurement. `Window.Close()` finishes through the dispatcher, and +the sample was holding itself open for the witness by parking its UI thread in +a sleep. The application was frozen halfway through tearing the windows down, +and the witness faithfully photographed framework book-keeping mid-teardown. + +With the hold changed to keep the message loop running and the count taken +only once the dispatcher goes idle, the same sample reports `ABSENT` — every +window collected, not one root of any kind. + +Two lessons, both cheap to state and expensive to learn: + +- **A runtime witness is only as honest as the moment you take the picture.** + Whatever suspends the process for measurement must not also change what the + process is holding. +- **The assertion that survived this bug was too narrow to catch it.** The + check asserted "no `static-event` root" — true, specific, and green while + the sample was misbehaving. An assertion scoped tightly enough to survive + anything tests nothing. It now asserts the whole contract. + +## 8. What CI actually proves + +Not "we ran some tests". On every commit, through the **public CLI and its +JSON artifact** — never internal APIs: + +| Claim | How it is checked | +| --- | --- | +| The static verdict is platform-independent | `owen check` runs on both Linux and Windows legs and must agree: `bad` → exit 1 + OWN001, `ok` → exit 0 | +| The samples build anywhere | both WPF projects compile on Linux (`EnableWindowsTargeting`); only *running* them needs Windows | +| The leak is real at runtime | on Windows, the sample is launched and held live, the witness attaches by pid, and must report `RETAINED`, exit 1, a `static-event` durable root, and the path anchors `AppSettings`, `PropertyChanged`, `_invocationList`, `DocumentWindow` | +| The fix is real at runtime | the fixed sample must report exit 0, `ABSENT` or `OBSERVED_ONLY`, and **zero** durable roots | +| The classifier's doctrine holds | a heap-free selftest (16 checks) pins the known root kinds and an unknown one, the verdict rules, and the agreement between the traversal-level and verdict-level definitions of "transient" | +| The demo is reproducible | one script builds, holds, attaches, and machine-validates the JSON against the human-readable verdict; a disagreement between them fails the run | + +The path anchors are checked as **semantics, not text**: type and field names +that must appear, never a verbatim path with addresses and hop counts, which +would break on any harmless change and prove nothing when it passed. + +## 9. Limits, and the operational contract + +The honest boundary of all of the above. + +**The snapshot is a moment.** The witness reports what is true when it reads +the heap. If the process is mid-teardown, mid-GC, or mid-anything, that is +what it reports (§7). Reading it as "always" is your inference, not its claim. + +**`ABSENT` and `OBSERVED_ONLY` are not interchangeable.** `ABSENT` means no +instance was on the heap; `OBSERVED_ONLY` means instances existed and were +reachable, but nothing durable held them. Both mean "no established +retention", which is why the acceptance contract accepts either — pinning one +would be pinning GC timing, which is not a public contract. + +**Unknown roots surface, they do not vanish.** A root kind with no mapping is +reported as `unsupported-root:` and counted as durable. You will see a +verdict you may not like rather than a clean bill of health the tool did not +earn. + +**Linux attach is governed by kernel policy, not by Owen.** Live attach needs +permission to trace the target. Where policy allows it, the analysis runs; +where policy denies it — the common default on modern distributions and on CI +runners — the witness exits **2** with an explicit diagnostic. It does not +retry silently, and it never converts "I could not look" into "I looked and +found nothing". A failed read is never a clean verdict. Running against a dump +avoids the question entirely. + +**What is not claimed.** The witness reports retention, not causation: it +shows the reference that holds the object, not the commit that introduced it. +It does not repair anything. And it is not, today, part of the published CLI +package — it is a separate tool in this repository. From 0e274cc24405ea9fce68a0dba9c9f94078f54772 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:20:07 +0000 Subject: [PATCH 4/7] docs: scope the retention claim to what a snapshot can establish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The article opened by promising the thing its own limits section denies. "A leak report is a claim about the future: this object will never be collected" framed the witness as proving permanence; §9 correctly says a snapshot describes a moment, and reading it as "always" is the reader's inference. A document cannot lead with a claim it later withdraws. The opening now states the two questions plainly — can the release be proven to run, and is a live process holding the object through a durable path — and assigns each to the half that answers it, the second explicitly for a particular heap snapshot. Dropped with it: "most tools are guessing". That is a comparative claim about other analysers, and this repository has not done the comparative study that would back it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- docs/how-owen-proves-retention.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/how-owen-proves-retention.md b/docs/how-owen-proves-retention.md index acd1b31f..0a1a36cd 100644 --- a/docs/how-owen-proves-retention.md +++ b/docs/how-owen-proves-retention.md @@ -1,14 +1,18 @@ # How Owen proves retention -A leak report is a claim about the future: *this object will never be -collected*. Most tools make that claim from source alone, and most of the time -they are guessing. This is how Owen makes it — what the static half can -establish, what only a running process can, and where the proof stops. +A lifetime-bug report asks two different questions: can the program prove that +an object's release runs, and is a live process actually holding that object +through a durable reference path? + +Static analysis answers the first question. A runtime witness answers the +second for a particular heap snapshot. Owen keeps those claims separate: +source establishes the missing lifetime guarantee; runtime evidence shows what +is holding the object at the moment of observation and where that path leads. Everything below is either **proven by CI** on every commit, **observed in a -specific run** (and named as such), or an **architectural rule** the code is -built around. Where something is not yet true, it says so. None of it is a -roadmap entry wearing the present tense. +specific run** and named as such, or an **architectural rule** the +implementation follows. Where something is not yet true, it says so. None of +it is a roadmap entry wearing the present tense. --- From 559608d7ae5eea1fe779ee06c615fac439219879 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:20:07 +0000 Subject: [PATCH 5/7] =?UTF-8?q?feat(action):=20annotate=20by=20default,=20?= =?UTF-8?q?fail=20only=20when=20asked=20=E2=80=94=20and=20never=20on=20an?= =?UTF-8?q?=20operational=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a checker to someone's repository should not turn their CI red on day one. The action's fail-on-finding now defaults to false: findings are published as PR annotations or SARIF exactly as before, and the step succeeds. Gating is an explicit opt-in. The dangerous half of that change is the half worth spelling out. "Annotations instead of failure" is a policy about DEFECTS FOUND IN YOUR CODE. It must never cover the analyser crashing, an unreadable input, a missing project, or a SARIF that could not be written — those are the tool failing to LOOK, and a friendly default that swallows them turns "could not run" into a green check. So the status contract is now four explicit tiers: exit 0 -> success, no annotations exit 1, findings -> annotations/SARIF published, success exit 1 + fail-on-finding: true -> annotations/SARIF published, failure exit >= 2 -> failure ALWAYS, diagnostic preserved To make that enforceable the non-SARIF branch stopped delegating its status to own-check. It now always runs with --fail-on-finding so it can see the true tier (0/1/>=2) and decides the step's outcome itself; run without the flag, own-check folds findings into 0 and tier 1 becomes indistinguishable from tier 0 — exactly the distinction the new default depends on. The SARIF branch already worked this way. Both modes analyse identically and publish identical evidence; only the final status differs. Verified locally by replaying the branch logic against real runs: default -> 160 annotations and SUCCESS; opt-in -> the same tree, FAILURE; clean tree -> SUCCESS with zero annotations; malformed config -> FAILURE at exit 2 even with fail-on-finding false. All four tiers are now pinned in ci.yml. Deliberately there rather than only in action-marketplace-readiness.yml, which covers similar ground for the consumer fixture but is path-filtered — a change to the core's exit codes, where these tiers originate, would never wake it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 76 ++++++++++++++++++++++++++++++++++++++++ README.md | 18 +++++++++- README.ru.md | 18 +++++++++- action.yml | 35 ++++++++++++++---- 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a1b388a..aa378ecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2011,6 +2011,22 @@ jobs: ' "$out" >/dev/null \ || { echo "FAIL: a result is unlocated or references an undeclared rule"; exit 1; } echo "OK: SARIF 2.1.0 — Owen driver, every result rule-backed + located" + - name: Fixtures for the action's status contract (clean tree, broken config) + run: | + # Tier 0: a tree with nothing to report. + mkdir -p "$RUNNER_TEMP/owen-action-clean" + printf 'public class Clean { public int M() { return 1; } }\n' \ + > "$RUNNER_TEMP/owen-action-clean/Clean.cs" + # Asserted here rather than on the action step: a `uses:` step's + # stdout is not capturable, so "annotates nothing" is checked through + # the same script the action runs. + out=$(scripts/own-check.sh --format github -- "$RUNNER_TEMP/owen-action-clean") + [ -z "$(echo "$out" | grep '^::' || true)" ] \ + || { echo "FAIL: a clean tree must emit no annotations, got:"; echo "$out"; exit 1; } + echo "OK: clean tree, zero annotations" + # Tier >=2: a config own-check refuses to parse, so it exits 2 before + # analysing anything. + printf 'this is not = valid toml [[[\n' > "$RUNNER_TEMP/owen-broken.toml" - name: The composite action runs end-to-end (non-failing) uses: ./ with: @@ -2018,6 +2034,66 @@ jobs: format: github fail-on-finding: "false" + # The four tiers of the action's status contract, each pinned. The point + # of "annotations, not failures" is that it applies to FINDINGS ONLY; + # every other tier must keep behaving exactly as before, or the friendly + # default silently turns "the tool could not run" into a green check. + # + # Deliberately here and not only in action-marketplace-readiness.yml, + # which covers the same ground for the consumer fixture: that workflow is + # PATH-FILTERED (action.yml, own-check.sh, …), so a change to the Python + # core's exit codes — where these tiers actually originate — would never + # wake it. This job runs on every push and PR. + - name: "Tier 1 — findings, DEFAULT inputs: annotations published, step succeeds" + id: default_findings + uses: ./ + with: + path: frontend/roslyn/samples + format: github + # fail-on-finding deliberately NOT set: this is the out-of-the-box + # experience of someone who just added Owen to their repository. + - name: "Tier 1 — the default really was non-blocking" + run: | + [ "${{ steps.default_findings.outcome }}" = "success" ] \ + || { echo "FAIL: a leaky tree must not fail the step by default"; exit 1; } + echo "OK: findings did not fail the step under default inputs" + - name: "Tier 1 opt-in — fail-on-finding: true turns the same findings into a failure" + id: strict_findings + continue-on-error: true + uses: ./ + with: + path: frontend/roslyn/samples + format: github + fail-on-finding: "true" + - name: "Tier 1 opt-in — the strict mode really did fail" + run: | + [ "${{ steps.strict_findings.outcome }}" = "failure" ] \ + || { echo "FAIL: fail-on-finding: true must fail on a leaky tree (outcome=${{ steps.strict_findings.outcome }})"; exit 1; } + echo "OK: the same tree, the same annotations, a failing status" + - name: "Tier 0 — a clean tree succeeds and annotates nothing" + id: clean_tree + uses: ./ + with: + path: ${{ runner.temp }}/owen-action-clean + format: github + - name: "Tier >=2 — an operational failure fails the step even with fail-on-finding: false" + id: operational_failure + continue-on-error: true + uses: ./ + with: + path: frontend/roslyn/samples + format: github + fail-on-finding: "false" + # A malformed own.toml makes own-check exit 2 before it can analyse + # anything. That is the tool failing to LOOK, not a defect found in + # the caller's code — the friendly default must not absorb it. + config: ${{ runner.temp }}/owen-broken.toml + - name: "Tier >=2 — the operational failure really did fail" + run: | + [ "${{ steps.operational_failure.outcome }}" = "failure" ] \ + || { echo "FAIL: an operational failure (exit >=2) must fail the step regardless of fail-on-finding (outcome=${{ steps.operational_failure.outcome }})"; exit 1; } + echo "OK: 'could not look' did not become 'looked and found nothing'" + # Dog-food the code-scanning surface end-to-end (P-013): run the composite action # with format: sarif over the sample tree, then upload the log to GitHub code # scanning. The repo is public, so code scanning is free — this is the live proof diff --git a/README.md b/README.md index 5f8043b8..2ef268e0 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,26 @@ The sample is real and runnable: [`examples/flagship/`](examples/flagship/) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: PhysShell/Own.NET@main # pre-release: no tagged release yet — pin a commit SHA for reproducibility with: + path: . format: github # inline PR annotations; use "sarif" for the Security tab - fail-on-finding: "true" ``` +Findings arrive as PR annotations and **the step stays green** — adding Owen +to a repository does not turn its CI red on day one. When you are ready to +gate on it: + +```yaml + with: + path: . + fail-on-finding: true +``` + +Both modes analyse identically and publish identical annotations/SARIF; only +the step's final status differs. And `fail-on-finding` governs *findings* +only — if Owen cannot complete the analysis (crash, unreadable input, no SARIF +written) the step fails in either mode. A friendly default must never turn +"could not look" into "looked and found nothing". + Once a release ships, prefer a pinned tag (`@v0.1.0`) or the moving major tag (`@v0`) over `@main` — see [`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md) diff --git a/README.ru.md b/README.ru.md index c6bcdea8..d6eb537d 100644 --- a/README.ru.md +++ b/README.ru.md @@ -60,10 +60,26 @@ ok → ABSENT or OBSERVED_ONLY / exit 0 / zero durable roots - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: PhysShell/Own.NET@main # пре-релиз: тегов ещё нет — для воспроизводимости пиньте commit SHA with: + path: . format: github # инлайн-аннотации в PR; "sarif" — для вкладки Security - fail-on-finding: "true" ``` +Находки приходят аннотациями в PR, а шаг **остаётся зелёным** — добавление +Owen в репозиторий не красит чужой CI в первый же день. Когда готовы на нём +гейтить: + +```yaml + with: + path: . + fail-on-finding: true +``` + +Оба режима анализируют одинаково и публикуют одинаковые аннотации/SARIF; +отличается только итоговый статус шага. И `fail-on-finding` управляет только +*находками*: если Owen не смог довести анализ до конца (краш, нечитаемый вход, +SARIF не записан), шаг падает в любом режиме. Дружелюбный дефолт не имеет +права превращать «не смог посмотреть» в «посмотрел и ничего не нашёл». + После первого релиза предпочитайте закреплённый тег (`@v0.1.0`) или подвижный major-тег (`@v0`) вместо `@main` — политика версионирования в [`docs/notes/action-marketplace-readiness.md`](docs/notes/action-marketplace-readiness.md). diff --git a/action.yml b/action.yml index 0cb780f8..10623b3c 100644 --- a/action.yml +++ b/action.yml @@ -33,9 +33,15 @@ inputs: required: false default: "error" fail-on-finding: - description: "Fail the step when any leak is found." + description: >- + Whether a FINDING fails the step. Default false: findings are published + as annotations/SARIF and the step succeeds, so adding Owen to a repository + cannot turn its CI red on day one. Set true once you are ready to gate on + it. This input governs findings ONLY — an operational failure (the + analyser crashed, the input could not be read, no SARIF could be written) + always fails the step, in either mode. required: false - default: "true" + default: "false" python-version: description: "Python version for the Owen core." required: false @@ -130,8 +136,25 @@ runs: fi exit 0 fi - args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT" --severity "$OWN_SEVERITY" "${config_args[@]}") - if [ "$OWN_FAIL_ON_FINDING" = "true" ]; then - args+=(--fail-on-finding) + # Always ask own-check for its TRUE tier (0 clean / 1 findings / >=2 + # operational failure) and decide the step's status here. Run WITHOUT + # --fail-on-finding and the script folds findings into 0, making tier 1 + # indistinguishable from tier 0 — and tier 1 is the only one this + # action is allowed to negotiate about. + set +e + "$check" --root "${{ github.action_path }}" --format "$OWN_FORMAT" \ + --severity "$OWN_SEVERITY" "${config_args[@]}" --fail-on-finding -- "$OWN_PATH" + rc=$? + set -e + if [ "$rc" -ge 2 ]; then + # NOT a finding: the analyser crashed, the input could not be read, or + # the contract drifted. "Annotations instead of failure" is a policy + # about defects found in your code, never about the tool failing to + # look — that must not reach anyone as a green check. + echo "::error::Owen could not complete the analysis (exit $rc). This is an operational failure, not a finding — fail-on-finding does not apply to it. The diagnostic is above." + exit "$rc" + fi + if [ "$rc" -eq 1 ] && [ "$OWN_FAIL_ON_FINDING" = "true" ]; then + exit 1 fi - "$check" "${args[@]}" -- "$OWN_PATH" + exit 0 From 35872372d04316b1d53a680d7d9f2c78cd0948e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:19:01 +0000 Subject: [PATCH 6/7] feat(audit): name the policy that refused the attach, and pin the refusal in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Attach allowed -> analysis; denied by policy -> exit 2 with an explicit diagnostic" was the documented contract, and only the exit code was true. A denied attach printed the raw ClrMD exception, which says nothing about why: a developer on Ubuntu — where Yama's ptrace_scope defaults to 1 and forbids attaching to anything that is not a descendant — was told "could not attach" and left to guess. The witness now explains it: the policy value it read, that the target was alive so this is permission and not absence, that Owen DID NOT LOOK and this is therefore not a verdict about the heap, and the four ways forward (dump, launch the target as a descendant, PR_SET_PTRACER, or relax the policy deliberately). The advice is narrow on purpose — live attach, Linux, target exists, Yama actually restricting. A typo'd pid still gets "process is not running" without a lecture about kernel security. CI now proves the refusal instead of trusting it. One step sets ptrace_scope=1, holds a sample as a SIBLING of the witness (the shape Yama forbids), and requires exit 2, the policy named in the diagnostic, and NO runtime.json written — a refused read must not leave a verdict artifact behind. The next step sets the scope to 0 and requires the full end-to-end demo. Both halves of the contract, asserted on every run. This is the failure the first CI round of the witness arc actually hit. docs/runtime-witness-operations.md is the operator-facing page: the exit-code tiers, the ptrace_scope table, how to choose among the options (a dump is the right default for anything you did not launch), and the rule that policy is relaxed in a workflow — visibly, on a throwaway runner — never inside a script people also run on their laptops. Owen does not escalate, does not retry with sudo, and does not ask to be run as root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 39 ++++++++ audit/runtime/RetentionPath/Program.cs | 40 ++++++++ docs/how-owen-proves-retention.md | 11 ++- docs/runtime-witness-operations.md | 122 +++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 docs/runtime-witness-operations.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa378ecb..5b35ea84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2645,6 +2645,45 @@ jobs: print(f"ok: nothing durably retains the window — verdict " f"{doc.get('verdict')}, roots seen: {dict(kinds) or 'none'}") PY + - name: "attach denied by kernel policy is an honest exit 2, never a clean verdict (A4)" + if: runner.os == 'Linux' + run: | + # The operational contract, asserted rather than described: with Yama + # at its default scope, attaching to a NON-DESCENDANT process is + # refused by the kernel. The witness must say so, name the policy, and + # exit 2 — the tier that means "I did not look", distinct from 0 + # (looked, nothing retained) and 1 (looked, retention found). This is + # the failure the first CI round of this arc actually hit. + # + # Runs BEFORE the demo step relaxes the scope; the app is a sibling of + # the witness (both children of this shell), which is exactly the + # shape Yama scope 1 forbids. + sudo sysctl -w kernel.yama.ptrace_scope=1 + dotnet build "$GITHUB_WORKSPACE/examples/flagship/console/bad" -c Release -v quiet + APP="$GITHUB_WORKSPACE/examples/flagship/console/bad/bin/Release/net8.0/BadDocumentApp.dll" + WITNESS="$GITHUB_WORKSPACE/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll" + STOP="$RUNNER_TEMP/denied-stop"; LOG="$RUNNER_TEMP/denied-app.log" + rm -f "$STOP" + OWEN_FLAGSHIP_HOLD=1 OWEN_FLAGSHIP_STOP="$STOP" OWEN_FLAGSHIP_HOLD_SECONDS=120 \ + dotnet "$APP" > "$LOG" 2>&1 & + for _ in $(seq 1 60); do grep -q "holding (pid" "$LOG" 2>/dev/null && break; sleep 1; done + grep -q "holding (pid" "$LOG" || { cat "$LOG"; echo "FAIL: sample never held"; exit 1; } + PID=$(sed -n 's/.*holding (pid \([0-9]*\)).*/\1/p' "$LOG" | head -1) + set +e + out=$(dotnet "$WITNESS" roots --pid "$PID" --type Owen.Flagship.DocumentView \ + --out "$RUNNER_TEMP/denied.json" 2>&1) + rc=$? + set -e + touch "$STOP" + echo "$out" + [ "$rc" -eq 2 ] || { echo "FAIL: a denied attach must exit 2, got $rc"; exit 1; } + echo "$out" | grep -q "ptrace_scope" \ + || { echo "FAIL: the diagnostic must name the policy that refused"; exit 1; } + echo "$out" | grep -q "NOT a verdict" \ + || { echo "FAIL: the diagnostic must say it did not look"; exit 1; } + [ ! -s "$RUNNER_TEMP/denied.json" ] \ + || { echo "FAIL: a refused attach must not write a verdict artifact"; exit 1; } + echo "OK: denied attach -> exit 2, policy named, no artifact written" - name: "flagship demo orchestrator end-to-end: bad DEMONSTRATED, ok VERIFIED (A3/A4)" if: runner.os == 'Linux' run: | diff --git a/audit/runtime/RetentionPath/Program.cs b/audit/runtime/RetentionPath/Program.cs index 502630e4..700f0c29 100644 --- a/audit/runtime/RetentionPath/Program.cs +++ b/audit/runtime/RetentionPath/Program.cs @@ -75,10 +75,50 @@ private static int Main(string[] args) // A failed read must not read as "clean" — exit 2, distinct from // 0 (analysed, nothing retained) and 1 (analysed, retention found). Console.Error.WriteLine($"retention-path: {ex.GetType().Name}: {ex.Message}"); + foreach (var line in AttachAdvice(pid, live: dump == null)) + { + Console.Error.WriteLine(line); + } return 2; } } + /// + /// Turn a bare ClrMD exception into something a person can act on when + /// the kernel — not the tool — refused the attach. On Linux, Yama's + /// ptrace_scope decides whether one process may trace another; the + /// default on most distributions and on CI runners forbids attaching to + /// a process that is not a descendant, and the resulting exception says + /// nothing about why. + /// + /// Deliberately narrow: advice only when this really could be the cause + /// — a LIVE attach, on Linux, where the target exists (a missing pid is + /// a different failure and deserves no lecture about ptrace) and Yama + /// is actually restricting. Otherwise the exception stands alone. + /// + private static IEnumerable AttachAdvice(int pid, bool live) + { + if (!live || !OperatingSystem.IsLinux()) yield break; + + try { using var _ = System.Diagnostics.Process.GetProcessById(pid); } + catch { yield break; } // no such process: not a permission story + + string scope; + try { scope = File.ReadAllText("/proc/sys/kernel/yama/ptrace_scope").Trim(); } + catch { yield break; } // no Yama on this kernel + if (scope == "0") yield break; + + yield return $" the target is alive, so this is a PERMISSION failure: the kernel's"; + yield return $" Yama policy (/proc/sys/kernel/yama/ptrace_scope = {scope}) forbids attaching"; + yield return " to a process that is not a descendant of this one. Owen did not look —"; + yield return " this is NOT a verdict about the target's heap. Options:"; + yield return " * take a dump and read that instead: retention-path roots --dump …"; + yield return " * start the target FROM the witness, so it is a descendant;"; + yield return " * have the target opt in: prctl(PR_SET_PTRACER, );"; + yield return " * or relax the policy deliberately and temporarily:"; + yield return " sudo sysctl -w kernel.yama.ptrace_scope=0"; + } + private static int Census(RetentionWalker walker, string[] args) { var c = walker.Census(); diff --git a/docs/how-owen-proves-retention.md b/docs/how-owen-proves-retention.md index 0a1a36cd..ff13182a 100644 --- a/docs/how-owen-proves-retention.md +++ b/docs/how-owen-proves-retention.md @@ -237,10 +237,13 @@ earn. **Linux attach is governed by kernel policy, not by Owen.** Live attach needs permission to trace the target. Where policy allows it, the analysis runs; where policy denies it — the common default on modern distributions and on CI -runners — the witness exits **2** with an explicit diagnostic. It does not -retry silently, and it never converts "I could not look" into "I looked and -found nothing". A failed read is never a clean verdict. Running against a dump -avoids the question entirely. +runners — the witness exits **2**, names the policy that refused, and writes no +artifact. It does not retry silently, does not escalate, and never converts "I +could not look" into "I looked and found nothing". Running against a dump +avoids the question entirely. The full operational contract, including the +choice between a dump, a descendant launch, `PR_SET_PTRACER`, and relaxing the +policy, is in +[`docs/runtime-witness-operations.md`](runtime-witness-operations.md). **What is not claimed.** The witness reports retention, not causation: it shows the reference that holds the object, not the commit that introduced it. diff --git a/docs/runtime-witness-operations.md b/docs/runtime-witness-operations.md new file mode 100644 index 00000000..74368656 --- /dev/null +++ b/docs/runtime-witness-operations.md @@ -0,0 +1,122 @@ +# Running the retention witness: permissions and failure modes + +The static half of Owen reads files. The runtime witness reads **another +process's memory**, which is a privileged operation on every operating system +that takes security seriously. This page is the operational contract: what it +needs, what happens when it is refused, and why a refusal is never reported as +a clean result. + +The witness lives in [`audit/runtime/RetentionPath`](../audit/runtime/RetentionPath) +and is a standalone tool — it is not part of the published `Owen.Cli` package +today. + +## Two ways to read a heap + +```console +$ retention-path roots --pid 4213 --type DocumentView # attach to a live process +$ retention-path roots --dump core.4213 --type DocumentView # read a dump file +``` + +**Live attach** suspends the target while it reads, then releases it. It needs +permission to trace that process. **Dump** mode needs only a readable file, so +it sidesteps the entire permission question — if attaching is inconvenient or +forbidden, this is the answer, not a workaround. + +## The exit-code contract + +| Exit | Meaning | +| --- | --- | +| 0 | The heap was read. `ABSENT` or `OBSERVED_ONLY` — nothing durably retains the type. | +| 1 | The heap was read. `RETAINED` — a durable retention path exists, and it is printed. | +| 2 | **The heap was not read.** Usage error, unreadable target, refused attach. | + +Exit 2 is the one that matters here. *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. A refused attach also +writes **no** `runtime.json` artifact — there is no verdict to record. + +**Proven by CI:** a denied attach exits 2, names the policy that refused, and +leaves no artifact behind. + +## Linux: Yama's `ptrace_scope` + +On Linux the decision belongs to the kernel, not to Owen. The Yama LSM +publishes its policy at `/proc/sys/kernel/yama/ptrace_scope`: + +| Value | Who may attach | +| --- | --- | +| `0` | any process of the same user (classic behaviour) | +| `1` | **only a descendant** — the default on Ubuntu, Debian, and GitHub-hosted runners | +| `2` | admin only | +| `3` | nobody; attach is disabled until reboot | + +Under the common default (`1`), attaching to a service you did not start from +this shell is refused *even though you own it*. The raw ClrMD exception does +not explain that, so the witness adds the reason and the ways out: + +```console +$ retention-path roots --pid 4213 --type DocumentView +retention-path: ClrDiagnosticsException: Could not attach to process 4213 + the target is alive, so this is a PERMISSION failure: the kernel's + Yama policy (/proc/sys/kernel/yama/ptrace_scope = 1) forbids attaching + to a process that is not a descendant of this one. Owen did not look — + this is NOT a verdict about the target's heap. Options: + * take a dump and read that instead: retention-path roots --dump … + * start the target FROM the witness, so it is a descendant; + * have the target opt in: prctl(PR_SET_PTRACER, ); + * or relax the policy deliberately and temporarily: + sudo sysctl -w kernel.yama.ptrace_scope=0 +``` + +The advice is deliberately narrow. It appears only when it could actually be +the cause — a live attach, on Linux, where the target process exists and Yama +is restricting. A typo'd pid gets the plain "process is not running" and no +lecture about kernel policy. + +### Choosing among the options + +- **A dump** is the right default for anything you did not launch yourself — + production services especially. No policy change, no privileges, and the + file can be read somewhere else entirely. +- **Launching the target from the witness** suits reproductions and demos: + descendants are always traceable. +- **`PR_SET_PTRACER`** is for programs that expect to be inspected and opt in + themselves. It requires changing the target. +- **Relaxing `ptrace_scope`** weakens a system-wide protection for every + process on the machine. Reasonable on a disposable CI runner or a developer + box; think twice anywhere else, and set it back. + +### What Owen does *not* do + +It does not attempt to escalate, does not retry with `sudo`, and does not +suggest running the whole tool as root. The witness stays inside whatever +permission it was given, reports honestly when that is not enough, and leaves +the policy decision to the operator. + +`scripts/flagship-demo.sh` is deliberately **sudo-free** for the same reason: a +demo script that quietly rewrites a kernel security setting is a bad neighbour. +The one place the project relaxes `ptrace_scope` is inside its own CI workflow, +on a throwaway runner, as one visible line. + +## Windows + +There is no Yama equivalent. Attaching works when the caller has sufficient +rights over the target — same user, or an elevated process for anything else. +Denials surface the same way: exit 2 with the underlying diagnostic, never a +verdict. CI attaches to its own child process, which needs no elevation. + +## In CI + +Two rules, both worth copying: + +1. **Read the exit code, not the output.** Exit 2 means the step failed to + measure; treat it as a broken step, not as a passing check with an empty + result. +2. **Relax policy in the workflow, never in the tool.** If a job needs a live + attach on a runner, the `sysctl` belongs in the job — visible in the log, + scoped to that machine — not hidden inside a script that people also run on + their laptops. + +Own.NET's own gate does exactly this, and asserts both halves: one step sets +`ptrace_scope=1` and requires the honest refusal; the next sets it to `0` and +requires the full end-to-end demo to produce its retention path. From 604272eb8405d5ff370e53657cef564ad779e1cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 15:28:43 +0000 Subject: [PATCH 7/7] fix(check,audit): a broken stage 1 is not a finding; Yama advice per mode; sync the documented default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all real. The first is a regression this branch introduced and is the important one. 1. A FAILED EXTRACTOR READ AS A FINDING (P1). own-check.sh runs under `set -euo pipefail`, so a stage-1 failure — a broken build, a crashed extractor — killed the script with exit 1. Exit 1 is the "analysed, and there are findings" tier. While the Action defaulted to failing on findings, the ambiguity was invisible: both outcomes failed the step. Changing the default to annotate-and-pass turned it into the exact failure this branch exists to prevent — a green check over a run that analysed nothing. Reproduced before fixing: `--root` at a path with no extractor project exited 1. Stage-1 failures are now normalised into the >=2 tier in both own-check.sh and own-check.ps1 (the extractor's own contract codes, 2 = usage and 4 = no input, already live there and pass through untouched). CI pins it, because a doctrine that only lives in a commit message decays. 2. YAMA ADVICE ASSUMED MODE 1 (P2). The diagnostic explained every nonzero ptrace_scope as "not a descendant" and offered remedies that only work there. Mode 2 requires CAP_SYS_PTRACE, where PR_SET_PTRACER is useless; mode 3 disables attach until reboot, where no sysctl or capability helps at all. Confident, actionable, wrong advice is worse than a bare exception, so the message now branches per mode and says plainly when the only way forward on this boot is a dump. 3. STALE DOCUMENTED DEFAULT (P2). docs/suppression-and-fp-policy.md still told readers the Action defaults to failing on findings — a document someone could follow into an ungated pipeline believing it was gated. Updated, and while there, it now states the tier boundary too: the lever governs findings, never operational failures. Verified locally: broken stage 1 -> 2 (was 1); clean -> 0; findings with the flag -> 1; findings without it -> 0; malformed config -> 2. Witness rebuilt, selftest 16/16, full suite and ruff green, demo unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015de4MezSeUnZBoWq1fFU5M --- .github/workflows/ci.yml | 16 +++++++++ audit/runtime/RetentionPath/Program.cs | 47 +++++++++++++++++++++----- docs/suppression-and-fp-policy.md | 2 +- scripts/own-check.ps1 | 10 +++++- scripts/own-check.sh | 13 +++++++ 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b35ea84..fe6d604b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1984,6 +1984,22 @@ jobs: echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1 fi echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit" + - name: A broken stage 1 is a hard error, never the "findings" tier + run: | + # Exit 1 means "analysed, and there are findings". If a failed + # extractor build could also exit 1, then a caller that chooses NOT to + # gate on findings — which is now the Action's default — reads a run + # that analysed nothing as clean. So stage-1 failures are normalised + # into the >=2 tier, and this pins it: --root at a path with no + # extractor project makes `dotnet run` fail with 1. + set +e + scripts/own-check.sh --root "$RUNNER_TEMP/no-such-root" --format github \ + -- "$RUNNER_TEMP/owen-action-clean" >/dev/null 2>"$RUNNER_TEMP/stage1.err" + rc=$? + set -e + tail -3 "$RUNNER_TEMP/stage1.err" || true + [ "$rc" -ge 2 ] || { echo "FAIL: a broken stage 1 must exit >=2 (the tool did not look), got $rc"; exit 1; } + echo "OK: stage-1 failure landed in the hard-error tier (exit $rc)" - name: SARIF surface is a valid 2.1.0 log (the structure code scanning enforces) run: | # The contract GitHub's code-scanning ingest enforces, checked locally so diff --git a/audit/runtime/RetentionPath/Program.cs b/audit/runtime/RetentionPath/Program.cs index 700f0c29..146b9293 100644 --- a/audit/runtime/RetentionPath/Program.cs +++ b/audit/runtime/RetentionPath/Program.cs @@ -108,15 +108,44 @@ private static IEnumerable AttachAdvice(int pid, bool live) catch { yield break; } // no Yama on this kernel if (scope == "0") yield break; - yield return $" the target is alive, so this is a PERMISSION failure: the kernel's"; - yield return $" Yama policy (/proc/sys/kernel/yama/ptrace_scope = {scope}) forbids attaching"; - yield return " to a process that is not a descendant of this one. Owen did not look —"; - yield return " this is NOT a verdict about the target's heap. Options:"; - yield return " * take a dump and read that instead: retention-path roots --dump …"; - yield return " * start the target FROM the witness, so it is a descendant;"; - yield return " * have the target opt in: prctl(PR_SET_PTRACER, );"; - yield return " * or relax the policy deliberately and temporarily:"; - yield return " sudo sysctl -w kernel.yama.ptrace_scope=0"; + yield return " the target is alive, so this is a PERMISSION failure: the kernel's"; + yield return $" Yama policy (/proc/sys/kernel/yama/ptrace_scope = {scope}) refused it."; + yield return " Owen did not look — this is NOT a verdict about the target's heap."; + + // Each mode restricts something different, and the remedies do not + // carry over: telling a scope-3 user to relaunch the target as a + // descendant would be confident, actionable, and wrong. + switch (scope) + { + case "1": + yield return " Mode 1: only a DESCENDANT of the tracer may be attached to. Options:"; + yield return " * take a dump and read that instead: retention-path roots --dump …"; + yield return " * start the target FROM the witness, so it is a descendant;"; + yield return " * have the target opt in: prctl(PR_SET_PTRACER, );"; + yield return " * or relax the policy deliberately and temporarily:"; + yield return " sudo sysctl -w kernel.yama.ptrace_scope=0"; + break; + case "2": + yield return " Mode 2: attaching requires CAP_SYS_PTRACE — descendant or not, and"; + yield return " PR_SET_PTRACER does not help here. Options:"; + yield return " * take a dump and read that instead: retention-path roots --dump …"; + yield return " * run the witness with CAP_SYS_PTRACE (e.g. under sudo);"; + yield return " * or relax the policy deliberately and temporarily:"; + yield return " sudo sysctl -w kernel.yama.ptrace_scope=0"; + break; + case "3": + yield return " Mode 3: attaching is disabled outright and CANNOT be re-enabled at"; + yield return " runtime — the value is locked until reboot, so no sysctl, capability,"; + yield return " or opt-in will help on this boot. Options:"; + yield return " * take a dump and read that instead: retention-path roots --dump …"; + yield return " * or change the policy in config and reboot."; + break; + default: + yield return $" Mode {scope} is not one this build knows (0-3 are documented). Options:"; + yield return " * take a dump and read that instead: retention-path roots --dump …"; + yield return " * or consult your kernel's Yama documentation for this value."; + break; + } } private static int Census(RetentionWalker walker, string[] args) diff --git a/docs/suppression-and-fp-policy.md b/docs/suppression-and-fp-policy.md index b9c5ecbf..5b963e86 100644 --- a/docs/suppression-and-fp-policy.md +++ b/docs/suppression-and-fp-policy.md @@ -44,7 +44,7 @@ fire on unprovable input. | Lever | Status | Scope | |---|---|---| | `--severity warning` | **works today** (P-013) | Global: downgrades every error-tier finding for that run to advisory. Per-run, not per-finding — an escape hatch for "show me everything, but don't fail the build yet," not a way to silence one specific site. | -| `--fail-on-finding` set to off | **works today** (P-013) | Global: findings still print/annotate, but the process/step exit code stays 0. The CLI (`own-check.sh`) is off by default — you must pass the flag to make findings fail the shell. The GitHub Action inverts that for safety: its `fail-on-finding` input defaults to `"true"` (fails the step on a finding), so to get the "annotate but don't fail" behavior in CI you must explicitly set `fail-on-finding: "false"`. | +| `--fail-on-finding` set to off | **works today** (P-013) | Global: findings still print/annotate, but the process/step exit code stays 0. Both surfaces are off by default: the CLI (`own-check.sh`) needs the flag to make findings fail the shell, and the Action's `fail-on-finding` input defaults to `"false"` — findings annotate the PR (or land in code scanning) and the step stays green, so adding Owen to a repository cannot turn its CI red on day one. Set `fail-on-finding: "true"` when you are ready to gate on it. **This lever governs findings only**: an operational failure (the analyser crashed, the input could not be read, no SARIF could be written — own-check's `>= 2` tier) fails the step in either mode. | | `[OwnIgnore("reason")]` | **works today** on `IDisposable` fields (P-004, #209) | Inline, per-site suppression attribute — the fine-grained escape hatch for a specific site the checker can't see enough context to clear. Put `[OwnIgnore("reason")]` on the field; the finding is then **silent-but-counted** — kept out of the exit code and the human findings stream, but tallied in the run summary and carried in SARIF `suppressions` (`kind: "inSource"`, your reason as the `justification`) so nothing is lost and a consumer can audit it. The **reason is mandatory**: a reason-less `[OwnIgnore]` (or an empty `[OwnIgnore("")]`) does **not** suppress — a suppression is a documented decision, never a silent accept. The attribute is matched by simple name, so you can declare your own `OwnIgnoreAttribute`. Currently reads on `IDisposable` **field** declarations (the clearest attribute site); other sites (subscriptions, timers) are follow-up increments. | | Project-wide config (`.ownrc`/`own.toml`) | **draft, not implemented** (P-015) | Per-check-category enable/disable + severity + per-path overrides (e.g. relax a category under `tests/`). Stub status — format (TOML vs INI vs JSON) and enforcement point are still open questions in the proposal. | | `corpus/oracle-fp-baseline.txt` | **exists, but not a user-facing suppression tool** | An allowlist the *oracle comparator* (`scripts/oracle_compare.py`, a dev/maintainer tool) uses to keep already-triaged false positives out of the `own-only` bucket on re-runs. It doesn't change what `own-check`/the Action reports — it only keeps the oracle's own triage queue from re-showing confirmed noise. | diff --git a/scripts/own-check.ps1 b/scripts/own-check.ps1 index 544674e2..8ecd0996 100644 --- a/scripts/own-check.ps1 +++ b/scripts/own-check.ps1 @@ -78,7 +78,15 @@ try { $exArgs = @($Paths) + @("-o", $facts.FullName) if (-not $Legacy) { $exArgs += "--flow-locals" } & dotnet run --project $extractor -- @exArgs 1>$null - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { + # Stage 1 failed: no verdict was produced. Exit 1 is reserved for + # "analysed, findings present", so a broken build must not borrow it — + # a caller that does not gate on findings would read it as clean. Map + # it into the hard-error tier (the extractor's own 2/4 pass through). + $stage1 = $LASTEXITCODE + if ($stage1 -eq 1) { $stage1 = 2 } + exit $stage1 + } # Stage 2: the one checker produces the verdict at the C# location. $env:PYTHONPATH = $Root diff --git a/scripts/own-check.sh b/scripts/own-check.sh index bb98fee0..8f937e42 100755 --- a/scripts/own-check.sh +++ b/scripts/own-check.sh @@ -111,7 +111,20 @@ if [[ -n "$config" ]]; then [[ -n "$pair" ]] && extractor_args+=(--weak-subscribe "$pair") done <<< "$weak_pairs" fi +set +e dotnet run --project "$extractor" -- "${extractor_args[@]}" 1>&2 +extract_rc=$? +set -e +if [[ "$extract_rc" -ne 0 ]]; then + # Stage 1 failed: the build broke, the extractor crashed, or it refused the + # input. NO VERDICT WAS PRODUCED, so this must not land on exit 1 — that code + # is reserved for "analysed, and there are findings", and a caller that + # chooses not to gate on findings (the Action's default) would read it as a + # clean run. Map it into the hard-error tier; the extractor's own contract + # codes (2 = usage, 4 = no input) already live there and pass through. + [[ "$extract_rc" -eq 1 ]] && extract_rc=2 + exit "$extract_rc" +fi # Optional: persist the OwnIR facts (the audit's XAML Phase-2 join consumes them # alongside xaml-facts.json). The verdict still comes from stage 2; this is just a