devlyn-cli 2.10.0 → 2.10.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 +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
- package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
- package/bin/devlyn.js +53 -8
- package/config/skills/_shared/adapters/grok.md +34 -12
- package/config/skills/_shared/collect-codex-findings.py +228 -52
- package/config/skills/_shared/expected.schema.json +7 -0
- package/config/skills/_shared/grok-anchor-guard.py +61 -0
- package/config/skills/_shared/phase-prompt-render.py +181 -0
- package/config/skills/_shared/spec-verify-check.py +308 -31
- package/config/skills/_shared/state-phase-write.py +720 -32
- package/config/skills/_shared/verify-merge-findings.py +170 -0
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:resolve/SKILL.md +7 -6
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +5 -1
- package/config/skills/devlyn:resolve/references/state-schema.md +2 -2
- package/package.json +1 -1
- package/scripts/lint-fixtures.sh +4 -1
- package/scripts/lint-skills.sh +138 -10
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Normalize raw
|
|
2
|
+
"""Normalize raw pair-JUDGE stdout into canonical VERIFY JSONL."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
8
|
import pathlib
|
|
9
|
+
import runpy
|
|
9
10
|
import sys
|
|
10
11
|
import tempfile
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
FINDING_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
|
|
16
|
+
ENVELOPE_KEYS = {"text", "stopReason", "sessionId", "requestId"}
|
|
17
|
+
IGNORABLE_FENCES = {"```", "```json", "```jsonl"}
|
|
18
|
+
MERGE_CONTRACT = runpy.run_path(pathlib.Path(__file__).with_name("verify-merge-findings.py"))
|
|
19
|
+
VERDICT_RANK = MERGE_CONTRACT["VERDICT_RANK"]
|
|
20
|
+
finding_rank = MERGE_CONTRACT["finding_rank"]
|
|
21
|
+
NARRATIVE_PREAMBLE_BYTES = frozenset(
|
|
22
|
+
b"\t\n\r !\"$%&'()*+,-./0123456789:;<=>?@"
|
|
23
|
+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_abcdefghijklmnopqrstuvwxyz|}~"
|
|
24
|
+
) | frozenset(range(0x80, 0x100))
|
|
15
25
|
|
|
16
26
|
|
|
17
27
|
def reject_json_constant(token: str) -> None:
|
|
@@ -32,76 +42,242 @@ def atomic_write(path: pathlib.Path, text: str) -> None:
|
|
|
32
42
|
pathlib.Path(tmp_name).replace(path)
|
|
33
43
|
|
|
34
44
|
|
|
35
|
-
def
|
|
45
|
+
def collect_text(
|
|
46
|
+
text: str, source: pathlib.Path
|
|
47
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
|
36
48
|
findings: list[dict[str, Any]] = []
|
|
37
49
|
summary: dict[str, Any] | None = None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if
|
|
44
|
-
|
|
45
|
-
item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
|
|
46
|
-
except ValueError as exc:
|
|
47
|
-
raise SystemExit(f"error: invalid SUMMARY JSON at {stdout_path}:{line_no}: {exc}")
|
|
48
|
-
if not isinstance(item, dict):
|
|
49
|
-
raise SystemExit(f"error: SUMMARY is not an object at {stdout_path}:{line_no}")
|
|
50
|
-
summary = item
|
|
51
|
-
continue
|
|
52
|
-
if raw.startswith("#"):
|
|
53
|
-
continue
|
|
50
|
+
for line_no, line in enumerate(text.splitlines(), 1):
|
|
51
|
+
raw = line.strip()
|
|
52
|
+
if not raw or raw in IGNORABLE_FENCES:
|
|
53
|
+
continue
|
|
54
|
+
if raw.startswith("# SUMMARY "):
|
|
55
|
+
if summary is not None:
|
|
56
|
+
raise SystemExit(f"error: record after terminal SUMMARY at {source}:{line_no}")
|
|
54
57
|
try:
|
|
55
|
-
item = loads_strict_json(raw)
|
|
58
|
+
item = loads_strict_json(raw.removeprefix("# SUMMARY ").strip())
|
|
56
59
|
except ValueError as exc:
|
|
57
|
-
raise SystemExit(f"error: invalid
|
|
60
|
+
raise SystemExit(f"error: invalid SUMMARY JSON at {source}:{line_no}: {exc}")
|
|
58
61
|
if not isinstance(item, dict):
|
|
59
|
-
raise SystemExit(f"error:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
raise SystemExit(f"error: SUMMARY is not an object at {source}:{line_no}")
|
|
63
|
+
if item.get("verdict") not in VERDICT_RANK:
|
|
64
|
+
raise SystemExit(f"error: SUMMARY has unknown verdict at {source}:{line_no}")
|
|
65
|
+
summary = item
|
|
66
|
+
continue
|
|
67
|
+
if raw.startswith("#"):
|
|
68
|
+
continue
|
|
69
|
+
if summary is not None:
|
|
70
|
+
raise SystemExit(f"error: record after terminal SUMMARY at {source}:{line_no}")
|
|
71
|
+
try:
|
|
72
|
+
item = loads_strict_json(raw)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise SystemExit(f"error: invalid JSONL at {source}:{line_no}: {exc}")
|
|
75
|
+
if not isinstance(item, dict):
|
|
76
|
+
raise SystemExit(f"error: JSONL item is not an object at {source}:{line_no}")
|
|
77
|
+
if set(item) == {"verdict"}:
|
|
78
|
+
if item["verdict"] not in VERDICT_RANK:
|
|
79
|
+
raise SystemExit(f"error: SUMMARY has unknown verdict at {source}:{line_no}")
|
|
80
|
+
summary = item
|
|
81
|
+
continue
|
|
82
|
+
severity = str(item.get("severity") or "").upper()
|
|
83
|
+
if severity not in FINDING_SEVERITIES:
|
|
84
|
+
raise SystemExit(f"error: finding missing valid severity at {source}:{line_no}")
|
|
85
|
+
findings.append(item)
|
|
86
|
+
if summary is not None and summary["verdict"] == "PASS" and any(
|
|
87
|
+
finding_rank(finding) == 2 for finding in findings
|
|
88
|
+
):
|
|
89
|
+
raise SystemExit("error: verdict-binding finding cannot have a PASS SUMMARY")
|
|
64
90
|
if not findings and (summary is None or summary.get("verdict") != "PASS"):
|
|
65
91
|
raise SystemExit("error: non-PASS SUMMARY without JSONL findings")
|
|
66
92
|
return findings, summary
|
|
67
93
|
|
|
68
94
|
|
|
95
|
+
def recover_envelope_text(
|
|
96
|
+
text: str, source: pathlib.Path
|
|
97
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
|
98
|
+
encoded = text.encode("utf-8")
|
|
99
|
+
for cut, byte in enumerate(encoded[:-1], 1):
|
|
100
|
+
if byte not in NARRATIVE_PREAMBLE_BYTES:
|
|
101
|
+
break
|
|
102
|
+
try:
|
|
103
|
+
candidate = encoded[cut:].decode("utf-8")
|
|
104
|
+
except UnicodeDecodeError:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
findings, summary = collect_text(candidate, source)
|
|
108
|
+
except SystemExit:
|
|
109
|
+
continue
|
|
110
|
+
if (
|
|
111
|
+
summary is not None
|
|
112
|
+
and summary["verdict"] == "NEEDS_WORK"
|
|
113
|
+
and any(finding_rank(finding) == 2 for finding in findings)
|
|
114
|
+
):
|
|
115
|
+
return findings, summary
|
|
116
|
+
raise SystemExit("error: no admissible envelope recovery")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def collect_stdout(stdout_path: pathlib.Path) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
|
|
120
|
+
stdout_text = stdout_path.read_text(encoding="utf-8")
|
|
121
|
+
try:
|
|
122
|
+
candidate = loads_strict_json(stdout_text)
|
|
123
|
+
except ValueError:
|
|
124
|
+
candidate = None
|
|
125
|
+
if (
|
|
126
|
+
not isinstance(candidate, dict)
|
|
127
|
+
or not ENVELOPE_KEYS.issubset(candidate)
|
|
128
|
+
or "severity" in candidate
|
|
129
|
+
):
|
|
130
|
+
return collect_text(stdout_text, stdout_path)
|
|
131
|
+
|
|
132
|
+
stop_reason = candidate["stopReason"]
|
|
133
|
+
if stop_reason != "EndTurn":
|
|
134
|
+
raise SystemExit(
|
|
135
|
+
f"error: envelope stopReason must be 'EndTurn' at {stdout_path}; "
|
|
136
|
+
f"got {stop_reason!r}"
|
|
137
|
+
)
|
|
138
|
+
envelope_text = candidate["text"]
|
|
139
|
+
if not isinstance(envelope_text, str):
|
|
140
|
+
raise SystemExit(f"error: envelope text must be a string at {stdout_path}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
return collect_text(envelope_text, stdout_path)
|
|
144
|
+
except SystemExit as exc:
|
|
145
|
+
try:
|
|
146
|
+
return recover_envelope_text(envelope_text, stdout_path)
|
|
147
|
+
except SystemExit:
|
|
148
|
+
raise SystemExit(f"error: envelope text rejected: {exc}") from None
|
|
149
|
+
|
|
150
|
+
|
|
69
151
|
def self_test() -> int:
|
|
70
152
|
with tempfile.TemporaryDirectory() as tmp:
|
|
71
153
|
root = pathlib.Path(tmp)
|
|
72
|
-
stdout_path = root / "
|
|
154
|
+
stdout_path = root / "pair-judge.stdout"
|
|
73
155
|
out_path = root / "verify.pair.findings.jsonl"
|
|
74
|
-
summary_path = root / "
|
|
156
|
+
summary_path = root / "pair-judge.summary.json"
|
|
157
|
+
|
|
158
|
+
def assert_rejected(text: str, label: str, *message_parts: str) -> None:
|
|
159
|
+
stdout_path.write_text(text, encoding="utf-8")
|
|
160
|
+
try:
|
|
161
|
+
collect_stdout(stdout_path)
|
|
162
|
+
except SystemExit as exc:
|
|
163
|
+
message = str(exc)
|
|
164
|
+
assert all(part in message for part in message_parts)
|
|
165
|
+
else:
|
|
166
|
+
raise AssertionError(f"{label} must be rejected")
|
|
167
|
+
|
|
168
|
+
plain_finding = {"id": "a", "severity": "HIGH"}
|
|
169
|
+
plain_summary = {"verdict": "NEEDS_WORK"}
|
|
75
170
|
stdout_path.write_text(
|
|
76
|
-
json.dumps(
|
|
77
|
-
+
|
|
171
|
+
json.dumps(plain_finding) + "\n"
|
|
172
|
+
+ "# SUMMARY " + json.dumps(plain_summary) + "\n",
|
|
78
173
|
encoding="utf-8",
|
|
79
174
|
)
|
|
80
|
-
findings, summary =
|
|
175
|
+
findings, summary = collect_stdout(stdout_path)
|
|
176
|
+
assert findings == [plain_finding]
|
|
177
|
+
assert summary == plain_summary
|
|
81
178
|
write_outputs(findings, summary, out_path, summary_path)
|
|
82
179
|
assert out_path.read_text(encoding="utf-8").count("\n") == 1
|
|
83
180
|
assert loads_strict_json(summary_path.read_text(encoding="utf-8"))["verdict"] == "NEEDS_WORK"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
else:
|
|
90
|
-
raise AssertionError("NaN Codex stdout finding must not normalize")
|
|
181
|
+
assert_rejected(
|
|
182
|
+
'{"id":"nan","severity":NaN}\n',
|
|
183
|
+
"NaN pair-JUDGE stdout finding",
|
|
184
|
+
"invalid JSON numeric constant: NaN",
|
|
185
|
+
)
|
|
91
186
|
rejection_cases = (
|
|
92
|
-
("", "no SUMMARY line"),
|
|
93
|
-
('# SUMMARY {}\n', "empty SUMMARY object"),
|
|
94
|
-
('# SUMMARY {"verdict":"pass"}\n', "lowercase pass"),
|
|
95
|
-
('# SUMMARY {"verdict":"UNKNOWN"}\n', "unknown verdict"),
|
|
187
|
+
("", "no SUMMARY line", "non-PASS SUMMARY without JSONL findings"),
|
|
188
|
+
('# SUMMARY {}\n', "empty SUMMARY object", "SUMMARY has unknown verdict"),
|
|
189
|
+
('# SUMMARY {"verdict":"pass"}\n', "lowercase pass", "SUMMARY has unknown verdict"),
|
|
190
|
+
('# SUMMARY {"verdict":"UNKNOWN"}\n', "unknown verdict", "SUMMARY has unknown verdict"),
|
|
96
191
|
)
|
|
97
|
-
for text, label in rejection_cases:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
192
|
+
for text, label, message in rejection_cases:
|
|
193
|
+
assert_rejected(
|
|
194
|
+
text,
|
|
195
|
+
label,
|
|
196
|
+
message,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def envelope(text: Any, stop_reason: str = "EndTurn") -> str:
|
|
200
|
+
return json.dumps(
|
|
201
|
+
{
|
|
202
|
+
"text": text,
|
|
203
|
+
"stopReason": stop_reason,
|
|
204
|
+
"sessionId": "session",
|
|
205
|
+
"requestId": "request",
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
envelope_finding = {
|
|
210
|
+
"id": "envelope-finding",
|
|
211
|
+
"severity": "HIGH",
|
|
212
|
+
"detail": {"preserved": True},
|
|
213
|
+
}
|
|
214
|
+
envelope_summary = {"verdict": "NEEDS_WORK"}
|
|
215
|
+
stdout_path.write_text(
|
|
216
|
+
envelope(
|
|
217
|
+
json.dumps(envelope_finding)
|
|
218
|
+
+ "\n# SUMMARY "
|
|
219
|
+
+ json.dumps(envelope_summary)
|
|
220
|
+
+ "\n"
|
|
221
|
+
),
|
|
222
|
+
encoding="utf-8",
|
|
223
|
+
)
|
|
224
|
+
findings, summary = collect_stdout(stdout_path)
|
|
225
|
+
assert findings == [envelope_finding]
|
|
226
|
+
assert summary == envelope_summary
|
|
227
|
+
|
|
228
|
+
assert_rejected(
|
|
229
|
+
envelope("review completed without findings"),
|
|
230
|
+
"findings-less envelope text",
|
|
231
|
+
"error: envelope text rejected: error: invalid JSONL",
|
|
232
|
+
)
|
|
233
|
+
assert_rejected(
|
|
234
|
+
envelope('{"findings":[],"verdict":"PASS"}', "Cancelled"),
|
|
235
|
+
"Cancelled envelope",
|
|
236
|
+
"envelope stopReason must be 'EndTurn'",
|
|
237
|
+
"'Cancelled'",
|
|
238
|
+
)
|
|
239
|
+
assert_rejected(
|
|
240
|
+
envelope("", "UnknownStop"),
|
|
241
|
+
"unknown envelope stopReason",
|
|
242
|
+
"envelope stopReason must be 'EndTurn'",
|
|
243
|
+
"'UnknownStop'",
|
|
244
|
+
)
|
|
245
|
+
assert_rejected(
|
|
246
|
+
envelope(None),
|
|
247
|
+
"non-string envelope text",
|
|
248
|
+
"error: envelope text must be a string",
|
|
249
|
+
)
|
|
250
|
+
welded = (
|
|
251
|
+
"I will inspect the result."
|
|
252
|
+
+ json.dumps(envelope_finding)
|
|
253
|
+
+ "\n# SUMMARY "
|
|
254
|
+
+ json.dumps(envelope_summary)
|
|
255
|
+
+ "\n"
|
|
256
|
+
)
|
|
257
|
+
stdout_path.write_text(envelope(welded), encoding="utf-8")
|
|
258
|
+
findings, summary = collect_stdout(stdout_path)
|
|
259
|
+
assert findings == [envelope_finding]
|
|
260
|
+
assert summary == envelope_summary
|
|
261
|
+
dual_document = (
|
|
262
|
+
json.dumps(envelope_finding)
|
|
263
|
+
+ json.dumps({"id": "second", "severity": "LOW"})
|
|
264
|
+
+ "\n# SUMMARY "
|
|
265
|
+
+ json.dumps(envelope_summary)
|
|
266
|
+
+ "\n"
|
|
267
|
+
)
|
|
268
|
+
assert_rejected(
|
|
269
|
+
envelope(dual_document),
|
|
270
|
+
"concatenated envelope documents",
|
|
271
|
+
"error: envelope text rejected: error: invalid JSONL",
|
|
272
|
+
)
|
|
273
|
+
clean_summary = {"verdict": "PASS"}
|
|
274
|
+
stdout_path.write_text(
|
|
275
|
+
envelope("# SUMMARY " + json.dumps(clean_summary) + "\n"),
|
|
276
|
+
encoding="utf-8",
|
|
277
|
+
)
|
|
278
|
+
findings, summary = collect_stdout(stdout_path)
|
|
279
|
+
assert findings == []
|
|
280
|
+
assert summary == clean_summary
|
|
105
281
|
return 0
|
|
106
282
|
|
|
107
283
|
|
|
@@ -122,9 +298,9 @@ def write_outputs(
|
|
|
122
298
|
def main() -> int:
|
|
123
299
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
124
300
|
parser.add_argument("--devlyn-dir", default=".devlyn")
|
|
125
|
-
parser.add_argument("--stdout-file", default="
|
|
301
|
+
parser.add_argument("--stdout-file", default="pair-judge.stdout")
|
|
126
302
|
parser.add_argument("--out", default="verify.pair.findings.jsonl")
|
|
127
|
-
parser.add_argument("--summary-out", default="
|
|
303
|
+
parser.add_argument("--summary-out", default="pair-judge.summary.json")
|
|
128
304
|
parser.add_argument("--self-test", action="store_true")
|
|
129
305
|
args = parser.parse_args()
|
|
130
306
|
if args.self_test:
|
|
@@ -135,7 +311,7 @@ def main() -> int:
|
|
|
135
311
|
if not stdout_path.is_file():
|
|
136
312
|
sys.stderr.write(f"error: {stdout_path} not found\n")
|
|
137
313
|
return 1
|
|
138
|
-
findings, summary =
|
|
314
|
+
findings, summary = collect_stdout(stdout_path)
|
|
139
315
|
write_outputs(findings, summary, devlyn / args.out, devlyn / args.summary_out)
|
|
140
316
|
print(json.dumps({"findings_count": len(findings), "summary": summary}, sort_keys=True))
|
|
141
317
|
return 0
|
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
"description": "Required exit code. Default 0 if omitted.",
|
|
25
25
|
"default": 0
|
|
26
26
|
},
|
|
27
|
+
"timeout_sec": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"minimum": 1,
|
|
30
|
+
"maximum": 600,
|
|
31
|
+
"description": "Per-command timeout in seconds. Default 60 if omitted; maximum 600.",
|
|
32
|
+
"default": 60
|
|
33
|
+
},
|
|
27
34
|
"stdout_contains": {
|
|
28
35
|
"type": "array",
|
|
29
36
|
"description": "Each substring must appear verbatim in (stdout + stderr) for pass.",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deny pair-judge shell outside the probe anchor, with a model-readable reason.
|
|
3
|
+
|
|
4
|
+
Registered as a native grok `PreToolUse` hook. It fires at step 1 of the
|
|
5
|
+
authorization pipeline, before the permission rules and before the mode policy
|
|
6
|
+
whose auto-deny silently terminates the review at exit 0 (iter-0081).
|
|
7
|
+
|
|
8
|
+
The anchor is supplied via DEVLYN_PROBE_ANCHOR, derived by the orchestrator from
|
|
9
|
+
the same source as the --allow rule.
|
|
10
|
+
|
|
11
|
+
Measured (iter-0081 M-C6 v2, external isolation, admissible cell): Line A 6/6 —
|
|
12
|
+
the denial arrives as a model-visible tool_result, the judge re-routes to
|
|
13
|
+
read_file, the anchor still executes inside the grok process, EndTurn with zero
|
|
14
|
+
PermissionCancelled. Line A-chain 1/1 — `<anchor> && <write>` is denied
|
|
15
|
+
model-visibly, neither segment executes, and the judge then runs the bare anchor.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
REASON = (
|
|
22
|
+
"DEVLYN-0081-HOOK-DENY: this shell command is outside the permitted probe "
|
|
23
|
+
"anchor. Use the read_file tool for file reads. The only permitted shell "
|
|
24
|
+
"command is the anchor named in your prompt, unchained."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
SHELL_TOOLS = {"run_terminal_command", "run_terminal_cmd", "Bash"}
|
|
28
|
+
|
|
29
|
+
# A conservative, quote-OBLIVIOUS lexical veto — deliberately not a shell parser.
|
|
30
|
+
# It rejects these characters even inside quotes, over-denying rather than ever
|
|
31
|
+
# under-denying a chain. Adding quote-, escape-, or operator-aware exceptions is
|
|
32
|
+
# how this becomes a parser; that is a registered falsifier, not a polish.
|
|
33
|
+
FORBIDDEN = set(";&|`$()<>") | {"\n", "\r"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
anchor = os.environ.get("DEVLYN_PROBE_ANCHOR", "")
|
|
38
|
+
payload = json.load(sys.stdin)
|
|
39
|
+
|
|
40
|
+
if payload.get("toolName") not in SHELL_TOOLS or not anchor:
|
|
41
|
+
print(json.dumps({"decision": "allow"}))
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# The anchor, or the anchor plus argv, and nothing that could start a second
|
|
45
|
+
# command. A bare prefix rule would allow `<anchor> && <other>` — the vendor's
|
|
46
|
+
# own documented footgun. Exact match would instead deny `<anchor> --n 5`,
|
|
47
|
+
# mechanically destroying the "bounded input variations" that verify.md
|
|
48
|
+
# requires of every engine. Both were rejected by both seats (iter-0081 v2).
|
|
49
|
+
command = (payload.get("toolInput") or {}).get("command", "").strip()
|
|
50
|
+
if command == anchor or (
|
|
51
|
+
command.startswith(anchor + " ")
|
|
52
|
+
and not any(ch in FORBIDDEN for ch in command)
|
|
53
|
+
):
|
|
54
|
+
print(json.dumps({"decision": "allow"}))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
print(json.dumps({"decision": "deny", "reason": REASON}))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render exact phase-prompt bytes and print their SHA-256 digest."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
EXCLUDED_ADAPTER_SECTION = re.compile(
|
|
14
|
+
rb"^## (?:Role eligibility|Invocation)(?:\r?\n|\Z).*?(?=^## |\Z)",
|
|
15
|
+
re.MULTILINE | re.DOTALL,
|
|
16
|
+
)
|
|
17
|
+
EXCLUDED_ADAPTER_HEADING = re.compile(
|
|
18
|
+
rb"^## (?:Role eligibility|Invocation)\r?$", re.MULTILINE
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def project_adapter(content: bytes) -> bytes:
|
|
23
|
+
projected = EXCLUDED_ADAPTER_SECTION.sub(b"", content)
|
|
24
|
+
assert EXCLUDED_ADAPTER_HEADING.search(projected) is None
|
|
25
|
+
return projected
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_plan_context(task_context: pathlib.Path, content: bytes) -> None:
|
|
29
|
+
if task_context.name != "plan.task-context":
|
|
30
|
+
return
|
|
31
|
+
working_directory = pathlib.Path.cwd()
|
|
32
|
+
expected = (
|
|
33
|
+
b"Working directory: "
|
|
34
|
+
+ os.fsencode(working_directory)
|
|
35
|
+
+ b"\nPlan output: "
|
|
36
|
+
+ os.fsencode(working_directory / ".devlyn" / "plan.md")
|
|
37
|
+
+ b"\n"
|
|
38
|
+
)
|
|
39
|
+
if not content.startswith(expected):
|
|
40
|
+
raise SystemExit("error: invalid PLAN task-context header")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_prompt(
|
|
44
|
+
adapter: pathlib.Path,
|
|
45
|
+
canonical_body: pathlib.Path,
|
|
46
|
+
task_context: pathlib.Path,
|
|
47
|
+
output: pathlib.Path,
|
|
48
|
+
) -> str:
|
|
49
|
+
try:
|
|
50
|
+
adapter_bytes = adapter.read_bytes()
|
|
51
|
+
body_bytes = canonical_body.read_bytes()
|
|
52
|
+
context_bytes = task_context.read_bytes()
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise SystemExit(f"error: prompt input unreadable: {exc}") from exc
|
|
55
|
+
projected_adapter = project_adapter(adapter_bytes)
|
|
56
|
+
validate_plan_context(task_context, context_bytes)
|
|
57
|
+
rendered = projected_adapter + body_bytes + context_bytes
|
|
58
|
+
rendered = rendered.rstrip(b"\n")
|
|
59
|
+
if not output.parent.is_dir():
|
|
60
|
+
raise SystemExit(f"error: prompt output parent is not a directory: {output.parent}")
|
|
61
|
+
fd, temporary = tempfile.mkstemp(dir=output.parent, prefix=output.name + ".tmp.")
|
|
62
|
+
temporary_path = pathlib.Path(temporary)
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(fd, "wb") as handle:
|
|
65
|
+
handle.write(rendered)
|
|
66
|
+
handle.flush()
|
|
67
|
+
os.fsync(handle.fileno())
|
|
68
|
+
temporary_path.replace(output)
|
|
69
|
+
except BaseException:
|
|
70
|
+
temporary_path.unlink(missing_ok=True)
|
|
71
|
+
raise
|
|
72
|
+
return hashlib.sha256(rendered).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def self_test() -> int:
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="phase-prompt-render-") as raw:
|
|
77
|
+
root = pathlib.Path(raw)
|
|
78
|
+
adapter = root / "adapter.md"
|
|
79
|
+
body = root / "plan.md"
|
|
80
|
+
context = root / "plan.task-context"
|
|
81
|
+
output = root / ".devlyn" / "plan.prompt"
|
|
82
|
+
output.parent.mkdir()
|
|
83
|
+
original_directory = pathlib.Path.cwd()
|
|
84
|
+
os.chdir(root)
|
|
85
|
+
try:
|
|
86
|
+
working_directory = pathlib.Path.cwd()
|
|
87
|
+
adapter_bytes = (
|
|
88
|
+
b"# Claude adapter\r\n"
|
|
89
|
+
b"## Identity\r\nkept-identity-\xff\r\n"
|
|
90
|
+
b"## Role eligibility\nremove-role\r\n"
|
|
91
|
+
b"## Output discipline\r\nkept-output-\x80\n"
|
|
92
|
+
b"## Invocation\r\nremove-invocation\n"
|
|
93
|
+
b"## Anti-patterns\nkept-tail-without-newline"
|
|
94
|
+
)
|
|
95
|
+
projected_adapter = (
|
|
96
|
+
b"# Claude adapter\r\n"
|
|
97
|
+
b"## Identity\r\nkept-identity-\xff\r\n"
|
|
98
|
+
b"## Output discipline\r\nkept-output-\x80\n"
|
|
99
|
+
b"## Anti-patterns\nkept-tail-without-newline"
|
|
100
|
+
)
|
|
101
|
+
for metadata_free in (
|
|
102
|
+
b"# Codex adapter\r\n## Identity\nkept-\xfe",
|
|
103
|
+
b"# omp adapter\n## Output discipline\r\nkept\x81",
|
|
104
|
+
):
|
|
105
|
+
assert project_adapter(metadata_free) == metadata_free
|
|
106
|
+
|
|
107
|
+
body_bytes = b"# PHASE 1 \xe2\x80\x94 PLAN\n"
|
|
108
|
+
context_bytes = (
|
|
109
|
+
b"Working directory: "
|
|
110
|
+
+ os.fsencode(working_directory)
|
|
111
|
+
+ b"\nPlan output: "
|
|
112
|
+
+ os.fsencode(working_directory / ".devlyn" / "plan.md")
|
|
113
|
+
+ b"\ncontext-without-newline"
|
|
114
|
+
)
|
|
115
|
+
for path, content in (
|
|
116
|
+
(adapter, adapter_bytes),
|
|
117
|
+
(body, body_bytes),
|
|
118
|
+
(context, context_bytes),
|
|
119
|
+
):
|
|
120
|
+
path.write_bytes(content)
|
|
121
|
+
expected = projected_adapter + body_bytes + context_bytes
|
|
122
|
+
digest = render_prompt(adapter, body, context, output)
|
|
123
|
+
assert output.read_bytes() == expected
|
|
124
|
+
assert digest == hashlib.sha256(expected).hexdigest()
|
|
125
|
+
assert render_prompt(adapter, body, context, output) == digest
|
|
126
|
+
assert output.read_bytes() == expected
|
|
127
|
+
|
|
128
|
+
context_prefix = context_bytes.removesuffix(b"context-without-newline")
|
|
129
|
+
for terminal_lfs in (b"", b"\n", b"\n\n"):
|
|
130
|
+
case_context = context_prefix + b"context-terminal-lf-case" + terminal_lfs
|
|
131
|
+
context.write_bytes(case_context)
|
|
132
|
+
expected = (projected_adapter + body_bytes + case_context).rstrip(b"\n")
|
|
133
|
+
digest = render_prompt(adapter, body, context, output)
|
|
134
|
+
written = output.read_bytes()
|
|
135
|
+
assert not written.endswith(b"\n")
|
|
136
|
+
assert digest == hashlib.sha256(written).hexdigest()
|
|
137
|
+
assert written == expected
|
|
138
|
+
|
|
139
|
+
invalid_contexts = (
|
|
140
|
+
b"context-without-header",
|
|
141
|
+
b"Working directory: relative\nPlan output: relative/.devlyn/plan.md\n",
|
|
142
|
+
b"Working directory: /mismatch\nPlan output: /mismatch/.devlyn/plan.md\n",
|
|
143
|
+
)
|
|
144
|
+
for invalid_context in invalid_contexts:
|
|
145
|
+
context.write_bytes(invalid_context)
|
|
146
|
+
output.write_bytes(b"unchanged")
|
|
147
|
+
try:
|
|
148
|
+
render_prompt(adapter, body, context, output)
|
|
149
|
+
except SystemExit:
|
|
150
|
+
pass
|
|
151
|
+
else:
|
|
152
|
+
raise AssertionError("invalid PLAN task-context header accepted")
|
|
153
|
+
assert output.read_bytes() == b"unchanged"
|
|
154
|
+
finally:
|
|
155
|
+
os.chdir(original_directory)
|
|
156
|
+
print("SELFTEST PASS: projected exact bytes + PLAN context validation")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main() -> int:
|
|
161
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
162
|
+
parser.add_argument("--adapter", type=pathlib.Path)
|
|
163
|
+
parser.add_argument("--canonical-body", type=pathlib.Path)
|
|
164
|
+
parser.add_argument("--task-context", type=pathlib.Path)
|
|
165
|
+
parser.add_argument("--output", type=pathlib.Path)
|
|
166
|
+
parser.add_argument("--self-test", action="store_true")
|
|
167
|
+
args = parser.parse_args()
|
|
168
|
+
if args.self_test:
|
|
169
|
+
if any((args.adapter, args.canonical_body, args.task_context, args.output)):
|
|
170
|
+
parser.error("render paths are not allowed with --self-test")
|
|
171
|
+
return self_test()
|
|
172
|
+
if not all((args.adapter, args.canonical_body, args.task_context, args.output)):
|
|
173
|
+
parser.error(
|
|
174
|
+
"--adapter, --canonical-body, --task-context, and --output are required"
|
|
175
|
+
)
|
|
176
|
+
print(render_prompt(args.adapter, args.canonical_body, args.task_context, args.output))
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
raise SystemExit(main())
|