@arbiterforge/ca-pi 0.10.6 → 0.10.8
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 +2 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +12 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +10 -9
- package/plugins/ca-pi/hooks/_hooklib.py +4 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_modelib.py +14 -8
- package/plugins/ca-pi/hooks/_sensitivelib.py +263 -4
- package/plugins/ca-pi/hooks/git-enforce.py +5 -6
- package/plugins/ca-pi/hooks/hostapi.py +2 -2
- package/plugins/ca-pi/hooks/security-pass.py +50 -20
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ 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 2.17.
|
|
13
|
+
<img alt="version 2.17.7" src="https://img.shields.io/badge/version-2.17.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-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-19-555">
|
|
@@ -117,7 +117,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
|
|
|
117
117
|
|
|
118
118
|
### Codex CLI
|
|
119
119
|
|
|
120
|
-
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.
|
|
120
|
+
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.7`;
|
|
121
121
|
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
|
|
122
122
|
Current packaging and shared-core parity are continuously verified, while that dated live-install
|
|
123
123
|
record stays labeled rather than being silently promoted to evidence for a newer adapter:
|
package/package.json
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.8] - 2026-09-06
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Distinguish ordinary return-code identifiers such as `rc2` from cipher configuration in the crypto/TLS commit gate.
|
|
12
|
+
|
|
13
|
+
## [0.10.7] - 2026-09-06
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Recover an unsettled mode-enter audit row on a repeated request instead of reporting an ineffective posture as a no-op.
|
|
18
|
+
|
|
7
19
|
## [0.10.6] - 2026-09-06
|
|
8
20
|
|
|
9
21
|
### Changed
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
# (case-insensitive)
|
|
39
39
|
# head_on_protected_tip(cwd) -> bool|None True iff detached HEAD sits on a
|
|
40
40
|
# protected branch's tip
|
|
41
|
-
# added_lines(cwd, ref, paths=None) ->
|
|
41
|
+
# added_lines(cwd, ref, paths=None) -> SecurityScan|None classified added lines,
|
|
42
42
|
# narrowed to the H-09b/H-10b candidate
|
|
43
43
|
# set, or None on a git-read failure
|
|
44
44
|
# staged_paths(cwd) -> set|None index paths (`git diff --cached
|
|
@@ -84,7 +84,7 @@ from _hooklib import (
|
|
|
84
84
|
ADR_LIFECYCLE_LOG_BASENAME, AUDIT_LOG_BASENAMES, AUDIT_LOG_FLAT_BASENAMES,
|
|
85
85
|
AUDIT_LOG_NAMES, CRYPTO_RE, DECISION_AUDIT_LOG_NAMES, DECISION_LOG_BASENAME, DECISIONS_DIR_RE,
|
|
86
86
|
GATE_MARKER_NAMES, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS, block,
|
|
87
|
-
content_digest, is_migration_path, line_digest, marker_fresh,
|
|
87
|
+
content_digest, is_migration_path, line_digest, marker_fresh, security_scan_diff,
|
|
88
88
|
)
|
|
89
89
|
from _gitexec import git_executable
|
|
90
90
|
import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE /
|
|
@@ -1042,7 +1042,7 @@ def head_on_protected_tip(cwd):
|
|
|
1042
1042
|
|
|
1043
1043
|
|
|
1044
1044
|
def added_lines(cwd, ref, paths=None):
|
|
1045
|
-
"""The
|
|
1045
|
+
"""The shared classification/bindings for a diff, or None
|
|
1046
1046
|
when git could not produce the diff (nonzero exit / timeout / error). The
|
|
1047
1047
|
None return (not "") lets the H-09b/H-10b security scan fail CLOSED on a read
|
|
1048
1048
|
error rather than silently passing — an empty diff and an unreadable diff are
|
|
@@ -1073,7 +1073,10 @@ def added_lines(cwd, ref, paths=None):
|
|
|
1073
1073
|
except Exception as e: # noqa: BLE001
|
|
1074
1074
|
_note_read_err(argv, repr(e))
|
|
1075
1075
|
return None
|
|
1076
|
-
|
|
1076
|
+
result = security_scan_diff(out.stdout)
|
|
1077
|
+
if result is None:
|
|
1078
|
+
_note_read_err(argv, "malformed or incomplete crypto context")
|
|
1079
|
+
return result
|
|
1077
1080
|
|
|
1078
1081
|
|
|
1079
1082
|
def _names(cwd, args):
|
|
@@ -1531,11 +1534,9 @@ def _check_h09b_h10b_crypto_secret(commit, add, cwd, root):
|
|
|
1531
1534
|
"read (git unavailable or timed out) — failing closed "
|
|
1532
1535
|
"(ORCHESTRATOR §2). Retry, or run the crypto-compliance / "
|
|
1533
1536
|
"secret-handling gate, then commit." + _read_err_hint())
|
|
1534
|
-
|
|
1535
|
-
sensitive = [ln for ln in added.splitlines()
|
|
1536
|
-
if CRYPTO_RE.search(ln) or SECRET_RE.search(ln)]
|
|
1537
|
+
sensitive = set().union(*(part.digests for part in parts))
|
|
1537
1538
|
if sensitive:
|
|
1538
|
-
touches_crypto =
|
|
1539
|
+
touches_crypto = any(part.crypto for part in parts)
|
|
1539
1540
|
kind = "crypto/TLS" if touches_crypto else "secret"
|
|
1540
1541
|
tag = "H-09b" if touches_crypto else "H-10b"
|
|
1541
1542
|
skill = "crypto-compliance" if touches_crypto else "secret-handling"
|
|
@@ -1551,7 +1552,7 @@ def _check_h09b_h10b_crypto_secret(commit, add, cwd, root):
|
|
|
1551
1552
|
approved = set(f.read().split())
|
|
1552
1553
|
except Exception: # noqa: BLE001
|
|
1553
1554
|
approved = set()
|
|
1554
|
-
uncovered =
|
|
1555
|
+
uncovered = sensitive - approved
|
|
1555
1556
|
if uncovered:
|
|
1556
1557
|
block(tag, f"{len(uncovered)} {kind} line(s) in this commit are not covered "
|
|
1557
1558
|
f"by the recorded security-gate pass — the pass is bound to the "
|
|
@@ -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.10.
|
|
14
|
+
adapter_version = "0.10.8"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -353,13 +353,14 @@ def flip(session_id, mode, root=None, payload=None, host_name=None, now=None):
|
|
|
353
353
|
"""Attempt to set `session_id`'s mode to `mode`. Returns one of
|
|
354
354
|
FLIP_FLIPPED / FLIP_NOOP / FLIP_FAILED. Never raises.
|
|
355
355
|
|
|
356
|
-
AC-6: a flip TO
|
|
357
|
-
attempted and no audit row is appended, so `overrides.log` is
|
|
358
|
-
byte-identical.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
356
|
+
AC-6: a flip TO AN ALREADY-ACTIVE, LEDGER-BACKED mode is a no-op — no
|
|
357
|
+
write is attempted and no audit row is appended, so `overrides.log` is
|
|
358
|
+
left byte-identical. The default arbiter posture needs no authorizing row.
|
|
359
|
+
If a prior non-arbiter write landed but its staged enter row did not, a
|
|
360
|
+
repeat request retries settlement: success reports the effective flip,
|
|
361
|
+
while another failure stays explicit instead of claiming the posture is
|
|
362
|
+
already active. This preserves T-14's fail direction because an unbacked
|
|
363
|
+
non-arbiter marker still cannot authorize persona composition.
|
|
363
364
|
|
|
364
365
|
On a genuine transition the order is deliberate: write first, audit row
|
|
365
366
|
ONLY on a confirmed write. AC-11's `ledger_backs` exists to catch exactly
|
|
@@ -369,7 +370,12 @@ def flip(session_id, mode, root=None, payload=None, host_name=None, now=None):
|
|
|
369
370
|
root = root if root is not None else marker_root(payload)
|
|
370
371
|
current, _diag = current_mode(session_id, root=root, payload=payload)
|
|
371
372
|
if current == mode:
|
|
372
|
-
|
|
373
|
+
if mode == MODES[0] or ledger_backs(root, mode, session_id=session_id):
|
|
374
|
+
return FLIP_NOOP
|
|
375
|
+
_settle_dev_close(root, host_name=host_name)
|
|
376
|
+
return (FLIP_FLIPPED
|
|
377
|
+
if ledger_backs(root, mode, session_id=session_id)
|
|
378
|
+
else FLIP_FAILED)
|
|
373
379
|
if not write_mode(session_id, mode, root=root, payload=payload):
|
|
374
380
|
return FLIP_FAILED
|
|
375
381
|
# ADR-0030 position 4: EVERY transition row is staged through the #396
|
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
import hashlib
|
|
29
|
+
import io
|
|
30
|
+
import json
|
|
29
31
|
import re
|
|
32
|
+
import tokenize
|
|
33
|
+
from bisect import bisect_right
|
|
34
|
+
from collections import namedtuple
|
|
30
35
|
|
|
31
36
|
from _pathnorm import norm_path
|
|
32
37
|
|
|
@@ -38,10 +43,31 @@ from _pathnorm import norm_path
|
|
|
38
43
|
# catch-all is narrowed to the members that actually sign, encrypt, derive
|
|
39
44
|
# keys, or produce security-relevant randomness. bcrypt stays: approved or
|
|
40
45
|
# not, a password-hashing change is exactly what crypto-compliance reviews.
|
|
46
|
+
# Short names also occur as return-code variables and ordinary prose (#678).
|
|
47
|
+
# Require configuration, known mode/padding suffixes, a crypto call/member, or
|
|
48
|
+
# an import/crypto namespace. A standalone quoted value also matches when its
|
|
49
|
+
# config key is outside the diff; unrelated quoted labels and prose do not.
|
|
50
|
+
# Line-shape arms also work in joined content (the H-09b verdict/reminder read).
|
|
51
|
+
_SHORT_CRYPTO_NAME = r"(?:rc2|rc4|des|rsa)"
|
|
52
|
+
_CRYPTO_LINE_END = r"(?:[ \t]*[,;\])}])*[ \t]*(?:(?:\#|//)[^\r\n]*)?\r?$"
|
|
41
53
|
CRYPTO_RE = re.compile(
|
|
42
|
-
r"(createHash|createCipher|createHmac|\bmd5\b|\bsha1\b
|
|
43
|
-
r"|\
|
|
44
|
-
|
|
54
|
+
r"(createHash|createCipher|createHmac|\bmd5\b|\bsha1\b|3des"
|
|
55
|
+
r"|\bblowfish\b|x509|bcrypt"
|
|
56
|
+
rf"""|(?m:^[ \t]*(?:-[ \t]*)?["']{_SHORT_CRYPTO_NAME}["']{_CRYPTO_LINE_END})"""
|
|
57
|
+
rf"|\b{_SHORT_CRYPTO_NAME}[-/](?:cbc|ecb|cfb|ofb|ctr|ede3?|oaep|pss|pkcs1)\b"
|
|
58
|
+
rf"|\b{_SHORT_CRYPTO_NAME}\s*\("
|
|
59
|
+
rf"|\b{_SHORT_CRYPTO_NAME}\s*\.\s*"
|
|
60
|
+
r"(?:new|create|generate|construct|import_?key|encrypt|decrypt|sign|verify"
|
|
61
|
+
r"|(?:rsa)?(?:public|private)(?:key|numbers)|rsakey|mode_)\w*\b"
|
|
62
|
+
rf"""|\b(?:cipher|algorithm)["']?\s*[:=]\s*["']?{_SHORT_CRYPTO_NAME}\b"""
|
|
63
|
+
r"""|\bkey_type["']?\s*[:=]\s*["']?rsa\b"""
|
|
64
|
+
rf"""|\b(?:Cipher|KeyFactory)\s*\.\s*getInstance\s*\(\s*["']{_SHORT_CRYPTO_NAME}\b"""
|
|
65
|
+
rf"|\b(?:Cipher|PublicKey|algorithms|asymmetric|Cryptography)\s*\.\s*{_SHORT_CRYPTO_NAME}\b"
|
|
66
|
+
rf"|\bcrypto/{_SHORT_CRYPTO_NAME}\b"
|
|
67
|
+
rf"|\b(?:from|import)(?:\s+(?:\(\s*)?|\(\s*)"
|
|
68
|
+
rf"(?:\w+(?:\.\w+)*(?:\s+as\s+\w+)?\s*,\s*)*"
|
|
69
|
+
rf"(?:\w+\.)*{_SHORT_CRYPTO_NAME}\b"
|
|
70
|
+
rf"|(?m:^[ \t]*{_SHORT_CRYPTO_NAME}[ \t]+as[ \t]+\w+{_CRYPTO_LINE_END})"
|
|
45
71
|
r"|crypto\.(subtle|sign|verify|createSign|createVerify|generateKey"
|
|
46
72
|
r"|publicEncrypt|privateDecrypt|pbkdf2|scrypt|randomBytes|createDiffieHellman)"
|
|
47
73
|
r"|InsecureSkipVerify|verify=False"
|
|
@@ -118,7 +144,8 @@ def is_sensitive_scan_exempt(rel):
|
|
|
118
144
|
# forget to pin it (that was exactly how this hole would keep reopening).
|
|
119
145
|
SECURITY_DIFF_GIT_ARGS = (
|
|
120
146
|
"-c", "diff.mnemonicPrefix=false", "-c", "diff.noprefix=false",
|
|
121
|
-
"diff", "--no-ext-diff", "--
|
|
147
|
+
"diff", "--no-ext-diff", "--no-textconv", "--no-color",
|
|
148
|
+
"--src-prefix=a/", "--dst-prefix=b/", "--unified=2147483647",
|
|
122
149
|
)
|
|
123
150
|
|
|
124
151
|
# The fixed-width destination-path prefix `diff_added_lines` strips off a
|
|
@@ -249,6 +276,238 @@ def sensitive_scan_added_lines(diff_text):
|
|
|
249
276
|
if not (path and is_sensitive_scan_exempt(path))]
|
|
250
277
|
|
|
251
278
|
|
|
279
|
+
# H-09b contextual additions (#678): raw line bindings stay compatible. Only
|
|
280
|
+
# bare short-name import items require lexical destination context. Context
|
|
281
|
+
# bindings use a separate marker namespace; a user-authored source string or
|
|
282
|
+
# an old line-only approval cannot impersonate one of these records.
|
|
283
|
+
# SecurityScan(crypto, digests): classification and exact approval identities.
|
|
284
|
+
# security_scan_lines(lines) -> SecurityScan|None: line-bound scan or refusal.
|
|
285
|
+
# security_scan_source(path, source, added_rows) -> SecurityScan|None.
|
|
286
|
+
# security_scan_diff(text) -> SecurityScan|None: None means untrusted context.
|
|
287
|
+
SecurityScan = namedtuple("SecurityScan", "crypto digests")
|
|
288
|
+
_SHORT_CRYPTO_TOKEN_RE = re.compile(rf"\b{_SHORT_CRYPTO_NAME}\b", re.I)
|
|
289
|
+
_IMPORT_ITEM = r"[^\W\d]\w*(?:[ \t]+as[ \t]+[^\W\d]\w*)?"
|
|
290
|
+
_IMPORT_ITEM_LINE_RE = re.compile(
|
|
291
|
+
rf"[ \t]*{_IMPORT_ITEM}(?:[ \t]*,[ \t]*{_IMPORT_ITEM})*[ \t]*,?[ \t]*"
|
|
292
|
+
r"(?:\\|\)[ \t]*(?:;[^\r\n]*)?)?[ \t]*(?:\#[^\r\n]*)?", re.I)
|
|
293
|
+
_FULL_HUNK_RE = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?")
|
|
294
|
+
_MAX_CRYPTO_CONTEXT_BYTES = 1_000_000
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _is_context_item_line(line):
|
|
298
|
+
"""Route bounded import-list syntax to context, never classify it alone."""
|
|
299
|
+
if not _SHORT_CRYPTO_TOKEN_RE.search(line):
|
|
300
|
+
return False
|
|
301
|
+
# Collapse only the temporary routing text, avoiding overlapping whitespace
|
|
302
|
+
# partitions. Tokenization and approval binding still use the original source.
|
|
303
|
+
candidate = re.sub(r"[ \t]+", " ", line)
|
|
304
|
+
return bool(_IMPORT_ITEM_LINE_RE.fullmatch(candidate))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cross_line_crypto_spans(lines):
|
|
308
|
+
"""Locate joined matches by zero-based row/column, without quadratic slicing."""
|
|
309
|
+
starts = [0]
|
|
310
|
+
for line in lines:
|
|
311
|
+
starts.append(starts[-1] + len(line) + 1)
|
|
312
|
+
spans = set()
|
|
313
|
+
for match in CRYPTO_RE.finditer("\n".join(lines)):
|
|
314
|
+
if "\n" not in match.group():
|
|
315
|
+
continue
|
|
316
|
+
first = bisect_right(starts, match.start()) - 1
|
|
317
|
+
last = bisect_right(starts, match.end()) - 1
|
|
318
|
+
spans.add((first, match.start() - starts[first],
|
|
319
|
+
last, match.end() - starts[last]))
|
|
320
|
+
return spans
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _scan_bound_lines(lines, contextual_bindings):
|
|
324
|
+
"""Each cross-line match needs its own recognized contextual identity."""
|
|
325
|
+
spans = _cross_line_crypto_spans(lines)
|
|
326
|
+
if not spans <= contextual_bindings.keys():
|
|
327
|
+
return None
|
|
328
|
+
return SecurityScan(bool(spans) or any(CRYPTO_RE.search(line) for line in lines), {
|
|
329
|
+
line_digest(line) for line in lines
|
|
330
|
+
if CRYPTO_RE.search(line) or SECRET_RE.search(line)
|
|
331
|
+
} | set(contextual_bindings.values()))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def security_scan_lines(lines):
|
|
335
|
+
"""Preserve same-line bindings; refuse crypto spanning unbound lines."""
|
|
336
|
+
return _scan_bound_lines(lines, {})
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def security_scan_source(path, source, added_rows):
|
|
340
|
+
"""Classify original added lines plus narrowly recognized Python imports.
|
|
341
|
+
|
|
342
|
+
`source` is ONE destination snapshot; rows are one-based positions in it.
|
|
343
|
+
Tokenization only separates import NAME/OP tokens from strings/comments;
|
|
344
|
+
this is not an expression, type, or general-language parser. Invalid or
|
|
345
|
+
unavailable required context returns None, never an ordinary exemption.
|
|
346
|
+
"""
|
|
347
|
+
lines = source.splitlines()
|
|
348
|
+
if any(row < 1 or row > len(lines) for row in added_rows):
|
|
349
|
+
return None
|
|
350
|
+
added = [lines[row - 1] for row in added_rows]
|
|
351
|
+
result = security_scan_lines(added)
|
|
352
|
+
candidates = {row for row in added_rows
|
|
353
|
+
if _is_context_item_line(lines[row - 1])}
|
|
354
|
+
if not candidates:
|
|
355
|
+
return result
|
|
356
|
+
if not path or len(source.encode("utf-8", "replace")) > _MAX_CRYPTO_CONTEXT_BYTES:
|
|
357
|
+
return None
|
|
358
|
+
# Replacement decoding can collapse distinct original bytes. Even a
|
|
359
|
+
# literal replacement character is ambiguous here: refuse contextual proof.
|
|
360
|
+
if "\ufffd" in source:
|
|
361
|
+
return None
|
|
362
|
+
try:
|
|
363
|
+
tokens = [token for token in tokenize.generate_tokens(io.StringIO(source).readline)
|
|
364
|
+
if token.type not in (tokenize.COMMENT, tokenize.NL,
|
|
365
|
+
tokenize.INDENT, tokenize.DEDENT)]
|
|
366
|
+
except (tokenize.TokenError, SyntaxError):
|
|
367
|
+
return None
|
|
368
|
+
if any(token.type == tokenize.ERRORTOKEN and not token.string.isspace()
|
|
369
|
+
for token in tokens):
|
|
370
|
+
return None
|
|
371
|
+
matched = set()
|
|
372
|
+
import_spans = []
|
|
373
|
+
start = [(tokenize.NAME, "from"), (tokenize.NAME, "Crypto"),
|
|
374
|
+
(tokenize.OP, "."), (tokenize.NAME, "Cipher"),
|
|
375
|
+
(tokenize.NAME, "import"), (tokenize.OP, "(")]
|
|
376
|
+
for index in range(len(tokens) - len(start)):
|
|
377
|
+
if [(token.type, token.string) for token in tokens[index:index + len(start)]] != start:
|
|
378
|
+
continue
|
|
379
|
+
cursor = index + len(start)
|
|
380
|
+
while cursor < len(tokens) and tokens[cursor].string != ")":
|
|
381
|
+
item = tokens[cursor]
|
|
382
|
+
if item.type != tokenize.NAME:
|
|
383
|
+
return None
|
|
384
|
+
cursor += 1
|
|
385
|
+
if cursor < len(tokens) and tokens[cursor].string == "as":
|
|
386
|
+
cursor += 1
|
|
387
|
+
if cursor >= len(tokens) or tokens[cursor].type != tokenize.NAME:
|
|
388
|
+
return None
|
|
389
|
+
cursor += 1
|
|
390
|
+
if item.start[0] in candidates and _SHORT_CRYPTO_TOKEN_RE.fullmatch(item.string):
|
|
391
|
+
matched.add(item.start[0])
|
|
392
|
+
import_spans.append((tokens[index + 4].start, item.end))
|
|
393
|
+
if cursor >= len(tokens) or tokens[cursor].string not in (",", ")"):
|
|
394
|
+
return None
|
|
395
|
+
if tokens[cursor].string == ",":
|
|
396
|
+
cursor += 1
|
|
397
|
+
if cursor >= len(tokens):
|
|
398
|
+
return None
|
|
399
|
+
# Preserve lexical whitespace; only transport CRLF decoding is normalized.
|
|
400
|
+
destination = hashlib.sha256(source.encode("utf-8", "replace")).hexdigest()
|
|
401
|
+
contexts = {row: "ctx-v1:" + hashlib.sha256(json.dumps(
|
|
402
|
+
[path, destination, row, lines[row - 1]], separators=(",", ":")
|
|
403
|
+
).encode("utf-8")).hexdigest() for row in matched}
|
|
404
|
+
positions = {row: index for index, row in enumerate(added_rows)}
|
|
405
|
+
bindings = {(positions[first[0]], first[1], positions[last[0]], last[1]): contexts[last[0]]
|
|
406
|
+
for first, last in import_spans
|
|
407
|
+
if first[0] in positions and last[0] in positions}
|
|
408
|
+
result = _scan_bound_lines(added, bindings)
|
|
409
|
+
if result is None:
|
|
410
|
+
return None
|
|
411
|
+
return SecurityScan(result.crypto or bool(contexts), result.digests | set(contexts.values()))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _full_destination(section):
|
|
415
|
+
"""Validate one whole-file Git hunk before using its destination context."""
|
|
416
|
+
path = None
|
|
417
|
+
hunk = None
|
|
418
|
+
for index, line in enumerate(section):
|
|
419
|
+
if line.startswith(_PLUS_B_PREFIX):
|
|
420
|
+
path = line[len(_PLUS_B_PREFIX):]
|
|
421
|
+
if line.startswith("@@"):
|
|
422
|
+
hunk = index
|
|
423
|
+
break
|
|
424
|
+
if not path or hunk is None:
|
|
425
|
+
return None
|
|
426
|
+
header = _FULL_HUNK_RE.fullmatch(section[hunk])
|
|
427
|
+
if header is None:
|
|
428
|
+
return None
|
|
429
|
+
old_start, old_count, new_start, new_count = header.groups()
|
|
430
|
+
old_start, new_start = int(old_start), int(new_start)
|
|
431
|
+
old_count, new_count = int(old_count or 1), int(new_count or 1)
|
|
432
|
+
if old_start != (1 if old_count else 0) or new_start != 1:
|
|
433
|
+
return None
|
|
434
|
+
destination, rows = [], []
|
|
435
|
+
old_seen = new_seen = 0
|
|
436
|
+
previous = None
|
|
437
|
+
for line in section[hunk + 1:]:
|
|
438
|
+
if line.startswith("\\"):
|
|
439
|
+
if line != "\" or previous not in ("+", "-", " "):
|
|
440
|
+
return None
|
|
441
|
+
if previous != "-":
|
|
442
|
+
destination[-1] = destination[-1].removesuffix("\n")
|
|
443
|
+
previous = None
|
|
444
|
+
continue
|
|
445
|
+
if not line or line[0] not in ("+", "-", " "):
|
|
446
|
+
return None
|
|
447
|
+
previous = line[0]
|
|
448
|
+
if previous in ("-", " "):
|
|
449
|
+
old_seen += 1
|
|
450
|
+
if previous in ("+", " "):
|
|
451
|
+
new_seen += 1
|
|
452
|
+
destination.append(line[1:] + "\n")
|
|
453
|
+
if previous == "+":
|
|
454
|
+
rows.append(new_seen)
|
|
455
|
+
if (old_seen, new_seen) != (old_count, new_count):
|
|
456
|
+
return None
|
|
457
|
+
return path, "".join(destination), rows
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def security_scan_diff(diff_text):
|
|
461
|
+
"""Shared producer/consumer scan; context comes from this one pinned diff.
|
|
462
|
+
|
|
463
|
+
Legacy classification and audit-path handling remain unchanged. Only a
|
|
464
|
+
section with an ambiguous bare added item pays the full-context/token cost.
|
|
465
|
+
Missing attribution, incomplete hunks and unsupported context fail closed.
|
|
466
|
+
"""
|
|
467
|
+
# A flattened match may not borrow approval from a different file section.
|
|
468
|
+
cross_spans = _cross_line_crypto_spans(sensitive_scan_added_lines(diff_text))
|
|
469
|
+
covered_spans = set()
|
|
470
|
+
added_offset = 0
|
|
471
|
+
result = SecurityScan(False, set())
|
|
472
|
+
sections = []
|
|
473
|
+
for line in diff_text.splitlines():
|
|
474
|
+
if line.startswith("diff ") or not sections:
|
|
475
|
+
sections.append([])
|
|
476
|
+
sections[-1].append(line)
|
|
477
|
+
for section in sections:
|
|
478
|
+
attributed = diff_added_lines("\n".join(section))
|
|
479
|
+
if attributed and all(path and is_sensitive_scan_exempt(path)
|
|
480
|
+
for path, _ in attributed):
|
|
481
|
+
continue
|
|
482
|
+
added = sensitive_scan_added_lines("\n".join(section))
|
|
483
|
+
# A damaged hunk header must not make an apparent added item disappear
|
|
484
|
+
# in the legacy permissive walker before context validation even runs.
|
|
485
|
+
if not any(line.startswith("+") and _is_context_item_line(line[1:])
|
|
486
|
+
for line in section):
|
|
487
|
+
contextual = security_scan_lines(added)
|
|
488
|
+
else:
|
|
489
|
+
if not section[0].startswith("diff --git "):
|
|
490
|
+
return None
|
|
491
|
+
context = _full_destination(section)
|
|
492
|
+
if context is None:
|
|
493
|
+
return None
|
|
494
|
+
path, source, rows = context
|
|
495
|
+
source_lines = source.splitlines()
|
|
496
|
+
if attributed != [(path, source_lines[row - 1]) for row in rows]:
|
|
497
|
+
return None
|
|
498
|
+
contextual = security_scan_source(path, source, rows)
|
|
499
|
+
if contextual is None:
|
|
500
|
+
return None
|
|
501
|
+
covered_spans.update((first + added_offset, col, last + added_offset, end)
|
|
502
|
+
for first, col, last, end in _cross_line_crypto_spans(added))
|
|
503
|
+
added_offset += len(added)
|
|
504
|
+
result = SecurityScan(result.crypto or contextual.crypto,
|
|
505
|
+
result.digests | contextual.digests)
|
|
506
|
+
if not cross_spans <= covered_spans:
|
|
507
|
+
return None
|
|
508
|
+
return result
|
|
509
|
+
|
|
510
|
+
|
|
252
511
|
def line_digest(line):
|
|
253
512
|
"""Digest of one added diff line, for the security-gate binding
|
|
254
513
|
(H-09b/H-10b). The gate-pass marker stores these digests instead of being
|
|
@@ -32,7 +32,7 @@ from _gitexec import git_executable # noqa: E402
|
|
|
32
32
|
from _hooklib import ( # noqa: E402
|
|
33
33
|
CRYPTO_RE, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS,
|
|
34
34
|
arbiter_active, content_digest, is_migration_path, line_digest,
|
|
35
|
-
marker_fresh,
|
|
35
|
+
marker_fresh, security_scan_diff, set_host, utf8_stdio,
|
|
36
36
|
)
|
|
37
37
|
|
|
38
38
|
|
|
@@ -164,7 +164,7 @@ def cached_added_lines(cwd):
|
|
|
164
164
|
r = _git([*SECURITY_DIFF_GIT_ARGS, "--cached"], cwd)
|
|
165
165
|
if r is None or r.returncode != 0:
|
|
166
166
|
return None
|
|
167
|
-
return
|
|
167
|
+
return security_scan_diff(r.stdout)
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
def cached_names(cwd):
|
|
@@ -229,10 +229,9 @@ def pre_commit(root):
|
|
|
229
229
|
if added is None:
|
|
230
230
|
block("H-09b", "the staged diff for the crypto/secret scan could not be read — "
|
|
231
231
|
"failing closed (ORCHESTRATOR §2).")
|
|
232
|
-
sensitive =
|
|
232
|
+
sensitive = added.digests
|
|
233
233
|
if sensitive:
|
|
234
|
-
|
|
235
|
-
touches_crypto = bool(CRYPTO_RE.search(joined))
|
|
234
|
+
touches_crypto = added.crypto
|
|
236
235
|
kind = "crypto/TLS" if touches_crypto else "secret"
|
|
237
236
|
tag = "H-09b" if touches_crypto else "H-10b"
|
|
238
237
|
skill = "crypto-compliance" if touches_crypto else "secret-handling"
|
|
@@ -242,7 +241,7 @@ def pre_commit(root):
|
|
|
242
241
|
block(tag, f"This commit introduces {kind} changes, but no security-gate pass is "
|
|
243
242
|
f"recorded (#161 git backstop). Run the {skill} gate, then commit.")
|
|
244
243
|
approved = _marker_set(marker_root, "security-gate-passed")
|
|
245
|
-
uncovered =
|
|
244
|
+
uncovered = sensitive - approved
|
|
246
245
|
if uncovered:
|
|
247
246
|
block(tag, f"{len(uncovered)} {kind} line(s) in this commit are not covered by the "
|
|
248
247
|
f"recorded security-gate pass (#161 git backstop) — re-run the {skill} "
|
|
@@ -218,7 +218,7 @@ def git_worktree_main_root(root):
|
|
|
218
218
|
of the command's effective exec root).
|
|
219
219
|
|
|
220
220
|
Deliberately NOT wired into `Host.project_root()` itself: `project_root()`
|
|
221
|
-
also backs `security-pass.py`'s DIFF SCAN (`
|
|
221
|
+
also backs `security-pass.py`'s DIFF SCAN (`candidate_scan()`), which
|
|
222
222
|
must stay worktree-local — escalating the general project root to "main"
|
|
223
223
|
would bind digests to the wrong (unrelated, possibly dirty) tree and
|
|
224
224
|
silently drop coverage for the diff actually being committed, the exact
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.17.
|
|
316
|
+
adapter_version = "2.17.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,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
# "$PY" "<plugin>/hooks/security-pass.py"
|
|
18
18
|
|
|
19
19
|
import os
|
|
20
|
+
import stat
|
|
20
21
|
import subprocess
|
|
21
22
|
|
|
22
23
|
from _gitexec import git_executable
|
|
@@ -26,8 +27,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
26
27
|
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
27
28
|
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
28
29
|
from _hooklib import ( # noqa: E402
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
SECURITY_DIFF_GIT_ARGS, SecurityScan, is_sensitive_scan_exempt,
|
|
31
|
+
marker_root, project_root, security_scan_diff, security_scan_source,
|
|
31
32
|
set_host, utf8_stdio, warn, write_text_atomic,
|
|
32
33
|
)
|
|
33
34
|
|
|
@@ -41,20 +42,26 @@ def run_git(args, cwd):
|
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def
|
|
45
|
+
def file_scan(root, rel):
|
|
45
46
|
p = os.path.join(root, rel)
|
|
46
47
|
try:
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
metadata = os.lstat(p)
|
|
49
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
50
|
+
return None
|
|
51
|
+
if metadata.st_size > MAX_UNTRACKED_BYTES:
|
|
52
|
+
return SecurityScan(False, set())
|
|
49
53
|
with open(p, encoding="utf-8", errors="replace") as f:
|
|
50
|
-
|
|
54
|
+
source = f.read()
|
|
55
|
+
return security_scan_source(rel, source, range(1, len(source.splitlines()) + 1))
|
|
51
56
|
except Exception: # noqa: BLE001
|
|
52
|
-
return
|
|
57
|
+
return None
|
|
53
58
|
|
|
54
59
|
|
|
55
|
-
def
|
|
56
|
-
"""
|
|
57
|
-
|
|
60
|
+
def candidate_scan(root):
|
|
61
|
+
"""Separate classified index and worktree snapshots, plus untracked content.
|
|
62
|
+
|
|
63
|
+
Every line the next commit could introduce: added lines of the
|
|
64
|
+
staged diff AND worktree-vs-HEAD diff, plus the full content
|
|
58
65
|
of untracked files — `git diff HEAD` never shows those, but they land in
|
|
59
66
|
the staged diff the moment commit-gate stages them.
|
|
60
67
|
|
|
@@ -69,23 +76,41 @@ def candidate_lines(root):
|
|
|
69
76
|
it pins the `a/`/`b/` prefix format `sensitive_scan_added_lines` depends
|
|
70
77
|
on for path attribution, regardless of the caller's `diff.mnemonicPrefix`
|
|
71
78
|
/ `diff.noprefix` / external-diff config (#279 review MEDIUM-1)."""
|
|
72
|
-
|
|
79
|
+
staged = run_git([*SECURITY_DIFF_GIT_ARGS, "--cached"], root)
|
|
80
|
+
if staged.returncode != 0:
|
|
81
|
+
return None
|
|
82
|
+
scans = [security_scan_diff(staged.stdout)]
|
|
73
83
|
diff = run_git([*SECURITY_DIFF_GIT_ARGS, "HEAD"], root)
|
|
74
84
|
if diff.returncode == 0:
|
|
75
|
-
|
|
85
|
+
scans.append(security_scan_diff(diff.stdout))
|
|
76
86
|
else:
|
|
77
|
-
#
|
|
87
|
+
# Confirm an absent branch ref, not merely a failed diff read. A real
|
|
88
|
+
# read/parse failure must never be laundered through the unborn path.
|
|
89
|
+
head = run_git(["rev-parse", "--verify", "--quiet", "HEAD"], root)
|
|
90
|
+
branch = run_git(["symbolic-ref", "--quiet", "HEAD"], root)
|
|
91
|
+
if head.returncode != 1 or branch.returncode != 0:
|
|
92
|
+
return None
|
|
93
|
+
absent = run_git(["show-ref", "--verify", "--quiet", branch.stdout.strip()], root)
|
|
94
|
+
if absent.returncode != 1:
|
|
95
|
+
return None
|
|
78
96
|
ls = run_git(["ls-files"], root)
|
|
97
|
+
if ls.returncode != 0:
|
|
98
|
+
return None
|
|
79
99
|
for rel in ls.stdout.splitlines():
|
|
80
100
|
if is_sensitive_scan_exempt(rel):
|
|
81
101
|
continue
|
|
82
|
-
|
|
102
|
+
scans.append(file_scan(root, rel))
|
|
83
103
|
untracked = run_git(["ls-files", "--others", "--exclude-standard"], root)
|
|
104
|
+
if untracked.returncode != 0:
|
|
105
|
+
return None
|
|
84
106
|
for rel in untracked.stdout.splitlines():
|
|
85
107
|
if is_sensitive_scan_exempt(rel):
|
|
86
108
|
continue
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
scans.append(file_scan(root, rel))
|
|
110
|
+
if any(scan is None for scan in scans):
|
|
111
|
+
return None
|
|
112
|
+
return SecurityScan(any(scan.crypto for scan in scans),
|
|
113
|
+
set().union(*(scan.digests for scan in scans)))
|
|
89
114
|
|
|
90
115
|
|
|
91
116
|
def main():
|
|
@@ -95,11 +120,16 @@ def main():
|
|
|
95
120
|
warn("no .codearbiter/ here — security-pass.py records nothing outside "
|
|
96
121
|
"an initialized repo")
|
|
97
122
|
sys.exit(1)
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
try:
|
|
124
|
+
scan = candidate_scan(root)
|
|
125
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
126
|
+
scan = None
|
|
127
|
+
if scan is None:
|
|
128
|
+
warn("security-gate pass refused: unavailable or malformed crypto/secret context")
|
|
129
|
+
sys.exit(1)
|
|
100
130
|
# #604: the MARKER root is deliberately NOT `root` above. `root` (plain
|
|
101
131
|
# project_root()) must stay wherever this process is actually running —
|
|
102
|
-
#
|
|
132
|
+
# candidate_scan(root) just scanned exactly that tree's diff, and binding
|
|
103
133
|
# digests to a DIFFERENT tree would review lines nobody staged. But in a
|
|
104
134
|
# linked git worktree, `root` names the worktree's own (gitignored,
|
|
105
135
|
# never-checked-out) `.codearbiter/.markers/`, while the H-09b/H-10b
|
|
@@ -110,7 +140,7 @@ def main():
|
|
|
110
140
|
marker_dir = os.path.join(write_root, ".codearbiter", ".markers")
|
|
111
141
|
os.makedirs(marker_dir, exist_ok=True)
|
|
112
142
|
marker = os.path.join(marker_dir, "security-gate-passed")
|
|
113
|
-
digests = sorted(
|
|
143
|
+
digests = sorted(scan.digests)
|
|
114
144
|
# Atomic write (migration-002): a crash mid-write never leaves a half-written
|
|
115
145
|
# marker, which the backstop would read as an unrecognized digest and force a
|
|
116
146
|
# spurious gate re-run.
|