devlyn-cli 2.8.1 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -1
- package/CLAUDE.md +9 -5
- package/README.md +31 -2
- package/benchmark/auto-resolve/fixtures/test-repo/README.md +8 -23
- package/benchmark/auto-resolve/fixtures/test-repo/bin/cli.js +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/package-lock.json +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/package.json +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/playwright.config.js +3 -3
- package/benchmark/auto-resolve/fixtures/test-repo/server/index.js +2 -2
- package/benchmark/auto-resolve/fixtures/test-repo/web/index.html +3 -3
- package/config/skills/_shared/adapters/claude.md +6 -3
- package/config/skills/_shared/archive_run.py +25 -1
- package/config/skills/_shared/codex-config.md +1 -0
- package/config/skills/_shared/finish-gate.py +517 -0
- package/config/skills/_shared/run-bounded.py +44 -0
- package/config/skills/_shared/spec-verify-check.py +716 -93
- package/config/skills/_shared/state-phase-write.py +113 -15
- package/config/skills/_shared/verify-merge-findings.py +197 -1
- package/config/skills/devlyn:resolve/SKILL.md +37 -20
- package/config/skills/devlyn:resolve/references/free-form-mode.md +7 -7
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +14 -3
- package/config/skills/devlyn:resolve/references/phases/verify.md +19 -4
- package/config/skills/devlyn:resolve/references/state-schema.md +16 -9
- package/package.json +5 -2
- package/scripts/lint-skills.sh +1 -1
|
@@ -126,6 +126,14 @@ FORBIDDEN_RISK_PROBE_CMD_RE = re.compile(
|
|
|
126
126
|
r'BENCH_FIXTURE_DIR|benchmark/auto-resolve/fixtures|/verifiers/|verifiers/'
|
|
127
127
|
)
|
|
128
128
|
EXTERNAL_URL_RE = re.compile(r"https?://([^/\s\"']+)", re.IGNORECASE)
|
|
129
|
+
RISK_PROBE_SCRIPT_RE = re.compile(
|
|
130
|
+
r'(?<![\w./-])(\.devlyn/probes/[A-Za-z0-9][A-Za-z0-9._/-]*)'
|
|
131
|
+
)
|
|
132
|
+
RISK_PROBE_INTEGRITY_FIX_HINT = (
|
|
133
|
+
"Probe artifacts changed after PHASE 1.5. Only the orchestrator may regenerate probes: "
|
|
134
|
+
"re-run probe validation and re-write state.risk_probes_digest; workers must never modify "
|
|
135
|
+
".devlyn/risk-probes.jsonl or .devlyn/probes/."
|
|
136
|
+
)
|
|
129
137
|
INLINE_JSON_OBJECT_RE = re.compile(r'`?\{\s*"[^"\n]+"\s*:', re.IGNORECASE)
|
|
130
138
|
BACKTICKED_TEXT_RE = re.compile(r"`[^`\n]+`")
|
|
131
139
|
OBSERVABLE_COMMAND_MARKERS = ("command", "observable", "expose")
|
|
@@ -457,6 +465,113 @@ def external_url_hosts(text: str) -> list[str]:
|
|
|
457
465
|
return hosts
|
|
458
466
|
|
|
459
467
|
|
|
468
|
+
def referenced_risk_probe_scripts(cmd: str) -> list[str]:
|
|
469
|
+
scripts: list[str] = []
|
|
470
|
+
for match in RISK_PROBE_SCRIPT_RE.finditer(cmd or ""):
|
|
471
|
+
path = match.group(1)
|
|
472
|
+
if path not in scripts:
|
|
473
|
+
scripts.append(path)
|
|
474
|
+
return scripts
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def validate_risk_probe_scripts(cmd: str, index: int, work: Path) -> str | None:
|
|
478
|
+
for rel_path in referenced_risk_probe_scripts(cmd):
|
|
479
|
+
path = Path(rel_path)
|
|
480
|
+
if ".." in path.parts:
|
|
481
|
+
return f"risk-probes[{index}].cmd references invalid probe script path: {rel_path}"
|
|
482
|
+
script_path = work / path
|
|
483
|
+
if not script_path.is_file():
|
|
484
|
+
return f"risk-probes[{index}].cmd references missing probe script: {rel_path}"
|
|
485
|
+
try:
|
|
486
|
+
content = script_path.read_text(encoding="utf-8")
|
|
487
|
+
except OSError as e:
|
|
488
|
+
return f"risk-probes[{index}].cmd references unreadable probe script {rel_path}: {e}"
|
|
489
|
+
if FORBIDDEN_RISK_PROBE_CMD_RE.search(content):
|
|
490
|
+
return (
|
|
491
|
+
f"risk-probes[{index}].cmd references {rel_path}, whose content "
|
|
492
|
+
"references hidden fixture/verifier paths; risk probes must "
|
|
493
|
+
"derive from visible spec text only"
|
|
494
|
+
)
|
|
495
|
+
external_hosts = external_url_hosts(content)
|
|
496
|
+
if external_hosts:
|
|
497
|
+
return (
|
|
498
|
+
f"risk-probes[{index}].cmd references {rel_path}, whose content "
|
|
499
|
+
f"references external URL(s): {', '.join(external_hosts)}; "
|
|
500
|
+
"use only worktree-local or localhost resources"
|
|
501
|
+
)
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def risk_probe_script_path(work: Path, rel_path: str) -> tuple[Path | None, str | None]:
|
|
506
|
+
path = Path(rel_path)
|
|
507
|
+
if path.is_absolute() or ".." in path.parts:
|
|
508
|
+
return (None, f"referenced probe script path is invalid: {rel_path}")
|
|
509
|
+
script_path = work / path
|
|
510
|
+
if not script_path.is_file():
|
|
511
|
+
return (None, f"referenced probe script is missing: {rel_path}")
|
|
512
|
+
return (script_path, None)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def risk_probes_digest(devlyn_dir: Path) -> tuple[str | None, str | None]:
|
|
516
|
+
probes_path = devlyn_dir / "risk-probes.jsonl"
|
|
517
|
+
if not probes_path.is_file():
|
|
518
|
+
return (None, "missing .devlyn/risk-probes.jsonl")
|
|
519
|
+
try:
|
|
520
|
+
probes_bytes = probes_path.read_bytes()
|
|
521
|
+
except OSError as e:
|
|
522
|
+
return (None, f"cannot read .devlyn/risk-probes.jsonl: {e}")
|
|
523
|
+
try:
|
|
524
|
+
probes_text = probes_bytes.decode("utf-8")
|
|
525
|
+
except UnicodeDecodeError as e:
|
|
526
|
+
return (None, f".devlyn/risk-probes.jsonl is not UTF-8: {e}")
|
|
527
|
+
|
|
528
|
+
scripts: set[str] = set()
|
|
529
|
+
for index, line in enumerate(probes_text.splitlines()):
|
|
530
|
+
if not line.strip():
|
|
531
|
+
continue
|
|
532
|
+
try:
|
|
533
|
+
probe = loads_strict_json(line)
|
|
534
|
+
except ValueError as e:
|
|
535
|
+
return (None, f"risk-probes[{index}] invalid JSON: {e}")
|
|
536
|
+
cmd = probe.get("cmd") if isinstance(probe, dict) else ""
|
|
537
|
+
scripts.update(referenced_risk_probe_scripts(cmd))
|
|
538
|
+
|
|
539
|
+
digest = hashlib.sha256()
|
|
540
|
+
digest.update(b".devlyn/risk-probes.jsonl\0")
|
|
541
|
+
digest.update(probes_bytes)
|
|
542
|
+
digest.update(b"\0")
|
|
543
|
+
for rel_path in sorted(scripts):
|
|
544
|
+
script_path, path_error = risk_probe_script_path(devlyn_dir.parent, rel_path)
|
|
545
|
+
if path_error:
|
|
546
|
+
return (None, path_error)
|
|
547
|
+
assert script_path is not None
|
|
548
|
+
try:
|
|
549
|
+
script_bytes = script_path.read_bytes()
|
|
550
|
+
except OSError as e:
|
|
551
|
+
return (None, f"cannot read {rel_path}: {e}")
|
|
552
|
+
digest.update(rel_path.encode("utf-8"))
|
|
553
|
+
digest.update(b"\0")
|
|
554
|
+
digest.update(script_bytes)
|
|
555
|
+
digest.update(b"\0")
|
|
556
|
+
return (digest.hexdigest(), None)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def risk_probe_integrity_error(state: dict, devlyn_dir: Path) -> str | None:
|
|
560
|
+
if not state_requires_risk_probes(state) and not (devlyn_dir / "risk-probes.jsonl").is_file():
|
|
561
|
+
return None
|
|
562
|
+
expected = state.get("risk_probes_digest")
|
|
563
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
564
|
+
return "pipeline.state.json risk_probes_digest is required when risk probes are enabled or present"
|
|
565
|
+
actual, digest_error = risk_probes_digest(devlyn_dir)
|
|
566
|
+
if digest_error:
|
|
567
|
+
return digest_error
|
|
568
|
+
assert actual is not None
|
|
569
|
+
expected = expected.strip()
|
|
570
|
+
if expected != actual:
|
|
571
|
+
return f"pipeline.state.json risk_probes_digest mismatch: expected {expected}, actual {actual}"
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
460
575
|
def validate_shape(data) -> str | None:
|
|
461
576
|
"""Return None if shape matches the canonical verification_commands
|
|
462
577
|
schema; else a human-readable error string.
|
|
@@ -629,7 +744,12 @@ def validate_sibling_spec_complexity(expected_path: Path) -> str | None:
|
|
|
629
744
|
return validate_present_spec_complexity(spec_text)
|
|
630
745
|
|
|
631
746
|
|
|
632
|
-
def validate_risk_probe(
|
|
747
|
+
def validate_risk_probe(
|
|
748
|
+
probe: object,
|
|
749
|
+
index: int,
|
|
750
|
+
verification_text: str,
|
|
751
|
+
work: Path,
|
|
752
|
+
) -> str | None:
|
|
633
753
|
if not isinstance(probe, dict):
|
|
634
754
|
return f"risk-probes[{index}] must be a JSON object"
|
|
635
755
|
probe_id = probe.get("id")
|
|
@@ -658,6 +778,9 @@ def validate_risk_probe(probe: object, index: int, verification_text: str) -> st
|
|
|
658
778
|
f"risk-probes[{index}].cmd references external URL(s): "
|
|
659
779
|
f"{', '.join(external_hosts)}; use only worktree-local or localhost resources"
|
|
660
780
|
)
|
|
781
|
+
script_err = validate_risk_probe_scripts(cmd, index, work)
|
|
782
|
+
if script_err:
|
|
783
|
+
return script_err
|
|
661
784
|
if len(cmd) > 4000:
|
|
662
785
|
return f"risk-probes[{index}].cmd exceeds 4000 characters"
|
|
663
786
|
tags = probe.get("tags")
|
|
@@ -786,7 +909,7 @@ def load_risk_probes(
|
|
|
786
909
|
probe = loads_strict_json(line)
|
|
787
910
|
except ValueError as e:
|
|
788
911
|
return ([], f"risk-probes[{index}] invalid JSON: {e}")
|
|
789
|
-
err = validate_risk_probe(probe, index, verification_text)
|
|
912
|
+
err = validate_risk_probe(probe, index, verification_text, devlyn_dir.parent)
|
|
790
913
|
if err:
|
|
791
914
|
return ([], err)
|
|
792
915
|
normalized = dict(probe)
|
|
@@ -1006,6 +1129,28 @@ def write_malformed_finding(devlyn_dir: Path, error: str, source_path: Path | No
|
|
|
1006
1129
|
fh.write(json.dumps(finding) + "\n")
|
|
1007
1130
|
|
|
1008
1131
|
|
|
1132
|
+
def write_risk_probe_integrity_finding(devlyn_dir: Path, error: str) -> None:
|
|
1133
|
+
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
1134
|
+
findings_path = devlyn_dir / output_findings_name()
|
|
1135
|
+
finding = {
|
|
1136
|
+
"id": f"{output_finding_prefix()}-0001",
|
|
1137
|
+
"rule_id": "correctness.risk-probe-integrity",
|
|
1138
|
+
"level": "error",
|
|
1139
|
+
"severity": "CRITICAL",
|
|
1140
|
+
"confidence": 1.0,
|
|
1141
|
+
"message": f"Risk probe artifact integrity check failed: {error}.",
|
|
1142
|
+
"file": ".devlyn/risk-probes.jsonl",
|
|
1143
|
+
"line": 1,
|
|
1144
|
+
"phase": output_phase(),
|
|
1145
|
+
"criterion_ref": "risk-probes://digest",
|
|
1146
|
+
"fix_hint": RISK_PROBE_INTEGRITY_FIX_HINT,
|
|
1147
|
+
"blocking": True,
|
|
1148
|
+
"status": "open",
|
|
1149
|
+
}
|
|
1150
|
+
with findings_path.open("w") as fh:
|
|
1151
|
+
fh.write(json.dumps(finding) + "\n")
|
|
1152
|
+
|
|
1153
|
+
|
|
1009
1154
|
def slice_diff_to_files(diff_text: str, files: list[str]) -> str:
|
|
1010
1155
|
if not files:
|
|
1011
1156
|
return diff_text
|
|
@@ -1238,62 +1383,82 @@ def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
|
1238
1383
|
return False
|
|
1239
1384
|
|
|
1240
1385
|
|
|
1241
|
-
def
|
|
1242
|
-
|
|
1243
|
-
) -> tuple[list[dict], int]:
|
|
1244
|
-
"""BUILD_GATE-only (caller gates on `output_phase() == "build_gate"`):
|
|
1245
|
-
enforce PLAN's declared `authorized_surface` against this run's diff.
|
|
1386
|
+
def is_devlyn_path(path: str) -> bool:
|
|
1387
|
+
return path == ".devlyn" or path.startswith(".devlyn/")
|
|
1246
1388
|
|
|
1247
|
-
iter-0046: closes the scope-leak drift class measured in
|
|
1248
|
-
autoresearch/iterations/0042 + 0045 — bare-model diffs leak an
|
|
1249
|
-
out-of-scope tracked file even with the full CLAUDE.md contract loaded,
|
|
1250
|
-
on every measured model tier. `fix_hint` deliberately never offers
|
|
1251
|
-
"widen the surface" — the fix-loop respawns the same IMPLEMENT worker
|
|
1252
|
-
that produced the leak, and letting it edit `plan.md` to authorize its
|
|
1253
|
-
own diff would let it self-authorize the exact drift this gate exists
|
|
1254
|
-
to catch. A persistent finding exhausts the existing BUILD_GATE
|
|
1255
|
-
fix-loop budget and halts for user/orchestrator review instead.
|
|
1256
1389
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1390
|
+
def git_status_entries(work: Path) -> tuple[list[tuple[str, str]], str | None]:
|
|
1391
|
+
proc = subprocess.run(
|
|
1392
|
+
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
1393
|
+
cwd=str(work),
|
|
1394
|
+
capture_output=True,
|
|
1395
|
+
)
|
|
1396
|
+
if proc.returncode != 0:
|
|
1397
|
+
error = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
|
|
1398
|
+
return ([], error or "git status failed")
|
|
1399
|
+
entries: list[tuple[str, str]] = []
|
|
1400
|
+
fields = proc.stdout.split(b"\0")
|
|
1401
|
+
i = 0
|
|
1402
|
+
while i < len(fields):
|
|
1403
|
+
raw = fields[i]
|
|
1404
|
+
i += 1
|
|
1405
|
+
if not raw:
|
|
1406
|
+
continue
|
|
1407
|
+
if len(raw) < 4:
|
|
1408
|
+
return ([], f"unexpected git status record: {raw!r}")
|
|
1409
|
+
status = raw[:2].decode("utf-8", "surrogateescape")
|
|
1410
|
+
path = raw[3:].decode("utf-8", "surrogateescape")
|
|
1411
|
+
entries.append((status, path))
|
|
1412
|
+
if ("R" in status or "C" in status) and i < len(fields) and fields[i]:
|
|
1413
|
+
old_path = fields[i].decode("utf-8", "surrogateescape")
|
|
1414
|
+
i += 1
|
|
1415
|
+
entries.append(("D ", old_path))
|
|
1416
|
+
return (entries, None)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def current_worktree_changed_paths(work: Path) -> tuple[list[str], str | None]:
|
|
1420
|
+
entries, error = git_status_entries(work)
|
|
1421
|
+
if error:
|
|
1422
|
+
return ([], error)
|
|
1423
|
+
paths: list[str] = []
|
|
1424
|
+
seen: set[str] = set()
|
|
1425
|
+
for _status, path in entries:
|
|
1426
|
+
if is_devlyn_path(path) or path in seen:
|
|
1427
|
+
continue
|
|
1428
|
+
paths.append(path)
|
|
1429
|
+
seen.add(path)
|
|
1430
|
+
return (sorted(paths), None)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def current_untracked_files(work: Path) -> tuple[set[str], str | None]:
|
|
1434
|
+
entries, error = git_status_entries(work)
|
|
1435
|
+
if error:
|
|
1436
|
+
return (set(), error)
|
|
1437
|
+
return ({path for status, path in entries if status == "??" and not is_devlyn_path(path)}, None)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def load_untracked_baseline(devlyn_dir: Path) -> tuple[set[str], str | None]:
|
|
1441
|
+
baseline_path = devlyn_dir / "untracked.baseline"
|
|
1442
|
+
if not baseline_path.is_file():
|
|
1443
|
+
return (set(), "BUILD_GATE requires .devlyn/untracked.baseline from PHASE 0; the file is missing.")
|
|
1444
|
+
try:
|
|
1445
|
+
lines = baseline_path.read_text(encoding="utf-8").splitlines()
|
|
1446
|
+
except OSError as e:
|
|
1447
|
+
return (set(), f"Cannot read {baseline_path}: {e}")
|
|
1448
|
+
return ({line for line in lines if line and not is_devlyn_path(line)}, None)
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def load_authorized_surface(devlyn_dir: Path) -> tuple[list[str] | None, str | None]:
|
|
1262
1452
|
plan_path = devlyn_dir / "plan.md"
|
|
1263
1453
|
if not plan_path.is_file():
|
|
1264
|
-
return (
|
|
1265
|
-
|
|
1266
|
-
"
|
|
1267
|
-
|
|
1268
|
-
"severity": "CRITICAL",
|
|
1269
|
-
"confidence": 1.0,
|
|
1270
|
-
"message": "BUILD_GATE requires .devlyn/plan.md with a declared authorized_surface; the file is missing.",
|
|
1271
|
-
"file": ".devlyn/plan.md",
|
|
1272
|
-
"line": 1,
|
|
1273
|
-
"phase": output_phase(),
|
|
1274
|
-
"criterion_ref": "plan.md/authorized_surface",
|
|
1275
|
-
"fix_hint": "PLAN must write .devlyn/plan.md with a `Files to touch` section and an authorized_surface json block before BUILD_GATE can run.",
|
|
1276
|
-
"blocking": True,
|
|
1277
|
-
"status": "open",
|
|
1278
|
-
}], finding_start + 1)
|
|
1454
|
+
return (
|
|
1455
|
+
None,
|
|
1456
|
+
"BUILD_GATE requires .devlyn/plan.md with a declared authorized_surface; the file is missing.",
|
|
1457
|
+
)
|
|
1279
1458
|
try:
|
|
1280
1459
|
plan_text = plan_path.read_text(encoding="utf-8")
|
|
1281
1460
|
except OSError as e:
|
|
1282
|
-
return (
|
|
1283
|
-
"id": f"{output_finding_prefix()}-{finding_start:04d}",
|
|
1284
|
-
"rule_id": "scope.authorized-surface-malformed",
|
|
1285
|
-
"level": "error",
|
|
1286
|
-
"severity": "CRITICAL",
|
|
1287
|
-
"confidence": 1.0,
|
|
1288
|
-
"message": f"Cannot read {plan_path}: {e}",
|
|
1289
|
-
"file": ".devlyn/plan.md",
|
|
1290
|
-
"line": 1,
|
|
1291
|
-
"phase": output_phase(),
|
|
1292
|
-
"criterion_ref": "plan.md/authorized_surface",
|
|
1293
|
-
"fix_hint": "Ensure .devlyn/plan.md is a readable UTF-8 file.",
|
|
1294
|
-
"blocking": True,
|
|
1295
|
-
"status": "open",
|
|
1296
|
-
}], finding_start + 1)
|
|
1461
|
+
return (None, f"Cannot read {plan_path}: {e}")
|
|
1297
1462
|
|
|
1298
1463
|
_section_found, block = extract_authorized_surface_block(plan_text)
|
|
1299
1464
|
parse_error: str | None = None
|
|
@@ -1306,64 +1471,191 @@ def authorized_surface_findings(
|
|
|
1306
1471
|
)
|
|
1307
1472
|
else:
|
|
1308
1473
|
try:
|
|
1309
|
-
|
|
1474
|
+
parsed = loads_strict_json(block)
|
|
1310
1475
|
except ValueError as e:
|
|
1311
1476
|
parse_error = f"authorized_surface json block is invalid JSON: {e}"
|
|
1477
|
+
else:
|
|
1478
|
+
data = parsed
|
|
1312
1479
|
if parse_error is None:
|
|
1313
1480
|
parse_error = validate_authorized_surface_shape(data)
|
|
1314
1481
|
if parse_error is not None:
|
|
1315
|
-
return (
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1482
|
+
return (None, f"plan.md authorized_surface is malformed: {parse_error}")
|
|
1483
|
+
assert data is not None
|
|
1484
|
+
return (list(data["authorized_surface"]), None)
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def scope_finding(
|
|
1488
|
+
seq: int,
|
|
1489
|
+
rule_id: str,
|
|
1490
|
+
message: str,
|
|
1491
|
+
file_ref: str,
|
|
1492
|
+
fix_hint: str,
|
|
1493
|
+
) -> dict:
|
|
1494
|
+
return {
|
|
1495
|
+
"id": f"{output_finding_prefix()}-{seq:04d}",
|
|
1496
|
+
"rule_id": rule_id,
|
|
1497
|
+
"level": "error",
|
|
1498
|
+
"severity": "CRITICAL",
|
|
1499
|
+
"confidence": 1.0,
|
|
1500
|
+
"message": message,
|
|
1501
|
+
"file": file_ref,
|
|
1502
|
+
"line": 1,
|
|
1503
|
+
"phase": output_phase(),
|
|
1504
|
+
"criterion_ref": "plan.md/authorized_surface",
|
|
1505
|
+
"fix_hint": fix_hint,
|
|
1506
|
+
"blocking": True,
|
|
1507
|
+
"status": "open",
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def authorized_surface_findings(
|
|
1512
|
+
work: Path, devlyn_dir: Path, state: dict, finding_start: int,
|
|
1513
|
+
) -> tuple[list[dict], int]:
|
|
1514
|
+
"""BUILD_GATE-only (caller gates on `output_phase() == "build_gate"`):
|
|
1515
|
+
enforce PLAN's declared `authorized_surface` against this run's diff.
|
|
1516
|
+
|
|
1517
|
+
This closes the measured scope-leak drift class: bare-model diffs leaked
|
|
1518
|
+
an out-of-scope tracked file even with the full CLAUDE.md contract loaded,
|
|
1519
|
+
on every measured model tier. `fix_hint` deliberately never offers
|
|
1520
|
+
"widen the surface" — the fix-loop respawns the same IMPLEMENT worker
|
|
1521
|
+
that produced the leak, and letting it edit `plan.md` to authorize its
|
|
1522
|
+
own diff would let it self-authorize the exact drift this gate exists
|
|
1523
|
+
to catch. A persistent finding exhausts the existing BUILD_GATE
|
|
1524
|
+
fix-loop budget and halts for user/orchestrator review instead.
|
|
1525
|
+
|
|
1526
|
+
Not re-run at VERIFY MECHANICAL time (post-CLEANUP): CLEANUP's own
|
|
1527
|
+
allowlist (tooling artifacts, dead code, doc-reference fixes) licenses
|
|
1528
|
+
paths PLAN never declared, so re-checking there would false-positive on
|
|
1529
|
+
CLEANUP's own sanctioned changes.
|
|
1530
|
+
"""
|
|
1531
|
+
surface, surface_error = load_authorized_surface(devlyn_dir)
|
|
1532
|
+
if surface_error is not None:
|
|
1533
|
+
return ([scope_finding(
|
|
1534
|
+
finding_start,
|
|
1535
|
+
"scope.authorized-surface-malformed",
|
|
1536
|
+
surface_error,
|
|
1537
|
+
".devlyn/plan.md",
|
|
1538
|
+
(
|
|
1539
|
+
"PLAN must write .devlyn/plan.md with a `Files to touch` "
|
|
1540
|
+
"section and an authorized_surface json block before "
|
|
1541
|
+
"BUILD_GATE can run."
|
|
1331
1542
|
),
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1543
|
+
)], finding_start + 1)
|
|
1544
|
+
assert surface is not None
|
|
1545
|
+
|
|
1546
|
+
baseline, baseline_error = load_untracked_baseline(devlyn_dir)
|
|
1547
|
+
if baseline_error is not None:
|
|
1548
|
+
return ([scope_finding(
|
|
1549
|
+
finding_start,
|
|
1550
|
+
"scope.authorized-surface-malformed",
|
|
1551
|
+
baseline_error,
|
|
1552
|
+
".devlyn/untracked.baseline",
|
|
1553
|
+
(
|
|
1554
|
+
"PHASE 0 must write .devlyn/untracked.baseline before "
|
|
1555
|
+
"BUILD_GATE so created-during-run untracked files remain "
|
|
1556
|
+
"visible to the scope gate."
|
|
1557
|
+
),
|
|
1558
|
+
)], finding_start + 1)
|
|
1559
|
+
current_untracked, untracked_error = current_untracked_files(work)
|
|
1560
|
+
if untracked_error is not None:
|
|
1561
|
+
return ([scope_finding(
|
|
1562
|
+
finding_start,
|
|
1563
|
+
"scope.authorized-surface-malformed",
|
|
1564
|
+
f"Cannot read current untracked files: {untracked_error}",
|
|
1565
|
+
".devlyn/untracked.baseline",
|
|
1566
|
+
"Ensure BUILD_GATE runs inside a readable git worktree.",
|
|
1567
|
+
)], finding_start + 1)
|
|
1335
1568
|
|
|
1336
|
-
surface = data["authorized_surface"]
|
|
1337
1569
|
findings: list[dict] = []
|
|
1338
1570
|
seq = finding_start
|
|
1339
1571
|
for path in changed_files(work, state, devlyn_dir):
|
|
1340
1572
|
if path_matches_surface(path, surface):
|
|
1341
1573
|
continue
|
|
1342
|
-
findings.append(
|
|
1343
|
-
|
|
1344
|
-
"
|
|
1345
|
-
"
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
"message": f"{path} is outside PLAN's declared authorized_surface.",
|
|
1349
|
-
"file": path,
|
|
1350
|
-
"line": 1,
|
|
1351
|
-
"phase": output_phase(),
|
|
1352
|
-
"criterion_ref": "plan.md/authorized_surface",
|
|
1353
|
-
"fix_hint": (
|
|
1574
|
+
findings.append(scope_finding(
|
|
1575
|
+
seq,
|
|
1576
|
+
"scope.out-of-scope-file",
|
|
1577
|
+
f"{path} is outside PLAN's declared authorized_surface.",
|
|
1578
|
+
path,
|
|
1579
|
+
(
|
|
1354
1580
|
f"Remove {path} from the diff. Do not widen plan.md's "
|
|
1355
1581
|
"authorized_surface to include it — that would let this "
|
|
1356
1582
|
"fix loop self-authorize its own scope leak. If the file "
|
|
1357
1583
|
"is genuinely required, halt per implement.md's contract "
|
|
1358
1584
|
"so this finding reaches the user/orchestrator for a new run."
|
|
1359
1585
|
),
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1586
|
+
))
|
|
1587
|
+
seq += 1
|
|
1588
|
+
for path in sorted(current_untracked - baseline):
|
|
1589
|
+
if path_matches_surface(path, surface):
|
|
1590
|
+
continue
|
|
1591
|
+
findings.append(scope_finding(
|
|
1592
|
+
seq,
|
|
1593
|
+
"scope.out-of-scope-file",
|
|
1594
|
+
f"{path} is a created-during-run unauthorized untracked file.",
|
|
1595
|
+
path,
|
|
1596
|
+
(
|
|
1597
|
+
f"Remove {path}. Do not widen plan.md's authorized_surface "
|
|
1598
|
+
"to include it — new untracked leaks must be removed, not "
|
|
1599
|
+
"self-authorized by the fix loop."
|
|
1600
|
+
),
|
|
1601
|
+
))
|
|
1363
1602
|
seq += 1
|
|
1364
1603
|
return (findings, seq)
|
|
1365
1604
|
|
|
1366
1605
|
|
|
1606
|
+
def run_print_authorized_surface(work: Path, devlyn_dir: Path) -> int:
|
|
1607
|
+
surface, surface_error = load_authorized_surface(devlyn_dir)
|
|
1608
|
+
if surface_error is not None:
|
|
1609
|
+
print(f"[spec-verify --print-authorized-surface] {surface_error}", file=sys.stderr)
|
|
1610
|
+
return 2
|
|
1611
|
+
assert surface is not None
|
|
1612
|
+
paths, status_error = current_worktree_changed_paths(work)
|
|
1613
|
+
if status_error is not None:
|
|
1614
|
+
print(f"[spec-verify --print-authorized-surface] git status failed: {status_error}", file=sys.stderr)
|
|
1615
|
+
return 2
|
|
1616
|
+
authorized_paths = [path for path in paths if path_matches_surface(path, surface)]
|
|
1617
|
+
if authorized_paths:
|
|
1618
|
+
sys.stdout.buffer.write("\0".join(authorized_paths).encode("utf-8", "surrogateescape") + b"\0")
|
|
1619
|
+
return 0
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def run_print_risk_probes_digest(devlyn_dir: Path) -> int:
|
|
1623
|
+
digest, error = risk_probes_digest(devlyn_dir)
|
|
1624
|
+
if error:
|
|
1625
|
+
print(f"[spec-verify --print-risk-probes-digest] {error}", file=sys.stderr)
|
|
1626
|
+
return 2
|
|
1627
|
+
assert digest is not None
|
|
1628
|
+
print(digest)
|
|
1629
|
+
return 0
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def run_write_untracked_baseline(work: Path, devlyn_dir: Path) -> int:
|
|
1633
|
+
"""PHASE 0 writer for `.devlyn/untracked.baseline`. Shares
|
|
1634
|
+
git_status_entries with the BUILD_GATE reader so writer and comparer can
|
|
1635
|
+
never disagree on quoting or directory collapsing (a shell-side
|
|
1636
|
+
`git status --porcelain | awk` writer records untracked directories as
|
|
1637
|
+
`dir/` and C-quotes special characters, while the comparer sees
|
|
1638
|
+
`--untracked-files=all` unquoted per-file paths — every pre-existing
|
|
1639
|
+
file under an untracked directory would false-positive as
|
|
1640
|
+
created-during-run)."""
|
|
1641
|
+
entries, error = git_status_entries(work)
|
|
1642
|
+
if error:
|
|
1643
|
+
print(f"[spec-verify --write-untracked-baseline] git status failed: {error}", file=sys.stderr)
|
|
1644
|
+
return 2
|
|
1645
|
+
untracked = sorted(
|
|
1646
|
+
path for status, path in entries
|
|
1647
|
+
if status == "??" and not is_devlyn_path(path)
|
|
1648
|
+
)
|
|
1649
|
+
devlyn_dir.mkdir(parents=True, exist_ok=True)
|
|
1650
|
+
baseline_path = devlyn_dir / "untracked.baseline"
|
|
1651
|
+
baseline_path.write_text(
|
|
1652
|
+
"".join(path + "\n" for path in untracked),
|
|
1653
|
+
encoding="utf-8",
|
|
1654
|
+
errors="surrogateescape",
|
|
1655
|
+
)
|
|
1656
|
+
return 0
|
|
1657
|
+
|
|
1658
|
+
|
|
1367
1659
|
def run_check_mode(md_path: Path) -> int:
|
|
1368
1660
|
"""`--check <markdown>` — validate the verification carrier without
|
|
1369
1661
|
running any commands. Used by /devlyn:ideate after item-spec write.
|
|
@@ -1461,10 +1753,14 @@ def run_self_test() -> int:
|
|
|
1461
1753
|
{"cmd": "printf ok", "exit_code": 0, "stdout_contains": ["ok"]}
|
|
1462
1754
|
]
|
|
1463
1755
|
}) + "\n")
|
|
1464
|
-
|
|
1756
|
+
probes_dir = devlyn / "probes"
|
|
1757
|
+
probes_dir.mkdir()
|
|
1758
|
+
probe_script = probes_dir / "P1.py"
|
|
1759
|
+
probe_script.write_text("print('probe-ok')\n", encoding="utf-8")
|
|
1760
|
+
risk_probe_payload = {
|
|
1465
1761
|
"id": "P1",
|
|
1466
1762
|
"derived_from": "probe must pass visible marker.",
|
|
1467
|
-
"cmd": "
|
|
1763
|
+
"cmd": "python3 .devlyn/probes/P1.py",
|
|
1468
1764
|
"exit_code": 0,
|
|
1469
1765
|
"stdout_contains": ["probe-ok"],
|
|
1470
1766
|
"stdout_not_contains": [],
|
|
@@ -1476,9 +1772,42 @@ def run_self_test() -> int:
|
|
|
1476
1772
|
"asserts_no_unexpected_output_keys",
|
|
1477
1773
|
],
|
|
1478
1774
|
},
|
|
1479
|
-
}
|
|
1775
|
+
}
|
|
1776
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n")
|
|
1480
1777
|
env = os.environ.copy()
|
|
1481
1778
|
env["BENCH_WORKDIR"] = str(work)
|
|
1779
|
+
validate_without_digest = subprocess.run(
|
|
1780
|
+
[sys.executable, script_path, "--validate-risk-probes"],
|
|
1781
|
+
cwd=work,
|
|
1782
|
+
env=env,
|
|
1783
|
+
capture_output=True,
|
|
1784
|
+
text=True,
|
|
1785
|
+
)
|
|
1786
|
+
if validate_without_digest.returncode != 0:
|
|
1787
|
+
print("--validate-risk-probes rejected valid probes without digest", file=sys.stderr)
|
|
1788
|
+
print(validate_without_digest.stderr, file=sys.stderr)
|
|
1789
|
+
return 1
|
|
1790
|
+
digest_run = subprocess.run(
|
|
1791
|
+
[sys.executable, script_path, "--print-risk-probes-digest"],
|
|
1792
|
+
cwd=work,
|
|
1793
|
+
env=env,
|
|
1794
|
+
capture_output=True,
|
|
1795
|
+
text=True,
|
|
1796
|
+
)
|
|
1797
|
+
if digest_run.returncode != 0:
|
|
1798
|
+
print("--print-risk-probes-digest rejected valid probes", file=sys.stderr)
|
|
1799
|
+
print(digest_run.stderr, file=sys.stderr)
|
|
1800
|
+
return 1
|
|
1801
|
+
risk_digest = digest_run.stdout.strip()
|
|
1802
|
+
if not re.fullmatch(r"[0-9a-f]{64}", risk_digest):
|
|
1803
|
+
print("--print-risk-probes-digest printed a non-sha256 digest", file=sys.stderr)
|
|
1804
|
+
print(repr(digest_run.stdout), file=sys.stderr)
|
|
1805
|
+
return 1
|
|
1806
|
+
(devlyn / "pipeline.state.json").write_text(json.dumps({
|
|
1807
|
+
"source": {"type": "spec", "spec_path": str(spec_md)},
|
|
1808
|
+
"risk_profile": {"risk_probes_enabled": True},
|
|
1809
|
+
"risk_probes_digest": risk_digest,
|
|
1810
|
+
}))
|
|
1482
1811
|
good = subprocess.run(
|
|
1483
1812
|
[sys.executable, script_path, "--include-risk-probes"],
|
|
1484
1813
|
cwd=work,
|
|
@@ -1490,10 +1819,84 @@ def run_self_test() -> int:
|
|
|
1490
1819
|
print(good.stderr, file=sys.stderr)
|
|
1491
1820
|
return 1
|
|
1492
1821
|
|
|
1822
|
+
probe_script.write_text("print('mutated-probe')\n", encoding="utf-8")
|
|
1823
|
+
mutated_script = subprocess.run(
|
|
1824
|
+
[sys.executable, script_path, "--include-risk-probes"],
|
|
1825
|
+
cwd=work,
|
|
1826
|
+
env=env,
|
|
1827
|
+
capture_output=True,
|
|
1828
|
+
text=True,
|
|
1829
|
+
)
|
|
1830
|
+
if mutated_script.returncode == 0:
|
|
1831
|
+
print("--include-risk-probes accepted mutated probe script bytes", file=sys.stderr)
|
|
1832
|
+
return 1
|
|
1833
|
+
integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
|
|
1834
|
+
if "correctness.risk-probe-integrity" not in integrity_findings:
|
|
1835
|
+
print("mutated probe script did not emit correctness.risk-probe-integrity", file=sys.stderr)
|
|
1836
|
+
print(integrity_findings, file=sys.stderr)
|
|
1837
|
+
return 1
|
|
1838
|
+
probe_script.write_text("print('probe-ok')\n", encoding="utf-8")
|
|
1839
|
+
|
|
1840
|
+
mutated_payload = dict(risk_probe_payload)
|
|
1841
|
+
mutated_payload["id"] = "P1-mutated"
|
|
1842
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps(mutated_payload) + "\n")
|
|
1843
|
+
mutated_jsonl = subprocess.run(
|
|
1844
|
+
[sys.executable, script_path, "--include-risk-probes"],
|
|
1845
|
+
cwd=work,
|
|
1846
|
+
env=env,
|
|
1847
|
+
capture_output=True,
|
|
1848
|
+
text=True,
|
|
1849
|
+
)
|
|
1850
|
+
if mutated_jsonl.returncode == 0:
|
|
1851
|
+
print("--include-risk-probes accepted mutated risk-probes.jsonl bytes", file=sys.stderr)
|
|
1852
|
+
return 1
|
|
1853
|
+
integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
|
|
1854
|
+
if "correctness.risk-probe-integrity" not in integrity_findings:
|
|
1855
|
+
print("mutated risk-probes.jsonl did not emit correctness.risk-probe-integrity", file=sys.stderr)
|
|
1856
|
+
print(integrity_findings, file=sys.stderr)
|
|
1857
|
+
return 1
|
|
1858
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n")
|
|
1859
|
+
|
|
1860
|
+
(devlyn / "pipeline.state.json").write_text(json.dumps({
|
|
1861
|
+
"source": {"type": "spec", "spec_path": str(spec_md)},
|
|
1862
|
+
"risk_profile": {"risk_probes_enabled": True},
|
|
1863
|
+
}))
|
|
1864
|
+
missing_digest = subprocess.run(
|
|
1865
|
+
[sys.executable, script_path, "--include-risk-probes"],
|
|
1866
|
+
cwd=work,
|
|
1867
|
+
env=env,
|
|
1868
|
+
capture_output=True,
|
|
1869
|
+
text=True,
|
|
1870
|
+
)
|
|
1871
|
+
if missing_digest.returncode == 0:
|
|
1872
|
+
print("--include-risk-probes accepted enabled risk probes with missing digest", file=sys.stderr)
|
|
1873
|
+
return 1
|
|
1874
|
+
integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
|
|
1875
|
+
if "correctness.risk-probe-integrity" not in integrity_findings:
|
|
1876
|
+
print("missing risk_probes_digest did not emit correctness.risk-probe-integrity", file=sys.stderr)
|
|
1877
|
+
print(integrity_findings, file=sys.stderr)
|
|
1878
|
+
return 1
|
|
1879
|
+
|
|
1493
1880
|
(devlyn / "risk-probes.jsonl").unlink()
|
|
1881
|
+
missing_jsonl_digest = subprocess.run(
|
|
1882
|
+
[sys.executable, script_path, "--print-risk-probes-digest"],
|
|
1883
|
+
cwd=work,
|
|
1884
|
+
env=env,
|
|
1885
|
+
capture_output=True,
|
|
1886
|
+
text=True,
|
|
1887
|
+
)
|
|
1888
|
+
if missing_jsonl_digest.returncode != 2:
|
|
1889
|
+
print("--print-risk-probes-digest accepted missing risk-probes.jsonl", file=sys.stderr)
|
|
1890
|
+
print(missing_jsonl_digest.stderr, file=sys.stderr)
|
|
1891
|
+
return 1
|
|
1892
|
+
if "missing .devlyn/risk-probes.jsonl" not in missing_jsonl_digest.stderr:
|
|
1893
|
+
print("missing risk-probes.jsonl digest mode had the wrong error", file=sys.stderr)
|
|
1894
|
+
print(missing_jsonl_digest.stderr, file=sys.stderr)
|
|
1895
|
+
return 1
|
|
1494
1896
|
(devlyn / "pipeline.state.json").write_text(json.dumps({
|
|
1495
1897
|
"source": {"type": "spec", "spec_path": str(spec_md)},
|
|
1496
1898
|
"risk_profile": {"risk_probes_enabled": True},
|
|
1899
|
+
"risk_probes_digest": risk_digest,
|
|
1497
1900
|
}))
|
|
1498
1901
|
missing_required_probe = subprocess.run(
|
|
1499
1902
|
[sys.executable, script_path, "--include-risk-probes"],
|
|
@@ -1505,8 +1908,8 @@ def run_self_test() -> int:
|
|
|
1505
1908
|
if missing_required_probe.returncode == 0:
|
|
1506
1909
|
print("--include-risk-probes accepted missing required risk-probes.jsonl", file=sys.stderr)
|
|
1507
1910
|
return 1
|
|
1508
|
-
if "risk
|
|
1509
|
-
print("--include-risk-probes missing required probe had the wrong error", file=sys.stderr)
|
|
1911
|
+
if "risk probes integrity failed" not in missing_required_probe.stderr:
|
|
1912
|
+
print("--include-risk-probes missing required probe had the wrong integrity error", file=sys.stderr)
|
|
1510
1913
|
print(missing_required_probe.stderr, file=sys.stderr)
|
|
1511
1914
|
return 1
|
|
1512
1915
|
|
|
@@ -1790,6 +2193,117 @@ def run_self_test() -> int:
|
|
|
1790
2193
|
print("hidden verifier path was accepted", file=sys.stderr)
|
|
1791
2194
|
return 1
|
|
1792
2195
|
|
|
2196
|
+
probes_dir = devlyn / "probes"
|
|
2197
|
+
probes_dir.mkdir(exist_ok=True)
|
|
2198
|
+
(probes_dir / "Pscript.py").write_text("print('script-ok')\n", encoding="utf-8")
|
|
2199
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps({
|
|
2200
|
+
"id": "Pscript",
|
|
2201
|
+
"derived_from": "probe must pass visible marker.",
|
|
2202
|
+
"cmd": "python3 .devlyn/probes/Pscript.py",
|
|
2203
|
+
"exit_code": 0,
|
|
2204
|
+
"stdout_contains": ["script-ok"],
|
|
2205
|
+
"stdout_not_contains": [],
|
|
2206
|
+
"tags": ["shape_contract"],
|
|
2207
|
+
"tag_evidence": {
|
|
2208
|
+
"shape_contract": [
|
|
2209
|
+
"uses_visible_input_key_names",
|
|
2210
|
+
"asserts_visible_output_key_names",
|
|
2211
|
+
"asserts_no_unexpected_output_keys",
|
|
2212
|
+
],
|
|
2213
|
+
},
|
|
2214
|
+
}) + "\n")
|
|
2215
|
+
good_script_probe = subprocess.run(
|
|
2216
|
+
[sys.executable, script_path, "--validate-risk-probes"],
|
|
2217
|
+
cwd=work,
|
|
2218
|
+
env=env,
|
|
2219
|
+
capture_output=True,
|
|
2220
|
+
text=True,
|
|
2221
|
+
)
|
|
2222
|
+
if good_script_probe.returncode != 0:
|
|
2223
|
+
print("risk probe script file was rejected", file=sys.stderr)
|
|
2224
|
+
print(good_script_probe.stderr, file=sys.stderr)
|
|
2225
|
+
return 1
|
|
2226
|
+
|
|
2227
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps({
|
|
2228
|
+
"id": "Pmissing",
|
|
2229
|
+
"derived_from": "probe must pass visible marker.",
|
|
2230
|
+
"cmd": "python3 .devlyn/probes/missing.py",
|
|
2231
|
+
"exit_code": 0,
|
|
2232
|
+
}) + "\n")
|
|
2233
|
+
missing_script_probe = subprocess.run(
|
|
2234
|
+
[sys.executable, script_path, "--validate-risk-probes"],
|
|
2235
|
+
cwd=work,
|
|
2236
|
+
env=env,
|
|
2237
|
+
capture_output=True,
|
|
2238
|
+
text=True,
|
|
2239
|
+
)
|
|
2240
|
+
if missing_script_probe.returncode == 0:
|
|
2241
|
+
print("risk probe missing script file was accepted", file=sys.stderr)
|
|
2242
|
+
return 1
|
|
2243
|
+
if "references missing probe script" not in missing_script_probe.stderr:
|
|
2244
|
+
print("missing risk probe script had the wrong error", file=sys.stderr)
|
|
2245
|
+
print(missing_script_probe.stderr, file=sys.stderr)
|
|
2246
|
+
return 1
|
|
2247
|
+
missing_script_digest = subprocess.run(
|
|
2248
|
+
[sys.executable, script_path, "--print-risk-probes-digest"],
|
|
2249
|
+
cwd=work,
|
|
2250
|
+
env=env,
|
|
2251
|
+
capture_output=True,
|
|
2252
|
+
text=True,
|
|
2253
|
+
)
|
|
2254
|
+
if missing_script_digest.returncode != 2:
|
|
2255
|
+
print("--print-risk-probes-digest accepted a missing referenced script", file=sys.stderr)
|
|
2256
|
+
print(missing_script_digest.stderr, file=sys.stderr)
|
|
2257
|
+
return 1
|
|
2258
|
+
if "referenced probe script is missing" not in missing_script_digest.stderr:
|
|
2259
|
+
print("missing script digest mode had the wrong error", file=sys.stderr)
|
|
2260
|
+
print(missing_script_digest.stderr, file=sys.stderr)
|
|
2261
|
+
return 1
|
|
2262
|
+
|
|
2263
|
+
(probes_dir / "Phidden.py").write_text("print('benchmark/auto-resolve/fixtures')\n", encoding="utf-8")
|
|
2264
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps({
|
|
2265
|
+
"id": "Phidden",
|
|
2266
|
+
"derived_from": "probe must pass visible marker.",
|
|
2267
|
+
"cmd": "python3 .devlyn/probes/Phidden.py",
|
|
2268
|
+
"exit_code": 0,
|
|
2269
|
+
}) + "\n")
|
|
2270
|
+
hidden_script_probe = subprocess.run(
|
|
2271
|
+
[sys.executable, script_path, "--validate-risk-probes"],
|
|
2272
|
+
cwd=work,
|
|
2273
|
+
env=env,
|
|
2274
|
+
capture_output=True,
|
|
2275
|
+
text=True,
|
|
2276
|
+
)
|
|
2277
|
+
if hidden_script_probe.returncode == 0:
|
|
2278
|
+
print("risk probe script containing a hidden fixture path was accepted", file=sys.stderr)
|
|
2279
|
+
return 1
|
|
2280
|
+
if "whose content references hidden fixture/verifier paths" not in hidden_script_probe.stderr:
|
|
2281
|
+
print("hidden-path risk probe script had the wrong error", file=sys.stderr)
|
|
2282
|
+
print(hidden_script_probe.stderr, file=sys.stderr)
|
|
2283
|
+
return 1
|
|
2284
|
+
|
|
2285
|
+
(probes_dir / "Pexternal.py").write_text("print('https://example.com/check')\n", encoding="utf-8")
|
|
2286
|
+
(devlyn / "risk-probes.jsonl").write_text(json.dumps({
|
|
2287
|
+
"id": "Pexternal",
|
|
2288
|
+
"derived_from": "probe must pass visible marker.",
|
|
2289
|
+
"cmd": "python3 .devlyn/probes/Pexternal.py",
|
|
2290
|
+
"exit_code": 0,
|
|
2291
|
+
}) + "\n")
|
|
2292
|
+
external_script_probe = subprocess.run(
|
|
2293
|
+
[sys.executable, script_path, "--validate-risk-probes"],
|
|
2294
|
+
cwd=work,
|
|
2295
|
+
env=env,
|
|
2296
|
+
capture_output=True,
|
|
2297
|
+
text=True,
|
|
2298
|
+
)
|
|
2299
|
+
if external_script_probe.returncode == 0:
|
|
2300
|
+
print("risk probe script containing an external URL was accepted", file=sys.stderr)
|
|
2301
|
+
return 1
|
|
2302
|
+
if "whose content references external URL" not in external_script_probe.stderr:
|
|
2303
|
+
print("external-URL risk probe script had the wrong error", file=sys.stderr)
|
|
2304
|
+
print(external_script_probe.stderr, file=sys.stderr)
|
|
2305
|
+
return 1
|
|
2306
|
+
|
|
1793
2307
|
(devlyn / "risk-probes.jsonl").write_text('{"id":NaN}\n')
|
|
1794
2308
|
bad_probe_nan = subprocess.run(
|
|
1795
2309
|
[sys.executable, script_path, "--validate-risk-probes"],
|
|
@@ -3135,6 +3649,27 @@ def run_self_test() -> int:
|
|
|
3135
3649
|
"source": {"type": "spec", "spec_path": str(scope_spec)},
|
|
3136
3650
|
"base_ref": {"sha": scope_base_sha},
|
|
3137
3651
|
}))
|
|
3652
|
+
(scope_root / "preexisting.local").write_text("pre-existing untracked\n", encoding="utf-8")
|
|
3653
|
+
# Writer parity: --write-untracked-baseline must share the reader's
|
|
3654
|
+
# parser — untracked DIRECTORIES expand to per-file paths and
|
|
3655
|
+
# special-character paths stay unquoted, or every pre-existing file
|
|
3656
|
+
# under an untracked directory false-positives as created-during-run.
|
|
3657
|
+
preexisting_dir = scope_root / "pre existing dir"
|
|
3658
|
+
preexisting_dir.mkdir()
|
|
3659
|
+
(preexisting_dir / "nested file.txt").write_text("scaffold\n", encoding="utf-8")
|
|
3660
|
+
write_baseline_run = subprocess.run(
|
|
3661
|
+
[sys.executable, script_path, "--write-untracked-baseline"],
|
|
3662
|
+
cwd=scope_root, capture_output=True, text=True,
|
|
3663
|
+
)
|
|
3664
|
+
if write_baseline_run.returncode != 0:
|
|
3665
|
+
print("--write-untracked-baseline failed", file=sys.stderr)
|
|
3666
|
+
print(write_baseline_run.stderr, file=sys.stderr)
|
|
3667
|
+
return 1
|
|
3668
|
+
baseline_lines = (scope_devlyn / "untracked.baseline").read_text(encoding="utf-8").splitlines()
|
|
3669
|
+
if baseline_lines != ["pre existing dir/nested file.txt", "preexisting.local"]:
|
|
3670
|
+
print("--write-untracked-baseline wrote wrong content", file=sys.stderr)
|
|
3671
|
+
print(repr(baseline_lines), file=sys.stderr)
|
|
3672
|
+
return 1
|
|
3138
3673
|
scope_findings_path = scope_devlyn / "spec-verify-findings.jsonl"
|
|
3139
3674
|
|
|
3140
3675
|
# Test 1: no .devlyn/plan.md at all -> fail-closed CRITICAL, not a no-op.
|
|
@@ -3162,15 +3697,58 @@ def run_self_test() -> int:
|
|
|
3162
3697
|
if "scope.authorized-surface-malformed" not in scope_findings_path.read_text():
|
|
3163
3698
|
print("missing authorized_surface block did not emit scope.authorized-surface-malformed", file=sys.stderr)
|
|
3164
3699
|
return 1
|
|
3700
|
+
malformed_print_surface = subprocess.run(
|
|
3701
|
+
[sys.executable, script_path, "--print-authorized-surface"],
|
|
3702
|
+
cwd=scope_root,
|
|
3703
|
+
capture_output=True,
|
|
3704
|
+
)
|
|
3705
|
+
if malformed_print_surface.returncode == 0:
|
|
3706
|
+
print("--print-authorized-surface accepted malformed plan.md", file=sys.stderr)
|
|
3707
|
+
return 1
|
|
3165
3708
|
|
|
3166
3709
|
# Test 3: valid surface, in-scope-only diff -> no scope findings, exit 0.
|
|
3167
3710
|
(scope_devlyn / "plan.md").write_text(
|
|
3168
3711
|
"# PLAN\n\n<!-- devlyn:authorized-surface -->\n## 1. Files to touch\n\n"
|
|
3169
3712
|
"- `bin/cli.js` (edit): ship the fix.\n\n"
|
|
3170
3713
|
"```json\n"
|
|
3171
|
-
'{"authorized_surface": ["bin/cli.js", "lib/**"]}\n'
|
|
3714
|
+
'{"authorized_surface": ["bin/cli.js", "lib/**", "authorized-but-uncreated.txt"]}\n'
|
|
3172
3715
|
"```\n"
|
|
3173
3716
|
)
|
|
3717
|
+
(scope_root / "lib" / "new.js").write_text("module.exports = { created: true };\n")
|
|
3718
|
+
print_surface = subprocess.run(
|
|
3719
|
+
[sys.executable, script_path, "--print-authorized-surface"],
|
|
3720
|
+
cwd=scope_root,
|
|
3721
|
+
capture_output=True,
|
|
3722
|
+
)
|
|
3723
|
+
if print_surface.returncode != 0:
|
|
3724
|
+
print("--print-authorized-surface rejected valid plan.md", file=sys.stderr)
|
|
3725
|
+
print(print_surface.stderr.decode("utf-8", "replace"), file=sys.stderr)
|
|
3726
|
+
return 1
|
|
3727
|
+
printed_paths = {
|
|
3728
|
+
path.decode("utf-8", "surrogateescape")
|
|
3729
|
+
for path in print_surface.stdout.split(b"\0") if path
|
|
3730
|
+
}
|
|
3731
|
+
if printed_paths != {"bin/cli.js", "lib/new.js"}:
|
|
3732
|
+
print("--print-authorized-surface printed the wrong paths", file=sys.stderr)
|
|
3733
|
+
print(repr(printed_paths), file=sys.stderr)
|
|
3734
|
+
return 1
|
|
3735
|
+
(scope_devlyn / "untracked.baseline").unlink()
|
|
3736
|
+
missing_baseline_run = subprocess.run(
|
|
3737
|
+
[sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
|
|
3738
|
+
)
|
|
3739
|
+
if missing_baseline_run.returncode == 0:
|
|
3740
|
+
print("BUILD_GATE accepted missing .devlyn/untracked.baseline", file=sys.stderr)
|
|
3741
|
+
return 1
|
|
3742
|
+
if "untracked.baseline" not in scope_findings_path.read_text():
|
|
3743
|
+
print("missing untracked baseline did not emit a scope finding", file=sys.stderr)
|
|
3744
|
+
return 1
|
|
3745
|
+
rewrite_baseline_run = subprocess.run(
|
|
3746
|
+
[sys.executable, script_path, "--write-untracked-baseline"],
|
|
3747
|
+
cwd=scope_root, capture_output=True, text=True,
|
|
3748
|
+
)
|
|
3749
|
+
if rewrite_baseline_run.returncode != 0:
|
|
3750
|
+
print("--write-untracked-baseline re-run failed", file=sys.stderr)
|
|
3751
|
+
return 1
|
|
3174
3752
|
in_scope_run = subprocess.run(
|
|
3175
3753
|
[sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
|
|
3176
3754
|
)
|
|
@@ -3187,6 +3765,7 @@ def run_self_test() -> int:
|
|
|
3187
3765
|
# flagged, and the fix_hint must never suggest self-authorization.
|
|
3188
3766
|
(scope_root / "lib" / "keep.js").write_text("module.exports = { touched: true };\n")
|
|
3189
3767
|
(scope_root / "data" / "usage-stats.json").write_text('{"leaked": true}\n')
|
|
3768
|
+
(scope_root / "data" / "scratch.json").write_text('{"untracked": true}\n')
|
|
3190
3769
|
out_of_scope_run = subprocess.run(
|
|
3191
3770
|
[sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
|
|
3192
3771
|
)
|
|
@@ -3199,6 +3778,15 @@ def run_self_test() -> int:
|
|
|
3199
3778
|
if "data/usage-stats.json" not in flagged_files:
|
|
3200
3779
|
print("scope.out-of-scope-file did not name data/usage-stats.json", file=sys.stderr)
|
|
3201
3780
|
return 1
|
|
3781
|
+
if "data/scratch.json" not in flagged_files:
|
|
3782
|
+
print("created-during-run unauthorized untracked file was not flagged", file=sys.stderr)
|
|
3783
|
+
return 1
|
|
3784
|
+
if "pre existing dir/nested file.txt" in flagged_files:
|
|
3785
|
+
print("pre-existing untracked directory file was flagged (writer/reader parity broken)", file=sys.stderr)
|
|
3786
|
+
return 1
|
|
3787
|
+
if "preexisting.local" in flagged_files:
|
|
3788
|
+
print("pre-existing untracked baseline file was flagged", file=sys.stderr)
|
|
3789
|
+
return 1
|
|
3202
3790
|
if "lib/keep.js" in flagged_files:
|
|
3203
3791
|
print("lib/** directory grant did not cover lib/keep.js", file=sys.stderr)
|
|
3204
3792
|
return 1
|
|
@@ -3307,12 +3895,24 @@ def run_self_test() -> int:
|
|
|
3307
3895
|
def main() -> int:
|
|
3308
3896
|
include_risk_probes = False
|
|
3309
3897
|
validate_risk_probes_only = False
|
|
3898
|
+
print_risk_probes_digest = False
|
|
3899
|
+
print_authorized_surface = False
|
|
3900
|
+
write_untracked_baseline = False
|
|
3310
3901
|
if "--include-risk-probes" in sys.argv[1:]:
|
|
3311
3902
|
include_risk_probes = True
|
|
3312
3903
|
sys.argv = [arg for arg in sys.argv if arg != "--include-risk-probes"]
|
|
3313
3904
|
if "--validate-risk-probes" in sys.argv[1:]:
|
|
3314
3905
|
validate_risk_probes_only = True
|
|
3315
3906
|
sys.argv = [arg for arg in sys.argv if arg != "--validate-risk-probes"]
|
|
3907
|
+
if "--print-risk-probes-digest" in sys.argv[1:]:
|
|
3908
|
+
print_risk_probes_digest = True
|
|
3909
|
+
sys.argv = [arg for arg in sys.argv if arg != "--print-risk-probes-digest"]
|
|
3910
|
+
if "--print-authorized-surface" in sys.argv[1:]:
|
|
3911
|
+
print_authorized_surface = True
|
|
3912
|
+
sys.argv = [arg for arg in sys.argv if arg != "--print-authorized-surface"]
|
|
3913
|
+
if "--write-untracked-baseline" in sys.argv[1:]:
|
|
3914
|
+
write_untracked_baseline = True
|
|
3915
|
+
sys.argv = [arg for arg in sys.argv if arg != "--write-untracked-baseline"]
|
|
3316
3916
|
|
|
3317
3917
|
if len(sys.argv) == 2 and sys.argv[1] == "--self-test":
|
|
3318
3918
|
return run_self_test()
|
|
@@ -3334,6 +3934,24 @@ def main() -> int:
|
|
|
3334
3934
|
devlyn_dir = work / ".devlyn"
|
|
3335
3935
|
spec_path = devlyn_dir / "spec-verify.json"
|
|
3336
3936
|
|
|
3937
|
+
if print_risk_probes_digest:
|
|
3938
|
+
if include_risk_probes or validate_risk_probes_only or print_authorized_surface or write_untracked_baseline or len(sys.argv) != 1:
|
|
3939
|
+
print("usage: spec-verify-check.py --print-risk-probes-digest", file=sys.stderr)
|
|
3940
|
+
return 2
|
|
3941
|
+
return run_print_risk_probes_digest(devlyn_dir)
|
|
3942
|
+
|
|
3943
|
+
if print_authorized_surface:
|
|
3944
|
+
if include_risk_probes or validate_risk_probes_only or write_untracked_baseline or len(sys.argv) != 1:
|
|
3945
|
+
print("usage: spec-verify-check.py --print-authorized-surface", file=sys.stderr)
|
|
3946
|
+
return 2
|
|
3947
|
+
return run_print_authorized_surface(work, devlyn_dir)
|
|
3948
|
+
|
|
3949
|
+
if write_untracked_baseline:
|
|
3950
|
+
if include_risk_probes or validate_risk_probes_only or len(sys.argv) != 1:
|
|
3951
|
+
print("usage: spec-verify-check.py --write-untracked-baseline", file=sys.stderr)
|
|
3952
|
+
return 2
|
|
3953
|
+
return run_write_untracked_baseline(work, devlyn_dir)
|
|
3954
|
+
|
|
3337
3955
|
# iter-0019.8 + iter-0019.9 (Codex R-phaseA): determine the contract
|
|
3338
3956
|
# carrier source for THIS run. Order:
|
|
3339
3957
|
# 1. Benchmark mode (BENCH_WORKDIR set) AND a pre-staged
|
|
@@ -3461,6 +4079,11 @@ def main() -> int:
|
|
|
3461
4079
|
print(f"[spec-verify] risk probes malformed: {risk_state_error}", file=sys.stderr)
|
|
3462
4080
|
write_malformed_finding(devlyn_dir, risk_state_error, Path("pipeline.state.json"))
|
|
3463
4081
|
return 1
|
|
4082
|
+
integrity_error = risk_probe_integrity_error(state, devlyn_dir)
|
|
4083
|
+
if integrity_error:
|
|
4084
|
+
print(f"[spec-verify] risk probes integrity failed: {integrity_error}", file=sys.stderr)
|
|
4085
|
+
write_risk_probe_integrity_finding(devlyn_dir, integrity_error)
|
|
4086
|
+
return 1
|
|
3464
4087
|
risk_probes, risk_error = load_risk_probes(
|
|
3465
4088
|
devlyn_dir,
|
|
3466
4089
|
source_md,
|