@arbiterforge/ca-pi 0.21.5 → 0.21.7

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/README.md CHANGED
@@ -10,7 +10,7 @@ and durable project context. You decide. codeArbiter enforces.
10
10
  <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
11
11
  <img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
12
12
  <img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
13
- <img alt="version 3.2.5" src="https://img.shields.io/badge/version-3.2.5-2b7489">
13
+ <img alt="version 3.2.7" src="https://img.shields.io/badge/version-3.2.7-2b7489">
14
14
  <img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
15
15
  <img alt="skills" src="https://img.shields.io/badge/skills-22-555">
16
16
  <img alt="agents" src="https://img.shields.io/badge/agents-20-555">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.21.5",
3
+ "version": "0.21.7",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,30 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.21.7] - 2026-10-21
8
+
9
+ ### Fixed
10
+
11
+ - Keep ordinary uppercase instructions such as `Approve DATA migration` out of
12
+ artifact reply capture unless that all-letter code belongs to a retained
13
+ request. Genuine coded replies and mismatch protection remain intact.
14
+ - Recognize verifier authority only from the actual wrapper invocation, not
15
+ inert PowerShell data mentioning its filename.
16
+ - Permit bounded literal read-only Python diagnostics using isolated `-I`
17
+ stdin input and literal `git show HEAD:<protected-path>` or `Path` reads.
18
+ No arbitrary script, subprocess, shell expansion or write exemption is added.
19
+
20
+ - Ordinary approval, sprint, prerequisite, and reconciliation instructions no longer enter legacy reply-code capture. Historical requests and actual host permissions retain their checks.
21
+ - Bounded literal JSON sent to the installed read-only diagnostic validator no longer triggers protected-state write guards merely by citing source paths. Ambiguous or executable shell forms remain guarded.
22
+ - Explicit staging in a multiline batch no longer borrows flags from later commands. Every staging command retains wildcard, directory, continuation, and working-directory checks.
23
+ - Explicit draft source refreshes now preserve started work and historical evidence instead of requiring an untouched initial approval pair. Source and plan revision checks and historical approval boundaries remain intact.
24
+
25
+ ## [0.21.6] - 2026-10-19
26
+
27
+ ### Fixed
28
+
29
+ - Engine model-validation errors now name what the schema accepts. A closed-enum refusal lists the allowed values (for example, applicability `mode` accepts `"conditional"`, `"unconditional"`, `"invariant"` and `"not_applicable"`), and an unknown key lists the record's allowed fields. Before, both said only that the value was refused, so the accepted spelling had to be found in the schema files. The list stays within the 512-byte public diagnostic limit.
30
+
7
31
  ## [0.21.5] - 2026-10-19
8
32
 
9
33
  ### Fixed
@@ -6,43 +6,43 @@
6
6
  "binaries": {
7
7
  "darwin/amd64": {
8
8
  "file": "ca-artifact-darwin-amd64",
9
- "sha256": "e501ca2b817c882feec23bc3560223f20d88ca372400f950679bfe01ba47cebd",
9
+ "sha256": "da9898ed084adde20454997c8a94490cc38c210dfe399cfe9e48bf85205e82c4",
10
10
  "native_tested": true
11
11
  },
12
12
  "darwin/arm64": {
13
13
  "file": "ca-artifact-darwin-arm64",
14
- "sha256": "0fe9c4672486a28db1340c77d9eebf37bc33774fc34b5c055728f135afe4d7e8",
14
+ "sha256": "8e23e57e617d2c7864c2cba779b2cfd19dbc556e876a68e5c2c150455c58fe1f",
15
15
  "native_tested": true
16
16
  },
17
17
  "linux/amd64": {
18
18
  "file": "ca-artifact-linux-amd64",
19
- "sha256": "f98d2647144640b226d0fa3fae6369eefbcee477e9d2bb818f767711e6a3e506",
19
+ "sha256": "3835ecfc7f6172e0d19c79b9c213eb211e530a46384df51b96820712ce471864",
20
20
  "native_tested": true
21
21
  },
22
22
  "linux/arm64": {
23
23
  "file": "ca-artifact-linux-arm64",
24
- "sha256": "7abc7f188851bde8c082c2b812b74378e9e1848541aeb9029ee8551f4b68fdaa",
24
+ "sha256": "8564bfec069ab1ce30db37135197e8c4e093a2bc4ce6ea7f308029799f09af09",
25
25
  "native_tested": true
26
26
  },
27
27
  "windows/amd64": {
28
28
  "file": "ca-artifact-windows-amd64.exe",
29
- "sha256": "3fe0a4b867fbcc8a278248f92697b649f4b9b208d51c5168885ba92fcbb73961",
29
+ "sha256": "55641212ecde2ddf078e70bcc44940696a68065f2dc323e69b3de804d24611ee",
30
30
  "native_tested": true
31
31
  },
32
32
  "windows/arm64": {
33
33
  "file": "ca-artifact-windows-arm64.exe",
34
- "sha256": "339210645436d11bca4129f74d4a7885ad12a9489f449669b5f46a6f9cd1056d",
34
+ "sha256": "7d92b708e52ebbb0a08bb146f6a4c372277b2aa58716ca56b9bad04d671dc995",
35
35
  "native_tested": true
36
36
  }
37
37
  },
38
38
  "context_qualification": {
39
39
  "darwin/amd64": {
40
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
40
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
41
41
  "workflow": ".github/workflows/ci.yml",
42
- "run_id": "39337812494",
43
- "binary_sha256": "e501ca2b817c882feec23bc3560223f20d88ca372400f950679bfe01ba47cebd",
44
- "observation_sha256": "c832f3aff1d1c2b553173e16bb4fcd5b7ddf1cbf45c2a57e32840f58232c5a7a",
45
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
42
+ "run_id": "39769650796",
43
+ "binary_sha256": "da9898ed084adde20454997c8a94490cc38c210dfe399cfe9e48bf85205e82c4",
44
+ "observation_sha256": "ba23366af537c89797803bc21f45368dcbca3ee18871fce020032695a4db70ee",
45
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
46
46
  "native_cases": [
47
47
  "TestContextBoundedRender",
48
48
  "TestContextDocumentContract",
@@ -53,12 +53,12 @@
53
53
  ]
54
54
  },
55
55
  "darwin/arm64": {
56
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
56
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
57
57
  "workflow": ".github/workflows/ci.yml",
58
- "run_id": "39337812494",
59
- "binary_sha256": "0fe9c4672486a28db1340c77d9eebf37bc33774fc34b5c055728f135afe4d7e8",
60
- "observation_sha256": "8007bb64505d6f4163df70b58d0a52d950f718caf284846b3a79b9936c759dee",
61
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
58
+ "run_id": "39769650796",
59
+ "binary_sha256": "8e23e57e617d2c7864c2cba779b2cfd19dbc556e876a68e5c2c150455c58fe1f",
60
+ "observation_sha256": "84141d37a93b7c204acb93dd4e043f4cf841ffacad1c0a497eb1d9d9f8852a7c",
61
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
62
62
  "native_cases": [
63
63
  "TestContextBoundedRender",
64
64
  "TestContextDocumentContract",
@@ -69,12 +69,12 @@
69
69
  ]
70
70
  },
71
71
  "linux/amd64": {
72
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
72
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
73
73
  "workflow": ".github/workflows/ci.yml",
74
- "run_id": "39337812494",
75
- "binary_sha256": "f98d2647144640b226d0fa3fae6369eefbcee477e9d2bb818f767711e6a3e506",
76
- "observation_sha256": "d7c40f95cd3189dee10f26db25d63fbc2891181280efc2403dda058912f80236",
77
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
74
+ "run_id": "39769650796",
75
+ "binary_sha256": "3835ecfc7f6172e0d19c79b9c213eb211e530a46384df51b96820712ce471864",
76
+ "observation_sha256": "cdc3731052edd16a558fade51587f6fe3679da6c1c3282250d4de5e9eb29658e",
77
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
78
78
  "native_cases": [
79
79
  "TestContextBoundedRender",
80
80
  "TestContextDocumentContract",
@@ -85,12 +85,12 @@
85
85
  ]
86
86
  },
87
87
  "linux/arm64": {
88
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
88
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
89
89
  "workflow": ".github/workflows/ci.yml",
90
- "run_id": "39337812494",
91
- "binary_sha256": "7abc7f188851bde8c082c2b812b74378e9e1848541aeb9029ee8551f4b68fdaa",
92
- "observation_sha256": "1bd885c3ecb6efacb789404d0d97db91596943de630c1cecbcee25482f2bf26f",
93
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
90
+ "run_id": "39769650796",
91
+ "binary_sha256": "8564bfec069ab1ce30db37135197e8c4e093a2bc4ce6ea7f308029799f09af09",
92
+ "observation_sha256": "13e4cbf8422754dfa6478785bd9a2d3dd0af1cdfb10e0a654048ee79c4b3787c",
93
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
94
94
  "native_cases": [
95
95
  "TestContextBoundedRender",
96
96
  "TestContextDocumentContract",
@@ -101,12 +101,12 @@
101
101
  ]
102
102
  },
103
103
  "windows/amd64": {
104
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
104
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
105
105
  "workflow": ".github/workflows/ci.yml",
106
- "run_id": "39337812494",
107
- "binary_sha256": "3fe0a4b867fbcc8a278248f92697b649f4b9b208d51c5168885ba92fcbb73961",
108
- "observation_sha256": "8439eeadb753215c8e5e983091ff61e1920ac12480d9406e9cc6a837faa7725f",
109
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
106
+ "run_id": "39769650796",
107
+ "binary_sha256": "55641212ecde2ddf078e70bcc44940696a68065f2dc323e69b3de804d24611ee",
108
+ "observation_sha256": "27e759e25cc94f952cea7606423dea542cefce0ae934c25ec1ac21dbc3eed6ee",
109
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
110
110
  "native_cases": [
111
111
  "TestContextBoundedRender",
112
112
  "TestContextDocumentContract",
@@ -117,12 +117,12 @@
117
117
  ]
118
118
  },
119
119
  "windows/arm64": {
120
- "source_commit": "0fc246614c8b0a85188ffcee9284f1942b4b3567",
120
+ "source_commit": "ddb07a3af1fc56692d1cff3fd4579058ee6c9ef7",
121
121
  "workflow": ".github/workflows/ci.yml",
122
- "run_id": "39337812494",
123
- "binary_sha256": "339210645436d11bca4129f74d4a7885ad12a9489f449669b5f46a6f9cd1056d",
124
- "observation_sha256": "062e23804043c352163576ced022881e88e849f1d9b202b14c08958ec9c63ae8",
125
- "module_sha256": "537452f67f1105572c4fa3829cec4af7354260a96e1614b66daa8fec7eefc256",
122
+ "run_id": "39769650796",
123
+ "binary_sha256": "7d92b708e52ebbb0a08bb146f6a4c372277b2aa58716ca56b9bad04d671dc995",
124
+ "observation_sha256": "345404cb624bb6a4306e6a838d383fc1d4b99f95db6352ad90fd394edf072f5c",
125
+ "module_sha256": "ee801a17cb5132ceb5500b62e24e74d593e79feae3db50c07777ef8ff47f567d",
126
126
  "native_cases": [
127
127
  "TestContextBoundedRender",
128
128
  "TestContextDocumentContract",
@@ -480,6 +480,8 @@ def consume_from_hook(*, root: str | Path, plugin_root: str | Path, prompt: str,
480
480
  prepared = _replylib.prepare(raw, "approval")
481
481
  prompt = prepared["text"]
482
482
  attempted = prepared["attempted"]
483
+ if not attempted:
484
+ return ""
483
485
 
484
486
  def missed(reason: str) -> str:
485
487
  _replylib.record_near_miss(root, raw, reason)
@@ -1889,12 +1889,16 @@ def _command_name(value: str) -> str:
1889
1889
  return value.replace("\\", "/").rsplit("/", 1)[-1].casefold()
1890
1890
 
1891
1891
 
1892
- def _command_tokens(command: str) -> list[str]:
1892
+ def _command_lexer(command: str) -> shlex.shlex:
1893
1893
  lexer = shlex.shlex(command, posix=True)
1894
1894
  lexer.whitespace_split = True
1895
1895
  lexer.commenters = ""
1896
1896
  lexer.escape = "" # Native Windows paths retain backslashes inside both quotes.
1897
- return list(lexer)
1897
+ return lexer
1898
+
1899
+
1900
+ def _command_tokens(command: str) -> list[str]:
1901
+ return list(_command_lexer(command))
1898
1902
 
1899
1903
 
1900
1904
  def _npm_package(manifest: Path) -> dict[str, Any]:
@@ -2705,6 +2709,17 @@ def _model_id(value: Any, field: str) -> str:
2705
2709
  return value
2706
2710
 
2707
2711
 
2712
+ def _verification_wrapper_start(tokens: list[str]) -> bool:
2713
+ return (
2714
+ len(tokens) >= 2
2715
+ and _command_name(tokens[0]) in {
2716
+ "python", "python.exe", "python3", "python3.exe", "py", "py.exe",
2717
+ }
2718
+ and _command_name(tokens[1]) == "artifact-authority.py"
2719
+ and (len(tokens) == 2 or tokens[2] == "verify")
2720
+ )
2721
+
2722
+
2708
2723
  def _verification_selector(event: dict[str, Any]) -> tuple[str, Path, str] | None:
2709
2724
  """Recognize only the closed wrapper selector, never caller-authored child argv."""
2710
2725
  if event.get("tool_name") not in {
@@ -2718,21 +2733,19 @@ def _verification_selector(event: dict[str, Any]) -> tuple[str, Path, str] | Non
2718
2733
  if not isinstance(command, str):
2719
2734
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "exec command is absent")
2720
2735
  compound = any(marker in command for marker in ("\n", "\r", ";", "&", "|", ">", "<", "`", "$"))
2736
+ tokens: list[str] = []
2737
+ lexer = _command_lexer(command)
2721
2738
  try:
2722
- tokens = _command_tokens(command)
2739
+ tokens.extend(lexer)
2723
2740
  except ValueError as exc:
2724
- # An ordinary command with unbalanced quoting is not ours to deny.
2725
- if "artifact-authority" not in command.casefold():
2741
+ # Retain the parsed selector prefix even when a later argument is
2742
+ # malformed. A filename in ordinary shell source selects no authority.
2743
+ if len(tokens) == 1:
2744
+ tokens.append(lexer.token) # An unfinished quoted script still refuses.
2745
+ if not _verification_wrapper_start(tokens):
2726
2746
  return None
2727
2747
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is malformed") from exc
2728
- wrapper_start = (
2729
- len(tokens) >= 2
2730
- and _command_name(tokens[0]) in {
2731
- "python", "python.exe", "python3", "python3.exe", "py", "py.exe",
2732
- }
2733
- and _command_name(tokens[1]) == "artifact-authority.py"
2734
- and (len(tokens) == 2 or tokens[2] == "verify")
2735
- )
2748
+ wrapper_start = _verification_wrapper_start(tokens)
2736
2749
  if compound:
2737
2750
  if wrapper_start:
2738
2751
  raise AuthorityError("UNSUPPORTED_HOST_SEAM", "verifier wrapper command is compound")
@@ -75,6 +75,7 @@
75
75
  # on a shell mutation of a registered
76
76
  # protected-state file
77
77
 
78
+ import json
78
79
  import os
79
80
  import re
80
81
  import subprocess
@@ -246,7 +247,12 @@ ARGS = r"(?P<args>(?:\"[^\"]*\"|'[^']*'|[^|;&])*)"
246
247
  COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])"
247
248
  COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS)
248
249
  PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS)
249
- ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS)
250
+ # Staging argv ends at an unquoted command newline. Keep quoted multiline
251
+ # filenames intact; otherwise a later `git push -u` becomes a wildcard add.
252
+ # run_guards inspects EVERY match, so narrowing the first capture cannot hide
253
+ # a later unsafe add. Commit/push parsing retains its existing contract.
254
+ ADD_ARGS = r"(?P<args>(?:\"[^\"]*\"|'[^']*'|[^|;&\r\n])*)"
255
+ ADD_RE = re.compile(GIT + r"\s+add\b" + ADD_ARGS)
250
256
  # #485 AC-4, the audit of the sibling matchers. Neither needs the same
251
257
  # treatment, and both were left alone deliberately:
252
258
  #
@@ -1480,10 +1486,12 @@ def _check_h03_wildcard_add(add, cwd):
1480
1486
  src/ just as surely as `git add -A` does."""
1481
1487
  if not add:
1482
1488
  return
1483
- if WILDCARD_ADD_RE.search(add.group("args")):
1489
+ args = add.group("args")
1490
+ if (WILDCARD_ADD_RE.search(args)
1491
+ or _has_literal_flag(args, {"-A", "--all", "-u", "--update"})):
1484
1492
  block("H-03", "'git add -A' / 'git add .' / 'git add --all' / 'git add -u' "
1485
1493
  "are prohibited. Stage files explicitly (commit-gate skill).")
1486
- why = add_violation(add.group("args"), cwd)
1494
+ why = add_violation(args, cwd)
1487
1495
  if why:
1488
1496
  block("H-03", f"Wildcard staging is prohibited — {why} stages a "
1489
1497
  f"non-explicit file set. Stage files explicitly, one path "
@@ -1844,6 +1852,164 @@ def _check_h14_migration(commit, add, cwd, root):
1844
1852
  f"bypass a migration gate, /override logs the exception.")
1845
1853
 
1846
1854
 
1855
+ def _literal_python_read_view(cmd):
1856
+ """Recognize only isolated, literal Python read diagnostics on stdin.
1857
+
1858
+ This is a closed read grammar, not an exemption for Python/subprocess.
1859
+ Unknown code, import shadowing, shell expansion, and extra commands keep
1860
+ the original guard view. Equivalent literal read commands still traverse
1861
+ every gate. No code is executed or filesystem state consulted here.
1862
+ """
1863
+ if "\n" not in cmd or " -I" not in cmd:
1864
+ return cmd
1865
+ try:
1866
+ if len(cmd.encode("utf-8")) > 32768:
1867
+ return cmd
1868
+ except UnicodeError:
1869
+ return cmd
1870
+ if re.search(r"\r(?!\n)|[\x00\u2018-\u201b]", cmd):
1871
+ return cmd
1872
+ text = cmd.replace("\r\n", "\n")
1873
+ # -I excludes workspace/PYTHONPATH modules: otherwise an apparent
1874
+ # standard-library import could execute a project-authored replacement.
1875
+ invocation = r"(?:python(?:3(?:\.\d+)*)?|py)(?:\.exe)? -I(?: -B)? -"
1876
+ fallback = cmd
1877
+ match = re.fullmatch(
1878
+ r"@'\n(?P<body>[\s\S]*?)\n'@[ \t]*\|[ \t]*(?P<invoke>" + invocation + r")[ \t]*\n?", text)
1879
+ if match:
1880
+ body = match["body"]
1881
+ # The consumer appears after its script in PowerShell. If the
1882
+ # read grammar refuses it, expose that consumer before the intact
1883
+ # source too, so interpreter/write guards cannot miss an earlier path.
1884
+ fallback = match["invoke"] + "\n" + cmd
1885
+ if re.search(r"(?m)^[ \t]*'@", body):
1886
+ return cmd
1887
+ else:
1888
+ match = re.fullmatch(
1889
+ invocation + r"[ \t]+<<(?P<quote>['\"])(?P<end>[A-Za-z_][A-Za-z0-9_]{0,31})"
1890
+ r"(?P=quote)[ \t]*\n(?P<body>[\s\S]*?)\n(?P=end)[ \t]*\n?", text)
1891
+ if not match:
1892
+ return cmd
1893
+ body = match["body"]
1894
+ if any(line == match["end"] for line in body.splitlines()):
1895
+ return cmd
1896
+ # Ordinary commands pay no AST import/parse cost.
1897
+ import ast
1898
+ try:
1899
+ tree = ast.parse(body)
1900
+ except (SyntaxError, ValueError, RecursionError):
1901
+ return fallback
1902
+ if len(tree.body) > 32 or sum(1 for _ in ast.walk(tree)) > 256:
1903
+ return fallback
1904
+
1905
+ def literal(node):
1906
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
1907
+
1908
+ def read_path(node):
1909
+ value = literal(node)
1910
+ if value is not None and re.fullmatch(
1911
+ r"\.codearbiter/(?:overrides\.log|CONTEXT\.md|decisions/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:md|jsonl))",
1912
+ value,
1913
+ ):
1914
+ return value
1915
+ return None
1916
+
1917
+ imports, reads = set(), []
1918
+ for statement in tree.body:
1919
+ if (isinstance(statement, ast.Import) and len(statement.names) == 1
1920
+ and statement.names[0].name == "subprocess" and statement.names[0].asname is None):
1921
+ imports.add("subprocess")
1922
+ continue
1923
+ if (isinstance(statement, ast.ImportFrom) and statement.level == 0
1924
+ and statement.module == "pathlib" and len(statement.names) == 1
1925
+ and statement.names[0].name == "Path" and statement.names[0].asname is None):
1926
+ imports.add("Path")
1927
+ continue
1928
+ if not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call)
1929
+ and isinstance(statement.value.func, ast.Name) and statement.value.func.id == "print"
1930
+ and len(statement.value.args) == 1 and not statement.value.keywords):
1931
+ return fallback
1932
+ call = statement.value.args[0]
1933
+ if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute):
1934
+ return fallback
1935
+ owner = call.func.value
1936
+ if (isinstance(owner, ast.Name) and owner.id == "subprocess" and "subprocess" in imports
1937
+ and call.func.attr == "check_output" and len(call.args) == 1
1938
+ and isinstance(call.args[0], (ast.List, ast.Tuple)) and len(call.keywords) == 1
1939
+ and call.keywords[0].arg == "text" and isinstance(call.keywords[0].value, ast.Constant)
1940
+ and call.keywords[0].value.value is True):
1941
+ args = [literal(value) for value in call.args[0].elts]
1942
+ if (len(args) != 3 or args[:2] != ["git", "show"] or args[2] is None
1943
+ or not args[2].startswith("HEAD:") or not read_path(ast.Constant(args[2][5:]))):
1944
+ return fallback
1945
+ reads.append(" ".join(args))
1946
+ continue
1947
+ if (isinstance(owner, ast.Call) and isinstance(owner.func, ast.Name)
1948
+ and owner.func.id == "Path" and "Path" in imports
1949
+ and len(owner.args) == 1 and not owner.keywords and not call.args):
1950
+ path = read_path(owner.args[0])
1951
+ allowed = call.func.attr in {"exists", "is_file", "read_text"} and not call.keywords
1952
+ if call.func.attr == "read_text" and len(call.keywords) == 1:
1953
+ allowed = call.keywords[0].arg == "encoding" and literal(call.keywords[0].value) == "utf-8"
1954
+ if path is not None and allowed:
1955
+ reads.append("cat " + path)
1956
+ continue
1957
+ return fallback
1958
+ return "\n".join(reads) if reads else fallback
1959
+
1960
+
1961
+ def _debug_validation_command_view(cmd):
1962
+ """Exclude literal stdin data only for this package's read-only validator.
1963
+
1964
+ Accept one PowerShell single-quoted here-string piped to the exact installed
1965
+ helper with literal ``-B ... validate`` argv. Everything else keeps the raw
1966
+ guard view: expandable strings, wrappers, other helpers, extra commands,
1967
+ redirections and early terminators cannot become an exemption.
1968
+ """
1969
+ # PowerShell accepts lone CR newlines and curly single-quote terminators.
1970
+ # Keep those forms raw rather than missing an early terminator and hiding
1971
+ # executable code. JSON can represent these data characters with escapes.
1972
+ if re.search(r"\r(?!\n)|[\u2018-\u201b]", cmd):
1973
+ return cmd
1974
+ opening = re.match(r"\A@'\r?\n", cmd)
1975
+ if opening is None:
1976
+ return cmd
1977
+ closing = re.search(r"(?m)^[ \t]*'@", cmd[opening.end():])
1978
+ if closing is None or closing.group() != "'@":
1979
+ return cmd
1980
+ body = cmd[opening.end():opening.end() + closing.start()]
1981
+ try:
1982
+ if len(body.encode("utf-8")) > 65536 or not isinstance(json.loads(body), dict):
1983
+ return cmd
1984
+ except (ValueError, UnicodeError, RecursionError):
1985
+ return cmd
1986
+ tail = cmd[opening.end() + closing.end():].strip()
1987
+ if not tail.startswith("|"):
1988
+ return cmd
1989
+ invocation = tail[1:].strip()
1990
+ if re.search(r"[\r\n;&|<>`$%!^*?{}\[\]()]", invocation):
1991
+ return cmd
1992
+ tokens, end = [], 0
1993
+ for match in re.finditer(r'''(?:[^\s'"\x00]|'[^']*'|"[^"]*")+''', invocation):
1994
+ if invocation[end:match.start()].strip():
1995
+ return cmd
1996
+ token = match.group()
1997
+ if len(token) >= 2 and token[0] == token[-1] and token[0] in "\"'":
1998
+ token = token[1:-1]
1999
+ tokens.append(token)
2000
+ end = match.end()
2001
+ if invocation[end:].strip() or len(tokens) != 4 or tokens[1] != "-B" or tokens[3] != "validate":
2002
+ return cmd
2003
+ executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1]
2004
+ if not re.fullmatch(r"(?:python(?:[23](?:\.\d+)*)?|py)(?:\.exe)?", executable, re.I):
2005
+ return cmd
2006
+ helper = tokens[2]
2007
+ known = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug-handoff.py")
2008
+ if not os.path.isabs(helper) or os.path.normcase(os.path.normpath(helper)) != os.path.normcase(known):
2009
+ return cmd
2010
+ return invocation
2011
+
2012
+
1847
2013
  def run_guards(payload, root, ti):
1848
2014
  """Run every H-NN gate against `ti` (the already host-normalized Bash/
1849
2015
  PowerShell tool_input — see pre-bash.py's `_run`, which resolves it via
@@ -1853,7 +2019,8 @@ def run_guards(payload, root, ti):
1853
2019
  sys.exit(0). This is the composed body pre-bash.py's `_run` used to run
1854
2020
  inline (issue #320) — moved here verbatim, split into one function per
1855
2021
  gate, each still carrying its H-NN ID."""
1856
- cmd = ti.get("command", "") or ""
2022
+ cmd = _debug_validation_command_view(ti.get("command", "") or "")
2023
+ cmd = _literal_python_read_view(cmd)
1857
2024
 
1858
2025
  # Heredoc bodies are stdin text, not arguments — match the git command over
1859
2026
  # a body-stripped view so message content (which may contain `;`/`|`/`&`,
@@ -1905,9 +2072,25 @@ def run_guards(payload, root, ti):
1905
2072
  heredoc_shell_fallback and PUSH_RE.search(cmd))
1906
2073
  _check_push_gates(push, cwd)
1907
2074
 
1908
- add = ADD_RE.search(git_view) or (
1909
- heredoc_shell_fallback and ADD_RE.search(cmd))
1910
- _check_h03_wildcard_add(add, cwd)
2075
+ # Preserve quoted literal paths AND check executable continuations inside
2076
+ # nested/double quotes. Outside quotes, join POSIX/PowerShell/cmd escapes
2077
+ # so a split flag remains one argv item. Neither view changes other guards.
2078
+ quoted_staging = re.sub(r'''("[^"]*"|'[^']*')|[\\`^](?:\r\n|\n|\r)''',
2079
+ lambda match: match.group(1) or "", cmd)
2080
+ joined_staging = re.sub(r"[\\`^](?:\r\n|\n|\r)", "", cmd)
2081
+ adds = []
2082
+ for staging_cmd in dict.fromkeys((quoted_staging, joined_staging)):
2083
+ # Continuations can form the heredoc operator or shell name itself.
2084
+ # Re-evaluate execution per view, without rewriting quoted paths again.
2085
+ staging_view = HEREDOC_RE.sub(r"\3 ", staging_cmd)
2086
+ adds.extend(ADD_RE.finditer(staging_view))
2087
+ if "<<" in staging_cmd and (
2088
+ _heredoc_fed_to_shell(staging_cmd) or _has_shell_executor(staging_cmd)):
2089
+ adds.extend(ADD_RE.finditer(staging_cmd))
2090
+ for staging in adds:
2091
+ _check_h03_wildcard_add(staging, git_cwd(staging.group(0), exec_root))
2092
+ # Commit security/migration checks still see that the batch stages files.
2093
+ add = adds[0] if adds else None
1911
2094
  _check_h05_audit_log(cmd)
1912
2095
  _check_h11_decisions(cmd)
1913
2096
  _check_h18_context_md(cmd)
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.21.5"
14
+ adapter_version = "0.21.7"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -950,6 +950,8 @@ def consume_from_hook(
950
950
  prepared = _replylib.prepare(raw, "prerequisite")
951
951
  prompt = prepared["text"]
952
952
  attempted = prepared["attempted"]
953
+ if not attempted:
954
+ return ""
953
955
  if prepared["notice"]:
954
956
  _replylib.record_near_miss(root, raw, "code")
955
957
  return prepared["notice"]
@@ -406,19 +406,72 @@ DIAGNOSTICS = Path(".codearbiter/.markers/reply-diagnostics.log")
406
406
  DIAGNOSTICS_CAP = 256 * 1024
407
407
 
408
408
 
409
+ def _capture_attempt(raw, route: str) -> bool:
410
+ """Recognize an explicit legacy reply, not an ordinary leading verb.
411
+
412
+ Keep near-miss diagnostics for a coded/token reply with appended prose,
413
+ without repairing that prose into an accepted reply. Merely saying
414
+ ``Approve`` or ``reconcile the plan`` must not start legacy capture.
415
+ """
416
+ if not isinstance(raw, str) or not raw or len(raw) > MAX_INPUT:
417
+ return False
418
+ words = _clean(raw).split(" ")
419
+ verb = words[0].lower()
420
+ if SHORT_ROUTE.get(verb) != route:
421
+ return False
422
+ for (candidate, count), shape in GRAMMAR.items():
423
+ if candidate != verb or len(words) < count:
424
+ continue
425
+ fields = list(zip(words[1:count], shape))
426
+ if any((kind == "I" and not ID_RE.fullmatch(word))
427
+ or (kind == "M" and word.lower() not in MODES)
428
+ or (kind == "S" and word.upper() not in STATES)
429
+ for word, kind in fields[:-1]):
430
+ continue
431
+ token = fields[-1][0].rstrip(",.!?")
432
+ if CODE_RE.fullmatch(token.upper()):
433
+ if not token.isalpha():
434
+ return True
435
+ # Ordinary words overlap the code alphabet in EVERY casing:
436
+ # "Approve DATA migration" is not a protocol request. Only a
437
+ # retained matching code makes an all-letter token diagnostic.
438
+ # Exact armed replies remain case-insensitive; prose is not repaired.
439
+ if any(value.get("route") == route
440
+ and value.get("short_reply", "").rsplit(" ", 1)[-1] == token.upper()
441
+ for _path, value, _alive in _entries()):
442
+ return True
443
+ continue
444
+ if "I" in shape and re.fullmatch(r"[A-Za-z0-9_-]{12,128}", token):
445
+ # The long form's ID/token grammar also fits natural prose such
446
+ # as "approve the implementation". Require a retained request
447
+ # for its named subject before classifying it as a near miss.
448
+ prefix = canonicalize(" ".join(words[:count])).rsplit(" ", 1)[0]
449
+ if any(value.get("route") == route
450
+ and value.get("full_reply", "").rsplit(" ", 1)[0] == prefix
451
+ for _path, value, _alive in _entries()):
452
+ return True
453
+ # Old full-token routes can predate the short-code registry.
454
+ import _artifactpromptlib
455
+ try:
456
+ return _artifactpromptlib.resolve(route, canonicalize(raw)) is not None
457
+ except _artifactpromptlib.PromptRouteError:
458
+ # Let the adapter report the ambiguous legacy route through
459
+ # its existing capture-failure boundary, never approve it.
460
+ return True
461
+ return False
462
+
463
+
409
464
  def prepare(raw, route: str) -> dict[str, object]:
410
465
  """The text an adapter should match, plus a notice owned by ``route``.
411
466
 
412
- ``attempted`` is true when the prompt is reply-shaped for this route, so
413
- the adapter reports a miss instead of staying silent.
467
+ Only explicit legacy replies enter capture. Ordinary user intent stays
468
+ byte-for-byte unchanged and never becomes a request for another code.
414
469
  """
470
+ if not _capture_attempt(raw, route):
471
+ return {"text": raw, "notice": "", "attempted": False}
415
472
  canonical = canonicalize(raw)
416
- first = canonical.split(" ", 1)[0] if isinstance(canonical, str) and canonical is not raw else ""
417
- if not first and looks_like_reply(raw):
418
- first = _clean(raw).split(" ", 1)[0].lower()
419
- mine = SHORT_ROUTE.get(first) == route
420
- expanded = expand(canonical) if mine else {"text": canonical, "notice": ""}
421
- return {"text": expanded["text"], "notice": expanded["notice"] if mine else "", "attempted": mine}
473
+ expanded = expand(canonical)
474
+ return {"text": expanded["text"], "notice": expanded["notice"], "attempted": True}
422
475
 
423
476
 
424
477
  def miss_notice(example: str) -> str:
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "3.2.5"
316
+ adapter_version = "3.2.7"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -93,24 +93,27 @@ approval, dispatch, acceptance, commit proof, or finalization. This is the
93
93
  repository's cooperative same-user attestation model, not cryptographic identity
94
94
  proof or protection from an unrestricted same-user filesystem writer.
95
95
 
96
- For ordinary sequential planning, create a plan as `draft_preview`, consume existing criterion IDs,
97
- then `plan-bind` to the approved spec and approve the plan through its existing workflow. Initial
98
- combined HTML sprint review uses the explicit paired path below instead of separate approvals.
99
- A normative change invalidates its approval. Rebinding is explicit and preserves
100
- history; it does not waive coverage or permit silent reinterpretation.
101
-
102
- When adversarial review revises a ready initial draft spec after its draft plan exists,
103
- query both current identities. The installed engine's explicit `plan-bind` request with
96
+ For ordinary planning and resume, create or maintain a plan as `draft_preview` and
97
+ consume existing criterion IDs. Source rebinding records the selected requirements;
98
+ it does not require a new approval or reset existing work. A normative change can make
99
+ historical evidence inapplicable. Preserve that history without relabeling it as current.
100
+
101
+ When an in-scope correction revises the source spec, query both current identities.
102
+ The installed engine's explicit `plan-bind` request with
104
103
  `binding_mode: draft_preview` requires the plan's existing `expected` CAS and the spec's
105
- `spec_expected` revision/model CAS. It rebinds only that same-ID, same-slug initial pair:
106
- both artifacts must remain unapproved drafts, tasks pending and unevidenced, prerequisites
107
- unsatisfied and unevidenced, and scopes empty. Optional typed plan corrections must leave
108
- both drafts fully ready. The binding remains `draft_preview`; this operation grants no
109
- approval, delegation, prerequisite satisfaction, execution or acceptance. Revalidate both
110
- current drafts, repeat the affected review and arm the exact combined approval afterward.
111
- Inspect the installed operation schema first. An engine without this explicit draft
112
- request reports a capability gap; never edit HTML, recreate the pair, substitute sequential
113
- approval or use ordinary approved-source `plan-bind` to repair it.
104
+ `spec_expected` revision/model CAS. It refreshes only the existing same-ID, same-slug
105
+ pair, including plans with started, reviewed, blocked or evidenced work. Optional typed
106
+ plan corrections must leave both documents ready. Execution, stops, observations and
107
+ historical approvals are retained; the refreshed plan has draft governance and a
108
+ `draft_preview` binding. This operation grants no approval, delegation, prerequisite
109
+ satisfaction, execution or acceptance. Revalidate the current pair and reassess only
110
+ affected work and evidence. Do not arm combined approval for ordinary maintenance.
111
+
112
+ Inspect the installed operation schema first. An engine that rejects this supported
113
+ source refresh has a capability gap; retain the stale binding honestly while continuing
114
+ unaffected authorized work. Never edit generated HTML, recreate the pair or erase history
115
+ to satisfy a legacy initial-approval restriction. The historical initial combined approval
116
+ path still requires an unapproved, unstarted and unevidenced initial pair.
114
117
 
115
118
  ## Historical paired approval and delegated methods
116
119
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.21.5",
3
+ "version": "0.21.7",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",