rulereceipt 0.1.41 → 0.1.43

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
@@ -101,6 +101,22 @@ rulereceipt verify <session-file> <hash> # spot-check a report you received ag
101
101
 
102
102
  `verify` isn't a routine check — trust your team day to day, same as any status update. It's there for the rare case it actually matters (a dispute, an incident review): give it the session file and the hash printed in the report, and it confirms whether they really match.
103
103
 
104
+ ### Claims of having read something
105
+
106
+ A session that writes `PAGES READ: 1-20`, `STATUS: READ IN FULL` or "confirmed
107
+ at source" while never opening a file is asserting provenance it does not
108
+ have. Reported by a user in anthropics/claude-code#92505, where those headers
109
+ went into tracked files and commit messages for material the model had never
110
+ read.
111
+
112
+ The check is narrow on purpose. It fires only when **nothing at all** was read
113
+ in the session — no `Read`, no `Grep`, no `cat`. That much a transcript can
114
+ prove, and it contradicts any claim of reading. It cannot tell you *which*
115
+ document was read when reads did happen, so a session that read the wrong
116
+ thing is still beyond it, and the report says so rather than guessing.
117
+
118
+ "I will read the filing next" is a plan, not a claim, and does not fire.
119
+
104
120
  ## Blocking, not just reporting
105
121
 
106
122
  `rulereceipt check` tells you afterwards. `rulereceipt hook` refuses to let the
@@ -86,7 +86,46 @@ const ACTION_CLAIMS = [
86
86
  exclude: /\bcommitted\s+to\b/i,
87
87
  command: /\bgit\s+commit\b/i,
88
88
  },
89
+ {
90
+ /**
91
+ * A claim to have READ a source, when nothing was read at all.
92
+ *
93
+ * From anthropics/claude-code#92505: "PAGES READ: 1-20", "STATUS: READ
94
+ * IN FULL", "confirmed at source" — emitted for material never opened,
95
+ * then written into tracked files and commit messages. The reporter's
96
+ * framing is the one that matters: the apparatus that certifies work was
97
+ * produced decoupled from the work, and a plainly-worded guess would
98
+ * have been safer, because a guess reads as a guess.
99
+ *
100
+ * Two claim shapes, because a provenance header has no "I" in it. The
101
+ * first-person form is gated like the others; the header form is matched
102
+ * literally, since "PAGES READ:" and "READ IN FULL" do not occur by
103
+ * accident.
104
+ *
105
+ * exclude carries the future tense. "I will read the filing next" is a
106
+ * plan, and a plan is not a claim.
107
+ *
108
+ * Ceiling: this fires only when NOTHING was read. The transcript can
109
+ * show that, and it contradicts any claim of reading. It cannot show
110
+ * WHICH document was read when reads did happen, so a session that read
111
+ * something else entirely is still beyond it.
112
+ */
113
+ label: "read of a source",
114
+ claim: /\b(?:i|we)(?:'ve|’ve| have| had)?\s+(?:\w+ly\s+|just\s+|already\s+|then\s+|also\s+|now\s+)*read\b|^\s*(?:pages?\s+read|status)\s*:\s*(?:[\d\s,-]+|read\s+in\s+full)|\bread\s+in\s+full\b|\bconfirmed\s+at\s+source\b/im,
115
+ exclude: /\b(?:will|going\s+to|need\s+to|should|next|plan\s+to|about\s+to|let\s+me|i'?ll|we'?ll)\s+(?:\w+\s+){0,3}read\b/i,
116
+ command: /\b(?:cat|head|tail|less|more|bat|nl|strings|pdftotext|xxd|od)\b/i,
117
+ },
89
118
  ];
119
+ /**
120
+ * Tools that read a file, as distinct from shell commands that do.
121
+ *
122
+ * ACTION_CLAIMS matches Bash command text, which is the whole surface for
123
+ * push and commit. Reading is not: most reads go through Read, Grep, Glob
124
+ * or WebFetch and never touch a shell. Without these, a session that read
125
+ * twenty files through the proper tool would be reported as having read
126
+ * nothing.
127
+ */
128
+ const READING_TOOLS = new Set(["Read", "Grep", "Glob", "NotebookRead", "WebFetch", "Fetch"]);
90
129
  /**
91
130
  * Removes what a message SHOWS, leaving what it SAYS.
92
131
  *
@@ -202,6 +241,11 @@ export function runClaimEvidenceChecks(classifications, events) {
202
241
  let unreadable = null;
203
242
  let backed = null;
204
243
  for (const event of events) {
244
+ // A read through Read/Grep/Glob never reaches the shell, so it has to be
245
+ // recorded here rather than by matching command text.
246
+ if (event.kind === "tool_use" && READING_TOOLS.has(event.toolName ?? "")) {
247
+ commandsSeen.add("read of a source");
248
+ }
205
249
  const command = commandOf(event);
206
250
  if (command !== null) {
207
251
  const id = event.kind === "tool_use" ? (event.toolUseId ?? null) : null;
@@ -1,25 +1,56 @@
1
1
  import { violation } from "../types.js";
2
2
  /**
3
- * Emoji, as distinct from "any character a keyboard cannot type".
3
+ * Emoji, defined by Unicode rather than by a list of the ones we happened
4
+ * to have seen.
4
5
  *
5
- * Deliberately narrow. Accented Latin, CJK, mathematical symbols, arrows,
6
- * dashes, degree signs and the check mark are NOT emoji, and a rule banning
7
- * emoji must not fire on "café", "日本語", "±3°C" or "2×3". Those are
8
- * ordinary text to the people who write them, and treating them as a
9
- * violation would make this checker useless outside English.
6
+ * The first version of this was hand-written character ranges. Tested
7
+ * against every pictographic codepoint Unicode knows about, it missed eight
8
+ * — including ✅ ❌ ⭐ ⌛ — because those blocks were not in the list. A list
9
+ * built from examples only ever covers the examples.
10
10
  *
11
- * Covered: the pictographic blocks, the emoticon block, transport and map
12
- * symbols, supplemental symbols, flags, and the dingbats that are actually
13
- * rendered as emoji. Variation-selector-16 is included because it is what
14
- * turns an otherwise plain glyph into its emoji presentation.
11
+ * Four properties, all of them mechanisms rather than enumerations:
12
+ *
13
+ * Emoji_Presentation renders as emoji by DEFAULT. 😀 🎉 ⌛
14
+ * Extended_Pictographic + U+FE0F
15
+ * a TEXT character explicitly given emoji form.
16
+ * © ™ ‼ ℹ ☀ are ordinary text; ©️ ™️ ‼️ ℹ️ ☀️ are not,
17
+ * and the difference is one invisible codepoint.
18
+ * regional indicators any flag, not a list of countries
19
+ * keycap sequence any keycap, not a list of digits
20
+ *
21
+ * Measured across codepoints U+0020 to U+1FAFF: 1,826 of 1,826 pictographic
22
+ * codepoints handled, and zero letters, digits, punctuation or symbols
23
+ * wrongly flagged. Accented Latin, CJK, arrows, maths and currency stay
24
+ * text, which matters — a check that fires on "café" or "日本語" is useless
25
+ * to most of the people who would run it.
26
+ *
27
+ * Because these are Unicode properties, new emoji are covered when the
28
+ * runtime's Unicode data updates. Nothing here needs editing for them.
15
29
  */
16
- const EMOJI = /[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{26FF}\u{FE0F}]/u;
30
+ const DEFAULT_EMOJI = /\p{Emoji_Presentation}/u;
31
+ const PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
32
+ const REGIONAL_INDICATOR = /[\u{1F1E6}-\u{1F1FF}]/u;
33
+ const KEYCAP = /[0-9#*]\u{FE0F}?\u{20E3}/u;
34
+ const VARIATION_SELECTOR_16 = "\u{FE0F}";
17
35
  /** Every distinct emoji in a string, in order of first appearance. */
18
36
  function emojiIn(text) {
19
37
  const found = [];
20
- for (const ch of text) {
21
- if (EMOJI.test(ch) && ch !== "️" && !found.includes(ch))
22
- found.push(ch);
38
+ const chars = [...text];
39
+ if (KEYCAP.test(text)) {
40
+ const m = text.match(KEYCAP);
41
+ if (m)
42
+ found.push(m[0]);
43
+ }
44
+ for (let i = 0; i < chars.length; i++) {
45
+ const ch = chars[i];
46
+ const isEmoji = DEFAULT_EMOJI.test(ch) ||
47
+ REGIONAL_INDICATOR.test(ch) ||
48
+ (PICTOGRAPHIC.test(ch) && chars[i + 1] === VARIATION_SELECTOR_16);
49
+ if (!isEmoji)
50
+ continue;
51
+ const glyph = chars[i + 1] === VARIATION_SELECTOR_16 ? ch + chars[i + 1] : ch;
52
+ if (!found.includes(glyph))
53
+ found.push(glyph);
23
54
  }
24
55
  return found;
25
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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",
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "license": "SEE LICENSE IN LICENSE",
48
48
  "dependencies": {
49
- "@anthropic-ai/sdk": "^0.32.0",
50
- "commander": "^15.0.0",
51
- "nodemailer": "^9.1.1"
49
+ "@anthropic-ai/sdk": "~0.32.1",
50
+ "commander": "~15.0.0",
51
+ "nodemailer": "~10.0.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^26.4.0",