Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c8bafba
ci(mine): echo report.md + findings.txt to the job log
claude Jun 17, 2026
226a2af
ci(mine): push-triggered miner driven by a sentinel target file
claude Jun 17, 2026
51683b6
ci(mine): point the sentinel at JoshClose/CsvHelper
claude Jun 17, 2026
5ef5652
ci(mine): probe Linux WPF reference assemblies + target ScreenToGif
claude Jun 17, 2026
45a340d
feat(extractor): load extra reference assemblies via OWN_EXTRA_REF_DI…
claude Jun 17, 2026
7a2e879
docs(p-004): bank milestone-1 mining run — real finding as regression…
claude Jun 17, 2026
2ea4d93
feat(extractor): self-owned subscription exemption now covers ref/out…
claude Jun 17, 2026
70af820
chore(dev): re-mine ScreenToGif (WPF profile) to confirm the self-own…
claude Jun 17, 2026
ab8f39c
docs(p-004): record the verified self-owned-control fix result (123 -…
claude Jun 17, 2026
a722ecb
test(corpus): lock the ScreenToGif SystemEvents leak as a real-world …
claude Jun 17, 2026
7e6ca46
chore(dev): push-triggerable oracle + run it on ScreenToGif (SystemEv…
claude Jun 17, 2026
1beb01d
fix(oracle): blobless clone so an abbreviated SHA ref resolves (re-ru…
claude Jun 17, 2026
85cdded
fix(oracle): parse multi-line/untagged own-check findings + give own-…
claude Jun 17, 2026
1246b56
docs(p-004): record the cross-tool oracle result (CodeQL misses the s…
claude Jun 17, 2026
0d31e10
test(oracle): Linux-buildable SystemEvents fixture so all three tools…
claude Jun 17, 2026
1f467a9
docs(p-004): the cross-tool 2x2 — Infer# also misses the subscription…
claude Jun 17, 2026
80dd30e
feat(extractor): lower `for` loops in the flow detector (close the fo…
claude Jun 17, 2026
d3d25c2
fix(mine_report): only absorb multi-line continuations while brackets…
claude Jun 17, 2026
afe8676
fix(extractor): narrow ref/out self-owned to same-class helpers + fie…
claude Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ jobs:
frontend/roslyn/samples/PooledBufferSample.cs \
frontend/roslyn/samples/LocalDisposableSample.cs \
frontend/roslyn/samples/SelfOwnedViewModel.cs \
frontend/roslyn/samples/SelfOwnedControlParts.cs \
frontend/roslyn/samples/ExternalRefSubscription.cs \
frontend/roslyn/samples/StaticHandlerViewModel.cs \
frontend/roslyn/samples/SampleTypes.cs \
-o "$RUNNER_TEMP/facts.json"
Expand Down Expand Up @@ -215,6 +217,18 @@ jobs:
if echo "$out" | grep -q "SelfOwnedViewModel.cs"; then
echo "FAIL: a self-owned subscription was wrongly reported"; exit 1
fi
# P-004 self-owned (extended): a field built indirectly via a `ref`/`out`
# helper, or fetched as one of the control's own template parts
# (GetTemplateChild), is owned just like a `new`'d field — both
# subscriptions in SelfOwnedControlParts are collectable cycles -> silent.
if echo "$out" | grep -q "SelfOwnedControlParts.cs"; then
echo "FAIL: a self-owned (ref-built / template-part) subscription was wrongly reported"; exit 1
fi
# P-004 (ref/out narrowing, Codex P2): a field populated by an EXTERNAL
# class's ref method (not this class's own helper) is NOT self-owned — the
# subscription must still be reported, not silently suppressed.
echo "$out" | grep -qE "ExternalRefSubscription\.cs:[0-9]+: warning: \[OWN001\]" \
|| { echo "FAIL: expected OWN001 on the external-ref subscription (must not be exempted)"; exit 1; }
# P-004 static-handler exemption: a static-method handler has a null
# delegate target — no instance retained, so not a leak — silent.
if echo "$out" | grep -q "StaticHandlerViewModel.cs"; then
Expand All @@ -241,21 +255,23 @@ jobs:
|| { echo "FAIL: expected the never-disposed wording for the 0-release Timer"; exit 1; }
echo "$out" | grep -qE "'leak' may not be disposed on every path" \
|| { echo "FAIL: expected the partial-path wording for LeakOnElse"; exit 1; }
# P-016 A1 reached the frontend: `while`/`foreach` bodies are now lowered
# (not skipped), so a per-iteration leak in one is caught.
# P-016 A1 reached the frontend: `while`/`foreach`/`for` bodies are now
# lowered (not skipped), so a per-iteration leak in one is caught.
echo "$out" | grep -qE "OWN001.*'whileLeak' is never disposed" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a while loop"; exit 1; }
echo "$out" | grep -qE "OWN001.*'foreachLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a foreach loop"; exit 1; }
# dispose-optional (Task), disposed/escaping locals, a `for` loop (still
# skipped: `looped`), and a balanced acquire+dispose in a loop (`whileClean`)
# must stay silent:
echo "$out" | grep -qE "OWN001.*'forLeak'" \
|| { echo "FAIL: expected OWN001 on the undisposed local in a for loop"; exit 1; }
# dispose-optional (Task), disposed/escaping locals, a `for` loop whose
# disposable is disposed after it (`looped`, balanced), and a balanced
# acquire+dispose in a loop (`whileClean`) must stay silent:
# released via `await x.DisposeAsync()` (asyncDisposed) and the chained
# `.ConfigureAwait(false)` form (asyncDisposedCfg) -> both must stay silent.
for ok in clean looped esc exemptTask whileClean asyncDisposed asyncDisposedCfg; do
if echo "$out" | grep -q "'$ok'"; then echo "FAIL: silent/exempt case '$ok' was reported"; exit 1; fi
done
echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach, never-vs-every-path wording, dispose-optional exempt, beyond flat)"
echo "OK: flow-sensitive OWN001/002/003 on real C# (path-sensitive, loops via while/foreach/for, never-vs-every-path wording, dispose-optional exempt, beyond flat)"
- name: Coverage summary (--stats)
run: |
# --stats prints a flow-locals coverage line to stderr and stamps the same
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/mine-on-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: mine (on push)

# Autonomous corpus mining for the eval loop. When corpus/mine-target.txt changes
# on the dev branch, clone the named public C# repo and run the Own.NET leak check
# over it — same tooling as mine.yml (docs/notes/mining.md), but push-triggered so
# the loop needs no manual workflow_dispatch. The target is read from the committed
# sentinel file and ALLOWLIST-validated before use (never interpolated raw into a
# shell; passed to the miner via env). One repo per run. Dev-branch only — remove
# before merging to main.

on:
push:
branches:
- claude/zen-pasteur-76hfs1
paths:
- corpus/mine-target.txt

permissions:
contents: read

jobs:
mine:
name: mine (sentinel)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Materialize WPF reference assemblies (WPF profile)
run: |
# The WindowsDesktop ref pack isn't in the Linux SDK, but a net8.0-windows
# /UseWPF stub restores it cross-platform (EnableWindowsTargeting). Export
# the ref dir so the extractor (OWN_EXTRA_REF_DIRS) can resolve WPF events
# and timers instead of dropping them as OWN050.
tmp=$(mktemp -d)
printf '%s\n' \
'<Project Sdk="Microsoft.NET.Sdk">' \
' <PropertyGroup>' \
' <TargetFramework>net8.0-windows</TargetFramework>' \
' <UseWPF>true</UseWPF>' \
' <EnableWindowsTargeting>true</EnableWindowsTargeting>' \
' </PropertyGroup>' \
'</Project>' > "$tmp/wpfref.csproj"
dotnet restore "$tmp/wpfref.csproj" >/dev/null 2>&1 || echo "wpf-ref restore failed (continuing without)"
d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true)
if [ -n "$d" ]; then
echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV"
echo "WPF refs ready: $d ($(find "$d" -name '*.dll' | wc -l) dlls)"
else
echo "WPF refs NOT found — the mine will run without them (OWN050 on framework types)"
fi
- name: Read & validate the target
id: target
run: |
file=corpus/mine-target.txt
# First non-comment, non-empty line: "owner/repo" or an https git URL.
target=$(grep -vE '^[[:space:]]*(#|$)' "$file" | head -1 | tr -d '[:space:]')
ref=$(grep -E '^ref=' "$file" | head -1 | sed 's/^ref=//' | tr -d '[:space:]')
paths=$(grep -E '^paths=' "$file" | head -1 | sed 's/^paths=//' | tr -d '[:space:]')
# Allowlist: GitHub owner/repo, or an https git URL. Reject anything else
# so a stray sentinel value can't smuggle shell or an odd scheme.
if ! [[ "$target" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$target" =~ ^https://[A-Za-z0-9./_-]+$ ]]; then
echo "mine-on-push: invalid target '$target' in $file" >&2; exit 2
fi
{ echo "target=$target"; echo "ref=$ref"; echo "paths=$paths"; } >> "$GITHUB_OUTPUT"
echo "mine-on-push: $target (ref='${ref:-HEAD}' paths='${paths:-*}')"
- name: Mine the target
env:
REPO: ${{ steps.target.outputs.target }}
REF: ${{ steps.target.outputs.ref }}
PATHS: ${{ steps.target.outputs.paths }}
run: |
args=()
[[ -n "$REF" ]] && args+=(--ref "$REF")
[[ -n "$PATHS" ]] && args+=(--paths "$PATHS")
scripts/mine.sh "${args[@]}" "$REPO"
- name: Surface the report (log + summary)
if: always()
run: |
report=$(find corpus/mined -name report.md -type f 2>/dev/null | head -1 || true)
findings=$(find corpus/mined -name findings.txt -type f 2>/dev/null | head -1 || true)
if [[ -n "$report" ]]; then
cat "$report" >> "$GITHUB_STEP_SUMMARY"
echo "::group::report.md"; cat "$report"; echo "::endgroup::"
else
echo "no report produced (see the Mine step log)" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "$findings" ]]; then
echo "::group::findings.txt"; cat "$findings"; echo "::endgroup::"
fi
- name: Upload the report
if: always()
uses: actions/upload-artifact@v4
with:
name: mine-report
path: |
corpus/mined/*/report.md
corpus/mined/*/report.json
corpus/mined/*/findings.txt
corpus/mined/*/extract.log
if-no-files-found: warn
7 changes: 7 additions & 0 deletions .github/workflows/mine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,18 @@ jobs:
if: always()
run: |
report=$(find corpus/mined -name report.md -type f 2>/dev/null | head -1 || true)
findings=$(find corpus/mined -name findings.txt -type f 2>/dev/null | head -1 || true)
if [[ -n "$report" ]]; then
cat "$report" >> "$GITHUB_STEP_SUMMARY"
# Also echo to stdout so the report is readable straight from the job
# log (the API/agent eval loop reads logs, not the summary or artifact).
echo "::group::report.md"; cat "$report"; echo "::endgroup::"
else
echo "no report produced (see the Mine step log)" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "$findings" ]]; then
echo "::group::findings.txt"; cat "$findings"; echo "::endgroup::"
fi
- name: Upload the report
if: always()
uses: actions/upload-artifact@v4
Expand Down
124 changes: 105 additions & 19 deletions .github/workflows/oracle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ on:
required: false
type: boolean
default: false
# Dev-loop fallback (the automation token can't `workflow_dispatch`): bump the
# sentinel corpus/oracle-target.txt to run the oracle push-triggered, reading the
# target from that file. Dev-branch only — remove before merging to main.
push:
branches:
- claude/zen-pasteur-76hfs1
paths:
- corpus/oracle-target.txt

permissions:
contents: read
Expand All @@ -57,35 +65,113 @@ jobs:
with:
dotnet-version: "8.0.x"

# Resolve the target: workflow_dispatch inputs win; on push, read the
# sentinel corpus/oracle-target.txt (same format/allowlist as mine-on-push).
# Everything downstream reads steps.t.outputs.* so both triggers share steps.
- name: Resolve target
id: t
env:
IN_REPO: ${{ inputs.repo }}
IN_REF: ${{ inputs.ref }}
IN_PATHS: ${{ inputs.paths }}
IN_BUILD: ${{ inputs.build }}
IN_TESTS: ${{ inputs.include_tests }}
run: |
file=corpus/oracle-target.txt
if [[ -n "$IN_REPO" ]]; then
repo="$IN_REPO"; ref="$IN_REF"; paths="$IN_PATHS"; build="$IN_BUILD"; tests="$IN_TESTS"
else
repo=$(grep -vE '^[[:space:]]*(#|$)' "$file" | head -1 | tr -d '[:space:]')
ref=$(grep -E '^ref=' "$file" | head -1 | sed 's/^ref=//' | tr -d '[:space:]')
paths=$(grep -E '^paths=' "$file" | head -1 | sed 's/^paths=//' | tr -d '[:space:]')
build=$(grep -E '^build=' "$file" | head -1 | sed 's/^build=//' | tr -d '[:space:]')
tests=$(grep -E '^include_tests=' "$file"| head -1 | sed 's/^include_tests=//' | tr -d '[:space:]')
fi
# Allowlist: GitHub owner/repo, an https URL, or local:<in-repo path>
# (a fixture copied into target/ instead of cloned) — never an odd scheme.
if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ || "$repo" =~ ^https://[A-Za-z0-9./_-]+$ || "$repo" =~ ^local:[A-Za-z0-9._/-]+$ ]]; then
echo "oracle: invalid target '$repo'" >&2; exit 2
fi
{ echo "repo=$repo"; echo "ref=$ref"; echo "paths=$paths";
echo "build=$build"; echo "tests=${tests:-false}"; } >> "$GITHUB_OUTPUT"
echo "oracle target: $repo (ref='${ref:-HEAD}' paths='${paths:-*}' build='${build:-auto}' tests='${tests:-false}')"

# Fast fail: the diff logic is unit-tested before we clone/build anything.
- name: Comparator selftest
run: python scripts/oracle_compare.py --selftest

- name: Clone the target
env:
REPO: ${{ inputs.repo }}
REF: ${{ inputs.ref }}
REPO: ${{ steps.t.outputs.repo }}
REF: ${{ steps.t.outputs.ref }}
run: |
if [[ "$REPO" == local:* ]]; then
# local:<in-repo path> — copy an in-repo fixture into target/ instead of
# cloning, so a tiny buildable repro (where ScreenToGif can't build on
# Linux) lets every oracle — including Infer#, which needs binaries — run.
src="${REPO#local:}"
[[ "$src" != *..* && -d "$src" ]] || { echo "oracle: bad local fixture '$src'" >&2; exit 2; }
cp -r "$src" target
echo "COMMIT=local@$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
echo "using in-repo fixture: $src"
else
case "$REPO" in
http://*|https://*|git@*) url="$REPO" ;;
*) url="https://github.com/${REPO}.git" ;;
esac
if [[ -n "$REF" ]]; then
# A blobless clone keeps the full commit/ref graph (blobs fetched on
# demand), so ANY ref — branch, tag, or an abbreviated SHA — resolves
# locally at checkout. A shallow `fetch origin <short-sha>` does NOT:
# servers reject abbreviated / unadvertised SHAs in a want request.
git clone --filter=blob:none --quiet "$url" target
git -C target checkout --quiet --detach "$REF"
else
git clone --depth 1 --quiet "$url" target
fi
echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV"
fi

# Framework-type references for own-check. The Roslyn extractor resolves a
# `+=` only when the event's declaring type is on its reference set — else the
# subscription is an OWN050 "unchecked" note, not a leak. Materialize the
# WindowsDesktop ref pack (WPF / WinForms / Microsoft.Win32.SystemEvents) and
# export OWN_EXTRA_REF_DIRS so own-check resolves framework events instead of
# dropping them — putting it on equal footing with CodeQL, which resolves
# types from source. Harmless for non-Windows targets (deduped vs the runtime).
- name: Materialize framework reference assemblies
continue-on-error: true
run: |
case "$REPO" in
http://*|https://*|git@*) url="$REPO" ;;
*) url="https://github.com/${REPO}.git" ;;
esac
git clone --depth 1 --quiet "$url" target
if [[ -n "$REF" ]]; then
# --branch only accepts a branch/tag; fetch+checkout also takes a raw SHA.
git -C target fetch --depth 1 --quiet origin "$REF"
git -C target checkout --quiet --detach FETCH_HEAD
tmp=$(mktemp -d)
printf '%s\n' \
'<Project Sdk="Microsoft.NET.Sdk">' \
' <PropertyGroup>' \
' <TargetFramework>net8.0-windows</TargetFramework>' \
' <UseWPF>true</UseWPF>' \
' <UseWindowsForms>true</UseWindowsForms>' \
' <EnableWindowsTargeting>true</EnableWindowsTargeting>' \
' </PropertyGroup>' \
'</Project>' > "$tmp/ref.csproj"
dotnet restore "$tmp/ref.csproj" >/dev/null 2>&1 || echo "ref restore failed (continuing)"
d=$(find "$HOME/.nuget/packages/microsoft.windowsdesktop.app.ref" -type d -name 'net8.0' 2>/dev/null | sort | tail -1 || true)
if [ -n "$d" ]; then
echo "OWN_EXTRA_REF_DIRS=$d" >> "$GITHUB_ENV"
echo "framework refs: $d ($(find "$d" -name '*.dll' | wc -l) dlls)"
else
echo "framework refs not found — own-check resolves runtime types only"
fi
echo "COMMIT=$(git -C target rev-parse HEAD)" >> "$GITHUB_ENV"

# Own.NET — no build needed; scans .cs directly.
# Own.NET — no build needed; scans .cs directly. --severity warning so the
# warning-tier leaks (injected-source subscriptions, e.g. VideoSource) are
# included, not just the provable static-source errors. OWN_EXTRA_REF_DIRS
# (above) is inherited by the extractor process.
- name: Own.NET own-check
env:
PATHS: ${{ inputs.paths }}
PATHS: ${{ steps.t.outputs.paths }}
run: |
scan="target"; [[ -n "$PATHS" ]] && scan="target/$PATHS"
set +e
scripts/own-check.sh --format human -- "$scan" > own.txt 2> own-extract.log
scripts/own-check.sh --format human --severity warning -- "$scan" > own.txt 2> own-extract.log
echo "own-check rc=$? ; $(wc -l < own.txt) finding line(s)"

# CodeQL — database from source (no build). The dispose/leak queries
Expand Down Expand Up @@ -115,8 +201,8 @@ jobs:
# projects); else a lone solution (.sln/.slnx); else the dir (-> partial).
- name: Build the target (for Infer#)
env:
BUILD: ${{ inputs.build }}
REPO: ${{ inputs.repo }}
BUILD: ${{ steps.t.outputs.build }}
REPO: ${{ steps.t.outputs.repo }}
continue-on-error: true
run: |
if [[ -n "$BUILD" ]]; then
Expand Down Expand Up @@ -162,8 +248,8 @@ jobs:
- name: Diff Own.NET vs the oracles
if: always()
env:
REPO: ${{ inputs.repo }}
INCLUDE_TESTS: ${{ inputs.include_tests }}
REPO: ${{ steps.t.outputs.repo }}
INCLUDE_TESTS: ${{ steps.t.outputs.tests }}
run: |
args=(--own own.txt --target "$REPO" --commit "${COMMIT:-}"
--strip "$GITHUB_WORKSPACE/target" --strip target
Expand Down
45 changes: 45 additions & 0 deletions corpus/fixtures/systemevents-console/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.IO;
using Microsoft.Win32;

namespace SystemEventsLeak;

// Two leak CLASSES in one tiny, Linux-buildable program, so the cross-tool oracle
// can run Own.NET, CodeQL AND Infer# over the same code. ScreenToGif (the real
// finding) is WPF and does not build on the Linux oracle runner, so Infer# was
// skipped there; this builds on Linux, so all three tools run.
// See README.md for the expected 2x2.
public sealed class DisplayWatcher
{
// (1) SUBSCRIPTION leak — Own.NET's class. SystemEvents is a static,
// process-lifetime source; subscribing without ever unsubscribing pins the
// subscriber for the life of the process. The RAII / dataflow oracles have no
// "event subscribed, never unsubscribed" query, so they should miss this.
public DisplayWatcher()
{
SystemEvents.DisplaySettingsChanged += OnDisplayChanged;
// ...no `-=` anywhere -> leak
}

private void OnDisplayChanged(object? sender, EventArgs e) { }
}

public static class Program
{
public static void Main()
{
_ = new DisplayWatcher();
LeakAFile();
}

// (2) DISPOSE leak — CodeQL's / Infer#'s class, and the control: a local
// IDisposable never disposed. All three tools should flag this, which proves the
// RAII oracles actually ran on the fixture — so a miss on (1) is a real
// capability gap, not an empty run.
private static void LeakAFile()
{
var stream = new FileStream("scratch.bin", FileMode.Create);
stream.WriteByte(0x42);
// ...no Dispose()/using -> resource leak
}
}
Loading
Loading