diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6c89e40..a8c5251e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2715,11 +2715,57 @@ jobs: [ "$rc" -eq 2 ] || { echo "FAIL: bare usage must exit 2 (never clean), got $rc"; exit 1; } echo "$out" | grep -q "RETAINED (root path shown) | OBSERVED_ONLY" || { echo "FAIL: usage must document the verdict vocabulary"; exit 1; } set +e - out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots --pid 999999 --type X 2>&1) + out=$(dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots --pid 999999 --type X \ + --out "$RUNNER_TEMP/nopid.json" 2>&1) rc=$? set -e echo "$out" [ "$rc" -eq 2 ] || { echo "FAIL: a failed attach must exit 2, never read as clean, got $rc"; exit 1; } + # Every platform, no target needed: a run that could not look records + # that it could not look. Absence of a file is not that statement — + # it is also "never invoked" and "artifact lost", so it means nothing. + cat "$RUNNER_TEMP/nopid.json" + python3 - "$RUNNER_TEMP/nopid.json" unreadable-target <<'PY' + import json, sys + doc = json.load(open(sys.argv[1], encoding="utf-8")) + ex = doc.get("execution") or {} + problems = [] + if ex.get("state") != "not_evaluated": + problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'") + if (ex.get("reason") or {}).get("code") != sys.argv[2]: + problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}") + if not (ex.get("reason") or {}).get("detail"): + problems.append("a not_evaluated record must carry a reason detail") + for key in ("verdict", "retained"): + if key in doc: + problems.append(f"a run that did not look must not record {key!r}") + for p in problems: + print(f"FAIL: {p}", file=sys.stderr) + sys.exit(1 if problems else 0) + PY + # A usage error is equally a state the record has to carry. + set +e + dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll roots \ + --out "$RUNNER_TEMP/usage.json" > /dev/null 2>&1 + rc=$? + set -e + [ "$rc" -eq 2 ] || { echo "FAIL: a usage error must exit 2, got $rc"; exit 1; } + python3 - "$RUNNER_TEMP/usage.json" usage-error <<'PY' + import json, sys + doc = json.load(open(sys.argv[1], encoding="utf-8")) + ex = doc.get("execution") or {} + problems = [] + if ex.get("state") != "not_evaluated": + problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'") + if (ex.get("reason") or {}).get("code") != sys.argv[2]: + problems.append(f"reason.code {(ex.get('reason') or {}).get('code')!r}, want {sys.argv[2]!r}") + for key in ("verdict", "retained"): + if key in doc: + problems.append(f"a run that did not look must not record {key!r}") + for p in problems: + print(f"FAIL: {p}", file=sys.stderr) + sys.exit(1 if problems else 0) + PY dotnet "$GITHUB_WORKSPACE"/audit/runtime/RetentionPath/bin/Release/net8.0/RetentionPath.dll selftest - name: "WPF flagship on Windows: the witness names the window the hub is holding (A2/A3)" if: runner.os == 'Windows' @@ -2860,9 +2906,68 @@ jobs: || { 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" + # The exit code says "I did not look" for as long as the process lives; + # the record has to say it afterwards. An absent file cannot: it also + # means never invoked, runner died, or artifact lost in transit. So the + # refusal is RECORDED — while the verdict it never earned is not. + [ -s "$RUNNER_TEMP/denied.json" ] \ + || { echo "FAIL: a refused attach must still record that it did not look"; exit 1; } + cat "$RUNNER_TEMP/denied.json" + python3 - "$RUNNER_TEMP/denied.json" <<'PY' + import json, sys + doc = json.load(open(sys.argv[1], encoding="utf-8")) + ex = doc.get("execution") or {} + problems = [] + if ex.get("state") != "not_evaluated": + problems.append(f"execution.state {ex.get('state')!r}, want 'not_evaluated'") + reason = ex.get("reason") or {} + if reason.get("code") != "refused-attach": + problems.append(f"reason.code {reason.get('code')!r}, want 'refused-attach'") + # A permission claim belongs to the one stage a permission check applies + # to. Anything later opened the target fine and must not cite a policy. + if reason.get("stage") != "open-target": + problems.append(f"reason.stage {reason.get('stage')!r}, want 'open-target'") + if "ptrace_scope" not in str(reason.get("policy_in_force", "")): + problems.append("reason.policy_in_force must name the policy that was in force, " + f"got {reason.get('policy_in_force')!r}") + # The half that must NOT come back: an unearned verdict, or an empty + # `retained` that reads downstream as "looked, found nothing". + for key in ("verdict", "retained"): + if key in doc: + problems.append(f"a refused attach must not record {key!r} (got {doc[key]!r})") + for p in problems: + print(f"FAIL: {p}", file=sys.stderr) + sys.exit(1 if problems else 0) + PY + # The other half, with the SAME restricting policy still in force: a + # failure that is not an attach must not borrow it. Yama being on is + # observable; Yama having caused the failure in hand is not, and the + # record may only say the first. + echo "not a dump" > "$RUNNER_TEMP/not-a-dump" + set +e + dotnet "$WITNESS" roots --dump "$RUNNER_TEMP/not-a-dump" --type X \ + --out "$RUNNER_TEMP/dumpfail.json" > "$RUNNER_TEMP/dumpfail.log" 2>&1 + drc=$? + set -e + cat "$RUNNER_TEMP/dumpfail.log" + [ "$drc" -eq 2 ] || { echo "FAIL: an unreadable dump must exit 2, got $drc"; exit 1; } + grep -q "ptrace_scope" "$RUNNER_TEMP/dumpfail.log" \ + && { echo "FAIL: a dump read must not lecture about ptrace"; exit 1; } + python3 - "$RUNNER_TEMP/dumpfail.json" <<'PY' + import json, sys + reason = ((json.load(open(sys.argv[1], encoding="utf-8")).get("execution") or {}) + .get("reason") or {}) + problems = [] + if reason.get("code") != "unreadable-target": + problems.append(f"reason.code {reason.get('code')!r}, want 'unreadable-target'") + if "policy_in_force" in reason: + problems.append(f"a dump read cited a ptrace policy: {reason['policy_in_force']!r}") + for p in problems: + print(f"FAIL: {p}", file=sys.stderr) + sys.exit(1 if problems else 0) + PY + echo "OK: denied attach -> exit 2, policy named, refusal recorded, no verdict;" + echo "OK: a non-attach failure under the same policy does not borrow it" - name: "flagship demo orchestrator end-to-end: bad DEMONSTRATED, ok VERIFIED (A3/A4)" if: runner.os == 'Linux' run: | diff --git a/audit/runtime/RetentionPath/Heap.cs b/audit/runtime/RetentionPath/Heap.cs index 67f71c12..1a1df95f 100644 --- a/audit/runtime/RetentionPath/Heap.cs +++ b/audit/runtime/RetentionPath/Heap.cs @@ -38,13 +38,24 @@ internal sealed class RetentionWalker : IDisposable private readonly DataTarget _target; private readonly ClrRuntime _runtime; - /// Attach to a LIVE process (suspends it for the read). No procdump needed. - public static RetentionWalker AttachToProcess(int pid) => - new RetentionWalker(DataTarget.AttachToProcess(pid, suspend: true)); + /// Attach to a LIVE process (suspends it for the read). No procdump needed. + /// + /// Opening the target is deliberately its OWN step, separate from + /// : it is the only one a kernel ptrace policy can + /// refuse. Folding the two together makes every CLR-initialisation + /// failure indistinguishable from a permission failure, and a caller + /// that cannot tell them apart will attribute one to the other. + public static DataTarget OpenLiveTarget(int pid) => + DataTarget.AttachToProcess(pid, suspend: true); /// Read a full dump — the right choice when the target must not be paused. - public static RetentionWalker LoadDump(string path) => - new RetentionWalker(DataTarget.LoadDump(path)); + public static DataTarget OpenDumpTarget(string path) => + DataTarget.LoadDump(path); + + /// Build the CLR view over an already-opened target. Takes + /// ownership: on success the walker disposes the target, and on failure + /// the target is still the caller's to dispose. + public static RetentionWalker Create(DataTarget target) => new RetentionWalker(target); private RetentionWalker(DataTarget target) { diff --git a/audit/runtime/RetentionPath/Program.cs b/audit/runtime/RetentionPath/Program.cs index 146b9293..72f149c6 100644 --- a/audit/runtime/RetentionPath/Program.cs +++ b/audit/runtime/RetentionPath/Program.cs @@ -40,7 +40,7 @@ internal static class Program { private static int Main(string[] args) { - if (args.Length == 0) return Usage(); + if (args.Length == 0) return Usage(args, "no verb given"); string verb = args[0].ToLowerInvariant(); // The classifier boundary, pinned without a heap: the live net8 @@ -49,25 +49,43 @@ private static int Main(string[] args) if (verb == "selftest") return ClassifierSelfTest() ? 0 : 1; + if (verb != "census" && verb != "roots") + return Usage(args, $"unknown verb '{verb}'"); + int pid = ArgInt(args, "--pid", 0); string? dump = Arg(args, "--dump"); if (pid == 0 && dump == null) { Console.Error.WriteLine("retention-path: need --pid or --dump "); - return 2; + return NotEvaluated(args, "usage-error", "neither --pid nor --dump was given"); } + // WHERE the failure happened decides what it means, so the stages are + // tracked separately instead of being collapsed into one "did we get + // in" flag. Opening the target is the only step a ptrace policy can + // refuse; building the CLR view happens after the target is already + // open, so its failures say nothing about permission; and a failure + // during the walk is the witness breaking, not the target refusing. + // One bool cannot carry that, and when it tried, a CLR-initialisation + // failure on a live process under a restricting Yama policy came back + // labelled `refused-attach`. + DataTarget? target = null; + var stage = Stage.OpenTarget; try { - using var walker = dump != null - ? RetentionWalker.LoadDump(dump) - : RetentionWalker.AttachToProcess(pid); + target = dump != null + ? RetentionWalker.OpenDumpTarget(dump) + : RetentionWalker.OpenLiveTarget(pid); + + stage = Stage.CreateRuntime; + using var walker = RetentionWalker.Create(target); + target = null; // the walker owns it from here + stage = Stage.Walk; switch (verb) { case "census": return Census(walker, args); - case "roots": return Roots(walker, args); - default: return Usage(); + default: return Roots(walker, args); } } catch (Exception ex) @@ -75,14 +93,72 @@ 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)) + + // Only the stage a policy could have refused gets the ptrace + // lecture. Printing it after the target opened would have stderr + // blaming the kernel while the record blames the walk — the two + // reading one observation is the whole point of sharing YamaScope. + if (stage == Stage.OpenTarget) { - Console.Error.WriteLine(line); + foreach (var line in AttachAdvice(pid, live: dump == null)) + Console.Error.WriteLine(line); } - return 2; + + return stage == Stage.Walk + ? Failed(args, ex) + : NotEvaluated(args, ReadFailure(stage, pid, live: dump == null, ex)); + } + finally + { + // Non-null only when ownership never reached the walker. + target?.Dispose(); } } + /// The stage a run reached, because the same exception means + /// different things at each one. + private enum Stage + { + /// Opening the process or dump — refusable by a ptrace policy. + OpenTarget, + /// Building the CLR view over an already-open target. + CreateRuntime, + /// Reading the heap. + Walk, + } + + /// + /// Why the heap could not be read, said no more strongly than the + /// evidence allows. + /// + /// `refused-attach` records a live attach that failed at `open-target` + /// while a restrictive policy was observed in force. It does not identify + /// the cause. A live process under `ptrace_scope=1` can fail to open for + /// reasons that have nothing to do with Yama, and the kernel does not + /// tell a tracer which check rejected it, so the code names a situation + /// and policy_in_force names what was seen — neither names a + /// culprit. Reading the code as a proven permission failure would put + /// back the same unearned confidence the execution record exists to + /// prevent: the enum is not smarter than the evidence behind it. + /// + /// Everything else — including a target that opened and then turned out + /// not to be a readable CLR process — gets the weaker, true + /// `unreadable-target`, with the exception in `detail`. + /// + private static Dictionary ReadFailure( + Stage stage, int pid, bool live, Exception ex) + { + string? policy = stage == Stage.OpenTarget ? RefusingPolicy(pid, live) : null; + var reason = new Dictionary + { + ["code"] = policy != null ? "refused-attach" : "unreadable-target", + ["stage"] = stage == Stage.OpenTarget ? "open-target" : "create-runtime", + ["detail"] = $"{ex.GetType().Name}: {ex.Message}", + }; + if (policy != null) reason["policy_in_force"] = policy; + return reason; + } + /// /// 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 @@ -98,18 +174,13 @@ private static int Main(string[] args) /// 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; + string? scope = YamaScope(pid, live); + if (scope == null) 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}) refused it."; + yield return " the target is alive and the open was refused, so a PERMISSION failure"; + yield return $" is the likely cause: the kernel's Yama policy is restricting"; + yield return $" (/proc/sys/kernel/yama/ptrace_scope = {scope}). That policy being in"; + yield return " force is what Owen can see; it cannot prove this open is what it stopped."; 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 @@ -148,6 +219,37 @@ private static IEnumerable AttachAdvice(int pid, bool live) } } + /// The one place that decides whether a restricting policy was + /// OBSERVED: a live attach, on Linux, to a process that still exists, + /// under a Yama policy that is not permissive. Returns the scope value, + /// or null when there is no such policy to name. + /// + /// This proves the policy was IN FORCE, not that it caused the failure + /// in hand — the kernel does not tell the tracer which check rejected + /// it. Both the human advice and the durable record read this one + /// function, and both are worded to that limit, so stderr and the + /// artifact cannot end up holding two opinions about one event. + private static string? YamaScope(int pid, bool live) + { + if (!live || !OperatingSystem.IsLinux()) return null; + + try { using var _ = System.Diagnostics.Process.GetProcessById(pid); } + catch { return null; } // no such process: not a permission story + + string scope; + try { scope = File.ReadAllText("/proc/sys/kernel/yama/ptrace_scope").Trim(); } + catch { return null; } // no Yama on this kernel + return scope == "0" ? null : scope; + } + + /// The restricting policy in force, named as the record names + /// it, or null when there is none to name. + private static string? RefusingPolicy(int pid, bool live) + { + string? scope = YamaScope(pid, live); + return scope == null ? null : $"kernel.yama.ptrace_scope={scope}"; + } + private static int Census(RetentionWalker walker, string[] args) { var c = walker.Census(); @@ -166,36 +268,43 @@ private static int Census(RetentionWalker walker, string[] args) foreach (var kv in c.ByType.OrderByDescending(k => k.Value.Bytes).Take(top)) Console.WriteLine($"{Short(kv.Key),-62}{kv.Value.Count,14:N0}{Mb(kv.Value.Bytes),12:N1}"); - string? outPath = Arg(args, "--out"); - if (outPath != null) - { - // The runtime.json contract. `expected` is left at 0 — the collector does not - // know the budget; the scenario/config does, and correlate.py applies it. - var retained = c.ByType - .OrderByDescending(k => k.Value.Bytes) - .Take(top) - .Select(kv => new Dictionary - { - ["type"] = kv.Key, - ["count"] = kv.Value.Count, - ["expected"] = 0, - ["bytes"] = kv.Value.Bytes, - ["roots"] = new object[0], - }) - .ToList(); - - var doc = new Dictionary + // The runtime.json contract. `expected` is left at 0 — the collector does not + // know the budget; the scenario/config does, and correlate.py applies it. + var retained = c.ByType + .OrderByDescending(k => k.Value.Bytes) + .Take(top) + .Select(kv => new Dictionary { - ["schema"] = "own-runtime/1", - ["collector"] = CollectorIdentity(args), - ["retained"] = retained, - }; - File.WriteAllText(outPath, JsonConvert.SerializeObject(doc, Formatting.Indented)); - Console.WriteLine(); - Console.WriteLine($"runtime.json written to {outPath}"); - } + ["type"] = kv.Key, + ["count"] = kv.Value.Count, + ["expected"] = 0, + ["bytes"] = kv.Value.Bytes, + ["roots"] = new object[0], + }) + .ToList(); + + // Same exit-code tiers as `roots`, so the same execution states: + // a majority-retained heap is something OBSERVED and worth a `roots` + // run; anything less is a clean look, not a silent one. + bool present = c.RetainedShare > 50; + var scope = new Dictionary + { + ["verb"] = "census", + ["mode"] = Arg(args, "--dump") != null ? "dump" : "attach", + ["roots_enumerated"] = c.Roots, + ["objects_on_heap"] = c.HeapObjects, + ["objects_reachable"] = c.RetainedObjects, + ["bytes_on_heap"] = c.HeapBytes, + ["bytes_reachable"] = c.RetainedBytes, + ["retained_share_pct"] = Math.Round(c.RetainedShare, 1), + ["types_on_heap"] = c.ByType.Count, + ["types_reported"] = retained.Count, + ["top_budget"] = top, + }; + WriteRecord(args, BuildRecord( + CollectorIdentity(args), Evaluated(present, scope), retained: retained)); - return c.RetainedShare > 50 ? 1 : 0; + return present ? 1 : 0; } private static int Roots(RetentionWalker walker, string[] args) @@ -204,7 +313,7 @@ private static int Roots(RetentionWalker walker, string[] args) if (type == null) { Console.Error.WriteLine("retention-path roots: need --type "); - return 2; + return NotEvaluated(args, "usage-error", "roots requires --type "); } // Display budgets only — clamped to at least 1 so a pathological // `--sample 0` cannot suppress the root path a RETAINED verdict @@ -214,10 +323,11 @@ private static int Roots(RetentionWalker walker, string[] args) int maxHops = Math.Max(1, ArgInt(args, "--max-hops", 40)); var report = walker.FindRetainers(type, sample, maxHops); + var scope = RootsScope(args, type, report, sample, maxHops); if (report.TotalOnHeap == 0) { Console.WriteLine($"verdict: ABSENT — no instance of {type} is on the heap"); - WriteArtifact(args, "ABSENT", type, 0, new List()); + WriteArtifact(args, "ABSENT", type, 0, scope, new List()); return 0; } if (report.Retained == 0) @@ -227,7 +337,8 @@ private static int Roots(RetentionWalker walker, string[] args) Console.WriteLine($"verdict: OBSERVED_ONLY — {report.TotalOnHeap:N0} instance(s) of {type} on the " + "heap, but none of them is reachable from a GC root " + "(garbage awaiting collection, not an established retention)"); - WriteArtifact(args, "OBSERVED_ONLY", type, report.TotalOnHeap, new List()); + WriteArtifact(args, "OBSERVED_ONLY", type, report.TotalOnHeap, scope, + new List()); return 0; } @@ -253,7 +364,8 @@ private static int Roots(RetentionWalker walker, string[] args) Console.WriteLine($" via [{r.ContractKind()}], {r.Path.Count} hops:"); Console.Write(r.Render()); } - WriteArtifact(args, "OBSERVED_ONLY", type, report.TotalOnHeap, report.Retainers); + WriteArtifact(args, "OBSERVED_ONLY", type, report.TotalOnHeap, scope, + report.Retainers); return 0; } @@ -292,7 +404,8 @@ private static int Roots(RetentionWalker walker, string[] args) "really is held from many places"); } - WriteArtifact(args, "RETAINED", report.TypeName, report.TotalOnHeap, report.Retainers); + WriteArtifact(args, "RETAINED", report.TypeName, report.TotalOnHeap, scope, + report.Retainers); return 1; // retention found } @@ -309,20 +422,117 @@ internal static bool IsDurableKind(string kind) => internal static string VerdictOf(IEnumerable retainerKinds) => retainerKinds.Any(IsDurableKind) ? "RETAINED" : "OBSERVED_ONLY"; - /// The one `runtime.json` writer — every verdict emits the + /// The one `runtime.json` writer — every outcome emits the /// artifact when `--out` is given, so the ok-side of a demo is as - /// machine-checkable as the leak side. - private static void WriteArtifact( - string[] args, string verdict, string typeName, long count, IReadOnlyList retainers) + /// machine-checkable as the leak side, and a run that never looked is as + /// machine-checkable as one that did. + /// + /// The exit codes already keep three states apart (0 evaluated/absent, + /// 1 evaluated/present, 2 not evaluated) and then the process ends. If + /// storage represents "not evaluated" by writing nothing, that guarantee + /// does not survive it: an absent file also means never invoked, runner + /// died, persistence failed, artifact lost in transit, or a format + /// nothing reads any more. Absence has too many preimages to carry + /// meaning, so it is given none — the record IS the state. + /// + /// What must NOT come back is a verdict that was not earned: a + /// `not_evaluated` or `error` record carries no `verdict` and no + /// `retained` key at all. An empty `retained: []` would read downstream + /// as "looked, found nothing", which is the very collapse this record + /// exists to prevent. + private static void WriteRecord(string[] args, Dictionary doc) { string? outPath = Arg(args, "--out"); if (outPath == null) return; + File.WriteAllText(outPath, JsonConvert.SerializeObject(doc, Formatting.Indented)); + Console.WriteLine(); + Console.WriteLine($"runtime.json written to {outPath}"); + } + + /// Assemble the document. Pure, so the selftest can assert the + /// record contract without a heap, a process, or a filesystem. + internal static Dictionary BuildRecord( + Dictionary collector, + Dictionary execution, + string? verdict = null, + object? retained = null) + { var doc = new Dictionary { ["schema"] = "own-runtime/1", - ["verdict"] = verdict, - ["collector"] = CollectorIdentity(args), - ["retained"] = new object[] + ["execution"] = execution, + ["collector"] = collector, + }; + // Only an evaluated state may carry a measurement. + if (verdict != null) doc["verdict"] = verdict; + if (retained != null) doc["retained"] = retained; + return doc; + } + + /// The execution state of an evaluation that HAPPENED. `scope` + /// is required, not decorative: a `clean` whose scope is unknown is a + /// malformed record — it does not say what was looked at, so it cannot + /// mean "nothing was there" — and consumers must treat it as a schema + /// violation rather than as a quieter `not_evaluated`. + internal static Dictionary Evaluated( + bool witnessPresent, Dictionary scope) => + new Dictionary + { + ["state"] = witnessPresent ? "observed" : "clean", + ["scope"] = scope, + }; + + /// Record "I did not look, and here is why", then exit 2. + private static int NotEvaluated(string[] args, string code, string detail) => + NotEvaluated(args, new Dictionary + { + ["code"] = code, + ["detail"] = detail, + }); + + private static int NotEvaluated(string[] args, Dictionary reason) + { + WriteRecord(args, BuildRecord( + CollectorIdentity(args), + new Dictionary + { + ["state"] = "not_evaluated", + ["reason"] = reason, + })); + return 2; + } + + /// Record "I looked and broke", then exit 2. Distinct from + /// `not_evaluated` on purpose: the heap was readable, so a partial walk + /// may have happened and the target is not exonerated by this outcome. + /// The classification is the exception type — the honest granularity a + /// collector has, rather than a guess at a cause. + private static int Failed(string[] args, Exception ex) + { + WriteRecord(args, BuildRecord( + CollectorIdentity(args), + new Dictionary + { + ["state"] = "error", + ["error"] = new Dictionary + { + ["classification"] = ex.GetType().Name, + ["detail"] = ex.Message, + ["stage"] = "walk", + }, + })); + return 2; + } + + private static void WriteArtifact( + string[] args, string verdict, string typeName, long count, + Dictionary scope, IReadOnlyList retainers) + { + var doc = BuildRecord( + CollectorIdentity(args), + Evaluated(witnessPresent: verdict == "RETAINED", scope: scope), + verdict, + new object[] { new Dictionary { @@ -340,13 +550,29 @@ private static void WriteArtifact( ["path"] = r.Path.Select(h => h.ToString()).ToList(), }).ToList(), }, - }, - }; - File.WriteAllText(outPath, JsonConvert.SerializeObject(doc, Formatting.Indented)); - Console.WriteLine(); - Console.WriteLine($"runtime.json written to {outPath}"); + }); + WriteRecord(args, doc); } + /// What the `roots` walk actually covered. Population figures + /// come from the exact whole-population census, and the budgets that + /// bounded only the DISPLAY are named as budgets — a reader must be able + /// to tell a number that constrained the verdict from one that did not. + private static Dictionary RootsScope( + string[] args, string typeName, RetentionReport report, int sample, int maxHops) => + new Dictionary + { + ["verb"] = "roots", + ["mode"] = Arg(args, "--dump") != null ? "dump" : "attach", + ["type"] = typeName, + ["instances_on_heap"] = report.TotalOnHeap, + ["instances_reachable"] = report.Retained, + ["instances_durably_retained"] = report.DurableRetained, + ["paths_resolved"] = report.PathsResolved, + ["sample_budget"] = sample, + ["max_hops_budget"] = maxHops, + }; + /// Who read the heap and how — so the artifact is auditable /// (A3): the target (pid or dump path), the collector runtime, the OS. /// No timestamps: identical heaps must yield identical artifacts. @@ -462,14 +688,103 @@ void Check(string name, string got, string want) $"IsTransientRootKind={transient}, IsDurableKind(Classify)={durable}"); } + // 7. The record contract (issue #331). The exit codes keep three + // states apart and then the process ends; these checks pin that + // the storage layer keeps them apart too, instead of letting + // file-absence stand in for "not evaluated" — an absence that + // also means never invoked, runner died, or artifact lost. + var collector = new Dictionary { ["tool"] = "retention-path" }; + var someScope = new Dictionary { ["verb"] = "roots" }; + + void CheckRecord(string name, Dictionary doc, + string wantState, bool wantMeasurement) + { + if (!doc.TryGetValue("execution", out var exObj) || + exObj is not Dictionary ex) + { + fails.Add($"{name}: record has no `execution` block"); + return; + } + Check($"{name}: state", ex.TryGetValue("state", out var s) ? $"{s}" : "", wantState); + + // Each state owes its own evidence. A state with nothing behind + // it is a label, and a label is what this record replaces. + string owes = wantState switch + { + "observed" or "clean" => "scope", + "not_evaluated" => "reason", + _ => "error", + }; + if (!ex.ContainsKey(owes)) + fails.Add($"{name}: state '{wantState}' must carry `{owes}`"); + + // The half that must NOT come back: an unearned verdict, or an + // empty `retained` that reads downstream as "looked, found + // nothing". Absence of the key is the point. + bool hasMeasurement = doc.ContainsKey("verdict") || doc.ContainsKey("retained"); + if (hasMeasurement != wantMeasurement) + fails.Add($"{name}: measurement keys present={hasMeasurement}, want {wantMeasurement}"); + } + + CheckRecord("observed record", + BuildRecord(collector, Evaluated(true, someScope), "RETAINED", new object[0]), + "observed", wantMeasurement: true); + CheckRecord("clean record", + BuildRecord(collector, Evaluated(false, someScope), "ABSENT", new object[0]), + "clean", wantMeasurement: true); + CheckRecord("not_evaluated record", + BuildRecord(collector, new Dictionary + { + ["state"] = "not_evaluated", + ["reason"] = new Dictionary { ["code"] = "refused-attach" }, + }), + "not_evaluated", wantMeasurement: false); + CheckRecord("error record", + BuildRecord(collector, new Dictionary + { + ["state"] = "error", + ["error"] = new Dictionary { ["classification"] = "IOException" }, + }), + "error", wantMeasurement: false); + + // The state names follow the exit-code tiers, so a consumer can map + // one onto the other without a second opinion about what happened. + Check("witness present is `observed`", + $"{Evaluated(true, someScope)["state"]}", "observed"); + Check("witness absent but evaluated is `clean`", + $"{Evaluated(false, someScope)["state"]}", "clean"); + + // 8. Stage attribution. A permission claim belongs to the one stage a + // permission check applies to. The first cut of this arc gated on + // "did we get a walker", which put every CLR-initialisation + // failure — a target that opened fine and then turned out not to + // be a managed process — under `refused-attach` whenever Yama + // happened to be restricting. + var boom = new InvalidOperationException("the target contains no CLR"); + var afterOpen = ReadFailure(Stage.CreateRuntime, pid: 1, live: true, ex: boom); + Check("a failure after the target opened is never a permission claim", + $"{afterOpen["code"]}", "unreadable-target"); + Check("and it says which stage it fell over at", + $"{afterOpen["stage"]}", "create-runtime"); + if (afterOpen.ContainsKey("policy_in_force")) + fails.Add("create-runtime failure must not cite a ptrace policy"); + + // A dump has no process to trace, so no policy can be in force for + // it — the same call must stay silent about permission there too. + var dumpFail = ReadFailure(Stage.OpenTarget, pid: 0, live: false, ex: boom); + Check("an unreadable dump is not a refusal", + $"{dumpFail["code"]}", "unreadable-target"); + if (dumpFail.ContainsKey("policy_in_force")) + fails.Add("a dump read must not cite a ptrace policy"); + foreach (var f in fails) Console.Error.WriteLine($"FAIL: classifier {f}"); if (fails.Count == 0) - Console.WriteLine("retention-path classifier selftest OK: 16 checks passed"); + Console.WriteLine("retention-path selftest OK: classifier, verdict and record contracts hold"); return fails.Count == 0; } - private static int Usage() + private static int Usage(string[] args, string detail) { Console.Error.WriteLine("usage:"); Console.Error.WriteLine(" RetentionPath selftest # classifier fixtures, no target needed"); @@ -479,7 +794,11 @@ private static int Usage() Console.Error.WriteLine(" census is there anything retained at all, or is the heap just uncollected garbage?"); Console.Error.WriteLine(" roots what holds the TYPICAL instance of a type (exact verdict; sampled, ranked paths);"); Console.Error.WriteLine(" verdicts: RETAINED (root path shown) | OBSERVED_ONLY (no path established) | ABSENT"); - return 2; + Console.Error.WriteLine(); + Console.Error.WriteLine(" --out write the runtime.json record. EVERY outcome writes one, including"); + Console.Error.WriteLine(" a run that never looked: execution.state is observed | clean |"); + Console.Error.WriteLine(" not_evaluated | error, and only an evaluated state carries a verdict."); + return NotEvaluated(args, "usage-error", detail); } } } diff --git a/docs/runtime-witness-operations.md b/docs/runtime-witness-operations.md index 74368656..b396afd4 100644 --- a/docs/runtime-witness-operations.md +++ b/docs/runtime-witness-operations.md @@ -32,11 +32,100 @@ forbidden, this is the answer, not a workaround. 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. +pipeline learns to report health it never measured. **Proven by CI:** a denied attach exits 2, names the policy that refused, and -leaves no artifact behind. +records that it did not look — without recording a verdict. + +## The durable record + +An exit code lives as long as the process. Everything downstream reads the +`runtime.json` that `--out` writes, so the three states above have to survive +into storage or the guarantee ends with the process. + +Representing *"not evaluated"* by writing **no file** does not survive it. +An absent artifact means: + +```text +not evaluated OR never invoked OR the runner died before invocation + OR persistence failed OR lost in transit + OR an older format nothing reads any more +``` + +Absence has too many preimages to carry meaning, so it is given none: + +> **Absence of a record means no durable knowledge, never a semantic outcome.** + +Every evaluation **for which persistence was requested** — that is, every run +given `--out` — writes a record, and the record states what happened. A run +without `--out` asked for no durable output and gets none; the witness is a +standalone tool with no other publication point, so `--out` is where the +invariant applies. The record states what happened: + +| `execution.state` | Exit | Carries | Meaning | +| --- | --- | --- | --- | +| `observed` | 1 | `scope`, `retained` | The heap was read; a witness is present. | +| `clean` | 0 | `scope`, `retained` | The heap was read; no witness. | +| `not_evaluated` | 2 | `reason.code`, `reason.stage`, `reason.detail` | Nothing was read. No verdict is recorded. | +| `error` | 2 | `error.classification`, `error.stage` | The heap was readable and the walk broke. | + +`verdict` is **command-specific, not part of the execution contract**: `roots` +carries one (`RETAINED` / `OBSERVED_ONLY` / `ABSENT`), `census` does not, because +"is any of this heap retained at all" is a different question with no such +vocabulary. Inventing a census verdict to make the schema uniform would add a +word nobody measured. What every evaluated state does owe is `scope` and +`retained`. + +```jsonc +{ + "schema": "own-runtime/1", + "execution": { + "state": "not_evaluated", + "reason": { + "code": "refused-attach", // usage-error | unreadable-target | refused-attach + "stage": "open-target", // open-target | create-runtime + "detail": "ClrDiagnosticsException: Could not attach to process 4213", + "policy_in_force": "kernel.yama.ptrace_scope=1" + } + }, + "collector": { "tool": "retention-path", "mode": "attach", "target": "4213", … } +} +``` + +Two things this record deliberately does **not** do. + +It does not record a verdict it did not earn. A `not_evaluated` or `error` +record carries no `verdict` key 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. + +It does not claim a refusal it did not observe. A run passes through three +stages — **open-target**, **create-runtime**, **walk** — and only the first is +one a permission check applies to. A failure at `create-runtime` means the +target opened and then turned out not to be a readable CLR process, which says +nothing about permission; a failure at `walk` is the witness breaking, not the +target refusing. So `refused-attach` is reserved for `open-target`, and the +ptrace advice on stderr is printed at that stage only — otherwise stderr would +blame the kernel while the record blamed the walk. + +Even there the claim is bounded. `policy_in_force` records that a restricting +Yama policy **was in force**, which is what the collector can see. It does not +record that the policy caused this particular failure, which it cannot: the +kernel does not tell a tracer which check rejected it, and a live process under +`ptrace_scope=1` can fail to open for unrelated reasons. Everything else gets +the weaker, true `unreadable-target` with the exception in `detail`. + +`not_evaluated` and `error` are separated by the same stages: before the heap +was readable, nothing was looked at and the target is not implicated; after it, +the witness broke mid-walk and the target is not exonerated. + +**A `clean` with no `scope` is malformed, not weak.** A record that does not say +what was looked at cannot mean "nothing was there", so consumers must route it +down the schema-violation path — never read it as a quieter `not_evaluated`. +`scope` 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. ## Linux: Yama's `ptrace_scope` diff --git a/scripts/flagship-demo.sh b/scripts/flagship-demo.sh index 0d5e35da..b6418b51 100755 --- a/scripts/flagship-demo.sh +++ b/scripts/flagship-demo.sh @@ -72,6 +72,21 @@ m = re.search(r"still subscribed", app) subs = re.search(r"(\d[\d,]*) still subscribed", app) problems = [] +def check_execution(doc, want_state): + """An evaluated record must say WHAT it looked at. A verdict whose scope is + unknown cannot mean 'nothing was there' — it is malformed, not merely + quiet, and gets the schema-violation path rather than a lenient read.""" + ex = doc.get("execution") or {} + if ex.get("state") != want_state: + problems.append(f"execution.state {ex.get('state')!r}, want {want_state!r}") + scope = ex.get("scope") + if not scope: + problems.append(f"an evaluated record without `scope` is malformed (state {ex.get('state')!r})") + return + for key in ("verb", "mode", "type", "instances_on_heap"): + if key not in scope: + problems.append(f"scope lacks {key!r}") + if variant == "bad": if rc != 1: problems.append(f"witness exit {rc}, want 1 (RETAINED)") @@ -82,6 +97,7 @@ if variant == "bad": doc = {} if doc.get("verdict") != "RETAINED": problems.append(f"JSON verdict {doc.get('verdict')!r}, want RETAINED") + check_execution(doc, "observed") if "verdict: RETAINED" not in human: problems.append("human output lacks 'verdict: RETAINED'") roots = (doc.get("retained") or [{}])[0].get("roots") or [] @@ -110,6 +126,7 @@ else: doc = {} if doc.get("verdict") not in ("ABSENT", "OBSERVED_ONLY"): problems.append(f"JSON verdict {doc.get('verdict')!r}, want ABSENT or OBSERVED_ONLY") + check_execution(doc, "clean") roots = (doc.get("retained") or [{}])[0].get("roots") or [] durable = [r.get("kind") for r in roots if r.get("kind") not in ("stack", "finalizer")] if durable: