agent-bios 0.9.9 → 0.10.0
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 +9 -1
- package/claude/CLAUDE.md +4 -41
- package/claude/guides/cli-multi-model-workflow.md +3 -3
- package/claude/guides/coding-staged-workflow.md +50 -48
- package/claude/guides/concept-economy.md +187 -0
- package/claude/guides/documentation-hygiene.md +112 -0
- package/claude/guides/review-request.md +9 -7
- package/claude/guides/tooling-gotchas.md +10 -0
- package/claude/guides/verification-discipline.md +166 -0
- package/claude/hooks/tooling-gotchas-hook.py +323 -13
- package/codex/AGENTS.md +4 -41
- package/codex/guides/cli-multi-model-workflow.md +3 -3
- package/codex/guides/coding-staged-workflow.md +50 -48
- package/codex/guides/concept-economy.md +187 -0
- package/codex/guides/documentation-hygiene.md +112 -0
- package/codex/guides/review-request.md +9 -7
- package/codex/guides/tooling-gotchas.md +10 -0
- package/codex/guides/verification-discipline.md +166 -0
- package/compose/assemble.py +10 -1
- package/compose/check-domains.py +882 -6
- package/compose/domains.json +10 -44
- package/install.sh +129 -12
- package/package.json +8 -4
- package/provenance.json +1 -0
- package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
|
@@ -108,6 +108,16 @@ depends on it, pin it explicitly instead of trusting the environment.
|
|
|
108
108
|
|
|
109
109
|
## Git operations
|
|
110
110
|
|
|
111
|
+
- **A stale local base inflates the range**: before reasoning about what a branch
|
|
112
|
+
contains or opening a PR, `git fetch`, then ask against the remote rather than the
|
|
113
|
+
local tracking ref — `git log origin/<base>..HEAD` for which commits are yours, and
|
|
114
|
+
the merge-base form below for the diff. On a shared repo the local base lags until
|
|
115
|
+
you pull, so `<base>..HEAD` quietly folds in work that already merged. When a range
|
|
116
|
+
looks surprisingly large, suspect the base before the branch.
|
|
117
|
+
- **"Mergeable" is measured against the base, not against siblings**: the platform
|
|
118
|
+
flag says each PR merges into the base, and two PRs can both be clean while
|
|
119
|
+
conflicting with each other. Before choosing a merge order, diff their changed-file
|
|
120
|
+
sets and simulate the sequence.
|
|
111
121
|
- **Two-dot diff semantics**: `git diff A..B` is a direct snapshot
|
|
112
122
|
comparison — unlike `git log A..B` it excludes nothing, so a lagging
|
|
113
123
|
merge-base injects unrelated upstream changes into the diff. For PR/review
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
guide_id: verification-discipline
|
|
3
|
+
language: en
|
|
4
|
+
status: active
|
|
5
|
+
use_when:
|
|
6
|
+
- deciding how much verification a change deserves, before spending on a slow or expensive run
|
|
7
|
+
- choosing what to run for a domain — code, ontology, config/data, spreadsheets, docs, a release
|
|
8
|
+
- building the case space for a check, or deciding what its expected answer should be
|
|
9
|
+
- a check came back green, empty, or fast, and you are about to believe it
|
|
10
|
+
- running independent or adversarial review, and judging what its agreement is worth
|
|
11
|
+
core_rules:
|
|
12
|
+
- a check is only evidence if it could have failed — assert a non-empty subject before any "no bad X" claim
|
|
13
|
+
- enumerate the case space from the artifact that defines it, and record real output instead of typing an expectation
|
|
14
|
+
- proportion depth to cost, risk, and information gain; a single-user tool does not warrant production assurance
|
|
15
|
+
- same-kind reviewers share blind spots, so their shared "clean" is an absence of objection, not verification
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Verification Discipline
|
|
19
|
+
|
|
20
|
+
A scoped extension of the global Verification Discipline section. Its subject is not "did you
|
|
21
|
+
test it" but the harder question underneath: **could this check have failed?** Everything below
|
|
22
|
+
is a way of answering that before the result is believed rather than after it is quoted.
|
|
23
|
+
|
|
24
|
+
The global rules that stay always-loaded are the ones whose moment does not announce itself — you
|
|
25
|
+
believe you are finished, and that belief is the failure. This guide is what you open once you
|
|
26
|
+
know you are verifying.
|
|
27
|
+
|
|
28
|
+
## Proportion the depth before you spend
|
|
29
|
+
|
|
30
|
+
Verification has a cost and an information yield, and they are not correlated by default. Decide
|
|
31
|
+
the depth first:
|
|
32
|
+
|
|
33
|
+
- Diagnose in code before running anything expensive, and replay the changed deterministic logic
|
|
34
|
+
over persisted real artifacts rather than re-running the whole pipeline to observe it.
|
|
35
|
+
- Probe at N=1 with the inputs precondition-checked. A single well-chosen case that reaches the
|
|
36
|
+
real path outranks a hundred that stop short of it.
|
|
37
|
+
- Reserve the full design-review-plus-live-verification treatment for first-of-kind work and for
|
|
38
|
+
changes that move authority — who may decide, who may write, what is irreversible.
|
|
39
|
+
- Proportion assurance to the deployment context. A single-user tool operating on its owner's own
|
|
40
|
+
data does not warrant production-grade assurance, and treating it as if it did buys nothing
|
|
41
|
+
while delaying delivery. Prefer shipping.
|
|
42
|
+
|
|
43
|
+
The failure this prevents is not under-testing. It is spending the verification budget on the
|
|
44
|
+
cheap half of the risk and having nothing left for the part that could actually hurt.
|
|
45
|
+
|
|
46
|
+
## The static floor
|
|
47
|
+
|
|
48
|
+
Run the broad, cheap checks first and let them fail before anything slower starts: typecheck,
|
|
49
|
+
lint, build, format, schema and config validation, graph validation, workbook structure checks,
|
|
50
|
+
import boundaries, and security checks where they exist. These are a floor, not a verdict — they
|
|
51
|
+
prove the artifact is well-formed, never that it behaves.
|
|
52
|
+
|
|
53
|
+
## Verification Menus
|
|
54
|
+
|
|
55
|
+
Pick the narrowest reliable mix that proves the changed behavior, meaning, or contract. Inside the
|
|
56
|
+
mix, the unit to add is the narrowest reliable runtime or semantic test that proves it — narrowest
|
|
57
|
+
meaning the smallest test that would fail if the change were wrong, which is not the same as the
|
|
58
|
+
cheapest one to write.
|
|
59
|
+
|
|
60
|
+
- Code: a layered mix of unit tests, integration tests for E2E segments, targeted E2E for changed flows, and full E2E for release or high-risk changes.
|
|
61
|
+
- Ontology: static graph checks, concept economy gates, changed-path integration checks, and competency-question E2E checks.
|
|
62
|
+
- Config or data: real parsers, schema checks, fixture validation, and sample transformations.
|
|
63
|
+
- Spreadsheets: static workbook checks, fixture-based output checks, cross-sheet flow checks, visual/layout checks, and real Microsoft Excel engine recalculation for formula-dependent results.
|
|
64
|
+
- Docs: links, terminology, current behavior alignment, and references to isolated historical notes.
|
|
65
|
+
- Release or distribution: after publishing to multiple independently writable channels (signed manifest, object storage, release host, embedded updater), digest-verify every referenced object against the staging original per channel — publish success and upload order are not evidence — and run the real installer/updater through its default path.
|
|
66
|
+
- A/B or on/off measurements: before accepting a null result, verify the arms actually received different treatment in the mechanism under test — a shared default or unconditional upstream step can silently apply the treatment to both arms.
|
|
67
|
+
- Model-behavior guardrails: verify by changed behavior, not recitation — a staged battery from named-trigger cases through disguised, deconfounded, category-wide, and single-variable framings; a clean pass means "no known defect", so re-run the battery when the model changes.
|
|
68
|
+
- Branch/version test builds against real data: explicitly separate every state sink the app touches (files, DB, OS-level stores that ignore env overrides), confirm the launch path propagates the isolation to child processes, and back up live data before the first run — a mismatched schema that drops unknown fields on write is data loss, not a no-op.
|
|
69
|
+
- Irreversible capture switches: when activation itself has unreproducible cost (a capture window that cannot be replayed), prove the downstream consumption path against existing samples before enabling — reversibility of the code path alone is not enough.
|
|
70
|
+
|
|
71
|
+
## Deriving the case space
|
|
72
|
+
|
|
73
|
+
Which scenarios exist is semantic work: derive them from the diff, the user impact, the
|
|
74
|
+
concept impact, and the failure modes. Running them is not — tools and code execute the
|
|
75
|
+
cases and report the evidence. Keeping that split is what stops a suite from being a
|
|
76
|
+
record of what someone imagined.
|
|
77
|
+
|
|
78
|
+
A check has two authored halves, and they rot differently. The **verdict** — what the
|
|
79
|
+
answer should be — rots by encoding a belief that was wrong from the start. The
|
|
80
|
+
**space** — which cases exist — rots by staying still while the thing it covers grows.
|
|
81
|
+
Recording the verdict is common practice; deriving the space is the half usually left
|
|
82
|
+
hand-written, and a suite can have every expectation derived and still cover a set
|
|
83
|
+
someone typed once.
|
|
84
|
+
|
|
85
|
+
- Make the criterion falsifiable before you make it green. Prefer a signal that fails when
|
|
86
|
+
the mechanism is wrong — a negative or contrast control. Where no existing gate can judge
|
|
87
|
+
a criterion, build the executable judge or do not claim the criterion met: a criterion
|
|
88
|
+
nothing can fail is a description of the work, not a check on it.
|
|
89
|
+
- Record the verdict, do not type it. Run the real path and store what came back;
|
|
90
|
+
drift then shows as a diff instead of as a belief someone has to re-justify.
|
|
91
|
+
- Enumerate the space from the artifact that defines it — the config's entries, the
|
|
92
|
+
schema's fields, the router's routes, the installer's call sites. Adding one there
|
|
93
|
+
should widen coverage with no edit here.
|
|
94
|
+
- Derive the exemption rule too. If some cases legitimately have no answer, decide that
|
|
95
|
+
from a property the artifact carries, never from a list of names: the list is the
|
|
96
|
+
authored space coming back through a side door, and it absorbs the regression where
|
|
97
|
+
a case that should have an answer stops having one.
|
|
98
|
+
- Dedupe on the tuple that actually determines the outcome, and report how many
|
|
99
|
+
collapsed. A coverage count that hides its own truncation reads as more than it is.
|
|
100
|
+
- Split by cost, not by space. When the real path needs money, credentials, or a
|
|
101
|
+
network, run a cheap stand-in on every commit and the real one on demand — both from
|
|
102
|
+
the **same enumeration**, so the two can never disagree about which cases exist.
|
|
103
|
+
- Derivation moves authorship rather than removing it: the extractor and the invariants
|
|
104
|
+
are still written by hand. Give them a negative control, or the derived suite is just
|
|
105
|
+
a larger unfalsifiable one.
|
|
106
|
+
- Planting a violation to prove a control fires is a write into the working tree, and
|
|
107
|
+
the restore is not atomic with it: if the probe can time out, abort, or be
|
|
108
|
+
interrupted, a restore sitting after it never runs and the plant survives into a
|
|
109
|
+
commit. Plant in a copy where the shape allows it, and when it must be in place, snapshot
|
|
110
|
+
first and restore from the snapshot as its own step rather than trusting the probe to finish.
|
|
111
|
+
|
|
112
|
+
## When a green means nothing
|
|
113
|
+
|
|
114
|
+
A passing check and a check that never ran look identical from outside. These are the shapes that
|
|
115
|
+
produce a green with no evidence behind it:
|
|
116
|
+
|
|
117
|
+
- **The empty subject.** Any "no bad X" or "all X satisfy P" claim over an empty set is
|
|
118
|
+
vacuously true. Assert the entity-under-test set has cardinality greater than zero **before**
|
|
119
|
+
the claim, and make the gate itself refuse to report clean when it judged nothing.
|
|
120
|
+
- **The fixture that misses the guard.** For a test touching a branch you are adding or deleting,
|
|
121
|
+
confirm its inputs satisfy the live branch's entry guard. A copied fixture that fails the new
|
|
122
|
+
guard routes silently into the about-to-be-deleted dead branch and stays green after the real
|
|
123
|
+
behavior breaks.
|
|
124
|
+
- **The permissive fallback in the checker.** A `a || b` inside a gate absorbs a wrong assumption
|
|
125
|
+
and keeps passing. Checker code must assert the shape it expects and fail loud.
|
|
126
|
+
- **The suspiciously fast or empty run.** When a check goes green unexpectedly quickly, or reports
|
|
127
|
+
nothing at all, dump what it actually ran over before believing it. A harness that crashed early
|
|
128
|
+
and one that found nothing produce the same exit code.
|
|
129
|
+
- **The control that went quiet.** A negative control indexing a live list stops testing when that
|
|
130
|
+
list empties, and says nothing about it. New controls build their own subject; resolving an item
|
|
131
|
+
means re-reading the controls for ones that have gone silent.
|
|
132
|
+
|
|
133
|
+
The discipline that covers all five: after adding a check, revert the fix it guards and watch the
|
|
134
|
+
check fail. A control that survives a faithful revert was never testing the thing it names.
|
|
135
|
+
|
|
136
|
+
## Keeping E2E honest
|
|
137
|
+
|
|
138
|
+
E2E is where flakiness is mistaken for environment noise and then ignored. Keep it deterministic
|
|
139
|
+
with fixed data, resilient selectors, isolated external dependencies, and explicit waits rather
|
|
140
|
+
than sleeps. A flaky E2E is not a weaker test; it is a test whose result carries no information,
|
|
141
|
+
and a suite that people re-run until it passes has been switched off without anyone deciding to.
|
|
142
|
+
|
|
143
|
+
## Independent review, and what agreement is worth
|
|
144
|
+
|
|
145
|
+
For non-trivial designs and high-risk changes, run independent adversarial review across distinct
|
|
146
|
+
lenses — ideally on the design, before implementation, when a finding is still cheap to act on.
|
|
147
|
+
Then re-verify each finding against real code before acting on it: a reviewer reasons from what it
|
|
148
|
+
was shown, and what it was shown may be wrong.
|
|
149
|
+
|
|
150
|
+
Apply the **convergence heuristic by reviewer kind** — judge the result by reviewer kind, not by count:
|
|
151
|
+
|
|
152
|
+
- Same-kind convergence is high confidence but blind-spot-sharing. Two reviewers of the same kind
|
|
153
|
+
agreeing that something is clean is an absence of objection, not verification.
|
|
154
|
+
- Different-kind divergence is the expected signal, not a problem to resolve. Act on the union of
|
|
155
|
+
what they found rather than the intersection.
|
|
156
|
+
- An orchestrated workflow's self-reported all-green is never sufficient on its own. Re-run the
|
|
157
|
+
diff inspection and the verification suite yourself.
|
|
158
|
+
|
|
159
|
+
The cheapest way to buy real independence is a different provider; after that a different model;
|
|
160
|
+
after that strictly higher effort. A reviewer run at lower effort than the work it checks buys
|
|
161
|
+
nothing — cheaper is not another perspective.
|
|
162
|
+
|
|
163
|
+
## Reporting
|
|
164
|
+
|
|
165
|
+
Before calling the work done, state the checks that ran, their results, and any risk left
|
|
166
|
+
unverified. "Unverified" is a legitimate outcome and a useful one; silence about it is not.
|
|
@@ -12,53 +12,159 @@ import sys
|
|
|
12
12
|
|
|
13
13
|
GUIDE = "guides/tooling-gotchas.md"
|
|
14
14
|
|
|
15
|
-
# (name, compiled trigger, one-line reminder). Priority order; max 2 injected.
|
|
15
|
+
# (name, compiled trigger, one-line reminder, guide anchor). Priority order; max 2 injected.
|
|
16
|
+
#
|
|
17
|
+
# The anchor is the heading in tooling-gotchas.md this rule compresses. It exists because the
|
|
18
|
+
# hook is Claude-only and the guide is what a Codex reader gets instead: a rule admitted here
|
|
19
|
+
# with no counterpart there would silently give the two hosts different guidance. The mapping
|
|
20
|
+
# is declared rather than matched, because a rule name and a guide heading do not share a
|
|
21
|
+
# string. --self-test checks every anchor against the guide AND its codex mirror.
|
|
16
22
|
RULES = [
|
|
17
23
|
("reserved-shell-names",
|
|
18
24
|
re.compile(r"\b(UID|EUID|GID|PPID)="),
|
|
19
25
|
"Assigning reserved shell names (UID/EUID/GID/PPID) can invoke the bound "
|
|
20
|
-
f"system behavior instead of storing a value — use unreserved names ({GUIDE})."
|
|
26
|
+
f"system behavior instead of storing a value — use unreserved names ({GUIDE}).",
|
|
27
|
+
"Reserved parameter names"),
|
|
21
28
|
("git-diff-two-dot",
|
|
22
29
|
re.compile(r"git\s+diff\s+[^|;&]*(?<!\.)\.\.(?!\.)"),
|
|
23
30
|
"git diff A..B is a direct snapshot comparison, not a range exclusion — "
|
|
24
|
-
f"for PR/review diffs use three-dot origin/base...HEAD ({GUIDE})."
|
|
31
|
+
f"for PR/review diffs use three-dot origin/base...HEAD ({GUIDE}).",
|
|
32
|
+
"Two-dot diff semantics"),
|
|
25
33
|
("git-pull-dirty",
|
|
26
34
|
re.compile(r"\bgit\s+pull\b"),
|
|
27
35
|
"Before pulling into a worktree with local changes: fetch first, compare "
|
|
28
|
-
f"incoming paths against dirty paths, prefer --ff-only ({GUIDE})."
|
|
36
|
+
f"incoming paths against dirty paths, prefer --ff-only ({GUIDE}).",
|
|
37
|
+
"Dirty-worktree pulls"),
|
|
29
38
|
("metachar-inline-arg",
|
|
30
39
|
re.compile(r"(codex-run|codex-helm|codex\s+exec|claude\s+-p)\b[^|;&]*[\"'][^\"']*(\$\(|`)"),
|
|
31
40
|
"Values with shell metacharacters must reach the target via stdin or a "
|
|
32
|
-
f"file, not inline arguments — the shell expands them first ({GUIDE})."
|
|
41
|
+
f"file, not inline arguments — the shell expands them first ({GUIDE}).",
|
|
42
|
+
"Metacharacter-bearing values"),
|
|
33
43
|
("pipe-exit-masking",
|
|
34
44
|
re.compile(r"\$\?"),
|
|
35
45
|
"A pipeline's $? reflects only the last stage — capture the tested "
|
|
36
|
-
f"stage's own status (unpiped run, PIPESTATUS, per-command pipefail) ({GUIDE})."
|
|
46
|
+
f"stage's own status (unpiped run, PIPESTATUS, per-command pipefail) ({GUIDE}).",
|
|
47
|
+
"Pipe exit masking"),
|
|
37
48
|
# Path-shaped argument only: `git checkout main` is a branch switch and needs no
|
|
38
49
|
# warning, while `git checkout src/x.py` silently discards every uncommitted edit in
|
|
39
50
|
# that file — including ones the caller did not put there.
|
|
40
51
|
("git-checkout-path",
|
|
41
52
|
re.compile(r"\bgit\s+(checkout|restore)\b[^|;&]*(--\s|[\w.-]*[./][\w./-]*)"),
|
|
42
53
|
"Reverting a path discards ALL uncommitted edits in that file, not just the one "
|
|
43
|
-
f"you planted — check `git diff <path>` first, or restore from a copy ({GUIDE})."
|
|
54
|
+
f"you planted — check `git diff <path>` first, or restore from a copy ({GUIDE}).",
|
|
55
|
+
"Reverting a path is not undoing your edit"),
|
|
44
56
|
("grep-binary-heuristic",
|
|
45
|
-
|
|
57
|
+
# Applied per extracted STAGE by matches(), not to the raw line. The stage's
|
|
58
|
+
# COMMAND WORD must be grep — after optional reserved words (`if ! grep -q`
|
|
59
|
+
# is the assert-absence idiom) and per-command assignments (`LC_ALL=C grep`
|
|
60
|
+
# — locale pinning is what our own guides recommend); bare, path-prefixed,
|
|
61
|
+
# or the DIRECT git subcommand (git grep shares the binary heuristic). A
|
|
62
|
+
# stage that merely passes the word along (`echo grep needle`) runs no grep
|
|
63
|
+
# and gets no reminder. Greps reached through any other prefix — sudo,
|
|
64
|
+
# xargs, git global options between git and the subcommand (`git -C repo
|
|
65
|
+
# grep`), env, timeout, and their successors — are accepted residual by
|
|
66
|
+
# decision: each is one rung of an endless prefix ladder, and a missed
|
|
67
|
+
# reminder is the failure mode this advisory-only hook tolerates.
|
|
68
|
+
re.compile(r"\s*(?:(?:!|if|elif|else|then|do|while|until|time)\s+)*"
|
|
69
|
+
r"(?:\w+=\S*\s+)*(?:\S+/)?(?:git\s+)?grep\s"),
|
|
46
70
|
"grep can misread text with heavy non-ASCII/NUL as binary and return a "
|
|
47
|
-
f"false no-match — prefer the Grep tool (ripgrep) or grep -a ({GUIDE})."
|
|
71
|
+
f"false no-match — prefer the Grep tool (ripgrep) or grep -a ({GUIDE}).",
|
|
72
|
+
"grep binary heuristic"),
|
|
48
73
|
]
|
|
49
74
|
MAX_INJECT = 2
|
|
50
75
|
|
|
51
76
|
|
|
52
|
-
def matches(command: str):
|
|
77
|
+
def matches(command: str, limit: int | None = MAX_INJECT):
|
|
78
|
+
"""Rules this command trips. `limit` is a DELIVERY policy — at most two reminders are
|
|
79
|
+
worth injecting at once — not a judgement about which rules matched, so the self-test
|
|
80
|
+
asks for the untruncated list rather than re-deriving the matching itself."""
|
|
53
81
|
hits = []
|
|
54
|
-
for name, rx, msg in RULES:
|
|
82
|
+
for name, rx, msg, _ in RULES:
|
|
55
83
|
if name == "pipe-exit-masking" and "|" not in command:
|
|
56
84
|
continue
|
|
57
|
-
if name == "grep-binary-heuristic"
|
|
85
|
+
if name == "grep-binary-heuristic":
|
|
86
|
+
# Per STAGE, not per command: -a on an upstream grep does nothing for a
|
|
87
|
+
# downstream one (`git grep -a foo | grep bar` leaves bar's stage on the
|
|
88
|
+
# binary heuristic), so the exemption holds only when EVERY grep stage
|
|
89
|
+
# carries its own text-mode flag.
|
|
90
|
+
# Every separator starts a new stage: a single & (background) and a newline
|
|
91
|
+
# join commands as surely as ; and | — `grep -a foo a & grep bar b` left the
|
|
92
|
+
# -a covering a stage it never touches.
|
|
93
|
+
# A command substitution executes regardless of the quotes around it:
|
|
94
|
+
# `echo "$(grep needle payload)"` runs that grep, and quote-blanking was
|
|
95
|
+
# hiding it from the stage list so an outer -a covered it. Substitution
|
|
96
|
+
# bodies are lifted out (innermost-first, to a fixpoint) and judged as
|
|
97
|
+
# stages of their own.
|
|
98
|
+
scan = command
|
|
99
|
+
seen_subs = set()
|
|
100
|
+
while True:
|
|
101
|
+
subs = [s2 for s2 in re.findall(r"\$\(([^()]*)\)", scan)
|
|
102
|
+
+ re.findall(r"`([^`]*)`", scan)
|
|
103
|
+
if s2 not in seen_subs]
|
|
104
|
+
if not subs:
|
|
105
|
+
break
|
|
106
|
+
seen_subs.update(subs)
|
|
107
|
+
scan = scan + "\n" + "\n".join(subs)
|
|
108
|
+
# Quotes are blanked BEFORE splitting: a separator inside a quoted pattern
|
|
109
|
+
# (`grep 'grep -a;foo'`) manufactured a pseudo-stage carrying a text-mode
|
|
110
|
+
# flag that exists only as pattern content. Blanked to a placeholder TOKEN,
|
|
111
|
+
# not to nothing: `grep -e "needle" -a file` blanked to whitespace left -e
|
|
112
|
+
# to consume the -a as its argument, and the real text-mode flag vanished
|
|
113
|
+
# with the operand's token boundary.
|
|
114
|
+
blanked = re.sub(r"'[^']*'|\"(?:\\\\.|[^\"\\\\])*\"", "0", scan)
|
|
115
|
+
# Comments go after the quotes: with quoted text blanked, any remaining # is
|
|
116
|
+
# a real comment, and `grep needle payload # use -a next time` was exempting
|
|
117
|
+
# itself with advice bash never passes to grep.
|
|
118
|
+
blanked = re.sub(r"#[^\n]*", "", blanked)
|
|
119
|
+
# Parens split as the old line-shaped trigger's [;&(|] class did: a
|
|
120
|
+
# subshell or group opener starts a command (`(grep needle)` runs grep),
|
|
121
|
+
# and dropping ( from the boundaries regressed exactly that form.
|
|
122
|
+
stages = [st for st in re.split(r"\|\||&&|[|;&\n()]", blanked)
|
|
123
|
+
if rx.match(st)]
|
|
124
|
+
# The STAGES are the trigger: a grep whose only appearance is inside a
|
|
125
|
+
# lifted substitution (`echo "\`grep needle payload\`"`) never matched a
|
|
126
|
+
# line-shaped regex, and a quoted assignment value (`FILTER='two words'
|
|
127
|
+
# grep ...`) hid the command word from it. The rule's regex judges each
|
|
128
|
+
# stage in COMMAND-WORD position — `echo grep needle` passes the word as
|
|
129
|
+
# an argument, runs no grep, and stays quiet.
|
|
130
|
+
if not stages:
|
|
131
|
+
continue
|
|
132
|
+
# Options end at `--`: after it, `-a` is the PATTERN operand (grep's usage is
|
|
133
|
+
# [OPTION]... PATTERNS [FILE]...), so `grep -- -a payload` is still on the
|
|
134
|
+
# binary heuristic and must keep its reminder.
|
|
135
|
+
# And `-e <pattern>` consumes its argument: in `grep -e -a payload` the -a
|
|
136
|
+
# is the PATTERN (grep --help: -e, --regexp=PATTERNS), so it must not read
|
|
137
|
+
# as text mode. The pattern-taking options are blanked before the flag scan.
|
|
138
|
+
# -f/--file consumes its argument the same way (-f, --file=FILE): every
|
|
139
|
+
# pattern-taking option is blanked, or its argument reads as a flag.
|
|
140
|
+
# Quoted text is pattern content, never options: `grep 'foo -a bar'` has no
|
|
141
|
+
# text-mode flag, and the unquoted scan read the -a inside the pattern.
|
|
142
|
+
# Quotes are blanked first, then the -- operand split, then pattern-taking
|
|
143
|
+
# option arguments.
|
|
144
|
+
opts = [re.sub(r"(^|\s)(?:-e|--regexp|-f|--file)(?:=\S+|\s+\S+)", " ",
|
|
145
|
+
st.split(" -- ", 1)[0])
|
|
146
|
+
for st in stages]
|
|
147
|
+
|
|
148
|
+
# Bundled short options count too: `grep -qa` enables text mode (grep
|
|
149
|
+
# --help: -a, --text), and requiring a whitespace-delimited -a warned
|
|
150
|
+
# about binary data on a grep that reads it as text. A letter that takes
|
|
151
|
+
# an argument (-e -f -m -A -B -C -d -D) consumes the REST of the bundle,
|
|
152
|
+
# so in `-ea` the a is the PATTERN, not a flag — an `a` reads as text
|
|
153
|
+
# mode only when every letter before it is argument-free.
|
|
154
|
+
def text_mode(o):
|
|
155
|
+
if re.search(r"(^|\s)(-a\b|--text\b|--binary-files(?:=|\s+)text\b)", o):
|
|
156
|
+
return True
|
|
157
|
+
return any(len(halves) == 2 and not set(halves[0]) & set("efmABCdD")
|
|
158
|
+
for tok in re.findall(r"(?:^|\s)-([A-Za-z0-9]+)", o)
|
|
159
|
+
for halves in [tok.split("a", 1)])
|
|
160
|
+
|
|
161
|
+
if all(text_mode(o) for o in opts):
|
|
162
|
+
continue
|
|
163
|
+
hits.append((name, msg))
|
|
58
164
|
continue
|
|
59
165
|
if rx.search(command):
|
|
60
166
|
hits.append((name, msg))
|
|
61
|
-
return hits[:
|
|
167
|
+
return hits if limit is None else hits[:limit]
|
|
62
168
|
|
|
63
169
|
|
|
64
170
|
def main() -> int:
|
|
@@ -82,5 +188,209 @@ def main() -> int:
|
|
|
82
188
|
return 0
|
|
83
189
|
|
|
84
190
|
|
|
191
|
+
def self_test() -> int:
|
|
192
|
+
"""Two halves, because this hook serves two hosts differently.
|
|
193
|
+
|
|
194
|
+
Claude gets the injection, so the first half runs the real entry point over real stdin and
|
|
195
|
+
requires the message out. Codex gets nothing from a hook, so its equivalent is the guide —
|
|
196
|
+
and the second half is the only thing that keeps the two hosts saying the same thing.
|
|
197
|
+
"""
|
|
198
|
+
import pathlib, subprocess
|
|
199
|
+
|
|
200
|
+
here = pathlib.Path(__file__).resolve()
|
|
201
|
+
repo = here.parent.parent.parent
|
|
202
|
+
problems = []
|
|
203
|
+
assert RULES, "no rules: this self-test would pass over nothing"
|
|
204
|
+
|
|
205
|
+
# -- half 1: the hook runs, matches, and emits. A file that is copied and registered but
|
|
206
|
+
# crashes on every invocation satisfies every other check in this repository.
|
|
207
|
+
fires = "UID=0 echo hi"
|
|
208
|
+
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": fires}})
|
|
209
|
+
r = subprocess.run([sys.executable, str(here)], input=payload,
|
|
210
|
+
capture_output=True, text=True)
|
|
211
|
+
if r.returncode != 0:
|
|
212
|
+
problems.append(f"hook exited {r.returncode} on a matching command: {r.stderr.strip()[:120]}")
|
|
213
|
+
elif "additionalContext" not in r.stdout:
|
|
214
|
+
problems.append(f"hook emitted no context for {fires!r}; got {r.stdout.strip()[:80]!r}")
|
|
215
|
+
|
|
216
|
+
# A non-matching command must stay silent, or "it fired" proves nothing.
|
|
217
|
+
quiet = json.dumps({"tool_name": "Bash", "tool_input": {"command": "echo hello"}})
|
|
218
|
+
r2 = subprocess.run([sys.executable, str(here)], input=quiet, capture_output=True, text=True)
|
|
219
|
+
if r2.stdout.strip():
|
|
220
|
+
problems.append(f"hook injected on a command that matches nothing: {r2.stdout.strip()[:80]!r}")
|
|
221
|
+
|
|
222
|
+
# Every rule needs a command that must reach it. A nonempty pattern is not evidence: a
|
|
223
|
+
# trigger of `(?!)` can never match, and a rule suppressed by the special-case logic in
|
|
224
|
+
# matches() never reaches the caller either — both leave the rule inert while this file
|
|
225
|
+
# goes on reporting that all of them fire.
|
|
226
|
+
FIXTURES = {
|
|
227
|
+
"reserved-shell-names": "UID=0 echo hi",
|
|
228
|
+
"git-diff-two-dot": "git diff main..HEAD",
|
|
229
|
+
"git-pull-dirty": "git pull origin main",
|
|
230
|
+
"metachar-inline-arg": 'codex exec "$(cat packet.md)"',
|
|
231
|
+
"pipe-exit-masking": "make build | tail -1; echo $?",
|
|
232
|
+
"git-checkout-path": "git checkout src/thing.py",
|
|
233
|
+
"grep-binary-heuristic": "grep needle haystack.md",
|
|
234
|
+
}
|
|
235
|
+
# A pipeline-stage grep must reach the rule too — the fixture alone exercises only
|
|
236
|
+
# command-start grep, and the reminder is most needed mid-pipeline.
|
|
237
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("cat payload | grep needle", limit=None)]:
|
|
238
|
+
problems.append("grep-binary-heuristic: does not fire on a pipeline stage "
|
|
239
|
+
"('cat payload | grep needle')")
|
|
240
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("git grep -a foo | grep bar", limit=None)]:
|
|
241
|
+
problems.append("grep-binary-heuristic: an upstream -a cancelled the reminder for "
|
|
242
|
+
"a downstream grep that has no text-mode flag")
|
|
243
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("git grep -a foo | grep -a bar", limit=None)]:
|
|
244
|
+
problems.append("grep-binary-heuristic: fired although every grep stage carries "
|
|
245
|
+
"its own text-mode flag")
|
|
246
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep -- -a payload", limit=None)]:
|
|
247
|
+
problems.append("grep-binary-heuristic: '-a' AFTER the -- marker is the pattern "
|
|
248
|
+
"operand, not a flag, and the reminder was suppressed")
|
|
249
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("grep -a -- pattern file.md", limit=None)]:
|
|
250
|
+
problems.append("grep-binary-heuristic: fired although -a precedes the -- marker")
|
|
251
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep -e -a payload", limit=None)]:
|
|
252
|
+
problems.append("grep-binary-heuristic: '-a' as the ARGUMENT of -e is the pattern, "
|
|
253
|
+
"not text mode, and the reminder was suppressed")
|
|
254
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("grep -a -e pattern file.md", limit=None)]:
|
|
255
|
+
problems.append("grep-binary-heuristic: fired although -a stands alone before -e")
|
|
256
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep -f -a payload", limit=None)]:
|
|
257
|
+
problems.append("grep-binary-heuristic: '-a' as the ARGUMENT of -f is a pattern "
|
|
258
|
+
"file, not text mode, and the reminder was suppressed")
|
|
259
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep 'foo -a bar' payload", limit=None)]:
|
|
260
|
+
problems.append("grep-binary-heuristic: a text-mode spelling INSIDE a quoted "
|
|
261
|
+
"pattern is pattern content, and the reminder was suppressed")
|
|
262
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("grep -a 'foo bar' payload", limit=None)]:
|
|
263
|
+
problems.append("grep-binary-heuristic: fired although -a stands outside the "
|
|
264
|
+
"quoted pattern")
|
|
265
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep 'grep -a;foo' payload", limit=None)]:
|
|
266
|
+
problems.append("grep-binary-heuristic: a separator inside a quoted pattern "
|
|
267
|
+
"manufactured a pseudo-stage whose -a suppressed the reminder")
|
|
268
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("grep -a 'x;y' payload", limit=None)]:
|
|
269
|
+
problems.append("grep-binary-heuristic: fired although the real stage carries -a "
|
|
270
|
+
"and the separator is only pattern content")
|
|
271
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
272
|
+
matches('echo "`grep needle payload`" | grep -a foo', limit=None)]:
|
|
273
|
+
problems.append("grep-binary-heuristic: a LEGACY backtick substitution's grep lost "
|
|
274
|
+
"its reminder to the outer stage's -a")
|
|
275
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
276
|
+
matches('echo "`grep needle payload`"', limit=None)]:
|
|
277
|
+
problems.append("grep-binary-heuristic: a grep whose ONLY appearance is a lifted "
|
|
278
|
+
"substitution never triggered")
|
|
279
|
+
if "grep-binary-heuristic" in [h for h, _ in
|
|
280
|
+
matches("grep -a needle payload # note", limit=None)]:
|
|
281
|
+
problems.append("grep-binary-heuristic: fired although the real invocation "
|
|
282
|
+
"carries -a and only the comment follows")
|
|
283
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
284
|
+
matches("grep needle payload # use -a next time", limit=None)]:
|
|
285
|
+
problems.append("grep-binary-heuristic: advice in a trailing comment exempted a "
|
|
286
|
+
"grep bash never passes it to")
|
|
287
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
288
|
+
matches("FILTER='two words' grep needle payload", limit=None)]:
|
|
289
|
+
problems.append("grep-binary-heuristic: a quoted assignment value hid the command "
|
|
290
|
+
"word from the trigger")
|
|
291
|
+
for argcmd in ("echo grep needle", "command -v grep"):
|
|
292
|
+
if "grep-binary-heuristic" in [h for h, _ in matches(argcmd, limit=None)]:
|
|
293
|
+
problems.append(f"grep-binary-heuristic: fired although grep is an ARGUMENT, "
|
|
294
|
+
f"not the stage's command word ({argcmd!r})")
|
|
295
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
296
|
+
matches("if ! grep -q needle payload; then echo none; fi",
|
|
297
|
+
limit=None)]:
|
|
298
|
+
problems.append("grep-binary-heuristic: reserved words and negation before the "
|
|
299
|
+
"command word suppressed the reminder")
|
|
300
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
301
|
+
matches("/usr/bin/grep needle payload", limit=None)]:
|
|
302
|
+
problems.append("grep-binary-heuristic: a path-prefixed grep is still grep, and "
|
|
303
|
+
"the reminder was suppressed")
|
|
304
|
+
for bundled in ("grep -qa needle payload", "grep -aH needle payload",
|
|
305
|
+
"cat payload | grep -qa needle"):
|
|
306
|
+
if "grep-binary-heuristic" in [h for h, _ in matches(bundled, limit=None)]:
|
|
307
|
+
problems.append(f"grep-binary-heuristic: fired although a bundled short "
|
|
308
|
+
f"option enables text mode ({bundled!r})")
|
|
309
|
+
for consumed in ("grep -ea payload", "grep -fa payload"):
|
|
310
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches(consumed, limit=None)]:
|
|
311
|
+
problems.append(f"grep-binary-heuristic: an 'a' consumed as an option "
|
|
312
|
+
f"ARGUMENT read as text mode ({consumed!r})")
|
|
313
|
+
for grouped in ("(grep needle payload)", "if (grep needle payload); then echo found; fi"):
|
|
314
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches(grouped, limit=None)]:
|
|
315
|
+
problems.append(f"grep-binary-heuristic: a subshell-grouped grep lost its "
|
|
316
|
+
f"reminder to the opening paren ({grouped!r})")
|
|
317
|
+
for qop in ('grep -e "needle" -a file', 'grep -f "patterns.txt" -a file'):
|
|
318
|
+
if "grep-binary-heuristic" in [h for h, _ in matches(qop, limit=None)]:
|
|
319
|
+
problems.append(f"grep-binary-heuristic: fired although -a stands after a "
|
|
320
|
+
f"QUOTED pattern operand — blanking ate the token boundary "
|
|
321
|
+
f"({qop!r})")
|
|
322
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
323
|
+
matches('grep -e "needle" file', limit=None)]:
|
|
324
|
+
problems.append("grep-binary-heuristic: a quoted pattern operand with no "
|
|
325
|
+
"text-mode flag was read as exempt")
|
|
326
|
+
for bf in ("grep --binary-files=text needle payload",
|
|
327
|
+
"grep --binary-files text needle payload"):
|
|
328
|
+
if "grep-binary-heuristic" in [h for h, _ in matches(bf, limit=None)]:
|
|
329
|
+
problems.append(f"grep-binary-heuristic: fired although --binary-files "
|
|
330
|
+
f"selects text handling ({bf!r})")
|
|
331
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
332
|
+
matches("grep --binary-files without-match needle payload",
|
|
333
|
+
limit=None)]:
|
|
334
|
+
problems.append("grep-binary-heuristic: a non-text --binary-files TYPE was "
|
|
335
|
+
"read as text mode")
|
|
336
|
+
if "grep-binary-heuristic" in [h for h, _ in matches("(grep -a needle payload)", limit=None)]:
|
|
337
|
+
problems.append("grep-binary-heuristic: fired although the subshell-grouped "
|
|
338
|
+
"grep carries its own text-mode flag")
|
|
339
|
+
for envcmd in ("LC_ALL=C grep needle payload", "cat payload | LC_ALL=C grep needle"):
|
|
340
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches(envcmd, limit=None)]:
|
|
341
|
+
problems.append(f"grep-binary-heuristic: an env-assignment prefix hid the "
|
|
342
|
+
f"command word ({envcmd!r})")
|
|
343
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
344
|
+
matches('echo "$(grep needle payload)" | grep -a foo', limit=None)]:
|
|
345
|
+
problems.append("grep-binary-heuristic: a grep inside a command substitution lost "
|
|
346
|
+
"its reminder to the outer stage's -a")
|
|
347
|
+
if "grep-binary-heuristic" in [h for h, _ in
|
|
348
|
+
matches('echo "$(grep -a needle payload)" | grep -a foo', limit=None)]:
|
|
349
|
+
problems.append("grep-binary-heuristic: fired although every invocation, nested "
|
|
350
|
+
"included, carries its own text-mode flag")
|
|
351
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep -a foo a & grep bar b", limit=None)]:
|
|
352
|
+
problems.append("grep-binary-heuristic: a background-joined second grep with no "
|
|
353
|
+
"text-mode flag lost its reminder to the first stage's -a")
|
|
354
|
+
if "grep-binary-heuristic" not in [h for h, _ in matches("grep -a foo a\ngrep bar b", limit=None)]:
|
|
355
|
+
problems.append("grep-binary-heuristic: a newline-joined second grep with no "
|
|
356
|
+
"text-mode flag lost its reminder to the first stage's -a")
|
|
357
|
+
if "grep-binary-heuristic" not in [h for h, _ in
|
|
358
|
+
matches('grep "foo \\" -a bar" payload', limit=None)]:
|
|
359
|
+
problems.append("grep-binary-heuristic: an escaped quote inside the pattern closed "
|
|
360
|
+
"the blanking early and the embedded -a read as a flag")
|
|
361
|
+
uncovered = [n for n, _rx, _m, _a in RULES if n not in FIXTURES]
|
|
362
|
+
if uncovered:
|
|
363
|
+
problems.append(f"rules with no fixture, so nothing proves they fire: {uncovered}")
|
|
364
|
+
for name, _rx, _msg, _anchor in RULES:
|
|
365
|
+
cmd = FIXTURES.get(name)
|
|
366
|
+
if cmd is None:
|
|
367
|
+
continue
|
|
368
|
+
if name not in [hit for hit, _ in matches(cmd, limit=None)]:
|
|
369
|
+
problems.append(f"{name}: its own fixture {cmd!r} does not reach it — the rule is inert")
|
|
370
|
+
|
|
371
|
+
# -- half 2: the Codex fallback. SURFACES.md admits a hook rule only with a guide
|
|
372
|
+
# counterpart, because the guide is what the other host receives.
|
|
373
|
+
for tree in ("claude", "codex"):
|
|
374
|
+
g = repo / tree / "guides" / "tooling-gotchas.md"
|
|
375
|
+
if not g.is_file():
|
|
376
|
+
problems.append(f"{tree}/guides/tooling-gotchas.md missing — no fallback to check")
|
|
377
|
+
continue
|
|
378
|
+
body = g.read_text(encoding="utf-8")
|
|
379
|
+
for name, _rx, _msg, anchor in RULES:
|
|
380
|
+
if anchor not in body:
|
|
381
|
+
problems.append(f"{name}: anchor {anchor!r} absent from {tree}/guides/tooling-gotchas.md")
|
|
382
|
+
|
|
383
|
+
for p in problems:
|
|
384
|
+
print(f"self-test [FAIL] {p}")
|
|
385
|
+
if problems:
|
|
386
|
+
print(f"HOOK SELF-TEST FAIL: {len(problems)} problem(s)")
|
|
387
|
+
return 1
|
|
388
|
+
print(f"HOOK SELF-TEST OK: {len(RULES)} rules each fire on their own fixture, stay quiet "
|
|
389
|
+
f"when they should, and each has its guide counterpart on both hosts")
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
|
|
85
393
|
if __name__ == "__main__":
|
|
394
|
+
if "--self-test" in sys.argv[1:]:
|
|
395
|
+
sys.exit(self_test())
|
|
86
396
|
sys.exit(main())
|