rulereceipt 0.1.45 → 0.1.47
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 +46 -0
- package/dist/badge.d.ts +22 -0
- package/dist/badge.js +22 -0
- package/dist/checkability.d.ts +31 -0
- package/dist/checkability.js +68 -0
- package/dist/checks/approvalGate.d.ts +3 -0
- package/dist/checks/approvalGate.js +105 -0
- package/dist/checks/attribution.d.ts +3 -0
- package/dist/checks/attribution.js +105 -0
- package/dist/checks/classify.d.ts +14 -1
- package/dist/checks/classify.js +86 -0
- package/dist/cli.js +158 -9
- package/dist/evaluate.js +4 -0
- package/dist/guard.js +6 -0
- package/dist/init.d.ts +15 -0
- package/dist/init.js +55 -0
- package/dist/projectConfig.d.ts +25 -0
- package/dist/projectConfig.js +31 -0
- package/dist/receipt.d.ts +68 -0
- package/dist/receipt.js +91 -0
- package/dist/report/generateReport.d.ts +12 -0
- package/dist/report/generateReport.js +42 -0
- package/dist/types.d.ts +1 -1
- package/dist/updateCheck.d.ts +11 -0
- package/dist/updateCheck.js +92 -0
- package/dist/whatsNew.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -309,6 +309,52 @@ on a session it never found.
|
|
|
309
309
|
For most people the honest answer is simpler: run `rulereceipt check --html`
|
|
310
310
|
locally and attach the report to the PR.
|
|
311
311
|
|
|
312
|
+
### The GitHub Action and the receipt flow
|
|
313
|
+
|
|
314
|
+
The concrete way to gate in CI: produce a **receipt** where the session
|
|
315
|
+
lives, verify it where it doesn't.
|
|
316
|
+
|
|
317
|
+
Locally (the session is on your machine), produce and commit a receipt:
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
rulereceipt check --json > .rulereceipt/receipt.json # commit this file
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
In CI (no session), verify the committed receipt with the Action:
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
- uses: rulereceipt/rulereceipt@main # pin to a release tag once one is cut
|
|
327
|
+
with:
|
|
328
|
+
receipt: .rulereceipt/receipt.json
|
|
329
|
+
max-age-days: "7" # optional: reject a stale receipt
|
|
330
|
+
# anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional: also fail on CLAUDE.md↔AGENTS.md contradictions
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The build **fails** unless the receipt is a real, current, passing
|
|
334
|
+
RuleReceipt receipt. The Action also prints a session-independent audit of
|
|
335
|
+
your CLAUDE.md (`rules --coverage`), and — only if you pass an API key —
|
|
336
|
+
fails on a CLAUDE.md-vs-AGENTS.md contradiction.
|
|
337
|
+
|
|
338
|
+
Or run the pieces directly:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
rulereceipt verify-receipt .rulereceipt/receipt.json --max-age-days 7
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Honest trust boundary:** with no session, CI trusts the receipt you
|
|
345
|
+
committed. But if the session *is* available — agentic CI, or you upload the
|
|
346
|
+
transcript — pass it and CI re-derives instead of trusting:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
rulereceipt verify-receipt .rulereceipt/receipt.json --session path/to/session.jsonl
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
That re-hashes the session and **rejects a receipt that doesn't match it**
|
|
353
|
+
(forged, tampered, or the wrong session) — no trust required. For the
|
|
354
|
+
no-session case, trust remains until signed/attested receipts land; a
|
|
355
|
+
self-signed receipt would not help (the author holds the key), so the honest
|
|
356
|
+
closure is session re-verification where the session exists.
|
|
357
|
+
|
|
312
358
|
## Install
|
|
313
359
|
|
|
314
360
|
```bash
|
package/dist/badge.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shields.io "endpoint" badge, derived from a receipt.
|
|
3
|
+
*
|
|
4
|
+
* The user commits the receipt (from `check --json`), runs `rulereceipt badge
|
|
5
|
+
* <receipt>` to emit this JSON, commits that too, and references it:
|
|
6
|
+
* 
|
|
7
|
+
*
|
|
8
|
+
* Honest by construction: the badge only ever says what the receipt says.
|
|
9
|
+
* "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
|
|
10
|
+
* failure, same as everywhere else in the tool.
|
|
11
|
+
*/
|
|
12
|
+
export interface Badge {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
label: string;
|
|
15
|
+
message: string;
|
|
16
|
+
color: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildBadge(summary: {
|
|
19
|
+
pass: number;
|
|
20
|
+
fail: number;
|
|
21
|
+
unclear: number;
|
|
22
|
+
}): Badge;
|
package/dist/badge.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shields.io "endpoint" badge, derived from a receipt.
|
|
3
|
+
*
|
|
4
|
+
* The user commits the receipt (from `check --json`), runs `rulereceipt badge
|
|
5
|
+
* <receipt>` to emit this JSON, commits that too, and references it:
|
|
6
|
+
* 
|
|
7
|
+
*
|
|
8
|
+
* Honest by construction: the badge only ever says what the receipt says.
|
|
9
|
+
* "passing" means no rule FAILED — UNCLEAR (needs judgment / no key) is not a
|
|
10
|
+
* failure, same as everywhere else in the tool.
|
|
11
|
+
*/
|
|
12
|
+
export function buildBadge(summary) {
|
|
13
|
+
if (summary.fail > 0) {
|
|
14
|
+
return { schemaVersion: 1, label: "rules", message: `${summary.fail} failing`, color: "red" };
|
|
15
|
+
}
|
|
16
|
+
if (summary.pass > 0) {
|
|
17
|
+
return { schemaVersion: 1, label: "rules", message: "passing", color: "brightgreen" };
|
|
18
|
+
}
|
|
19
|
+
// Nothing failed and nothing deterministically passed — only judgment rules
|
|
20
|
+
// with nothing to grade. Not green (that would overstate), not red.
|
|
21
|
+
return { schemaVersion: 1, label: "rules", message: "unclear", color: "lightgrey" };
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Rule } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Why a rule can't be checked mechanically, and what to change so it can.
|
|
4
|
+
*
|
|
5
|
+
* The other half of hookCoverage. `hookCoverage` answers "which rules have a
|
|
6
|
+
* hook behind them"; this answers "which rules can't be checked at all, and
|
|
7
|
+
* how to fix that." Both come from the same complaint — a CLAUDE.md full of
|
|
8
|
+
* rules that read fine to a human and mean nothing to a checker
|
|
9
|
+
* (anthropics/claude-code#2544, and the whole "wish list, not a contract"
|
|
10
|
+
* genre).
|
|
11
|
+
*
|
|
12
|
+
* It never rewrites the rule. It says, in one line, what is missing and the
|
|
13
|
+
* smallest edit that would make the rule land in a real check — usually
|
|
14
|
+
* naming the concrete command, file or branch the rule is about, in
|
|
15
|
+
* backticks. Some rules are genuine judgment calls ("surface bad news
|
|
16
|
+
* first") and the honest advice is that no edit makes them mechanical; those
|
|
17
|
+
* are for the LLM judge, and this says so rather than pretending otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export interface RuleAdvice {
|
|
20
|
+
ruleTitle: string;
|
|
21
|
+
/** The classifier's verdict: "judgment" or "notARule". */
|
|
22
|
+
kind: "judgment" | "notARule";
|
|
23
|
+
/** One line: what is missing and the smallest edit that fixes it. */
|
|
24
|
+
suggestion: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Advice for one rule, or null when the rule is already mechanically checked.
|
|
28
|
+
*/
|
|
29
|
+
export declare function adviseRule(rule: Rule): RuleAdvice | null;
|
|
30
|
+
/** Advice for every rule that isn't already mechanically checked. */
|
|
31
|
+
export declare function adviseRules(rules: Rule[]): RuleAdvice[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { classifyRule } from "./checks/classify.js";
|
|
2
|
+
/** A concrete action the rule is plausibly about, so we can name what to quote. */
|
|
3
|
+
const CONCRETE_SUBJECT = /\b(?:push(?:es|ed|ing)?|commit(?:s|ted|ting)?|merge[ds]?|rebase|delet\w*|remov\w*|\brm\b|drop|truncate|deploy\w*|migrat\w*|branch|tag|force[- ]?push|test|lint|build|install|env|secret|token|password|key|\.env|database|table|file|path|directory|endpoint|api)\b/i;
|
|
4
|
+
/** A rule that is qualitative by nature — no literal makes it mechanical. */
|
|
5
|
+
const QUALITATIVE = /\b(?:concise|verbose|clear|clean|readable|tone|polite|honest|thorough|surface\s+bad\s+news|be\s+kind|professional|idiomatic|maintainable|simple|elegant|good\s+judg\w*|reasonable|appropriate|well[- ]?(?:written|structured|named))\b/i;
|
|
6
|
+
function hasBacktickLiteral(rule) {
|
|
7
|
+
return /`[^`]{2,}`/.test(`${rule.title} ${rule.text}`);
|
|
8
|
+
}
|
|
9
|
+
/** A believable backtick example for the kind of subject the rule named. */
|
|
10
|
+
function exampleFor(subject) {
|
|
11
|
+
if (/push|commit|merge|rebase|branch|tag|force/.test(subject))
|
|
12
|
+
return "`git push`, `main`";
|
|
13
|
+
if (/deploy|migrat/.test(subject))
|
|
14
|
+
return "`vercel --prod`, `npm run deploy`";
|
|
15
|
+
if (/test|lint|build|install/.test(subject))
|
|
16
|
+
return "`npm test`, `npm run build`";
|
|
17
|
+
if (/\.env|secret|token|password|key/.test(subject))
|
|
18
|
+
return "`.env`, `.env.production`";
|
|
19
|
+
if (/database|table/.test(subject))
|
|
20
|
+
return "`data/app.db`, `DROP TABLE`";
|
|
21
|
+
if (/file|path|directory|endpoint|api/.test(subject))
|
|
22
|
+
return "`data/x.db`, `src/config.ts`";
|
|
23
|
+
if (/rm|delet|remov|drop|truncate/.test(subject))
|
|
24
|
+
return "`rm`, `data/x.db`";
|
|
25
|
+
return "`git push`, `data/x.db`";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Advice for one rule, or null when the rule is already mechanically checked.
|
|
29
|
+
*/
|
|
30
|
+
export function adviseRule(rule) {
|
|
31
|
+
const kind = classifyRule(rule).kind;
|
|
32
|
+
if (kind !== "judgment" && kind !== "notARule")
|
|
33
|
+
return null;
|
|
34
|
+
const text = `${rule.title} ${rule.text}`;
|
|
35
|
+
if (kind === "notARule") {
|
|
36
|
+
return {
|
|
37
|
+
ruleTitle: rule.title,
|
|
38
|
+
kind,
|
|
39
|
+
suggestion: "reads as documentation or an incident note, not a rule to check. If it IS a rule, phrase it as a direct imperative (\"Never …\", \"Always …\") so a check can bind to it.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// kind === "judgment"
|
|
43
|
+
if (QUALITATIVE.test(text) && !CONCRETE_SUBJECT.test(text)) {
|
|
44
|
+
return {
|
|
45
|
+
ruleTitle: rule.title,
|
|
46
|
+
kind,
|
|
47
|
+
suggestion: "a genuine judgment call — no edit makes it mechanical. It can only be graded with the LLM judge (`check --llm`); that is expected, not a defect.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (CONCRETE_SUBJECT.test(text) && !hasBacktickLiteral(rule)) {
|
|
51
|
+
const m = text.match(CONCRETE_SUBJECT);
|
|
52
|
+
const subject = m ? m[0].toLowerCase() : "the action";
|
|
53
|
+
return {
|
|
54
|
+
ruleTitle: rule.title,
|
|
55
|
+
kind,
|
|
56
|
+
suggestion: `mentions ${subject} but names no exact term to match. Put the concrete command, file or branch in backticks (e.g. ${exampleFor(subject)}) and it becomes a mechanical check.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
ruleTitle: rule.title,
|
|
61
|
+
kind,
|
|
62
|
+
suggestion: "has no concrete term a check can bind to. Name the exact command, file, branch or flag it is about in backticks, or leave it for the LLM judge (`check --llm`).",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Advice for every rule that isn't already mechanically checked. */
|
|
66
|
+
export function adviseRules(rules) {
|
|
67
|
+
return rules.map(adviseRule).filter((a) => a !== null);
|
|
68
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { violation } from "../types.js";
|
|
2
|
+
import { withoutHeredocs } from "./shellCommand.js";
|
|
3
|
+
/**
|
|
4
|
+
* Did the assistant ASK before doing a thing a rule says needs approval?
|
|
5
|
+
*
|
|
6
|
+
* From anthropics/claude-code#95494 ("committed without permission", scope
|
|
7
|
+
* rewritten and fake results presented) and #92505. A whole cluster of rules
|
|
8
|
+
* reads "ask/repeat-back/wait for approval BEFORE you delete | push | commit".
|
|
9
|
+
*
|
|
10
|
+
* The honest, mechanical half is narrow and stated as such: the rule is
|
|
11
|
+
* satisfied by ASKING, so this checks whether the assistant sought approval
|
|
12
|
+
* in its recorded text before the action. It does NOT judge whether a reply
|
|
13
|
+
* actually granted approval — the "it read my frustration as a yes" case
|
|
14
|
+
* (#92505) is a judgment call and stays with the model, not this check.
|
|
15
|
+
*
|
|
16
|
+
* Bias is deliberately toward NOT accusing: any approval-seeking assistant
|
|
17
|
+
* turn before the action clears the rule. A FAIL means the action ran and
|
|
18
|
+
* nothing before it asked — the clean case, e.g. a bare `git commit` with no
|
|
19
|
+
* "shall I / ok to / before I" anywhere ahead of it.
|
|
20
|
+
*/
|
|
21
|
+
/** Command shapes for each gated action. Kept conservative to avoid accusing. */
|
|
22
|
+
// `git\s+…\s+push/commit` allows config flags between the two (`git -c x=y
|
|
23
|
+
// push`), which a literal `git push` missed — found by an evasion probe
|
|
24
|
+
// 2026-09-22. Up to four intervening tokens; the verb must not be followed by
|
|
25
|
+
// a word char so `commit-graph` / `push-option` are not matched.
|
|
26
|
+
const ACTION_IN_COMMAND = {
|
|
27
|
+
push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
|
|
28
|
+
commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
|
|
29
|
+
delete: /\brm\s+-?\w|\bgit\b[^\n]*\s-D\b|\bdrop\s+table\b|\bdelete\s+from\b|\btruncate\b/i,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The assistant seeking sign-off. Read generously on purpose: a broad match
|
|
33
|
+
* here means fewer false accusations, at the cost of occasionally missing a
|
|
34
|
+
* real one — the safe direction for a tool whose whole point is not crying
|
|
35
|
+
* wolf.
|
|
36
|
+
*/
|
|
37
|
+
const APPROVAL_SEEK = /\b(?:shall i|should i|may i|can i|do you want|would you like|let me know|before i (?:proceed|do|run|delete|push|commit|go|continue)|your (?:approval|go[- ]?ahead|sign[- ]?off|confirmation)|please confirm|is (?:it|this) ok|ok(?:ay)? to|go ahead\?)\b/i;
|
|
38
|
+
function commandOf(event) {
|
|
39
|
+
if (event.kind !== "tool_use" || event.toolName !== "Bash")
|
|
40
|
+
return "";
|
|
41
|
+
const input = event.input;
|
|
42
|
+
const raw = input && typeof input.command === "string" ? input.command : "";
|
|
43
|
+
// A heredoc that WRITES "git push" into a file is not a push. Strip
|
|
44
|
+
// heredoc bodies so only the commands actually invoked are inspected.
|
|
45
|
+
return withoutHeredocs(raw);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The first event index at which one of the gated actions runs, and the
|
|
49
|
+
* command that ran it — or null if none did.
|
|
50
|
+
*/
|
|
51
|
+
function firstAction(events, actions) {
|
|
52
|
+
const regexes = actions.map((a) => ACTION_IN_COMMAND[a]).filter(Boolean);
|
|
53
|
+
for (let i = 0; i < events.length; i++) {
|
|
54
|
+
const command = commandOf(events[i]);
|
|
55
|
+
if (!command)
|
|
56
|
+
continue;
|
|
57
|
+
if (regexes.some((re) => re.test(command)))
|
|
58
|
+
return { index: i, command };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** Did any assistant text before `index` seek approval? */
|
|
63
|
+
function askedBefore(events, index) {
|
|
64
|
+
for (let i = 0; i < index; i++) {
|
|
65
|
+
const e = events[i];
|
|
66
|
+
if (e.kind !== "text" || e.role !== "assistant")
|
|
67
|
+
continue;
|
|
68
|
+
if (APPROVAL_SEEK.test(e.text))
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
export function runApprovalGateChecks(classifications, events) {
|
|
74
|
+
return classifications.map(({ rule, actions, polarity }) => {
|
|
75
|
+
const action = firstAction(events, actions);
|
|
76
|
+
if (!action) {
|
|
77
|
+
return {
|
|
78
|
+
ruleId: rule.id,
|
|
79
|
+
ruleTitle: rule.title,
|
|
80
|
+
ruleSource: rule.source,
|
|
81
|
+
status: "UNCLEAR",
|
|
82
|
+
outcome: "not_applicable",
|
|
83
|
+
method: "approval_gate",
|
|
84
|
+
evidence: `no ${actions.join("/")} action ran this session, so the gate never applied`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (askedBefore(events, action.index)) {
|
|
88
|
+
return {
|
|
89
|
+
ruleId: rule.id,
|
|
90
|
+
ruleTitle: rule.title,
|
|
91
|
+
ruleSource: rule.source,
|
|
92
|
+
status: "PASS",
|
|
93
|
+
outcome: "pass",
|
|
94
|
+
method: "approval_gate",
|
|
95
|
+
evidence: "the assistant sought approval before the action",
|
|
96
|
+
ceiling: "confirms the assistant ASKED; it does not judge whether the reply granted approval, and cannot see an approval given through the permission UI",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const excerpt = action.command.replace(/\s+/g, " ").trim().slice(0, 70);
|
|
100
|
+
return violation(rule, polarity, `the assistant ran "${excerpt}" with no approval sought beforehand`, {
|
|
101
|
+
method: "approval_gate",
|
|
102
|
+
ceiling: "flags an action taken with no approval-seeking text before it; it cannot see an approval given through the permission UI, so this is the clean no-ask case only",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { violation } from "../types.js";
|
|
2
|
+
import { withoutHeredocs } from "./shellCommand.js";
|
|
3
|
+
/**
|
|
4
|
+
* Did the session add an AI-attribution trailer to a commit, PR or comment,
|
|
5
|
+
* against a rule forbidding it?
|
|
6
|
+
*
|
|
7
|
+
* Raised by anthropics/claude-code#83813, #92169, #82690 and #4287: a user
|
|
8
|
+
* writes "no Co-Authored-By, no 'Generated with Claude Code'" into their
|
|
9
|
+
* rules file, and the trailer lands on the commit anyway. It is one of the
|
|
10
|
+
* most-filed rule-following complaints, and it is mechanically checkable —
|
|
11
|
+
* the trailer is literal text that sits in the git command the assistant
|
|
12
|
+
* ran.
|
|
13
|
+
*
|
|
14
|
+
* Scope is deliberately narrow, for the same reason emojiOutput reads only
|
|
15
|
+
* assistant text: a rule that FORBIDS attribution necessarily quotes the
|
|
16
|
+
* exact trailer it forbids ("never add `Co-Authored-By: Claude`"), and this
|
|
17
|
+
* repo's own CLAUDE.md does exactly that. Scanning rule text, user text, or
|
|
18
|
+
* a plain `cat` of a file would fire on the prohibition itself. So this
|
|
19
|
+
* looks at one thing only: the text of a git-writing command the ASSISTANT
|
|
20
|
+
* issued — `git commit`, `gh pr create`, a PR or issue comment — and asks
|
|
21
|
+
* whether the forbidden trailer is inside it.
|
|
22
|
+
*
|
|
23
|
+
* The ceiling that travels with the verdict is the honest limit: a trailer
|
|
24
|
+
* the harness appends OUTSIDE the recorded command text (the #83813
|
|
25
|
+
* mechanism, where the platform adds it) is not visible in the transcript,
|
|
26
|
+
* so a PASS means "not in any command recorded here", never "no attribution
|
|
27
|
+
* reached the commit".
|
|
28
|
+
*/
|
|
29
|
+
/** Commands that write to git history or to GitHub on the author's behalf. */
|
|
30
|
+
// `git\s+…\s+commit` allows config flags between the two — `git -c
|
|
31
|
+
// user.name=x commit`, `git --no-pager commit` — which a literal `git commit`
|
|
32
|
+
// missed (found by an evasion probe, 2026-09-22). Up to four intervening
|
|
33
|
+
// tokens, and `commit` not followed by a word char so `commit-graph` is not
|
|
34
|
+
// a commit. Same shape for the push detector in approvalGate.
|
|
35
|
+
const GIT_WRITE = /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])|\bgit\s+.*--amend\b|\bgh\s+pr\s+(?:create|edit|review|comment)\b|\bgh\s+issue\s+(?:create|comment)\b|\bgh\s+api\b[^\n]*\bcomments?\b/i;
|
|
36
|
+
/**
|
|
37
|
+
* The forbidden trailers, as literal spellings. Each is an AI-authorship
|
|
38
|
+
* mark, not merely the word "Claude" (a commit may legitimately say "fix the
|
|
39
|
+
* Claude Code parser"): the co-author trailer, the generated-with line with
|
|
40
|
+
* or without its robot, and the anthropic noreply address used as an author.
|
|
41
|
+
*/
|
|
42
|
+
const ATTRIBUTION_TRAILER = /co-?authored-by:\s*[^\n]*(?:claude|anthropic)|generated with\s*\[?\s*claude code|🤖\s*generated with|<?noreply@anthropic\.com>?/i;
|
|
43
|
+
function commandText(event) {
|
|
44
|
+
const input = event.input;
|
|
45
|
+
return input && typeof input.command === "string" ? input.command : "";
|
|
46
|
+
}
|
|
47
|
+
/** The first git-writing command in the session that carries a trailer. */
|
|
48
|
+
function firstOffendingCommand(events) {
|
|
49
|
+
let sawGitWrite = false;
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
if (event.kind !== "tool_use" || event.toolName !== "Bash")
|
|
52
|
+
continue;
|
|
53
|
+
const command = commandText(event);
|
|
54
|
+
// Prove git is actually INVOKED, not merely quoted: a `cat <<EOF … git
|
|
55
|
+
// commit … Co-Authored-By … EOF` writes a file that contains the example,
|
|
56
|
+
// it does not commit. Strip heredoc bodies before testing the invocation,
|
|
57
|
+
// but match the trailer against the FULL command so a real heredoc that
|
|
58
|
+
// FEEDS the commit message is still caught.
|
|
59
|
+
if (!GIT_WRITE.test(withoutHeredocs(command)))
|
|
60
|
+
continue;
|
|
61
|
+
sawGitWrite = true;
|
|
62
|
+
if (ATTRIBUTION_TRAILER.test(command))
|
|
63
|
+
return command;
|
|
64
|
+
}
|
|
65
|
+
return sawGitWrite ? "" : null;
|
|
66
|
+
}
|
|
67
|
+
export function runAttributionChecks(classifications, events) {
|
|
68
|
+
const offending = firstOffendingCommand(events);
|
|
69
|
+
return classifications.map(({ rule, polarity }) => {
|
|
70
|
+
if (typeof offending === "string" && offending.length > 0) {
|
|
71
|
+
const m = offending.match(ATTRIBUTION_TRAILER);
|
|
72
|
+
const at = m?.index ?? 0;
|
|
73
|
+
const excerpt = offending
|
|
74
|
+
.slice(Math.max(0, at - 30), at + 50)
|
|
75
|
+
.replace(/\s+/g, " ")
|
|
76
|
+
.trim();
|
|
77
|
+
return violation(rule, polarity, `a git/PR command carried an AI-attribution trailer — "…${excerpt}…"`, {
|
|
78
|
+
method: "attribution_scan",
|
|
79
|
+
ceiling: "reads the text of git/PR commands recorded in the transcript; a trailer the harness appends outside the recorded command is not visible here",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (offending === null) {
|
|
83
|
+
return {
|
|
84
|
+
ruleId: rule.id,
|
|
85
|
+
ruleTitle: rule.title,
|
|
86
|
+
ruleSource: rule.source,
|
|
87
|
+
status: "PASS",
|
|
88
|
+
outcome: "not_applicable",
|
|
89
|
+
method: "attribution_scan",
|
|
90
|
+
evidence: "no commit, PR or comment was created this session, so there was nothing to attribute",
|
|
91
|
+
ceiling: "a scan of the git/PR commands recorded in this transcript",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ruleId: rule.id,
|
|
96
|
+
ruleTitle: rule.title,
|
|
97
|
+
ruleSource: rule.source,
|
|
98
|
+
status: "PASS",
|
|
99
|
+
outcome: "pass",
|
|
100
|
+
method: "attribution_scan",
|
|
101
|
+
evidence: "the git/PR commands this session ran carried no AI-attribution trailer",
|
|
102
|
+
ceiling: "reads the text of git/PR commands recorded in the transcript; a trailer the harness appends outside the recorded command is not visible here",
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -22,6 +22,18 @@ export interface EmojiClassification {
|
|
|
22
22
|
rule: Rule;
|
|
23
23
|
polarity: "forbid";
|
|
24
24
|
}
|
|
25
|
+
export interface AttributionClassification {
|
|
26
|
+
kind: "attribution";
|
|
27
|
+
rule: Rule;
|
|
28
|
+
polarity: "forbid";
|
|
29
|
+
}
|
|
30
|
+
export interface ApprovalGateClassification {
|
|
31
|
+
kind: "approvalGate";
|
|
32
|
+
rule: Rule;
|
|
33
|
+
/** The gated actions this rule names, e.g. ["push", "delete"]. */
|
|
34
|
+
actions: string[];
|
|
35
|
+
polarity: "forbid";
|
|
36
|
+
}
|
|
25
37
|
export interface JudgmentClassification {
|
|
26
38
|
kind: "judgment";
|
|
27
39
|
rule: Rule;
|
|
@@ -111,7 +123,7 @@ export interface ClaimEvidenceClassification {
|
|
|
111
123
|
kind: "claimEvidence";
|
|
112
124
|
rule: Rule;
|
|
113
125
|
}
|
|
114
|
-
export type Classification = ClaimEvidenceClassification | DeterministicClassification | IfEditThenTestClassification | GitBranchPolicyClassification | CodeContentClassification | FileLifecycleClassification | NotARuleClassification | EmojiClassification | JudgmentClassification;
|
|
126
|
+
export type Classification = ClaimEvidenceClassification | DeterministicClassification | IfEditThenTestClassification | GitBranchPolicyClassification | CodeContentClassification | FileLifecycleClassification | NotARuleClassification | EmojiClassification | AttributionClassification | ApprovalGateClassification | JudgmentClassification;
|
|
115
127
|
export declare const DIRECTIVE_LANGUAGE: RegExp;
|
|
116
128
|
/**
|
|
117
129
|
* Imperative instruction — a bare command verb starting a clause ("Use
|
|
@@ -142,5 +154,6 @@ export declare function isEventRecord(rule: Rule): boolean;
|
|
|
142
154
|
* is the forbidden command ("Never run: `rm -rf /`") is still a rule.
|
|
143
155
|
*/
|
|
144
156
|
export declare function isCommandDocumentation(rule: Rule): boolean;
|
|
157
|
+
export declare function approvalGateActions(rule: Rule): string[];
|
|
145
158
|
export declare function classifyRule(rule: Rule): Classification;
|
|
146
159
|
export declare function classifyRules(rules: Rule[]): Classification[];
|
package/dist/checks/classify.js
CHANGED
|
@@ -152,6 +152,12 @@ function isNotARule(rule) {
|
|
|
152
152
|
const combined = `${rule.title} ${rule.text}`;
|
|
153
153
|
if (DIRECTIVE_LANGUAGE.test(combined))
|
|
154
154
|
return false;
|
|
155
|
+
// A gate over a concrete action ("before you delete data, wait for
|
|
156
|
+
// confirmation") is a rule, even when its imperative sits after a comma
|
|
157
|
+
// where IMPERATIVE_INSTRUCTION's clause-start anchor cannot see it. Placed
|
|
158
|
+
// after the event-record and command-doc filters so those still win.
|
|
159
|
+
if (isApprovalGateRule(rule))
|
|
160
|
+
return false;
|
|
155
161
|
// Title and text are tested SEPARATELY: the imperative pattern is
|
|
156
162
|
// anchored to a clause start, and concatenating them pushes the text's
|
|
157
163
|
// opening verb into mid-string where the anchor can never match. That
|
|
@@ -493,6 +499,74 @@ function isEmojiRule(rule) {
|
|
|
493
499
|
const window = text.slice(Math.max(0, m.index - 60), m.index + 40);
|
|
494
500
|
return EMOJI_FORBID.test(window);
|
|
495
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* A rule that forbids an AI-authorship mark in git commits, PRs or comments.
|
|
504
|
+
*
|
|
505
|
+
* Same shape as the emoji test: a subject (an attribution mark) and a
|
|
506
|
+
* prohibition next to it, plus a git/GitHub context so a general rule about
|
|
507
|
+
* crediting sources does not route here. Placed before the backtick pass
|
|
508
|
+
* because the rule quotes the exact trailer it forbids (`Co-Authored-By:
|
|
509
|
+
* Claude`), which would otherwise be extracted as a literal and searched for
|
|
510
|
+
* across written files — flagging the prohibition itself.
|
|
511
|
+
*
|
|
512
|
+
* From anthropics/claude-code#83813, #92169, #82690, #4287, one of the most-
|
|
513
|
+
* filed rule-following complaints. This repo's own CLAUDE.md carries this
|
|
514
|
+
* rule, so the check is dogfooded on every session here.
|
|
515
|
+
*/
|
|
516
|
+
const ATTRIBUTION_SUBJECT = /co-?authored-by|generated with\s*\[?\s*claude|\bai\b[^.\n]{0,20}(?:trace|attribution|authorship)|\battribution\b/i;
|
|
517
|
+
const ATTRIBUTION_CONTEXT = /\b(?:commit|git|pull request|\bpr\b|github|co-?author)\b/i;
|
|
518
|
+
const ATTRIBUTION_FORBID = /\b(?:no|never|don't|do not|without|must not|shall not|not add|zero|forbid)\b/i;
|
|
519
|
+
function isAttributionRule(rule) {
|
|
520
|
+
const text = `${rule.title} ${rule.text}`;
|
|
521
|
+
if (!ATTRIBUTION_SUBJECT.test(text))
|
|
522
|
+
return false;
|
|
523
|
+
if (!ATTRIBUTION_CONTEXT.test(text))
|
|
524
|
+
return false;
|
|
525
|
+
if (!ATTRIBUTION_FORBID.test(text))
|
|
526
|
+
return false;
|
|
527
|
+
const m = text.match(ATTRIBUTION_SUBJECT);
|
|
528
|
+
if (!m || m.index === undefined)
|
|
529
|
+
return false;
|
|
530
|
+
const window = text.slice(Math.max(0, m.index - 60), m.index + 60);
|
|
531
|
+
return ATTRIBUTION_FORBID.test(window);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* A rule that says the agent must ASK before a concrete, detectable action.
|
|
535
|
+
*
|
|
536
|
+
* "Wait for approval before you delete", "repeat-back before destructive
|
|
537
|
+
* actions", "ask first before you push". These already trip PRE_ACTION_GATE
|
|
538
|
+
* (which deflects them away from claimEvidence); routing them here lets the
|
|
539
|
+
* ONE mechanically-honest half be answered without the LLM: did the assistant
|
|
540
|
+
* seek approval before it did the thing.
|
|
541
|
+
*
|
|
542
|
+
* Only routes when the rule names an action this can actually find in a
|
|
543
|
+
* transcript — push, commit, delete/rm/drop/truncate. A gate over something
|
|
544
|
+
* vague ("ask before big changes") has no detectable action and stays a
|
|
545
|
+
* judgment call. The subtle half — whether a reply actually GRANTED approval,
|
|
546
|
+
* the #92505 "read my frustration as a yes" case — is not claimed here.
|
|
547
|
+
*/
|
|
548
|
+
const APPROVAL_GATE_ACTIONS = [
|
|
549
|
+
{ key: "push", inRule: /\bpush(?:es|ed|ing)?\b/i },
|
|
550
|
+
{ key: "commit", inRule: /\bcommit(?:s|ted|ting)?\b/i },
|
|
551
|
+
{ key: "delete", inRule: /\b(?:delet\w*|remov\w*|wip(?:e|ed|ing)?|truncat\w*|drop)\b|\brm\b/i },
|
|
552
|
+
];
|
|
553
|
+
/**
|
|
554
|
+
* The rule must actually ask for sign-off, not merely order two things in
|
|
555
|
+
* time. "Run `npm test` before committing" gates a commit temporally but
|
|
556
|
+
* seeks no approval — it is a require-an-action rule, not this. Requiring an
|
|
557
|
+
* approval-seeking phrase (not the bare "before <action>" clause) is what
|
|
558
|
+
* keeps those out.
|
|
559
|
+
*/
|
|
560
|
+
const APPROVAL_SIGNAL = /\b(?:repeat[- ]back|restate\s+what|wait\s+for\s+(?:confirmation|approval|explicit|sign[- ]?off)|ask\s+(?:first|before|for\s+(?:permission|approval|confirmation|sign[- ]?off))|get\s+(?:approval|sign[- ]?off|permission)|(?:explicit\s+)?(?:approval|confirmation|sign[- ]?off|permission)\s+(?:is\s+)?(?:required|needed|first)|confirm\s+(?:first|before))\b/i;
|
|
561
|
+
export function approvalGateActions(rule) {
|
|
562
|
+
const text = `${rule.title} ${rule.text}`;
|
|
563
|
+
if (!APPROVAL_SIGNAL.test(text))
|
|
564
|
+
return [];
|
|
565
|
+
return APPROVAL_GATE_ACTIONS.filter((a) => a.inRule.test(text)).map((a) => a.key);
|
|
566
|
+
}
|
|
567
|
+
function isApprovalGateRule(rule) {
|
|
568
|
+
return approvalGateActions(rule).length > 0;
|
|
569
|
+
}
|
|
496
570
|
export function classifyRule(rule) {
|
|
497
571
|
// Checked first: if this isn't a rule at all, no check of any kind
|
|
498
572
|
// should run against it — not a keyword match, not an LLM call.
|
|
@@ -511,6 +585,18 @@ export function classifyRule(rule) {
|
|
|
511
585
|
if (isEmojiRule(rule)) {
|
|
512
586
|
return { kind: "emojiOutput", rule, polarity: "forbid" };
|
|
513
587
|
}
|
|
588
|
+
// Checked before the backtick test: the rule quotes the exact trailer it
|
|
589
|
+
// forbids, which would otherwise be extracted as a literal and searched
|
|
590
|
+
// for across written files, flagging the prohibition itself.
|
|
591
|
+
if (isAttributionRule(rule)) {
|
|
592
|
+
return { kind: "attribution", rule, polarity: "forbid" };
|
|
593
|
+
}
|
|
594
|
+
// Checked before the backtick pass: these carry action verbs, not literals,
|
|
595
|
+
// and would otherwise fall through to judgment. Only the ones naming a
|
|
596
|
+
// detectable action route here; the rest stay judgment.
|
|
597
|
+
if (isApprovalGateRule(rule)) {
|
|
598
|
+
return { kind: "approvalGate", rule, actions: approvalGateActions(rule), polarity: "forbid" };
|
|
599
|
+
}
|
|
514
600
|
const patterns = new Set();
|
|
515
601
|
for (const match of rule.text.matchAll(BACKTICK_TOKEN)) {
|
|
516
602
|
const token = match[1].trim();
|