@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 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.5" src="https://img.shields.io/badge/version-2.17.5-2b7489">
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.5`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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) -> str|None added ('+') diff lines,
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, sensitive_scan_added_lines,
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 added (`+`) lines of a diff — what a commit would introduce — or None
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
- return "\n".join(sensitive_scan_added_lines(out.stdout))
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
- added = "\n".join(parts)
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 = bool(CRYPTO_RE.search(added))
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 = [ln for ln in sensitive if line_digest(ln) not in approved]
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 "
@@ -209,6 +209,10 @@ from _sensitivelib import ( # noqa: F401
209
209
  is_sensitive_scan_exempt,
210
210
  line_digest,
211
211
  sensitive_scan_added_lines,
212
+ SecurityScan,
213
+ security_scan_diff,
214
+ security_scan_lines,
215
+ security_scan_source,
212
216
  )
213
217
 
214
218
 
@@ -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.6"
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 THE ALREADY-ACTIVE mode is a no-op — no write is even
357
- attempted and no audit row is appended, so `overrides.log` is left
358
- byte-identical. This is also the mechanism behind T-14's fail-direction
359
- asymmetry: once a failed flip has left the session's resolved mode
360
- unchanged, any LATER flip back to that same resolved mode is a no-op
361
- under ANY filesystem state — including a markers directory that cannot
362
- be written to at all — because a no-op never touches disk.
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
- return FLIP_NOOP
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|\brc4\b|\bdes\b|3des"
43
- r"|\brc2\b|\bblowfish\b"
44
- r"|\bRSA\b|x509|bcrypt"
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", "--src-prefix=a/", "--dst-prefix=b/",
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, sensitive_scan_added_lines, set_host, utf8_stdio,
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 sensitive_scan_added_lines(r.stdout)
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 = [ln for ln in added if CRYPTO_RE.search(ln) or SECRET_RE.search(ln)]
232
+ sensitive = added.digests
233
233
  if sensitive:
234
- joined = "\n".join(added)
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 = [ln for ln in sensitive if line_digest(ln) not in approved]
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 (`candidate_lines()`), which
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.5"
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
- CRYPTO_RE, SECRET_RE, SECURITY_DIFF_GIT_ARGS, is_sensitive_scan_exempt,
30
- line_digest, marker_root, project_root, sensitive_scan_added_lines,
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 file_lines(root, rel):
45
+ def file_scan(root, rel):
45
46
  p = os.path.join(root, rel)
46
47
  try:
47
- if os.path.getsize(p) > MAX_UNTRACKED_BYTES:
48
- return []
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
- return f.read().splitlines()
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 candidate_lines(root):
56
- """Every line the next commit could introduce: added lines of the
57
- worktree-vs-HEAD diff (staged and unstaged alike), plus the full content
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
- lines = []
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
- lines += sensitive_scan_added_lines(diff.stdout)
85
+ scans.append(security_scan_diff(diff.stdout))
76
86
  else:
77
- # Unborn branch (no HEAD yet): every tracked file is new content.
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
- lines += file_lines(root, rel)
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
- lines += file_lines(root, rel)
88
- return lines
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
- sensitive = [ln for ln in candidate_lines(root)
99
- if CRYPTO_RE.search(ln) or SECRET_RE.search(ln)]
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
- # candidate_lines(root) just scanned exactly that tree's diff, and binding
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({line_digest(ln) for ln in sensitive})
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.