Skip to content

Commit 1c577d7

Browse files
PhysShellclaude
andcommitted
feat(S2-1): apply-input gate — validate + candidates↔plan hash binding + source guards
S2 slice 1 (steps 1-3 of the accepted order): the untrusted-input gate that must pass before the later-slice Owen.CSharp.Rewriter is allowed to touch a byte of source. Pure Python; nothing here parses C# or writes files. validate_apply_inputs(validated_plan, candidates, root) takes the TWO hash-bound inputs the arbiter mandated — the validated-plan is the sole authority for `action`; the candidates bundle is the untrusted-but-hash-bound source of the event/source/handler identity + containing type the rewriter's span-node guard will need. It: - validates the validated-plan shape (validate_validated_plan): version/operation, one type/file, canonical paths + SHA form, decisions carry only {finding_id, action, file, acquire_span} with an S1-only action; - re-validates the candidates via the shared validate_candidates_bundle; - BINDS them: bundle_sha256(candidates) == validated_plan.input_bundle_sha256, and target_api / source_files must match; - cross-checks every decision against its candidate: full bijection, exact file + span, action in that candidate's allowed_actions, convert_acquire only on an inotify_property_changed contract; - refuses overlapping convert_acquire spans; - guards the source: root-confined (_resolve_source) + the on-disk bytes must match the pristine preimage SHA (STALE SOURCE refusal) — never fuzzy relocation. Returns an apply context (convert_acquire targets with their candidate identities, the manual_review ids, the source path, the pinned target API) once every guard passes. Every violation is a controlled ApplyError, never a traceback or a partial apply. tests/test_fix_apply.py (18 checks): happy path, hash-binding mismatch, target/file/span disagreement, action-not-allowed, unknown/missing/duplicate decision, overlapping spans, unknown fields, out-of-scope action, stale preimage SHA, uncontained source. ruff + mypy (ownlang) + run_tests green. Next slice: the separate Owen.CSharp.Rewriter (Roslyn span-node identity + deterministic edits + postimage/patch/manifest, steps 4-8). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G
1 parent 139a756 commit 1c577d7

2 files changed

Lines changed: 394 additions & 0 deletions

File tree

‎ownlang/fix_apply.py‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""S2 slice 1 — the strict apply-input gate: validate the S1 validated-plan.json, bind
2+
it by hash to its candidates.json, cross-check every decision against its candidate, and
3+
guard the source file (SHA + root confinement). NOTHING here parses C# or writes a byte;
4+
it is the untrusted-input gate that must pass before the (later-slice) `Owen.CSharp.Rewriter`
5+
is allowed to touch source.
6+
7+
Two hash-bound inputs (arbiter): the validated-plan is the sole authority for `action`;
8+
the candidates bundle is the untrusted-but-hash-bound source of the syntax/semantic
9+
context (event / source / handler identity, containing type) the rewriter's span-node
10+
guard needs. `bundle_sha256(candidates) == validated_plan.input_bundle_sha256` binds them.
11+
A stale source, an escaping path, a decision that disagrees with its candidate, or a
12+
convert_acquire on a non-INotifyPropertyChanged contract is a hard refusal.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import hashlib
18+
from itertools import pairwise
19+
from typing import Any
20+
21+
from ownlang.fix_candidates import (
22+
S1_ACTIONS,
23+
CollectError,
24+
_field,
25+
_require_canonical_relpath,
26+
_require_finding_id,
27+
_require_sha256,
28+
_resolve_source,
29+
_validate_span,
30+
validate_candidates_bundle,
31+
)
32+
from ownlang.fix_plan import bundle_sha256
33+
34+
_PLAN_KEYS = {
35+
"version", "operation", "input_bundle_sha256", "target_api",
36+
"selection", "source_files", "decisions",
37+
}
38+
_DECISION_KEYS = {"finding_id", "action", "file", "acquire_span"}
39+
40+
41+
class ApplyError(Exception):
42+
"""A malformed / unbound / stale apply input. A hard, controlled refusal — never a
43+
traceback, never a partial apply."""
44+
45+
46+
def validate_validated_plan(plan: Any) -> None:
47+
"""Validate the shape of an S1 validated-plan.json (fix_plan.validate_plan's output).
48+
Raises ApplyError on any violation."""
49+
if not isinstance(plan, dict):
50+
raise ApplyError("validated-plan must be an object")
51+
unknown = sorted(set(plan) - _PLAN_KEYS)
52+
if unknown:
53+
raise ApplyError(f"validated-plan has unknown field(s): {unknown}")
54+
if plan.get("version") != 1 or isinstance(plan.get("version"), bool):
55+
raise ApplyError("validated-plan version must be integer 1")
56+
if plan.get("operation") != "fix-subscriptions":
57+
raise ApplyError("validated-plan operation must be 'fix-subscriptions'")
58+
try:
59+
_require_sha256(
60+
_field(plan, "input_bundle_sha256", "str", "validated-plan"),
61+
"validated-plan.input_bundle_sha256",
62+
)
63+
_field(_field(plan, "target_api", "dict", "validated-plan"), "subscribe", "str",
64+
"validated-plan.target_api")
65+
sel = _field(plan, "selection", "dict", "validated-plan")
66+
at = _field(sel, "allowed_types", "list", "validated-plan.selection")
67+
if len(at) != 1:
68+
raise ApplyError("validated-plan.selection.allowed_types must have exactly one entry")
69+
sfs = _field(plan, "source_files", "list", "validated-plan")
70+
if len(sfs) != 1:
71+
raise ApplyError("validated-plan.source_files must have exactly one entry")
72+
sf = sfs[0]
73+
if not isinstance(sf, dict):
74+
raise ApplyError("validated-plan.source_files[0] must be an object")
75+
_require_canonical_relpath(_field(sf, "path", "str", "validated-plan.source_files[0]"),
76+
"validated-plan.source_files[0]")
77+
_require_sha256(_field(sf, "sha256", "str", "validated-plan.source_files[0]"),
78+
"validated-plan.source_files[0]")
79+
for i, d in enumerate(_field(plan, "decisions", "list", "validated-plan")):
80+
dctx = f"validated-plan.decisions[{i}]"
81+
if not isinstance(d, dict):
82+
raise ApplyError(f"{dctx}: must be an object")
83+
extra = sorted(set(d) - _DECISION_KEYS)
84+
if extra:
85+
raise ApplyError(f"{dctx}: unknown field(s) {extra}")
86+
_require_finding_id(_field(d, "finding_id", "str", dctx), dctx)
87+
action = _field(d, "action", "str", dctx)
88+
if action not in S1_ACTIONS:
89+
raise ApplyError(f"{dctx}: out-of-scope action {action!r}")
90+
_require_canonical_relpath(_field(d, "file", "str", dctx), dctx)
91+
_validate_span(d.get("acquire_span"), dctx)
92+
except CollectError as exc:
93+
raise ApplyError(f"validated-plan: {exc}") from exc
94+
95+
96+
def _sha_file(abs_path: str) -> str:
97+
with open(abs_path, "rb") as fh:
98+
return "sha256:" + hashlib.sha256(fh.read()).hexdigest()
99+
100+
101+
def validate_apply_inputs(
102+
validated_plan: dict[str, Any], candidates: dict[str, Any], root: str
103+
) -> dict[str, Any]:
104+
"""Run the full S2 pre-apply gate over the two hash-bound inputs + the source root.
105+
Returns an apply CONTEXT (the convert_acquire targets with their candidate identities,
106+
the manual_review ids, the source path, the target API) once every guard passes.
107+
Raises ApplyError on any violation — no source is read for rewriting, only hashed."""
108+
validate_validated_plan(validated_plan)
109+
try:
110+
validate_candidates_bundle(candidates)
111+
except CollectError as exc:
112+
raise ApplyError(f"invalid candidates bundle: {exc}") from exc
113+
114+
# Hash binding: the candidates are the plan's own input.
115+
if bundle_sha256(candidates) != validated_plan["input_bundle_sha256"]:
116+
raise ApplyError("candidates sha256 does not match validated-plan.input_bundle_sha256")
117+
if validated_plan["target_api"] != candidates["target_api"]:
118+
raise ApplyError("validated-plan.target_api does not match candidates.target_api")
119+
if validated_plan["source_files"] != candidates["source_files"]:
120+
raise ApplyError("validated-plan.source_files does not match candidates.source_files")
121+
122+
candidate_by_id = {c["finding_id"]: c for c in candidates["candidates"]}
123+
seen: set[str] = set()
124+
convert: list[dict[str, Any]] = []
125+
manual: list[str] = []
126+
for d in validated_plan["decisions"]:
127+
fid = d["finding_id"]
128+
if fid not in candidate_by_id:
129+
raise ApplyError(f"decision {fid!r} is not a candidate")
130+
if fid in seen:
131+
raise ApplyError(f"duplicate decision {fid!r}")
132+
seen.add(fid)
133+
c = candidate_by_id[fid]
134+
if d["file"] != c["file"]:
135+
raise ApplyError(f"{fid}: decision file != candidate file")
136+
if d["acquire_span"] != c["acquire_span"]:
137+
raise ApplyError(f"{fid}: decision acquire_span != candidate acquire_span")
138+
if d["action"] not in c["allowed_actions"]:
139+
raise ApplyError(f"{fid}: action {d['action']!r} not allowed by candidate")
140+
if d["action"] == "convert_acquire":
141+
if c["event_contract"] != "inotify_property_changed":
142+
raise ApplyError(
143+
f"{fid}: convert_acquire requires an inotify_property_changed contract"
144+
)
145+
convert.append({
146+
"finding_id": fid,
147+
"file": c["file"],
148+
"acquire_span": c["acquire_span"],
149+
"containing_type": c["containing_type"],
150+
"event": c["event"],
151+
"event_identity": c["event_identity"],
152+
"source": c["source"],
153+
"handler": c["handler"],
154+
})
155+
else:
156+
manual.append(fid)
157+
missing = set(candidate_by_id) - seen
158+
if missing:
159+
raise ApplyError(f"missing decisions for: {sorted(missing)}")
160+
161+
# convert_acquire edit spans must not overlap.
162+
ranges = sorted(
163+
(e["acquire_span"]["start"], e["acquire_span"]["start"] + e["acquire_span"]["length"])
164+
for e in convert
165+
)
166+
for (_, end_a), (start_b, _) in pairwise(ranges):
167+
if start_b < end_a:
168+
raise ApplyError("overlapping convert_acquire spans")
169+
170+
# Source guard: confine to root, then match the pristine preimage SHA.
171+
source = candidates["source_files"][0]
172+
try:
173+
canonical, abs_path = _resolve_source(root, source["path"])
174+
except CollectError as exc:
175+
raise ApplyError(f"source path: {exc}") from exc
176+
if _sha_file(abs_path) != source["sha256"]:
177+
raise ApplyError(f"STALE SOURCE / PREIMAGE MISMATCH for {canonical}")
178+
179+
return {
180+
"source_file": canonical,
181+
"target_subscribe": validated_plan["target_api"]["subscribe"],
182+
"convert_acquire": convert,
183+
"manual_review": manual,
184+
}

‎tests/test_fix_apply.py‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""S2 slice 1 — the apply-input gate (validate + hash-bind + source guard), SDK-free.
3+
4+
Covers the locked pre-apply contract that must pass before any source is rewritten:
5+
validated-plan shape, candidates hash binding, decision↔candidate cross-checks, the
6+
frozen convert_acquire-only-for-INPC tiering (via the allowed_actions path), overlapping
7+
spans, root confinement, and the pristine preimage SHA guard. No C# / Roslyn here.
8+
9+
Run: python tests/test_fix_apply.py
10+
python tests/run_tests.py (auto-discovered)
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import hashlib
16+
import os
17+
import sys
18+
import tempfile
19+
20+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
21+
22+
from ownlang.fix_apply import ApplyError, validate_apply_inputs
23+
from ownlang.fix_plan import bundle_sha256
24+
25+
_FID_A = "OWN001:sha256:" + "a" * 64
26+
_FID_B = "OWN001:sha256:" + "b" * 64
27+
_EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged"
28+
_REL = "N/C.cs"
29+
30+
31+
def _cand(fid: str, actions: tuple[str, ...], contract: str, start: int) -> dict:
32+
return {
33+
"finding_id": fid, "diagnostic_code": "OWN001", "containing_type": "N.C",
34+
"file": _REL, "enclosing_member": "N.C.C()", "event": "PropertyChanged",
35+
"event_identity": _EV, "event_contract": contract, "source": "_pub",
36+
"source_identity": "N.C._pub", "source_identity_kind": "stable_symbol",
37+
"handler": "OnChanged", "handler_identity": "N.C.OnChanged(object, ...)",
38+
"handler_identity_kind": "stable_symbol", "occurrence_ordinal": 0,
39+
"acquire_span": {"start": start, "length": 30, "start_line": 1,
40+
"start_column": 1, "end_line": 1, "end_column": 31},
41+
"teardown": {"status": "none", "candidates": []},
42+
"allowed_actions": list(actions),
43+
}
44+
45+
46+
def _bundle(cands: list[dict], sha: str) -> dict:
47+
return {
48+
"version": 1, "operation": "fix-subscriptions",
49+
"target_api": {"subscribe": "WeakEvents.AddPropertyChanged"},
50+
"selection": {
51+
"allowed_types": [{"full_name": "N.C", "file": _REL}],
52+
"selected_findings": None,
53+
"constraints": {"max_types_changed": 1, "max_files_changed": 1,
54+
"allow_helper_changes": False, "allow_config_changes": False,
55+
"allow_suppressions": False},
56+
},
57+
"source_files": [{"path": _REL, "sha256": sha}],
58+
"candidates": cands,
59+
}
60+
61+
62+
def _decision(fid: str, action: str, start: int) -> dict:
63+
return {"finding_id": fid, "action": action, "file": _REL,
64+
"acquire_span": {"start": start, "length": 30, "start_line": 1,
65+
"start_column": 1, "end_line": 1, "end_column": 31}}
66+
67+
68+
def _vplan(cands: dict, decisions: list[dict]) -> dict:
69+
the_type = cands["selection"]["allowed_types"][0]
70+
sf = cands["source_files"][0]
71+
return {
72+
"version": 1, "operation": "fix-subscriptions",
73+
"input_bundle_sha256": bundle_sha256(cands),
74+
"target_api": {"subscribe": cands["target_api"]["subscribe"]},
75+
"selection": {
76+
"allowed_types": [{"full_name": the_type["full_name"], "file": the_type["file"]}],
77+
"selected_findings": cands["selection"].get("selected_findings"),
78+
"constraints": {"max_types_changed": 1, "max_files_changed": 1,
79+
"allow_helper_changes": False, "allow_config_changes": False,
80+
"allow_suppressions": False},
81+
},
82+
"source_files": [{"path": sf["path"], "sha256": sf["sha256"]}],
83+
"decisions": decisions,
84+
}
85+
86+
87+
def run() -> int:
88+
ok = 0
89+
bad = 0
90+
91+
def check(cond: bool, label: str) -> None:
92+
nonlocal ok, bad
93+
if cond:
94+
ok += 1
95+
else:
96+
bad += 1
97+
print(f" FAIL: {label}")
98+
99+
def raises(vplan: dict, cands: dict, root: str) -> bool:
100+
try:
101+
validate_apply_inputs(vplan, cands, root)
102+
except ApplyError:
103+
return True
104+
return False
105+
106+
with tempfile.TemporaryDirectory() as root:
107+
src = os.path.join(root, "N", "C.cs")
108+
os.makedirs(os.path.dirname(src), exist_ok=True)
109+
content = b"// pretend source with a subscription\nclass C {}\n"
110+
with open(src, "wb") as fh:
111+
fh.write(content)
112+
sha = "sha256:" + hashlib.sha256(content).hexdigest()
113+
114+
inpc = ("convert_acquire", "manual_review")
115+
cands = _bundle([_cand(_FID_A, inpc, "inotify_property_changed", 100),
116+
_cand(_FID_B, ("manual_review",), "name_only", 200)], sha)
117+
118+
# happy path: one convert_acquire + one manual_review
119+
vplan = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
120+
_decision(_FID_B, "manual_review", 200)])
121+
ctx = validate_apply_inputs(vplan, cands, root)
122+
conv = ctx["convert_acquire"]
123+
check(len(conv) == 1 and conv[0]["finding_id"] == _FID_A, "one convert_acquire target")
124+
check(ctx["manual_review"] == [_FID_B], "one manual_review")
125+
check(ctx["source_file"] == _REL
126+
and ctx["target_subscribe"] == "WeakEvents.AddPropertyChanged",
127+
"context carries source + target")
128+
check(ctx["convert_acquire"][0]["source"] == "_pub"
129+
and ctx["convert_acquire"][0]["handler"] == "OnChanged",
130+
"convert target carries candidate identity")
131+
132+
# hash binding: mutate candidates after the plan was built
133+
mutated = _bundle([_cand(_FID_A, inpc, "inotify_property_changed", 100),
134+
_cand(_FID_B, ("manual_review",), "name_only", 999)], sha)
135+
check(raises(vplan, mutated, root), "candidates/plan hash mismatch refused")
136+
137+
# target / source_files mismatch
138+
bad_target = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
139+
_decision(_FID_B, "manual_review", 200)])
140+
bad_target["target_api"] = {"subscribe": "Other.Add"}
141+
check(raises(bad_target, cands, root), "target_api mismatch refused")
142+
143+
# decision file != candidate file
144+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
145+
_decision(_FID_B, "manual_review", 200)])
146+
v["decisions"][0]["file"] = "N/Other.cs"
147+
check(raises(v, cands, root), "decision file != candidate refused")
148+
# decision span != candidate span
149+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 101),
150+
_decision(_FID_B, "manual_review", 200)])
151+
check(raises(v, cands, root), "decision span != candidate refused")
152+
153+
# action not allowed by the candidate (convert on a manual-only finding)
154+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
155+
_decision(_FID_B, "convert_acquire", 200)])
156+
check(raises(v, cands, root), "convert_acquire on a manual-only candidate refused")
157+
158+
# unknown / missing / duplicate decisions
159+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
160+
_decision(_FID_B, "manual_review", 200),
161+
_decision("OWN001:sha256:" + "c" * 64, "manual_review", 300)])
162+
check(raises(v, cands, root), "unknown decision refused")
163+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100)])
164+
check(raises(v, cands, root), "missing decision refused")
165+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
166+
_decision(_FID_A, "manual_review", 100),
167+
_decision(_FID_B, "manual_review", 200)])
168+
check(raises(v, cands, root), "duplicate decision refused")
169+
170+
# overlapping convert spans
171+
over = _bundle([_cand(_FID_A, inpc, "inotify_property_changed", 100),
172+
_cand(_FID_B, inpc, "inotify_property_changed", 110)], sha)
173+
vo = _vplan(over, [_decision(_FID_A, "convert_acquire", 100),
174+
_decision(_FID_B, "convert_acquire", 110)])
175+
check(raises(vo, over, root), "overlapping convert spans refused")
176+
177+
# unknown fields / bad version / out-of-scope action in the plan
178+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
179+
_decision(_FID_B, "manual_review", 200)])
180+
v["oops"] = 1
181+
check(raises(v, cands, root), "unknown top-level plan field refused")
182+
v = _vplan(cands, [_decision(_FID_A, "convert_acquire", 100),
183+
_decision(_FID_B, "manual_review", 200)])
184+
v["decisions"][0]["confidence"] = "high"
185+
check(raises(v, cands, root), "unknown decision field refused")
186+
v = _vplan(cands, [_decision(_FID_A, "convert_exact_teardown", 100),
187+
_decision(_FID_B, "manual_review", 200)])
188+
check(raises(v, cands, root), "out-of-scope action refused")
189+
190+
# stale source SHA
191+
stale_sha = "sha256:" + "0" * 64
192+
stale_cands = _bundle([_cand(_FID_A, inpc, "inotify_property_changed", 100)], stale_sha)
193+
stale_v = _vplan(stale_cands, [_decision(_FID_A, "convert_acquire", 100)])
194+
check(raises(stale_v, stale_cands, root), "stale source preimage SHA refused")
195+
196+
# root confinement: a candidates path escaping the root
197+
with tempfile.TemporaryDirectory() as root2:
198+
esc = _bundle([_cand(_FID_A, ("convert_acquire", "manual_review"),
199+
"inotify_property_changed", 100)], "sha256:" + "0" * 64)
200+
esc["source_files"][0]["path"] = "N/C.cs" # keep candidates valid; file just won't exist
201+
ev = _vplan(esc, [_decision(_FID_A, "convert_acquire", 100)])
202+
# the file does not exist under root2 -> _resolve_source refuses (not a regular file)
203+
check(raises(ev, esc, root2), "missing/uncontained source refused")
204+
205+
print(f"fix-apply (S2 slice 1): {ok} ok, {bad} bad")
206+
return bad
207+
208+
209+
if __name__ == "__main__":
210+
raise SystemExit(run())

0 commit comments

Comments
 (0)