@arbiterforge/ca-pi 0.10.7 → 0.10.10

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.6" src="https://img.shields.io/badge/version-2.17.6-2b7489">
13
+ <img alt="version 2.17.9" src="https://img.shields.io/badge/version-2.17.9-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.6`;
120
+ The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.9`;
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.7",
3
+ "version": "0.10.10",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,24 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.10] - 2026-09-07
8
+
9
+ ### Fixed
10
+
11
+ - Use the same documented done-tasks template for initialization and first-use archiving, with byte-identical UTF-8 headers on Windows and POSIX.
12
+
13
+ ## [0.10.9] - 2026-09-06
14
+
15
+ ### Fixed
16
+
17
+ - Keep task-board lifecycles Git-clean by locally excluding the retained OS-lock sidecar during initialization, with an explicit repair option for existing repositories.
18
+
19
+ ## [0.10.8] - 2026-09-06
20
+
21
+ ### Fixed
22
+
23
+ - Distinguish ordinary return-code identifiers such as `rc2` from cipher configuration in the crypto/TLS commit gate.
24
+
7
25
  ## [0.10.7] - 2026-09-06
8
26
 
9
27
  ### Fixed
@@ -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.7"
14
+ adapter_version = "0.10.10"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -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
@@ -704,6 +704,23 @@ ARCHIVE_CUTOFF_DAYS = 14
704
704
 
705
705
  DONE_TASKS_HEADING = "# Done tasks"
706
706
 
707
+ # The canonical documented archive template, shared by init and first use.
708
+ DONE_TASKS = """\
709
+ # Done tasks
710
+
711
+ Completed work swept off the board by `taskwrite archive`, newest last.
712
+ APPEND-ONLY: entries are added here and never edited or removed, so a
713
+ finished task has exactly one permanent record.
714
+
715
+ Each archived task preserves its original lifecycle line and indented task block verbatim
716
+ from `open-tasks.md`. The lifecycle line keeps its `(done YYYY-MM-DD)` stamp
717
+ intact — the stamp is what makes an entry ageable, and what `archive` refuses
718
+ to invent.
719
+
720
+ Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
721
+ per-item confirmation; nothing sweeps automatically.
722
+ """
723
+
707
724
 
708
725
  def archive_candidates(text, *, today, cutoff_days=ARCHIVE_CUTOFF_DAYS):
709
726
  """`(aged, undated)` — done tasks eligible for the archival sweep.
@@ -862,7 +879,7 @@ def archive_transform(open_text, done_text, task):
862
879
  if already_archived(done_text, task):
863
880
  new_done = done_text
864
881
  else:
865
- body = done_text if done_text.strip() else DONE_TASKS_HEADING + "\n"
882
+ body = done_text if done_text.strip() else DONE_TASKS
866
883
  if not body.endswith("\n"):
867
884
  body += "\n"
868
885
  new_done = body + "\n".join(block) + "\n"
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — locally exclude the retained task-board OS-lock sidecar.
3
+ #
4
+ # The board lock is intentionally never removed: unlinking a locked inode lets
5
+ # another process acquire a different lock. Only Git's common info/exclude is
6
+ # changed, through its own exclusive create/replace lockfile protocol. Existing
7
+ # bytes and modes survive; no tracked file or Git index is written.
8
+ # Static Git-metadata link/reparse aliases and observed concurrent edits fail closed. This
9
+ # cooperates with writers honoring exclude.lock; it is not a sandbox against a
10
+ # hostile same-user process swapping paths between filesystem operations.
11
+ #
12
+ # Public API:
13
+ # ensure_task_lock_excluded(root, required=False) -> str | None
14
+ # "added" / "already present", or None for optional non-Git scaffolding.
15
+ # Raises TaskExclusionError with a surfaced diagnostic on unsafe/failing IO.
16
+
17
+ import os
18
+ from pathlib import Path
19
+ import stat
20
+ import subprocess
21
+
22
+ from _gitexec import git_executable, root_bound_git_env
23
+
24
+
25
+ LOCK_PATH = ".codearbiter/open-tasks.md.lock"
26
+ EXCLUDE_RULE = b"/" + LOCK_PATH.encode("ascii")
27
+ MAX_EXCLUDE_BYTES = 2 * 1024 * 1024
28
+ _REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
29
+
30
+
31
+ class TaskExclusionError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _version(info):
36
+ # Windows Python can expose creation time via lstat().st_ctime but change
37
+ # time via fstat().st_ctime. Compare the common birth-time field there;
38
+ # identity, size, mtime, mode/link count and byte rereads still bind edits.
39
+ change = getattr(info, "st_birthtime_ns", 0) if os.name == "nt" else info.st_ctime_ns
40
+ return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns,
41
+ change, info.st_mode, info.st_nlink)
42
+
43
+
44
+ def _regular(info):
45
+ return (stat.S_ISREG(info.st_mode) and info.st_nlink == 1
46
+ and not getattr(info, "st_file_attributes", 0) & _REPARSE)
47
+
48
+
49
+ def _directories(path):
50
+ for directory in (*reversed(path.parents), path):
51
+ info = directory.lstat()
52
+ if not stat.S_ISDIR(info.st_mode) or getattr(info, "st_file_attributes", 0) & _REPARSE:
53
+ raise TaskExclusionError("unsafe directory in task lock exclusion path")
54
+
55
+
56
+ def _git(root, *args, input_bytes=None):
57
+ return subprocess.run([git_executable(), "-C", str(root), *args],
58
+ env=root_bound_git_env(), capture_output=True, timeout=5,
59
+ input=input_bytes)
60
+
61
+
62
+ def _git_path(root, *args):
63
+ result = _git(root, "rev-parse", *args)
64
+ value = os.fsdecode(result.stdout).rstrip("\r\n")
65
+ if result.returncode or not value or not os.path.isabs(value) or "\n" in value:
66
+ raise TaskExclusionError("cannot bind task lock exclusion to selected Git")
67
+ return Path(value)
68
+
69
+
70
+ def _same_path(left, right):
71
+ return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
72
+
73
+
74
+ def _binding(root, required):
75
+ # A failed Git probe in an actual checkout is never mistaken for non-Git.
76
+ markers = [item / ".git" for item in (root, *root.parents)]
77
+ has_marker = any(os.path.lexists(path) for path in markers)
78
+ try:
79
+ top = _git_path(root, "--show-toplevel")
80
+ except (OSError, RuntimeError, subprocess.SubprocessError):
81
+ if not required and not has_marker:
82
+ return None
83
+ raise TaskExclusionError("cannot resolve repository root for task lock exclusion") from None
84
+ # Git reports physical roots: /var aliases and Windows 8.3 names can differ
85
+ # from the caller. Resolve only this identity, never metadata paths whose
86
+ # unresolved link/reparse attributes the checks below must inspect.
87
+ root = Path(os.path.realpath(root))
88
+ if not _same_path(os.path.realpath(top), root):
89
+ raise TaskExclusionError("task lock exclusion requires the exact repository root")
90
+ _directories(root)
91
+ marker = root / ".git"
92
+ info = marker.lstat()
93
+ if (not (stat.S_ISDIR(info.st_mode) or _regular(info))
94
+ or getattr(info, "st_file_attributes", 0) & _REPARSE):
95
+ raise TaskExclusionError("unsafe Git marker for task lock exclusion")
96
+ common = _git_path(root, "--path-format=absolute", "--git-common-dir")
97
+ exclude = _git_path(root, "--path-format=absolute", "--git-path", "info/exclude")
98
+ if not _same_path(exclude, common / "info/exclude"):
99
+ raise TaskExclusionError("task lock exclusion common-directory mismatch")
100
+ _directories(common)
101
+ return exclude
102
+
103
+
104
+ def _read_exclude(path):
105
+ try:
106
+ before = path.lstat()
107
+ except FileNotFoundError:
108
+ return b"", None
109
+ if not _regular(before):
110
+ raise TaskExclusionError("task lock exclusion is not a regular single-link file")
111
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
112
+ with os.fdopen(fd, "rb") as handle:
113
+ opened = os.fstat(handle.fileno())
114
+ if not _regular(opened) or _version(opened) != _version(before):
115
+ raise TaskExclusionError("task lock exclusion changed while opening")
116
+ data = handle.read(MAX_EXCLUDE_BYTES + 1)
117
+ if len(data) > MAX_EXCLUDE_BYTES:
118
+ raise TaskExclusionError("task lock exclusion exceeds size limit")
119
+ if _version(os.fstat(handle.fileno())) != _version(before):
120
+ raise TaskExclusionError("task lock exclusion changed while reading")
121
+ if _version(path.lstat()) != _version(before):
122
+ raise TaskExclusionError("task lock exclusion path changed while reading")
123
+ return data, before
124
+
125
+
126
+ def _check_conflicts(root):
127
+ tracked = _git(root, "ls-files", "--error-unmatch", "--", LOCK_PATH)
128
+ if tracked.returncode == 0:
129
+ raise TaskExclusionError("task lock sidecar is tracked; local exclusion cannot untrack it")
130
+ if tracked.returncode != 1:
131
+ raise TaskExclusionError("cannot check tracked task lock sidecar")
132
+ # A file can serve both global and per-directory roles. For this conflict
133
+ # probe only, disable the lower-priority global source without changing
134
+ # repository configuration. Effectiveness checks below use actual config.
135
+ ignored = _git(root, "-c", "core.excludesFile=", "check-ignore", "--no-index", "-z", "-v", "--stdin",
136
+ input_bytes=LOCK_PATH.encode("ascii") + b"\0")
137
+ if ignored.returncode not in (0, 1):
138
+ raise TaskExclusionError("cannot check task lock exclusion precedence")
139
+ fields = ignored.stdout.split(b"\0")
140
+ # Git reports the target's per-directory sources relative to the -C root.
141
+ if len(fields) == 5 and fields[0].replace(b"\\", b"/") in (
142
+ b".gitignore", b".codearbiter/.gitignore") \
143
+ and fields[2].startswith(b"!"):
144
+ raise TaskExclusionError("a .gitignore negation overrides local task lock exclusion")
145
+
146
+
147
+ def _ensure(root, required):
148
+ # Retain the caller spelling so publication-time binding detects retargeting.
149
+ root = Path(os.path.abspath(root))
150
+ exclude = _binding(root, required)
151
+ if exclude is None:
152
+ return None
153
+ # mkdir is exclusive and never follows a preexisting info alias.
154
+ try:
155
+ exclude.parent.mkdir()
156
+ except FileExistsError:
157
+ pass
158
+ _directories(exclude.parent)
159
+ lock = exclude.with_name("exclude.lock")
160
+ fd = None
161
+ owned = None
162
+ try:
163
+ try:
164
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL
165
+ | getattr(os, "O_NOFOLLOW", 0), 0o666)
166
+ except FileExistsError:
167
+ raise TaskExclusionError("exclude.lock already exists; another writer may own it") from None
168
+ info = os.fstat(fd)
169
+ owned = info.st_dev, info.st_ino
170
+ before, snapshot = _read_exclude(exclude)
171
+ _check_conflicts(root)
172
+ if EXCLUDE_RULE in before.splitlines():
173
+ ignored = _git(root, "check-ignore", "-q", "--", LOCK_PATH)
174
+ if ignored.returncode == 0:
175
+ return "already present"
176
+ if ignored.returncode != 1:
177
+ raise TaskExclusionError("cannot verify task lock exclusion")
178
+ newline = b"\r\n" if b"\r\n" in before else b"\n"
179
+ data = before + (newline if before and not before.endswith(b"\n") else b"")
180
+ data += EXCLUDE_RULE + newline
181
+ if len(data) > MAX_EXCLUDE_BYTES:
182
+ raise TaskExclusionError("task lock exclusion output exceeds size limit")
183
+ with os.fdopen(fd, "wb") as handle:
184
+ fd = None
185
+ handle.write(data)
186
+ handle.flush()
187
+ os.fsync(handle.fileno())
188
+ if snapshot is not None:
189
+ os.chmod(lock, stat.S_IMODE(snapshot.st_mode))
190
+ latest, current = _read_exclude(exclude)
191
+ if latest != before or ((snapshot is None) != (current is None)) \
192
+ or snapshot is not None and _version(snapshot) != _version(current):
193
+ raise TaskExclusionError("task lock exclusion changed before publication")
194
+ if _binding(root, True) != exclude:
195
+ raise TaskExclusionError("task lock exclusion binding changed before publication")
196
+ _directories(exclude.parent)
197
+ info = lock.lstat()
198
+ if not _regular(info) or (info.st_dev, info.st_ino) != owned:
199
+ raise TaskExclusionError("exclude.lock ownership changed before publication")
200
+ os.replace(lock, exclude)
201
+ owned = None
202
+ if _git(root, "check-ignore", "-q", "--", LOCK_PATH).returncode != 0:
203
+ raise TaskExclusionError("local rule added but task lock exclusion is not effective")
204
+ return "added"
205
+ finally:
206
+ if fd is not None:
207
+ os.close(fd)
208
+ if owned is not None:
209
+ try:
210
+ info = lock.lstat()
211
+ if (info.st_dev, info.st_ino) == owned:
212
+ lock.unlink() # Only OUR Git transaction file, NEVER the board OS lock.
213
+ except FileNotFoundError:
214
+ pass
215
+
216
+
217
+ def ensure_task_lock_excluded(root, required=False):
218
+ try:
219
+ return _ensure(root, required)
220
+ except TaskExclusionError:
221
+ raise
222
+ except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
223
+ raise TaskExclusionError(f"task lock exclusion failed ({type(exc).__name__})") from None
@@ -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.6"
316
+ adapter_version = "2.17.9"
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,
@@ -12,6 +12,7 @@
12
12
  # Usage:
13
13
  # python init-codearbiter.py [--root PATH] [--stage N]
14
14
  # python init-codearbiter.py --check # report state, create nothing
15
+ # python init-codearbiter.py --repair-lock-exclusion [--root PATH]
15
16
 
16
17
  import argparse
17
18
  import subprocess
@@ -19,7 +20,8 @@ import os
19
20
  import sys
20
21
 
21
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
- from _gitexec import git_executable # noqa: E402
23
+ from _gitexec import git_executable, root_bound_git_env # noqa: E402
24
+ from _taskboardlib import DONE_TASKS # noqa: E402 — shared archive template (#625)
23
25
  import hostapi # noqa: E402 — host seam (ADR-0011)
24
26
  import _hooklib # noqa: E402 — set_host DI seam (#257)
25
27
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
@@ -92,21 +94,6 @@ OVERRIDES = """\
92
94
  # last-checkpoint marker as "overrides since last checkpoint."
93
95
  """
94
96
 
95
- DONE_TASKS = """\
96
- # Done tasks
97
-
98
- Completed work swept off the board by `taskwrite archive`, newest last.
99
- APPEND-ONLY: entries are added here and never edited or removed, so a
100
- finished task has exactly one permanent record.
101
-
102
- Each line is the task's original lifecycle line, moved verbatim from
103
- `open-tasks.md` with its `(done YYYY-MM-DD)` stamp intact — the stamp is
104
- what makes an entry ageable, and what `archive` refuses to invent.
105
-
106
- Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
107
- per-item confirmation; nothing sweeps automatically.
108
- """
109
-
110
97
  FILES = {
111
98
  "open-tasks.md": OPEN_TASKS,
112
99
  # B-23: scaffolded at init rather than created on first archive. An
@@ -126,7 +113,7 @@ def project_root(opt):
126
113
  # prefer git toplevel; fall back to cwd
127
114
  try:
128
115
  out = subprocess.run([git_executable(), "rev-parse", "--show-toplevel"],
129
- capture_output=True, text=True, timeout=2)
116
+ env=root_bound_git_env(), capture_output=True, text=True, timeout=2)
130
117
  top = out.stdout.strip()
131
118
  if out.returncode == 0 and top:
132
119
  return os.path.abspath(top)
@@ -138,9 +125,16 @@ def project_root(opt):
138
125
  def main(argv=None):
139
126
  ap = argparse.ArgumentParser(add_help=True)
140
127
  ap.add_argument("--root")
141
- ap.add_argument("--stage", type=int, default=1)
128
+ ap.add_argument("--stage", type=int)
142
129
  ap.add_argument("--check", action="store_true")
130
+ ap.add_argument("--repair-lock-exclusion", action="store_true", help=(
131
+ "repair only Git-local info/exclude for an existing scaffold; retain the "
132
+ "task-board OS lock, never rewrite project state (cannot combine with --check/--stage)"))
143
133
  args = ap.parse_args(argv)
134
+ if args.repair_lock_exclusion and (args.check or args.stage is not None):
135
+ ap.error("--repair-lock-exclusion cannot combine with --check or --stage")
136
+ if args.stage is None:
137
+ args.stage = 1
144
138
 
145
139
  root = project_root(args.root)
146
140
  cad = os.path.join(root, ".codearbiter")
@@ -168,11 +162,29 @@ def main(argv=None):
168
162
  print(f"NOT SCAFFOLDED: {cad} would be created. Run without --check to scaffold.")
169
163
  return
170
164
 
165
+ from _taskexcludelib import ensure_task_lock_excluded, TaskExclusionError
166
+ if args.repair_lock_exclusion:
167
+ if not os.path.isfile(ctx) or os.path.islink(ctx):
168
+ raise SystemExit("REFUSING: task lock exclusion repair requires an existing scaffold.")
169
+ try:
170
+ result = ensure_task_lock_excluded(root, required=True)
171
+ except TaskExclusionError as exc:
172
+ raise SystemExit(f"REFUSING: {exc}") from None
173
+ print(f"task lock exclusion: {result}")
174
+ return
175
+
171
176
  if os.path.exists(ctx):
172
177
  raise SystemExit(
173
178
  f"REFUSING: {ctx} already exists. .codearbiter/ is already scaffolded here. "
174
179
  f"To populate it, run {_cc} or {_dc}; to repair, edit by hand.")
175
180
 
181
+ try:
182
+ result = ensure_task_lock_excluded(root)
183
+ except TaskExclusionError as exc:
184
+ raise SystemExit(f"REFUSING: {exc}") from None
185
+ if result:
186
+ print(f"task lock exclusion: {result}")
187
+
176
188
  os.makedirs(cad, exist_ok=True)
177
189
  created = []
178
190
  if not os.path.exists(ctx):
@@ -183,7 +195,10 @@ def main(argv=None):
183
195
  for fname, content in FILES.items():
184
196
  fp = os.path.join(cad, fname)
185
197
  if not os.path.exists(fp):
186
- with open(fp, "w", encoding="utf-8") as f:
198
+ # Match taskwrite's LF output for this shared append-only template
199
+ # on Windows too; other scaffold files retain their existing EOLs.
200
+ newline = "\n" if fname == "done-tasks.md" else None
201
+ with open(fp, "w", encoding="utf-8", newline=newline) as f:
187
202
  f.write(content)
188
203
  created.append(fname)
189
204
 
@@ -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.