@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 +2 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +18 -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/_sensitivelib.py +263 -4
- package/plugins/ca-pi/hooks/_taskboardlib.py +18 -1
- package/plugins/ca-pi/hooks/_taskexcludelib.py +223 -0
- package/plugins/ca-pi/hooks/git-enforce.py +5 -6
- package/plugins/ca-pi/hooks/hostapi.py +2 -2
- package/plugins/ca-pi/hooks/init-codearbiter.py +34 -19
- 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.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.
|
|
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
|
@@ -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) ->
|
|
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.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
|
|
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
|
|
@@ -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
|
|
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,
|
|
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.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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|