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.
@@ -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
- // Unwrap `sh -c '…'` BEFORE blanking quotes, so a push hidden in a wrapper is
36
- // exposed as a real command rather than blanked as a quoted mention (#2).
37
- return typeof c === "string" ? withoutQuotedMentions(unwrapShellWrappers(withoutHeredocs(c))) : "";
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
- out = out.slice(0, m.index) + ` ; ${m[2]} ; ` + out.slice(m.index + m[0].length);
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: "PASS", outcome: "pass", method: "file_events",
90
- evidence: "no write, edit, or delete of `.env` this session" },
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-card.svg")
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-card.svg");
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
- if (last && last.command === command.replace(/\s+/g, " ").trim().slice(0, 80) && last.verdict !== "approved") {
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
  }
@@ -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.72",
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",