rulereceipt 0.1.72 → 0.1.73
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/approvalGate.d.ts +14 -0
- package/dist/checks/approvalGate.js +63 -5
- package/dist/checks/shellCommand.js +4 -1
- package/dist/cli.js +26 -4
- package/dist/guard.js +6 -3
- package/dist/historyReport.js +16 -1
- package/package.json +1 -1
|
@@ -27,11 +27,25 @@ import type { CheckResult, TranscriptEvent } from "../types.js";
|
|
|
27
27
|
* permission UI, which the transcript never records.
|
|
28
28
|
*/
|
|
29
29
|
type Action = "push" | "commit" | "pr" | "delete";
|
|
30
|
+
/** The canonical short form of a command, for matching a proposed call to its occurrence. */
|
|
31
|
+
export declare function approvalCommandShort(command: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* The branch a "push/commit … to <branch>" approval rule is scoped to, if any.
|
|
34
|
+
* "Never push to main without asking" is about MAIN — it must not gate a push to
|
|
35
|
+
* a feature branch. Found by a real test 2026-09-29: the guard asked on every
|
|
36
|
+
* push. Returns undefined for a generic "never push without asking" (all pushes).
|
|
37
|
+
*/
|
|
38
|
+
export declare function approvalScopedBranch(rule: {
|
|
39
|
+
title: string;
|
|
40
|
+
text: string;
|
|
41
|
+
}): string | undefined;
|
|
30
42
|
/** `Bash(git push:*)`, `Bash(git:*)`, `Bash` style allow entries. */
|
|
31
43
|
export declare function allowListed(command: string, allow: string[]): boolean;
|
|
32
44
|
export interface ApprovalOptions {
|
|
33
45
|
/** `permissions.allow` entries from the project's and user's Claude Code settings. */
|
|
34
46
|
allow?: string[];
|
|
47
|
+
/** When set, a `push` action is only gated if it targets this branch (or its target is unknown). */
|
|
48
|
+
scopedBranch?: string;
|
|
35
49
|
}
|
|
36
50
|
interface Occurrence {
|
|
37
51
|
action: Action;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { violation } from "../types.js";
|
|
2
|
-
import { withoutHeredocs, withoutQuotedMentions, unwrapShellWrappers } from "./shellCommand.js";
|
|
2
|
+
import { withoutHeredocs, withoutQuotedMentions, unwrapShellWrappers, leadingCommand } from "./shellCommand.js";
|
|
3
|
+
/**
|
|
4
|
+
* Commands that only READ/print/search their arguments — a push/commit named as
|
|
5
|
+
* an ARGUMENT to one of these is a mention, not the action. Same list the
|
|
6
|
+
* deterministic checker uses; kept local to avoid a circular import.
|
|
7
|
+
*/
|
|
8
|
+
const MENTION_ONLY = new Set([
|
|
9
|
+
"echo", "printf", "grep", "rg", "ag", "ack", "egrep", "fgrep", "cat", "bat",
|
|
10
|
+
"head", "tail", "less", "more", "ls", "find", "fd", "sed", "awk", "cut", "tr",
|
|
11
|
+
]);
|
|
3
12
|
const IN_COMMAND = {
|
|
4
13
|
push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
|
|
5
14
|
commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
|
|
@@ -32,9 +41,54 @@ function commandOf(e) {
|
|
|
32
41
|
// Strip heredoc bodies (a heredoc that WRITES "git push" is not a push) and
|
|
33
42
|
// blank quoted/commented mentions (`echo "git push"`, `# git push`), so only
|
|
34
43
|
// a command actually being run is matched.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
if (typeof c !== "string")
|
|
45
|
+
return "";
|
|
46
|
+
// Same parser as the check path (one parser, not two): unwrap `sh -c '…'`
|
|
47
|
+
// BEFORE blanking quotes (so a push hidden in a wrapper is exposed), then keep
|
|
48
|
+
// ONLY segments that actually RUN a command — a push/commit that is only an
|
|
49
|
+
// argument to echo/grep/printf/sed is a mention, never the action. This is
|
|
50
|
+
// what makes `echo git push is bad` not read as a push in the guard.
|
|
51
|
+
const exposed = withoutQuotedMentions(unwrapShellWrappers(withoutHeredocs(c)));
|
|
52
|
+
return exposed
|
|
53
|
+
.split(/\n|&&|\|\||[;|]/)
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter((s) => s.length > 0 && !MENTION_ONLY.has(leadingCommand(s)))
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
/** The canonical short form of a command, for matching a proposed call to its occurrence. */
|
|
59
|
+
export function approvalCommandShort(command) {
|
|
60
|
+
return commandOf({ role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "" }).replace(/\s+/g, " ").trim().slice(0, 80);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The branch a "push/commit … to <branch>" approval rule is scoped to, if any.
|
|
64
|
+
* "Never push to main without asking" is about MAIN — it must not gate a push to
|
|
65
|
+
* a feature branch. Found by a real test 2026-09-29: the guard asked on every
|
|
66
|
+
* push. Returns undefined for a generic "never push without asking" (all pushes).
|
|
67
|
+
*/
|
|
68
|
+
export function approvalScopedBranch(rule) {
|
|
69
|
+
const m = `${rule.title} ${rule.text}`.match(/\b(?:push(?:ing)?|commit(?:ting)?|merg(?:e|ing))\b[^.\n]*?\b(main|master|develop|trunk|release)\b/i);
|
|
70
|
+
return m ? m[1].toLowerCase() : undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether a `git push` command targets `branch` (or its target is unknown — a
|
|
74
|
+
* bare `git push`, which could be main, so it is gated to be safe). An explicit
|
|
75
|
+
* push to a DIFFERENT branch returns false, so a feature-branch push is not
|
|
76
|
+
* gated by a rule that names main.
|
|
77
|
+
*/
|
|
78
|
+
function pushTargetsBranch(command, branch) {
|
|
79
|
+
const m = command.match(/\bgit\s+(?:\S+\s+){0,4}?push\b(.*)/i);
|
|
80
|
+
if (!m)
|
|
81
|
+
return true;
|
|
82
|
+
const args = m[1];
|
|
83
|
+
const refspec = args.match(/[\w./-]+:([\w./-]+)/); // src:dst -> dst is the target
|
|
84
|
+
if (refspec)
|
|
85
|
+
return refspec[1] === branch || refspec[1].endsWith(`/${branch}`);
|
|
86
|
+
const tokens = args.split(/\s+/).filter((t) => t && !t.startsWith("-"));
|
|
87
|
+
if (tokens.length >= 2) {
|
|
88
|
+
const b = tokens[tokens.length - 1];
|
|
89
|
+
return b === branch || b.endsWith(`/${branch}`);
|
|
90
|
+
}
|
|
91
|
+
return true; // bare `git push` / `git push origin` — unknown target, gate to be safe
|
|
38
92
|
}
|
|
39
93
|
/** The result for the call at `i`: matched by id when present, else the next result. */
|
|
40
94
|
function resultOf(events, i) {
|
|
@@ -72,6 +126,10 @@ export function approvalOccurrences(events, actions, opts = {}) {
|
|
|
72
126
|
for (const action of actions) {
|
|
73
127
|
if (!IN_COMMAND[action].test(command))
|
|
74
128
|
continue;
|
|
129
|
+
// A branch-scoped push rule ("push to main") does not gate a push to a
|
|
130
|
+
// different branch — only main (or a bare push whose target is unknown).
|
|
131
|
+
if (action === "push" && opts.scopedBranch && !pushTargetsBranch(command, opts.scopedBranch))
|
|
132
|
+
continue;
|
|
75
133
|
const res = resultOf(events, i);
|
|
76
134
|
if (res && res.kind === "tool_result" && res.isError)
|
|
77
135
|
continue; // rejected in the prompt, or it never went through
|
|
@@ -125,7 +183,7 @@ export function approvalOccurrences(events, actions, opts = {}) {
|
|
|
125
183
|
}
|
|
126
184
|
export function runApprovalGateChecks(classifications, events, opts = {}) {
|
|
127
185
|
return classifications.map(({ rule, actions, polarity }) => {
|
|
128
|
-
const occ = approvalOccurrences(events, actions, opts);
|
|
186
|
+
const occ = approvalOccurrences(events, actions, { ...opts, scopedBranch: approvalScopedBranch(rule) });
|
|
129
187
|
const base = { ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source, method: "approval_gate" };
|
|
130
188
|
if (occ.length === 0) {
|
|
131
189
|
return { ...base, status: "UNCLEAR", outcome: "not_applicable", evidence: `no ${actions.join("/")} went through this session, so the rule never applied` };
|
|
@@ -103,7 +103,10 @@ export function unwrapShellWrappers(command) {
|
|
|
103
103
|
const m = out.match(SHELL_WRAPPER);
|
|
104
104
|
if (!m || m.index === undefined)
|
|
105
105
|
break;
|
|
106
|
-
|
|
106
|
+
// Hoist with NEWLINES, not `; … ;`: segments() splits on both, but a newline
|
|
107
|
+
// collapses away in evidence display (\s+ -> " ") whereas literal `;` leaked
|
|
108
|
+
// into a quoted-back command as `"; git push ;"` (found by a real test).
|
|
109
|
+
out = out.slice(0, m.index) + `\n${m[2]}\n` + out.slice(m.index + m[0].length);
|
|
107
110
|
}
|
|
108
111
|
return out;
|
|
109
112
|
}
|
package/dist/cli.js
CHANGED
|
@@ -81,13 +81,31 @@ async function shareResults(results) {
|
|
|
81
81
|
// promise, and the demo is the first thing people run. (A judgment rule shown
|
|
82
82
|
// Broken here was found by a real test 2026-09-29 and was exactly the kind of
|
|
83
83
|
// over-claim this product exists to catch.)
|
|
84
|
+
/** Add an entry to .gitignore (best-effort, git repos only) so a written file can't be committed by accident. */
|
|
85
|
+
function ensureGitignored(cwd, entry) {
|
|
86
|
+
try {
|
|
87
|
+
if (!existsSync(join(cwd, ".git")))
|
|
88
|
+
return;
|
|
89
|
+
const gi = join(cwd, ".gitignore");
|
|
90
|
+
const current = existsSync(gi) ? readFileSync(gi, "utf-8") : "";
|
|
91
|
+
const want = entry.replace(/\/$/, "");
|
|
92
|
+
if (current.split(/\r?\n/).some((l) => l.trim() === want || l.trim() === entry))
|
|
93
|
+
return;
|
|
94
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
|
|
95
|
+
writeFileSync(gi, `${current}${prefix}${entry}\n`);
|
|
96
|
+
console.log(`(added ${entry} to .gitignore so it isn't committed)`);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* best-effort — a card that isn't gitignored is a minor issue, not a failure */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
84
102
|
const DEMO_RESULTS = [
|
|
85
103
|
{ ruleId: "1", ruleTitle: "Never push to `main`", ruleSource: "project", status: "FAIL", outcome: "fail", method: "git_events",
|
|
86
104
|
evidence: 'a git command actually targeted the "main" branch: git push origin main' },
|
|
87
105
|
{ ruleId: "2", ruleTitle: "Evidence or it didn't happen", ruleSource: "global", status: "FAIL", outcome: "fail", method: "claim_vs_evidence",
|
|
88
106
|
evidence: 'the session stated "All tests pass ✅" but the last test run before it, `npm test`, reported "1 failed"' },
|
|
89
|
-
{ ruleId: "3", ruleTitle: "Never edit `.env`", ruleSource: "project", status: "
|
|
90
|
-
evidence: "
|
|
107
|
+
{ ruleId: "3", ruleTitle: "Never edit `.env`", ruleSource: "project", status: "UNCLEAR", outcome: "not_applicable", method: "file_events",
|
|
108
|
+
evidence: "the `.env` file was never written to, deleted, or moved this session — a forbid rule that never came up, not a pass" },
|
|
91
109
|
{ ruleId: "4", ruleTitle: "Surface bad news first", ruleSource: "global", status: "UNCLEAR", needsHuman: true,
|
|
92
110
|
evidence: "" },
|
|
93
111
|
];
|
|
@@ -1157,7 +1175,7 @@ program
|
|
|
1157
1175
|
program
|
|
1158
1176
|
.command("card")
|
|
1159
1177
|
.description("Make a shareable image and pre-filled share links from your last 30 days of sessions — counts only, no code, paths or rule text (add rule names to the copy-text with --show-rules). Saves an SVG locally and prints X/LinkedIn/Bluesky/Reddit compose links. Nothing is posted and nothing is uploaded.")
|
|
1160
|
-
.option("--out <path>", "where to write the SVG card", "rulereceipt
|
|
1178
|
+
.option("--out <path>", "where to write the SVG card", join(".rulereceipt", "card.svg"))
|
|
1161
1179
|
.option("--show-rules", "include the broken rule names in the copy-text (never in the image)")
|
|
1162
1180
|
.option("--days <n>", "how many days back to summarize", "30")
|
|
1163
1181
|
.action(async (opts) => {
|
|
@@ -1184,7 +1202,11 @@ program
|
|
|
1184
1202
|
who: s.tools.length === 1 && s.tools[0] === "claude-code" ? "Claude" : "the agent",
|
|
1185
1203
|
brokenTitles: s.breaks.map((b) => b.ruleTitle),
|
|
1186
1204
|
};
|
|
1187
|
-
const outPath = resolve(cwd, opts.out ?? "rulereceipt
|
|
1205
|
+
const outPath = resolve(cwd, opts.out ?? join(".rulereceipt", "card.svg"));
|
|
1206
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
1207
|
+
// Keep the card out of the repo: it lives under .rulereceipt/, which we add
|
|
1208
|
+
// to .gitignore on first write so it can't be committed by accident.
|
|
1209
|
+
ensureGitignored(cwd, ".rulereceipt/");
|
|
1188
1210
|
writeFileSync(outPath, cardSvg(data));
|
|
1189
1211
|
console.log(renderCardShare(data, outPath, Boolean(opts.showRules)));
|
|
1190
1212
|
});
|
package/dist/guard.js
CHANGED
|
@@ -6,7 +6,7 @@ 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, allowListed } from "./checks/approvalGate.js";
|
|
9
|
+
import { approvalOccurrences, allowListed, approvalCommandShort, approvalScopedBranch } from "./checks/approvalGate.js";
|
|
10
10
|
import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
@@ -204,9 +204,12 @@ function unapprovedGate(cwd, command, events) {
|
|
|
204
204
|
const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
|
|
205
205
|
for (const { rule, actions } of gates) {
|
|
206
206
|
const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
|
|
207
|
-
const occ = approvalOccurrences([...events, proposed], actions);
|
|
207
|
+
const occ = approvalOccurrences([...events, proposed], actions, { scopedBranch: approvalScopedBranch(rule) });
|
|
208
208
|
const last = occ[occ.length - 1];
|
|
209
|
-
|
|
209
|
+
// Compare against the SAME canonical short the occurrence uses (mention
|
|
210
|
+
// segments dropped, `sh -c` unwrapped) — a raw-string compare missed a
|
|
211
|
+
// wrapped push and mis-fired on a mention (found by a real test).
|
|
212
|
+
if (last && last.command === approvalCommandShort(command) && approvalCommandShort(command) !== "" && last.verdict !== "approved") {
|
|
210
213
|
return { rule, action: last.action };
|
|
211
214
|
}
|
|
212
215
|
}
|
package/dist/historyReport.js
CHANGED
|
@@ -16,6 +16,16 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
16
16
|
const rules_ = new Map();
|
|
17
17
|
const tools = new Set();
|
|
18
18
|
let sessionsScanned = 0;
|
|
19
|
+
/** The latest event timestamp in a session, or null if none parse. */
|
|
20
|
+
const lastEventMs = (events) => {
|
|
21
|
+
let max = 0;
|
|
22
|
+
for (const e of events) {
|
|
23
|
+
const t = Date.parse(e.timestamp);
|
|
24
|
+
if (!Number.isNaN(t) && t > max)
|
|
25
|
+
max = t;
|
|
26
|
+
}
|
|
27
|
+
return max > 0 ? max : null;
|
|
28
|
+
};
|
|
19
29
|
for (const { adapter, file } of sessions) {
|
|
20
30
|
let ms;
|
|
21
31
|
try {
|
|
@@ -37,6 +47,11 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
37
47
|
continue;
|
|
38
48
|
sessionsScanned++;
|
|
39
49
|
tools.add(adapter.tool);
|
|
50
|
+
// The DATE shown is the session's own last timestamp, not the file's mtime —
|
|
51
|
+
// a file touched today can hold a session from last week, and showing "last:
|
|
52
|
+
// today" for it is wrong (found by a real test, 2026-09-29). Falls back to
|
|
53
|
+
// the mtime only when the transcript carries no usable timestamp.
|
|
54
|
+
const sessionMs = lastEventMs(events) ?? ms;
|
|
40
55
|
const { results } = await evaluateSession(cwd, rules, events, false, needsLlmResult);
|
|
41
56
|
for (const r of results) {
|
|
42
57
|
const k = key(r);
|
|
@@ -46,7 +61,7 @@ export async function scanHistory(cwd, rules, days = 30, now = Date.now(), sessi
|
|
|
46
61
|
rules_.set(k, a);
|
|
47
62
|
}
|
|
48
63
|
if (r.status === "FAIL")
|
|
49
|
-
a.breaks.push({ ms, quote: r.evidence });
|
|
64
|
+
a.breaks.push({ ms: sessionMs, quote: r.evidence });
|
|
50
65
|
else if (r.status === "PASS")
|
|
51
66
|
a.passed = true;
|
|
52
67
|
else if (r.status === "UNCLEAR" && r.needsHuman)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.73",
|
|
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",
|