rulereceipt 0.1.70 → 0.1.71

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/card.js CHANGED
@@ -14,13 +14,16 @@ const SITE = "rulereceipt.dev";
14
14
  export function shareText(d, showRules = false) {
15
15
  // A single dropped session (the browser demo) has no "over N days" span, so
16
16
  // it gets a "this session" caption rather than the history-mode one.
17
+ // Never claim "now it can't" — that is only true if `protect` is installed,
18
+ // and even then a determined command can slip a guard (found by a real test,
19
+ // 2026-09-29). The card states the FACT it can prove: what the session did.
17
20
  const single = d.sessions === 1;
18
21
  const base = single
19
22
  ? d.broken > 0
20
- ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in this session — now it can't.`
23
+ ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in this session — with the receipt to prove it.`
21
24
  : `RuleReceipt checked one of my agent's sessions against my written rules: ${d.broken} broken.`
22
25
  : d.broken > 0
23
- ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} sessions) — now it can't.`
26
+ ? `${d.who} broke my written rules ${d.broken} time${d.broken === 1 ? "" : "s"} in ${d.days} days (${d.sessions} sessions) — with the receipt to prove it.`
24
27
  : `RuleReceipt checked ${d.sessions} of my agent sessions over ${d.days} days against my written rules: ${d.broken} broken.`;
25
28
  const tail = `Checked with RuleReceipt — runs locally, nothing uploaded. ${SITE}`;
26
29
  if (showRules && d.broken > 0 && d.brokenTitles && d.brokenTitles.length > 0) {
@@ -1,5 +1,5 @@
1
1
  import { violation } from "../types.js";
2
- import { withoutHeredocs, withoutQuotedMentions } from "./shellCommand.js";
2
+ import { withoutHeredocs, withoutQuotedMentions, unwrapShellWrappers } from "./shellCommand.js";
3
3
  const IN_COMMAND = {
4
4
  push: /\bgit\s+(?:\S+\s+){0,4}?push(?![\w-])/,
5
5
  commit: /\bgit\s+(?:\S+\s+){0,4}?commit(?![\w-])/,
@@ -32,7 +32,9 @@ function commandOf(e) {
32
32
  // Strip heredoc bodies (a heredoc that WRITES "git push" is not a push) and
33
33
  // blank quoted/commented mentions (`echo "git push"`, `# git push`), so only
34
34
  // a command actually being run is matched.
35
- return typeof c === "string" ? withoutQuotedMentions(withoutHeredocs(c)) : "";
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))) : "";
36
38
  }
37
39
  /** The result for the call at `i`: matched by id when present, else the next result. */
38
40
  function resultOf(events, i) {
@@ -31,6 +31,7 @@ export declare function withoutHeredocs(command: string): string;
31
31
  * runs (proposedAction, attribution).
32
32
  */
33
33
  export declare function segments(command: string): string[];
34
+ export declare function unwrapShellWrappers(command: string): string;
34
35
  /** The executable a segment invokes, with env assignments and `sudo` skipped. */
35
36
  export declare function leadingCommand(segment: string): string;
36
37
  /**
@@ -78,11 +78,35 @@ function isHeredocOpener(before, afterDelim) {
78
78
  * runs (proposedAction, attribution).
79
79
  */
80
80
  export function segments(command) {
81
- return withoutHeredocs(command)
81
+ return unwrapShellWrappers(withoutHeredocs(command))
82
82
  .split(/\n|&&|\|\||[;|]/)
83
83
  .map((s) => s.trim())
84
84
  .filter((s) => s.length > 0);
85
85
  }
86
+ /**
87
+ * Hoists the script out of a shell wrapper — `sh -c '<script>'`, `bash -lc
88
+ * "<script>"`, `zsh -c ...`, `dash -c ...`, `eval '<script>'` — so the command
89
+ * it actually runs is seen by every checker instead of hiding in a quoted arg.
90
+ *
91
+ * Found by a real test 2026-09-29: `sh -c 'git push origin main'` slipped past
92
+ * the guard AND the report, because the push sat inside a quote that
93
+ * withoutQuotedMentions blanks. Unwrapping first exposes the inner command as
94
+ * its own segment. Only a shell/eval wrapper is unwrapped — `echo "git push"`
95
+ * is not a wrapper and stays a mention. Bounded loop handles one nested level;
96
+ * deeper nesting is rare and fails toward not-unwrapped (a miss, not a false
97
+ * accusation).
98
+ */
99
+ const SHELL_WRAPPER = /\b(?:sh|bash|zsh|dash|eval)\b(?:\s+-[A-Za-z]+)*\s+(['"])([\s\S]*?)\1/;
100
+ export function unwrapShellWrappers(command) {
101
+ let out = command;
102
+ for (let i = 0; i < 4; i++) {
103
+ const m = out.match(SHELL_WRAPPER);
104
+ if (!m || m.index === undefined)
105
+ break;
106
+ out = out.slice(0, m.index) + ` ; ${m[2]} ; ` + out.slice(m.index + m[0].length);
107
+ }
108
+ return out;
109
+ }
86
110
  /** The executable a segment invokes, with env assignments and `sudo` skipped. */
87
111
  export function leadingCommand(segment) {
88
112
  const words = segment.split(/\s+/).filter(Boolean);
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { scanHistory, renderHistory } from "./historyReport.js";
20
20
  import { observeSessions, renderNoRules, draftRulesFromHistory } from "./sessionObserve.js";
21
21
  import { listSessionRows, renderSessionList } from "./listSessions.js";
22
22
  import { runSelfTestChecks, renderSelfTest } from "./selftest.js";
23
- import { planProtect, applyProtect, undoProtect } from "./protect.js";
23
+ import { planProtect, applyProtect, undoProtect, PROTECT_HOOK_SNIPPET } from "./protect.js";
24
24
  import { cardSvg, renderCardShare } from "./card.js";
25
25
  import { createInterface } from "node:readline";
26
26
  import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
@@ -76,10 +76,20 @@ async function shareResults(results) {
76
76
  console.log("\n(--share failed to send, non-fatal, report above is unaffected)");
77
77
  }
78
78
  }
79
+ // Sample output. A FAIL is only ever shown here for a STRUCTURED rule with a
80
+ // quoted command — never a judgment rule — because that is the tool's actual
81
+ // promise, and the demo is the first thing people run. (A judgment rule shown
82
+ // Broken here was found by a real test 2026-09-29 and was exactly the kind of
83
+ // over-claim this product exists to catch.)
79
84
  const DEMO_RESULTS = [
80
- { ruleId: "7", ruleTitle: "Tests must be able to fail", ruleSource: "global", status: "PASS", evidence: "sabotage-and-revert shown before the green run" },
81
- { ruleId: "4", ruleTitle: "Surface bad news first", ruleSource: "global", status: "FAIL", evidence: "reply led with passing tests, the one broken test was mentioned last" },
82
- { ruleId: "11", ruleTitle: "Fails closed on error", ruleSource: "global", status: "UNCLEAR", evidence: "no error occurred this session, nothing to verify against" },
85
+ { ruleId: "1", ruleTitle: "Never push to `main`", ruleSource: "project", status: "FAIL", outcome: "fail", method: "git_events",
86
+ evidence: 'a git command actually targeted the "main" branch: git push origin main' },
87
+ { ruleId: "2", ruleTitle: "Evidence or it didn't happen", ruleSource: "global", status: "FAIL", outcome: "fail", method: "claim_vs_evidence",
88
+ 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" },
91
+ { ruleId: "4", ruleTitle: "Surface bad news first", ruleSource: "global", status: "UNCLEAR", needsHuman: true,
92
+ evidence: "" },
83
93
  ];
84
94
  async function emailResults(reportText) {
85
95
  const config = loadEmailConfig();
@@ -1114,6 +1124,14 @@ program
1114
1124
  return;
1115
1125
  }
1116
1126
  const plan = planProtect(cwd);
1127
+ if (plan.parseError) {
1128
+ console.log(`Your ${plan.settingsPath} is not valid JSON (a comment, a trailing comma, or a syntax error).`);
1129
+ console.log("protect will NOT touch it — rewriting it could delete your own settings (deny rules, model, other hooks).");
1130
+ console.log("\nFix the JSON, then re-run rulereceipt protect — or add these two hooks by hand:\n");
1131
+ console.log(PROTECT_HOOK_SNIPPET.split("\n").map((l) => ` ${l}`).join("\n"));
1132
+ process.exitCode = 1;
1133
+ return;
1134
+ }
1117
1135
  if (plan.alreadyProtected) {
1118
1136
  console.log(`Already protected — the RuleReceipt hooks are in ${plan.settingsPath}. Nothing to add.`);
1119
1137
  return;
@@ -103,7 +103,7 @@ export function renderHistory(s, projectName, now = Date.now()) {
103
103
  return out.join("\n");
104
104
  }
105
105
  if (s.breaks.length === 0) {
106
- out.push("No rules were broken in these sessions. Nothing to flag.");
106
+ out.push("No proven breaks found in these sessions (a structured check with quoted evidence). Rules needing judgment are shown per-session, not counted here.");
107
107
  }
108
108
  else {
109
109
  const who = s.tools.length === 1 && s.tools[0] === "claude-code" ? "Claude" : "the agent";
package/dist/protect.d.ts CHANGED
@@ -9,7 +9,17 @@ export interface ProtectPlan {
9
9
  toAdd: string[];
10
10
  /** True when both hooks are already present — nothing to do. */
11
11
  alreadyProtected: boolean;
12
+ /**
13
+ * True when the settings file exists but is not parseable JSON (a comment, a
14
+ * trailing comma, or genuinely broken). protect MUST NOT rewrite it — doing so
15
+ * would delete the user's own settings (deny rules, model, other hooks). The
16
+ * caller shows the hooks to add by hand and changes nothing. Found by a real
17
+ * test, 2026-09-29: a JSONC file lost its `permissions.deny` rule.
18
+ */
19
+ parseError: boolean;
12
20
  }
21
+ /** The two hook lines to add by hand, for the parse-error path. */
22
+ export declare const PROTECT_HOOK_SNIPPET = "\"hooks\": {\n \"PreToolUse\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"rulereceipt guard\" }] }],\n \"Stop\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"rulereceipt hook\" }] }]\n}";
13
23
  export declare function planProtect(cwd: string): ProtectPlan;
14
24
  export declare function applyProtect(cwd: string, plan: ProtectPlan): void;
15
25
  export interface UndoResult {
package/dist/protect.js CHANGED
@@ -39,22 +39,29 @@ function atomicWrite(path, content) {
39
39
  writeFileSync(tmp, content);
40
40
  renameSync(tmp, path);
41
41
  }
42
+ /** The two hook lines to add by hand, for the parse-error path. */
43
+ export const PROTECT_HOOK_SNIPPET = `"hooks": {
44
+ "PreToolUse": [{ "hooks": [{ "type": "command", "command": "${GUARD_CMD}" }] }],
45
+ "Stop": [{ "hooks": [{ "type": "command", "command": "${HOOK_CMD}" }] }]
46
+ }`;
42
47
  export function planProtect(cwd) {
43
48
  const settingsPath = settingsPathFor(cwd);
44
49
  const existed = existsSync(settingsPath);
45
50
  let original = null;
46
51
  let settings = {};
47
52
  if (existed) {
53
+ original = readFileSync(settingsPath, "utf-8");
48
54
  try {
49
- original = readFileSync(settingsPath, "utf-8");
50
55
  const parsed = JSON.parse(original);
51
56
  if (parsed && typeof parsed === "object")
52
57
  settings = parsed;
53
58
  }
54
59
  catch {
55
- // Malformed JSON: we keep the ORIGINAL bytes (for a faithful undo) but
56
- // build the new file from an empty object rather than guessing at a merge.
57
- settings = {};
60
+ // Malformed/JSONC (comment, trailing comma, or broken): REFUSE. Rewriting
61
+ // it would silently delete the user's own settings — deny rules, model,
62
+ // other hooks. Return a plan that changes NOTHING and flags the parse
63
+ // error so the caller can tell the user and show the lines to add by hand.
64
+ return { settingsPath, existed, original, next: original, toAdd: [], alreadyProtected: false, parseError: true };
58
65
  }
59
66
  }
60
67
  const toAdd = [];
@@ -73,9 +80,14 @@ export function planProtect(cwd) {
73
80
  next: `${JSON.stringify(settings, null, 2)}\n`,
74
81
  toAdd,
75
82
  alreadyProtected: toAdd.length === 0,
83
+ parseError: false,
76
84
  };
77
85
  }
78
86
  export function applyProtect(cwd, plan) {
87
+ // Never write over a file we could not parse — that is the data-loss bug this
88
+ // guards against. The CLI stops before here, but this makes it impossible.
89
+ if (plan.parseError)
90
+ throw new Error("refusing to write: the settings file is not valid JSON");
79
91
  // Record exactly what to restore (the original bytes, or that there was no
80
92
  // file) BEFORE touching anything, so --undo is byte-for-byte.
81
93
  atomicWrite(backupPathFor(cwd), `${JSON.stringify({ settingsPath: plan.settingsPath, existed: plan.existed, original: plan.original }, null, 2)}\n`);
@@ -52,7 +52,6 @@ export function gateOffer(state) {
52
52
  return null;
53
53
  if (state.hookInstalled)
54
54
  return null;
55
- const n = state.failures;
56
- return (`This report is after the fact. \`rulereceipt hook\` runs as a Claude Code Stop hook and\n` +
57
- `refuses to let a session end on ${n === 1 ? "a broken rule" : "a broken rule"} — see the README for the four lines to add.`);
55
+ return (`This report is after the fact. To refuse a session that ends on a broken rule, run\n` +
56
+ `\`rulereceipt protect\` — it adds the Stop hook and the guard for you (with a preview and undo).`);
58
57
  }
package/dist/selftest.js CHANGED
@@ -78,7 +78,7 @@ export function renderSelfTest(r) {
78
78
  return (`rulereceipt selftest\n\n` +
79
79
  ` ${r.total} checks, all correct.\n` +
80
80
  ` 0 network calls — this ran entirely on your machine (watch it with lsof / Little Snitch if you like).\n\n` +
81
- `The same checkers ran here as on your real sessions. If any of these were ever wrong, this would say so.`);
81
+ `The same checkers ran here as on your real sessions. These are a sample of hand-checked cases across the checkers — a regression in any of them shows up here (it is not exhaustive proof).`);
82
82
  }
83
83
  const lines = r.failures.map((f) => ` ✗ ${f.name} — ${f.detail}`);
84
84
  return `rulereceipt selftest\n\n ${r.passed}/${r.total} correct, ${r.failures.length} WRONG:\n` + lines.join("\n") + `\n\nThis is a bug — please report it with the version (rulereceipt --version).`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
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",