rulereceipt 0.1.60 → 0.1.62
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/dist/checks/classify.js +5 -1
- package/dist/checks/selfEditedRules.d.ts +3 -0
- package/dist/checks/selfEditedRules.js +69 -0
- package/dist/cli.js +13 -1
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +69 -10
- package/dist/report/generateReport.d.ts +1 -1
- package/dist/report/generateReport.js +4 -1
- package/package.json +1 -1
package/dist/checks/classify.js
CHANGED
|
@@ -551,7 +551,11 @@ function isEmojiRule(rule) {
|
|
|
551
551
|
* rule, so the check is dogfooded on every session here.
|
|
552
552
|
*/
|
|
553
553
|
const ATTRIBUTION_SUBJECT = /co-?authored-by|generated with\s*\[?\s*claude|\bai\b[^.\n]{0,20}(?:trace|attribution|authorship)|\battribution\b/i;
|
|
554
|
-
|
|
554
|
+
// Plural and verb forms count: "no Co-Authored-By on commits" / "when
|
|
555
|
+
// committing" / "on PRs" are the same rule as the singular. `\bcommit\b` alone
|
|
556
|
+
// missed "commits"/"committing" and left the rule at judgment (KNOWN-GAPS,
|
|
557
|
+
// fixed 2026-09-28).
|
|
558
|
+
const ATTRIBUTION_CONTEXT = /\b(?:commit(?:s|ted|ting|ment|ments)?|git|pull\s+requests?|prs?|github|co-?authors?)\b/i;
|
|
555
559
|
const ATTRIBUTION_FORBID = /\b(?:no|never|don't|do not|without|must not|shall not|not add|zero|forbid)\b/i;
|
|
556
560
|
function isAttributionRule(rule) {
|
|
557
561
|
const text = `${rule.title} ${rule.text}`;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { withoutHeredocs } from "./shellCommand.js";
|
|
2
|
+
/**
|
|
3
|
+
* Did the session EDIT the rules or agent-settings it is being judged by?
|
|
4
|
+
*
|
|
5
|
+
* A verdict is about the rules as they are NOW. If the agent rewrote CLAUDE.md,
|
|
6
|
+
* `.claude/settings.json` or `.rulereceipt/` during the session, the report
|
|
7
|
+
* should say so out loud — "Claude changed CLAUDE.md this session, then passed
|
|
8
|
+
* its own rules" is exactly what a reader needs to know. This is a NOTE, never a
|
|
9
|
+
* FAIL: editing a rules file is not itself a violation, and plenty of legitimate
|
|
10
|
+
* work does it (a session that adds a rule, `init`, a settings tweak). Added
|
|
11
|
+
* 2026-09-28. Reading a rules file (Read / `cat` / `grep`) is not editing and
|
|
12
|
+
* never warns — only a write whose target is a rules/settings file counts.
|
|
13
|
+
*/
|
|
14
|
+
/** A rules or agent-settings file, matched by path suffix. */
|
|
15
|
+
const RULE_FILE = /(?:^|[\\/])(?:CLAUDE(?:\.local)?\.md|AGENTS(?:\.local)?\.md|AGENT\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|copilot-instructions\.md|settings(?:\.local)?\.json)$|(?:^|[\\/])\.claude[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.cursor[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.agents[\\/]rules[\\/][^\\/]+$|(?:^|[\\/])\.rulereceipt[\\/].+$/i;
|
|
16
|
+
function editTargetPath(input) {
|
|
17
|
+
const o = input;
|
|
18
|
+
const p = o?.file_path ?? o?.notebook_path ?? o?.path;
|
|
19
|
+
return typeof p === "string" ? p : "";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Rules files a shell command WRITES to: a `>`/`>>`/`tee` target, a `sed -i`
|
|
23
|
+
* file argument, or a `cp`/`mv`/`install` destination. A rules file that is only
|
|
24
|
+
* read (`cat f`, `grep x f`, `sed -n p f`) is never a write target and does not
|
|
25
|
+
* match — the same read-vs-write distinction the code checks already draw.
|
|
26
|
+
*/
|
|
27
|
+
function shellWriteTargets(command) {
|
|
28
|
+
const cmd = withoutHeredocs(command);
|
|
29
|
+
const hits = new Set();
|
|
30
|
+
// redirect / tee target
|
|
31
|
+
for (const m of cmd.matchAll(/(?:>>?|\btee\s+(?:-a\s+)?)\s*("?)([^\s"'|;&<>()]+)\1/g)) {
|
|
32
|
+
if (RULE_FILE.test(m[2]))
|
|
33
|
+
hits.add(m[2]);
|
|
34
|
+
}
|
|
35
|
+
// sed -i edits its file argument(s) in place
|
|
36
|
+
if (/\bsed\s+-i/.test(cmd)) {
|
|
37
|
+
for (const m of cmd.matchAll(/(\S+)/g))
|
|
38
|
+
if (RULE_FILE.test(m[1]))
|
|
39
|
+
hits.add(m[1]);
|
|
40
|
+
}
|
|
41
|
+
// cp / mv / install: the destination is the last non-option argument
|
|
42
|
+
for (const m of cmd.matchAll(/\b(?:cp|mv|install)\b([^|;&]*)/g)) {
|
|
43
|
+
const args = m[1].trim().split(/\s+/).filter((a) => a && !a.startsWith("-"));
|
|
44
|
+
const dest = args[args.length - 1];
|
|
45
|
+
if (dest && RULE_FILE.test(dest))
|
|
46
|
+
hits.add(dest);
|
|
47
|
+
}
|
|
48
|
+
return [...hits];
|
|
49
|
+
}
|
|
50
|
+
/** Rules/settings files the session wrote to, deduped, in first-seen order. */
|
|
51
|
+
export function detectSelfEditedRuleFiles(events) {
|
|
52
|
+
const found = new Set();
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
if (e.kind !== "tool_use")
|
|
55
|
+
continue;
|
|
56
|
+
if (e.toolName === "Write" || e.toolName === "Edit" || e.toolName === "MultiEdit" || e.toolName === "NotebookEdit") {
|
|
57
|
+
const p = editTargetPath(e.input);
|
|
58
|
+
if (p && RULE_FILE.test(p))
|
|
59
|
+
found.add(p);
|
|
60
|
+
}
|
|
61
|
+
else if (e.toolName === "Bash") {
|
|
62
|
+
const c = e.input?.command;
|
|
63
|
+
if (typeof c === "string")
|
|
64
|
+
for (const f of shellWriteTargets(c))
|
|
65
|
+
found.add(f);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...found];
|
|
69
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { auditSessions, renderComplianceReport } from "./report/complianceReport
|
|
|
15
15
|
import { auditProject, renderProjectAudit } from "./audit.js";
|
|
16
16
|
import { evaluateSession } from "./evaluate.js";
|
|
17
17
|
import { buildWrongReport, findTarget } from "./wrong.js";
|
|
18
|
+
import { detectSelfEditedRuleFiles } from "./checks/selfEditedRules.js";
|
|
18
19
|
import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
|
|
19
20
|
import { runHook } from "./hook.js";
|
|
20
21
|
import { runGuard } from "./guard.js";
|
|
@@ -232,13 +233,24 @@ async function runCheck(opts) {
|
|
|
232
233
|
const blockingFails = blockingFailures(results, projectConfig, handleFor);
|
|
233
234
|
const warnedFails = warningFailures(results, projectConfig, handleFor);
|
|
234
235
|
const meta = { sessionFilePath, ruleCount: results.length };
|
|
236
|
+
// A NOTE, never a verdict: if the session rewrote the rules or settings it is
|
|
237
|
+
// being judged by, say so at the top. "Claude changed CLAUDE.md this session,
|
|
238
|
+
// then passed its own rules" is exactly what a reader needs to know.
|
|
239
|
+
const editedRuleFiles = detectSelfEditedRuleFiles(events);
|
|
240
|
+
const editedNote = editedRuleFiles.length > 0
|
|
241
|
+
? `Note: the agent changed ${editedRuleFiles.length === 1 ? "a rules/settings file" : `${editedRuleFiles.length} rules/settings files`} during this session (${editedRuleFiles
|
|
242
|
+
.map((f) => f.replace(`${cwd}/`, ""))
|
|
243
|
+
.join(", ")}). The verdicts below are against the rules as they are now.`
|
|
244
|
+
: "";
|
|
235
245
|
// Kept in human/markdown form for --email and any other reader below, even
|
|
236
246
|
// when stdout is JSON — a manager gets a readable report, not raw JSON.
|
|
237
247
|
const reportText = markdown ? generateMarkdownReport(results, meta) : generateReport(results, meta);
|
|
238
248
|
if (json) {
|
|
239
|
-
console.log(generateJsonReport(results, meta, pkg.version));
|
|
249
|
+
console.log(generateJsonReport(results, meta, pkg.version, editedRuleFiles));
|
|
240
250
|
}
|
|
241
251
|
else {
|
|
252
|
+
if (editedNote)
|
|
253
|
+
console.log(`${editedNote}\n`);
|
|
242
254
|
console.log(reportText);
|
|
243
255
|
// Name the tool when it is not the default Claude Code, so a Codex run is
|
|
244
256
|
// not silently reported as if it were a Claude session.
|
package/dist/guard.d.ts
CHANGED
|
@@ -73,6 +73,6 @@ export interface GuardDecision {
|
|
|
73
73
|
*/
|
|
74
74
|
export declare function guardDecision(cwd: string, toolName: string, toolInput: {
|
|
75
75
|
command?: unknown;
|
|
76
|
-
} & Record<string, unknown>, events?: TranscriptEvent[]): GuardDecision;
|
|
76
|
+
} & Record<string, unknown>, events?: TranscriptEvent[], permissionMode?: string): GuardDecision;
|
|
77
77
|
export declare function runGuard(): Promise<void>;
|
|
78
78
|
export {};
|
package/dist/guard.js
CHANGED
|
@@ -6,8 +6,39 @@ import { runGitBranchPolicyChecks } from "./checks/gitBranchPolicy.js";
|
|
|
6
6
|
import { runAttributionChecks } from "./checks/attribution.js";
|
|
7
7
|
import { loadOverrides, ruleFingerprint, ratifiedForbids } from "./overrides.js";
|
|
8
8
|
import { commandRunsLiteral } from "./checks/proposedAction.js";
|
|
9
|
-
import { approvalOccurrences } from "./checks/approvalGate.js";
|
|
9
|
+
import { approvalOccurrences, allowListed } from "./checks/approvalGate.js";
|
|
10
10
|
import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
/**
|
|
15
|
+
* Modes where Claude Code shows NO permission prompt, so a hook's "ask" is
|
|
16
|
+
* ignored and the call just runs (Claude Code #89561; "ask" also drops bypass
|
|
17
|
+
* mode, #37420; headless silently denies, #95726). In these the only thing that
|
|
18
|
+
* actually stops an unapproved gated action is a real deny.
|
|
19
|
+
*/
|
|
20
|
+
const NO_PROMPT_MODES = new Set(["bypassPermissions", "auto", "dontAsk"]);
|
|
21
|
+
/**
|
|
22
|
+
* `permissions.deny` from the Claude Code settings that apply here. If the user
|
|
23
|
+
* already denies a command, the guard must NOT answer "ask" for it — an "ask"
|
|
24
|
+
* can switch a deny off and let the command run with no prompt (Claude Code
|
|
25
|
+
* #39344). So a command the user denies is left entirely to Claude Code's own
|
|
26
|
+
* deny; the guard stands aside.
|
|
27
|
+
*/
|
|
28
|
+
function claudeDenyList(cwd) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const p of [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json"), join(homedir(), ".claude", "settings.json")]) {
|
|
31
|
+
try {
|
|
32
|
+
const deny = JSON.parse(readFileSync(p, "utf-8")).permissions?.deny;
|
|
33
|
+
if (Array.isArray(deny))
|
|
34
|
+
out.push(...deny.filter((x) => typeof x === "string"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* absent or unreadable */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
11
42
|
function readStdin() {
|
|
12
43
|
return new Promise((resolve) => {
|
|
13
44
|
let data = "";
|
|
@@ -148,19 +179,20 @@ function reason(blocks) {
|
|
|
148
179
|
* The approval half of the guard. Uses the same per-action logic as the report
|
|
149
180
|
* (approvalOccurrences) so the two can never disagree: the proposed call is
|
|
150
181
|
* appended to the session as if no prompt were possible, and if the report
|
|
151
|
-
* would call it unapproved, the
|
|
182
|
+
* would call it unapproved, this returns the rule and action so the caller can
|
|
183
|
+
* decide — by the current permission mode — whether to ask or to deny.
|
|
152
184
|
*/
|
|
153
|
-
function
|
|
185
|
+
function unapprovedGate(cwd, command, events) {
|
|
154
186
|
const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
|
|
155
187
|
for (const { rule, actions } of gates) {
|
|
156
188
|
const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
|
|
157
189
|
const occ = approvalOccurrences([...events, proposed], actions);
|
|
158
190
|
const last = occ[occ.length - 1];
|
|
159
191
|
if (last && last.command === command.replace(/\s+/g, " ").trim().slice(0, 80) && last.verdict !== "approved") {
|
|
160
|
-
return
|
|
192
|
+
return { rule, action: last.action };
|
|
161
193
|
}
|
|
162
194
|
}
|
|
163
|
-
return
|
|
195
|
+
return null;
|
|
164
196
|
}
|
|
165
197
|
/**
|
|
166
198
|
* The allow/deny decision for one proposed tool call, with no I/O.
|
|
@@ -173,7 +205,7 @@ function approvalAsk(cwd, command, events) {
|
|
|
173
205
|
* thin when it lands. Same reasoning as evaluateSession: one body of code so
|
|
174
206
|
* two callers can never disagree about whether a rule was broken.
|
|
175
207
|
*/
|
|
176
|
-
export function guardDecision(cwd, toolName, toolInput, events = []) {
|
|
208
|
+
export function guardDecision(cwd, toolName, toolInput, events = [], permissionMode) {
|
|
177
209
|
const allow = { deny: false, reason: "", blocks: [] };
|
|
178
210
|
if (loadRules(cwd).length === 0)
|
|
179
211
|
return allow;
|
|
@@ -197,10 +229,37 @@ export function guardDecision(cwd, toolName, toolInput, events = []) {
|
|
|
197
229
|
}
|
|
198
230
|
if (blocks.length > 0)
|
|
199
231
|
return { deny: true, reason: reason(blocks), blocks };
|
|
232
|
+
// Approval gates ("never push/commit without asking"). What we answer depends
|
|
233
|
+
// on the permission mode, because a hook's "ask" is only honoured in the modes
|
|
234
|
+
// that actually show a prompt (Claude Code #89561/#37420/#95726):
|
|
235
|
+
// - the user already DENIES this command -> stand aside (never weaken a
|
|
236
|
+
// deny with an "ask", #39344); Claude Code's own deny handles it.
|
|
237
|
+
// - no-prompt mode (bypass/auto/dontAsk) or headless -> real DENY with a
|
|
238
|
+
// reason, because "ask" is ignored there and would let the push run. The
|
|
239
|
+
// per-action check clears it after the user says yes in chat and it retries.
|
|
240
|
+
// - default/acceptEdits/plan (or unknown) -> "ask": the prompt appears and
|
|
241
|
+
// the user decides. Unknown modes ask rather than deny so we never wrongly
|
|
242
|
+
// block a legitimate action.
|
|
200
243
|
if (toolName === "Bash" && typeof toolInput.command === "string") {
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
|
|
244
|
+
const gate = unapprovedGate(cwd, toolInput.command, events);
|
|
245
|
+
if (gate) {
|
|
246
|
+
if (allowListed(toolInput.command, claudeDenyList(cwd)))
|
|
247
|
+
return allow;
|
|
248
|
+
const title = gate.rule.title.slice(0, 120);
|
|
249
|
+
if (permissionMode && NO_PROMPT_MODES.has(permissionMode)) {
|
|
250
|
+
return {
|
|
251
|
+
deny: true,
|
|
252
|
+
reason: `RuleReceipt: your rule "${title}" needs your OK for this ${gate.action}. Claude Code does not show a prompt in ${permissionMode} mode, so this call is stopped. Ask the user in the chat; after they say yes, run it again.`,
|
|
253
|
+
blocks: [{ rule: gate.rule, why: `${gate.action} with no approval, and no prompt would be shown in ${permissionMode} mode` }],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
deny: false,
|
|
258
|
+
reason: "",
|
|
259
|
+
blocks: [],
|
|
260
|
+
ask: `RuleReceipt: your rule "${title}" needs your OK for this ${gate.action}, and nothing in this session approved it yet.`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
204
263
|
}
|
|
205
264
|
return allow;
|
|
206
265
|
}
|
|
@@ -223,7 +282,7 @@ export async function runGuard() {
|
|
|
223
282
|
/* unreadable: judge the call on its own, which can only ask more, never less */
|
|
224
283
|
}
|
|
225
284
|
}
|
|
226
|
-
const decision = guardDecision(cwd, tool, toolInput, events);
|
|
285
|
+
const decision = guardDecision(cwd, tool, toolInput, events, input.permission_mode);
|
|
227
286
|
if (!decision.deny && decision.ask) {
|
|
228
287
|
// "ask" is not a refusal: no exit 2. Claude Code shows its permission
|
|
229
288
|
// prompt with this reason; the user's click decides.
|
|
@@ -24,4 +24,4 @@ export declare function generateMarkdownReport(results: CheckResult[], meta: Rep
|
|
|
24
24
|
* way as every other output: a hostile CLAUDE.md does not get to smuggle
|
|
25
25
|
* control characters through the JSON either.
|
|
26
26
|
*/
|
|
27
|
-
export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string): string;
|
|
27
|
+
export declare function generateJsonReport(results: CheckResult[], meta: ReportMeta, toolVersion: string, editedRuleFiles?: string[]): string;
|
|
@@ -277,7 +277,7 @@ export function generateMarkdownReport(results, meta) {
|
|
|
277
277
|
* way as every other output: a hostile CLAUDE.md does not get to smuggle
|
|
278
278
|
* control characters through the JSON either.
|
|
279
279
|
*/
|
|
280
|
-
export function generateJsonReport(results, meta, toolVersion) {
|
|
280
|
+
export function generateJsonReport(results, meta, toolVersion, editedRuleFiles = []) {
|
|
281
281
|
const clean = results.map(sanitize);
|
|
282
282
|
const count = (s) => clean.filter((r) => r.status === s).length;
|
|
283
283
|
const report = {
|
|
@@ -289,6 +289,9 @@ export function generateJsonReport(results, meta, toolVersion) {
|
|
|
289
289
|
path: meta.sessionFilePath,
|
|
290
290
|
sha256: computeTranscriptHash(meta.sessionFilePath),
|
|
291
291
|
},
|
|
292
|
+
// Rules/settings files the session itself edited (a NOTE, not a verdict):
|
|
293
|
+
// the verdicts are against the rules as they are now.
|
|
294
|
+
agentEditedRuleFiles: editedRuleFiles,
|
|
292
295
|
summary: {
|
|
293
296
|
total: clean.length,
|
|
294
297
|
pass: count("PASS"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|