rulereceipt 0.1.61 → 0.1.62

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/guard.d.ts CHANGED
@@ -73,6 +73,6 @@ export interface GuardDecision {
73
73
  */
74
74
  export declare function guardDecision(cwd: string, toolName: string, toolInput: {
75
75
  command?: unknown;
76
- } & Record<string, unknown>, events?: TranscriptEvent[]): GuardDecision;
76
+ } & Record<string, unknown>, events?: TranscriptEvent[], permissionMode?: string): GuardDecision;
77
77
  export declare function runGuard(): Promise<void>;
78
78
  export {};
package/dist/guard.js CHANGED
@@ -6,8 +6,39 @@ 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 } from "./checks/approvalGate.js";
9
+ import { approvalOccurrences, allowListed } from "./checks/approvalGate.js";
10
10
  import { readTranscriptFromFile } from "./parsers/transcriptParser.js";
11
+ import { readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ /**
15
+ * Modes where Claude Code shows NO permission prompt, so a hook's "ask" is
16
+ * ignored and the call just runs (Claude Code #89561; "ask" also drops bypass
17
+ * mode, #37420; headless silently denies, #95726). In these the only thing that
18
+ * actually stops an unapproved gated action is a real deny.
19
+ */
20
+ const NO_PROMPT_MODES = new Set(["bypassPermissions", "auto", "dontAsk"]);
21
+ /**
22
+ * `permissions.deny` from the Claude Code settings that apply here. If the user
23
+ * already denies a command, the guard must NOT answer "ask" for it — an "ask"
24
+ * can switch a deny off and let the command run with no prompt (Claude Code
25
+ * #39344). So a command the user denies is left entirely to Claude Code's own
26
+ * deny; the guard stands aside.
27
+ */
28
+ function claudeDenyList(cwd) {
29
+ const out = [];
30
+ for (const p of [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json"), join(homedir(), ".claude", "settings.json")]) {
31
+ try {
32
+ const deny = JSON.parse(readFileSync(p, "utf-8")).permissions?.deny;
33
+ if (Array.isArray(deny))
34
+ out.push(...deny.filter((x) => typeof x === "string"));
35
+ }
36
+ catch {
37
+ /* absent or unreadable */
38
+ }
39
+ }
40
+ return out;
41
+ }
11
42
  function readStdin() {
12
43
  return new Promise((resolve) => {
13
44
  let data = "";
@@ -148,19 +179,20 @@ function reason(blocks) {
148
179
  * The approval half of the guard. Uses the same per-action logic as the report
149
180
  * (approvalOccurrences) so the two can never disagree: the proposed call is
150
181
  * appended to the session as if no prompt were possible, and if the report
151
- * would call it unapproved, the guard asks.
182
+ * would call it unapproved, this returns the rule and action so the caller can
183
+ * decide — by the current permission mode — whether to ask or to deny.
152
184
  */
153
- function approvalAsk(cwd, command, events) {
185
+ function unapprovedGate(cwd, command, events) {
154
186
  const gates = classifyRules(loadRules(cwd)).filter((c) => c.kind === "approvalGate");
155
187
  for (const { rule, actions } of gates) {
156
188
  const proposed = { role: "assistant", kind: "tool_use", toolName: "Bash", input: { command }, timestamp: "", permissionMode: "dontAsk" };
157
189
  const occ = approvalOccurrences([...events, proposed], actions);
158
190
  const last = occ[occ.length - 1];
159
191
  if (last && last.command === command.replace(/\s+/g, " ").trim().slice(0, 80) && last.verdict !== "approved") {
160
- return `RuleReceipt: your rule "${rule.title.slice(0, 120)}" needs your OK for this ${last.action}, and nothing in this session approved it yet.`;
192
+ return { rule, action: last.action };
161
193
  }
162
194
  }
163
- return "";
195
+ return null;
164
196
  }
165
197
  /**
166
198
  * The allow/deny decision for one proposed tool call, with no I/O.
@@ -173,7 +205,7 @@ function approvalAsk(cwd, command, events) {
173
205
  * thin when it lands. Same reasoning as evaluateSession: one body of code so
174
206
  * two callers can never disagree about whether a rule was broken.
175
207
  */
176
- export function guardDecision(cwd, toolName, toolInput, events = []) {
208
+ export function guardDecision(cwd, toolName, toolInput, events = [], permissionMode) {
177
209
  const allow = { deny: false, reason: "", blocks: [] };
178
210
  if (loadRules(cwd).length === 0)
179
211
  return allow;
@@ -197,10 +229,37 @@ export function guardDecision(cwd, toolName, toolInput, events = []) {
197
229
  }
198
230
  if (blocks.length > 0)
199
231
  return { deny: true, reason: reason(blocks), blocks };
232
+ // Approval gates ("never push/commit without asking"). What we answer depends
233
+ // on the permission mode, because a hook's "ask" is only honoured in the modes
234
+ // that actually show a prompt (Claude Code #89561/#37420/#95726):
235
+ // - the user already DENIES this command -> stand aside (never weaken a
236
+ // deny with an "ask", #39344); Claude Code's own deny handles it.
237
+ // - no-prompt mode (bypass/auto/dontAsk) or headless -> real DENY with a
238
+ // reason, because "ask" is ignored there and would let the push run. The
239
+ // per-action check clears it after the user says yes in chat and it retries.
240
+ // - default/acceptEdits/plan (or unknown) -> "ask": the prompt appears and
241
+ // the user decides. Unknown modes ask rather than deny so we never wrongly
242
+ // block a legitimate action.
200
243
  if (toolName === "Bash" && typeof toolInput.command === "string") {
201
- const ask = approvalAsk(cwd, toolInput.command, events);
202
- if (ask)
203
- return { deny: false, reason: "", blocks: [], ask };
244
+ const gate = unapprovedGate(cwd, toolInput.command, events);
245
+ if (gate) {
246
+ if (allowListed(toolInput.command, claudeDenyList(cwd)))
247
+ return allow;
248
+ const title = gate.rule.title.slice(0, 120);
249
+ if (permissionMode && NO_PROMPT_MODES.has(permissionMode)) {
250
+ return {
251
+ deny: true,
252
+ reason: `RuleReceipt: your rule "${title}" needs your OK for this ${gate.action}. Claude Code does not show a prompt in ${permissionMode} mode, so this call is stopped. Ask the user in the chat; after they say yes, run it again.`,
253
+ blocks: [{ rule: gate.rule, why: `${gate.action} with no approval, and no prompt would be shown in ${permissionMode} mode` }],
254
+ };
255
+ }
256
+ return {
257
+ deny: false,
258
+ reason: "",
259
+ blocks: [],
260
+ ask: `RuleReceipt: your rule "${title}" needs your OK for this ${gate.action}, and nothing in this session approved it yet.`,
261
+ };
262
+ }
204
263
  }
205
264
  return allow;
206
265
  }
@@ -223,7 +282,7 @@ export async function runGuard() {
223
282
  /* unreadable: judge the call on its own, which can only ask more, never less */
224
283
  }
225
284
  }
226
- const decision = guardDecision(cwd, tool, toolInput, events);
285
+ const decision = guardDecision(cwd, tool, toolInput, events, input.permission_mode);
227
286
  if (!decision.deny && decision.ask) {
228
287
  // "ask" is not a refusal: no exit 2. Claude Code shows its permission
229
288
  // prompt with this reason; the user's click decides.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulereceipt",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
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",