Skip to content

Commit ea47d45

Browse files
PhysShellclaude
andcommitted
fix(S2): step 9 — the five HOLD blockers (and a latent step-8 test-harness defect)
1. THE TEST MODULE KILLED THE AGGREGATE RUNNER. test_gate_patch.py ran its checks at import and ended with sys.exit(), so when run_tests.py imported it every module discovered after it — and the aggregate return code — was silently dropped: a green job that had "turned the lights off in the building". The SAME latent defect was already in the merged step-8 test_patch_bundle.py (it sorts AFTER test_gate_patch, so it had never actually run in the aggregate). Both now expose `def run() -> int` with no import-time sys.exit and an `if __name__ == "__main__"` guard. A new tests/test_harness_contract.py STATICALLY proves (via ast) that every sibling test_*.py exposes run() and never calls sys.exit / raise SystemExit at module scope — so this class of bug cannot recur. With the fix the suite now reaches its summary and both modules gate: step 8 = 79 checks, step 9 = 63, harness = 29, all under one run_tests return code (verified: an injected failure flips rc to 1). 2. FULL FROZEN-CANDIDATE VALIDATION. The exact key set was held but not every value. The gate now validates every string field (diagnostic_code, enclosing_member, event_identity, ...), occurrence_ordinal as a non-negative int, and the full teardown block — exact keys, status in {none,exact,ambiguous}, and each teardown candidate's exact keys with its 6-int span. A re-hashed bundle with `event_identity: 123` or a malformed teardown now refuses at AUTHORITY_BINDING (fixtures added for event_identity/occurrence_ordinal wrong type, teardown extra key / unknown status / malformed candidate / bad span shape). 3. EXACT DIRECTORY LAYOUT + BUNDLE-ROOT lstat. The layout walk returned only files, so an extra/hidden/nested EMPTY directory rode through, and --bundle was realpath'd first so a symlinked bundle root was accepted. The walk now returns (dirs, files) and the postimage subtree must equal EXACTLY rel's ancestor dirs plus rel; --bundle is lstat'd before realpath and a symlink/reparse root is refused. Fixtures: extra/hidden/nested empty dir, symlinked bundle root — all BUNDLE_LAYOUT. 4. A TRULY CLAIMED WORKDIR + RE-PROVEN PUBLICATION. _prepare_out only checked a name; the dir was created later with a plain makedirs. A new _claim_workdir creates the unpredictable dir immediately (mode 0700 on POSIX, in the mkdir itself) and PROVES it: not a link/reparse, realpath == itself under the platform-aware comparison, empty. And _publish re-resolves the out-dir parent, re-confirms it is off the source tree, and re-confirms no final out exists — immediately before the atomic rename, not only at parse time. 5. TIGHTER PATCH GRAMMAR. The no-newline marker was allowed after any seen body line and any number of times. It is now stateful: it must sit immediately after an eligible ( / - / + line, marks the LAST line of its side(s) (a later line of a closed side is refused), and appears at most once per side. Range bounds now cover the zero-length (insertion) case: old_len>0 requires 1 <= old_start and old_start+old_len-1 <= preimage lines (so a non-zero range starting at 0 is refused); old_len==0 requires 0 <= old_start <= preimage lines (so an insertion past EOF is refused rather than dying later as APPLY_CHECK). Fixtures: marker before any line, duplicate marker, marker with more of the same side after it, insertion past the preimage, non-zero range at 0, plus positive cases (a well-placed marker, a top insertion). The step-8 change is strictly the test-harness run() wrapper — no test logic, no Steps 4-8 semantics/schema touched. All other Step 9 architecture (authority model, six snapshots, manifest schema, git environment, baseline index, evidence schema, empty-patch policy, taxonomy) is unchanged. Steps 10-12 not started. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G
1 parent fbe88bf commit ea47d45

5 files changed

Lines changed: 309 additions & 51 deletions

File tree

ownlang/fix_gate.py

Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,29 @@ def _bundle_sha256(candidates: dict[str, Any]) -> str:
248248
return _sha_bytes(_canonical_json(candidates))
249249

250250

251+
def _span(obj: dict[str, Any], name: str, cat: str, where: str) -> tuple[int, ...]:
252+
"""The frozen 6-key int span. Returns the values as a tuple for exact comparison."""
253+
sp = _obj(_need(obj, name, cat, where), cat, f"{where}.{name}")
254+
_exact(sp, cat, f"{where}.{name}", *_SPAN_KEYS)
255+
return tuple(_int(sp, k, cat, f"{where}.{name}") for k in _SPAN_KEYS)
256+
257+
258+
def _validate_teardown(td: Any, cat: str, where: str) -> None:
259+
"""The frozen teardown block: status vocabulary + exact-key candidates with a span."""
260+
t = _obj(td, cat, f"{where}.teardown")
261+
_exact(t, cat, f"{where}.teardown", "status", "candidates")
262+
if _s(t, "status", cat, f"{where}.teardown") not in ("none", "exact", "ambiguous"):
263+
raise GateError(cat, f"{where}.teardown: unknown status")
264+
for i, tc_any in enumerate(_list(t, "candidates", cat, f"{where}.teardown")):
265+
tctx = f"{where}.teardown.candidates[{i}]"
266+
tc = _obj(tc_any, cat, tctx)
267+
_exact(tc, cat, tctx, "source", "handler", "match", "span")
268+
_s(tc, "source", cat, tctx)
269+
_s(tc, "handler", cat, tctx)
270+
_s(tc, "match", cat, tctx)
271+
_span(tc, "span", cat, tctx)
272+
273+
251274
def _check_constraints(cons: dict[str, Any], cat: str, where: str) -> None:
252275
_exact(cons, cat, where, "max_types_changed", "max_files_changed",
253276
"allow_helper_changes", "allow_config_changes", "allow_suppressions")
@@ -317,20 +340,22 @@ def _validate_candidates(bundle: dict[str, Any], cat: str) -> dict[str, Any]:
317340
raise GateError(cat, f"{where}: duplicate finding_id {fid}")
318341
id_set.add(fid)
319342
ids.append(fid)
320-
for k in ("event", "source", "handler", "source_identity", "source_identity_kind",
321-
"handler_identity", "handler_identity_kind"):
343+
# EVERY string field the frozen S0 candidate carries — not just the identity
344+
# subset — so a wrong-typed field on a re-hashed bundle cannot ride through.
345+
for k in ("diagnostic_code", "enclosing_member", "event", "event_identity",
346+
"source", "source_identity", "source_identity_kind",
347+
"handler", "handler_identity", "handler_identity_kind"):
322348
_s(c, k, cat, where)
349+
_int(c, "occurrence_ordinal", cat, where)
350+
_validate_teardown(c["teardown"], cat, where)
323351
if _s(c, "containing_type", cat, where) != type_name:
324352
raise GateError(cat, f"{where}: outside the selected type {type_name}")
325353
if _s(c, "file", cat, where) != src_path:
326354
raise GateError(cat, f"{where}: outside the selected file {src_path}")
327355
contract = _s(c, "event_contract", cat, where)
328356
if contract not in _CONTRACTS:
329357
raise GateError(cat, f"{where}: unknown event_contract '{contract}'")
330-
span = _obj(c.get("acquire_span"), cat, f"{where}.acquire_span")
331-
_exact(span, cat, f"{where}.acquire_span", *_SPAN_KEYS)
332-
span_by_id[fid] = tuple(_int(span, k, cat, f"{where}.acquire_span")
333-
for k in _SPAN_KEYS)
358+
span_by_id[fid] = _span(c, "acquire_span", cat, where)
334359
actions = _list(c, "allowed_actions", cat, where)
335360
if not actions:
336361
raise GateError(cat, f"{where}: allowed_actions must be non-empty")
@@ -429,10 +454,7 @@ def validate_gate_authority(validated_plan: Any, candidates: Any) -> GateAuthori
429454
raise GateError(cat, f"{where}: action '{action}' not allowed for {fid}")
430455
if _s(d, "file", cat, where) != src_path:
431456
raise GateError(cat, f"{where}: file != the selected source file")
432-
d_span = _obj(d.get("acquire_span"), cat, f"{where}.acquire_span")
433-
_exact(d_span, cat, f"{where}.acquire_span", *_SPAN_KEYS)
434-
got = tuple(_int(d_span, k, cat, f"{where}.acquire_span") for k in _SPAN_KEYS)
435-
if got != facts["span_by_id"][fid]:
457+
if _span(d, "acquire_span", cat, where) != facts["span_by_id"][fid]:
436458
raise GateError(cat, f"{where}: acquire_span != the candidate")
437459
(applied if action == "convert_acquire" else manual).append(fid)
438460

@@ -561,33 +583,56 @@ def parse_step8_patch(patch: bytes, rel: str, preimage: bytes) -> None:
561583
raise GateError(PATCH_STRUCTURE, "patch: malformed hunk header") from exc
562584
old_start, old_len = _range(old_part)
563585
_new_start, new_len = _range(new_part)
586+
# Range bounds, including the zero-length (pure-insertion) case: a 0-length old
587+
# range names the line BEFORE it (0..pre_lines); a non-zero range is 1-based and
588+
# must lie within the preimage.
589+
if old_len > 0:
590+
if old_start < 1 or old_start + old_len - 1 > pre_lines:
591+
raise GateError(PATCH_STRUCTURE, "patch: a hunk range is outside the preimage")
592+
elif old_start > pre_lines:
593+
raise GateError(PATCH_STRUCTURE, "patch: an insertion range is past the preimage")
564594
if old_start < prev_old_end:
565595
raise GateError(PATCH_STRUCTURE, "patch: hunks not increasing / non-overlapping")
566-
if old_len > 0 and old_start + old_len - 1 > pre_lines:
567-
raise GateError(PATCH_STRUCTURE, "patch: a hunk range is outside the preimage")
568596
prev_old_end = old_start + old_len
569597
saw_hunk = True
570598
i += 1
571599
ctx = minus = plus = 0
572-
body_lines = 0
600+
old_closed = new_closed = False # a no-newline marker closes a side; once each
601+
last_head: bytes | None = None # the head of the last body line (None after a marker)
573602
while i < len(recs) and not (recs[i].startswith(b"@@ -")
574603
and recs[i].endswith(b" @@")):
575604
line = recs[i]
576605
if line == b"\\ No newline at end of file":
577-
if body_lines == 0:
578-
raise GateError(PATCH_STRUCTURE, "patch: no-newline marker with no line")
606+
# Must sit immediately after an eligible body line, mark the LAST line of
607+
# its side(s), and appear at most once per side. A context line is on both
608+
# sides; a `-` line only old; a `+` line only new.
609+
if last_head is None:
610+
raise GateError(PATCH_STRUCTURE, "patch: no-newline marker not after a line")
611+
marks_old = last_head in (b" ", b"-")
612+
marks_new = last_head in (b" ", b"+")
613+
if (marks_old and old_closed) or (marks_new and new_closed):
614+
raise GateError(PATCH_STRUCTURE, "patch: duplicate no-newline marker")
615+
old_closed = old_closed or marks_old
616+
new_closed = new_closed or marks_new
617+
last_head = None
579618
i += 1
580619
continue
581620
if not line or line[:1] not in (b" ", b"-", b"+"):
582621
raise GateError(PATCH_STRUCTURE, f"patch: illegal hunk line {line[:40]!r}")
583622
head = line[:1]
623+
# A line of a side already closed by a marker means the marker did not mark the
624+
# LAST line of that side.
625+
if head in (b" ", b"-") and old_closed:
626+
raise GateError(PATCH_STRUCTURE, "patch: old-side line after a no-newline marker")
627+
if head in (b" ", b"+") and new_closed:
628+
raise GateError(PATCH_STRUCTURE, "patch: new-side line after a no-newline marker")
584629
if head == b" ":
585630
ctx += 1
586631
elif head == b"-":
587632
minus += 1
588633
else:
589634
plus += 1
590-
body_lines += 1
635+
last_head = head
591636
i += 1
592637
if ctx + minus != old_len or ctx + plus != new_len:
593638
raise GateError(PATCH_STRUCTURE, "patch: hunk line counts disagree with header")
@@ -758,10 +803,10 @@ def apply_in_throwaway(workdir: str, rel: str, preimage: bytes, postimage: bytes
758803
# --- publication (the step 8 protocol, reused) -------------------------------------
759804

760805

761-
def _prepare_out(out: str, root: str) -> tuple[str, str, str]:
762-
"""(out_phys, workdir, staging). The out-dir must be fresh and PHYSICALLY off the
763-
source tree; the workdir (holding staging + the throwaway repo) is claimed under the
764-
verified physical parent with an unpredictable name."""
806+
def _out_parent(out: str, root: str) -> tuple[str, str, str]:
807+
"""(out_phys, parent_phys, root_phys). The out-dir must be fresh and PHYSICALLY off the
808+
source tree — its parent is resolved and confined here, and re-proven immediately
809+
before the publishing rename."""
765810
out_abs = os.path.abspath(out)
766811
name = os.path.basename(out_abs.rstrip(os.sep))
767812
if not name:
@@ -776,16 +821,50 @@ def _prepare_out(out: str, root: str) -> tuple[str, str, str]:
776821
out_phys = os.path.join(parent_phys, name)
777822
if os.path.exists(out_phys) or os.path.islink(out_phys):
778823
raise GateError(PUBLICATION, f"--out {out!r} already exists")
779-
workdir = os.path.join(parent_phys, f".{name}.owen-gate-{os.urandom(16).hex()}")
780-
if os.path.exists(workdir) or os.path.islink(workdir):
781-
raise GateError(PUBLICATION, "the work directory already exists")
782-
return out_phys, workdir, os.path.join(workdir, "staging")
783-
784-
785-
def _publish(staging: str, out_phys: str, evidence: bytes) -> None:
824+
return out_phys, parent_phys, root_phys
825+
826+
827+
def _claim_workdir(parent_phys: str) -> str:
828+
"""CLAIM an unpredictable working directory: create it here and now (owner-only on
829+
POSIX, as part of the mkdir), then PROVE we own it — not a link/reparse, resolving to
830+
itself under a platform-aware comparison, and empty. A name that is merely checked and
831+
then written into is a window; this closes it."""
832+
for _ in range(8):
833+
path = os.path.join(parent_phys, f".owen-gate-{os.urandom(16).hex()}")
834+
if os.path.exists(path) or os.path.islink(path):
835+
continue
836+
try:
837+
os.mkdir(path, mode=0o700)
838+
except FileExistsError:
839+
continue
840+
except OSError as exc:
841+
raise GateError(PUBLICATION,
842+
f"cannot claim a work directory ({exc.strerror or exc})") from exc
843+
lst = os.lstat(path)
844+
if _is_link(lst):
845+
raise GateError(PUBLICATION, "the claimed work directory is a link")
846+
if not stat.S_ISDIR(lst.st_mode) or os.path.realpath(path) != path \
847+
or not _same_or_inside(parent_phys, os.path.realpath(path)):
848+
raise GateError(PUBLICATION, "the claimed work directory does not resolve to itself")
849+
if any(os.scandir(path)):
850+
raise GateError(PUBLICATION, "the claimed work directory is not empty")
851+
return path
852+
raise GateError(PUBLICATION, "could not claim a work directory")
853+
854+
855+
def _publish(staging: str, out_phys: str, evidence: bytes, root_phys: str) -> None:
786856
os.makedirs(staging)
787857
with open(os.path.join(staging, "gate-result.json"), "wb") as fh:
788858
fh.write(evidence)
859+
# Re-prove the destination against the filesystem AS IT IS NOW, right before the rename.
860+
parent = os.path.dirname(out_phys)
861+
if not os.path.isdir(parent):
862+
raise GateError(PUBLICATION, "the out-dir parent vanished before publication")
863+
if os.path.realpath(parent) != os.path.dirname(out_phys) \
864+
or _same_or_inside(root_phys, os.path.realpath(parent)):
865+
raise GateError(PUBLICATION, "the out-dir parent changed to resolve inside the root")
866+
if os.path.exists(out_phys) or os.path.islink(out_phys):
867+
raise GateError(PUBLICATION, "the out-dir appeared before publication")
789868
try:
790869
os.rename(staging, out_phys)
791870
except OSError as exc:
@@ -822,10 +901,13 @@ def _require_top_level(bundle: str) -> None:
822901
raise GateError(BUNDLE_LAYOUT, "postimage: is not a real directory")
823902

824903

825-
def _walk_regular(root: str, what: str) -> set[str]:
904+
def _walk_tree(root: str, what: str) -> tuple[set[str], set[str]]:
826905
"""Every entry under `root` must be a real directory or a regular file — no symlinks,
827-
reparse points, fifos, sockets or devices. Returns `/`-joined file paths."""
828-
leaves: set[str] = set()
906+
reparse points, fifos, sockets or devices. Returns (dir paths, file paths), both
907+
`/`-joined and relative to `root`, so the caller can require an EXACT layout (extra or
908+
hidden empty directories are a violation, not just extra files)."""
909+
dirs: set[str] = set()
910+
files: set[str] = set()
829911
stack = [root]
830912
while stack:
831913
current = stack.pop()
@@ -835,16 +917,18 @@ def _walk_regular(root: str, what: str) -> set[str]:
835917
raise GateError(BUNDLE_LAYOUT, f"{what}: cannot scan ({exc.strerror or exc})") from exc
836918
for entry in entries:
837919
st = _lentry(entry.path, entry.path)
920+
rel = os.path.relpath(entry.path, root).replace("\\", "/")
838921
if _is_link(st):
839922
raise GateError(BUNDLE_LAYOUT, f"{what}: '{entry.name}' is a symlink/reparse")
840923
if stat.S_ISDIR(st.st_mode):
924+
dirs.add(rel)
841925
stack.append(entry.path)
842926
elif stat.S_ISREG(st.st_mode):
843-
leaves.add(os.path.relpath(entry.path, root).replace("\\", "/"))
927+
files.add(rel)
844928
else:
845929
raise GateError(BUNDLE_LAYOUT,
846930
f"{what}: '{entry.name}' is not a regular file or dir")
847-
return leaves
931+
return dirs, files
848932

849933

850934
# --- evidence + orchestration ------------------------------------------------------
@@ -878,13 +962,17 @@ def run_gate(bundle: str, plan_path: str, candidates_path: str, root: str, out:
878962
path; raises GateError (no output, source untouched) on any refusal."""
879963
import shutil
880964

881-
# [1] bundle layout — entry types + top-level set; collect the postimage leaves.
882-
bundle_phys = os.path.realpath(bundle)
883-
if not os.path.isdir(bundle_phys):
965+
# [1] bundle layout — the bundle root itself must not be a symlink/reparse (lstat
966+
# BEFORE realpath), then exact entry types + the full postimage subtree.
967+
blst = _lentry(bundle, "--bundle")
968+
if _is_link(blst):
969+
raise GateError(BUNDLE_LAYOUT, "--bundle is a symlink / reparse point")
970+
if not stat.S_ISDIR(blst.st_mode):
884971
raise GateError(BUNDLE_LAYOUT, "--bundle is not a directory")
972+
bundle_phys = os.path.realpath(bundle)
885973
_require_top_level(bundle_phys)
886974
postimage_root = os.path.join(bundle_phys, "postimage")
887-
post_leaves = _walk_regular(postimage_root, "postimage")
975+
post_dirs, post_leaves = _walk_tree(postimage_root, "postimage")
888976

889977
# [2] snapshot the inputs that do NOT need rel (each read exactly once).
890978
manifest_bytes = _snapshot(os.path.join(bundle_phys, "apply-manifest.json"),
@@ -898,10 +986,13 @@ def run_gate(bundle: str, plan_path: str, candidates_path: str, root: str, out:
898986
manifest = _load_json(manifest_bytes, MANIFEST_SHAPE, "apply-manifest.json")
899987
rel, m_pre, m_post, m_patch = validate_manifest_shape(manifest)
900988

901-
# [4] bundle layout, final: exactly one postimage leaf, and it is rel.
902-
if post_leaves != {rel}:
903-
raise GateError(BUNDLE_LAYOUT,
904-
f"postimage holds {sorted(post_leaves)}, want ['{rel}']")
989+
# [4] bundle layout, final: the postimage subtree is EXACTLY rel's ancestor dirs plus
990+
# rel — no extra, hidden or empty directory rides through.
991+
parts = rel.split("/")
992+
expected_dirs = {"/".join(parts[:i]) for i in range(1, len(parts))}
993+
if post_leaves != {rel} or post_dirs != expected_dirs:
994+
raise GateError(BUNDLE_LAYOUT, f"postimage layout {sorted(post_dirs | post_leaves)} "
995+
f"!= exactly {sorted(expected_dirs | {rel})}")
905996
postimage_bytes = _snapshot(os.path.join(postimage_root, *rel.split("/")),
906997
BUNDLE_LAYOUT, f"postimage/{rel}")
907998

@@ -949,9 +1040,10 @@ def run_gate(bundle: str, plan_path: str, candidates_path: str, root: str, out:
9491040
parse_step8_patch(patch_bytes, rel, preimage_bytes)
9501041

9511042
# [10] apply semantics + publication.
952-
out_phys, workdir, staging = _prepare_out(out, root)
1043+
out_phys, parent_phys, root_phys = _out_parent(out, root)
1044+
workdir = _claim_workdir(parent_phys)
1045+
staging = os.path.join(workdir, "staging")
9531046
try:
954-
os.makedirs(workdir)
9551047
if empty_patch:
9561048
# manual-only: pre == post == the pristine bytes; Git is not run.
9571049
if postimage_bytes != preimage_bytes:
@@ -966,7 +1058,7 @@ def run_gate(bundle: str, plan_path: str, candidates_path: str, root: str, out:
9661058

9671059
evidence = _build_evidence(auth, rel, plan_bytes, manifest_bytes, patch_bytes,
9681060
m_pre, m_post, git_gates)
969-
_publish(staging, out_phys, evidence)
1061+
_publish(staging, out_phys, evidence, root_phys)
9701062
except BaseException:
9711063
shutil.rmtree(workdir, ignore_errors=True)
9721064
raise

tests/gate_regressions.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ refuse "a symlink entry in the bundle" "$T/g_sym" "$T/bundle_sym" BUNDLE_LAYOUT
178178
cp -r "$T/bundle" "$T/bundle_pi_extra"
179179
touch "$T/bundle_pi_extra/postimage/frontend/roslyn/samples/Extra.cs"
180180
refuse "an extra postimage file" "$T/g_pie" "$T/bundle_pi_extra" BUNDLE_LAYOUT
181+
# An extra EMPTY directory (a file-set check alone would miss it).
182+
cp -r "$T/bundle" "$T/bundle_ed"; mkdir -p "$T/bundle_ed/postimage/frontend/roslyn/samples/empty"
183+
refuse "an extra empty directory" "$T/g_ed" "$T/bundle_ed" BUNDLE_LAYOUT
184+
cp -r "$T/bundle" "$T/bundle_hd"; mkdir -p "$T/bundle_hd/postimage/.hidden"
185+
refuse "a hidden empty directory" "$T/g_hd" "$T/bundle_hd" BUNDLE_LAYOUT
186+
cp -r "$T/bundle" "$T/bundle_nd"; mkdir -p "$T/bundle_nd/postimage/a/b/c"
187+
refuse "a nested empty directory" "$T/g_nd" "$T/bundle_nd" BUNDLE_LAYOUT
188+
# A symlinked bundle ROOT (rejected by lstat before realpath).
189+
ln -s "$T/bundle" "$T/bundle_link"
190+
refuse "a symlinked bundle root" "$T/g_bl" "$T/bundle_link" BUNDLE_LAYOUT
181191

182192
echo "== 5. pristine source =="
183193
zero=$(printf '0%.0s' $(seq 1 64))

0 commit comments

Comments
 (0)