devlyn-cli 2.10.1 → 3.0.0
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 +4 -9
- package/CLAUDE.md +11 -15
- package/README.md +31 -7
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
- package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
- package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
- package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
- package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
- package/bin/devlyn.js +6 -1
- package/config/skills/_shared/.ruff.toml +2 -0
- package/config/skills/_shared/adapters/claude.md +9 -1
- package/config/skills/_shared/adapters/codex.md +5 -0
- package/config/skills/_shared/adapters/grok.md +4 -2
- package/config/skills/_shared/archive_run.py +738 -56
- package/config/skills/_shared/codex-config.md +19 -4
- package/config/skills/_shared/codex-monitored.sh +81 -12
- package/config/skills/_shared/collect-codex-findings.py +311 -128
- package/config/skills/_shared/engine-preflight.md +8 -7
- package/config/skills/_shared/expected.schema.json +61 -0
- package/config/skills/_shared/finish-gate.py +12 -11
- package/config/skills/_shared/grok-anchor-guard.py +18 -1
- package/config/skills/_shared/invocation-receipt.py +1041 -0
- package/config/skills/_shared/judge-output-parser.py +200 -0
- package/config/skills/_shared/judge-role-evidence.py +273 -0
- package/config/skills/_shared/process-evidence.py +909 -0
- package/config/skills/_shared/resolve-bootstrap.py +776 -84
- package/config/skills/_shared/resolve-stop-hook.py +33 -1
- package/config/skills/_shared/role-config.py +552 -0
- package/config/skills/_shared/run-bounded.py +1 -1
- package/config/skills/_shared/spec-verify-check.py +749 -181
- package/config/skills/_shared/state-phase-write.py +1838 -58
- package/config/skills/_shared/terminal-claim-check.py +15 -0
- package/config/skills/_shared/verify-merge-findings.py +958 -51
- package/config/skills/devlyn:engines/SKILL.md +8 -16
- package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
- package/config/skills/devlyn:queue/SKILL.md +7 -4
- package/config/skills/devlyn:resolve/SKILL.md +91 -33
- package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
- package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
- package/package.json +5 -1
- package/scripts/lint-skills.sh +314 -285
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Capture and validate run-scoped process evidence without third-party dependencies."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = "1.0"
|
|
19
|
+
PHASES = {"implement", "build_gate", "verify"}
|
|
20
|
+
CAPABILITIES = {"filesystem", "subprocess", "loopback", "pty", "network"}
|
|
21
|
+
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
22
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
23
|
+
OBLIGATION_KEYS = {
|
|
24
|
+
"id", "phase", "cmd", "argv", "exit_code", "timeout_sec",
|
|
25
|
+
"stdout_contains", "stdout_not_contains",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EvidenceError(ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _reject_constant(token: str) -> None:
|
|
34
|
+
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict:
|
|
38
|
+
result = {}
|
|
39
|
+
for key, value in pairs:
|
|
40
|
+
if key in result:
|
|
41
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
42
|
+
result[key] = value
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def loads_strict_json(text: str):
|
|
47
|
+
return json.loads(
|
|
48
|
+
text, parse_constant=_reject_constant, object_pairs_hook=_unique_object,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_json(path: pathlib.Path):
|
|
53
|
+
try:
|
|
54
|
+
return loads_strict_json(path.read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
56
|
+
raise EvidenceError(f"{path} is not valid UTF-8 JSON: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _sha256(raw: bytes) -> str:
|
|
60
|
+
return hashlib.sha256(raw).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _atomic_write(path: pathlib.Path, raw: bytes) -> None:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".tmp.")
|
|
66
|
+
try:
|
|
67
|
+
with os.fdopen(fd, "wb") as handle:
|
|
68
|
+
handle.write(raw)
|
|
69
|
+
pathlib.Path(temporary).replace(path)
|
|
70
|
+
except BaseException:
|
|
71
|
+
pathlib.Path(temporary).unlink(missing_ok=True)
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _json_bytes(value: object) -> bytes:
|
|
76
|
+
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _string_list(value: object, label: str) -> list[str]:
|
|
80
|
+
if not isinstance(value, list) or any(
|
|
81
|
+
not isinstance(item, str) or not item for item in value
|
|
82
|
+
):
|
|
83
|
+
raise EvidenceError(f"{label} must be an array of non-empty strings")
|
|
84
|
+
return list(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_obligation(value: object, phase: str | None = None) -> dict:
|
|
88
|
+
if not isinstance(value, dict):
|
|
89
|
+
raise EvidenceError("process_evidence entry must be an object")
|
|
90
|
+
unknown = sorted(set(value) - OBLIGATION_KEYS)
|
|
91
|
+
if unknown:
|
|
92
|
+
raise EvidenceError(f"process_evidence entry has unknown key(s): {','.join(unknown)}")
|
|
93
|
+
evidence_id = value.get("id")
|
|
94
|
+
if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
|
|
95
|
+
raise EvidenceError("process_evidence id must be a safe non-empty path component")
|
|
96
|
+
declared_phase = value.get("phase")
|
|
97
|
+
if declared_phase not in PHASES:
|
|
98
|
+
raise EvidenceError(f"process_evidence[{evidence_id}].phase is invalid")
|
|
99
|
+
if phase is not None and declared_phase != phase:
|
|
100
|
+
raise EvidenceError(
|
|
101
|
+
f"process_evidence[{evidence_id}].phase expected {phase}, got {declared_phase}"
|
|
102
|
+
)
|
|
103
|
+
has_command = "cmd" in value
|
|
104
|
+
has_argv = "argv" in value
|
|
105
|
+
if has_command == has_argv:
|
|
106
|
+
raise EvidenceError(
|
|
107
|
+
f"process_evidence[{evidence_id}] requires exactly one of cmd or argv"
|
|
108
|
+
)
|
|
109
|
+
command = value.get("cmd") if has_command else None
|
|
110
|
+
argv = value.get("argv") if has_argv else None
|
|
111
|
+
if has_command and (not isinstance(command, str) or not command):
|
|
112
|
+
raise EvidenceError(f"process_evidence[{evidence_id}].cmd must be non-empty")
|
|
113
|
+
if has_argv and (
|
|
114
|
+
not isinstance(argv, list) or not argv or not isinstance(argv[0], str)
|
|
115
|
+
or not argv[0] or any(not isinstance(arg, str) for arg in argv)
|
|
116
|
+
):
|
|
117
|
+
raise EvidenceError(
|
|
118
|
+
f"process_evidence[{evidence_id}].argv must be a string array with non-empty argv[0]"
|
|
119
|
+
)
|
|
120
|
+
exit_code = value.get("exit_code", 0)
|
|
121
|
+
timeout_sec = value.get("timeout_sec", 60)
|
|
122
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code < 0:
|
|
123
|
+
raise EvidenceError(f"process_evidence[{evidence_id}].exit_code must be non-negative int")
|
|
124
|
+
if (
|
|
125
|
+
isinstance(timeout_sec, bool) or not isinstance(timeout_sec, int)
|
|
126
|
+
or timeout_sec < 1 or timeout_sec > 600
|
|
127
|
+
):
|
|
128
|
+
raise EvidenceError(
|
|
129
|
+
f"process_evidence[{evidence_id}].timeout_sec must be int from 1 to 600"
|
|
130
|
+
)
|
|
131
|
+
normalized = {
|
|
132
|
+
"id": evidence_id,
|
|
133
|
+
"phase": declared_phase,
|
|
134
|
+
"exit_code": exit_code,
|
|
135
|
+
"timeout_sec": timeout_sec,
|
|
136
|
+
"stdout_contains": _string_list(
|
|
137
|
+
value.get("stdout_contains", []),
|
|
138
|
+
f"process_evidence[{evidence_id}].stdout_contains",
|
|
139
|
+
),
|
|
140
|
+
"stdout_not_contains": _string_list(
|
|
141
|
+
value.get("stdout_not_contains", []),
|
|
142
|
+
f"process_evidence[{evidence_id}].stdout_not_contains",
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
normalized["cmd" if has_command else "argv"] = command if has_command else list(argv)
|
|
146
|
+
return normalized
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _safe_relative_path(value: str, label: str) -> pathlib.PurePosixPath:
|
|
150
|
+
if not isinstance(value, str) or not value or "\\" in value:
|
|
151
|
+
raise EvidenceError(f"{label} must be a non-empty POSIX relative path")
|
|
152
|
+
relative = pathlib.PurePosixPath(value)
|
|
153
|
+
if relative.is_absolute() or value.startswith("./") or ".." in relative.parts:
|
|
154
|
+
raise EvidenceError(f"{label} escapes the worktree: {value!r}")
|
|
155
|
+
return relative
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _checked_file(work: pathlib.Path, relative: str, label: str) -> pathlib.Path:
|
|
159
|
+
rel = _safe_relative_path(relative, label)
|
|
160
|
+
root = work.resolve()
|
|
161
|
+
candidate = work.joinpath(*rel.parts)
|
|
162
|
+
try:
|
|
163
|
+
resolved = candidate.resolve(strict=True)
|
|
164
|
+
resolved.relative_to(root)
|
|
165
|
+
except (OSError, ValueError) as exc:
|
|
166
|
+
raise EvidenceError(f"{label} is missing or escapes the worktree: {relative!r}") from exc
|
|
167
|
+
if not resolved.is_file():
|
|
168
|
+
raise EvidenceError(f"{label} is not a regular file: {relative!r}")
|
|
169
|
+
return resolved
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _relative_to_work(work: pathlib.Path, path: pathlib.Path, label: str) -> str:
|
|
173
|
+
try:
|
|
174
|
+
return path.resolve().relative_to(work.resolve()).as_posix()
|
|
175
|
+
except (OSError, ValueError) as exc:
|
|
176
|
+
raise EvidenceError(f"{label} escapes the worktree: {path}") from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _source_expected_path(work: pathlib.Path, state: dict) -> pathlib.Path | None:
|
|
180
|
+
source = state.get("source")
|
|
181
|
+
if not isinstance(source, dict) or source.get("type") != "spec":
|
|
182
|
+
return None
|
|
183
|
+
spec_path = source.get("spec_path")
|
|
184
|
+
if not isinstance(spec_path, str) or not spec_path:
|
|
185
|
+
raise EvidenceError("state.source.spec_path is missing")
|
|
186
|
+
spec_rel = _safe_relative_path(spec_path, "state.source.spec_path")
|
|
187
|
+
expected = work.joinpath(*spec_rel.parts).with_suffix(".expected.json")
|
|
188
|
+
try:
|
|
189
|
+
expected.resolve().relative_to(work.resolve())
|
|
190
|
+
except (OSError, ValueError) as exc:
|
|
191
|
+
raise EvidenceError("source expected contract escapes the worktree") from exc
|
|
192
|
+
return expected if expected.is_file() else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def declared_obligations(work: pathlib.Path, state: dict, phase: str) -> list[dict]:
|
|
196
|
+
if phase not in PHASES:
|
|
197
|
+
raise EvidenceError(f"unsupported evidence phase: {phase}")
|
|
198
|
+
expected_path = _source_expected_path(work, state)
|
|
199
|
+
if expected_path is None:
|
|
200
|
+
return []
|
|
201
|
+
contract = _read_json(expected_path)
|
|
202
|
+
if not isinstance(contract, dict):
|
|
203
|
+
raise EvidenceError(f"{expected_path} must contain a JSON object")
|
|
204
|
+
raw = contract.get("process_evidence", [])
|
|
205
|
+
if not isinstance(raw, list):
|
|
206
|
+
raise EvidenceError("process_evidence must be an array")
|
|
207
|
+
obligations = [normalize_obligation(item) for item in raw]
|
|
208
|
+
ids: set[str] = set()
|
|
209
|
+
for item in obligations:
|
|
210
|
+
key = f"{item['phase']}:{item['id']}"
|
|
211
|
+
if key in ids:
|
|
212
|
+
raise EvidenceError(f"duplicate process_evidence id: {key}")
|
|
213
|
+
ids.add(key)
|
|
214
|
+
return [item for item in obligations if item["phase"] == phase]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def mechanical_evidence_required(work: pathlib.Path, state: dict) -> bool:
|
|
218
|
+
"""Return whether BUILD_GATE/VERIFY has declared executable obligations."""
|
|
219
|
+
expected_path = _source_expected_path(work, state)
|
|
220
|
+
if expected_path is not None:
|
|
221
|
+
expected = _read_json(expected_path)
|
|
222
|
+
commands = expected.get("verification_commands") if isinstance(expected, dict) else None
|
|
223
|
+
if commands is not None and not isinstance(commands, list):
|
|
224
|
+
raise EvidenceError("verification_commands must be an array")
|
|
225
|
+
if commands:
|
|
226
|
+
return True
|
|
227
|
+
devlyn = work / ".devlyn"
|
|
228
|
+
staged_path = devlyn / "spec-verify.json"
|
|
229
|
+
if staged_path.is_file():
|
|
230
|
+
staged = _read_json(staged_path)
|
|
231
|
+
commands = staged.get("verification_commands") if isinstance(staged, dict) else None
|
|
232
|
+
if commands is not None and not isinstance(commands, list):
|
|
233
|
+
raise EvidenceError("staged verification_commands must be an array")
|
|
234
|
+
if commands:
|
|
235
|
+
return True
|
|
236
|
+
probes = devlyn / "risk-probes.jsonl"
|
|
237
|
+
return probes.is_file() and bool(probes.read_text(encoding="utf-8").strip())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def phase_round(state: dict, phase: str) -> int:
|
|
241
|
+
entry = (state.get("phases") or {}).get(phase)
|
|
242
|
+
round_ = entry.get("round") if isinstance(entry, dict) else None
|
|
243
|
+
if isinstance(round_, bool) or not isinstance(round_, int) or round_ < 0:
|
|
244
|
+
raise EvidenceError(f"state.phases.{phase}.round must be a non-negative integer")
|
|
245
|
+
return round_
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def manifest_relative_path(state: dict, phase: str) -> str:
|
|
249
|
+
run_id = state.get("run_id")
|
|
250
|
+
if not isinstance(run_id, str) or ID_RE.fullmatch(run_id) is None:
|
|
251
|
+
raise EvidenceError("state.run_id must be a safe non-empty path component")
|
|
252
|
+
round_ = phase_round(state, phase)
|
|
253
|
+
return f".devlyn/process-evidence/{run_id}/{phase}/round-{round_}/manifest.json"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _expectation(obligation: dict) -> dict:
|
|
257
|
+
return {
|
|
258
|
+
"exit_code": obligation["exit_code"],
|
|
259
|
+
"timeout_sec": obligation["timeout_sec"],
|
|
260
|
+
"stdout_contains": obligation["stdout_contains"],
|
|
261
|
+
"stdout_not_contains": obligation["stdout_not_contains"],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _execution(obligation: dict) -> dict:
|
|
266
|
+
return {
|
|
267
|
+
"command": obligation.get("cmd"),
|
|
268
|
+
"argv": obligation.get("argv"),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _expectation_met(expectation: dict, outcome: dict, stdout: bytes, stderr: bytes) -> bool:
|
|
273
|
+
combined = stdout + stderr
|
|
274
|
+
return (
|
|
275
|
+
outcome.get("kind") == "exit"
|
|
276
|
+
and outcome.get("exit_code") == expectation["exit_code"]
|
|
277
|
+
and all(value.encode("utf-8") in combined for value in expectation["stdout_contains"])
|
|
278
|
+
and all(value.encode("utf-8") not in combined for value in expectation["stdout_not_contains"])
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _stream_record(work: pathlib.Path, path: pathlib.Path, raw: bytes) -> dict:
|
|
283
|
+
return {
|
|
284
|
+
"path": _relative_to_work(work, path, "process evidence stream"),
|
|
285
|
+
"sha256": _sha256(raw),
|
|
286
|
+
"bytes": len(raw),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _load_manifest(path: pathlib.Path, run_id: str, phase: str, round_: int) -> dict:
|
|
291
|
+
if not path.exists():
|
|
292
|
+
return {
|
|
293
|
+
"schema_version": SCHEMA_VERSION,
|
|
294
|
+
"run_id": run_id,
|
|
295
|
+
"phase": phase,
|
|
296
|
+
"round": round_,
|
|
297
|
+
"entries": [],
|
|
298
|
+
}
|
|
299
|
+
manifest = _read_json(path)
|
|
300
|
+
if not isinstance(manifest, dict) or set(manifest) != {
|
|
301
|
+
"schema_version", "run_id", "phase", "round", "entries",
|
|
302
|
+
}:
|
|
303
|
+
raise EvidenceError("process evidence manifest has an invalid top-level shape")
|
|
304
|
+
if (
|
|
305
|
+
manifest["schema_version"] != SCHEMA_VERSION or manifest["run_id"] != run_id
|
|
306
|
+
or manifest["phase"] != phase or manifest["round"] != round_
|
|
307
|
+
or not isinstance(manifest["entries"], list)
|
|
308
|
+
):
|
|
309
|
+
raise EvidenceError("process evidence manifest identity does not match the current run")
|
|
310
|
+
ids = []
|
|
311
|
+
for entry in manifest["entries"]:
|
|
312
|
+
evidence_id = entry.get("id") if isinstance(entry, dict) else None
|
|
313
|
+
if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
|
|
314
|
+
raise EvidenceError("process evidence manifest contains a malformed id")
|
|
315
|
+
if evidence_id in ids:
|
|
316
|
+
raise EvidenceError("process evidence manifest contains a duplicate id")
|
|
317
|
+
ids.append(evidence_id)
|
|
318
|
+
return manifest
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _append_entry(
|
|
322
|
+
work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
|
|
323
|
+
round_: int, evidence_id: str, execution: dict, expectation: dict,
|
|
324
|
+
outcome: dict, classification: dict, stdout: bytes, stderr: bytes,
|
|
325
|
+
duration_ms: int,
|
|
326
|
+
) -> dict:
|
|
327
|
+
_relative_to_work(work, manifest_path, "process evidence manifest")
|
|
328
|
+
manifest = _load_manifest(manifest_path, run_id, phase, round_)
|
|
329
|
+
if any(item["id"] == evidence_id for item in manifest["entries"]):
|
|
330
|
+
raise EvidenceError(f"duplicate process evidence id: {phase}:{evidence_id}")
|
|
331
|
+
stdout_path = manifest_path.parent / f"{evidence_id}.stdout"
|
|
332
|
+
stderr_path = manifest_path.parent / f"{evidence_id}.stderr"
|
|
333
|
+
if stdout_path.exists() or stderr_path.exists():
|
|
334
|
+
raise EvidenceError(f"unattributed process evidence stream already exists: {evidence_id}")
|
|
335
|
+
_atomic_write(stdout_path, stdout)
|
|
336
|
+
_atomic_write(stderr_path, stderr)
|
|
337
|
+
entry = {
|
|
338
|
+
"id": evidence_id,
|
|
339
|
+
"execution": execution,
|
|
340
|
+
"expectation": expectation,
|
|
341
|
+
"outcome": outcome,
|
|
342
|
+
"classification": classification,
|
|
343
|
+
"stdout": _stream_record(work, stdout_path, stdout),
|
|
344
|
+
"stderr": _stream_record(work, stderr_path, stderr),
|
|
345
|
+
"duration_ms": duration_ms,
|
|
346
|
+
"expectation_met": _expectation_met(expectation, outcome, stdout, stderr),
|
|
347
|
+
}
|
|
348
|
+
manifest["entries"].append(entry)
|
|
349
|
+
_atomic_write(manifest_path, _json_bytes(manifest))
|
|
350
|
+
return entry
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def capture_process(
|
|
354
|
+
work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
|
|
355
|
+
round_: int, obligation: dict,
|
|
356
|
+
) -> dict:
|
|
357
|
+
item = normalize_obligation(obligation, phase)
|
|
358
|
+
started = time.monotonic()
|
|
359
|
+
try:
|
|
360
|
+
proc = subprocess.run(
|
|
361
|
+
item.get("cmd") if "cmd" in item else item["argv"],
|
|
362
|
+
cwd=work, shell="cmd" in item, capture_output=True,
|
|
363
|
+
timeout=item["timeout_sec"], check=False,
|
|
364
|
+
)
|
|
365
|
+
stdout, stderr = proc.stdout, proc.stderr
|
|
366
|
+
outcome = (
|
|
367
|
+
{"kind": "exit", "exit_code": proc.returncode, "signal": None}
|
|
368
|
+
if proc.returncode >= 0 else
|
|
369
|
+
{"kind": "signal", "exit_code": None, "signal": -proc.returncode}
|
|
370
|
+
)
|
|
371
|
+
except subprocess.TimeoutExpired as exc:
|
|
372
|
+
stdout = exc.stdout or b""
|
|
373
|
+
stderr = exc.stderr or b""
|
|
374
|
+
outcome = {"kind": "timeout", "exit_code": None, "signal": None}
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
stdout = b""
|
|
377
|
+
stderr = os.fsencode(str(exc))
|
|
378
|
+
outcome = {"kind": "spawn_error", "exit_code": None, "signal": None}
|
|
379
|
+
return _append_entry(
|
|
380
|
+
work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
|
|
381
|
+
_expectation(item), outcome,
|
|
382
|
+
{"kind": "product_result", "operation": None}, stdout, stderr,
|
|
383
|
+
max(0, round((time.monotonic() - started) * 1000)),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def record_capability_denial(
|
|
388
|
+
work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
|
|
389
|
+
round_: int, obligation: dict, operation: str, detail: bytes,
|
|
390
|
+
) -> dict:
|
|
391
|
+
item = normalize_obligation(obligation, phase)
|
|
392
|
+
if operation not in CAPABILITIES:
|
|
393
|
+
raise EvidenceError(f"unsupported denied capability: {operation}")
|
|
394
|
+
return _append_entry(
|
|
395
|
+
work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
|
|
396
|
+
_expectation(item), {"kind": "not_run", "exit_code": None, "signal": None},
|
|
397
|
+
{"kind": "capability_denied", "operation": operation}, b"", detail, 0,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _validate_stream(work: pathlib.Path, record: object, expected_path: str, label: str) -> dict:
|
|
402
|
+
if not isinstance(record, dict) or set(record) != {"path", "sha256", "bytes"}:
|
|
403
|
+
raise EvidenceError(f"{label} has an invalid stream record")
|
|
404
|
+
if record["path"] != expected_path:
|
|
405
|
+
raise EvidenceError(f"{label} path mismatch: {record['path']!r}")
|
|
406
|
+
if not isinstance(record["sha256"], str) or SHA256_RE.fullmatch(record["sha256"]) is None:
|
|
407
|
+
raise EvidenceError(f"{label} sha256 is invalid")
|
|
408
|
+
if isinstance(record["bytes"], bool) or not isinstance(record["bytes"], int) or record["bytes"] < 0:
|
|
409
|
+
raise EvidenceError(f"{label} byte count is invalid")
|
|
410
|
+
raw = _checked_file(work, record["path"], label).read_bytes()
|
|
411
|
+
if len(raw) != record["bytes"] or _sha256(raw) != record["sha256"]:
|
|
412
|
+
raise EvidenceError(f"{label} digest or byte count mismatch")
|
|
413
|
+
return {"path": record["path"], "sha256": record["sha256"]}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def validate_manifest(
|
|
417
|
+
work: pathlib.Path, manifest_relative: str, run_id: str, phase: str, round_: int,
|
|
418
|
+
obligations: list[dict] | None = None, *, require_expectations: bool = True,
|
|
419
|
+
) -> dict:
|
|
420
|
+
expected_relative = (
|
|
421
|
+
f".devlyn/process-evidence/{run_id}/{phase}/round-{round_}/manifest.json"
|
|
422
|
+
)
|
|
423
|
+
if manifest_relative != expected_relative:
|
|
424
|
+
raise EvidenceError("process evidence manifest path does not match run identity")
|
|
425
|
+
manifest_path = _checked_file(work, manifest_relative, "process evidence manifest")
|
|
426
|
+
manifest_raw = manifest_path.read_bytes()
|
|
427
|
+
manifest = _load_manifest(manifest_path, run_id, phase, round_)
|
|
428
|
+
declared = {}
|
|
429
|
+
for raw in obligations or []:
|
|
430
|
+
item = normalize_obligation(raw, phase)
|
|
431
|
+
if item["id"] in declared:
|
|
432
|
+
raise EvidenceError(f"duplicate process evidence obligation id: {phase}:{item['id']}")
|
|
433
|
+
declared[item["id"]] = item
|
|
434
|
+
seen: set[str] = set()
|
|
435
|
+
streams = []
|
|
436
|
+
for index, entry in enumerate(manifest["entries"]):
|
|
437
|
+
label = f"process evidence entry {index}"
|
|
438
|
+
if not isinstance(entry, dict) or set(entry) != {
|
|
439
|
+
"id", "execution", "expectation", "outcome", "classification",
|
|
440
|
+
"stdout", "stderr", "duration_ms", "expectation_met",
|
|
441
|
+
}:
|
|
442
|
+
raise EvidenceError(f"{label} has an invalid shape")
|
|
443
|
+
evidence_id = entry["id"]
|
|
444
|
+
if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
|
|
445
|
+
raise EvidenceError(f"{label} id is invalid")
|
|
446
|
+
if evidence_id in seen:
|
|
447
|
+
raise EvidenceError(f"duplicate process evidence id: {phase}:{evidence_id}")
|
|
448
|
+
seen.add(evidence_id)
|
|
449
|
+
execution = entry["execution"]
|
|
450
|
+
expectation = entry["expectation"]
|
|
451
|
+
if not isinstance(execution, dict) or set(execution) != {"command", "argv"}:
|
|
452
|
+
raise EvidenceError(f"{label} execution is invalid")
|
|
453
|
+
normalized = normalize_obligation({
|
|
454
|
+
"id": evidence_id, "phase": phase,
|
|
455
|
+
**({"cmd": execution["command"]} if execution["command"] is not None else
|
|
456
|
+
{"argv": execution["argv"]}),
|
|
457
|
+
**(expectation if isinstance(expectation, dict) else {}),
|
|
458
|
+
}, phase)
|
|
459
|
+
if execution != _execution(normalized) or expectation != _expectation(normalized):
|
|
460
|
+
raise EvidenceError(f"{label} execution or expectation is non-canonical")
|
|
461
|
+
if evidence_id in declared and normalized != declared[evidence_id]:
|
|
462
|
+
raise EvidenceError(f"declared process evidence mismatch: {phase}:{evidence_id}")
|
|
463
|
+
outcome = entry["outcome"]
|
|
464
|
+
classification = entry["classification"]
|
|
465
|
+
if not isinstance(outcome, dict) or set(outcome) != {"kind", "exit_code", "signal"}:
|
|
466
|
+
raise EvidenceError(f"{label} outcome is invalid")
|
|
467
|
+
if not isinstance(classification, dict) or set(classification) != {"kind", "operation"}:
|
|
468
|
+
raise EvidenceError(f"{label} classification is invalid")
|
|
469
|
+
outcome_kind = outcome["kind"]
|
|
470
|
+
if outcome_kind not in {"exit", "signal", "timeout", "spawn_error", "not_run"}:
|
|
471
|
+
raise EvidenceError(f"{label} outcome kind is invalid")
|
|
472
|
+
exit_code = outcome["exit_code"]
|
|
473
|
+
signal_number = outcome["signal"]
|
|
474
|
+
if outcome_kind == "exit":
|
|
475
|
+
if (
|
|
476
|
+
isinstance(exit_code, bool) or not isinstance(exit_code, int)
|
|
477
|
+
or exit_code < 0 or signal_number is not None
|
|
478
|
+
):
|
|
479
|
+
raise EvidenceError(f"{label} exit outcome is invalid")
|
|
480
|
+
elif outcome_kind == "signal":
|
|
481
|
+
if (
|
|
482
|
+
exit_code is not None or isinstance(signal_number, bool)
|
|
483
|
+
or not isinstance(signal_number, int) or signal_number < 1
|
|
484
|
+
):
|
|
485
|
+
raise EvidenceError(f"{label} signal outcome is invalid")
|
|
486
|
+
elif exit_code is not None or signal_number is not None:
|
|
487
|
+
raise EvidenceError(f"{label} non-exit outcome is invalid")
|
|
488
|
+
if classification["kind"] == "capability_denied":
|
|
489
|
+
if classification["operation"] not in CAPABILITIES or outcome_kind != "not_run":
|
|
490
|
+
raise EvidenceError(f"{label} capability classification is invalid")
|
|
491
|
+
elif classification != {"kind": "product_result", "operation": None} or outcome_kind == "not_run":
|
|
492
|
+
raise EvidenceError(f"{label} product classification is invalid")
|
|
493
|
+
parent = pathlib.PurePosixPath(manifest_relative).parent
|
|
494
|
+
stdout_record = _validate_stream(
|
|
495
|
+
work, entry["stdout"], (parent / f"{evidence_id}.stdout").as_posix(),
|
|
496
|
+
f"{label} stdout",
|
|
497
|
+
)
|
|
498
|
+
stderr_record = _validate_stream(
|
|
499
|
+
work, entry["stderr"], (parent / f"{evidence_id}.stderr").as_posix(),
|
|
500
|
+
f"{label} stderr",
|
|
501
|
+
)
|
|
502
|
+
stdout = _checked_file(work, stdout_record["path"], f"{label} stdout").read_bytes()
|
|
503
|
+
stderr = _checked_file(work, stderr_record["path"], f"{label} stderr").read_bytes()
|
|
504
|
+
met = _expectation_met(expectation, outcome, stdout, stderr)
|
|
505
|
+
if not isinstance(entry["expectation_met"], bool) or entry["expectation_met"] != met:
|
|
506
|
+
raise EvidenceError(f"{label} expectation result mismatch")
|
|
507
|
+
if isinstance(entry["duration_ms"], bool) or not isinstance(entry["duration_ms"], int) or entry["duration_ms"] < 0:
|
|
508
|
+
raise EvidenceError(f"{label} duration is invalid")
|
|
509
|
+
if require_expectations and not met:
|
|
510
|
+
raise EvidenceError(f"process evidence expectation mismatch: {phase}:{evidence_id}")
|
|
511
|
+
streams.append({"id": evidence_id, "stdout": stdout_record, "stderr": stderr_record})
|
|
512
|
+
missing = sorted(set(declared) - seen)
|
|
513
|
+
if missing:
|
|
514
|
+
raise EvidenceError(f"missing process evidence id(s): {','.join(missing)}")
|
|
515
|
+
return {
|
|
516
|
+
"phase": phase,
|
|
517
|
+
"round": round_,
|
|
518
|
+
"manifest": {"path": manifest_relative, "sha256": _sha256(manifest_raw)},
|
|
519
|
+
"streams": streams,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def validate_bound_carrier(work: pathlib.Path, carrier: object) -> None:
|
|
524
|
+
if not isinstance(carrier, dict) or set(carrier) != {"phase", "round", "manifest", "streams"}:
|
|
525
|
+
raise EvidenceError("state process-evidence carrier has an invalid shape")
|
|
526
|
+
manifest = carrier["manifest"]
|
|
527
|
+
if not isinstance(manifest, dict) or set(manifest) != {"path", "sha256"}:
|
|
528
|
+
raise EvidenceError("state process-evidence manifest binding is invalid")
|
|
529
|
+
raw = _checked_file(work, manifest["path"], "bound process evidence manifest").read_bytes()
|
|
530
|
+
if not isinstance(manifest["sha256"], str) or _sha256(raw) != manifest["sha256"]:
|
|
531
|
+
raise EvidenceError("bound process evidence manifest digest mismatch")
|
|
532
|
+
document = _read_json(_checked_file(work, manifest["path"], "bound process evidence manifest"))
|
|
533
|
+
if not isinstance(document, dict) or not isinstance(document.get("run_id"), str):
|
|
534
|
+
raise EvidenceError("bound process evidence manifest identity is invalid")
|
|
535
|
+
validated = validate_manifest(
|
|
536
|
+
work, manifest["path"], document["run_id"],
|
|
537
|
+
carrier["phase"], carrier["round"], require_expectations=False,
|
|
538
|
+
)
|
|
539
|
+
if validated["streams"] != carrier["streams"]:
|
|
540
|
+
raise EvidenceError("bound process evidence stream binding mismatch")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def bound_carrier_outcome(work: pathlib.Path, carrier: object) -> dict:
|
|
544
|
+
"""Derive the verdict floor from the bytes already authenticated by carrier."""
|
|
545
|
+
validate_bound_carrier(work, carrier)
|
|
546
|
+
manifest = carrier["manifest"]
|
|
547
|
+
document = _read_json(
|
|
548
|
+
_checked_file(work, manifest["path"], "bound process evidence manifest")
|
|
549
|
+
)
|
|
550
|
+
failed_ids = []
|
|
551
|
+
capability_denials = []
|
|
552
|
+
for entry in document["entries"]:
|
|
553
|
+
if entry["classification"]["kind"] == "capability_denied":
|
|
554
|
+
capability_denials.append({
|
|
555
|
+
"id": entry["id"],
|
|
556
|
+
"operation": entry["classification"]["operation"],
|
|
557
|
+
"execution": entry["execution"],
|
|
558
|
+
})
|
|
559
|
+
elif not entry["expectation_met"]:
|
|
560
|
+
failed_ids.append(entry["id"])
|
|
561
|
+
verdict = (
|
|
562
|
+
"BLOCKED" if capability_denials else
|
|
563
|
+
"NEEDS_WORK" if failed_ids else
|
|
564
|
+
"PASS"
|
|
565
|
+
)
|
|
566
|
+
return {
|
|
567
|
+
"verdict": verdict,
|
|
568
|
+
"failed_ids": failed_ids,
|
|
569
|
+
"capability_denials": capability_denials,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def bound_carrier_summary_commands(work: pathlib.Path, carrier: object) -> list[dict]:
|
|
574
|
+
"""Derive the canonical MECHANICAL summary from sealed manifest bytes."""
|
|
575
|
+
validate_bound_carrier(work, carrier)
|
|
576
|
+
manifest = carrier["manifest"]
|
|
577
|
+
document = _read_json(
|
|
578
|
+
_checked_file(work, manifest["path"], "bound process evidence manifest")
|
|
579
|
+
)
|
|
580
|
+
summaries = []
|
|
581
|
+
for index, entry in enumerate(document["entries"]):
|
|
582
|
+
stdout = _checked_file(
|
|
583
|
+
work, entry["stdout"]["path"], f"process evidence entry {index} stdout",
|
|
584
|
+
).read_bytes()
|
|
585
|
+
stderr = _checked_file(
|
|
586
|
+
work, entry["stderr"]["path"], f"process evidence entry {index} stderr",
|
|
587
|
+
).read_bytes()
|
|
588
|
+
expectation = entry["expectation"]
|
|
589
|
+
outcome = entry["outcome"]
|
|
590
|
+
classification = entry["classification"]
|
|
591
|
+
combined = stdout + stderr
|
|
592
|
+
actual_exit = outcome["exit_code"] if outcome["kind"] == "exit" else None
|
|
593
|
+
if classification["kind"] == "capability_denied":
|
|
594
|
+
reason = "capability_denied"
|
|
595
|
+
elif entry["expectation_met"]:
|
|
596
|
+
reason = None
|
|
597
|
+
elif outcome["kind"] == "timeout":
|
|
598
|
+
reason = "timeout"
|
|
599
|
+
elif outcome["kind"] != "exit" or actual_exit != expectation["exit_code"]:
|
|
600
|
+
reason = "exit"
|
|
601
|
+
elif not all(
|
|
602
|
+
marker.encode("utf-8") in combined
|
|
603
|
+
for marker in expectation["stdout_contains"]
|
|
604
|
+
):
|
|
605
|
+
reason = "missing_contains"
|
|
606
|
+
else:
|
|
607
|
+
reason = "unexpected_text"
|
|
608
|
+
summaries.append({
|
|
609
|
+
"index": index,
|
|
610
|
+
"cmd": entry["execution"]["command"],
|
|
611
|
+
"expected_exit": expectation["exit_code"],
|
|
612
|
+
"actual_exit": actual_exit,
|
|
613
|
+
"timeout_sec": expectation["timeout_sec"],
|
|
614
|
+
"stdout_contains": expectation["stdout_contains"],
|
|
615
|
+
"stdout_not_contains": expectation["stdout_not_contains"],
|
|
616
|
+
"pass": entry["expectation_met"],
|
|
617
|
+
"reason": reason,
|
|
618
|
+
"evidence_id": entry["id"],
|
|
619
|
+
"outcome": outcome,
|
|
620
|
+
"classification": classification,
|
|
621
|
+
"stdout": entry["stdout"],
|
|
622
|
+
"stderr": entry["stderr"],
|
|
623
|
+
})
|
|
624
|
+
return summaries
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def validate_summary_commands(
|
|
628
|
+
work: pathlib.Path, commands: object, carrier: object,
|
|
629
|
+
) -> dict:
|
|
630
|
+
"""Cross-check the mutable MECHANICAL summary against sealed manifest bytes."""
|
|
631
|
+
if not isinstance(commands, list):
|
|
632
|
+
raise EvidenceError("spec-verify.results.json commands must be an array")
|
|
633
|
+
expected_commands = bound_carrier_summary_commands(work, carrier)
|
|
634
|
+
if len(commands) != len(expected_commands):
|
|
635
|
+
raise EvidenceError(
|
|
636
|
+
"spec-verify.results.json command count disagrees with sealed process evidence"
|
|
637
|
+
)
|
|
638
|
+
for index, (summary, expected) in enumerate(zip(commands, expected_commands)):
|
|
639
|
+
if not isinstance(summary, dict):
|
|
640
|
+
raise EvidenceError(f"spec-verify.results.json command {index} is not an object")
|
|
641
|
+
if summary != expected:
|
|
642
|
+
raise EvidenceError(
|
|
643
|
+
f"spec-verify.results.json command {index} disagrees with sealed process evidence"
|
|
644
|
+
)
|
|
645
|
+
return bound_carrier_outcome(work, carrier)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _load_state(devlyn: pathlib.Path) -> dict:
|
|
649
|
+
state = _read_json(devlyn / "pipeline.state.json")
|
|
650
|
+
if not isinstance(state, dict):
|
|
651
|
+
raise EvidenceError("pipeline.state.json must contain an object")
|
|
652
|
+
return state
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _declared_by_id(work: pathlib.Path, state: dict, phase: str, evidence_id: str) -> dict:
|
|
656
|
+
matches = [item for item in declared_obligations(work, state, phase) if item["id"] == evidence_id]
|
|
657
|
+
if len(matches) != 1:
|
|
658
|
+
raise EvidenceError(f"undeclared process evidence id: {phase}:{evidence_id}")
|
|
659
|
+
return matches[0]
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def self_test() -> int:
|
|
663
|
+
try:
|
|
664
|
+
loads_strict_json('{"run_id":"a","run_id":"b"}')
|
|
665
|
+
except ValueError as exc:
|
|
666
|
+
assert "duplicate JSON key" in str(exc)
|
|
667
|
+
else:
|
|
668
|
+
raise AssertionError("duplicate process-evidence JSON key was accepted")
|
|
669
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
670
|
+
work = pathlib.Path(raw_tmp)
|
|
671
|
+
devlyn = work / ".devlyn"
|
|
672
|
+
devlyn.mkdir()
|
|
673
|
+
state = {
|
|
674
|
+
"run_id": "rs-self-test",
|
|
675
|
+
"phases": {"implement": {"round": 0}},
|
|
676
|
+
}
|
|
677
|
+
manifest_rel = manifest_relative_path(state, "implement")
|
|
678
|
+
manifest = work / manifest_rel
|
|
679
|
+
obligation = normalize_obligation({
|
|
680
|
+
"id": "red-first", "phase": "implement",
|
|
681
|
+
"cmd": "printf raw-out; printf raw-err >&2; exit 7",
|
|
682
|
+
"exit_code": 7, "stdout_contains": ["raw-outraw-err"],
|
|
683
|
+
})
|
|
684
|
+
entry = capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
|
|
685
|
+
assert entry["expectation_met"]
|
|
686
|
+
assert (manifest.parent / "red-first.stdout").read_bytes() == b"raw-out"
|
|
687
|
+
assert (manifest.parent / "red-first.stderr").read_bytes() == b"raw-err"
|
|
688
|
+
empty = normalize_obligation({
|
|
689
|
+
"id": "empty-streams", "phase": "implement",
|
|
690
|
+
"argv": [sys.executable, "-c", "pass"],
|
|
691
|
+
})
|
|
692
|
+
empty_entry = capture_process(
|
|
693
|
+
work, manifest, state["run_id"], "implement", 0, empty,
|
|
694
|
+
)
|
|
695
|
+
assert empty_entry["expectation_met"]
|
|
696
|
+
assert empty_entry["stdout"]["bytes"] == empty_entry["stderr"]["bytes"] == 0
|
|
697
|
+
assert (manifest.parent / "empty-streams.stdout").is_file()
|
|
698
|
+
assert (manifest.parent / "empty-streams.stderr").is_file()
|
|
699
|
+
carrier = validate_manifest(
|
|
700
|
+
work, manifest_rel, state["run_id"], "implement", 0, [obligation, empty],
|
|
701
|
+
)
|
|
702
|
+
assert carrier["manifest"]["sha256"] == _sha256(manifest.read_bytes())
|
|
703
|
+
summaries = bound_carrier_summary_commands(work, carrier)
|
|
704
|
+
assert validate_summary_commands(work, summaries, carrier)["verdict"] == "PASS"
|
|
705
|
+
summaries[0]["pass"] = False
|
|
706
|
+
try:
|
|
707
|
+
validate_summary_commands(work, summaries, carrier)
|
|
708
|
+
except EvidenceError as exc:
|
|
709
|
+
assert "command 0 disagrees" in str(exc)
|
|
710
|
+
else:
|
|
711
|
+
raise AssertionError("mutable command summary contradicted sealed process evidence")
|
|
712
|
+
try:
|
|
713
|
+
capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
|
|
714
|
+
except EvidenceError as exc:
|
|
715
|
+
assert "duplicate process evidence id" in str(exc)
|
|
716
|
+
else:
|
|
717
|
+
raise AssertionError("duplicate evidence id was accepted")
|
|
718
|
+
print("PASS process evidence raw streams, digest binding, and duplicate-id guard")
|
|
719
|
+
|
|
720
|
+
stderr_path = manifest.parent / "red-first.stderr"
|
|
721
|
+
stderr_path.write_bytes(b"altered")
|
|
722
|
+
try:
|
|
723
|
+
validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
|
|
724
|
+
except EvidenceError as exc:
|
|
725
|
+
assert "digest or byte count mismatch" in str(exc)
|
|
726
|
+
else:
|
|
727
|
+
raise AssertionError("altered raw stream was accepted")
|
|
728
|
+
stderr_path.write_bytes(b"raw-err")
|
|
729
|
+
validate_bound_carrier(work, carrier)
|
|
730
|
+
manifest.write_bytes(manifest.read_bytes() + b" ")
|
|
731
|
+
try:
|
|
732
|
+
validate_bound_carrier(work, carrier)
|
|
733
|
+
except EvidenceError as exc:
|
|
734
|
+
assert "manifest digest mismatch" in str(exc)
|
|
735
|
+
else:
|
|
736
|
+
raise AssertionError("altered bound manifest was accepted")
|
|
737
|
+
print("PASS process evidence raw and manifest mutation detection")
|
|
738
|
+
|
|
739
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
740
|
+
work = pathlib.Path(raw_tmp)
|
|
741
|
+
state = {"run_id": "rs-path-test", "phases": {"implement": {"round": 0}}}
|
|
742
|
+
manifest_rel = manifest_relative_path(state, "implement")
|
|
743
|
+
manifest = work / manifest_rel
|
|
744
|
+
obligation = normalize_obligation({
|
|
745
|
+
"id": "path-test", "phase": "implement", "argv": [sys.executable, "-c", "pass"],
|
|
746
|
+
})
|
|
747
|
+
capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
|
|
748
|
+
document = _read_json(manifest)
|
|
749
|
+
document["entries"][0]["stdout"]["path"] = "../escape"
|
|
750
|
+
_atomic_write(manifest, _json_bytes(document))
|
|
751
|
+
try:
|
|
752
|
+
validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
|
|
753
|
+
except EvidenceError as exc:
|
|
754
|
+
assert "path mismatch" in str(exc)
|
|
755
|
+
else:
|
|
756
|
+
raise AssertionError("path-escaping evidence was accepted")
|
|
757
|
+
document["entries"][0]["stdout"]["path"] = (
|
|
758
|
+
pathlib.PurePosixPath(manifest_rel).parent / "path-test.stdout"
|
|
759
|
+
).as_posix()
|
|
760
|
+
document["entries"].append(document["entries"][0])
|
|
761
|
+
_atomic_write(manifest, _json_bytes(document))
|
|
762
|
+
try:
|
|
763
|
+
validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
|
|
764
|
+
except EvidenceError as exc:
|
|
765
|
+
assert "duplicate" in str(exc)
|
|
766
|
+
else:
|
|
767
|
+
raise AssertionError("duplicate manifest id was accepted")
|
|
768
|
+
print("PASS process evidence path and manifest duplicate-id rejection")
|
|
769
|
+
|
|
770
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
771
|
+
work = pathlib.Path(raw_tmp)
|
|
772
|
+
state = {"run_id": "rs-expect-test", "phases": {"implement": {"round": 0}}}
|
|
773
|
+
manifest_rel = manifest_relative_path(state, "implement")
|
|
774
|
+
manifest = work / manifest_rel
|
|
775
|
+
mismatch = normalize_obligation({
|
|
776
|
+
"id": "mismatch", "phase": "implement", "cmd": "exit 0", "exit_code": 9,
|
|
777
|
+
})
|
|
778
|
+
entry = capture_process(work, manifest, state["run_id"], "implement", 0, mismatch)
|
|
779
|
+
assert not entry["expectation_met"]
|
|
780
|
+
try:
|
|
781
|
+
validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [mismatch])
|
|
782
|
+
except EvidenceError as exc:
|
|
783
|
+
assert "expectation mismatch" in str(exc)
|
|
784
|
+
else:
|
|
785
|
+
raise AssertionError("expectation-mismatched evidence was accepted")
|
|
786
|
+
mismatch_carrier = validate_manifest(
|
|
787
|
+
work, manifest_rel, state["run_id"], "implement", 0,
|
|
788
|
+
require_expectations=False,
|
|
789
|
+
)
|
|
790
|
+
assert bound_carrier_outcome(work, mismatch_carrier) == {
|
|
791
|
+
"verdict": "NEEDS_WORK",
|
|
792
|
+
"failed_ids": ["mismatch"],
|
|
793
|
+
"capability_denials": [],
|
|
794
|
+
}
|
|
795
|
+
signaled = normalize_obligation({
|
|
796
|
+
"id": "signaled", "phase": "implement",
|
|
797
|
+
"argv": [
|
|
798
|
+
sys.executable, "-c",
|
|
799
|
+
"import os, signal; os.kill(os.getpid(), signal.SIGTERM)",
|
|
800
|
+
],
|
|
801
|
+
})
|
|
802
|
+
signal_entry = capture_process(
|
|
803
|
+
work, manifest, state["run_id"], "implement", 0, signaled,
|
|
804
|
+
)
|
|
805
|
+
assert signal_entry["outcome"] == {
|
|
806
|
+
"kind": "signal", "exit_code": None, "signal": signal.SIGTERM,
|
|
807
|
+
}
|
|
808
|
+
validate_manifest(
|
|
809
|
+
work, manifest_rel, state["run_id"], "implement", 0,
|
|
810
|
+
require_expectations=False,
|
|
811
|
+
)
|
|
812
|
+
print("PASS process evidence exit expectation rejection and signal capture")
|
|
813
|
+
|
|
814
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
815
|
+
work = pathlib.Path(raw_tmp)
|
|
816
|
+
state = {"run_id": "rs-cap-test", "phases": {"build_gate": {"round": 0}}}
|
|
817
|
+
manifest_rel = manifest_relative_path(state, "build_gate")
|
|
818
|
+
manifest = work / manifest_rel
|
|
819
|
+
product = normalize_obligation({
|
|
820
|
+
"id": "product", "phase": "build_gate",
|
|
821
|
+
"cmd": "printf 'Operation not permitted' >&2; exit 1",
|
|
822
|
+
})
|
|
823
|
+
denied = normalize_obligation({
|
|
824
|
+
"id": "denied", "phase": "build_gate", "cmd": "python3 -m pytest",
|
|
825
|
+
})
|
|
826
|
+
product_entry = capture_process(
|
|
827
|
+
work, manifest, state["run_id"], "build_gate", 0, product,
|
|
828
|
+
)
|
|
829
|
+
denied_entry = record_capability_denial(
|
|
830
|
+
work, manifest, state["run_id"], "build_gate", 0, denied,
|
|
831
|
+
"subprocess", b"parent route denied subprocess creation",
|
|
832
|
+
)
|
|
833
|
+
assert product_entry["classification"] == {"kind": "product_result", "operation": None}
|
|
834
|
+
assert denied_entry["classification"] == {
|
|
835
|
+
"kind": "capability_denied", "operation": "subprocess",
|
|
836
|
+
}
|
|
837
|
+
denied_carrier = validate_manifest(
|
|
838
|
+
work, manifest_rel, state["run_id"], "build_gate", 0,
|
|
839
|
+
require_expectations=False,
|
|
840
|
+
)
|
|
841
|
+
denied_outcome = bound_carrier_outcome(work, denied_carrier)
|
|
842
|
+
assert denied_outcome["verdict"] == "BLOCKED"
|
|
843
|
+
assert denied_outcome["capability_denials"] == [{
|
|
844
|
+
"id": "denied",
|
|
845
|
+
"operation": "subprocess",
|
|
846
|
+
"execution": {"command": "python3 -m pytest", "argv": None},
|
|
847
|
+
}]
|
|
848
|
+
print("PASS process evidence explicit capability classification without stderr heuristics")
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def main() -> int:
|
|
853
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
854
|
+
parser.add_argument("--workdir", default=".")
|
|
855
|
+
parser.add_argument("--devlyn-dir", default=".devlyn")
|
|
856
|
+
parser.add_argument("--self-test", action="store_true")
|
|
857
|
+
subparsers = parser.add_subparsers(dest="action")
|
|
858
|
+
run_parser = subparsers.add_parser("run")
|
|
859
|
+
run_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
|
|
860
|
+
run_parser.add_argument("--id", required=True)
|
|
861
|
+
deny_parser = subparsers.add_parser("record-capability-denial")
|
|
862
|
+
deny_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
|
|
863
|
+
deny_parser.add_argument("--id", required=True)
|
|
864
|
+
deny_parser.add_argument("--operation", choices=sorted(CAPABILITIES), required=True)
|
|
865
|
+
deny_parser.add_argument("--detail", required=True)
|
|
866
|
+
validate_parser = subparsers.add_parser("validate")
|
|
867
|
+
validate_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
|
|
868
|
+
args = parser.parse_args()
|
|
869
|
+
if args.self_test:
|
|
870
|
+
return self_test()
|
|
871
|
+
if args.action is None:
|
|
872
|
+
parser.error("an action is required unless --self-test")
|
|
873
|
+
try:
|
|
874
|
+
work = pathlib.Path(args.workdir).resolve()
|
|
875
|
+
devlyn_arg = pathlib.Path(args.devlyn_dir)
|
|
876
|
+
devlyn = devlyn_arg.resolve() if devlyn_arg.is_absolute() else (work / devlyn_arg).resolve()
|
|
877
|
+
devlyn.relative_to(work)
|
|
878
|
+
state = _load_state(devlyn)
|
|
879
|
+
phase = args.phase
|
|
880
|
+
round_ = phase_round(state, phase)
|
|
881
|
+
manifest_rel = manifest_relative_path(state, phase)
|
|
882
|
+
manifest = work / manifest_rel
|
|
883
|
+
obligations = declared_obligations(work, state, phase)
|
|
884
|
+
if args.action == "validate":
|
|
885
|
+
carrier = validate_manifest(
|
|
886
|
+
work, manifest_rel, state["run_id"], phase, round_, obligations,
|
|
887
|
+
)
|
|
888
|
+
sys.stdout.write(json.dumps(carrier, sort_keys=True) + "\n")
|
|
889
|
+
return 0
|
|
890
|
+
obligation = _declared_by_id(work, state, phase, args.id)
|
|
891
|
+
if args.action == "run":
|
|
892
|
+
entry = capture_process(work, manifest, state["run_id"], phase, round_, obligation)
|
|
893
|
+
else:
|
|
894
|
+
entry = record_capability_denial(
|
|
895
|
+
work, manifest, state["run_id"], phase, round_, obligation,
|
|
896
|
+
args.operation, args.detail.encode("utf-8"),
|
|
897
|
+
)
|
|
898
|
+
sys.stdout.write(json.dumps({
|
|
899
|
+
"id": entry["id"], "expectation_met": entry["expectation_met"],
|
|
900
|
+
"manifest_path": manifest_rel, "classification": entry["classification"],
|
|
901
|
+
}, sort_keys=True) + "\n")
|
|
902
|
+
return 0 if entry["expectation_met"] else 1
|
|
903
|
+
except (EvidenceError, OSError, UnicodeError, ValueError) as exc:
|
|
904
|
+
sys.stderr.write(f"BLOCKED:process-evidence-invalid: {exc}\n")
|
|
905
|
+
return 2
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
if __name__ == "__main__":
|
|
909
|
+
raise SystemExit(main())
|