From 78da5df5ab18f487957013df0f34431f281d21c6 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 19:54:26 +0500 Subject: [PATCH 01/20] feat(s2-step11): the OwnSharp.WeakTargetProbe (bind + probe modes) The fixed C# probe for Step 11's Verified Target Wrapper gate. Two internal modes, no code generation / build / restore inside verify-target: - bind (G1, G2): parses the pristine preimage (syntactic) and the accepted Step 8 postimage (semantic), locates each converted candidate's AddAssignment at its hash-bound acquire_span, reproduces the frozen Step 8 replacement text, computes each derived postimage span via ordered non-overlapping edits + cumulative length deltas, requires exactly one invocation node filling that span matching the target + source + normalized handler, resolves its IMethodSymbol, enforces the per-finding bijection (all callsites one symbol; not source-defined), derives the wrapper slot ordinal from the resolved assembly (first-simple-name-wins), and emits a canonical binding-result.json with a sorted callsites array. Framework references come from the selected runtime (TPA) the probe is pinned to, plus the ordered reference slots. - probe (G3, G4, F4): loads the derived wrapper from its EXACT materialized slot path via a dedicated AssemblyLoadContext (deps by first-wins across slots, framework from the selected runtime), runs the runtime-compatibility preflight (resolve type + exact method, build delegate, RuntimeHelpers.PrepareMethod; loader failures -> exit 10 = WRAPPER_RUNTIME_UNSUPPORTED), then the frozen GC harness for ONE attempt: strong control, collectability control, and the target attempt (deliver once while alive, drop the subscriber in a NoInlining/NoOptimization helper, 5 collection rounds with the fixed allocation pressure, GC.KeepAlive(source), then require the subscriber WeakReference is dead). Emits a canonical probe-result.json with the actually-loaded resolved_wrapper identity (ordinal, slot_sha256, MVID, metadata token, signature). Smoke-validated locally: a genuine weak wrapper -> subscriber_collected true; a strong decoy -> false; bind and probe emit byte-identical signatures. No frozen file touched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe.csproj | 24 + .../OwnSharp.WeakTargetProbe/Program.cs | 547 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj create mode 100644 frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj b/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj new file mode 100644 index 00000000..93f934d3 --- /dev/null +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/OwnSharp.WeakTargetProbe.csproj @@ -0,0 +1,24 @@ + + + + + Exe + net8.0 + enable + enable + OwnSharp.WeakTargetProbe + true + + + + + + + diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs new file mode 100644 index 00000000..c27c77d4 --- /dev/null +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -0,0 +1,547 @@ +// S2 Step 11 — OwnSharp.WeakTargetProbe. See the .csproj for the two-mode contract. +using System.ComponentModel; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + if (args.Length >= 1 && args[0] == "bind") return BindMode.Run(args); + if (args.Length >= 1 && args[0] == "probe") return ProbeMode.Run(args); + Console.Error.WriteLine("weak-target-probe: usage: (bind|probe) ..."); + return 2; + } + catch (Exception e) + { + Console.Error.WriteLine($"weak-target-probe: internal error ({e.GetType().Name}: {e.Message})"); + return 2; + } + } + + // --- deterministic canonical JSON (sorted keys, compact, trailing LF) for a restricted + // value domain (bool / non-negative long / printable-ASCII string / array / object) so the + // bytes byte-match Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False). + internal static void WriteCanonical(string path, object value) + { + var sb = new StringBuilder(); + Emit(sb, value); + sb.Append('\n'); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes(sb.ToString())); + } + + private static void Emit(StringBuilder sb, object? v) + { + switch (v) + { + case null: sb.Append("null"); break; + case bool b: sb.Append(b ? "true" : "false"); break; + case int i: sb.Append(i.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; + case long l: sb.Append(l.ToString(System.Globalization.CultureInfo.InvariantCulture)); break; + case string s: EmitString(sb, s); break; + case IReadOnlyList arr: + sb.Append('['); + for (var k = 0; k < arr.Count; k++) { if (k > 0) sb.Append(','); Emit(sb, arr[k]); } + sb.Append(']'); + break; + case IReadOnlyDictionary obj: + sb.Append('{'); + var keys = obj.Keys.ToList(); + keys.Sort(StringComparer.Ordinal); + for (var k = 0; k < keys.Count; k++) + { + if (k > 0) sb.Append(','); + EmitString(sb, keys[k]); + sb.Append(':'); + Emit(sb, obj[keys[k]]); + } + sb.Append('}'); + break; + default: throw new InvalidOperationException($"non-canonical value type {v.GetType()}"); + } + } + + private static void EmitString(StringBuilder sb, string s) + { + sb.Append('"'); + foreach (var ch in s) + { + if (ch == '"' || ch == '\\') { sb.Append('\\').Append(ch); } + else if (ch < 0x20) throw new InvalidOperationException("control char in canonical string"); + else sb.Append(ch); + } + sb.Append('"'); + } + + // The single canonical signature form used by BOTH bind (Roslyn) and probe (reflection). + internal static string Sig(string returnFull, string declFull, string method, IEnumerable paramFull) + => $"{returnFull} {declFull}.{method}({string.Join(", ", paramFull)})"; +} + +// --- BIND MODE (G1, G2) ------------------------------------------------------------------ +internal static class BindMode +{ + public static int Run(string[] args) + { + var a = Args.Parse(args, 1); + var preText = SourceText.From(File.ReadAllText(a["preimage"]), Encoding.UTF8); + var postText = SourceText.From(File.ReadAllText(a["postimage"]), Encoding.UTF8); + var target = a["target"]; + var selectedType = a["selected-class"]; + var sourceFile = a["source-file"]; + var slotsDir = a["slots-dir"]; + using var bp = JsonDocument.Parse(File.ReadAllBytes(a["bind-params"])); + + var parse = new CSharpParseOptions(LanguageVersion.CSharp12, DocumentationMode.None); + var preTree = CSharpSyntaxTree.ParseText(preText, parse, sourceFile); + var postTree = CSharpSyntaxTree.ParseText(postText, parse, sourceFile); + var (refs, slotByPath) = References.Build(slotsDir); + // only the POSTIMAGE is compiled (semantic model); the preimage is used syntactically + // (span location + identity + the frozen replacement text), so its class type does not + // clash with the postimage's. + var comp = CSharpCompilation.Create("weaktargetbind", + new[] { postTree }, refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, deterministic: true)); + var postModel = comp.GetSemanticModel(postTree); + + var preRoot = preTree.GetRoot(); + var postRoot = postTree.GetRoot(); + + // 1-3: locate every converted preimage AddAssignment at its hash-bound span; revalidate + // identity; reproduce the frozen Step 8 replacement text; record (start, len, replacement). + var edits = new List(); + foreach (var cand in bp.RootElement.GetProperty("converted").EnumerateArray()) + { + var fid = cand.GetProperty("finding_id").GetString()!; + var span = new TextSpan(cand.GetProperty("acquire_span").GetProperty("start").GetInt32(), + cand.GetProperty("acquire_span").GetProperty("length").GetInt32()); + var node = preRoot.FindNode(span, getInnermostNodeForTie: true); + if (node is not AssignmentExpressionSyntax asg || asg.Span != span + || !asg.IsKind(SyntaxKind.AddAssignmentExpression)) + return Refuse("CALLSITE_BINDING", $"{fid}: preimage span is not an event += acquire"); + var replacement = ReplacementText(asg, target, cand, out var err); + if (replacement is null) return Refuse("CALLSITE_BINDING", $"{fid}: {err}"); + if ((cand.GetProperty("file").GetString() ?? "") != sourceFile) + return Refuse("CALLSITE_BINDING", $"{fid}: candidate file is not the target file"); + edits.Add(new Edit(fid, span.Start, span.Length, replacement, + cand.GetProperty("source").GetString()!, + cand.GetProperty("normalized_handler").GetString()!)); + } + if (edits.Count == 0) return Refuse("CALLSITE_BINDING", "no converted candidates to bind"); + + // 4: ordered non-overlapping edits, cumulative length deltas -> each derived postimage span. + edits.Sort((x, y) => x.PreStart.CompareTo(y.PreStart)); + for (var i = 1; i < edits.Count; i++) + if (edits[i].PreStart < edits[i - 1].PreStart + edits[i - 1].PreLen) + return Refuse("CALLSITE_BINDING", "overlapping converted acquire spans"); + long delta = 0; + var callsites = new List>(); + IMethodSymbol? firstSym = null; + int derivedOrdinal = -1; + string? asmName = null, mvid = null, token = null, sig = null; + foreach (var e in edits) + { + var postStart = (int)(e.PreStart + delta); + var postSpan = new TextSpan(postStart, e.Replacement.Length); + delta += e.Replacement.Length - e.PreLen; + + // 5-6: exactly one invocation node exactly filling the derived postimage span. + var pnode = postRoot.FindNode(postSpan, getInnermostNodeForTie: true); + if (pnode is not InvocationExpressionSyntax inv || inv.Span != postSpan) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: no invocation at the derived postimage span"); + if (inv.Expression.ToString() != target) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation target is not plan.target_api.subscribe"); + var argList = inv.ArgumentList.Arguments; + if (argList.Count != 2 + || argList[0].Expression.ToString() != e.Source + || Rewrite.NormalizeHandler(argList[1].Expression).ToString() != e.NormalizedHandler) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation arguments do not match the candidate"); + + // 7: resolve the IMethodSymbol; every converted callsite must resolve to one symbol. + if (postModel.GetSymbolInfo(inv).Symbol is not IMethodSymbol sym) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: cannot resolve the invocation symbol"); + if (SymbolEqualityComparer.Default.Equals(sym.ContainingAssembly, comp.Assembly)) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: target is source-defined, not a reference wrapper"); + if (firstSym is null) + { + firstSym = sym; + var mref = comp.GetMetadataReference(sym.ContainingAssembly) as PortableExecutableReference; + var path = mref?.FilePath ?? ""; + if (!slotByPath.TryGetValue(path, out var slot)) + return Refuse("WRAPPER_BINDING", $"{e.Fid}: resolved assembly is not a materialized slot"); + derivedOrdinal = slot.Ordinal; + asmName = sym.ContainingAssembly.Name; + mvid = ReadMvid(path); + token = "0x" + sym.MetadataToken.ToString("x8"); + sig = Program.Sig(FQ(sym.ReturnType), FQ(sym.ContainingType), sym.Name, + sym.Parameters.Select(p => FQ(p.Type))); + } + else if (!SymbolEqualityComparer.Default.Equals(sym, firstSym)) + { + return Refuse("CALLSITE_BINDING", $"{e.Fid}: converted callsites resolve to different methods"); + } + callsites.Add(new Dictionary + { + ["finding_id"] = e.Fid, + ["preimage_span"] = new List { (long)e.PreStart, (long)e.PreLen }, + ["postimage_span"] = new List { (long)postSpan.Start, (long)postSpan.Length }, + ["assembly_simple_name"] = asmName!, + ["module_mvid"] = mvid!, + ["metadata_token"] = token!, + ["resolved_signature"] = sig!, + }); + } + callsites.Sort((x, y) => string.CompareOrdinal((string)x["finding_id"], (string)y["finding_id"])); + + var outObj = new Dictionary + { + ["version"] = 1L, + ["operation"] = "weak-target-bind", + ["converted_callsites"] = (long)edits.Count, + ["derived_wrapper_ordinal"] = (long)derivedOrdinal, + ["resolved_wrapper"] = new Dictionary + { + ["assembly_simple_name"] = asmName!, + ["module_mvid"] = mvid!, + ["metadata_token"] = token!, + ["resolved_signature"] = sig!, + }, + ["callsite_binding"] = new Dictionary + { + ["all_callsites_same_symbol"] = true, + ["target_is_source_defined"] = false, + }, + ["callsites"] = callsites.Cast().ToList(), + }; + Program.WriteCanonical(a["out"], outObj); + return 0; + } + + private static string? ReplacementText(AssignmentExpressionSyntax asg, string target, + JsonElement cand, out string err) + { + err = ""; + ExpressionSyntax receiver; + string eventName; + if (asg.Left is MemberAccessExpressionSyntax lhs + && lhs.IsKind(SyntaxKind.SimpleMemberAccessExpression) + && lhs.Name is IdentifierNameSyntax ev) + { receiver = lhs.Expression; eventName = ev.Identifier.Text; } + else if (asg.Left is IdentifierNameSyntax bare) + { receiver = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ThisExpression(); eventName = bare.Identifier.Text; } + else { err = "LHS is not an event member access"; return null; } + if (eventName != cand.GetProperty("event").GetString()) { err = "event name mismatch"; return null; } + var eventFull = asg.Left.ToString(); + var dot = eventFull.LastIndexOf('.'); + var srcDisplay = dot >= 0 ? eventFull[..dot] : "this"; + if (srcDisplay != cand.GetProperty("source").GetString()) { err = "receiver mismatch"; return null; } + if (asg.Right.ToString() != cand.GetProperty("handler").GetString()) { err = "handler mismatch"; return null; } + var handler = Rewrite.NormalizeHandler(asg.Right); + if (Rewrite.NormWs(handler.ToString()) != cand.GetProperty("normalized_handler").GetString()) + { err = "normalized handler mismatch"; return null; } + var comma = Microsoft.CodeAnalysis.CSharp.SyntaxFactory + .Token(SyntaxKind.CommaToken) + .WithTrailingTrivia(Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Space); + return Microsoft.CodeAnalysis.CSharp.SyntaxFactory.InvocationExpression( + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression(target), + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ArgumentList( + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.SeparatedList( + new[] + { + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Argument(receiver.WithoutTrivia()), + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.Argument(handler.WithoutTrivia()), + }, + new[] { comma }))).ToString(); + } + + // fully-qualified metadata type name (no `global::`, no C# keyword aliasing), matching the + // reflection FullName the probe uses, so bind and probe signatures are byte-identical (G3). + private static readonly SymbolDisplayFormat FQFmt = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static string FQ(ITypeSymbol t) => t.ToDisplayString(FQFmt).Replace("global::", ""); + + private static string ReadMvid(string dllPath) + { + using var fs = File.OpenRead(dllPath); + using var pe = new PEReader(fs); + var mr = pe.GetMetadataReader(); + return mr.GetGuid(mr.GetModuleDefinition().Mvid).ToString("D"); + } + + private static int Refuse(string category, string message) + { + Console.Error.WriteLine($"{category}: {message}"); + return category switch + { + "CALLSITE_BINDING" => 11, + "WRAPPER_BINDING" => 12, + "TOOLCHAIN_BINDING" => 13, + _ => 2, + }; + } + + private readonly record struct Edit(string Fid, int PreStart, int PreLen, string Replacement, + string Source, string NormalizedHandler); +} + +// The frozen Step 8 handler peel + whitespace normalization (copied grammar; the rewriter stays frozen). +internal static class Rewrite +{ + public static ExpressionSyntax NormalizeHandler(ExpressionSyntax e) + { + while (e is BaseObjectCreationExpressionSyntax { ArgumentList.Arguments: { Count: 1 } args }) + e = args[0].Expression; + return e; + } + + public static string NormWs(string s) => string.Join(" ", s.Split((char[]?)null, + StringSplitOptions.RemoveEmptyEntries)); +} + +internal static class References +{ + public static (List, Dictionary) Build(string slotsDir) + { + var refs = new List(); + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + // framework references = the SELECTED runtime the probe is pinned to (TPA), first. + var tpa = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var p in tpa) { seen.Add(Path.GetFileNameWithoutExtension(p)); refs.Add(MetadataReference.CreateFromFile(p)); } + // reference slots in exact ordinal order; first simple-name wins (framework wins over a slot). + if (Directory.Exists(slotsDir)) + foreach (var slot in Directory.GetDirectories(slotsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var dll = Directory.GetFiles(slot, "*.dll").Single(); + var name = Path.GetFileNameWithoutExtension(dll); + var ordinal = int.Parse(Path.GetFileName(slot)); + var full = Path.GetFullPath(dll); + byPath[full] = new Slot(ordinal, full, name); + if (seen.Add(name)) refs.Add(MetadataReference.CreateFromFile(full)); + } + return (refs, byPath); + } + + public readonly record struct Slot(int Ordinal, string Path, string SimpleName); +} + +internal static class Args +{ + public static Dictionary Parse(string[] args, int start) + { + var d = new Dictionary(); + for (var i = start; i < args.Length; i++) + if (args[i].StartsWith("--") && i + 1 < args.Length) { d[args[i][2..]] = args[i + 1]; i++; } + return d; + } +} + +// --- PROBE MODE (G3, G4, F4) ------------------------------------------------------------- +internal static class ProbeMode +{ + private const int CollectionRounds = 5; + private const int AllocPerRound = 4194304; + + public static int Run(string[] args) + { + var a = Args.Parse(args, 1); + var ordinal = int.Parse(a["wrapper-ordinal"]); + var attempt = int.Parse(a["attempt"]); + var target = a["target"]; + var slotsDir = a["slots-dir"]; + var outPath = a["out"]; + + var slot = Path.Combine(slotsDir, ordinal.ToString("D6")); + var rootPath = Path.GetFullPath(Directory.GetFiles(slot, "*.dll").Single()); + var slotSha = Sha256(rootPath); + + var dot = target.LastIndexOf('.'); + var typeName = target[..dot]; + var methodName = target[(dot + 1)..]; + + var alc = new WrapperLoadContext(rootPath, slotsDir); + MethodInfo method; + Action invoke; + string asmName, mvid, token, sig; + try + { + // G4 preflight: load-by-path, resolve type + exact method, build delegate, prepare. + var root = alc.LoadFromAssemblyPath(rootPath); + var type = root.GetTypes().Single(t => t.IsPublic && !t.IsGenericType && t.Name == typeName); + var cands = type.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == methodName).ToList(); + method = cands.Single(m => !m.IsGenericMethod && m.ReturnType == typeof(void) + && Params(m).SequenceEqual(new[] { typeof(INotifyPropertyChanged), typeof(PropertyChangedEventHandler) })); + invoke = (Action) + method.CreateDelegate(typeof(Action)); + RuntimeHelpers.PrepareMethod(method.MethodHandle); + asmName = root.GetName().Name!; + mvid = method.Module.ModuleVersionId.ToString("D"); + token = "0x" + method.MetadataToken.ToString("x8"); + sig = Program.Sig("System.Void", FullName(type), method.Name, Params(method).Select(FullName)); + } + catch (Exception e) when (IsLoaderFailure(e)) + { + var obj = new Dictionary + { + ["version"] = 1L, ["operation"] = "weak-target-probe", ["attempt"] = (long)attempt, + ["runtime_unsupported"] = true, + ["reason"] = Inner(e).GetType().Name, + }; + Program.WriteCanonical(outPath, obj); + return 10; // WRAPPER_RUNTIME_UNSUPPORTED + } + + var strongSource = new ProbeSource(); + var strongRef = RunStrong(strongSource, out var strongDelivered); + var weakRef = RunCollectable(); + var targetSource = new ProbeSource(); + var wref = RunTarget(targetSource, invoke, out var delivered, out var threwSub, out var threwFirst); + + for (var r = 0; r < CollectionRounds; r++) CollectRound(strongSource, targetSource); + GC.KeepAlive(strongSource); + GC.KeepAlive(targetSource); + + var strongRetained = strongRef.TryGetTarget(out _); + var weakCollected = !weakRef.TryGetTarget(out _); + var subscriberCollected = !wref.TryGetTarget(out _); + + bool threwPost = false; + try { targetSource.Raise(); } catch { threwPost = true; } + GC.KeepAlive(targetSource); + + var result = new Dictionary + { + ["version"] = 1L, + ["operation"] = "weak-target-probe", + ["attempt"] = (long)attempt, + ["strong_delivered_once"] = strongDelivered == 1, + ["strong_retained"] = strongRetained, + ["weak_control_collected"] = weakCollected, + ["delivered_count"] = (long)delivered, + ["threw_on_subscribe"] = threwSub, + ["threw_on_first_raise"] = threwFirst, + ["subscriber_collected"] = subscriberCollected, + ["threw_on_post_collection_raise"] = threwPost, + ["resolved_wrapper"] = new Dictionary + { + ["ordinal"] = (long)ordinal, + ["slot_sha256"] = "sha256:" + slotSha, + ["assembly_simple_name"] = asmName, + ["module_mvid"] = mvid, + ["metadata_token"] = token, + ["resolved_signature"] = sig, + }, + }; + Program.WriteCanonical(outPath, result); + return 0; + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunTarget(ProbeSource source, + Action invoke, + out int delivered, out bool threwSub, out bool threwFirst) + { + threwSub = false; threwFirst = false; delivered = 0; + var sub = new ProbeSubscriber(); + var handler = new PropertyChangedEventHandler(sub.OnChanged); + try { invoke(source, handler); } catch { threwSub = true; } + if (!threwSub) { try { source.Raise(); } catch { threwFirst = true; } } + delivered = sub.Count; + return new WeakReference(sub, trackResurrection: false); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunStrong(ProbeSource source, out int delivered) + { + var sub = new ProbeSubscriber(); + source.PropertyChanged += sub.OnChanged; + source.Raise(); + delivered = sub.Count; + return new WeakReference(sub, trackResurrection: false); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static WeakReference RunCollectable() + => new(new ProbeSubscriber(), trackResurrection: false); + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static void CollectRound(ProbeSource keepA, ProbeSource keepB) + { + var pressure = new byte[AllocPerRound]; + for (var i = 0; i < pressure.Length; i += 4096) pressure[i] = 1; + pressure = null!; + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); + GC.KeepAlive(keepA); + GC.KeepAlive(keepB); + } + + private static Type[] Params(MethodInfo m) => m.GetParameters().Select(p => p.ParameterType).ToArray(); + private static string FullName(Type t) => t.FullName ?? t.Name; + + private static bool IsLoaderFailure(Exception e) + { + var x = Inner(e); + return x is BadImageFormatException or FileNotFoundException or FileLoadException + or TypeLoadException or MissingMethodException or MissingMemberException + or ReflectionTypeLoadException or InvalidOperationException; + } + + private static Exception Inner(Exception e) + => e is TargetInvocationException { InnerException: { } inner } ? inner : e; + + private static string Sha256(string path) + { + using var s = File.OpenRead(path); + using var h = System.Security.Cryptography.SHA256.Create(); + return Convert.ToHexString(h.ComputeHash(s)).ToLowerInvariant(); + } +} + +internal sealed class ProbeSource : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + public void Raise() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Probe")); +} + +internal sealed class ProbeSubscriber +{ + public int Count; + public void OnChanged(object? sender, PropertyChangedEventArgs e) => Count++; +} + +internal sealed class WrapperLoadContext : AssemblyLoadContext +{ + private readonly string _slotsDir; + public WrapperLoadContext(string rootPath, string slotsDir) : base("weak-target", isCollectible: false) + => _slotsDir = slotsDir; + + protected override Assembly? Load(AssemblyName name) + { + if (!Directory.Exists(_slotsDir)) return null; + foreach (var slot in Directory.GetDirectories(_slotsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var dll = Directory.GetFiles(slot, "*.dll").SingleOrDefault(); + if (dll != null && string.Equals(Path.GetFileNameWithoutExtension(dll), name.Name, + StringComparison.OrdinalIgnoreCase)) + return LoadFromAssemblyPath(Path.GetFullPath(dll)); + } + return null; + } +} From 71a7d447d560d446fc087ec9fea0b89fa29355ac Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 20:18:28 +0500 Subject: [PATCH 02/20] feat(s2-step11): fix_target.py orchestration + verify-target CLI The Python half of Step 11. ownlang/fix_target.py reuses the frozen Step 8/9/10 helpers by import (nothing frozen is touched) and drives: - load_authority + the OWN001-only candidate guard; - bind_delta (F1/F2): canonical Step 10 delta, exact schema, all seventeen checks pass, hashes/target/expected bound to THESE plan/candidates; - bind_bundle (F1): frozen Step 8 layout + the four bundle hashes bound to the delta + the target rel equals the frozen Step 10 analysis scope; - reference_closure (F1): reconstruct via the frozen Step 10 ordering and require semantic equality with delta.reference_closure (REFERENCE_BINDING); - conditional inputs (F2): converted needs --probe-dll + --wrapper-ordinal; manual-only forbids them and runs no bind/probe; - snapshot_probe_deployment + resolve_probe_runtime (G2): the probe runtime must match delta.resolved_runtime_identity; - build_bind_params + run_bind (G1): the Roslyn callsite bijection; derived ordinal cross-checked against --wrapper-ordinal (caller ordinal is only an assertion); - three isolated run_probe_attempt children + classify (G3/G4/F5): actual-loaded identity cross-check, controls, and the exact classification precedence (WRAPPER_RUNTIME_UNSUPPORTED / HARNESS_INVALID / TARGET_BEHAVIOR / TARGET_RETAINS / HARNESS_NONDETERMINISM / pass); - converted + manual-only target-result serializers (executed-check tracking); - _publish_target (G5): protected-root exclusion, EXECUTION_WORK_ROOT removed before publication, single atomic rename, honest cleanup-failure PUBLICATION. Adds the `own-fix subscriptions verify-target` CLI verb. Validated end-to-end over a real chain: a genuine weak wrapper -> status pass; a strong decoy -> TARGET_RETAINS. ruff + mypy clean. No frozen file touched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/__main__.py | 58 +++- ownlang/fix_target.py | 692 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 ownlang/fix_target.py diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 3e58250d..87c87e93 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -672,8 +672,58 @@ def _cmd_verify_delta(rest: list[str]) -> int: return 0 +def _cmd_verify_target(rest: list[str]) -> int: + """S2 step 11: `own-fix subscriptions verify-target` — the fake-target gate. Binds the + accepted Step 8 bundle + Step 10 delta, runs the fixed OwnSharp.WeakTargetProbe (Roslyn + callsite bijection, then three isolated runtime probes) over the wrapper the postimage + actually calls, and proves it is a genuine non-retaining subscription.""" + from ownlang.fix_gate import GateError + from ownlang.fix_target import TargetError, run_verify_target + + flags = {"--bundle", "--root", "--plan", "--candidates", "--delta", "--out", + "--probe-dll", "--wrapper-ordinal"} + parsed = _own_fix_parse(rest, flags, {"--ref-dir"}) + if parsed is None: + return 2 + positional, opts = parsed + for f in flags: + if rest.count(f) > 1: + print(f"own-fix: {f} given more than once", file=sys.stderr) + return 2 + required = ("--bundle", "--root", "--plan", "--candidates", "--delta", "--out") + if positional or not all(opts.get(k) for k in required): + print("usage: own-fix subscriptions verify-target --bundle " + "--root --plan " + "--candidates --delta " + "--out [--ref-dir ]... " + "[--probe-dll ] [--wrapper-ordinal ]", file=sys.stderr) + return 2 + wrapper_ordinal = None + if opts.get("--wrapper-ordinal") is not None: + raw = opts["--wrapper-ordinal"] + if not raw.isdigit(): + print("own-fix: --wrapper-ordinal must be a non-negative integer", file=sys.stderr) + return 2 + wrapper_ordinal = int(raw) + try: + published = run_verify_target( + opts["--bundle"], opts["--root"], opts["--plan"], opts["--candidates"], + opts["--delta"], opts.get("--probe-dll"), opts["--out"], + opts.get("--ref-dir") or [], wrapper_ordinal) + except (TargetError, GateError) as exc: + print(f"own-fix: refuse: {exc.category}: {exc}", file=sys.stderr) + return 2 + except Exception as exc: # fail closed + print(f"own-fix: refuse: INFRASTRUCTURE: internal error " + f"({type(exc).__name__}: {exc})", file=sys.stderr) + return 2 + print(f"own-fix: wrote target-result.json -> {published}") + return 0 + + def cmd_own_fix(rest: list[str]) -> int: - """`own-fix subscriptions {candidates|render|validate-plan|apply|gate|verify-delta} ...`.""" + """`own-fix subscriptions {candidates|render|validate-plan|apply|gate|verify-delta| + verify-target} ...`.""" if len(rest) < 2 or rest[0] != "subscriptions": print("usage: python -m ownlang own-fix subscriptions " "{candidates|render|validate-plan|apply|gate|verify-delta} ...", file=sys.stderr) @@ -691,8 +741,10 @@ def cmd_own_fix(rest: list[str]) -> int: return _cmd_gate(args) if verb == "verify-delta": return _cmd_verify_delta(args) - print(f"own-fix: unknown subcommand {verb!r} " - "(candidates | render | validate-plan | apply | gate | verify-delta)", file=sys.stderr) + if verb == "verify-target": + return _cmd_verify_target(args) + print(f"own-fix: unknown subcommand {verb!r} (candidates | render | validate-plan | apply " + "| gate | verify-delta | verify-target)", file=sys.stderr) return 2 diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py new file mode 100644 index 00000000..31e08bf5 --- /dev/null +++ b/ownlang/fix_target.py @@ -0,0 +1,692 @@ +"""S2 step 11 — the Verified Target Wrapper gate (the fake-target gate). + + python -m ownlang own-fix subscriptions verify-target \ + --bundle --root --plan \ + --candidates --delta \ + --probe-dll --out \ + [--ref-dir ]... --wrapper-ordinal + +Step 10 proves the analyzer stops reporting OWN001 for a converted subscription, but the +analyzer recognizes the replacement wrapper BY NAME only. Step 11 proves the wrapper the +accepted Step 8 postimage actually calls is a genuine non-retaining subscription: it runs a +fixed Roslyn `bind` over the pristine preimage + the accepted postimage (per-finding callsite +bijection), then a fixed runtime `probe` (three fresh isolated children) that loads the derived +wrapper from its exact materialized slot, runs a runtime-compatibility preflight, and proves +the subscriber becomes GC-collectable after a subscribe-then-drop. A wrapper that retains the +subscriber is a fake target and is refused TARGET_RETAINS. + +Step 11 reuses the frozen Step 8/9/10 helpers by import and touches NO frozen artifact. +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import tempfile +from typing import Any, cast + +from ownlang.fix_delta import ( + _hash_resolved, + _manifest_sha, + _read_runtimeconfig, + _resolve_dotnet_host, + _runtime_manifest, + _select_runtime, + _walk_regular_files, +) +from ownlang.fix_gate import ( + GateError, + _canonical_bytes, + _canonical_json, + _claim_workdir, + _is_link, + _out_parent, + _same_or_inside, + _same_path, + _sha_bytes, + _snapshot, + validate_gate_authority, +) + +# --- failure taxonomy -------------------------------------------------------------- +INPUT_LAYOUT = "INPUT_LAYOUT" +AUTHORITY_BINDING = "AUTHORITY_BINDING" +DELTA_BINDING = "DELTA_BINDING" +REFERENCE_BINDING = "REFERENCE_BINDING" +TOOLCHAIN_BINDING = "TOOLCHAIN_BINDING" +CALLSITE_BINDING = "CALLSITE_BINDING" +WRAPPER_BINDING = "WRAPPER_BINDING" +WRAPPER_RUNTIME_UNSUPPORTED = "WRAPPER_RUNTIME_UNSUPPORTED" +HARNESS_INVALID = "HARNESS_INVALID" +TARGET_BEHAVIOR = "TARGET_BEHAVIOR" +TARGET_RETAINS = "TARGET_RETAINS" +HARNESS_NONDETERMINISM = "HARNESS_NONDETERMINISM" +ISOLATION = "ISOLATION" +PUBLICATION = "PUBLICATION" +INFRASTRUCTURE = "INFRASTRUCTURE" + +_CHECK_NAMES = ( + "input_layout", "authority_binding", "delta_binding", "reference_binding", + "probe_toolchain_binding", "wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism", "publication", +) +_MANUAL_ONLY_NA = ( + "probe_toolchain_binding", "wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism", +) +_STEP10_CHECKS = ( + "input_layout", "authority_binding", "gate_binding", "toolchain_binding", + "core_analyzer_binding", "analysis_scope", "baseline_authority", "baseline_analysis", + "postimage_analysis", "analysis_identity", "delta_subscription", "delta_core", + "new_own001", "new_own050", "semantic_idempotence", "isolation", "publication", +) +_ATTEMPT_COUNT = 3 +_COLLECTION_ROUNDS = 5 +_ALLOC_PER_ROUND = 4194304 +_CHILD_TIMEOUT_SECONDS = 30 +_OUT_LIMIT = 65536 + + +class TargetError(Exception): + """A controlled refusal carrying the stable category for regression assertions.""" + + def __init__(self, category: str, message: str) -> None: + super().__init__(message) + self.category = category + + +def _canonical(obj: dict[str, Any]) -> bytes: + return _canonical_bytes(obj) + + +# --- authority + delta + bundle + reference binding (F1, F2) ------------------------ + + +def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any, Any]: + try: + plan = json.loads(plan_bytes) + candidates = json.loads(candidates_bytes) + except ValueError as exc: + raise TargetError(AUTHORITY_BINDING, f"plan/candidates not valid JSON ({exc})") from exc + try: + auth = validate_gate_authority(plan, candidates) + except GateError as exc: + raise TargetError(exc.category, str(exc)) from exc + for c in candidates["candidates"]: + if c.get("diagnostic_code") != "OWN001": + raise TargetError(AUTHORITY_BINDING, "candidates carry a non-OWN001 diagnostic") + return auth, plan, candidates + + +def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, + candidates_bytes: bytes) -> dict[str, Any]: + """Bind the Step 10 delta-result.json as the upstream authority (canonical bytes, exact + schema, all seventeen checks pass, hashes/target/expected bound to THESE inputs).""" + cat = DELTA_BINDING + if not delta_bytes.endswith(b"\n"): + raise TargetError(cat, "delta-result.json is missing its trailing newline") + try: + d = json.loads(delta_bytes) + except ValueError as exc: + raise TargetError(cat, f"delta-result.json is not valid JSON ({exc})") from exc + if _canonical_bytes(d) != delta_bytes: + raise TargetError(cat, "delta-result.json is not canonical bytes") + if d.get("schema") != 1 or d.get("operation") != "verify-subscription-analyzer-delta" \ + or d.get("status") != "pass": + raise TargetError(cat, "delta-result.json schema/operation/status is wrong") + checks = d.get("checks") + if not isinstance(checks, dict) or set(checks) != set(_STEP10_CHECKS) \ + or set(checks.values()) != {"pass"}: + raise TargetError(cat, "delta-result.json checks are not the seventeen all-pass set") + ih = d.get("input_hashes", {}) + if ih.get("input_bundle_sha256") != auth.input_bundle_sha256 \ + or ih.get("validated_plan_sha256") != _sha_bytes(plan_bytes) \ + or ih.get("candidates_sha256") != _sha_bytes(candidates_bytes): + raise TargetError(cat, "delta-result.json is not bound to these plan/candidates") + if d.get("target_api", {}).get("subscribe") != auth.target_subscribe: + raise TargetError(cat, "delta-result.json target_api does not bind the plan") + if d.get("expected", {}).get("convert_acquire_ids") != sorted(auth.applied) \ + or d.get("expected", {}).get("manual_review_ids") != sorted(auth.manual): + raise TargetError(cat, "delta-result.json expected ids do not bind the plan") + return cast("dict[str, Any]", d) + + +def bind_bundle(bundle: str, root: str, rel: str, delta: dict[str, Any]) -> dict[str, str]: + """Validate the frozen Step 8 bundle layout and bind its four hashes to the delta (F1).""" + cat = DELTA_BINDING + try: + names = set(os.listdir(bundle)) + except OSError as exc: + raise TargetError(cat, f"cannot list --bundle ({exc})") from exc + if names != {"change.patch", "apply-manifest.json", "postimage"}: + raise TargetError(cat, f"--bundle holds {sorted(names)}, not the step 8 layout") + manifest = _snapshot(os.path.join(bundle, "apply-manifest.json"), cat, "apply-manifest.json") + patch = _snapshot(os.path.join(bundle, "change.patch"), cat, "change.patch") + postimage = _snapshot(os.path.join(bundle, "postimage", *rel.split("/")), cat, "postimage") + preimage = _snapshot(os.path.join(root, *rel.split("/")), cat, "preimage") + ih = delta["input_hashes"] + if _sha_bytes(manifest) != ih["apply_manifest_sha256"] \ + or _sha_bytes(patch) != ih["patch_sha256"] \ + or _sha_bytes(preimage) != ih["pre_sha256"] \ + or _sha_bytes(postimage) != ih["post_sha256"]: + raise TargetError(cat, "step 8 bundle hashes do not bind the delta") + scope = delta.get("analysis_scope", {}) + if scope.get("source_file") != rel or scope.get("target_file_identity") != rel: + raise TargetError(cat, "target rel does not equal the frozen Step 10 analysis scope") + return {"preimage": preimage.decode("utf-8"), "postimage": postimage.decode("utf-8"), + "apply_manifest_sha256": ih["apply_manifest_sha256"], + "patch_sha256": ih["patch_sha256"], + "pre_sha256": ih["pre_sha256"], "post_sha256": ih["post_sha256"]} + + +def reference_closure(work: str, ref_dirs: list[str], + delta: dict[str, Any]) -> tuple[list[str], list[dict[str, Any]]]: + """Materialize the ordered one-DLL-per-slot closure and require semantic equality with + delta.reference_closure (REFERENCE_BINDING).""" + from ownlang.fix_delta import snapshot_reference_closure + try: + slot_dirs, evidence = snapshot_reference_closure(work, ref_dirs) + except Exception as exc: # fix_delta raises DeltaError(ANALYSIS_SCOPE/INPUT_LAYOUT) + cat = getattr(exc, "category", REFERENCE_BINDING) + raise TargetError(REFERENCE_BINDING if cat == "ANALYSIS_SCOPE" else INPUT_LAYOUT, + str(exc)) from exc + if evidence != delta.get("reference_closure"): + raise TargetError(REFERENCE_BINDING, + "reconstructed closure != delta.reference_closure") + return slot_dirs, evidence + + +# --- probe toolchain + runtime (G2) ------------------------------------------------ + + +def snapshot_probe_deployment(work: str, probe_dll: str) -> tuple[str, dict[str, Any]]: + """Snapshot the whole probe deployment into WORK/probe and return the copied DLL path + + the probe fingerprint. Execute the COPY (TOCTOU-closed).""" + dll_abs = os.path.abspath(probe_dll) + src = os.path.dirname(dll_abs) + name = os.path.basename(dll_abs) + if _is_link(os.lstat(src)): + raise TargetError(TOOLCHAIN_BINDING, "the probe deployment root is a link") + dst_root = os.path.join(work, "probe") + os.makedirs(dst_root) + manifest: list[dict[str, str]] = [] + for rel in _walk_regular_files(src, TOOLCHAIN_BINDING): + data = _snapshot(os.path.join(src, rel.replace("/", os.sep)), + TOOLCHAIN_BINDING, f"probe {rel}") + dst = os.path.join(dst_root, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, "wb") as fh: + fh.write(data) + manifest.append({"path": rel, "sha256": _sha_bytes(data)}) + manifest.sort(key=lambda m: m["path"]) + if name not in {m["path"] for m in manifest}: + raise TargetError(TOOLCHAIN_BINDING, f"the probe DLL {name!r} is not in its deployment") + fingerprint = { + "probe_deployment_manifest_sha256": _sha_bytes(_canonical_json(manifest)), + "probe_runner_sha256": _sha_bytes(_snapshot(os.path.join(dst_root, name), + TOOLCHAIN_BINDING, "probe runner")), + "probe_files": manifest, + } + return os.path.join(dst_root, name), fingerprint + + +def resolve_probe_runtime(dll_dst: str, dotnet_host: str, + delta: dict[str, Any]) -> tuple[dict[str, Any], str, str, str, str]: + """Select the runtime with the accepted Step 10 policy and require it to MATCH the runtime + Step 10 recorded (G2.2). Returns (probe_runtime_identity, dotnet_version, dotnet_host_sha256, + selected_version, rt_dir).""" + tfm, fname, fver = _read_runtimeconfig(dll_dst) + dotnet_host_sha = _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") + from ownlang.fix_delta import _run_capture + dotnet_version = _run_capture([dotnet_host, "--version"], TOOLCHAIN_BINDING, + "dotnet --version").strip() + listing = _run_capture([dotnet_host, "--list-runtimes"], TOOLCHAIN_BINDING, + "dotnet --list-runtimes") + selected_ver, rt_dir = _select_runtime(listing, fname, fver) + if not os.path.isdir(rt_dir): + raise TargetError(TOOLCHAIN_BINDING, "the selected runtime directory does not exist") + identity = {"framework_name": fname, "tfm": tfm, "requested_framework_version": fver, + "selected_framework_version": selected_ver, + "selected_runtime_manifest_sha256": _runtime_manifest(rt_dir)} + step10 = delta.get("toolchain_fingerprint", {}).get("resolved_runtime_identity", {}) + for k in ("framework_name", "tfm", "requested_framework_version", + "selected_framework_version", "selected_runtime_manifest_sha256"): + s10 = step10.get("runtime_manifest_sha256" if k == "selected_runtime_manifest_sha256" + else k) + if identity[k] != s10: + raise TargetError(TOOLCHAIN_BINDING, + f"probe runtime {k} does not match the Step 10 runtime") + return identity, dotnet_version, dotnet_host_sha, selected_ver, rt_dir + + +# --- bind-params + the Roslyn bind subprocess (G1) --------------------------------- + + +def _peel_handler(handler: str) -> str: + """The frozen Step 8 handler peel + whitespace normalization, mirrored for bind-params: + `new H(M)` / `new(M)` -> M, then collapse whitespace.""" + import re + s = handler.strip() + while True: + m = re.fullmatch(r"new\s+[^\s(]+\s*\(\s*(.*)\s*\)", s) or re.fullmatch( + r"new\s*\(\s*(.*)\s*\)", s) + if not m: + break + s = m.group(1).strip() + return " ".join(s.split()) + + +def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict[str, Any]: + by_id = {c["finding_id"]: c for c in candidates["candidates"]} + conv: list[dict[str, Any]] = [] + for fid in convert_ids: + c = by_id[fid] + conv.append({ + "finding_id": fid, + "occurrence_ordinal": c["occurrence_ordinal"], + "file": rel, + "containing_type": c["containing_type"], + "event": c["event"], + "source": c["source"], + "handler": c["handler"], + "normalized_handler": _peel_handler(c["handler"]), + "acquire_span": {"start": c["acquire_span"]["start"], + "length": c["acquire_span"]["length"]}, + }) + return {"converted": conv} + + +_BIND_EXIT = {11: CALLSITE_BINDING, 12: WRAPPER_BINDING, 13: TOOLCHAIN_BINDING} + + +def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel: str, + preimage: str, postimage: str, slots_dir: str, target: str, + selected_class: str, bind_params: dict[str, Any]) -> dict[str, Any]: + core = os.path.join(work, "bind") + os.makedirs(core, exist_ok=True) + pre_path = os.path.join(core, "pre.cs") + post_path = os.path.join(core, "post.cs") + params_path = os.path.join(core, "bind-params.json") + out_path = os.path.join(core, "binding-result.json") + with open(pre_path, "w", encoding="utf-8", newline="") as fh: + fh.write(preimage) + with open(post_path, "w", encoding="utf-8", newline="") as fh: + fh.write(postimage) + with open(params_path, "wb") as fh: + fh.write(_canonical_json(bind_params)) + argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", + probe_dll, "bind", "--preimage", pre_path, "--postimage", post_path, + "--slots-dir", slots_dir, "--target", target, "--selected-class", selected_class, + "--source-file", rel, "--bind-params", params_path, "--out", out_path] + proc = subprocess.run(argv, cwd=core, env=_probe_env(work, core), + capture_output=True, text=True, check=False) + if proc.returncode in _BIND_EXIT: + raise TargetError(_BIND_EXIT[proc.returncode], f"bind: {proc.stderr.strip()[:300]}") + if proc.returncode != 0: + raise TargetError(INFRASTRUCTURE, f"bind failed (rc={proc.returncode}): " + f"{proc.stderr.strip()[:300]}") + try: + with open(out_path, "rb") as fh: + raw = fh.read() + binding = json.loads(raw) + except (OSError, ValueError) as exc: + raise TargetError(INFRASTRUCTURE, f"binding-result.json unreadable ({exc})") from exc + if _canonical_bytes(binding) != raw: + raise TargetError(INFRASTRUCTURE, "binding-result.json is not canonical bytes") + return cast("dict[str, Any]", binding) + + +def _probe_env(work: str, cwd_dir: str) -> dict[str, str]: + env: dict[str, str] = {} + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"): + if k in os.environ: + env[k] = os.environ[k] + home = os.path.join(work, "home") + env["HOME"] = home + env["XDG_CACHE_HOME"] = os.path.join(home, ".cache") + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + env["DOTNET_NOLOGO"] = "1" + env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" + env["TMPDIR"] = cwd_dir + env["TEMP"] = cwd_dir + env["TMP"] = cwd_dir + return env + + +# --- the probe subprocess + classification (G3, G4, F4, F5) ------------------------- + +_PROBE_KEYS = ("version", "operation", "attempt", "strong_delivered_once", "strong_retained", + "weak_control_collected", "delivered_count", "threw_on_subscribe", + "threw_on_first_raise", "subscriber_collected", "threw_on_post_collection_raise", + "resolved_wrapper") +_RESOLVED_KEYS = ("ordinal", "slot_sha256", "assembly_simple_name", "module_mvid", + "metadata_token", "resolved_signature") + + +def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, + wrapper_ordinal: int, slots_dir: str, target: str, + attempt: int) -> tuple[int, dict[str, Any] | None]: + adir = os.path.join(work, f"attempt-{attempt}") + os.makedirs(adir, exist_ok=True) + out_path = os.path.join(adir, "probe-result.json") + argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", + probe_dll, "probe", "--wrapper-ordinal", str(wrapper_ordinal), + "--slots-dir", slots_dir, "--attempt", str(attempt), "--target", target, + "--out", out_path] + try: + proc = subprocess.run(argv, cwd=os.path.join(work, "probe"), env=_probe_env(work, adir), + capture_output=True, timeout=_CHILD_TIMEOUT_SECONDS, check=False) + except subprocess.TimeoutExpired: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} timed out") from None + if len(proc.stdout) > _OUT_LIMIT or len(proc.stderr) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} output overflow") + if proc.returncode == 10: + return 10, None + if proc.returncode != 0: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={proc.returncode}") + try: + with open(out_path, "rb") as fh: + raw = fh.read() + if len(raw) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, "probe-result.json too large") + obj = json.loads(raw) + except (OSError, ValueError) as exc: + raise TargetError(INFRASTRUCTURE, f"probe-result.json unreadable ({exc})") from exc + if _canonical_bytes(obj) != raw: + raise TargetError(INFRASTRUCTURE, "probe-result.json is not canonical bytes") + _validate_probe_result(obj, attempt) + return 0, obj + + +def _validate_probe_result(obj: Any, attempt: int) -> None: + if not isinstance(obj, dict) or set(obj) != set(_PROBE_KEYS): + raise TargetError(INFRASTRUCTURE, "probe-result.json is not the exact schema") + if obj["version"] != 1 or obj["operation"] != "weak-target-probe" or obj["attempt"] != attempt: + raise TargetError(INFRASTRUCTURE, "probe-result.json version/operation/attempt wrong") + for k in ("strong_delivered_once", "strong_retained", "weak_control_collected", + "threw_on_subscribe", "threw_on_first_raise", "subscriber_collected", + "threw_on_post_collection_raise"): + if not isinstance(obj[k], bool): + raise TargetError(INFRASTRUCTURE, f"probe-result.{k} must be a boolean") + if not isinstance(obj["delivered_count"], int) or isinstance(obj["delivered_count"], bool): + raise TargetError(INFRASTRUCTURE, "probe-result.delivered_count must be an int") + rw = obj["resolved_wrapper"] + if not isinstance(rw, dict) or set(rw) != set(_RESOLVED_KEYS): + raise TargetError(INFRASTRUCTURE, "probe-result.resolved_wrapper is not the exact schema") + + +def _attempt_verdict(p: dict[str, Any]) -> str: + if p["threw_on_subscribe"] or p["threw_on_first_raise"] \ + or p["threw_on_post_collection_raise"] or p["delivered_count"] != 1: + return "TARGET_BEHAVIOR" + if not p["subscriber_collected"]: + return "TARGET_RETAINS" + return "pass" + + +def classify(attempts: list[dict[str, Any]], binding: dict[str, Any], + wrapper_ordinal: int, slot_evidence: list[dict[str, Any]]) -> str: + """Return the final target verdict ('pass') or raise the refusal per the exact F5 + precedence, after the G3 actually-loaded identity cross-check.""" + b = binding["resolved_wrapper"] + slot = slot_evidence[wrapper_ordinal] + for p in attempts: + rw = p["resolved_wrapper"] + if rw["ordinal"] != wrapper_ordinal or rw["slot_sha256"] != slot["sha256"] \ + or rw["assembly_simple_name"] != b["assembly_simple_name"] \ + or rw["module_mvid"] != b["module_mvid"] \ + or rw["metadata_token"] != b["metadata_token"] \ + or rw["resolved_signature"] != b["resolved_signature"]: + raise TargetError(WRAPPER_BINDING, "an attempt loaded a different wrapper identity") + # 2. controls + for p in attempts: + if not (p["strong_delivered_once"] and p["strong_retained"] + and p["weak_control_collected"]): + raise TargetError(HARNESS_INVALID, "a strong/collectability control failed") + # 3-7. target verdicts + verdicts = [_attempt_verdict(p) for p in attempts] + if len(set(verdicts)) != 1: + raise TargetError(HARNESS_NONDETERMINISM, f"attempts disagree: {verdicts}") + v = verdicts[0] + if v == "TARGET_BEHAVIOR": + raise TargetError(TARGET_BEHAVIOR, "the wrapper did not deliver exactly once / threw") + if v == "TARGET_RETAINS": + raise TargetError(TARGET_RETAINS, "the wrapper retained the subscriber (fake target)") + return "pass" + + +# --- publication (F7, G5) ---------------------------------------------------------- + + +def _publish_target(out: str, protected: list[str], evidence_bytes: bytes) -> str: + from ownlang.fix_gate import PUBLICATION as _P + try: + out_phys, parent_phys, _root_phys = _out_parent(out, out) # root==out: only the + # existence/off-parent checks matter; the protected-root exclusion is explicit below. + except GateError as exc: + raise TargetError(exc.category if exc.category != _P else PUBLICATION, str(exc)) from exc + for root in protected: + if _same_or_inside(os.path.realpath(root), parent_phys): + raise TargetError(PUBLICATION, + "the out-dir parent resolves inside a protected root") + workdir = _claim_workdir(parent_phys) + succeeded = False + try: + with open(os.path.join(workdir, "target-result.json"), "wb") as fh: + fh.write(evidence_bytes) + if not _same_path(os.path.realpath(os.path.dirname(out_phys)), parent_phys) \ + or os.path.exists(out_phys) or os.path.islink(out_phys): + raise TargetError(PUBLICATION, "the out-dir destination changed before publication") + _require_single(workdir) + try: + os.rename(workdir, out_phys) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot publish ({exc})") from exc + succeeded = True + finally: + if not succeeded: + try: + shutil.rmtree(workdir) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot remove staging ({exc})") from exc + return out_phys + + +def _require_single(workdir: str) -> None: + entries = list(os.scandir(workdir)) + if len(entries) != 1 or entries[0].name != "target-result.json": + raise TargetError(PUBLICATION, "staging is not exactly target-result.json") + st = entries[0].stat(follow_symlinks=False) + if _is_link(st) or not stat.S_ISREG(st.st_mode): + raise TargetError(PUBLICATION, "staged target-result.json is not a regular file") + + +# --- target-result serializers + orchestration ------------------------------------- + + +def _delta_binding_block(delta_bytes: bytes) -> dict[str, Any]: + return {"delta_result_sha256": _sha_bytes(delta_bytes), + "step10_operation": "verify-subscription-analyzer-delta", + "step10_status": "pass", "bound": True} + + +def build_manual_only_result(input_hashes: dict[str, Any], delta_bytes: bytes, + delta: dict[str, Any], target: str, + checks_passed: set[str]) -> dict[str, Any]: + if checks_passed != {"input_layout", "authority_binding", "delta_binding", + "reference_binding", "publication"}: + raise TargetError(INFRASTRUCTURE, "manual-only executed-check set is incomplete") + checks = {n: "not_applicable" if n in _MANUAL_ONLY_NA else "pass" for n in _CHECK_NAMES} + return {"schema": 1, "operation": "verify-target-wrapper", "status": "pass", + "input_hashes": input_hashes, "delta_binding": _delta_binding_block(delta_bytes), + "target_api": {"subscribe": target}, "reference_closure": delta["reference_closure"], + "checks": checks} + + +def build_converted_result(input_hashes: dict[str, Any], delta_bytes: bytes, + delta: dict[str, Any], target: str, slot_evidence: list[dict[str, Any]], + wrapper_ordinal: int, binding: dict[str, Any], probe_fp: dict[str, Any], + dotnet_host_sha: str, dotnet_version: str, + runtime_identity: dict[str, Any], attempts: list[dict[str, Any]], + checks_passed: set[str]) -> dict[str, Any]: + if checks_passed != set(_CHECK_NAMES): + raise TargetError(INFRASTRUCTURE, + f"refusing to publish unexecuted checks: " + f"{sorted(set(_CHECK_NAMES) - checks_passed)}") + b = binding["resolved_wrapper"] + slot = slot_evidence[wrapper_ordinal] + attempt_rows = [{ + "attempt": p["attempt"], "strong_delivered_once": p["strong_delivered_once"], + "strong_retained": p["strong_retained"], + "weak_control_collected": p["weak_control_collected"], + "delivered_count": p["delivered_count"], "threw_on_subscribe": p["threw_on_subscribe"], + "threw_on_first_raise": p["threw_on_first_raise"], + "subscriber_collected": p["subscriber_collected"], + "threw_on_post_collection_raise": p["threw_on_post_collection_raise"], + "verdict": _attempt_verdict(p), + } for p in attempts] + return { + "schema": 1, "operation": "verify-target-wrapper", "status": "pass", + "input_hashes": input_hashes, + "delta_binding": _delta_binding_block(delta_bytes), + "target_api": {"subscribe": target}, + "reference_closure": delta["reference_closure"], + "callsite_binding": { + "converted_callsites": binding["converted_callsites"], + "all_callsites_same_symbol": binding["callsite_binding"]["all_callsites_same_symbol"], + "target_is_source_defined": binding["callsite_binding"]["target_is_source_defined"], + "derived_wrapper_ordinal": binding["derived_wrapper_ordinal"], + "asserted_wrapper_ordinal": wrapper_ordinal, + }, + "selected_wrapper": { + "ordinal": wrapper_ordinal, "relative_path": slot["relative_path"], + "sha256": slot["sha256"], "assembly_simple_name": b["assembly_simple_name"], + "module_mvid": b["module_mvid"], "metadata_token": b["metadata_token"], + "resolved_signature": b["resolved_signature"], + }, + "probe_toolchain_fingerprint": {**probe_fp, "dotnet_host_sha256": dotnet_host_sha, + "dotnet_version": dotnet_version}, + "probe_runtime_identity": runtime_identity, + "probe_protocol": { + "attempt_count": _ATTEMPT_COUNT, "collection_rounds": _COLLECTION_ROUNDS, + "allocation_pressure_bytes_per_round": _ALLOC_PER_ROUND, + "child_timeout_seconds": _CHILD_TIMEOUT_SECONDS, "stdout_limit_bytes": _OUT_LIMIT, + "stderr_limit_bytes": _OUT_LIMIT, "probe_result_limit_bytes": _OUT_LIMIT, + "delivered_count_required": 1, + }, + "attempts": attempt_rows, + "checks": dict.fromkeys(_CHECK_NAMES, "pass"), + } + + +def _revalidate(work: str, probe_fp: dict[str, Any], slot_evidence: list[dict[str, Any]], + slot_dirs: list[str], rt_dir: str, runtime_identity: dict[str, Any]) -> None: + pdir = os.path.join(work, "probe") + if _manifest_sha(pdir, _walk_regular_files(pdir, TOOLCHAIN_BINDING), TOOLCHAIN_BINDING) \ + != probe_fp["probe_deployment_manifest_sha256"]: + raise TargetError(TOOLCHAIN_BINDING, "the materialized probe deployment changed") + for i, ev in enumerate(slot_evidence): + dll = os.path.join(slot_dirs[i], ev["relative_path"].rsplit("/", 1)[-1]) + if _sha_bytes(_snapshot(dll, REFERENCE_BINDING, "slot")) != ev["sha256"]: + raise TargetError(REFERENCE_BINDING, "a materialized reference slot changed") + if _runtime_manifest(rt_dir) != runtime_identity["selected_runtime_manifest_sha256"]: + raise TargetError(TOOLCHAIN_BINDING, "the selected runtime changed") + + +def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: str, + delta_path: str, probe_dll: str | None, out: str, ref_dirs: list[str], + wrapper_ordinal: int | None) -> str: + passed: set[str] = set() + plan_bytes = _snapshot(plan_path, INPUT_LAYOUT, "--plan") + candidates_bytes = _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") + delta_bytes = _snapshot(delta_path, INPUT_LAYOUT, "--delta") + auth, _plan, candidates = load_authority(plan_bytes, candidates_bytes) + passed.add("authority_binding") + convert_ids = list(auth.applied) + converted = bool(convert_ids) + if converted and (probe_dll is None or wrapper_ordinal is None): + raise TargetError(INPUT_LAYOUT, "converted plan needs --probe-dll and --wrapper-ordinal") + if not converted and (probe_dll is not None or wrapper_ordinal is not None): + raise TargetError(INPUT_LAYOUT, "manual-only plan forbids --probe-dll/--wrapper-ordinal") + + delta = bind_delta(delta_bytes, auth, plan_bytes, candidates_bytes) + rel = auth.rel + bundle_info = bind_bundle(bundle, root, rel, delta) + passed.add("input_layout") + passed.add("delta_binding") + target = auth.target_subscribe + input_hashes = { + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "candidates_sha256": _sha_bytes(candidates_bytes), + "apply_manifest_sha256": bundle_info["apply_manifest_sha256"], + "patch_sha256": bundle_info["patch_sha256"], + "pre_sha256": bundle_info["pre_sha256"], "post_sha256": bundle_info["post_sha256"], + } + + work = tempfile.mkdtemp(prefix="owen-target-") + try: + slot_dirs, slot_evidence = reference_closure(work, ref_dirs, delta) + passed.add("reference_binding") + protected = [root, bundle, work, *ref_dirs] + if probe_dll is not None: + protected.append(os.path.dirname(os.path.abspath(probe_dll))) + + if not converted: + passed.add("publication") + evidence = build_manual_only_result(input_hashes, delta_bytes, delta, target, passed) + return _publish_target(out, protected, _canonical(evidence)) + + assert probe_dll is not None and wrapper_ordinal is not None + probe_dll_dst, probe_fp = snapshot_probe_deployment(work, probe_dll) + try: + dotnet_host = _resolve_dotnet_host() + except Exception as exc: + raise TargetError(INFRASTRUCTURE, str(exc)) from exc + runtime_identity, dotnet_version, dotnet_host_sha, selected_ver, rt_dir = \ + resolve_probe_runtime(probe_dll_dst, dotnet_host, delta) + passed.add("probe_toolchain_binding") + + class_fqn = candidates["selection"]["allowed_types"][0]["full_name"] + bind_params = build_bind_params(candidates, convert_ids, rel) + slots_root = os.path.join(work, "references") + binding = run_bind(work, dotnet_host, probe_dll_dst, selected_ver, rel, + bundle_info["preimage"], bundle_info["postimage"], slots_root, + target, class_fqn, bind_params) + if binding["converted_callsites"] != len(convert_ids): + raise TargetError(CALLSITE_BINDING, "bound callsite count != converted candidates") + if not (0 <= wrapper_ordinal < len(slot_evidence)): + raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal is out of range") + if binding["derived_wrapper_ordinal"] != wrapper_ordinal: + raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal != the derived ordinal") + + attempts: list[dict[str, Any]] = [] + for k in range(_ATTEMPT_COUNT): + rc, res = run_probe_attempt(work, dotnet_host, probe_dll_dst, selected_ver, + wrapper_ordinal, slots_root, target, k) + if rc == 10: + raise TargetError(WRAPPER_RUNTIME_UNSUPPORTED, + "the wrapper cannot execute under the fixed probe runtime") + assert res is not None + attempts.append(res) + classify(attempts, binding, wrapper_ordinal, slot_evidence) + passed.update({"wrapper_binding", "harness_controls", "target_behavior", + "target_nonretention", "harness_determinism"}) + + _revalidate(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity) + passed.add("publication") + evidence = build_converted_result(input_hashes, delta_bytes, delta, target, slot_evidence, + wrapper_ordinal, binding, probe_fp, dotnet_host_sha, + dotnet_version, runtime_identity, attempts, passed) + evidence_bytes = _canonical(evidence) + try: + shutil.rmtree(work) # G5: remove EXECUTION_WORK_ROOT before public publication + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot remove the work root ({exc})") from exc + return _publish_target(out, protected, evidence_bytes) + finally: + shutil.rmtree(work, ignore_errors=True) + From e2469f4a53bb29c0b1211dd30f0ced7a1446978f Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 20:55:21 +0500 Subject: [PATCH 03/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20veri?= =?UTF-8?q?fy-target=20Tier=20A=20+=20full-CLI=20Tier=20B=20acceptance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier A (SDK-free) drives fix_target's manual-only path end to end plus the classify precedence matrix, the converted/manual-only serializers, the atomic publisher, and the handler peel. Tier B drives the real PUBLIC CLI only (extractor -> candidates -> validate-plan -> apply -> gate -> verify-delta -> verify-target) over separately-compiled wrapper fixtures shipped as reference slots: weak -> pass, strong -> TARGET_RETAINS, no-op/twice/throwing -> TARGET_BEHAVIOR, wrong-signature -> WRAPPER_BINDING, source-defined -> CALLSITE_BINDING, two converted callsites -> pass, net9 / missing-dependency -> WRAPPER_RUNTIME_UNSUPPORTED, manual-only -> pass (six probe checks not_applicable), two runs -> byte-identical. The wrong-signature case is RED against the current bind: a three-parameter wrapper surfaces as WRAPPER_RUNTIME_UNSUPPORTED (the probe method-select throws) instead of the contract's WRAPPER_BINDING. The next commit adds the TARGET_BINDING shape check that turns it green. Wired REQUIRED into the wpf-extractor CI job (OWN_TIERB_REQUIRED=1); adds the verify-target row to spec/CLI.md and installs the 9.0 SDK there purely to build the deliberately-incompatible net9 wrapper fixture. Frozen Steps 0-10 untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .github/workflows/ci.yml | 11 +- spec/CLI.md | 1 + tests/test_verify_target.py | 311 +++++++++++++++++++ tests/test_verify_target_tierb.py | 490 ++++++++++++++++++++++++++++++ 4 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 tests/test_verify_target.py create mode 100644 tests/test_verify_target_tierb.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39371efa..7b40e20d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,12 @@ jobs: python-version: "3.13" - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: - dotnet-version: "8.0.x" + # 8.0.x runs the pinned net8.0 extractor/probe; 9.0.x is only needed to BUILD the + # deliberately-incompatible net9 wrapper fixture in the step 11 Tier B suite (which + # then proves it is refused WRAPPER_RUNTIME_UNSUPPORTED under the fixed net8 probe). + dotnet-version: | + 8.0.x + 9.0.x - name: Extract OwnIR facts from sample C# run: | dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ @@ -260,6 +265,10 @@ jobs: env: OWN_TIERB_REQUIRED: "1" run: python tests/test_verify_delta_tierb.py + - name: S2 step 11 verified-target-wrapper gate (Tier B, full public CLI) + env: + OWN_TIERB_REQUIRED: "1" + run: python tests/test_verify_target_tierb.py - name: Check facts through the core run: | out=$(python -m ownlang ownir "$RUNNER_TEMP/facts.json" || true) diff --git a/spec/CLI.md b/spec/CLI.md index 9ba920ab..e66bcc15 100644 --- a/spec/CLI.md +++ b/spec/CLI.md @@ -12,6 +12,7 @@ | `config`| reads an explicit `own.toml` and prints the declared P-035 `[weak-subscription].subscribe` names, one per line (the minimal P-015 config carrier). `python -m ownlang config ` | non-zero on a **malformed** config (hard error) | | `own-fix subscriptions candidates`| S0 (analysis-only): reads a `--fix-candidates` facts file and, for one **exact** `--class `, emits a deterministic `candidates.json` — a selection-request safety envelope plus a candidate bundle per leaky subscription (line-independent `finding_id`, pinned `target_api`, `allowed_actions` = `convert_acquire` for a proven INotifyPropertyChanged contract else `manual_review`, per-file SHA-256). `python -m ownlang own-fix subscriptions candidates --config --class [--finding-id ]... --output [--root ]` | non-zero on a partial/nested/generated/unknown class, an unknown finding-id, an unpinnable target, or an unreadable source | | `own-fix subscriptions verify-delta`| S2 step 10 (analyzer-semantic gate): binds the mandatory step 9 `gate-result.json`, then re-runs Own.NET's real core analyzer — from a **snapshotted** `ownlang` package in a fresh isolated `python -S -B -E` subprocess, the extractor from a snapshotted deployment on the pinned runtime — over the pristine preimage and the accepted step 8 postimage, and proves the OWN001 delta matches the plan (converted candidates gone, manual-review preserved, no new OWN001 of any resource lane, no new OWN050), publishing a byte-deterministic `delta-result.json`. OWN001-only (an OWN014 candidate is `ANALYSIS_SCOPE`); no `--config`. `python -m ownlang own-fix subscriptions verify-delta --bundle --plan --candidates --root --gate --extractor-dll --out [--ref-dir ]...` | non-zero (exit 2) on any refusal (stable category: `INPUT_LAYOUT`/`AUTHORITY_BINDING`/`GATE_BINDING`/`TOOLCHAIN_BINDING`/`ANALYSIS_SCOPE`/`BASELINE_ANALYSIS`/`POSTIMAGE_ANALYSIS`/`ANALYSIS_IDENTITY`/`DELTA_MISMATCH`/`NEW_OWN001`/`NEW_OWN050`/`IDEMPOTENCE`/`ISOLATION`/`PUBLICATION`/`INFRASTRUCTURE`), no partial output | +| `own-fix subscriptions verify-target`| S2 step 11 (fake-target gate): binds the mandatory step 10 `delta-result.json` and the step 8 bundle, then proves the wrapper the accepted postimage actually calls is a genuine non-retaining subscription — a fixed Roslyn `bind` (SemanticModel over the pristine preimage + accepted postimage; per-finding callsite bijection; the `plan.target_api.subscribe` shape resolved inside the selected reference-slot wrapper) followed by a fixed runtime `probe` (three fresh isolated children that load the derived wrapper from its exact materialized slot via a dedicated AssemblyLoadContext, run a runtime-compatibility preflight, then a frozen GC harness) proving the subscriber becomes GC-collectable after a subscribe-then-drop. A wrapper that retains the subscriber is a fake target (`TARGET_RETAINS`). A converted plan needs `--probe-dll`/`--wrapper-ordinal`; a manual-only plan forbids them (the six probe checks publish `not_applicable`). Publishes a byte-deterministic `target-result.json`. `python -m ownlang own-fix subscriptions verify-target --bundle --root --plan --candidates --delta --out [--probe-dll --wrapper-ordinal ] [--ref-dir ]...` | non-zero (exit 2) on any refusal (stable category: `INPUT_LAYOUT`/`AUTHORITY_BINDING`/`DELTA_BINDING`/`REFERENCE_BINDING`/`TOOLCHAIN_BINDING`/`CALLSITE_BINDING`/`WRAPPER_BINDING`/`WRAPPER_RUNTIME_UNSUPPORTED`/`HARNESS_INVALID`/`TARGET_BEHAVIOR`/`TARGET_RETAINS`/`HARNESS_NONDETERMINISM`/`ISOLATION`/`PUBLICATION`/`INFRASTRUCTURE`), no partial output | Notes: - `check`'s non-zero exit on errors is what makes it usable as a CI gate. diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py new file mode 100644 index 00000000..8fb33cf7 --- /dev/null +++ b/tests/test_verify_target.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""S2 step 11 — the Verified Target Wrapper gate (Tier A, SDK-free). + +Drives ownlang/fix_target.py without dotnet: the manual-only path is a full public +end-to-end (authority, delta binding, Step 8 bundle binding, reference-closure equality, +conditional inputs, the manual-only serializer, and atomic publication) in pure Python; the +classify precedence, the converted/manual-only serializers, the publisher, and the handler +peel are exercised over synthetic inputs. The real bind/probe over dotnet is the Tier-B job. + +Run: python tests/test_verify_target.py +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang import fix_target as ft +from ownlang.fix_gate import _bundle_sha256, _canonical_bytes, _sha_bytes, validate_gate_authority + +_EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged" +_REL = "Own/Sample.cs" +_PRE = b"class A\n{\n void M()\n {\n p.PropertyChanged += OnX;\n }\n}\n" + + +def _cand(fid: str, start: int, dc: str = "OWN001", contract: str = "name_only", + actions: list | None = None) -> dict: + return {"finding_id": fid, "diagnostic_code": dc, "containing_type": "N.A", "file": _REL, + "enclosing_member": "N.A..ctor(N.IPub)", "event": "PropertyChanged", + "event_identity": _EV, "event_contract": contract, "source": "p", + "source_identity": "p", "source_identity_kind": "computed", "handler": "OnX", + "handler_identity": "N.A.OnX(object, ...)", "handler_identity_kind": "stable_symbol", + "occurrence_ordinal": 0, + "acquire_span": {"start": start, "length": 10, "start_line": 5, + "start_column": 9, "end_line": 5, "end_column": 19}, + "teardown": {"status": "none", "candidates": []}, + "allowed_actions": actions or ["manual_review"]} + + +def _cands(cs: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "target_api": {"subscribe": "WeakEvents.AddPropertyChanged"}, + "selection": {"allowed_types": [{"full_name": "N.A", "file": _REL}], + "selected_findings": None, + "constraints": {"max_types_changed": 1, "max_files_changed": 1, + "allow_helper_changes": False, + "allow_config_changes": False, + "allow_suppressions": False}}, + "source_files": [{"path": _REL, "sha256": _sha_bytes(_PRE)}], "candidates": cs} + + +def _plan(cands: dict, actions: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "input_bundle_sha256": _bundle_sha256(cands), + "target_api": {"subscribe": cands["target_api"]["subscribe"]}, + "selection": {"allowed_types": [dict(cands["selection"]["allowed_types"][0])], + "selected_findings": cands["selection"]["selected_findings"], + "constraints": dict(cands["selection"]["constraints"])}, + "source_files": [dict(cands["source_files"][0])], + "decisions": [{"finding_id": c["finding_id"], "action": actions[i], + "file": c["file"], "acquire_span": c["acquire_span"]} + for i, c in enumerate(cands["candidates"])]} + + +def _make_delta(cands_bytes: bytes, plan_bytes: bytes, auth, manifest_sha: str, patch_sha: str, + pre_sha: str, post_sha: str, ref_closure: list) -> bytes: + d = { + "schema": 1, "operation": "verify-subscription-analyzer-delta", "status": "pass", + "analysis_scope": {"source_file": _REL, "target_file_identity": _REL}, + "input_hashes": { + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "candidates_sha256": _sha_bytes(cands_bytes), + "apply_manifest_sha256": manifest_sha, "patch_sha256": patch_sha, + "pre_sha256": pre_sha, "post_sha256": post_sha, + }, + "toolchain_fingerprint": {"resolved_runtime_identity": { + "framework_name": "Microsoft.NETCore.App", "tfm": "net8.0", + "requested_framework_version": "8.0.0", "selected_framework_version": "8.0.28", + "runtime_manifest_sha256": "sha256:" + "0" * 64}}, + "target_api": {"subscribe": auth.target_subscribe}, + "expected": {"convert_acquire_ids": sorted(auth.applied), + "manual_review_ids": sorted(auth.manual)}, + "reference_closure": ref_closure, + "checks": dict.fromkeys(ft._STEP10_CHECKS, "pass"), + } + return _canonical_bytes(d) + + +def _manual_fixture(tmp: str): + """A manual-only chain (no dotnet): candidates all manual_review, an empty-patch bundle, + and a delta that binds. Returns paths + bytes.""" + cands = _cands([_cand("OWN001:sha256:" + "1" * 64, 40)]) + plan = _plan(cands, ["manual_review"]) + cands_bytes = json.dumps(cands).encode() + plan_bytes = json.dumps(plan).encode() + auth = validate_gate_authority(plan, cands) + root = os.path.join(tmp, "root") + os.makedirs(os.path.join(root, os.path.dirname(_REL))) + with open(os.path.join(root, *_REL.split("/")), "wb") as fh: + fh.write(_PRE) + bundle = os.path.join(tmp, "bundle") + os.makedirs(os.path.join(bundle, "postimage", os.path.dirname(_REL))) + with open(os.path.join(bundle, "postimage", *_REL.split("/")), "wb") as fh: + fh.write(_PRE) # manual-only: postimage == preimage + with open(os.path.join(bundle, "change.patch"), "wb") as fh: + fh.write(b"") + manifest = b'{"manual":true}\n' + with open(os.path.join(bundle, "apply-manifest.json"), "wb") as fh: + fh.write(manifest) + delta_bytes = _make_delta(cands_bytes, plan_bytes, auth, _sha_bytes(manifest), + _sha_bytes(b""), _sha_bytes(_PRE), _sha_bytes(_PRE), []) + paths = {} + for name, data in (("candidates.json", cands_bytes), ("plan.json", plan_bytes), + ("delta.json", delta_bytes)): + paths[name] = os.path.join(tmp, name) + with open(paths[name], "wb") as fh: + fh.write(data) + return root, bundle, paths, delta_bytes + + +def _attempt(**over) -> dict: + a = {"attempt": 0, "strong_delivered_once": True, "strong_retained": True, + "weak_control_collected": True, "delivered_count": 1, "threw_on_subscribe": False, + "threw_on_first_raise": False, "subscriber_collected": True, + "threw_on_post_collection_raise": False, + "resolved_wrapper": {"ordinal": 0, "slot_sha256": "sha256:" + "a" * 64, + "assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}} + a.update(over) + return a + + +_BINDING = {"resolved_wrapper": {"assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}, + "converted_callsites": 1, "derived_wrapper_ordinal": 0, + "callsite_binding": {"all_callsites_same_symbol": True, + "target_is_source_defined": False}} +_SLOTS = [{"ordinal": 0, "source_dir_ordinal": 0, "relative_path": "W.dll", + "sha256": "sha256:" + "a" * 64}] + + +def _raises(cat, fn, *a) -> bool: + try: + fn(*a) + except ft.TargetError as exc: + return exc.category == cat + return False + + +def run() -> int: + ok = 0 + bad = 0 + + def check(cond: bool, label: str) -> None: + nonlocal ok, bad + if cond: + ok += 1 + else: + bad += 1 + print(f" FAIL: {label}") + + # --- manual-only end-to-end (dotnet-free) --------------------------------- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + outdir = os.path.join(tmp, "pub", "target") + os.makedirs(os.path.join(tmp, "pub")) + published = ft.run_verify_target(bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + outdir, [], None) + with open(os.path.join(published, "target-result.json"), "rb") as fh: + res = json.loads(fh.read()) + check(res["status"] == "pass", "manual-only: status pass") + na = {k for k, v in res["checks"].items() if v == "not_applicable"} + check(na == set(ft._MANUAL_ONLY_NA), "manual-only: exactly the six not_applicable") + check("selected_wrapper" not in res and "attempts" not in res, + "manual-only: omits probe fields") + check(os.listdir(published) == ["target-result.json"], "manual-only: only the artifact") + + # a manual-only plan must forbid --probe-dll / --wrapper-ordinal + check(_raises(ft.INPUT_LAYOUT, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], "x.dll", + os.path.join(tmp, "o2"), [], None), + "manual-only + --probe-dll -> INPUT_LAYOUT") + + # tamper the delta -> DELTA_BINDING + bad_delta = os.path.join(tmp, "baddelta.json") + with open(bad_delta, "wb") as fh: + fh.write(delta_bytes.replace(b'"status":"pass"', b'"status":"fail"')) + check(_raises(ft.DELTA_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], bad_delta, None, os.path.join(tmp, "o3"), [], None), + "delta status fail -> DELTA_BINDING") + + # tamper the bundle postimage -> DELTA_BINDING (hash mismatch) + with open(os.path.join(bundle, "postimage", *_REL.split("/")), "ab") as fh: + fh.write(b"// tamper\n") + check(_raises(ft.DELTA_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + os.path.join(tmp, "o4"), [], None), + "postimage hash mismatch -> DELTA_BINDING") + + # --- classify precedence matrix ------------------------------------------- + good = [_attempt(attempt=i) for i in range(3)] + check(ft.classify(good, _BINDING, 0, _SLOTS) == "pass", "classify: all pass") + retains = [_attempt(attempt=i, subscriber_collected=False) for i in range(3)] + check(_raises(ft.TARGET_RETAINS, ft.classify, retains, _BINDING, 0, _SLOTS), + "classify: retains -> TARGET_RETAINS") + behav = [_attempt(attempt=i, delivered_count=0) for i in range(3)] + check(_raises(ft.TARGET_BEHAVIOR, ft.classify, behav, _BINDING, 0, _SLOTS), + "classify: delivered!=1 -> TARGET_BEHAVIOR") + threw = [_attempt(attempt=i, threw_on_subscribe=True) for i in range(3)] + check(_raises(ft.TARGET_BEHAVIOR, ft.classify, threw, _BINDING, 0, _SLOTS), + "classify: threw on subscribe -> TARGET_BEHAVIOR") + ctrl = [_attempt(attempt=0, strong_retained=False), _attempt(attempt=1), _attempt(attempt=2)] + check(_raises(ft.HARNESS_INVALID, ft.classify, ctrl, _BINDING, 0, _SLOTS), + "classify: broken strong control -> HARNESS_INVALID") + wctrl = [_attempt(attempt=0, weak_control_collected=False), _attempt(attempt=1), + _attempt(attempt=2)] + check(_raises(ft.HARNESS_INVALID, ft.classify, wctrl, _BINDING, 0, _SLOTS), + "classify: broken collectability control -> HARNESS_INVALID") + disagree = [_attempt(attempt=0), _attempt(attempt=1, subscriber_collected=False), + _attempt(attempt=2)] + check(_raises(ft.HARNESS_NONDETERMINISM, ft.classify, disagree, _BINDING, 0, _SLOTS), + "classify: disagreement -> HARNESS_NONDETERMINISM") + ident = copy.deepcopy(good) + ident[1]["resolved_wrapper"]["module_mvid"] = "00000000-0000-0000-0000-000000000000" + check(_raises(ft.WRAPPER_BINDING, ft.classify, ident, _BINDING, 0, _SLOTS), + "classify: attempt identity mismatch -> WRAPPER_BINDING") + + # --- serializers ---------------------------------------------------------- + ih = {"input_bundle_sha256": "sha256:" + "1" * 64, + "validated_plan_sha256": "sha256:" + "1" * 64, + "candidates_sha256": "sha256:" + "1" * 64, "apply_manifest_sha256": "sha256:" + "1" * 64, + "patch_sha256": "sha256:" + "1" * 64, "pre_sha256": "sha256:" + "1" * 64, + "post_sha256": "sha256:" + "1" * 64} + delta_min = {"reference_closure": _SLOTS} + conv = ft.build_converted_result(ih, b"x\n", delta_min, "WeakEvents.AddPropertyChanged", + _SLOTS, 0, _BINDING, {"probe_deployment_manifest_sha256": "s", + "probe_runner_sha256": "s", + "probe_files": []}, "sha256:" + "0" * 64, + "8.0.100", {"framework_name": "x"}, good, set(ft._CHECK_NAMES)) + check(set(conv["checks"]) == set(ft._CHECK_NAMES) and len(ft._CHECK_NAMES) == 11, + "serializer: eleven checks (converted)") + check(set(conv["checks"].values()) == {"pass"}, "serializer: converted all pass") + check(conv["callsite_binding"]["asserted_wrapper_ordinal"] == 0 + and conv["callsite_binding"]["derived_wrapper_ordinal"] == 0, + "serializer: derived == asserted ordinal recorded") + check(len(conv["attempts"]) == 3 + and conv["probe_protocol"]["allocation_pressure_bytes_per_round"] == 4194304, + "serializer: three attempts + fixed constants") + check(_raises(ft.INFRASTRUCTURE, ft.build_converted_result, ih, b"x\n", delta_min, "T", + _SLOTS, 0, _BINDING, {"probe_deployment_manifest_sha256": "s", + "probe_runner_sha256": "s", "probe_files": []}, + "sha256:0", "8.0.100", {}, good, set(ft._CHECK_NAMES) - {"publication"}), + "serializer: unexecuted check -> INFRASTRUCTURE") + man = ft.build_manual_only_result(ih, b"x\n", delta_min, "T", + {"input_layout", "authority_binding", "delta_binding", + "reference_binding", "publication"}) + check({k for k, v in man["checks"].items() if v == "not_applicable"} == set(ft._MANUAL_ONLY_NA), + "serializer: manual-only six not_applicable") + + # --- _publish_target ------------------------------------------------------ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "root") + pub = os.path.join(tmp, "pub") + os.makedirs(root) + os.makedirs(pub) + out = os.path.join(pub, "ev") + ft._publish_target(out, [root], b'{"ok":true}\n') + check(os.listdir(out) == ["target-result.json"], "publish: only target-result.json") + check([n for n in os.listdir(pub) if n.startswith(".owen-gate-")] == [], + "publish: no workdir residue") + check(_raises(ft.PUBLICATION, ft._publish_target, os.path.join(root, "x"), [root], + b'{}\n'), "publish: inside protected root -> PUBLICATION") + # cleanup-failure normalization + import shutil as _sh + orig_rename, orig_rmtree = ft.os.rename, ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("boom") + + try: + ft.os.rename = _boom + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._publish_target, os.path.join(pub, "ev2"), [root], + b'{}\n'), "publish: cleanup failure -> PUBLICATION") + finally: + ft.os.rename, ft.shutil.rmtree = orig_rename, orig_rmtree + _ = _sh + + # --- handler peel --------------------------------------------------------- + check(ft._peel_handler("OnA") == "OnA", "peel: method group") + check(ft._peel_handler("new PropertyChangedEventHandler(OnA)") == "OnA", "peel: new H(M)") + check(ft._peel_handler("new(OnA)") == "OnA", "peel: new(M)") + + total = ok + bad + print(f"verify-target (Tier A): {ok}/{total} checks pass") + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py new file mode 100644 index 00000000..9ceaeb7e --- /dev/null +++ b/tests/test_verify_target_tierb.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +"""S2 step 11 — Tier B: the FULL public CLI acceptance of the fake-target gate. + +Every case runs the real pipeline end to end through the PUBLIC command line only — +never a private helper as its proof: + + real extractor -> candidates -> validate-plan -> apply (Owen rewriter) -> gate + -> `own-fix subscriptions verify-delta` (--ref-dir wrapper closure) + -> `own-fix subscriptions verify-target` (fixed Roslyn bind + fixed runtime probe) + -> published target-result.json + +The wrapper under test is a SEPARATELY COMPILED assembly shipped as a reference slot (a +name-only-recognized decoy cannot pass): a genuine weak wrapper is accepted; a strong decoy is +refused TARGET_RETAINS; no-op / twice-delivering / throwing wrappers are refused TARGET_BEHAVIOR; +a wrong-signature wrapper is refused WRAPPER_BINDING; a source-defined target is refused +CALLSITE_BINDING; two converted callsites onto the same wrapper pass; a net9 / missing-dependency +wrapper is refused WRAPPER_RUNTIME_UNSUPPORTED (never TARGET_RETAINS); a manual-only plan passes +with the six probe checks not_applicable; and two independent runs are byte-identical. + +Gating: REQUIRED (a missing dotnet / build / execution failure is a FAILURE, not a skip) exactly +when OWN_TIERB_REQUIRED=1 — which the wpf-extractor CI job sets. In any other context it is the +explicit non-required mode and skips cleanly, so the offline suite stays green. + +Run: OWN_TIERB_REQUIRED=1 python tests/test_verify_target_tierb.py +""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.fix_target import _CHECK_NAMES, _MANUAL_ONLY_NA + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_EXT = os.path.join(_REPO, "frontend", "roslyn", "OwnSharp.Extractor") +_RW = os.path.join(_REPO, "frontend", "roslyn", "Owen.CSharp.Rewriter") +_PROBE = os.path.join(_REPO, "frontend", "roslyn", "OwnSharp.WeakTargetProbe") +_REL = "S.cs" +_FQN = "S" + +# --- the pristine preimage variants ------------------------------------------------ +_PRE = """using System.ComponentModel; +public class S +{ + public S(INotifyPropertyChanged a) { a.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" +# two DISTINCT converted callsites (a, b) both onto the same wrapper. +_PRE_TWO = """using System.ComponentModel; +public class S +{ + public S(INotifyPropertyChanged a, INotifyPropertyChanged b) + { a.PropertyChanged += OnA; b.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" +# the wrapper is defined IN the source (not a reference slot) -> CALLSITE_BINDING. +_PRE_SRCDEF = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[]{s, e}); + }; + source.PropertyChanged += relay; + } +} +public class S +{ + public S(INotifyPropertyChanged a) { a.PropertyChanged += OnA; } + void OnA(object s, PropertyChangedEventArgs e) { } +} +""" + +# --- the wrapper variants (all named WeakEvents.AddPropertyChanged) ----------------- +_WEAK = """using System.ComponentModel; +public static class WeakEvents +{ + // A genuine non-retaining subscription: the subscriber is held only weakly. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[] { s, e }); + }; + source.PropertyChanged += relay; + } +} +""" +_STRONG = """using System.ComponentModel; +public static class WeakEvents +{ + // A decoy: named like the accepted wrapper but strongly retains the subscriber. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { source.PropertyChanged += handler; } +} +""" +_NOOP = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } +} +""" +_TWICE = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { source.PropertyChanged += handler; source.PropertyChanged += handler; } +} +""" +_THROWING = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { throw new System.InvalidOperationException("no target"); } +} +""" +_WRONGSIG = """using System.ComponentModel; +public static class WeakEvents +{ + // wrong shape: an extra parameter, so the accepted (INPC, PCEH) target does not exist. + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler, int extra) { } +} +""" +# net9 wrapper: correct shape and byte-valid metadata, but cannot execute under the net8 probe. +_INCOMPAT = _STRONG +# missing runtime dependency: the body calls a Helper assembly not shipped in the slot. +_HELPER = "namespace Helper { public static class Aux { public static void Touch() { } } }\n" +_MISSINGDEP = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { Helper.Aux.Touch(); source.PropertyChanged += handler; } +} +""" + +_CASES = [ + {"name": "weak", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "converted")}, + {"name": "strong", "pre": _PRE, "wrapper": _STRONG, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_RETAINS")}, + {"name": "noop", "pre": _PRE, "wrapper": _NOOP, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "twice", "pre": _PRE, "wrapper": _TWICE, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "throwing", "pre": _PRE, "wrapper": _THROWING, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, + {"name": "wrongsig", "pre": _PRE, "wrapper": _WRONGSIG, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "WRAPPER_BINDING")}, + {"name": "srcdef", "pre": _PRE_SRCDEF, "wrapper": None, "convert": True, "ref": False, + "probe": True, "expect": ("refuse", "CALLSITE_BINDING")}, + {"name": "twoconv", "pre": _PRE_TWO, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "two")}, + {"name": "incompat", "pre": _PRE, "wrapper": _INCOMPAT, "tfm": "net9.0", "convert": True, + "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + {"name": "missingdep", "pre": _PRE, "wrapper": _MISSINGDEP, "helper": _HELPER, + "convert": True, "ref": True, "probe": True, + "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + {"name": "manual", "pre": _PRE, "wrapper": None, "convert": False, "ref": False, + "probe": False, "expect": ("pass", "manual")}, +] + + +class Fail(Exception): + pass + + +def _sha(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def _run(argv: list[str], cwd: str | None = None, + env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False, env=env) + + +def _py(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess: + return _run([sys.executable, "-m", "ownlang", *args], cwd=cwd or _REPO) + + +def _find_dotnet() -> str | None: + return shutil.which("dotnet") or (r"C:\Program Files\dotnet\dotnet.exe" + if os.path.isfile(r"C:\Program Files\dotnet\dotnet.exe") + else None) + + +def _dotnet_env(dotnet: str) -> dict: + env = dict(os.environ) + env["PATH"] = os.path.dirname(dotnet) + os.pathsep + env.get("PATH", "") + return env + + +def _build(dotnet: str) -> tuple[str, str]: + for proj in (_EXT, _RW, _PROBE): + p = _run([dotnet, "build", proj, "-c", "Release", "-v", "q", "--nologo"]) + if p.returncode != 0: + raise Fail(f"build {proj}: {p.stdout[-400:]}{p.stderr[-400:]}") + ext = glob.glob(os.path.join(_EXT, "bin", "*", "*", "ownsharp-extract.dll")) + probe = glob.glob(os.path.join(_PROBE, "bin", "*", "net8.0", "OwnSharp.WeakTargetProbe.dll")) + if not ext or not probe: + raise Fail("extractor/probe DLL not found after build") + return ext[0], probe[0] + + +def _csproj(tfm: str, helper_hint: str | None) -> str: + ref = (f'{helper_hint}' + f'') if helper_hint else "" + return (f'' + f'{tfm}enable' + f'WeakEventstrue' + f'{ref}') + + +def _build_wrapper(dotnet: str, work: str, name: str, src: str, tfm: str, + helper: str | None) -> str: + """Compile a standalone WeakEvents.dll (optionally referencing a NON-shipped Helper.dll) + and return a fresh ref-dir holding ONLY WeakEvents.dll (exactly one ordered slot).""" + helper_hint = None + if helper is not None: + hd = os.path.join(work, f"h-{name}") + os.makedirs(hd) + with open(os.path.join(hd, "H.csproj"), "w", encoding="utf-8") as fh: + fh.write('' + f'{tfm}Helper' + 'true') + with open(os.path.join(hd, "H.cs"), "w", encoding="utf-8") as fh: + fh.write(helper) + p = _run([dotnet, "build", os.path.join(hd, "H.csproj"), "-c", "Release", "-v", "q", + "--nologo"]) + if p.returncode != 0: + raise Fail(f"helper build: {p.stdout[-300:]}") + helper_hint = os.path.join(hd, "bin", "Release", tfm, "Helper.dll") + wd = os.path.join(work, f"w-{name}") + os.makedirs(wd) + with open(os.path.join(wd, "W.csproj"), "w", encoding="utf-8") as fh: + fh.write(_csproj(tfm, helper_hint)) + with open(os.path.join(wd, "W.cs"), "w", encoding="utf-8") as fh: + fh.write(src) + p = _run([dotnet, "build", os.path.join(wd, "W.csproj"), "-c", "Release", "-v", "q", + "--nologo"]) + if p.returncode != 0: + raise Fail(f"wrapper {name} build: {p.stdout[-400:]}") + refdir = os.path.join(work, f"ref-{name}") + os.makedirs(refdir) + shutil.copy(os.path.join(wd, "bin", "Release", tfm, "WeakEvents.dll"), + os.path.join(refdir, "WeakEvents.dll")) + return refdir + + +def _mkroot(work: str, name: str, cs: str) -> str: + root = os.path.join(work, f"root-{name}") + os.makedirs(os.path.join(root, os.path.dirname(_REL)) if os.path.dirname(_REL) else root, + exist_ok=True) + with open(os.path.join(root, *_REL.split("/")), "wb") as fh: + fh.write(cs.replace("\r\n", "\n").encode("utf-8")) + return root + + +def _candidates(dotnet: str, dll: str, root: str, work: str) -> str: + facts = os.path.join(work, "fc.json") + p = _run([dotnet, "exec", dll, "extract", _REL, "--out", facts, "--fix-candidates", + "--weak-subscribe", "WeakEvents.AddPropertyChanged"], cwd=root) + if p.returncode != 0: + raise Fail(f"extract: {p.stderr[-300:]}") + own = os.path.join(work, "own.toml") + with open(own, "w", encoding="utf-8") as fh: + fh.write('[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n') + cands = os.path.join(work, "candidates.json") + p = _py(["own-fix", "subscriptions", "candidates", facts, "--config", own, + "--class", _FQN, "--output", cands, "--root", root]) + if p.returncode != 0: + raise Fail(f"candidates: {p.stderr[-300:]}") + return cands + + +def _plan(cands: str, work: str, convert: bool) -> str: + from ownlang.fix_plan import validate_plan + with open(cands, encoding="utf-8") as fh: + c = json.load(fh) + action = "convert_acquire" if convert else "manual_review" + decisions = [{"finding_id": x["finding_id"], "action": action} for x in c["candidates"]] + plan = os.path.join(work, "plan.json") + with open(plan, "w", encoding="utf-8") as fh: + json.dump(validate_plan(c, {"version": 1, "decisions": decisions}), fh) + return plan + + +def _apply_and_gate(cands: str, plan: str, root: str, work: str) -> tuple[str, str]: + bundle = os.path.join(work, "bundle") + rewriter = f'dotnet run --project "{_RW.replace(os.sep, "/")}" -c Release --no-build --' + p = _py(["own-fix", "subscriptions", "apply", "--plan", plan, "--candidates", cands, + "--root", root, "--out", bundle, "--rewriter", rewriter]) + if p.returncode != 0: + raise Fail(f"apply: {p.stderr[-400:]}") + gate_out = os.path.join(work, "gate") + p = _py(["own-fix", "subscriptions", "gate", "--bundle", bundle, "--plan", plan, + "--candidates", cands, "--root", root, "--out", gate_out]) + if p.returncode != 0: + raise Fail(f"gate: {p.stderr[-400:]}") + return bundle, os.path.join(gate_out, "gate-result.json") + + +def _verify_delta(dotnet: str, dll: str, bundle: str, plan: str, cands: str, root: str, + gate: str, work: str, refdir: str | None) -> str: + out = os.path.join(work, "delta") + argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-delta", + "--bundle", bundle, "--plan", plan, "--candidates", cands, "--root", root, + "--gate", gate, "--extractor-dll", dll, "--out", out] + if refdir: + argv += ["--ref-dir", refdir] + p = _run(argv, cwd=_REPO, env=_dotnet_env(dotnet)) + if p.returncode != 0: + raise Fail(f"verify-delta rc={p.returncode}: {p.stderr[-500:]}") + return os.path.join(out, "delta-result.json") + + +def _verify_target(dotnet: str, probe: str, bundle: str, plan: str, cands: str, root: str, + delta: str, out: str, refdir: str | None, ordinal: int, + use_probe: bool) -> subprocess.CompletedProcess: + argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-target", + "--bundle", bundle, "--root", root, "--plan", plan, "--candidates", cands, + "--delta", delta, "--out", out] + if use_probe: + argv += ["--probe-dll", probe, "--wrapper-ordinal", str(ordinal)] + if refdir: + argv += ["--ref-dir", refdir] + return _run(argv, cwd=_REPO, env=_dotnet_env(dotnet)) + + +def _schema_ok(path: str, manual: bool, check, name: str) -> dict: + with open(path, "rb") as fh: + raw = fh.read() + obj = json.loads(raw) + canon = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + check(raw == canon.encode() + b"\n", f"{name}: target-result.json is canonical bytes + LF") + check(obj.get("schema") == 1 and obj.get("operation") == "verify-target-wrapper" + and obj.get("status") == "pass", f"{name}: schema/operation/status") + check(set(obj["checks"]) == set(_CHECK_NAMES) and len(_CHECK_NAMES) == 11, + f"{name}: exactly the eleven checks") + check(obj["delta_binding"]["bound"] is True and obj["delta_binding"]["step10_status"] == "pass", + f"{name}: delta bound") + return obj + + +def run() -> int: + required = os.environ.get("OWN_TIERB_REQUIRED") == "1" + if not required: + print("verify-target (Tier B): SKIP (non-required mode; set OWN_TIERB_REQUIRED=1)") + return 0 + dotnet = _find_dotnet() + if dotnet is None: + print("verify-target (Tier B): FAIL — required but no dotnet host") + return 1 + + ok = 0 + fails: list[str] = [] + + def check(cond: bool, label: str) -> None: + nonlocal ok + if cond: + ok += 1 + else: + fails.append(label) + + try: + ext, probe = _build(dotnet) + with tempfile.TemporaryDirectory() as work: + for c in _CASES: + _run_case(dotnet, ext, probe, work, c, check) + _determinism(dotnet, ext, probe, work, check) + except Fail as exc: + fails.append(f"Tier B setup: {exc}") + + for f in fails: + print(f" FAIL: {f}") + total = ok + len(fails) + print(f"verify-target (Tier B, full CLI): {ok}/{total} checks pass") + return 1 if fails else 0 + + +def _chain(dotnet: str, ext: str, work: str, + c: dict) -> tuple[str, str, str, str, str | None, str]: + """extract -> candidates -> plan -> apply -> gate -> verify-delta; returns + (bundle, plan, cands, delta, refdir, root).""" + name = c["name"] + tfm = c.get("tfm", "net8.0") + refdir = None + if c["wrapper"] is not None: + refdir = _build_wrapper(dotnet, work, name, c["wrapper"], tfm, c.get("helper")) + root = _mkroot(work, name, c["pre"]) + w = os.path.join(work, f"run-{name}") + os.makedirs(w) + cands = _candidates(dotnet, ext, root, w) + plan = _plan(cands, w, c["convert"]) + bundle, gate = _apply_and_gate(cands, plan, root, w) + delta = _verify_delta(dotnet, ext, bundle, plan, cands, root, gate, w, + refdir if c["ref"] else None) + return bundle, plan, cands, delta, (refdir if c["ref"] else None), root + + +def _run_case(dotnet: str, ext: str, probe: str, work: str, c: dict, check) -> None: + name = c["name"] + try: + bundle, plan, cands, delta, refdir, root = _chain(dotnet, ext, work, c) + except Fail as exc: + check(False, f"{name}: chain setup failed ({exc})") + return + out = os.path.join(work, f"run-{name}", "target") + p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, refdir, 0, c["probe"]) + kind, detail = c["expect"] + if kind == "refuse": + check(p.returncode == 2 and detail in p.stderr, + f"{name}: expect refuse {detail} (rc={p.returncode}: {p.stderr.strip()[-160:]})") + return + if p.returncode != 0: + check(False, f"{name}: expect pass ({p.stderr.strip()[-200:]})") + return + obj = _schema_ok(os.path.join(out, "target-result.json"), detail == "manual", check, name) + if detail == "manual": + na = {k for k, v in obj["checks"].items() if v == "not_applicable"} + check(na == set(_MANUAL_ONLY_NA), f"{name}: exactly the six not_applicable") + check(all(k not in obj for k in ("selected_wrapper", "attempts", "callsite_binding")), + f"{name}: manual-only omits the probe fields") + else: + check(set(obj["checks"].values()) == {"pass"}, f"{name}: all eleven checks pass") + check(len(obj["attempts"]) == 3, f"{name}: three probe attempts recorded") + check(obj["selected_wrapper"]["assembly_simple_name"] == "WeakEvents", + f"{name}: selected wrapper is WeakEvents") + want = 2 if detail == "two" else 1 + check(obj["callsite_binding"]["converted_callsites"] == want, + f"{name}: converted_callsites == {want}") + check(obj["callsite_binding"]["derived_wrapper_ordinal"] + == obj["callsite_binding"]["asserted_wrapper_ordinal"] == 0, + f"{name}: derived == asserted ordinal 0") + + +def _determinism(dotnet: str, ext: str, probe: str, work: str, check) -> None: + c = {"name": "det", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, + "probe": True, "expect": ("pass", "converted")} + try: + bundle, plan, cands, delta, refdir, root = _chain(dotnet, ext, work, c) + except Fail as exc: + check(False, f"determinism: chain setup failed ({exc})") + return + o1 = os.path.join(work, "det-1") + o2 = os.path.join(work, "det-2") + p1 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o1, refdir, 0, True) + p2 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o2, refdir, 0, True) + if p1.returncode != 0 or p2.returncode != 0: + check(False, f"determinism: a run failed ({p1.returncode}/{p2.returncode})") + return + with open(os.path.join(o1, "target-result.json"), "rb") as fh: + r1 = fh.read() + with open(os.path.join(o2, "target-result.json"), "rb") as fh: + r2 = fh.read() + check(r1 == r2, "determinism: two independent runs are byte-identical") + check(_sha(r1) == _sha(r2), "determinism: identical evidence sha") + + +if __name__ == "__main__": + raise SystemExit(run()) From 36cea9aeaac5e09a485d37e8476906955d9f1ecd Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 20:55:21 +0500 Subject: [PATCH 04/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20TA?= =?UTF-8?q?RGET=5FBINDING=20shape=20check=20in=20bind=20(WRAPPER=5FBINDING?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accepted contract's TARGET_BINDING literal requires plan.target_api.subscribe, resolved by name inside the selected reference-slot wrapper, to have the exact accepted shape: exactly one public type; exactly one public static, non-generic void(INotifyPropertyChanged, PropertyChangedEventHandler) method; no other overload; no ref/optional/params/custom-modifier parameter. Any mismatch is WRAPPER_BINDING. bind now resolves the converted callsite symbol with a CandidateSymbols fallback (so a failed overload still yields the candidate wrapper method) and validates its containing type via TargetBinding.Validate before deriving the ordinal. A wrong-signature or overloaded wrapper is therefore refused WRAPPER_BINDING at bind time rather than surfacing as an unresolved callsite (CALLSITE_BINDING) or a runtime load failure (WRAPPER_RUNTIME_UNSUPPORTED). The genuine weak / strong / no-op / throwing paths are unchanged. Turns the Tier B wrong-signature case green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe/Program.cs | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs index c27c77d4..83cdf21b 100644 --- a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -168,8 +168,13 @@ public static int Run(string[] args) || Rewrite.NormalizeHandler(argList[1].Expression).ToString() != e.NormalizedHandler) return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation arguments do not match the candidate"); - // 7: resolve the IMethodSymbol; every converted callsite must resolve to one symbol. - if (postModel.GetSymbolInfo(inv).Symbol is not IMethodSymbol sym) + // 7: resolve the IMethodSymbol. A failed overload still yields the CANDIDATE wrapper + // method, so a wrong-signature / overloaded wrapper is a TARGET_BINDING (WRAPPER_BINDING) + // refusal — not an unresolved callsite. Every converted callsite must resolve to one symbol. + var si = postModel.GetSymbolInfo(inv); + var sym = si.Symbol as IMethodSymbol + ?? si.CandidateSymbols.OfType().FirstOrDefault(); + if (sym is null) return Refuse("CALLSITE_BINDING", $"{e.Fid}: cannot resolve the invocation symbol"); if (SymbolEqualityComparer.Default.Equals(sym.ContainingAssembly, comp.Assembly)) return Refuse("CALLSITE_BINDING", $"{e.Fid}: target is source-defined, not a reference wrapper"); @@ -180,6 +185,12 @@ public static int Run(string[] args) var path = mref?.FilePath ?? ""; if (!slotByPath.TryGetValue(path, out var slot)) return Refuse("WRAPPER_BINDING", $"{e.Fid}: resolved assembly is not a materialized slot"); + // TARGET_BINDING: resolve plan.target_api.subscribe (SimpleType.Method) inside the + // selected wrapper assembly and enforce the exact required shape (exactly one public + // type, exactly one public static non-generic void(INPC, PCEH) method, no other + // overload, no ref/optional/params/modopt) — any mismatch is WRAPPER_BINDING. + var terr = TargetBinding.Validate(sym.ContainingAssembly, target); + if (terr is not null) return Refuse("WRAPPER_BINDING", $"{e.Fid}: {terr}"); derivedOrdinal = slot.Ordinal; asmName = sym.ContainingAssembly.Name; mvid = ReadMvid(path); @@ -339,6 +350,57 @@ public static (List, Dictionary) Build(string s public readonly record struct Slot(int Ordinal, string Path, string SimpleName); } +// TARGET_BINDING (WRAPPER_BINDING on any mismatch): the plan.target_api.subscribe method, resolved +// by name ONLY inside the selected wrapper assembly, must have the exact accepted shape. This is +// independent of whether the postimage callsite's arguments bind, so a wrong-signature or overloaded +// wrapper is refused here rather than surfacing as an unresolved callsite or a runtime load failure. +internal static class TargetBinding +{ + private static readonly SymbolDisplayFormat FQFmt = SymbolDisplayFormat.FullyQualifiedFormat + .WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions + & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static string FQ(ITypeSymbol t) => t.ToDisplayString(FQFmt).Replace("global::", ""); + + public static string? Validate(IAssemblySymbol asm, string target) + { + var dot = target.LastIndexOf('.'); + if (dot <= 0) return "target_api.subscribe is not SimpleType.Method"; + var typeName = target[..dot]; + var methodName = target[(dot + 1)..]; + + var types = new List(); + Collect(asm.GlobalNamespace, types); + var matches = types.Where(t => t.DeclaredAccessibility == Accessibility.Public + && (t.Name == typeName || FQ(t) == typeName)).ToList(); + if (matches.Count != 1) return $"expected exactly one public type '{typeName}'"; + var declType = matches[0]; + if (declType.IsGenericType) return "the wrapper type is generic"; + + var overloads = declType.GetMembers(methodName).OfType() + .Where(m => m.IsStatic && m.DeclaredAccessibility == Accessibility.Public).ToList(); + if (overloads.Count != 1) return $"expected exactly one public static '{methodName}'"; + var m = overloads[0]; + if (m.IsGenericMethod) return "the wrapper method is generic"; + if (m.ReturnType.SpecialType != SpecialType.System_Void) return "return type is not System.Void"; + if (m.Parameters.Length != 2) return "parameter count is not 2"; + if (FQ(m.Parameters[0].Type) != "System.ComponentModel.INotifyPropertyChanged" + || FQ(m.Parameters[1].Type) != "System.ComponentModel.PropertyChangedEventHandler") + return "parameters are not (INotifyPropertyChanged, PropertyChangedEventHandler)"; + foreach (var p in m.Parameters) + if (p.RefKind != RefKind.None || p.IsOptional || p.IsParams + || p.CustomModifiers.Any() || p.RefCustomModifiers.Any()) + return "a parameter uses ref/optional/params/custom-modifier shape"; + return null; + } + + private static void Collect(INamespaceSymbol ns, List acc) + { + acc.AddRange(ns.GetTypeMembers()); + foreach (var child in ns.GetNamespaceMembers()) Collect(child, acc); + } +} + internal static class Args { public static Dictionary Parse(string[] args, int start) From a44564f51cdc438cb10484b616902d045da96c77 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 21:39:53 +0500 Subject: [PATCH 05/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20H1?= =?UTF-8?q?=20a=20candidate=20wrapper=20must=20never=20bind;=20ambiguous?= =?UTF-8?q?=20decoy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the H1 Tier B regressions to the fake-target gate and generalizes the harness to multi-ref-dir cases: a wrapper exporting a same-name overload is refused WRAPPER_BINDING, and two DIFFERENT reference assemblies both exporting global WeakEvents make the source call ambiguous. The ambiguous case is RED against the current bind: it falls back to CandidateSymbols.FirstOrDefault(), promotes a candidate to the bound symbol, and wrongly PASSES. The next commit forbids promoting any candidate to the bound symbol. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target_tierb.py | 236 ++++++++++++++++++++++-------- 1 file changed, 172 insertions(+), 64 deletions(-) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py index 9ceaeb7e..113e65f1 100644 --- a/tests/test_verify_target_tierb.py +++ b/tests/test_verify_target_tierb.py @@ -162,6 +162,60 @@ { Helper.Aux.Touch(); source.PropertyChanged += handler; } } """ +# exact-shape target PLUS a same-name overload -> WRAPPER_BINDING (no other overload allowed). +_OVERLOAD = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } + public static void AddPropertyChanged(string source, PropertyChangedEventHandler handler) { } +} +""" +# two different assemblies both exporting global WeakEvents -> the source call is ambiguous. +_AMB = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) { } +} +""" +# a wrapper that depends on a Helper assembly (weak, genuinely non-retaining) when Helper resolves. +_DEPWEAK = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + Helper.Aux.Touch(); + var wr = new System.WeakReference(handler.Target); + var mi = handler.Method; + PropertyChangedEventHandler? relay = null; + relay = (s, e) => + { + var t = wr.Target; + if (t == null) source.PropertyChanged -= relay; + else mi.Invoke(t, new object?[] { s, e }); + }; + source.PropertyChanged += relay; + } +} +""" +_HELPER_OK = "namespace Helper { public static class Aux { public static void Touch() { } } }\n" +_HELPER_THROW = ("namespace Helper { public static class Aux { public static void Touch() " + "{ throw new System.InvalidOperationException(\"bad helper\"); } } }\n") +# a wrapper whose body pulls in Microsoft.CodeAnalysis — a NON-framework assembly already loaded in +# the probe's default context — proving a closed load context never satisfies it from there. +_DEP_ROSLYN = """using System.ComponentModel; +public static class WeakEvents +{ + public static void AddPropertyChanged( + INotifyPropertyChanged source, PropertyChangedEventHandler handler) + { + _ = Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp12; + source.PropertyChanged += handler; + } +} +""" _CASES = [ {"name": "weak", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, @@ -176,6 +230,8 @@ "probe": True, "expect": ("refuse", "TARGET_BEHAVIOR")}, {"name": "wrongsig", "pre": _PRE, "wrapper": _WRONGSIG, "convert": True, "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_BINDING")}, + {"name": "overload", "pre": _PRE, "wrapper": _OVERLOAD, "convert": True, "ref": True, + "probe": True, "expect": ("refuse", "WRAPPER_BINDING")}, {"name": "srcdef", "pre": _PRE_SRCDEF, "wrapper": None, "convert": True, "ref": False, "probe": True, "expect": ("refuse", "CALLSITE_BINDING")}, {"name": "twoconv", "pre": _PRE_TWO, "wrapper": _WEAK, "convert": True, "ref": True, @@ -231,51 +287,55 @@ def _build(dotnet: str) -> tuple[str, str]: return ext[0], probe[0] -def _csproj(tfm: str, helper_hint: str | None) -> str: - ref = (f'{helper_hint}' - f'') if helper_hint else "" +def _csproj(tfm: str, refs: list[tuple[str, str]], asmname: str, + pkgs: list[tuple[str, str]] | None = None) -> str: + items = "".join(f'{h}' + for n, h in refs) + items += "".join(f'' + for n, v in (pkgs or [])) + grp = f"{items}" if items else "" return (f'' f'{tfm}enable' - f'WeakEventstrue' - f'{ref}') - - -def _build_wrapper(dotnet: str, work: str, name: str, src: str, tfm: str, - helper: str | None) -> str: - """Compile a standalone WeakEvents.dll (optionally referencing a NON-shipped Helper.dll) - and return a fresh ref-dir holding ONLY WeakEvents.dll (exactly one ordered slot).""" - helper_hint = None - if helper is not None: - hd = os.path.join(work, f"h-{name}") - os.makedirs(hd) - with open(os.path.join(hd, "H.csproj"), "w", encoding="utf-8") as fh: - fh.write('' - f'{tfm}Helper' - 'true') - with open(os.path.join(hd, "H.cs"), "w", encoding="utf-8") as fh: - fh.write(helper) - p = _run([dotnet, "build", os.path.join(hd, "H.csproj"), "-c", "Release", "-v", "q", - "--nologo"]) - if p.returncode != 0: - raise Fail(f"helper build: {p.stdout[-300:]}") - helper_hint = os.path.join(hd, "bin", "Release", tfm, "Helper.dll") - wd = os.path.join(work, f"w-{name}") - os.makedirs(wd) - with open(os.path.join(wd, "W.csproj"), "w", encoding="utf-8") as fh: - fh.write(_csproj(tfm, helper_hint)) - with open(os.path.join(wd, "W.cs"), "w", encoding="utf-8") as fh: + f'{asmname}true' + f'{grp}') + + +def _compile(dotnet: str, work: str, name: str, src: str, tfm: str, asmname: str, + refs: list[tuple[str, str]] | None = None, + pkgs: list[tuple[str, str]] | None = None) -> str: + """Compile .dll and return its output path (not a slot).""" + d = os.path.join(work, f"c-{name}") + os.makedirs(d) + with open(os.path.join(d, "P.csproj"), "w", encoding="utf-8") as fh: + fh.write(_csproj(tfm, refs or [], asmname, pkgs)) + with open(os.path.join(d, "P.cs"), "w", encoding="utf-8") as fh: fh.write(src) - p = _run([dotnet, "build", os.path.join(wd, "W.csproj"), "-c", "Release", "-v", "q", - "--nologo"]) + p = _run([dotnet, "build", os.path.join(d, "P.csproj"), "-c", "Release", "-v", "q", "--nologo"]) if p.returncode != 0: - raise Fail(f"wrapper {name} build: {p.stdout[-400:]}") + raise Fail(f"compile {name}: {p.stdout[-500:]}") + return os.path.join(d, "bin", "Release", tfm, f"{asmname}.dll") + + +def _slot(work: str, name: str, dll: str, as_name: str | None = None) -> str: + """A fresh ref-dir holding exactly one DLL (one ordered slot).""" refdir = os.path.join(work, f"ref-{name}") os.makedirs(refdir) - shutil.copy(os.path.join(wd, "bin", "Release", tfm, "WeakEvents.dll"), - os.path.join(refdir, "WeakEvents.dll")) + shutil.copy(dll, os.path.join(refdir, as_name or os.path.basename(dll))) return refdir +def _build_wrapper(dotnet: str, work: str, name: str, src: str, tfm: str, + helper: str | None, asmname: str = "WeakEvents") -> str: + """Compile a standalone .dll (optionally referencing a NON-shipped Helper.dll) + and return a fresh ref-dir holding ONLY that DLL (exactly one ordered slot).""" + refs: list[tuple[str, str]] = [] + if helper is not None: + helper_dll = _compile(dotnet, work, f"h-{name}", helper, tfm, "Helper") + refs.append(("Helper", helper_dll)) + dll = _compile(dotnet, work, f"w-{name}", src, tfm, asmname, refs) + return _slot(work, name, dll) + + def _mkroot(work: str, name: str, cs: str) -> str: root = os.path.join(work, f"root-{name}") os.makedirs(os.path.join(root, os.path.dirname(_REL)) if os.path.dirname(_REL) else root, @@ -330,13 +390,13 @@ def _apply_and_gate(cands: str, plan: str, root: str, work: str) -> tuple[str, s def _verify_delta(dotnet: str, dll: str, bundle: str, plan: str, cands: str, root: str, - gate: str, work: str, refdir: str | None) -> str: + gate: str, work: str, ref_dirs: list[str]) -> str: out = os.path.join(work, "delta") argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-delta", "--bundle", bundle, "--plan", plan, "--candidates", cands, "--root", root, "--gate", gate, "--extractor-dll", dll, "--out", out] - if refdir: - argv += ["--ref-dir", refdir] + for rd in ref_dirs: + argv += ["--ref-dir", rd] p = _run(argv, cwd=_REPO, env=_dotnet_env(dotnet)) if p.returncode != 0: raise Fail(f"verify-delta rc={p.returncode}: {p.stderr[-500:]}") @@ -344,16 +404,16 @@ def _verify_delta(dotnet: str, dll: str, bundle: str, plan: str, cands: str, roo def _verify_target(dotnet: str, probe: str, bundle: str, plan: str, cands: str, root: str, - delta: str, out: str, refdir: str | None, ordinal: int, - use_probe: bool) -> subprocess.CompletedProcess: + delta: str, out: str, ref_dirs: list[str], ordinal: int | None, + use_probe: bool, env: dict | None = None) -> subprocess.CompletedProcess: argv = [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-target", "--bundle", bundle, "--root", root, "--plan", plan, "--candidates", cands, "--delta", delta, "--out", out] if use_probe: argv += ["--probe-dll", probe, "--wrapper-ordinal", str(ordinal)] - if refdir: - argv += ["--ref-dir", refdir] - return _run(argv, cwd=_REPO, env=_dotnet_env(dotnet)) + for rd in ref_dirs: + argv += ["--ref-dir", rd] + return _run(argv, cwd=_REPO, env=env or _dotnet_env(dotnet)) def _schema_ok(path: str, manual: bool, check, name: str) -> dict: @@ -368,9 +428,19 @@ def _schema_ok(path: str, manual: bool, check, name: str) -> dict: f"{name}: exactly the eleven checks") check(obj["delta_binding"]["bound"] is True and obj["delta_binding"]["step10_status"] == "pass", f"{name}: delta bound") + _no_absolute_paths(raw, check, name) return obj +def _no_absolute_paths(raw: bytes, check, name: str) -> None: + """No slot / deployment / execution absolute path may leak into published evidence (H2).""" + import re + text = raw.decode("utf-8") + leaked = bool(re.search(r"[A-Za-z]:\\", text)) or any( + s in text for s in ("/tmp/", "/home/", "/var/", "/root/", "\\\\")) + check(not leaked, f"{name}: evidence publishes no absolute path") + + def run() -> int: required = os.environ.get("OWN_TIERB_REQUIRED") == "1" if not required: @@ -397,6 +467,7 @@ def check(cond: bool, label: str) -> None: for c in _CASES: _run_case(dotnet, ext, probe, work, c, check) _determinism(dotnet, ext, probe, work, check) + _run_ambiguous(dotnet, ext, probe, work, check) except Fail as exc: fails.append(f"Tier B setup: {exc}") @@ -407,35 +478,43 @@ def check(cond: bool, label: str) -> None: return 1 if fails else 0 -def _chain(dotnet: str, ext: str, work: str, - c: dict) -> tuple[str, str, str, str, str | None, str]: - """extract -> candidates -> plan -> apply -> gate -> verify-delta; returns - (bundle, plan, cands, delta, refdir, root).""" - name = c["name"] - tfm = c.get("tfm", "net8.0") - refdir = None - if c["wrapper"] is not None: - refdir = _build_wrapper(dotnet, work, name, c["wrapper"], tfm, c.get("helper")) - root = _mkroot(work, name, c["pre"]) +def _build_chain(dotnet: str, ext: str, work: str, name: str, pre: str, ref_dirs: list[str], + convert: bool) -> tuple[str, str, str, str, str, str]: + """extract -> candidates -> plan -> apply -> gate -> verify-delta over EXPLICIT ref-dirs; + returns (bundle, plan, cands, delta, root, workdir).""" + root = _mkroot(work, name, pre) w = os.path.join(work, f"run-{name}") - os.makedirs(w) + os.makedirs(w, exist_ok=True) cands = _candidates(dotnet, ext, root, w) - plan = _plan(cands, w, c["convert"]) + plan = _plan(cands, w, convert) bundle, gate = _apply_and_gate(cands, plan, root, w) - delta = _verify_delta(dotnet, ext, bundle, plan, cands, root, gate, w, - refdir if c["ref"] else None) - return bundle, plan, cands, delta, (refdir if c["ref"] else None), root + delta = _verify_delta(dotnet, ext, bundle, plan, cands, root, gate, w, ref_dirs) + return bundle, plan, cands, delta, root, w + + +def _chain(dotnet: str, ext: str, work: str, + c: dict) -> tuple[str, str, str, str, list[str], str]: + name = c["name"] + ref_dirs: list[str] = [] + if c["wrapper"] is not None: + ref_dirs = [_build_wrapper(dotnet, work, name, c["wrapper"], c.get("tfm", "net8.0"), + c.get("helper"))] + used = ref_dirs if c["ref"] else [] + bundle, plan, cands, delta, root, _w = _build_chain(dotnet, ext, work, name, c["pre"], + used, c["convert"]) + return bundle, plan, cands, delta, used, root def _run_case(dotnet: str, ext: str, probe: str, work: str, c: dict, check) -> None: name = c["name"] try: - bundle, plan, cands, delta, refdir, root = _chain(dotnet, ext, work, c) + bundle, plan, cands, delta, ref_dirs, root = _chain(dotnet, ext, work, c) except Fail as exc: check(False, f"{name}: chain setup failed ({exc})") return out = os.path.join(work, f"run-{name}", "target") - p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, refdir, 0, c["probe"]) + p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, ref_dirs, + 0 if c["probe"] else None, c["probe"]) kind, detail = c["expect"] if kind == "refuse": check(p.returncode == 2 and detail in p.stderr, @@ -467,14 +546,14 @@ def _determinism(dotnet: str, ext: str, probe: str, work: str, check) -> None: c = {"name": "det", "pre": _PRE, "wrapper": _WEAK, "convert": True, "ref": True, "probe": True, "expect": ("pass", "converted")} try: - bundle, plan, cands, delta, refdir, root = _chain(dotnet, ext, work, c) + bundle, plan, cands, delta, ref_dirs, root = _chain(dotnet, ext, work, c) except Fail as exc: check(False, f"determinism: chain setup failed ({exc})") return o1 = os.path.join(work, "det-1") o2 = os.path.join(work, "det-2") - p1 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o1, refdir, 0, True) - p2 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o2, refdir, 0, True) + p1 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o1, ref_dirs, 0, True) + p2 = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, o2, ref_dirs, 0, True) if p1.returncode != 0 or p2.returncode != 0: check(False, f"determinism: a run failed ({p1.returncode}/{p2.returncode})") return @@ -486,5 +565,34 @@ def _determinism(dotnet: str, ext: str, probe: str, work: str, check) -> None: check(_sha(r1) == _sha(r2), "determinism: identical evidence sha") +def _expect_result(p: subprocess.CompletedProcess, out: str, expect: tuple[str, str], + check, name: str) -> dict | None: + kind, detail = expect + if kind == "refuse": + check(p.returncode == 2 and detail in p.stderr, + f"{name}: expect refuse {detail} (rc={p.returncode}: {p.stderr.strip()[-160:]})") + return None + if p.returncode != 0: + check(False, f"{name}: expect pass ({p.stderr.strip()[-200:]})") + return None + return _schema_ok(os.path.join(out, "target-result.json"), detail == "manual", check, name) + + +def _run_ambiguous(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H1: two DIFFERENT reference assemblies both exporting global WeakEvents make the source + call ambiguous -> CALLSITE_BINDING (a candidate is never promoted to the bound symbol).""" + try: + da = _slot(work, "ambA", _compile(dotnet, work, "ambA", _AMB, "net8.0", "WeakEventsA")) + db = _slot(work, "ambB", _compile(dotnet, work, "ambB", _AMB, "net8.0", "WeakEventsB")) + bundle, plan, cands, delta, root, w = _build_chain(dotnet, ext, work, "amb", _PRE, + [da, db], True) + except Fail as exc: + check(False, f"ambiguous: chain setup failed ({exc})") + return + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, bundle, plan, cands, root, delta, out, [da, db], 0, True) + _expect_result(p, out, ("refuse", "CALLSITE_BINDING"), check, "ambiguous") + + if __name__ == "__main__": raise SystemExit(run()) From fb4fcd17061d0e2553d69d8efdbea016e3d8be9d Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 21:39:53 +0500 Subject: [PATCH 06/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20bi?= =?UTF-8?q?nd=20never=20promotes=20CandidateSymbols=20to=20the=20bound=20s?= =?UTF-8?q?ymbol=20(H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A converted callsite may pass ONLY on a cleanly bound Symbol that is an IMethodSymbol; Symbol == null is ALWAYS a refusal. CandidateSymbols are inspected only to choose the category: exactly one candidate wrapper slot of the wrong frozen shape -> WRAPPER_BINDING; a shape correct by name that still fails to unify with the fixed probe runtime (a net9 / .NET-Framework-only wrapper) -> WRAPPER_RUNTIME_UNSUPPORTED (new bind exit 14, never TARGET_RETAINS); anything else (ambiguous / multiple / otherwise unresolved) -> CALLSITE_BINDING. bind now also enforces: the invocation sits in the accepted source tree/file and inside the selected class; every converted finding id appears exactly once; the emitted callsites are a total bijection onto the converted findings with distinct postimage spans. fix_target maps bind exit 14 -> WRAPPER_RUNTIME_UNSUPPORTED. The genuine weak / strong / no-op / wrong-signature / source-defined / net9 outcomes are unchanged; the ambiguous two-assembly decoy is now CALLSITE_BINDING, not a false pass. Tier B 39/39. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe/Program.cs | 69 +++++++++++++++++-- ownlang/fix_target.py | 3 +- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs index 83cdf21b..86511294 100644 --- a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -145,6 +145,11 @@ public static int Run(string[] args) for (var i = 1; i < edits.Count; i++) if (edits[i].PreStart < edits[i - 1].PreStart + edits[i - 1].PreLen) return Refuse("CALLSITE_BINDING", "overlapping converted acquire spans"); + // every converted finding id appears exactly once. + var convertedFids = new HashSet(StringComparer.Ordinal); + foreach (var e in edits) + if (!convertedFids.Add(e.Fid)) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: converted finding id appears more than once"); long delta = 0; var callsites = new List>(); IMethodSymbol? firstSym = null; @@ -168,14 +173,21 @@ public static int Run(string[] args) || Rewrite.NormalizeHandler(argList[1].Expression).ToString() != e.NormalizedHandler) return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation arguments do not match the candidate"); - // 7: resolve the IMethodSymbol. A failed overload still yields the CANDIDATE wrapper - // method, so a wrong-signature / overloaded wrapper is a TARGET_BINDING (WRAPPER_BINDING) - // refusal — not an unresolved callsite. Every converted callsite must resolve to one symbol. + // the invocation must sit in the accepted source tree/file and inside the selected class. + if (inv.SyntaxTree != postTree || inv.SyntaxTree.FilePath != sourceFile) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation is not in the accepted source file"); + var enclosingType = postModel.GetEnclosingSymbol(inv.SpanStart)?.ContainingType; + if (enclosingType is null || FQ(enclosingType) != selectedType) + return Refuse("CALLSITE_BINDING", $"{e.Fid}: invocation is not inside the selected class"); + + // 7: a converted callsite may pass ONLY on a cleanly bound Symbol that is an + // IMethodSymbol. Symbol == null is ALWAYS a refusal — CandidateSymbols are inspected only + // to choose the category: exactly one candidate wrapper slot of the wrong frozen shape is + // WRAPPER_BINDING; anything else (ambiguous / multiple / otherwise unresolved) is + // CALLSITE_BINDING. A candidate method is never promoted to the bound symbol. var si = postModel.GetSymbolInfo(inv); - var sym = si.Symbol as IMethodSymbol - ?? si.CandidateSymbols.OfType().FirstOrDefault(); - if (sym is null) - return Refuse("CALLSITE_BINDING", $"{e.Fid}: cannot resolve the invocation symbol"); + if (si.Symbol is not IMethodSymbol sym) + return RefuseUnbound(e.Fid, si, comp, slotByPath, target); if (SymbolEqualityComparer.Default.Equals(sym.ContainingAssembly, comp.Assembly)) return Refuse("CALLSITE_BINDING", $"{e.Fid}: target is source-defined, not a reference wrapper"); if (firstSym is null) @@ -215,6 +227,20 @@ public static int Run(string[] args) } callsites.Sort((x, y) => string.CompareOrdinal((string)x["finding_id"], (string)y["finding_id"])); + // the emitted callsites are a total bijection onto the converted findings with distinct + // postimage spans (defence in depth; the Python parent re-checks the canonical bytes). + var emitted = callsites.Select(c => (string)c["finding_id"]).ToList(); + if (emitted.Count != edits.Count || !convertedFids.SetEquals(emitted) + || new HashSet(emitted, StringComparer.Ordinal).Count != emitted.Count) + return Refuse("CALLSITE_BINDING", "callsites are not a bijection onto the converted findings"); + var spanKeys = callsites.Select(c => + { + var s = (List)c["postimage_span"]; + return $"{s[0]}:{s[1]}"; + }).ToList(); + if (new HashSet(spanKeys, StringComparer.Ordinal).Count != spanKeys.Count) + return Refuse("CALLSITE_BINDING", "two callsites share a derived postimage span"); + var outObj = new Dictionary { ["version"] = 1L, @@ -292,6 +318,34 @@ private static string ReadMvid(string dllPath) return mr.GetGuid(mr.GetModuleDefinition().Mvid).ToString("D"); } + // Symbol == null: choose the refusal category WITHOUT ever promoting a candidate to the bound + // symbol. With exactly one candidate wrapper slot: a wrong frozen shape is WRAPPER_BINDING; a + // shape that is correct by name yet still fails to bind means the wrapper's metadata/types do + // NOT unify with the fixed probe runtime (e.g. a net9 / .NET-Framework-only wrapper), which is + // WRAPPER_RUNTIME_UNSUPPORTED (never TARGET_RETAINS). Anything else — ambiguous, multiple, or + // otherwise unresolved — is CALLSITE_BINDING. + private static int RefuseUnbound(string fid, SymbolInfo si, CSharpCompilation comp, + Dictionary slotByPath, string target) + { + var asms = new List(); + foreach (var m in si.CandidateSymbols.OfType()) + { + if (SymbolEqualityComparer.Default.Equals(m.ContainingAssembly, comp.Assembly)) continue; + if (!asms.Any(x => SymbolEqualityComparer.Default.Equals(x, m.ContainingAssembly))) + asms.Add(m.ContainingAssembly); + } + if (asms.Count == 1) + { + var path = (comp.GetMetadataReference(asms[0]) as PortableExecutableReference)?.FilePath ?? ""; + if (slotByPath.ContainsKey(path)) + return TargetBinding.Validate(asms[0], target) is not null + ? Refuse("WRAPPER_BINDING", $"{fid}: the wrapper target has the wrong frozen shape") + : Refuse("WRAPPER_RUNTIME_UNSUPPORTED", + $"{fid}: the wrapper target does not unify with the selected probe runtime"); + } + return Refuse("CALLSITE_BINDING", $"{fid}: the invocation does not bind to a single wrapper symbol"); + } + private static int Refuse(string category, string message) { Console.Error.WriteLine($"{category}: {message}"); @@ -300,6 +354,7 @@ private static int Refuse(string category, string message) "CALLSITE_BINDING" => 11, "WRAPPER_BINDING" => 12, "TOOLCHAIN_BINDING" => 13, + "WRAPPER_RUNTIME_UNSUPPORTED" => 14, _ => 2, }; } diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index 31e08bf5..41da33ea 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -299,7 +299,8 @@ def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict return {"converted": conv} -_BIND_EXIT = {11: CALLSITE_BINDING, 12: WRAPPER_BINDING, 13: TOOLCHAIN_BINDING} +_BIND_EXIT = {11: CALLSITE_BINDING, 12: WRAPPER_BINDING, 13: TOOLCHAIN_BINDING, + 14: WRAPPER_RUNTIME_UNSUPPORTED} def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel: str, From 619d3c49c021db889981b087df69a1f362e53b31 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 21:56:03 +0500 Subject: [PATCH 07/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20H2?= =?UTF-8?q?=20the=20wrapper=20load=20context=20must=20be=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the H2 Tier B regressions: a wrapper dependency present in an accepted slot passes; a dependency absent from the slots but copied NEXT TO the probe deployment, or ALREADY present in the probe's default context (Microsoft.CodeAnalysis), is refused WRAPPER_RUNTIME_UNSUPPORTED; two slots exporting the same simple-name dependency prove the frozen first-winning slot is the one loaded; and no absolute path leaks into published evidence. The next-to-probe and default-context cases are RED against the current probe: its WrapperLoadContext returns null for a missing dependency, so the .NET default context happily satisfies it from the probe deployment and the wrapper wrongly runs (pass / TARGET_RETAINS). The next commit closes that. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target_tierb.py | 85 +++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py index 113e65f1..74a762d6 100644 --- a/tests/test_verify_target_tierb.py +++ b/tests/test_verify_target_tierb.py @@ -203,16 +203,18 @@ _HELPER_OK = "namespace Helper { public static class Aux { public static void Touch() { } } }\n" _HELPER_THROW = ("namespace Helper { public static class Aux { public static void Touch() " "{ throw new System.InvalidOperationException(\"bad helper\"); } } }\n") -# a wrapper whose body pulls in Microsoft.CodeAnalysis — a NON-framework assembly already loaded in -# the probe's default context — proving a closed load context never satisfies it from there. +# a wrapper whose body makes a RUNTIME call into Microsoft.CodeAnalysis — a NON-framework assembly +# already present in the probe's default context — proving the closed load context never satisfies +# it from there. (A constant/enum reference would be folded at compile time and load nothing, so the +# body must force an actual assembly load: a real method call into the Roslyn assembly.) _DEP_ROSLYN = """using System.ComponentModel; public static class WeakEvents { public static void AddPropertyChanged( INotifyPropertyChanged source, PropertyChangedEventHandler handler) { - _ = Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp12; - source.PropertyChanged += handler; + var e = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseExpression("a"); + if (e != null) source.PropertyChanged += handler; } } """ @@ -468,6 +470,8 @@ def check(cond: bool, label: str) -> None: _run_case(dotnet, ext, probe, work, c, check) _determinism(dotnet, ext, probe, work, check) _run_ambiguous(dotnet, ext, probe, work, check) + _run_dep_cases(dotnet, ext, probe, work, check) + _run_two_slot_versions(dotnet, ext, probe, work, check) except Fail as exc: fails.append(f"Tier B setup: {exc}") @@ -594,5 +598,78 @@ def _run_ambiguous(dotnet: str, ext: str, probe: str, work: str, check) -> None: _expect_result(p, out, ("refuse", "CALLSITE_BINDING"), check, "ambiguous") +def _run_dep_cases(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H2: the closed load context. A wrapper dependency resolves ONLY from a materialized slot; + it is never satisfied from the probe deployment, the default context, or an arbitrary path.""" + # (a) dependency present in an accepted slot -> pass. + try: + helper = _compile(dotnet, work, "dhOK", _HELPER_OK, "net8.0", "Helper") + weak = _compile(dotnet, work, "dwOK", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", helper)]) + wd, hd = _slot(work, "dwOK", weak), _slot(work, "dhOK", helper) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depin", _PRE, [wd, hd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, hd], 0, True) + _expect_result(p, out, ("pass", "converted"), check, "dep-in-slot") + except Fail as exc: + check(False, f"dep-in-slot: chain setup failed ({exc})") + + # (b) dependency absent from slots but copied NEXT TO the probe deployment -> unsupported. + try: + helper = _compile(dotnet, work, "dhNP", _HELPER_OK, "net8.0", "Helper") + weak = _compile(dotnet, work, "dwNP", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", helper)]) + wd = _slot(work, "dwNP", weak) + probe_copy_dir = os.path.join(work, "probe-copy") + shutil.copytree(os.path.dirname(probe), probe_copy_dir) + shutil.copy(helper, os.path.join(probe_copy_dir, "Helper.dll")) + probe_copy = os.path.join(probe_copy_dir, os.path.basename(probe)) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depnp", _PRE, [wd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe_copy, b, pl, ca, rt, dl, out, [wd], 0, True) + _expect_result(p, out, ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED"), check, + "dep-next-to-probe") + except Fail as exc: + check(False, f"dep-next-to-probe: chain setup failed ({exc})") + + # (c) dependency absent from slots but ALREADY loaded in the probe's default context + # (Microsoft.CodeAnalysis, a probe deployment assembly) -> unsupported, never satisfied. + try: + weak = _compile(dotnet, work, "dwDC", _DEP_ROSLYN, "net8.0", "WeakEvents", None, + [("Microsoft.CodeAnalysis.CSharp", "4.9.2")]) + wd = _slot(work, "dwDC", weak) + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "depdc", _PRE, [wd], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd], 0, True) + _expect_result(p, out, ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED"), check, + "dep-default-context") + except Fail as exc: + check(False, f"dep-default-context: chain setup failed ({exc})") + + +def _run_two_slot_versions(dotnet: str, ext: str, probe: str, work: str, check) -> None: + """H2: two slots export the same simple-name dependency; the FROZEN first-winning slot is the + one loaded, proven behaviorally (good Helper -> pass; throwing Helper -> TARGET_BEHAVIOR).""" + try: + hgood = _compile(dotnet, work, "tvHg", _HELPER_OK, "net8.0", "Helper") + hthrow = _compile(dotnet, work, "tvHt", _HELPER_THROW, "net8.0", "Helper") + weak = _compile(dotnet, work, "tvW", _DEPWEAK, "net8.0", "WeakEvents", + [("Helper", hgood)]) + wd = _slot(work, "tvW", weak) + good, throw = _slot(work, "tvHg", hgood), _slot(work, "tvHt", hthrow) + # good Helper is the earlier (first-winning) slot -> pass + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "tvA", _PRE, [wd, good, throw], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, good, throw], 0, True) + _expect_result(p, out, ("pass", "converted"), check, "two-slot first-wins (good)") + # throwing Helper is the earlier slot -> the wrapper throws on subscribe -> TARGET_BEHAVIOR + b, pl, ca, dl, rt, w = _build_chain(dotnet, ext, work, "tvB", _PRE, [wd, throw, good], True) + out = os.path.join(w, "target") + p = _verify_target(dotnet, probe, b, pl, ca, rt, dl, out, [wd, throw, good], 0, True) + _expect_result(p, out, ("refuse", "TARGET_BEHAVIOR"), check, "two-slot first-wins (throw)") + except Fail as exc: + check(False, f"two-slot-versions: chain setup failed ({exc})") + + if __name__ == "__main__": raise SystemExit(run()) From 90ddc9fc81407dde7b743bcbd2d211d967c115ee Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 21:56:03 +0500 Subject: [PATCH 08/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20cl?= =?UTF-8?q?ose=20the=20wrapper=20AssemblyLoadContext=20(H2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WrapperLoadContext no longer returns null for an arbitrary missing dependency (which let the .NET default context satisfy the wrapper from the probe deployment). It now resolves against an explicit framework set = the simple names physically present in the SELECTED probe runtime directory: - framework assemblies resolve ONLY from the selected runtime (delegate to default); - the wrapper root loads only from its exact derived slot; - every non-framework dependency resolves ONLY from the ordered materialized slots (first filename wins) — never the probe deployment, the default context, the cwd, the user profile, or PATH; - a non-framework dependency absent from the slots throws a controlled loader failure -> WRAPPER_RUNTIME_UNSUPPORTED; - a dependency already loaded in the default context from the probe deployment can no longer satisfy the wrapper. fix_target passes the selected runtime dir to each probe attempt via --runtime-dir. Tier B 52/52; genuine weak / dep-in-slot pass, dep-next-to-probe / dep-in-default / missing-dependency are WRAPPER_RUNTIME_UNSUPPORTED, the frozen first-winning slot is proven behaviorally, and no absolute path leaks into evidence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe/Program.cs | 51 +++++++++++++++++-- ownlang/fix_target.py | 10 ++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs index 86511294..26742fd1 100644 --- a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -480,6 +480,7 @@ public static int Run(string[] args) var attempt = int.Parse(a["attempt"]); var target = a["target"]; var slotsDir = a["slots-dir"]; + var runtimeDir = a["runtime-dir"]; var outPath = a["out"]; var slot = Path.Combine(slotsDir, ordinal.ToString("D6")); @@ -490,7 +491,13 @@ public static int Run(string[] args) var typeName = target[..dot]; var methodName = target[(dot + 1)..]; - var alc = new WrapperLoadContext(rootPath, slotsDir); + // the closed load context: the wrapper root loads only from its exact derived slot; its + // non-framework dependencies resolve ONLY from the ordered materialized slots (first + // filename wins); framework assemblies resolve ONLY from the selected probe runtime; a + // non-framework dependency absent from the slots is a controlled loader failure. The probe + // deployment / default context can NEVER satisfy a wrapper dependency. + var framework = FrameworkNames(runtimeDir); + var alc = new WrapperLoadContext(slotsDir, framework); MethodInfo method; Action invoke; string asmName, mvid, token, sig; @@ -629,6 +636,18 @@ private static string Sha256(string path) using var h = System.Security.Cryptography.SHA256.Create(); return Convert.ToHexString(h.ComputeHash(s)).ToLowerInvariant(); } + + // the framework assembly set = the simple names physically present in the SELECTED probe + // runtime directory. Only these may resolve from the default context; everything else must + // come from a materialized slot. + private static HashSet FrameworkNames(string runtimeDir) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(runtimeDir)) + foreach (var dll in Directory.GetFiles(runtimeDir, "*.dll")) + set.Add(Path.GetFileNameWithoutExtension(dll)); + return set; + } } internal sealed class ProbeSource : INotifyPropertyChanged @@ -646,18 +665,40 @@ internal sealed class ProbeSubscriber internal sealed class WrapperLoadContext : AssemblyLoadContext { private readonly string _slotsDir; - public WrapperLoadContext(string rootPath, string slotsDir) : base("weak-target", isCollectible: false) - => _slotsDir = slotsDir; + private readonly HashSet _framework; + + public WrapperLoadContext(string slotsDir, HashSet framework) + : base("weak-target", isCollectible: false) + { + _slotsDir = slotsDir; + _framework = framework; + } protected override Assembly? Load(AssemblyName name) + { + var simple = name.Name ?? ""; + // framework assemblies resolve ONLY from the selected probe runtime (the default context, + // which the probe pinned via --fx-version). Returning null delegates to that runtime. + if (_framework.Contains(simple)) return null; + // a non-framework dependency resolves ONLY from the ordered materialized slots (first + // filename wins) — never the probe deployment, the default context, the working directory, + // the user profile, or an arbitrary PATH entry. + var dll = ResolveSlot(simple); + if (dll != null) return LoadFromAssemblyPath(dll); + // absent from the slots -> a controlled loader failure -> WRAPPER_RUNTIME_UNSUPPORTED. + throw new FileNotFoundException( + $"the wrapper dependency '{simple}' is not in the materialized reference closure"); + } + + private string? ResolveSlot(string simple) { if (!Directory.Exists(_slotsDir)) return null; foreach (var slot in Directory.GetDirectories(_slotsDir).OrderBy(d => d, StringComparer.Ordinal)) { var dll = Directory.GetFiles(slot, "*.dll").SingleOrDefault(); - if (dll != null && string.Equals(Path.GetFileNameWithoutExtension(dll), name.Name, + if (dll != null && string.Equals(Path.GetFileNameWithoutExtension(dll), simple, StringComparison.OrdinalIgnoreCase)) - return LoadFromAssemblyPath(Path.GetFullPath(dll)); + return Path.GetFullPath(dll); } return null; } diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index 41da33ea..12c978fe 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -368,15 +368,15 @@ def _probe_env(work: str, cwd_dir: str) -> dict[str, str]: def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, - wrapper_ordinal: int, slots_dir: str, target: str, - attempt: int) -> tuple[int, dict[str, Any] | None]: + wrapper_ordinal: int, slots_dir: str, target: str, attempt: int, + runtime_dir: str) -> tuple[int, dict[str, Any] | None]: adir = os.path.join(work, f"attempt-{attempt}") os.makedirs(adir, exist_ok=True) out_path = os.path.join(adir, "probe-result.json") argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", probe_dll, "probe", "--wrapper-ordinal", str(wrapper_ordinal), - "--slots-dir", slots_dir, "--attempt", str(attempt), "--target", target, - "--out", out_path] + "--slots-dir", slots_dir, "--runtime-dir", runtime_dir, "--attempt", str(attempt), + "--target", target, "--out", out_path] try: proc = subprocess.run(argv, cwd=os.path.join(work, "probe"), env=_probe_env(work, adir), capture_output=True, timeout=_CHILD_TIMEOUT_SECONDS, check=False) @@ -667,7 +667,7 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s attempts: list[dict[str, Any]] = [] for k in range(_ATTEMPT_COUNT): rc, res = run_probe_attempt(work, dotnet_host, probe_dll_dst, selected_ver, - wrapper_ordinal, slots_root, target, k) + wrapper_ordinal, slots_root, target, k, rt_dir) if rc == 10: raise TargetError(WRAPPER_RUNTIME_UNSUPPORTED, "the wrapper cannot execute under the fixed probe runtime") From 49614609c720a433ac096468def17f70267fa8d5 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 22:10:19 +0500 Subject: [PATCH 09/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20H3?= =?UTF-8?q?=20execution-root=20isolation=20+=20full=20revalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tier A unit regressions for the G5 protocol: EXECUTION_WORK_ROOT is refused ISOLATION when the temp parent resolves inside the source / bundle / output-parent; plan / candidates / delta / pristine source / patch / manifest / postimage drift is refused; probe-deployment / slot / dotnet-host / selected-runtime drift is refused; a cleanup failure is PUBLICATION; and no filesystem operation runs after the publication rename (no execution-root residue). RED against the current fix_target, which has no _execution_root / _reval_inputs / _reval_toolchain / _remove_root and creates the work root with an unchecked mkdtemp plus an ignore_errors cleanup. The next commit implements the literal G5 protocol. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target.py | 181 ++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py index 8fb33cf7..70c2c858 100644 --- a/tests/test_verify_target.py +++ b/tests/test_verify_target.py @@ -302,10 +302,191 @@ def _boom(*_a, **_k): check(ft._peel_handler("new PropertyChangedEventHandler(OnA)") == "OnA", "peel: new H(M)") check(ft._peel_handler("new(OnA)") == "OnA", "peel: new(M)") + _h3_isolation(check) + _h3_revalidation(check) + _h3_cleanup_and_publish(check) + total = ok + bad print(f"verify-target (Tier A): {ok}/{total} checks pass") return 1 if bad else 0 +def _reads(p: str) -> bytes: + with open(p, "rb") as fh: + return fh.read() + + +def _h3_isolation(check) -> None: + """G5: EXECUTION_WORK_ROOT is created under a temp parent physically outside every protected + root (TMPDIR inside the source / bundle / output parent is ISOLATION).""" + with tempfile.TemporaryDirectory() as tmp: + for name in ("src", "bundle", "outp"): + prot = os.path.join(tmp, name) + inside = os.path.join(prot, "t") + os.makedirs(inside) + orig = ft.tempfile.gettempdir + try: + ft.tempfile.gettempdir = lambda p=inside: p + check(_raises(ft.ISOLATION, ft._execution_root, [prot]), + f"exec-root: TMPDIR inside {name} -> ISOLATION") + finally: + ft.tempfile.gettempdir = orig + # a temp parent outside every protected root yields a fresh, contained execution root. + safe, prot = os.path.join(tmp, "safe"), os.path.join(tmp, "prot") + os.makedirs(safe) + os.makedirs(prot) + orig = ft.tempfile.gettempdir + try: + ft.tempfile.gettempdir = lambda: safe + wr = ft._execution_root([prot]) + check(os.path.isdir(wr) and ft._same_or_inside(os.path.realpath(safe), wr), + "exec-root: a safe parent yields a fresh contained root") + ft.shutil.rmtree(wr) + finally: + ft.tempfile.gettempdir = orig + + +def _h3_revalidation(check) -> None: + from ownlang.fix_delta import _manifest_sha, _runtime_manifest, _walk_regular_files + + # --- _reval_inputs: any authoritative input changing after binding is a refusal --- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + parts = _REL.split("/") + man = os.path.join(bundle, "apply-manifest.json") + ih = {"pre_sha256": _sha_bytes(_reads(os.path.join(root, *parts))), + "patch_sha256": _sha_bytes(_reads(os.path.join(bundle, "change.patch"))), + "apply_manifest_sha256": _sha_bytes(_reads(man)), + "post_sha256": _sha_bytes(_reads(os.path.join(bundle, "postimage", *parts)))} + args = (paths["plan.json"], paths["candidates.json"], paths["delta.json"], root, bundle, + _REL, plan_b, cand_b, delta_bytes, ih) + try: + ft._reval_inputs(*args) + check(True, "reval-inputs: no drift passes") + except ft.TargetError: + check(False, "reval-inputs: false drift") + drifts = [(paths["plan.json"], ft.AUTHORITY_BINDING, "plan"), + (paths["candidates.json"], ft.AUTHORITY_BINDING, "candidates"), + (paths["delta.json"], ft.DELTA_BINDING, "delta"), + (os.path.join(root, *parts), ft.DELTA_BINDING, "source"), + (os.path.join(bundle, "change.patch"), ft.DELTA_BINDING, "patch"), + (os.path.join(bundle, "apply-manifest.json"), ft.DELTA_BINDING, "manifest"), + (os.path.join(bundle, "postimage", *parts), ft.DELTA_BINDING, "postimage")] + for path, cat, label in drifts: + original = _reads(path) + with open(path, "ab") as fh: + fh.write(b"// drift\n") + check(_raises(cat, ft._reval_inputs, *args), f"reval-inputs: {label} drift -> {cat}") + with open(path, "wb") as fh: + fh.write(original) + + # --- _reval_toolchain: probe deployment / slot / host / runtime drift --- + with tempfile.TemporaryDirectory() as tmp: + work = os.path.join(tmp, "work") + pdir = os.path.join(work, "probe") + os.makedirs(pdir) + with open(os.path.join(pdir, "probe.dll"), "wb") as fh: + fh.write(b"PROBE") + probe_fp = {"probe_deployment_manifest_sha256": + _manifest_sha(pdir, _walk_regular_files(pdir, ft.TOOLCHAIN_BINDING), + ft.TOOLCHAIN_BINDING)} + slotd = os.path.join(work, "references", "000000") + os.makedirs(slotd) + with open(os.path.join(slotd, "W.dll"), "wb") as fh: + fh.write(b"WDLL") + slot_ev = [{"ordinal": 0, "relative_path": "W.dll", "sha256": _sha_bytes(b"WDLL")}] + rt = os.path.join(tmp, "rt") + os.makedirs(rt) + with open(os.path.join(rt, "System.Private.CoreLib.dll"), "wb") as fh: + fh.write(b"RT") + rid = {"selected_runtime_manifest_sha256": _runtime_manifest(rt)} + host = sys.executable + host_sha = ft._hash_resolved(host, ft.TOOLCHAIN_BINDING, "dotnet host") + base = (work, probe_fp, slot_ev, [slotd], rt, rid, host, host_sha) + try: + ft._reval_toolchain(*base) + check(True, "reval-toolchain: no drift passes") + except ft.TargetError: + check(False, "reval-toolchain: false drift") + with open(os.path.join(pdir, "probe.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: probe deployment drift -> TOOLCHAIN_BINDING") + with open(os.path.join(pdir, "probe.dll"), "wb") as fh: + fh.write(b"PROBE") + with open(os.path.join(slotd, "W.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.REFERENCE_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: slot drift -> REFERENCE_BINDING") + with open(os.path.join(slotd, "W.dll"), "wb") as fh: + fh.write(b"WDLL") + drifted_host = (work, probe_fp, slot_ev, [slotd], rt, rid, host, "sha256:" + "0" * 64) + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *drifted_host), + "reval-toolchain: dotnet host drift -> TOOLCHAIN_BINDING") + with open(os.path.join(rt, "System.Private.CoreLib.dll"), "ab") as fh: + fh.write(b"X") + check(_raises(ft.TOOLCHAIN_BINDING, ft._reval_toolchain, *base), + "reval-toolchain: selected runtime drift -> TOOLCHAIN_BINDING") + + +def _h3_cleanup_and_publish(check) -> None: + # --- _remove_root: a cleanup failure is PUBLICATION --- + with tempfile.TemporaryDirectory() as tmp: + d = os.path.join(tmp, "work") + os.makedirs(d) + orig = ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("locked") + + try: + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._remove_root, d), + "remove-root: cleanup failure -> PUBLICATION") + finally: + ft.shutil.rmtree = orig + + # --- no filesystem operation runs after the publication rename; no private residue --- + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, _delta = _manual_fixture(tmp) + outdir = os.path.join(tmp, "pub", "target") + os.makedirs(os.path.join(tmp, "pub")) + state = {"renamed": False, "after": []} + real_rename, real_rmtree = ft.os.rename, ft.shutil.rmtree + real_scandir, real_stat = ft.os.scandir, ft.os.stat + + def rename_spy(a, b): + real_rename(a, b) + state["renamed"] = True + + def guard(name, real): + def _f(*a, **k): + if state["renamed"]: + state["after"].append(name) + return real(*a, **k) + return _f + try: + ft.os.rename = rename_spy + ft.shutil.rmtree = guard("rmtree", real_rmtree) + ft.os.scandir = guard("scandir", real_scandir) + ft.os.stat = guard("stat", real_stat) + before = {n for n in os.listdir(ft.tempfile.gettempdir()) + if n.startswith("owen-target-")} + published = ft.run_verify_target(bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, + outdir, [], None) + after = {n for n in os.listdir(ft.tempfile.gettempdir()) + if n.startswith("owen-target-")} + finally: + ft.os.rename, ft.shutil.rmtree = real_rename, real_rmtree + ft.os.scandir, ft.os.stat = real_scandir, real_stat + check(state["renamed"] and state["after"] == [], + "publish: no filesystem op after the rename") + check(after == before, "publish: no EXECUTION_WORK_ROOT residue after success") + check(os.path.isfile(os.path.join(published, "target-result.json")), + "publish: the artifact exists after a clean run") + + if __name__ == "__main__": raise SystemExit(run()) From 8c692b854328837d7abd35551e72be45001f5c91 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 22:10:19 +0500 Subject: [PATCH 10/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20li?= =?UTF-8?q?teral=20G5=20execution-root=20isolation=20+=20revalidation=20(H?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_verify_target now follows the ordered G5 protocol on both paths: snapshot all inputs; create EXECUTION_WORK_ROOT under a temp parent PHYSICALLY resolved to be outside and not equal to every protected root (source, bundle, probe deployment, ref-dirs, output parent), with no protected root inside it (else ISOLATION); materialize and execute; revalidate the probe deployment, slots, dotnet host, and selected runtime before bind AND before every probe attempt; revalidate every input and the whole toolchain again before constructing evidence; build the canonical bytes in memory; remove the execution root strictly (a failure is PUBLICATION and the out-dir stays absent); then publish through one atomic rename with no filesystem operation afterwards. Every ignore_errors=True is removed from the Step 11 path: the success path removes the work root strictly, the failure path cleans up best-effort without masking the refusal. Tier A 49/49, Tier B 52/52. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_target.py | 137 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index 12c978fe..b734c068 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -583,20 +583,87 @@ def build_converted_result(input_hashes: dict[str, Any], delta_bytes: bytes, } -def _revalidate(work: str, probe_fp: dict[str, Any], slot_evidence: list[dict[str, Any]], - slot_dirs: list[str], rt_dir: str, runtime_identity: dict[str, Any]) -> None: - pdir = os.path.join(work, "probe") - if _manifest_sha(pdir, _walk_regular_files(pdir, TOOLCHAIN_BINDING), TOOLCHAIN_BINDING) \ - != probe_fp["probe_deployment_manifest_sha256"]: - raise TargetError(TOOLCHAIN_BINDING, "the materialized probe deployment changed") +def _reval_slots(slot_evidence: list[dict[str, Any]], slot_dirs: list[str]) -> None: for i, ev in enumerate(slot_evidence): dll = os.path.join(slot_dirs[i], ev["relative_path"].rsplit("/", 1)[-1]) if _sha_bytes(_snapshot(dll, REFERENCE_BINDING, "slot")) != ev["sha256"]: raise TargetError(REFERENCE_BINDING, "a materialized reference slot changed") + + +def _reval_toolchain(work: str, probe_fp: dict[str, Any], slot_evidence: list[dict[str, Any]], + slot_dirs: list[str], rt_dir: str, runtime_identity: dict[str, Any], + dotnet_host: str, dotnet_host_sha: str) -> None: + """Revalidate the copied probe deployment, the materialized slots, the dotnet host, and the + selected runtime — before bind and before EVERY probe attempt (G5).""" + pdir = os.path.join(work, "probe") + if _manifest_sha(pdir, _walk_regular_files(pdir, TOOLCHAIN_BINDING), TOOLCHAIN_BINDING) \ + != probe_fp["probe_deployment_manifest_sha256"]: + raise TargetError(TOOLCHAIN_BINDING, "the materialized probe deployment changed") + _reval_slots(slot_evidence, slot_dirs) + if _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") != dotnet_host_sha: + raise TargetError(TOOLCHAIN_BINDING, "the dotnet host changed") if _runtime_manifest(rt_dir) != runtime_identity["selected_runtime_manifest_sha256"]: raise TargetError(TOOLCHAIN_BINDING, "the selected runtime changed") +def _reval_inputs(plan_path: str, candidates_path: str, delta_path: str, root: str, bundle: str, + rel: str, plan_bytes: bytes, candidates_bytes: bytes, delta_bytes: bytes, + input_hashes: dict[str, str]) -> None: + """Revalidate every authoritative input still equals what was bound — before constructing the + published evidence (G5): plan, candidates, delta, source, patch, manifest, postimage.""" + if _snapshot(plan_path, INPUT_LAYOUT, "--plan") != plan_bytes: + raise TargetError(AUTHORITY_BINDING, "--plan changed during verification") + if _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") != candidates_bytes: + raise TargetError(AUTHORITY_BINDING, "--candidates changed during verification") + if _snapshot(delta_path, INPUT_LAYOUT, "--delta") != delta_bytes: + raise TargetError(DELTA_BINDING, "--delta changed during verification") + manifest_path = os.path.join(bundle, "apply-manifest.json") + post_path = os.path.join(bundle, "postimage", *rel.split("/")) + for path, key, label in ( + (os.path.join(root, *rel.split("/")), "pre_sha256", "the pristine source"), + (os.path.join(bundle, "change.patch"), "patch_sha256", "change.patch"), + (manifest_path, "apply_manifest_sha256", "apply-manifest.json"), + (post_path, "post_sha256", "the accepted postimage"), + ): + if _sha_bytes(_snapshot(path, DELTA_BINDING, label)) != input_hashes[key]: + raise TargetError(DELTA_BINDING, f"{label} changed during verification") + + +def _execution_root(protected: list[str]) -> str: + """Create EXECUTION_WORK_ROOT under a temp parent PHYSICALLY resolved to be outside and not + equal to every protected root, and with no protected root inside it — else ISOLATION (G5).""" + parent = os.path.realpath(tempfile.gettempdir()) + prots = [os.path.realpath(p) for p in protected] + for pr in prots: + if _same_or_inside(pr, parent): + raise TargetError(ISOLATION, + "the execution temp parent resolves inside a protected root") + root = tempfile.mkdtemp(prefix="owen-target-", dir=parent) + rp = os.path.realpath(root) + for pr in prots: + if _same_or_inside(rp, pr): + _discard_root(root) + raise TargetError(ISOLATION, "a protected root resolves inside the execution root") + return root + + +def _discard_root(work: str) -> None: + """Best-effort cleanup on the FAILURE path (never masks the original refusal).""" + try: + shutil.rmtree(work) + except OSError: + pass + + +def _remove_root(work: str) -> None: + """Strict cleanup on the SUCCESS path, before publication: a failure is PUBLICATION and the + out-dir stays absent (G5).""" + try: + shutil.rmtree(work) + except OSError as exc: + raise TargetError(PUBLICATION, f"cannot remove the execution root ({exc})") from exc + + def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: str, delta_path: str, probe_dll: str | None, out: str, ref_dirs: list[str], wrapper_ordinal: int | None) -> str: @@ -628,18 +695,32 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s "pre_sha256": bundle_info["pre_sha256"], "post_sha256": bundle_info["post_sha256"], } - work = tempfile.mkdtemp(prefix="owen-target-") + # the protected roots the EXECUTION_WORK_ROOT must be created outside of (G5). `work` itself is + # added afterwards for the publication-staging exclusion. + out_parent = os.path.realpath(os.path.dirname(os.path.abspath(out))) + iso_protected = [root, bundle, out_parent, *ref_dirs] + if probe_dll is not None: + iso_protected.append(os.path.dirname(os.path.abspath(probe_dll))) + work = _execution_root(iso_protected) + publish_protected = [root, bundle, work, *ref_dirs] + if probe_dll is not None: + publish_protected.append(os.path.dirname(os.path.abspath(probe_dll))) + + work_removed = False try: slot_dirs, slot_evidence = reference_closure(work, ref_dirs, delta) passed.add("reference_binding") - protected = [root, bundle, work, *ref_dirs] - if probe_dll is not None: - protected.append(os.path.dirname(os.path.abspath(probe_dll))) if not converted: + _reval_inputs(plan_path, candidates_path, delta_path, root, bundle, rel, + plan_bytes, candidates_bytes, delta_bytes, input_hashes) + _reval_slots(slot_evidence, slot_dirs) passed.add("publication") - evidence = build_manual_only_result(input_hashes, delta_bytes, delta, target, passed) - return _publish_target(out, protected, _canonical(evidence)) + evidence_bytes = _canonical( + build_manual_only_result(input_hashes, delta_bytes, delta, target, passed)) + _remove_root(work) + work_removed = True + return _publish_target(out, publish_protected, evidence_bytes) assert probe_dll is not None and wrapper_ordinal is not None probe_dll_dst, probe_fp = snapshot_probe_deployment(work, probe_dll) @@ -654,6 +735,8 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s class_fqn = candidates["selection"]["allowed_types"][0]["full_name"] bind_params = build_bind_params(candidates, convert_ids, rel) slots_root = os.path.join(work, "references") + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) # before bind binding = run_bind(work, dotnet_host, probe_dll_dst, selected_ver, rel, bundle_info["preimage"], bundle_info["postimage"], slots_root, target, class_fqn, bind_params) @@ -666,6 +749,8 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s attempts: list[dict[str, Any]] = [] for k in range(_ATTEMPT_COUNT): + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) # before EVERY attempt rc, res = run_probe_attempt(work, dotnet_host, probe_dll_dst, selected_ver, wrapper_ordinal, slots_root, target, k, rt_dir) if rc == 10: @@ -677,17 +762,21 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s passed.update({"wrapper_binding", "harness_controls", "target_behavior", "target_nonretention", "harness_determinism"}) - _revalidate(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity) + # before constructing evidence: revalidate every input and the whole toolchain again. + _reval_inputs(plan_path, candidates_path, delta_path, root, bundle, rel, + plan_bytes, candidates_bytes, delta_bytes, input_hashes) + _reval_toolchain(work, probe_fp, slot_evidence, slot_dirs, rt_dir, runtime_identity, + dotnet_host, dotnet_host_sha) passed.add("publication") - evidence = build_converted_result(input_hashes, delta_bytes, delta, target, slot_evidence, - wrapper_ordinal, binding, probe_fp, dotnet_host_sha, - dotnet_version, runtime_identity, attempts, passed) - evidence_bytes = _canonical(evidence) - try: - shutil.rmtree(work) # G5: remove EXECUTION_WORK_ROOT before public publication - except OSError as exc: - raise TargetError(PUBLICATION, f"cannot remove the work root ({exc})") from exc - return _publish_target(out, protected, evidence_bytes) - finally: - shutil.rmtree(work, ignore_errors=True) + evidence_bytes = _canonical(build_converted_result( + input_hashes, delta_bytes, delta, target, slot_evidence, wrapper_ordinal, binding, + probe_fp, dotnet_host_sha, dotnet_version, runtime_identity, attempts, passed)) + _remove_root(work) # G5: remove EXECUTION_WORK_ROOT before publication + work_removed = True + # one atomic rename; NO filesystem operation runs after it succeeds. + return _publish_target(out, publish_protected, evidence_bytes) + except BaseException: + if not work_removed: + _discard_root(work) + raise From 97db6e32e3a1cc96888419bcac67a1c7dc74e4e3 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 22:21:14 +0500 Subject: [PATCH 11/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20H4?= =?UTF-8?q?=20bounded=20child=20+=20strict=20result=20validation=20+=20bin?= =?UTF-8?q?d=5Fdelta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tier A regressions: a bounded subprocess runner (over-time / stdout-overflow / stderr-overflow are killed and capped); strict binding-result validation (bool ordinal / extra key / negative span / identity mismatch -> INFRASTRUCTURE; a well-formed but non-bijective / duplicate-span binding -> CALLSITE_BINDING); strict probe-result field formats (bool ordinal / malformed sha / mvid / extra key); the runtime-unsupported result schema that gates accepting child exit 10; and bind_delta rejecting an unknown top-level key / a missing or wrong-typed consumed field -> DELTA_BINDING. Extends the fixture delta to the frozen 15-key shape. RED against the current fix_target, which has no _run_child / _validate_binding_result / _validate_unsupported_result / _bind_delta_shapes and buffers child output before checking it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target.py | 155 ++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py index 70c2c858..e86a8afc 100644 --- a/tests/test_verify_target.py +++ b/tests/test_verify_target.py @@ -87,6 +87,9 @@ def _make_delta(cands_bytes: bytes, plan_bytes: bytes, auth, manifest_sha: str, "expected": {"convert_acquire_ids": sorted(auth.applied), "manual_review_ids": sorted(auth.manual)}, "reference_closure": ref_closure, + # the remaining frozen Step 10 top-level keys (present but not consumed by Step 11). + "gate_binding": {"git_gates_status": "not_applicable"}, + "baseline": {}, "postimage": {}, "delta": {}, "semantic_idempotence": {"pass": True}, "checks": dict.fromkeys(ft._STEP10_CHECKS, "pass"), } return _canonical_bytes(d) @@ -305,6 +308,9 @@ def _boom(*_a, **_k): _h3_isolation(check) _h3_revalidation(check) _h3_cleanup_and_publish(check) + _h4_validators(check) + _h4_run_child(check) + _h4_bind_delta(check) total = ok + bad print(f"verify-target (Tier A): {ok}/{total} checks pass") @@ -488,5 +494,154 @@ def _f(*a, **k): "publish: the artifact exists after a clean run") +_RW = {"assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", "resolved_signature": "System.Void WeakEvents.M()"} + + +def _binding_result(fids: list[str]) -> dict: + cs = [{"finding_id": f, "preimage_span": [i, 10], "postimage_span": [i * 2, 20], **_RW} + for i, f in enumerate(sorted(fids))] + return {"version": 1, "operation": "weak-target-bind", "converted_callsites": len(fids), + "derived_wrapper_ordinal": 0, "resolved_wrapper": dict(_RW), + "callsite_binding": {"all_callsites_same_symbol": True, + "target_is_source_defined": False}, + "callsites": cs} + + +def _probe_result(**over) -> dict: + r = {"version": 1, "operation": "weak-target-probe", "attempt": 0, + "strong_delivered_once": True, "strong_retained": True, "weak_control_collected": True, + "delivered_count": 1, "threw_on_subscribe": False, "threw_on_first_raise": False, + "subscriber_collected": True, "threw_on_post_collection_raise": False, + "resolved_wrapper": {"ordinal": 0, "slot_sha256": "sha256:" + "a" * 64, + "assembly_simple_name": "WeakEvents", + "module_mvid": "d94f6f4c-0000-4000-8000-00000000abcd", + "metadata_token": "0x06000001", + "resolved_signature": "System.Void WeakEvents.M()"}} + r.update(over) + return r + + +def _h4_validators(check) -> None: + ids = ["OWN001:sha256:" + "1" * 64, "OWN001:sha256:" + "2" * 64] + try: + ft._validate_binding_result(_binding_result(ids), ids) + check(True, "binding-result: a valid bijection passes") + except ft.TargetError: + check(False, "binding-result: false rejection") + # malformed shape/type -> INFRASTRUCTURE + b = _binding_result(ids) + b["derived_wrapper_ordinal"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: bool ordinal -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["surprise"] = 1 + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: extra callsite key -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["postimage_span"] = [1, -1] + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: negative span -> INFRASTRUCTURE") + b = _binding_result(ids) + b["callsites"][0]["module_mvid"] = "different" + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: callsite identity != resolved_wrapper -> INFRASTRUCTURE") + # well-formed but non-bijective / duplicate span -> CALLSITE_BINDING + check(_raises(ft.CALLSITE_BINDING, ft._validate_binding_result, _binding_result(ids), ids[:1]), + "binding-result: not a bijection -> CALLSITE_BINDING") + b = _binding_result(ids) + b["callsites"][1]["postimage_span"] = list(b["callsites"][0]["postimage_span"]) + check(_raises(ft.CALLSITE_BINDING, ft._validate_binding_result, b, ids), + "binding-result: duplicate postimage span -> CALLSITE_BINDING") + + # probe-result field types / formats + try: + ft._validate_probe_result(_probe_result(), 0) + check(True, "probe-result: a valid result passes") + except ft.TargetError: + check(False, "probe-result: false rejection") + bad = _probe_result() + bad["resolved_wrapper"]["ordinal"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: bool ordinal -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["slot_sha256"] = "sha256:XYZ" + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: malformed slot sha -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["extra"] = 1 + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: extra resolved_wrapper key -> INFRASTRUCTURE") + bad = _probe_result() + bad["resolved_wrapper"]["module_mvid"] = "not-a-guid" + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), + "probe-result: malformed mvid -> INFRASTRUCTURE") + + # runtime-unsupported result schema (gate for accepting child exit 10) + good = {"version": 1, "operation": "weak-target-probe", "attempt": 0, + "runtime_unsupported": True, "reason": "FileNotFoundException"} + try: + ft._validate_unsupported_result(good, 0) + check(True, "unsupported-result: valid schema passes") + except ft.TargetError: + check(False, "unsupported-result: false rejection") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, {**good, "reason": 1}, 0), + "unsupported-result: non-string reason -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, + {**good, "runtime_unsupported": False}, 0), + "unsupported-result: flag not true -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, good, 1), + "unsupported-result: wrong attempt -> INFRASTRUCTURE") + + +def _h4_run_child(check) -> None: + with tempfile.TemporaryDirectory() as tmp: + env = {k: v for k, v in os.environ.items() if k in ("PATH", "SystemRoot", "SYSTEMROOT")} + rc, out, err, reason = ft._run_child( + [sys.executable, "-c", "import time; time.sleep(30)"], tmp, env, 1) + check(reason == "timeout", "run-child: over-time child -> timeout (killed)") + flood_out = "import sys; sys.stdout.buffer.write(b'x'*200000)" + rc, out, err, reason = ft._run_child([sys.executable, "-c", flood_out], tmp, env, 20) + check(reason == "stdout_overflow" and len(out) <= ft._OUT_LIMIT, + "run-child: stdout overflow -> capped + killed") + flood_err = "import sys; sys.stderr.buffer.write(b'y'*200000)" + rc, out, err, reason = ft._run_child([sys.executable, "-c", flood_err], tmp, env, 20) + check(reason == "stderr_overflow" and len(err) <= ft._OUT_LIMIT, + "run-child: stderr overflow -> capped + killed") + rc, out, err, reason = ft._run_child( + [sys.executable, "-c", "print('ok')"], tmp, env, 20) + check(reason is None and rc == 0 and out.strip() == b"ok", + "run-child: a clean child returns bounded output") + + +def _h4_bind_delta(check) -> None: + with tempfile.TemporaryDirectory() as tmp: + _root, _bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + try: + ft.bind_delta(delta_bytes, auth, plan_b, cand_b) + check(True, "bind_delta: the frozen 15-key delta binds") + except ft.TargetError: + check(False, "bind_delta: false rejection of a valid delta") + d = json.loads(delta_bytes) + d["surprise"] = 1 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: unknown top-level key -> DELTA_BINDING") + d = json.loads(delta_bytes) + del d["input_hashes"]["pre_sha256"] + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: missing consumed field -> DELTA_BINDING") + d = json.loads(delta_bytes) + d["toolchain_fingerprint"]["resolved_runtime_identity"]["tfm"] = 9 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: wrong-typed runtime identity -> DELTA_BINDING") + + if __name__ == "__main__": raise SystemExit(run()) From 9876b361d815f46570d100d64d59e1ae35dafc06 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 22:21:15 +0500 Subject: [PATCH 12/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20bo?= =?UTF-8?q?unded=20child=20runner=20+=20strict=20validation=20+=20bind=5Fd?= =?UTF-8?q?elta=20(H4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared bounded runner (_run_child) drives both bind and every probe attempt: it enforces the fixed 30s timeout, caps stdout/stderr at 65536 bytes WHILE the child runs, and kills the direct child (no descendant-containment claim) on timeout or overflow. The Python parent now strictly validates the canonical binding-result.json (exact keys/types, non-negative derived ordinal, per-callsite identity == resolved wrapper, a sorted total bijection onto the converted ids with distinct postimage spans; malformed -> INFRASTRUCTURE, non-bijective -> CALLSITE_BINDING), the probe-result resolved-wrapper field formats (int ordinal, lowercase sha256:, 0x token, GUID mvid), and the exact runtime-unsupported schema before accepting child exit 10 (else INFRASTRUCTURE). bind_delta rejects unknown top-level keys and validates the exact frozen shapes it consumes, so a malformed Step 10 delta is DELTA_BINDING, never a KeyError. Tier A 73/73, Tier B 52/52; the frozen Step 10 producer/schema is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_target.py | 259 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 226 insertions(+), 33 deletions(-) diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index b734c068..7dd789ad 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -22,10 +22,12 @@ import json import os +import re import shutil import stat import subprocess import tempfile +import threading from typing import Any, cast from ownlang.fix_delta import ( @@ -121,6 +123,42 @@ def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any return auth, plan, candidates +_DELTA_TOP_KEYS = frozenset({ + "schema", "operation", "status", "analysis_scope", "input_hashes", "gate_binding", + "toolchain_fingerprint", "reference_closure", "target_api", "expected", "baseline", + "postimage", "delta", "semantic_idempotence", "checks", +}) +_DELTA_IH_KEYS = ("input_bundle_sha256", "validated_plan_sha256", "candidates_sha256", + "apply_manifest_sha256", "patch_sha256", "pre_sha256", "post_sha256") +_DELTA_RID_KEYS = ("framework_name", "tfm", "requested_framework_version", + "selected_framework_version", "runtime_manifest_sha256") + + +def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None: + """Validate the exact frozen shapes of the Step 10 delta fields Step 11 consumes, so a + malformed/missing field is DELTA_BINDING rather than a KeyError/INFRASTRUCTURE (H4).""" + ih = d["input_hashes"] + if not isinstance(ih, dict) or not all(isinstance(ih.get(k), str) for k in _DELTA_IH_KEYS): + raise TargetError(cat, "delta-result.json input_hashes shape is wrong") + scope = d["analysis_scope"] + if not isinstance(scope, dict) or not isinstance(scope.get("source_file"), str) \ + or not isinstance(scope.get("target_file_identity"), str): + raise TargetError(cat, "delta-result.json analysis_scope shape is wrong") + tfp = d["toolchain_fingerprint"] + rid = tfp.get("resolved_runtime_identity") if isinstance(tfp, dict) else None + if not isinstance(rid, dict) or not all(isinstance(rid.get(k), str) for k in _DELTA_RID_KEYS): + raise TargetError(cat, "delta-result.json resolved_runtime_identity shape is wrong") + tapi = d["target_api"] + if not isinstance(tapi, dict) or not isinstance(tapi.get("subscribe"), str): + raise TargetError(cat, "delta-result.json target_api shape is wrong") + exp = d["expected"] + if not isinstance(exp, dict) or not isinstance(exp.get("convert_acquire_ids"), list) \ + or not isinstance(exp.get("manual_review_ids"), list): + raise TargetError(cat, "delta-result.json expected shape is wrong") + if not isinstance(d["reference_closure"], list): + raise TargetError(cat, "delta-result.json reference_closure shape is wrong") + + def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, candidates_bytes: bytes) -> dict[str, Any]: """Bind the Step 10 delta-result.json as the upstream authority (canonical bytes, exact @@ -134,6 +172,9 @@ def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, raise TargetError(cat, f"delta-result.json is not valid JSON ({exc})") from exc if _canonical_bytes(d) != delta_bytes: raise TargetError(cat, "delta-result.json is not canonical bytes") + if not isinstance(d, dict) or set(d) != _DELTA_TOP_KEYS: + raise TargetError(cat, "delta-result.json has unknown or missing top-level keys") + _bind_delta_shapes(d, cat) if d.get("schema") != 1 or d.get("operation") != "verify-subscription-analyzer-delta" \ or d.get("status") != "pass": raise TargetError(cat, "delta-result.json schema/operation/status is wrong") @@ -268,7 +309,6 @@ def resolve_probe_runtime(dll_dst: str, dotnet_host: str, def _peel_handler(handler: str) -> str: """The frozen Step 8 handler peel + whitespace normalization, mirrored for bind-params: `new H(M)` / `new(M)` -> M, then collapse whitespace.""" - import re s = handler.strip() while True: m = re.fullmatch(r"new\s+[^\s(]+\s*\(\s*(.*)\s*\)", s) or re.fullmatch( @@ -303,9 +343,63 @@ def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict 14: WRAPPER_RUNTIME_UNSUPPORTED} +def _run_child(argv: list[str], cwd: str, env: dict[str, str], + timeout: int) -> tuple[int, bytes, bytes, str | None]: + """One shared bounded runner for the bind and probe children (H4). It enforces the fixed + timeout, caps each of stdout/stderr at _OUT_LIMIT bytes WHILE the child runs, kills the DIRECT + child on timeout or overflow (no descendant-containment claim), and returns bounded diagnostics. + Returns (returncode, stdout, stderr, reason) where reason is None on a clean exit, else + 'timeout' / 'stdout_overflow' / 'stderr_overflow'.""" + proc = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + bufs = {"stdout": bytearray(), "stderr": bytearray()} + over: dict[str, str | None] = {"which": None} + lock = threading.Lock() + + def pump(name: str, pipe: Any) -> None: + try: + while True: + chunk = pipe.read(4096) + if not chunk: + return + with lock: + buf = bufs[name] + room = _OUT_LIMIT - len(buf) + if room > 0: + buf.extend(chunk[:room]) + if len(chunk) > room: + over["which"] = over["which"] or name + try: + proc.kill() + except OSError: + pass + return + except (OSError, ValueError): + return + + threads = [threading.Thread(target=pump, args=(n, p), daemon=True) + for n, p in (("stdout", proc.stdout), ("stderr", proc.stderr))] + for t in threads: + t.start() + reason: str | None = None + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + reason = "timeout" + try: + proc.kill() + except OSError: + pass + proc.wait() + for t in threads: + t.join(2) + if reason is None and over["which"]: + reason = f"{over['which']}_overflow" + return proc.returncode, bytes(bufs["stdout"]), bytes(bufs["stderr"]), reason + + def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel: str, - preimage: str, postimage: str, slots_dir: str, target: str, - selected_class: str, bind_params: dict[str, Any]) -> dict[str, Any]: + preimage: str, postimage: str, slots_dir: str, target: str, selected_class: str, + bind_params: dict[str, Any], convert_ids: list[str]) -> dict[str, Any]: core = os.path.join(work, "bind") os.makedirs(core, exist_ok=True) pre_path = os.path.join(core, "pre.cs") @@ -322,24 +416,92 @@ def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel probe_dll, "bind", "--preimage", pre_path, "--postimage", post_path, "--slots-dir", slots_dir, "--target", target, "--selected-class", selected_class, "--source-file", rel, "--bind-params", params_path, "--out", out_path] - proc = subprocess.run(argv, cwd=core, env=_probe_env(work, core), - capture_output=True, text=True, check=False) - if proc.returncode in _BIND_EXIT: - raise TargetError(_BIND_EXIT[proc.returncode], f"bind: {proc.stderr.strip()[:300]}") - if proc.returncode != 0: - raise TargetError(INFRASTRUCTURE, f"bind failed (rc={proc.returncode}): " - f"{proc.stderr.strip()[:300]}") + rc, _out, err, reason = _run_child(argv, core, _probe_env(work, core), _CHILD_TIMEOUT_SECONDS) + if reason is not None: + raise TargetError(INFRASTRUCTURE, f"bind {reason}") + err_text = err.decode("utf-8", "replace").strip()[:300] + if rc in _BIND_EXIT: + raise TargetError(_BIND_EXIT[rc], f"bind: {err_text}") + if rc != 0: + raise TargetError(INFRASTRUCTURE, f"bind failed (rc={rc}): {err_text}") try: with open(out_path, "rb") as fh: raw = fh.read() - binding = json.loads(raw) - except (OSError, ValueError) as exc: + except OSError as exc: raise TargetError(INFRASTRUCTURE, f"binding-result.json unreadable ({exc})") from exc + if len(raw) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, "binding-result.json too large") + try: + binding = json.loads(raw) + except ValueError as exc: + raise TargetError(INFRASTRUCTURE, f"binding-result.json is not valid JSON ({exc})") from exc if _canonical_bytes(binding) != raw: raise TargetError(INFRASTRUCTURE, "binding-result.json is not canonical bytes") + _validate_binding_result(binding, convert_ids) return cast("dict[str, Any]", binding) +_BINDING_KEYS = ("version", "operation", "converted_callsites", "derived_wrapper_ordinal", + "resolved_wrapper", "callsite_binding", "callsites") +_BINDING_RW_KEYS = ("assembly_simple_name", "module_mvid", "metadata_token", "resolved_signature") +_BINDING_CB_KEYS = ("all_callsites_same_symbol", "target_is_source_defined") +_BINDING_CS_KEYS = ("finding_id", "preimage_span", "postimage_span", "assembly_simple_name", + "module_mvid", "metadata_token", "resolved_signature") + + +def _is_int(x: Any) -> bool: + return isinstance(x, int) and not isinstance(x, bool) + + +def _validate_binding_result(obj: Any, convert_ids: list[str]) -> None: + """Strict canonical binding-result.json validation (H4). A malformed shape/type is + INFRASTRUCTURE; a well-formed but semantically incomplete / non-bijective binding is + CALLSITE_BINDING.""" + inf = INFRASTRUCTURE + if not isinstance(obj, dict) or set(obj) != set(_BINDING_KEYS): + raise TargetError(inf, "binding-result.json is not the exact schema") + if obj["version"] != 1 or obj["operation"] != "weak-target-bind": + raise TargetError(inf, "binding-result.json version/operation wrong") + if not _is_int(obj["converted_callsites"]): + raise TargetError(inf, "converted_callsites must be an int") + if not _is_int(obj["derived_wrapper_ordinal"]) or obj["derived_wrapper_ordinal"] < 0: + raise TargetError(inf, "derived_wrapper_ordinal must be a non-negative int") + rw = obj["resolved_wrapper"] + if not isinstance(rw, dict) or set(rw) != set(_BINDING_RW_KEYS) \ + or not all(isinstance(rw[k], str) for k in _BINDING_RW_KEYS): + raise TargetError(inf, "resolved_wrapper is not the exact schema") + cb = obj["callsite_binding"] + if not isinstance(cb, dict) or set(cb) != set(_BINDING_CB_KEYS) \ + or not all(isinstance(cb[k], bool) for k in _BINDING_CB_KEYS): + raise TargetError(inf, "callsite_binding is not the exact schema") + cs = obj["callsites"] + if not isinstance(cs, list): + raise TargetError(inf, "callsites must be a list") + fids, spans = [], [] + for c in cs: + if not isinstance(c, dict) or set(c) != set(_BINDING_CS_KEYS): + raise TargetError(inf, "a callsite is not the exact schema") + if not isinstance(c["finding_id"], str): + raise TargetError(inf, "callsite finding_id must be a string") + for sk in ("preimage_span", "postimage_span"): + sp = c[sk] + if not isinstance(sp, list) or len(sp) != 2 \ + or not all(_is_int(v) and v >= 0 for v in sp): + raise TargetError(inf, f"callsite {sk} must be two non-negative ints") + if any(c[k] != rw[k] for k in _BINDING_RW_KEYS): + raise TargetError(inf, "a callsite identity does not equal resolved_wrapper") + fids.append(c["finding_id"]) + spans.append((c["postimage_span"][0], c["postimage_span"][1])) + if fids != sorted(fids): + raise TargetError(inf, "callsites are not sorted by finding_id") + want = set(convert_ids) + if obj["converted_callsites"] != len(want) or len(cs) != len(want) \ + or set(fids) != want or len(set(fids)) != len(fids): + raise TargetError(CALLSITE_BINDING, "callsites are not a bijection onto the converted ids") + if len(set(spans)) != len(spans): + raise TargetError(CALLSITE_BINDING, "two callsites share a postimage span") + + def _probe_env(work: str, cwd_dir: str) -> dict[str, str]: env: dict[str, str] = {} for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"): @@ -377,29 +539,49 @@ def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver: probe_dll, "probe", "--wrapper-ordinal", str(wrapper_ordinal), "--slots-dir", slots_dir, "--runtime-dir", runtime_dir, "--attempt", str(attempt), "--target", target, "--out", out_path] - try: - proc = subprocess.run(argv, cwd=os.path.join(work, "probe"), env=_probe_env(work, adir), - capture_output=True, timeout=_CHILD_TIMEOUT_SECONDS, check=False) - except subprocess.TimeoutExpired: - raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} timed out") from None - if len(proc.stdout) > _OUT_LIMIT or len(proc.stderr) > _OUT_LIMIT: - raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} output overflow") - if proc.returncode == 10: + rc, _out, _err, reason = _run_child(argv, os.path.join(work, "probe"), + _probe_env(work, adir), _CHILD_TIMEOUT_SECONDS) + if reason is not None: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} {reason}") + if rc not in (0, 10): + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={rc}") + obj = _read_probe_json(out_path, attempt) + if rc == 10: + _validate_unsupported_result(obj, attempt) # exit 10 needs the exact unsupported schema return 10, None - if proc.returncode != 0: - raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} rc={proc.returncode}") + _validate_probe_result(obj, attempt) + return 0, obj + + +def _read_probe_json(out_path: str, attempt: int) -> Any: try: with open(out_path, "rb") as fh: raw = fh.read() - if len(raw) > _OUT_LIMIT: - raise TargetError(INFRASTRUCTURE, "probe-result.json too large") + except OSError as exc: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result unreadable ({exc})") \ + from exc + if len(raw) > _OUT_LIMIT: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result too large") + try: obj = json.loads(raw) - except (OSError, ValueError) as exc: - raise TargetError(INFRASTRUCTURE, f"probe-result.json unreadable ({exc})") from exc + except ValueError as exc: + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not JSON ({exc})") \ + from exc if _canonical_bytes(obj) != raw: - raise TargetError(INFRASTRUCTURE, "probe-result.json is not canonical bytes") - _validate_probe_result(obj, attempt) - return 0, obj + raise TargetError(INFRASTRUCTURE, f"probe attempt {attempt} result not canonical") + return obj + + +_UNSUPPORTED_KEYS = ("version", "operation", "attempt", "runtime_unsupported", "reason") + + +def _validate_unsupported_result(obj: Any, attempt: int) -> None: + if not isinstance(obj, dict) or set(obj) != set(_UNSUPPORTED_KEYS): + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result is not the exact schema") + if obj["version"] != 1 or obj["operation"] != "weak-target-probe" or obj["attempt"] != attempt: + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result version/operation/attempt") + if obj["runtime_unsupported"] is not True or not isinstance(obj["reason"], str): + raise TargetError(INFRASTRUCTURE, "runtime-unsupported result flag/reason wrong") def _validate_probe_result(obj: Any, attempt: int) -> None: @@ -412,11 +594,24 @@ def _validate_probe_result(obj: Any, attempt: int) -> None: "threw_on_post_collection_raise"): if not isinstance(obj[k], bool): raise TargetError(INFRASTRUCTURE, f"probe-result.{k} must be a boolean") - if not isinstance(obj["delivered_count"], int) or isinstance(obj["delivered_count"], bool): + if not _is_int(obj["delivered_count"]): raise TargetError(INFRASTRUCTURE, "probe-result.delivered_count must be an int") rw = obj["resolved_wrapper"] if not isinstance(rw, dict) or set(rw) != set(_RESOLVED_KEYS): raise TargetError(INFRASTRUCTURE, "probe-result.resolved_wrapper is not the exact schema") + if not _is_int(rw["ordinal"]) or rw["ordinal"] < 0: + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.ordinal must be a non-negative int") + for k in ("slot_sha256", "assembly_simple_name", "module_mvid", "metadata_token", + "resolved_signature"): + if not isinstance(rw[k], str): + raise TargetError(INFRASTRUCTURE, f"resolved_wrapper.{k} must be a string") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", rw["slot_sha256"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.slot_sha256 is not a lowercase sha256") + if not re.fullmatch(r"0x[0-9a-f]{8}", rw["metadata_token"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.metadata_token is malformed") + if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + rw["module_mvid"]): + raise TargetError(INFRASTRUCTURE, "resolved_wrapper.module_mvid is not a GUID") def _attempt_verdict(p: dict[str, Any]) -> str: @@ -739,9 +934,7 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s dotnet_host, dotnet_host_sha) # before bind binding = run_bind(work, dotnet_host, probe_dll_dst, selected_ver, rel, bundle_info["preimage"], bundle_info["postimage"], slots_root, - target, class_fqn, bind_params) - if binding["converted_callsites"] != len(convert_ids): - raise TargetError(CALLSITE_BINDING, "bound callsite count != converted candidates") + target, class_fqn, bind_params, convert_ids) if not (0 <= wrapper_ordinal < len(slot_evidence)): raise TargetError(INPUT_LAYOUT, "--wrapper-ordinal is out of range") if binding["derived_wrapper_ordinal"] != wrapper_ordinal: From a44368c9482bae57ff104d2bbd4577b1e66c9c71 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 23:01:48 +0500 Subject: [PATCH 13/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20K1?= =?UTF-8?q?=20drift=20categories=20+=20K2=20closed-schema=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K1: source / change.patch / apply-manifest.json / accepted postimage drift AFTER the initial binding must be ISOLATION (a startup mismatch stays DELTA_BINDING via bind_bundle). K2: every JSON integer field is validated with the exact-int helper (a bool is not an int), version==1 and attempt==the expected non-negative int; the consumed Step 10 nested objects (analysis_scope, input_hashes, target_api, expected, resolved_runtime_identity, each reference_closure entry) are closed key sets -> DELTA_BINDING. RED against the current fix_target, which classifies source/patch/manifest/postimage drift as DELTA_BINDING, accepts version=true / attempt=false via `!= 1` / `!= attempt` (bool == int in Python), and only presence-checks the nested delta shapes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target.py | 84 ++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py index e86a8afc..5df8dadc 100644 --- a/tests/test_verify_target.py +++ b/tests/test_verify_target.py @@ -71,7 +71,9 @@ def _make_delta(cands_bytes: bytes, plan_bytes: bytes, auth, manifest_sha: str, pre_sha: str, post_sha: str, ref_closure: list) -> bytes: d = { "schema": 1, "operation": "verify-subscription-analyzer-delta", "status": "pass", - "analysis_scope": {"source_file": _REL, "target_file_identity": _REL}, + "analysis_scope": {"source_file": _REL, "selected_class": "N.A", + "closure_kind": "single-file", "reference_dir_count": 0, + "target_file_identity": _REL}, "input_hashes": { "input_bundle_sha256": auth.input_bundle_sha256, "validated_plan_sha256": _sha_bytes(plan_bytes), @@ -308,6 +310,7 @@ def _boom(*_a, **_k): _h3_isolation(check) _h3_revalidation(check) _h3_cleanup_and_publish(check) + _k1_initial_vs_drift(check) _h4_validators(check) _h4_run_child(check) _h4_bind_delta(check) @@ -372,13 +375,15 @@ def _h3_revalidation(check) -> None: check(True, "reval-inputs: no drift passes") except ft.TargetError: check(False, "reval-inputs: false drift") + # locked K1 categories: plan/candidates drift -> AUTHORITY_BINDING, delta -> DELTA_BINDING, + # source/patch/manifest/postimage drift AFTER binding -> ISOLATION. drifts = [(paths["plan.json"], ft.AUTHORITY_BINDING, "plan"), (paths["candidates.json"], ft.AUTHORITY_BINDING, "candidates"), (paths["delta.json"], ft.DELTA_BINDING, "delta"), - (os.path.join(root, *parts), ft.DELTA_BINDING, "source"), - (os.path.join(bundle, "change.patch"), ft.DELTA_BINDING, "patch"), - (os.path.join(bundle, "apply-manifest.json"), ft.DELTA_BINDING, "manifest"), - (os.path.join(bundle, "postimage", *parts), ft.DELTA_BINDING, "postimage")] + (os.path.join(root, *parts), ft.ISOLATION, "source"), + (os.path.join(bundle, "change.patch"), ft.ISOLATION, "patch"), + (os.path.join(bundle, "apply-manifest.json"), ft.ISOLATION, "manifest"), + (os.path.join(bundle, "postimage", *parts), ft.ISOLATION, "postimage")] for path, cat, label in drifts: original = _reads(path) with open(path, "ab") as fh: @@ -523,6 +528,43 @@ def _probe_result(**over) -> dict: return r +def _k1_initial_vs_drift(check) -> None: + """K1: a startup mismatch against the accepted delta is DELTA_BINDING (bind_bundle); the same + file changing AFTER the initial binding is ISOLATION (_reval_inputs).""" + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + delta = ft.bind_delta(delta_bytes, auth, plan_b, cand_b) + parts = _REL.split("/") + man = os.path.join(bundle, "apply-manifest.json") + ih = {"pre_sha256": _sha_bytes(_reads(os.path.join(root, *parts))), + "patch_sha256": _sha_bytes(_reads(os.path.join(bundle, "change.patch"))), + "apply_manifest_sha256": _sha_bytes(_reads(man)), + "post_sha256": _sha_bytes(_reads(os.path.join(bundle, "postimage", *parts)))} + args = (paths["plan.json"], paths["candidates.json"], paths["delta.json"], root, bundle, + _REL, plan_b, cand_b, delta_bytes, ih) + for path, label in ((os.path.join(root, *parts), "source"), + (os.path.join(bundle, "change.patch"), "patch"), + (man, "manifest"), + (os.path.join(bundle, "postimage", *parts), "postimage")): + original = _reads(path) + with open(path, "ab") as fh: + fh.write(b"// x\n") + # startup: the accepted bundle no longer binds the delta -> DELTA_BINDING + check(_raises(ft.DELTA_BINDING, ft.bind_bundle, bundle, root, _REL, delta), + f"{label}: initial hash mismatch -> DELTA_BINDING") + # after binding: the same drift during execution -> ISOLATION + check(_raises(ft.ISOLATION, ft._reval_inputs, *args), + f"{label}: post-binding drift -> ISOLATION") + with open(path, "wb") as fh: + fh.write(original) + + def _h4_validators(check) -> None: ids = ["OWN001:sha256:" + "1" * 64, "OWN001:sha256:" + "2" * 64] try: @@ -547,6 +589,10 @@ def _h4_validators(check) -> None: b["callsites"][0]["module_mvid"] = "different" check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), "binding-result: callsite identity != resolved_wrapper -> INFRASTRUCTURE") + b = _binding_result(ids) + b["version"] = True + check(_raises(ft.INFRASTRUCTURE, ft._validate_binding_result, b, ids), + "binding-result: version=true (bool) -> INFRASTRUCTURE") # well-formed but non-bijective / duplicate span -> CALLSITE_BINDING check(_raises(ft.CALLSITE_BINDING, ft._validate_binding_result, _binding_result(ids), ids[:1]), "binding-result: not a bijection -> CALLSITE_BINDING") @@ -577,6 +623,10 @@ def _h4_validators(check) -> None: bad["resolved_wrapper"]["module_mvid"] = "not-a-guid" check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, bad, 0), "probe-result: malformed mvid -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, _probe_result(version=True), 0), + "probe-result: version=true (bool) -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_probe_result, _probe_result(attempt=False), 0), + "probe-result: attempt=false for attempt 0 -> INFRASTRUCTURE") # runtime-unsupported result schema (gate for accepting child exit 10) good = {"version": 1, "operation": "weak-target-probe", "attempt": 0, @@ -593,6 +643,9 @@ def _h4_validators(check) -> None: "unsupported-result: flag not true -> INFRASTRUCTURE") check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, good, 1), "unsupported-result: wrong attempt -> INFRASTRUCTURE") + check(_raises(ft.INFRASTRUCTURE, ft._validate_unsupported_result, + {**good, "attempt": True}, 1), + "unsupported-result: attempt=true for attempt 1 -> INFRASTRUCTURE") def _h4_run_child(check) -> None: @@ -641,6 +694,27 @@ def _h4_bind_delta(check) -> None: d["toolchain_fingerprint"]["resolved_runtime_identity"]["tfm"] = 9 check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), "bind_delta: wrong-typed runtime identity -> DELTA_BINDING") + # exact closed nested key sets: an extra key in a consumed nested object is DELTA_BINDING. + for pointer, label in ( + (("input_hashes",), "input_hashes"), + (("target_api",), "target_api"), + (("toolchain_fingerprint", "resolved_runtime_identity"), "runtime-identity"), + (("analysis_scope",), "analysis_scope"), + (("expected",), "expected"), + ): + d = json.loads(delta_bytes) + node = d + for key in pointer: + node = node[key] + node["surprise"] = 1 + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), + auth, plan_b, cand_b), + f"bind_delta: extra {label} key -> DELTA_BINDING") + # a malformed reference_closure entry (missing keys) is DELTA_BINDING, not a KeyError. + d = json.loads(delta_bytes) + d["reference_closure"] = [{"ordinal": 0, "relative_path": "W.dll"}] + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), auth, plan_b, cand_b), + "bind_delta: malformed reference_closure entry -> DELTA_BINDING") if __name__ == "__main__": From f0af45c8d6b834a805915ede59a3ea478224949f Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 23:01:48 +0500 Subject: [PATCH 14/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20K1?= =?UTF-8?q?=20drift=20categories=20+=20K2=20closed-schema=20(fix=5Ftarget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K1: _reval_inputs classifies post-binding drift of the pristine source / change.patch / apply-manifest.json / accepted postimage as ISOLATION; plan/candidates stay AUTHORITY_BINDING, the delta stays DELTA_BINDING, and the initial hash mismatch in bind_bundle stays DELTA_BINDING. K2: the exact-int helper (isinstance int and not bool) now guards binding-result, probe-result, and runtime-unsupported version + attempt (version must be integer 1, attempt the exact expected non-negative integer — a boolean is rejected). bind_delta validates the EXACT frozen key sets of every consumed Step 10 nested object (analysis_scope, input_hashes, target_api, expected, resolved_runtime_identity, and each reference_closure entry) -> DELTA_BINDING, never a KeyError. The frozen Step 10 producer/schema is unchanged. Tier A 91/91. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_target.py | 57 +++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index 7dd789ad..a686a272 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -132,31 +132,49 @@ def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any "apply_manifest_sha256", "patch_sha256", "pre_sha256", "post_sha256") _DELTA_RID_KEYS = ("framework_name", "tfm", "requested_framework_version", "selected_framework_version", "runtime_manifest_sha256") +_DELTA_SCOPE_KEYS = ("source_file", "selected_class", "closure_kind", "reference_dir_count", + "target_file_identity") +_DELTA_TAPI_KEYS = ("subscribe",) +_DELTA_EXP_KEYS = ("convert_acquire_ids", "manual_review_ids") +_DELTA_RC_KEYS = ("ordinal", "source_dir_ordinal", "relative_path", "sha256") def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None: - """Validate the exact frozen shapes of the Step 10 delta fields Step 11 consumes, so a - malformed/missing field is DELTA_BINDING rather than a KeyError/INFRASTRUCTURE (H4).""" + """Validate the EXACT frozen shapes of the Step 10 delta fields Step 11 consumes — closed key + sets, so an extra/missing/wrong-typed nested field is DELTA_BINDING, never a KeyError or an + accidental INFRASTRUCTURE (K2). This validates Step 10's actual frozen schema; it does not + modify the frozen producer.""" ih = d["input_hashes"] - if not isinstance(ih, dict) or not all(isinstance(ih.get(k), str) for k in _DELTA_IH_KEYS): + if not isinstance(ih, dict) or set(ih) != set(_DELTA_IH_KEYS) \ + or not all(isinstance(ih[k], str) for k in _DELTA_IH_KEYS): raise TargetError(cat, "delta-result.json input_hashes shape is wrong") scope = d["analysis_scope"] - if not isinstance(scope, dict) or not isinstance(scope.get("source_file"), str) \ - or not isinstance(scope.get("target_file_identity"), str): + if not isinstance(scope, dict) or set(scope) != set(_DELTA_SCOPE_KEYS) \ + or not isinstance(scope["source_file"], str) \ + or not isinstance(scope["target_file_identity"], str): raise TargetError(cat, "delta-result.json analysis_scope shape is wrong") tfp = d["toolchain_fingerprint"] rid = tfp.get("resolved_runtime_identity") if isinstance(tfp, dict) else None - if not isinstance(rid, dict) or not all(isinstance(rid.get(k), str) for k in _DELTA_RID_KEYS): + if not isinstance(rid, dict) or set(rid) != set(_DELTA_RID_KEYS) \ + or not all(isinstance(rid[k], str) for k in _DELTA_RID_KEYS): raise TargetError(cat, "delta-result.json resolved_runtime_identity shape is wrong") tapi = d["target_api"] - if not isinstance(tapi, dict) or not isinstance(tapi.get("subscribe"), str): + if not isinstance(tapi, dict) or set(tapi) != set(_DELTA_TAPI_KEYS) \ + or not isinstance(tapi["subscribe"], str): raise TargetError(cat, "delta-result.json target_api shape is wrong") exp = d["expected"] - if not isinstance(exp, dict) or not isinstance(exp.get("convert_acquire_ids"), list) \ - or not isinstance(exp.get("manual_review_ids"), list): + if not isinstance(exp, dict) or set(exp) != set(_DELTA_EXP_KEYS) \ + or not isinstance(exp["convert_acquire_ids"], list) \ + or not isinstance(exp["manual_review_ids"], list): raise TargetError(cat, "delta-result.json expected shape is wrong") - if not isinstance(d["reference_closure"], list): + rc = d["reference_closure"] + if not isinstance(rc, list): raise TargetError(cat, "delta-result.json reference_closure shape is wrong") + for ent in rc: + if not isinstance(ent, dict) or set(ent) != set(_DELTA_RC_KEYS) \ + or not _is_int(ent["ordinal"]) or not _is_int(ent["source_dir_ordinal"]) \ + or not isinstance(ent["relative_path"], str) or not isinstance(ent["sha256"], str): + raise TargetError(cat, "delta-result.json reference_closure entry is malformed") def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, @@ -460,7 +478,7 @@ def _validate_binding_result(obj: Any, convert_ids: list[str]) -> None: inf = INFRASTRUCTURE if not isinstance(obj, dict) or set(obj) != set(_BINDING_KEYS): raise TargetError(inf, "binding-result.json is not the exact schema") - if obj["version"] != 1 or obj["operation"] != "weak-target-bind": + if not _is_int(obj["version"]) or obj["version"] != 1 or obj["operation"] != "weak-target-bind": raise TargetError(inf, "binding-result.json version/operation wrong") if not _is_int(obj["converted_callsites"]): raise TargetError(inf, "converted_callsites must be an int") @@ -578,7 +596,9 @@ def _read_probe_json(out_path: str, attempt: int) -> Any: def _validate_unsupported_result(obj: Any, attempt: int) -> None: if not isinstance(obj, dict) or set(obj) != set(_UNSUPPORTED_KEYS): raise TargetError(INFRASTRUCTURE, "runtime-unsupported result is not the exact schema") - if obj["version"] != 1 or obj["operation"] != "weak-target-probe" or obj["attempt"] != attempt: + if not _is_int(obj["version"]) or obj["version"] != 1 \ + or obj["operation"] != "weak-target-probe" \ + or not _is_int(obj["attempt"]) or obj["attempt"] != attempt: raise TargetError(INFRASTRUCTURE, "runtime-unsupported result version/operation/attempt") if obj["runtime_unsupported"] is not True or not isinstance(obj["reason"], str): raise TargetError(INFRASTRUCTURE, "runtime-unsupported result flag/reason wrong") @@ -587,7 +607,9 @@ def _validate_unsupported_result(obj: Any, attempt: int) -> None: def _validate_probe_result(obj: Any, attempt: int) -> None: if not isinstance(obj, dict) or set(obj) != set(_PROBE_KEYS): raise TargetError(INFRASTRUCTURE, "probe-result.json is not the exact schema") - if obj["version"] != 1 or obj["operation"] != "weak-target-probe" or obj["attempt"] != attempt: + if not _is_int(obj["version"]) or obj["version"] != 1 \ + or obj["operation"] != "weak-target-probe" \ + or not _is_int(obj["attempt"]) or obj["attempt"] != attempt: raise TargetError(INFRASTRUCTURE, "probe-result.json version/operation/attempt wrong") for k in ("strong_delivered_once", "strong_retained", "weak_control_collected", "threw_on_subscribe", "threw_on_first_raise", "subscriber_collected", @@ -805,7 +827,10 @@ def _reval_inputs(plan_path: str, candidates_path: str, delta_path: str, root: s rel: str, plan_bytes: bytes, candidates_bytes: bytes, delta_bytes: bytes, input_hashes: dict[str, str]) -> None: """Revalidate every authoritative input still equals what was bound — before constructing the - published evidence (G5): plan, candidates, delta, source, patch, manifest, postimage.""" + published evidence (G5). The locked drift categories: plan/candidates -> AUTHORITY_BINDING, the + delta -> DELTA_BINDING, and the pristine source / patch / manifest / accepted postimage changing + AFTER the initial binding -> ISOLATION (the initial hash mismatch, in bind_bundle, stays + DELTA_BINDING; here we are proving nothing drifted while we executed).""" if _snapshot(plan_path, INPUT_LAYOUT, "--plan") != plan_bytes: raise TargetError(AUTHORITY_BINDING, "--plan changed during verification") if _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") != candidates_bytes: @@ -820,8 +845,8 @@ def _reval_inputs(plan_path: str, candidates_path: str, delta_path: str, root: s (manifest_path, "apply_manifest_sha256", "apply-manifest.json"), (post_path, "post_sha256", "the accepted postimage"), ): - if _sha_bytes(_snapshot(path, DELTA_BINDING, label)) != input_hashes[key]: - raise TargetError(DELTA_BINDING, f"{label} changed during verification") + if _sha_bytes(_snapshot(path, ISOLATION, label)) != input_hashes[key]: + raise TargetError(ISOLATION, f"{label} changed after the initial binding") def _execution_root(protected: list[str]) -> str: From 964507c75f9a857a6bc8608259ac6727b1f47e77 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 23:01:49 +0500 Subject: [PATCH 15/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20K3?= =?UTF-8?q?=20WRAPPER=5FRUNTIME=5FUNSUPPORTED=20needs=20a=20positive=20pre?= =?UTF-8?q?dicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bind-unit regressions: an exact-shape net8 wrapper whose callsite cannot bind for a NON-runtime reason — a receiver that cannot convert to INotifyPropertyChanged, or an ambiguous / non-convertible handler method group — must be CALLSITE_BINDING, not WRAPPER_RUNTIME_UNSUPPORTED. RED against the current bind, which returns WRAPPER_RUNTIME_UNSUPPORTED for ANY shape-correct single-candidate slot whose callsite fails to bind, inferring incompatibility instead of positively establishing it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target_tierb.py | 58 +++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py index 74a762d6..575d106c 100644 --- a/tests/test_verify_target_tierb.py +++ b/tests/test_verify_target_tierb.py @@ -470,6 +470,7 @@ def check(cond: bool, label: str) -> None: _run_case(dotnet, ext, probe, work, c, check) _determinism(dotnet, ext, probe, work, check) _run_ambiguous(dotnet, ext, probe, work, check) + _run_bind_unit_cases(dotnet, ext, probe, work, check) _run_dep_cases(dotnet, ext, probe, work, check) _run_two_slot_versions(dotnet, ext, probe, work, check) except Fail as exc: @@ -598,6 +599,63 @@ def _run_ambiguous(dotnet: str, ext: str, probe: str, work: str, check) -> None: _expect_result(p, out, ("refuse", "CALLSITE_BINDING"), check, "ambiguous") +# K3: an exact-shape net8 wrapper with a callsite that cannot bind for a NON-runtime reason must be +# CALLSITE_BINDING (never WRAPPER_RUNTIME_UNSUPPORTED). These are bind-unit cases: the postimage is +# crafted so Roslyn's SemanticModel returns a null Symbol, exercising the positive-predicate branch. +_PRE_OBJ = ("using System.ComponentModel;\npublic class S { public S(object a) " + "{ a.PropertyChanged += OnA; } void OnA(object s, PropertyChangedEventArgs e){} }\n") +_POST_OBJ = ("using System.ComponentModel;\npublic class S { public S(object a) " + "{ WeakEvents.AddPropertyChanged(a, OnA); } " + "void OnA(object s, PropertyChangedEventArgs e){} }\n") +_PRE_BADH = ("using System.ComponentModel;\npublic class S { public S(INotifyPropertyChanged a) " + "{ a.PropertyChanged += OnA; } void OnA(int x){} void OnA(string y){} }\n") +_POST_BADH = ("using System.ComponentModel;\npublic class S { public S(INotifyPropertyChanged a) " + "{ WeakEvents.AddPropertyChanged(a, OnA); } " + "void OnA(int x){} void OnA(string y){} }\n") + + +def _bind_unit(dotnet: str, probe: str, work: str, name: str, pre: str, post: str, + wrapper: str, expect_cat: str, check) -> None: + from ownlang.fix_delta import _select_runtime + from ownlang.fix_target import TargetError, run_bind + try: + dll = _compile(dotnet, work, f"bu-{name}", wrapper, "net8.0", "WeakEvents") + slot = os.path.join(work, f"buslots-{name}", "000000") + os.makedirs(slot) + shutil.copy(dll, os.path.join(slot, "WeakEvents.dll")) + slots_root = os.path.dirname(slot) + listing = _run([dotnet, "--list-runtimes"]).stdout + selected_ver, _rt = _select_runtime(listing, "Microsoft.NETCore.App", "8.0.0") + start = pre.index("a.PropertyChanged += OnA") + fid = "OWN001:sha256:" + "a" * 64 + bind_params = {"converted": [{"finding_id": fid, "occurrence_ordinal": 0, "file": "S.cs", + "containing_type": "S", "event": "PropertyChanged", + "source": "a", "handler": "OnA", "normalized_handler": "OnA", + "acquire_span": {"start": start, + "length": len("a.PropertyChanged += OnA")}}]} + w = os.path.join(work, f"buw-{name}") + os.makedirs(w) + except Fail as exc: + check(False, f"bind-unit {name}: setup failed ({exc})") + return + try: + run_bind(w, dotnet, probe, selected_ver, "S.cs", pre, post, slots_root, + "WeakEvents.AddPropertyChanged", "S", bind_params, [fid]) + check(False, f"bind-unit {name}: expected {expect_cat} but bind passed") + except TargetError as exc: + check(exc.category == expect_cat, + f"bind-unit {name}: {exc.category} (want {expect_cat})") + + +def _run_bind_unit_cases(dotnet: str, ext: str, probe: str, work: str, check) -> None: + # exact-shape net8 wrapper, receiver cannot convert to INPC -> CALLSITE_BINDING (not runtime) + _bind_unit(dotnet, probe, work, "argconv", _PRE_OBJ, _POST_OBJ, _WEAK, + "CALLSITE_BINDING", check) + # exact-shape net8 wrapper, ambiguous / non-convertible handler method group -> CALLSITE_BINDING + _bind_unit(dotnet, probe, work, "badhandler", _PRE_BADH, _POST_BADH, _WEAK, + "CALLSITE_BINDING", check) + + def _run_dep_cases(dotnet: str, ext: str, probe: str, work: str, check) -> None: """H2: the closed load context. A wrapper dependency resolves ONLY from a materialized slot; it is never satisfied from the probe deployment, the default context, or an arbitrary path.""" From 1045ec8f2e2c2bb44b05feb93ff15fbffd46221e Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 23:01:49 +0500 Subject: [PATCH 16/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20po?= =?UTF-8?q?sitive=20runtime-incompat=20predicate=20in=20bind=20(K3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WRAPPER_RUNTIME_UNSUPPORTED is now returned ONLY when a deterministic positive predicate holds: the wrapper assembly references a core framework assembly (System.Runtime / System.Private.CoreLib / netstandard / mscorlib) at a version STRICTLY NEWER than the one the selected probe runtime provides to the bind compilation (via System.Object's containing assembly). A shape-correct single-candidate wrapper whose callsite merely fails to bind for another reason (argument-conversion failure, method-group ambiguity, source-compilation failure) is CALLSITE_BINDING; a wrong frozen shape stays WRAPPER_BINDING; ambiguous / multiple candidate assemblies stay CALLSITE_BINDING. A clean IMethodSymbol is still required for success and CandidateSymbols remain diagnostic-only. A net9 wrapper still refuses WRAPPER_RUNTIME_UNSUPPORTED, now positively established. Tier B 54/54. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe/Program.cs | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs index 26742fd1..6c0d969e 100644 --- a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -319,11 +319,12 @@ private static string ReadMvid(string dllPath) } // Symbol == null: choose the refusal category WITHOUT ever promoting a candidate to the bound - // symbol. With exactly one candidate wrapper slot: a wrong frozen shape is WRAPPER_BINDING; a - // shape that is correct by name yet still fails to bind means the wrapper's metadata/types do - // NOT unify with the fixed probe runtime (e.g. a net9 / .NET-Framework-only wrapper), which is - // WRAPPER_RUNTIME_UNSUPPORTED (never TARGET_RETAINS). Anything else — ambiguous, multiple, or - // otherwise unresolved — is CALLSITE_BINDING. + // symbol. With exactly one candidate wrapper slot: a wrong frozen shape is WRAPPER_BINDING; + // WRAPPER_RUNTIME_UNSUPPORTED is returned ONLY when a POSITIVE predicate establishes the wrapper + // targets a core framework NEWER than the selected probe runtime (metadata/runtime + // incompatibility) — never inferred from "one candidate + correct textual shape". Every other + // unresolved callsite (argument conversion failure, method-group ambiguity, multiple / ambiguous + // assemblies, source-compilation failure) is CALLSITE_BINDING. private static int RefuseUnbound(string fid, SymbolInfo si, CSharpCompilation comp, Dictionary slotByPath, string target) { @@ -338,14 +339,35 @@ private static int RefuseUnbound(string fid, SymbolInfo si, CSharpCompilation co { var path = (comp.GetMetadataReference(asms[0]) as PortableExecutableReference)?.FilePath ?? ""; if (slotByPath.ContainsKey(path)) - return TargetBinding.Validate(asms[0], target) is not null - ? Refuse("WRAPPER_BINDING", $"{fid}: the wrapper target has the wrong frozen shape") - : Refuse("WRAPPER_RUNTIME_UNSUPPORTED", - $"{fid}: the wrapper target does not unify with the selected probe runtime"); + { + if (TargetBinding.Validate(asms[0], target) is not null) + return Refuse("WRAPPER_BINDING", $"{fid}: the wrapper target has the wrong frozen shape"); + if (RuntimeIncompatible(comp, asms[0])) + return Refuse("WRAPPER_RUNTIME_UNSUPPORTED", + $"{fid}: the wrapper targets a core framework newer than the selected probe runtime"); + } } return Refuse("CALLSITE_BINDING", $"{fid}: the invocation does not bind to a single wrapper symbol"); } + // The POSITIVE runtime-incompatibility predicate (K3): the wrapper assembly references a core + // framework assembly at a version STRICTLY NEWER than the one the selected probe runtime provides + // to this compilation. This is a deterministic metadata comparison, not an inference from a + // failed overload — a shape-correct wrapper that merely fails to bind for another reason is NOT + // runtime-incompatible. + private static readonly HashSet CoreFramework = new(StringComparer.OrdinalIgnoreCase) + { "System.Runtime", "System.Private.CoreLib", "netstandard", "mscorlib" }; + + private static bool RuntimeIncompatible(CSharpCompilation comp, IAssemblySymbol wrapperAsm) + { + var compCore = comp.GetSpecialType(SpecialType.System_Object).ContainingAssembly.Identity.Version; + foreach (var mod in wrapperAsm.Modules) + foreach (var r in mod.ReferencedAssemblies) + if (CoreFramework.Contains(r.Name) && r.Version > compCore) + return true; + return false; + } + private static int Refuse(string category, string message) { Console.Error.WriteLine($"{category}: {message}"); From 5f0984a922ca7fd9b7040baa793314c3dadf0599 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Sat, 18 Jul 2026 02:34:55 +0500 Subject: [PATCH 17/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20L1?= =?UTF-8?q?=20positive=20runtime-FAMILY=20incompatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tier B regressions: a .NET Framework 4.x wrapper (references mscorlib) under the net8 probe is refused WRAPPER_RUNTIME_UNSUPPORTED (cross-family), while a compatible netstandard2.0 wrapper is NOT rejected by the runtime-family predicate and continues to binding/probe (passes). Also decodes child output as UTF-8 (a localized MSBuild/NuGet line must not crash the reader thread) and sets LangVersion=latest so net48/netstandard2.0 compile the nullable wrapper sources; net4x pulls in the cross-platform reference assemblies. RED against the current bind: a net48 wrapper binds cleanly (its types retarget onto the net8 refs) and reaches the probe, so the version-only predicate never sees the mscorlib cross-family reference. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target_tierb.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_verify_target_tierb.py b/tests/test_verify_target_tierb.py index 575d106c..5b375b5e 100644 --- a/tests/test_verify_target_tierb.py +++ b/tests/test_verify_target_tierb.py @@ -240,6 +240,12 @@ "probe": True, "expect": ("pass", "two")}, {"name": "incompat", "pre": _PRE, "wrapper": _INCOMPAT, "tfm": "net9.0", "convert": True, "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + # a .NET Framework 4.x wrapper references mscorlib -> cross-family incompatibility (L1). + {"name": "netfx", "pre": _PRE, "wrapper": _STRONG, "tfm": "net48", "convert": True, + "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, + # a compatible netstandard2.0 wrapper is NOT rejected by the runtime-family predicate. + {"name": "netstandard", "pre": _PRE, "wrapper": _WEAK, "tfm": "netstandard2.0", "convert": True, + "ref": True, "probe": True, "expect": ("pass", "converted")}, {"name": "missingdep", "pre": _PRE, "wrapper": _MISSINGDEP, "helper": _HELPER, "convert": True, "ref": True, "probe": True, "expect": ("refuse", "WRAPPER_RUNTIME_UNSUPPORTED")}, @@ -258,7 +264,10 @@ def _sha(b: bytes) -> str: def _run(argv: list[str], cwd: str | None = None, env: dict | None = None) -> subprocess.CompletedProcess: - return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False, env=env) + # decode as UTF-8 with replacement — a localized MSBuild / NuGet-restore line (e.g. under a + # non-UTF-8 Windows console codepage) must never crash the reader thread. + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False, env=env, + encoding="utf-8", errors="replace") def _py(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess: @@ -291,13 +300,18 @@ def _build(dotnet: str) -> tuple[str, str]: def _csproj(tfm: str, refs: list[tuple[str, str]], asmname: str, pkgs: list[tuple[str, str]] | None = None) -> str: + pkgs = list(pkgs or []) + if tfm.startswith("net4"): # .NET Framework targets need the cross-platform ref assemblies + pkgs.append(("Microsoft.NETFramework.ReferenceAssemblies", "1.0.3")) items = "".join(f'{h}' for n, h in refs) - items += "".join(f'' - for n, v in (pkgs or [])) + items += "".join(f'' for n, v in pkgs) grp = f"{items}" if items else "" + # LangVersion latest: net48 / netstandard2.0 default to C# 7.3, where Nullable=enable and the + # nullable annotations in the wrapper sources are an error. return (f'' f'{tfm}enable' + f'latest' f'{asmname}true' f'{grp}') From 4cc5737bad853a2f018ae3ac6b262f75b349c679 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Sat, 18 Jul 2026 02:34:55 +0500 Subject: [PATCH 18/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20po?= =?UTF-8?q?sitive=20runtime-FAMILY=20predicate=20in=20bind=20(L1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuntimeIncompatible now positively establishes an incompatible runtime FAMILY as well as a newer same-family contract: it reads the selected probe runtime's core family from the compilation (System.Object's containing assembly — System.Private.CoreLib for CoreCLR) and refuses WRAPPER_RUNTIME_UNSUPPORTED when the wrapper references mscorlib under a CoreCLR probe (or System.Private.CoreLib under a non-CoreCLR probe), a System.Runtime / System.Private.CoreLib contract at a version strictly newer than the runtime supplies, or carries a TargetFrameworkAttribute that positively declares an incompatible family. A netstandard reference is never a reason to reject. The check runs on BOTH the Symbol==null path and the cleanly-bound path (a net48 wrapper retargets onto the net8 refs and binds), so it is refused before probing. Argument-conversion failures, ambiguous handler groups, source-defined targets and multiple assemblies remain CALLSITE_BINDING; a wrong frozen shape remains WRAPPER_BINDING. Tier B 65/65. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .../OwnSharp.WeakTargetProbe/Program.cs | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs index 6c0d969e..b90d2916 100644 --- a/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs +++ b/frontend/roslyn/OwnSharp.WeakTargetProbe/Program.cs @@ -203,6 +203,12 @@ public static int Run(string[] args) // overload, no ref/optional/params/modopt) — any mismatch is WRAPPER_BINDING. var terr = TargetBinding.Validate(sym.ContainingAssembly, target); if (terr is not null) return Refuse("WRAPPER_BINDING", $"{e.Fid}: {terr}"); + // even a CLEANLY BOUND wrapper may target an incompatible runtime family (e.g. a + // .NET Framework mscorlib wrapper whose types retarget onto the net8 refs) — the + // positive runtime-family predicate refuses it before probing (L1). + if (RuntimeIncompatible(comp, sym.ContainingAssembly)) + return Refuse("WRAPPER_RUNTIME_UNSUPPORTED", + $"{e.Fid}: the wrapper targets a runtime family/version incompatible with the probe runtime"); derivedOrdinal = slot.Ordinal; asmName = sym.ContainingAssembly.Name; mvid = ReadMvid(path); @@ -350,24 +356,51 @@ private static int RefuseUnbound(string fid, SymbolInfo si, CSharpCompilation co return Refuse("CALLSITE_BINDING", $"{fid}: the invocation does not bind to a single wrapper symbol"); } - // The POSITIVE runtime-incompatibility predicate (K3): the wrapper assembly references a core - // framework assembly at a version STRICTLY NEWER than the one the selected probe runtime provides - // to this compilation. This is a deterministic metadata comparison, not an inference from a - // failed overload — a shape-correct wrapper that merely fails to bind for another reason is NOT - // runtime-incompatible. - private static readonly HashSet CoreFramework = new(StringComparer.OrdinalIgnoreCase) - { "System.Runtime", "System.Private.CoreLib", "netstandard", "mscorlib" }; + // The POSITIVE runtime-compatibility predicate (K3 / L1): a deterministic metadata comparison, + // never an inference from a failed overload. It positively establishes BOTH an incompatible + // runtime FAMILY (a .NET-Framework `mscorlib` wrapper under a CoreCLR probe, or a CoreCLR + // `System.Private.CoreLib` wrapper under a non-CoreCLR probe) AND a newer SAME-family runtime + // contract (a core contract at a version strictly newer than the selected runtime supplies), plus + // a TargetFrameworkAttribute that positively declares an incompatible family. A netstandard + // reference is NEVER a reason to reject (netstandard is a compatibility contract, not a family). + private static readonly HashSet SameFamilyContract = new(StringComparer.OrdinalIgnoreCase) + { "System.Runtime", "System.Private.CoreLib" }; private static bool RuntimeIncompatible(CSharpCompilation comp, IAssemblySymbol wrapperAsm) { - var compCore = comp.GetSpecialType(SpecialType.System_Object).ContainingAssembly.Identity.Version; + var coreAsm = comp.GetSpecialType(SpecialType.System_Object).ContainingAssembly; + var runtimeVersion = coreAsm.Identity.Version; + var runtimeIsCoreClr = string.Equals(coreAsm.Name, "System.Private.CoreLib", + StringComparison.OrdinalIgnoreCase); foreach (var mod in wrapperAsm.Modules) foreach (var r in mod.ReferencedAssemblies) - if (CoreFramework.Contains(r.Name) && r.Version > compCore) - return true; + { + if (string.Equals(r.Name, "mscorlib", StringComparison.OrdinalIgnoreCase) + && runtimeIsCoreClr) + return true; // .NET Framework wrapper under a CoreCLR probe + if (string.Equals(r.Name, "System.Private.CoreLib", StringComparison.OrdinalIgnoreCase) + && !runtimeIsCoreClr) + return true; // CoreCLR wrapper under a non-CoreCLR probe + if (SameFamilyContract.Contains(r.Name) && r.Version > runtimeVersion) + return true; // a same-family core contract strictly newer than the runtime + } + foreach (var attr in wrapperAsm.GetAttributes()) + if (attr.AttributeClass?.ToDisplayString() + == "System.Runtime.Versioning.TargetFrameworkAttribute" + && attr.ConstructorArguments.Length > 0 + && attr.ConstructorArguments[0].Value is string moniker + && MonikerFamilyIncompatible(moniker, runtimeIsCoreClr)) + return true; return false; } + private static bool MonikerFamilyIncompatible(string moniker, bool runtimeIsCoreClr) + { + var isFramework = moniker.StartsWith(".NETFramework", StringComparison.OrdinalIgnoreCase); + var isCoreApp = moniker.StartsWith(".NETCoreApp", StringComparison.OrdinalIgnoreCase); + return (runtimeIsCoreClr && isFramework) || (!runtimeIsCoreClr && isCoreApp); + } + private static int Refuse(string category, string message) { Console.Error.WriteLine($"{category}: {message}"); From e1578a767e067a1927b58f2a21d8439d2c21a852 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Sat, 18 Jul 2026 02:34:55 +0500 Subject: [PATCH 19/20] =?UTF-8?q?test(s2-step11):=20red=20=E2=80=94=20L2?= =?UTF-8?q?=20exact=20consumed-schema=20+=20L3=20strict=20failure=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 Tier A regressions: delta schema=true, reference_dir_count=false, non-string selected_class / closure_kind, boolean / negative reference ordinal, non-string expected id, malformed input / reference SHA, and a duplicate / unsorted reference ordinal are all DELTA_BINDING. L3 Tier A regressions: an ordinary controlled failure after the execution root is claimed re-raises its original category on successful cleanup, becomes PUBLICATION on a cleanup failure (and likewise for an isolation-after-creation failure), and leaves OUTPUT_DIR absent in every case. RED against the current fix_target, which presence-checks (not SHA / ordinal-order / exact-int) the consumed Step 10 shapes and cleans up the work root best-effort (except OSError: pass) so a cleanup failure is silently swallowed rather than PUBLICATION. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- tests/test_verify_target.py | 111 +++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/tests/test_verify_target.py b/tests/test_verify_target.py index 5df8dadc..bdfbc075 100644 --- a/tests/test_verify_target.py +++ b/tests/test_verify_target.py @@ -314,6 +314,8 @@ def _boom(*_a, **_k): _h4_validators(check) _h4_run_child(check) _h4_bind_delta(check) + _l2_schema(check) + _l3_cleanup(check) total = ok + bad print(f"verify-target (Tier A): {ok}/{total} checks pass") @@ -442,7 +444,7 @@ def _h3_revalidation(check) -> None: def _h3_cleanup_and_publish(check) -> None: - # --- _remove_root: a cleanup failure is PUBLICATION --- + # --- _remove_root_strict: a cleanup failure is PUBLICATION --- with tempfile.TemporaryDirectory() as tmp: d = os.path.join(tmp, "work") os.makedirs(d) @@ -453,7 +455,7 @@ def _boom(*_a, **_k): try: ft.shutil.rmtree = _boom - check(_raises(ft.PUBLICATION, ft._remove_root, d), + check(_raises(ft.PUBLICATION, ft._remove_root_strict, d), "remove-root: cleanup failure -> PUBLICATION") finally: ft.shutil.rmtree = orig @@ -717,5 +719,110 @@ def _h4_bind_delta(check) -> None: "bind_delta: malformed reference_closure entry -> DELTA_BINDING") +def _l2_schema(check) -> None: + """L2: the exact consumed Step 10 schema — bool-vs-int, value types, SHA form, ordinal order.""" + with tempfile.TemporaryDirectory() as tmp: + _root, _bundle, paths, delta_bytes = _manual_fixture(tmp) + with open(paths["candidates.json"], encoding="utf-8") as fh: + cands = json.load(fh) + with open(paths["plan.json"], encoding="utf-8") as fh: + plan = json.load(fh) + auth = validate_gate_authority(plan, cands) + plan_b, cand_b = _reads(paths["plan.json"]), _reads(paths["candidates.json"]) + rc0 = {"ordinal": 0, "source_dir_ordinal": 0, "relative_path": "W.dll", + "sha256": "sha256:" + "a" * 64} + + def run_bad(fn, label: str) -> None: + d = json.loads(delta_bytes) + fn(d) + check(_raises(ft.DELTA_BINDING, ft.bind_delta, _canonical_bytes(d), + auth, plan_b, cand_b), label) + + run_bad(lambda d: d.update(schema=True), "L2: schema=true -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(reference_dir_count=False), + "L2: reference_dir_count=false -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(selected_class=1), + "L2: selected_class non-string -> DELTA_BINDING") + run_bad(lambda d: d["analysis_scope"].update(closure_kind=1), + "L2: closure_kind non-string -> DELTA_BINDING") + run_bad(lambda d: d["expected"].update(manual_review_ids=[1]), + "L2: non-string expected id -> DELTA_BINDING") + run_bad(lambda d: d["input_hashes"].update(pre_sha256="sha256:ZZ"), + "L2: malformed input hash -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "ordinal": True}]), + "L2: boolean reference ordinal -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "ordinal": -1}]), + "L2: negative reference ordinal -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[{**rc0, "sha256": "sha256:zz"}]), + "L2: malformed reference sha -> DELTA_BINDING") + run_bad(lambda d: d.update(reference_closure=[dict(rc0), {**rc0, "ordinal": 0}]), + "L2: duplicate / unsorted reference ordinal -> DELTA_BINDING") + # positive control: a valid, ordered two-entry closure binds. + d = json.loads(delta_bytes) + d["reference_closure"] = [rc0, {"ordinal": 1, "source_dir_ordinal": 1, + "relative_path": "B.dll", "sha256": "sha256:" + "b" * 64}] + try: + ft.bind_delta(_canonical_bytes(d), auth, plan_b, cand_b) + check(True, "L2: a valid ordered two-entry closure binds") + except ft.TargetError: + check(False, "L2: false rejection of a valid closure") + + +def _l3_cleanup(check) -> None: + """L3: strict failure-path cleanup — successful cleanup re-raises the original refusal, a + cleanup failure is PUBLICATION, and OUTPUT_DIR is always absent (no ignore_errors).""" + # (a) a controlled failure after the execution root is claimed, cleanup succeeds -> original + # category; (b) same with a cleanup failure -> PUBLICATION. Both leave the out-dir absent. + with tempfile.TemporaryDirectory() as tmp: + root, bundle, paths, _delta = _manual_fixture(tmp) + refdir = os.path.join(tmp, "refs") # a ref-dir the delta's empty closure will not match + os.makedirs(refdir) + with open(os.path.join(refdir, "X.dll"), "wb") as fh: + fh.write(b"MZ") + out_a = os.path.join(tmp, "out-a") + check(_raises(ft.REFERENCE_BINDING, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, out_a, [refdir], None), + "L3: failure + successful cleanup -> original category") + check(not os.path.exists(out_a), "L3: out-dir absent after the original-category failure") + + out_b = os.path.join(tmp, "out-b") + orig = ft.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("locked") + + try: + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft.run_verify_target, bundle, root, paths["plan.json"], + paths["candidates.json"], paths["delta.json"], None, out_b, + [refdir], None), + "L3: failure + execution-root cleanup failure -> PUBLICATION") + finally: + ft.shutil.rmtree = orig + check(not os.path.exists(out_b), "L3: out-dir absent after the cleanup-failure PUBLICATION") + + # (c) a protected root resolves inside the execution root: cleanup success -> ISOLATION; + # cleanup failure -> PUBLICATION. + with tempfile.TemporaryDirectory() as tmp: + base = os.path.join(tmp, "base") + prot = os.path.join(base, "prot") + os.makedirs(prot) + orig_mkdtemp, orig_rmtree = ft.tempfile.mkdtemp, ft.shutil.rmtree + try: + ft.tempfile.mkdtemp = lambda *a, **k: base + check(_raises(ft.ISOLATION, ft._execution_root, [prot]), + "L3: protected-inside-root + cleanup success -> ISOLATION") + os.makedirs(prot, exist_ok=True) + + def _boom(*_a, **_k): + raise OSError("locked") + + ft.shutil.rmtree = _boom + check(_raises(ft.PUBLICATION, ft._execution_root, [prot]), + "L3: protected-inside-root + cleanup failure -> PUBLICATION") + finally: + ft.tempfile.mkdtemp, ft.shutil.rmtree = orig_mkdtemp, orig_rmtree + + if __name__ == "__main__": raise SystemExit(run()) From c7ab5e4fafcb7c2d0bc0f3eedcd96b8976294469 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Sat, 18 Jul 2026 02:34:56 +0500 Subject: [PATCH 20/20] =?UTF-8?q?feat(s2-step11):=20green=20=E2=80=94=20ex?= =?UTF-8?q?act=20consumed-schema=20(L2)=20+=20strict=20failure=20cleanup?= =?UTF-8?q?=20(L3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2: _bind_delta_shapes validates the exact consumed Step 10 schema — an exact-int helper for delta.schema(==1), analysis_scope.reference_dir_count(>=0) and every reference_closure ordinal (>=0); string types for the analysis_scope fields; a list-of-strings for the expected id lists; the `sha256:<64 hex>` form for every consumed digest (input_hashes, reference_closure sha256, runtime_manifest_sha256); and reference_closure entries in exact ordinal order with no duplicate. Any violation is DELTA_BINDING. L3: the single _remove_root_strict is the ONLY execution-root removal, used by the success path, the isolation-after-creation path, and the failure path. On any pre-publication failure the work root is removed without swallowing OSError; a successful cleanup re-raises the original controlled refusal, a cleanup failure is PUBLICATION (chained from the cleanup error), and OUTPUT_DIR stays absent. Every ignore_errors and `except OSError: pass` is gone. Tier A 108/108. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_target.py | 63 ++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/ownlang/fix_target.py b/ownlang/fix_target.py index a686a272..6ad885ac 100644 --- a/ownlang/fix_target.py +++ b/ownlang/fix_target.py @@ -139,24 +139,32 @@ def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any _DELTA_RC_KEYS = ("ordinal", "source_dir_ordinal", "relative_path", "sha256") +def _is_sha(v: Any) -> bool: + return isinstance(v, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", v) is not None + + def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None: """Validate the EXACT frozen shapes of the Step 10 delta fields Step 11 consumes — closed key - sets, so an extra/missing/wrong-typed nested field is DELTA_BINDING, never a KeyError or an - accidental INFRASTRUCTURE (K2). This validates Step 10's actual frozen schema; it does not + sets, exact value types, exact-int (a bool is not an int) for every consumed integer, the + `sha256:<64 hex>` form for every consumed digest, and reference-closure entries in exact ordinal + order with no duplicate ordinal. Any violation is DELTA_BINDING, never a KeyError or an + accidental INFRASTRUCTURE (K2 / L2). This validates Step 10's actual frozen schema; it does not modify the frozen producer.""" ih = d["input_hashes"] if not isinstance(ih, dict) or set(ih) != set(_DELTA_IH_KEYS) \ - or not all(isinstance(ih[k], str) for k in _DELTA_IH_KEYS): + or not all(_is_sha(ih[k]) for k in _DELTA_IH_KEYS): raise TargetError(cat, "delta-result.json input_hashes shape is wrong") scope = d["analysis_scope"] if not isinstance(scope, dict) or set(scope) != set(_DELTA_SCOPE_KEYS) \ - or not isinstance(scope["source_file"], str) \ - or not isinstance(scope["target_file_identity"], str): + or not all(isinstance(scope[k], str) for k in + ("source_file", "selected_class", "closure_kind", "target_file_identity")) \ + or not _is_int(scope["reference_dir_count"]) or scope["reference_dir_count"] < 0: raise TargetError(cat, "delta-result.json analysis_scope shape is wrong") tfp = d["toolchain_fingerprint"] rid = tfp.get("resolved_runtime_identity") if isinstance(tfp, dict) else None if not isinstance(rid, dict) or set(rid) != set(_DELTA_RID_KEYS) \ - or not all(isinstance(rid[k], str) for k in _DELTA_RID_KEYS): + or not all(isinstance(rid[k], str) for k in _DELTA_RID_KEYS) \ + or not _is_sha(rid["runtime_manifest_sha256"]): raise TargetError(cat, "delta-result.json resolved_runtime_identity shape is wrong") tapi = d["target_api"] if not isinstance(tapi, dict) or set(tapi) != set(_DELTA_TAPI_KEYS) \ @@ -164,17 +172,20 @@ def _bind_delta_shapes(d: dict[str, Any], cat: str) -> None: raise TargetError(cat, "delta-result.json target_api shape is wrong") exp = d["expected"] if not isinstance(exp, dict) or set(exp) != set(_DELTA_EXP_KEYS) \ - or not isinstance(exp["convert_acquire_ids"], list) \ - or not isinstance(exp["manual_review_ids"], list): + or not all(isinstance(exp[k], list) and all(isinstance(x, str) for x in exp[k]) + for k in _DELTA_EXP_KEYS): raise TargetError(cat, "delta-result.json expected shape is wrong") rc = d["reference_closure"] if not isinstance(rc, list): raise TargetError(cat, "delta-result.json reference_closure shape is wrong") - for ent in rc: + for i, ent in enumerate(rc): if not isinstance(ent, dict) or set(ent) != set(_DELTA_RC_KEYS) \ - or not _is_int(ent["ordinal"]) or not _is_int(ent["source_dir_ordinal"]) \ - or not isinstance(ent["relative_path"], str) or not isinstance(ent["sha256"], str): + or not _is_int(ent["ordinal"]) or ent["ordinal"] < 0 \ + or not _is_int(ent["source_dir_ordinal"]) or ent["source_dir_ordinal"] < 0 \ + or not isinstance(ent["relative_path"], str) or not _is_sha(ent["sha256"]): raise TargetError(cat, "delta-result.json reference_closure entry is malformed") + if ent["ordinal"] != i: # exact ordinal order, no duplicate / gap / reorder + raise TargetError(cat, "delta-result.json reference_closure ordinal is out of order") def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, @@ -193,7 +204,8 @@ def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes, if not isinstance(d, dict) or set(d) != _DELTA_TOP_KEYS: raise TargetError(cat, "delta-result.json has unknown or missing top-level keys") _bind_delta_shapes(d, cat) - if d.get("schema") != 1 or d.get("operation") != "verify-subscription-analyzer-delta" \ + if not _is_int(d["schema"]) or d["schema"] != 1 \ + or d.get("operation") != "verify-subscription-analyzer-delta" \ or d.get("status") != "pass": raise TargetError(cat, "delta-result.json schema/operation/status is wrong") checks = d.get("checks") @@ -862,22 +874,16 @@ def _execution_root(protected: list[str]) -> str: rp = os.path.realpath(root) for pr in prots: if _same_or_inside(rp, pr): - _discard_root(root) + _remove_root_strict(root) # cleanup failure -> PUBLICATION (chained); else ISOLATION raise TargetError(ISOLATION, "a protected root resolves inside the execution root") return root -def _discard_root(work: str) -> None: - """Best-effort cleanup on the FAILURE path (never masks the original refusal).""" - try: - shutil.rmtree(work) - except OSError: - pass - - -def _remove_root(work: str) -> None: - """Strict cleanup on the SUCCESS path, before publication: a failure is PUBLICATION and the - out-dir stays absent (G5).""" +def _remove_root_strict(work: str) -> None: + """The ONLY execution-root removal (L3) — used by the success path, the isolation-after-creation + path, and the failure path. It removes EXECUTION_WORK_ROOT without swallowing OSError; a cleanup + failure is PUBLICATION, chained from the cleanup error, with the out-dir left absent (private + residue may remain, but no public partial artifact exists). No ignore_errors / except pass.""" try: shutil.rmtree(work) except OSError as exc: @@ -938,7 +944,7 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s passed.add("publication") evidence_bytes = _canonical( build_manual_only_result(input_hashes, delta_bytes, delta, target, passed)) - _remove_root(work) + _remove_root_strict(work) work_removed = True return _publish_target(out, publish_protected, evidence_bytes) @@ -989,12 +995,15 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s evidence_bytes = _canonical(build_converted_result( input_hashes, delta_bytes, delta, target, slot_evidence, wrapper_ordinal, binding, probe_fp, dotnet_host_sha, dotnet_version, runtime_identity, attempts, passed)) - _remove_root(work) # G5: remove EXECUTION_WORK_ROOT before publication + _remove_root_strict(work) # G5: remove EXECUTION_WORK_ROOT before publication work_removed = True # one atomic rename; NO filesystem operation runs after it succeeds. return _publish_target(out, publish_protected, evidence_bytes) except BaseException: + # L3: on any pre-publication failure, remove the work root strictly. If cleanup succeeds the + # original controlled refusal is re-raised; if cleanup FAILS, _remove_root_strict raises + # PUBLICATION (chained from the cleanup error) and the out-dir stays absent. No swallowing. if not work_removed: - _discard_root(work) + _remove_root_strict(work) raise