rulereceipt 0.1.19 → 0.1.20

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 CHANGED
@@ -1,8 +1,20 @@
1
1
  # RuleReceipt
2
2
 
3
+ [![CI](https://github.com/rulereceipt/rulereceipt/actions/workflows/ci.yml/badge.svg)](https://github.com/rulereceipt/rulereceipt/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/rulereceipt/rulereceipt/actions/workflows/codeql.yml/badge.svg)](https://github.com/rulereceipt/rulereceipt/actions/workflows/codeql.yml)
5
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/rulereceipt/rulereceipt/badge)](https://scorecard.dev/viewer/?uri=github.com/rulereceipt/rulereceipt)
6
+ [![npm](https://img.shields.io/npm/v/rulereceipt)](https://www.npmjs.com/package/rulereceipt)
7
+ [![provenance](https://img.shields.io/badge/npm-provenance%20signed-blue)](https://www.npmjs.com/package/rulereceipt#provenance)
8
+
3
9
  Checks whether a Claude Code session actually followed the rules in your
4
10
  CLAUDE.md / AGENTS.md — with evidence, not just a vibe.
5
11
 
12
+ Every release from 0.1.19 on is built and published by GitHub Actions and
13
+ signed with [npm provenance](https://docs.npmjs.com/generating-provenance-statements),
14
+ so you can verify the published package was built from this repository at
15
+ a specific commit. No publishing token exists to be stolen. Check it
16
+ yourself with `npm audit signatures` after installing.
17
+
6
18
  Licensed source-available software — see [LICENSE](./LICENSE) and
7
19
  [NOTICE.md](./NOTICE.md) before reusing this code.
8
20
 
@@ -56,6 +68,31 @@ Published and live on npm, actively developed.
56
68
  that exact file. (It proves the report matches the file, not that the
57
69
  file is an unmodified record — see SECURITY.md.)
58
70
 
71
+ ## Exit codes
72
+
73
+ `check` exits **1** when a rule was actually broken, and **0** otherwise,
74
+ so CI can gate on it. Rules that need human judgment report UNCLEAR and
75
+ never affect the exit code — most rules in a real CLAUDE.md need judgment,
76
+ and gating on those would make every build red on day one.
77
+
78
+ `--exit-zero` prints the report without failing the build. `--require-session`
79
+ does the opposite and is the one to use anywhere automated: it fails when
80
+ there is no session, or an empty one, instead of reporting a pass for a
81
+ check that never actually ran.
82
+
83
+ ### A limit worth knowing before you wire this into CI
84
+
85
+ Claude Code writes its session transcript to the machine the agent ran on
86
+ — your laptop. A CI runner is a fresh machine that has never seen it, so a
87
+ CI job cannot check a session that happened on your laptop unless you
88
+ deliberately make that transcript available to the job. See
89
+ [templates/rulereceipt-ci.yml](./templates/rulereceipt-ci.yml), which
90
+ explains the options and, if you use it, fails loudly rather than passing
91
+ on a session it never found.
92
+
93
+ For most people the honest answer is simpler: run `rulereceipt check --html`
94
+ locally and attach the report to the PR.
95
+
59
96
  ## Sharing a report
60
97
 
61
98
  `rulereceipt check --html` writes one self-contained HTML file. No
@@ -89,6 +126,8 @@ rulereceipt check # check the latest session in this project
89
126
  rulereceipt check --markdown # same, formatted for pasting into a PR/Slack
90
127
  rulereceipt check --html # write a shareable single-file HTML report you can send
91
128
  rulereceipt check --html report.html # ...to a specific path
129
+ rulereceipt check --require-session # fail if there's no session, instead of passing silently
130
+ rulereceipt check --exit-zero # report failures without failing the build
92
131
  rulereceipt check --llm # opt-in: grade judgment rules with your own Claude key
93
132
  rulereceipt check --share # opt-in: send anonymous pass/fail/unclear counts
94
133
  rulereceipt check --telemetry # opt-in: send one random per-machine ID
package/dist/cli.js CHANGED
@@ -121,7 +121,7 @@ function writeHtmlReport(results, meta, cwd, target) {
121
121
  }
122
122
  }
123
123
  async function runCheck(opts) {
124
- const { markdown, share, email, emailAlways, llm, telemetry, html, transcriptOverride } = opts;
124
+ const { markdown, share, email, emailAlways, llm, telemetry, html, exitZero, requireSession, transcriptOverride } = opts;
125
125
  const cwd = process.cwd();
126
126
  const rules = loadRules(cwd);
127
127
  if (rules.length === 0) {
@@ -139,9 +139,34 @@ async function runCheck(opts) {
139
139
  console.log("No Claude Code session found for this project yet.\n" +
140
140
  "Run Claude Code here at least once, then try `rulereceipt check` again — " +
141
141
  "or pass --transcript <path-to-.jsonl> directly if your session lives somewhere non-standard.");
142
+ // Exiting 0 here is right for a person running this locally for the
143
+ // first time — nothing is wrong, there is simply nothing yet. It is
144
+ // dangerous anywhere automated, where a silent 0 reads as "checked,
145
+ // all clear" when nothing was checked at all. --require-session makes
146
+ // that case fail loudly. See the note in templates/rulereceipt-ci.yml.
147
+ if (requireSession) {
148
+ console.error("\n--require-session was set and no session was found, so nothing could be checked. " +
149
+ "Failing rather than reporting a pass for a check that never ran.");
150
+ process.exitCode = 1;
151
+ }
142
152
  return;
143
153
  }
144
154
  const events = transcriptOverride ? readTranscriptFromFile(sessionFilePath) : readLatestTranscript(cwd);
155
+ // A session file with nothing in it produces a report full of PASSes,
156
+ // because no forbidden action appears in an empty session. That is
157
+ // technically true and badly misleading: "we found no proof of
158
+ // wrongdoing" gets printed as "you're fine." Same shape as the rule this
159
+ // tool already enforces on itself — an absence of evidence is not
160
+ // evidence. Say so out loud, and fail where a machine is reading it.
161
+ if (events.length === 0) {
162
+ console.log("\n⚠ This session file contains no recorded activity, so there was nothing to check against.\n" +
163
+ " Every result below reflects an empty session, not a clean one.");
164
+ if (requireSession) {
165
+ console.error("\n--require-session was set and the session was empty. Failing rather than reporting a pass for a check that had no evidence.");
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+ }
145
170
  const classifications = classifyRules(rules);
146
171
  const deterministic = classifications.filter((c) => c.kind === "deterministic");
147
172
  const ifEditThenTest = classifications.filter((c) => c.kind === "ifEditThenTest");
@@ -201,6 +226,23 @@ async function runCheck(opts) {
201
226
  if (isTelemetryEnabled(telemetry)) {
202
227
  await sendTelemetryPing();
203
228
  }
229
+ // Exit non-zero when a rule was actually broken, so CI can gate on it.
230
+ //
231
+ // This was a real shipped falsehood (found 2026-08-31):
232
+ // templates/rulereceipt-ci.yml told people to copy a workflow and said
233
+ // "rulereceipt already exits non-zero on FAIL, this just wires that
234
+ // into CI" — while `check` always exited 0. Anyone who used that
235
+ // template had a job that passed even as the agent broke their rules,
236
+ // which is worse than having no check at all, because it reads as
237
+ // evidence that nothing went wrong.
238
+ //
239
+ // Only FAIL counts. UNCLEAR must not, and that isn't a softening: most
240
+ // rules in a real CLAUDE.md need judgment, so without --llm they
241
+ // legitimately report UNCLEAR. Gating on those would make every build
242
+ // red on day one and the check would be deleted within a week.
243
+ if (!exitZero && results.some((r) => r.status === "FAIL")) {
244
+ process.exitCode = 1;
245
+ }
204
246
  }
205
247
  function runDemo(markdown) {
206
248
  const meta = { sessionFilePath: null, ruleCount: DEMO_RESULTS.length };
@@ -222,6 +264,8 @@ program
222
264
  .option("--llm", "opt-in: grade rules that need judgment (not just pattern matching) using your own Anthropic key. Without this flag, those rules report UNCLEAR and nothing is sent anywhere — deterministic checks always run with no key regardless.")
223
265
  .option("--telemetry", "opt-in: send an anonymous install-count ping (a random per-machine ID, never rule text or results) so real distinct-install counts are knowable. Off by default. DO_NOT_TRACK=1 or RULERECEIPT_NO_TELEMETRY=1 overrides this flag back off.")
224
266
  .option("--html [path]", `write a shareable single-file HTML report you can email, attach to a ticket, or print to PDF. Defaults to ./${DEFAULT_HTML_REPORT_NAME}. Written locally — nothing is uploaded.`)
267
+ .option("--exit-zero", "always exit 0, even when a rule was broken. Without this, `check` exits 1 on any FAIL so CI can gate on it (rules needing human judgment report UNCLEAR and never affect the exit code).")
268
+ .option("--require-session", "fail (exit 1) if no session is found, or the session is empty, instead of reporting a pass for a check that never actually ran. Use this anywhere automated.")
225
269
  .option("--transcript <path>", "manual override: check this exact .jsonl session file instead of auto-detecting one. Useful if your Claude Code session lives somewhere non-standard that auto-detection doesn't cover.")
226
270
  .action((opts) => {
227
271
  runCheck({
@@ -233,6 +277,8 @@ program
233
277
  telemetry: Boolean(opts.telemetry),
234
278
  // commander gives `true` for a bare --html and the string for --html <path>
235
279
  html: opts.html ?? false,
280
+ exitZero: Boolean(opts.exitZero),
281
+ requireSession: Boolean(opts.requireSession),
236
282
  transcriptOverride: opts.transcript,
237
283
  }).catch((err) => {
238
284
  console.error("Something went wrong:", err instanceof Error ? err.message : err);
@@ -67,6 +67,31 @@ export function generateReport(results, meta) {
67
67
  lines.push(`checked: ${new Date().toISOString()}`);
68
68
  return lines.join("\n");
69
69
  }
70
+ /**
71
+ * Makes a value safe to place inside a markdown table cell.
72
+ *
73
+ * The previous version escaped only `|`, which broke a real table three
74
+ * separate ways (first found by CodeQL js/incomplete-sanitization, the
75
+ * other two while fixing it):
76
+ *
77
+ * 1. Backslash was not escaped, so evidence containing a literal `\|`
78
+ * became `\\|` — rendering as a backslash followed by a live column
79
+ * separator, splitting the row. Backslash must be escaped FIRST, or
80
+ * it re-escapes the pipes added afterwards.
81
+ * 2. The rule TITLE was not escaped at all, so any rule whose title
82
+ * contains a pipe broke the table. Titles come from a user's
83
+ * CLAUDE.md, and pipes appear naturally in shell examples.
84
+ * 3. Newlines were not handled. stripControlChars deliberately keeps
85
+ * `\n` so multi-line evidence stays readable in the terminal, but a
86
+ * newline inside a table cell ends the row and destroys everything
87
+ * below it. Rendered as a literal <br> instead.
88
+ */
89
+ function escapeMarkdownCell(value) {
90
+ return value
91
+ .replace(/\\/g, "\\\\")
92
+ .replace(/\|/g, "\\|")
93
+ .replace(/\r?\n/g, "<br>");
94
+ }
70
95
  export function generateMarkdownReport(results, meta) {
71
96
  const clean = results.map(sanitize);
72
97
  const lines = [];
@@ -75,8 +100,8 @@ export function generateMarkdownReport(results, meta) {
75
100
  lines.push("| Status | Rule | Evidence |");
76
101
  lines.push("|---|---|---|");
77
102
  for (const r of clean) {
78
- const evidence = (r.evidence || "").replace(/\|/g, "\\|");
79
- lines.push(`| ${MARK[r.status]} ${r.status} | ${ruleLabel(r, clean)} | ${evidence} |`);
103
+ const evidence = escapeMarkdownCell(r.evidence || "");
104
+ lines.push(`| ${MARK[r.status]} ${r.status} | ${escapeMarkdownCell(ruleLabel(r, clean))} | ${evidence} |`);
80
105
  }
81
106
  lines.push("");
82
107
  const hash = computeTranscriptHash(meta.sessionFilePath);
@@ -15,5 +15,15 @@ export declare function isTelemetryEnabled(telemetryFlag: boolean): boolean;
15
15
  * session content, or even pass/fail counts (that's what opt-in --share is
16
16
  * for). A send failure must never affect the `check` command's own exit
17
17
  * code or output; this is best-effort and silent on failure.
18
+ *
19
+ * CodeQL flags this as js/file-access-to-http ("outbound network request
20
+ * depends on file data"), which is technically accurate and not a real
21
+ * issue: the file it reads is ~/.rulereceipt/telemetry-id, whose entire
22
+ * contents are a random UUID this tool generated and wrote itself. No
23
+ * user content, no path, and nothing derived from the session ever
24
+ * reaches this request. Reviewed and dismissed deliberately rather than
25
+ * left open — a permanently red alert list is one nobody reads. If the
26
+ * payload here ever grows beyond `{ id }`, that decision is void and
27
+ * this needs re-reviewing.
18
28
  */
19
29
  export declare function sendTelemetryPing(): Promise<void>;
package/dist/telemetry.js CHANGED
@@ -61,6 +61,16 @@ export function isTelemetryEnabled(telemetryFlag) {
61
61
  * session content, or even pass/fail counts (that's what opt-in --share is
62
62
  * for). A send failure must never affect the `check` command's own exit
63
63
  * code or output; this is best-effort and silent on failure.
64
+ *
65
+ * CodeQL flags this as js/file-access-to-http ("outbound network request
66
+ * depends on file data"), which is technically accurate and not a real
67
+ * issue: the file it reads is ~/.rulereceipt/telemetry-id, whose entire
68
+ * contents are a random UUID this tool generated and wrote itself. No
69
+ * user content, no path, and nothing derived from the session ever
70
+ * reaches this request. Reviewed and dismissed deliberately rather than
71
+ * left open — a permanently red alert list is one nobody reads. If the
72
+ * payload here ever grows beyond `{ id }`, that decision is void and
73
+ * this needs re-reviewing.
64
74
  */
65
75
  export async function sendTelemetryPing() {
66
76
  const id = getOrCreateTelemetryId();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Checks whether a Claude Code session actually followed your CLAUDE.md / AGENTS.md rules, with evidence.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,16 +45,16 @@
45
45
  "license": "SEE LICENSE IN LICENSE",
46
46
  "dependencies": {
47
47
  "@anthropic-ai/sdk": "^0.32.0",
48
- "commander": "^12.1.0",
49
- "nodemailer": "^9.0.5"
48
+ "commander": "^15.0.0",
49
+ "nodemailer": "^9.0.6"
50
50
  },
51
51
  "devDependencies": {
52
- "@types/node": "^22.0.0",
52
+ "@types/node": "^26.4.0",
53
53
  "@types/nodemailer": "^8.0.1",
54
- "eslint": "^9.0.0",
54
+ "eslint": "^10.9.1",
55
55
  "tsx": "^4.19.0",
56
56
  "typescript": "^5.6.0",
57
- "typescript-eslint": "^8.67.0",
57
+ "typescript-eslint": "^8.68.0",
58
58
  "vitest": "^4.1.11"
59
59
  }
60
60
  }