rulereceipt 0.1.70 → 0.1.72
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 +5 -2
- package/dist/checks/approvalGate.js +4 -2
- package/dist/checks/ifEditThenTest.js +10 -2
- package/dist/checks/judgmentChecks.js +18 -1
- package/dist/checks/shellCommand.d.ts +1 -0
- package/dist/checks/shellCommand.js +25 -1
- package/dist/cli.js +22 -4
- package/dist/historyReport.js +1 -1
- package/dist/protect.d.ts +10 -0
- package/dist/protect.js +16 -4
- package/dist/report/gateOffer.js +2 -3
- package/dist/selftest.js +1 -1
- package/package.json +1 -1
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 —
|
|
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) —
|
|
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
|
-
|
|
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) {
|
|
@@ -97,12 +97,20 @@ export function runIfEditThenTestChecks(classifications, events) {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
if (testPaths.length === 0) {
|
|
100
|
+
// Shadow, 2026-09-29: "every change needs a test" is a REQUIRE rule, and
|
|
101
|
+
// an edit with no test touched or run is not PROOF the rule was broken —
|
|
102
|
+
// the test may not have been needed, or may have run in another terminal.
|
|
103
|
+
// The product's guarantee is that "Broken" means a forbidden action
|
|
104
|
+
// actually happened, or a claim was contradicted by the session; this is
|
|
105
|
+
// neither, so it reports "can't tell", never a fabricated FAIL. Promote to
|
|
106
|
+
// Broken only after 30+ real cases are hand-checked at 0 wrong.
|
|
100
107
|
return {
|
|
101
108
|
ruleId: rule.id,
|
|
102
109
|
ruleTitle: rule.title,
|
|
103
110
|
ruleSource: rule.source,
|
|
104
|
-
status: "
|
|
105
|
-
|
|
111
|
+
status: "UNCLEAR",
|
|
112
|
+
method: "edit_test_pairing",
|
|
113
|
+
evidence: `edited ${prodPaths.slice(0, 3).join(", ")}${prodPaths.length > 3 ? ", ..." : ""}, and no test file was touched or run this session — can't tell whether this change needed a test`,
|
|
106
114
|
};
|
|
107
115
|
}
|
|
108
116
|
return {
|
|
@@ -261,7 +261,24 @@ export async function runJudgmentChecks(classifications, events) {
|
|
|
261
261
|
: "the situation this rule governs never arose in this session",
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
|
-
if (status === "
|
|
264
|
+
if (status === "FAIL") {
|
|
265
|
+
// An AI opinion is NEVER a verdict — the product's guarantee is that
|
|
266
|
+
// "Broken" comes only from a forbidden action that happened or a claim
|
|
267
|
+
// contradicted by the session, never a model's guess. A model "FAIL" is
|
|
268
|
+
// surfaced as a clearly-labelled opinion needing a human, so it can never
|
|
269
|
+
// be counted as Broken in the report, history, card, digest or badge.
|
|
270
|
+
const evidence = (parsed.evidence ?? "").trim();
|
|
271
|
+
return {
|
|
272
|
+
ruleId: rule.id,
|
|
273
|
+
ruleTitle: rule.title,
|
|
274
|
+
ruleSource: rule.source,
|
|
275
|
+
status: "UNCLEAR",
|
|
276
|
+
needsHuman: true,
|
|
277
|
+
method: "model_judgment",
|
|
278
|
+
evidence: `AI opinion (not a verdict — a human should decide): this looks broken. ${evidence}${transcript.truncated ? ` ${TRUNCATION_NOTE}` : ""}`.trim(),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (status === "PASS" || status === "UNCLEAR") {
|
|
265
282
|
const evidence = parsed.evidence ?? "";
|
|
266
283
|
return {
|
|
267
284
|
ruleId: rule.id,
|
|
@@ -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: "
|
|
81
|
-
|
|
82
|
-
{ ruleId: "
|
|
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;
|
package/dist/historyReport.js
CHANGED
|
@@ -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
|
|
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
|
|
56
|
-
//
|
|
57
|
-
|
|
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`);
|
package/dist/report/gateOffer.js
CHANGED
|
@@ -52,7 +52,6 @@ export function gateOffer(state) {
|
|
|
52
52
|
return null;
|
|
53
53
|
if (state.hookInstalled)
|
|
54
54
|
return null;
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.1.72",
|
|
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",
|