rulereceipt 0.1.51 → 0.1.53
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/checks/classify.js
CHANGED
|
@@ -567,6 +567,13 @@ export function approvalGateActions(rule) {
|
|
|
567
567
|
function isApprovalGateRule(rule) {
|
|
568
568
|
return approvalGateActions(rule).length > 0;
|
|
569
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* A subordinating condition that scopes when a prohibition applies. A literal
|
|
572
|
+
* matcher cannot read it, so a forbid carrying one is routed to judgment
|
|
573
|
+
* rather than flagged on every occurrence. Kept to clear scoping words so a
|
|
574
|
+
* flat prohibition that merely contains "if" in passing is not swept in.
|
|
575
|
+
*/
|
|
576
|
+
const CONDITIONAL_SCOPE = /\b(?:when(?:ever)?|unless|except\s+when|only\s+(?:if|when)|as\s+long\s+as|provided\s+that|in\s+cases?\s+where)\b|\bif\s+(?:you|the|it|they|we|a|an|on|in|running|building|committing|pushing|deploying)\b/i;
|
|
570
577
|
export function classifyRule(rule) {
|
|
571
578
|
// Checked first: if this isn't a rule at all, no check of any kind
|
|
572
579
|
// should run against it — not a keyword match, not an LLM call.
|
|
@@ -634,6 +641,19 @@ export function classifyRule(rule) {
|
|
|
634
641
|
if (BRANCH_WORD.test(text) && branchName !== undefined) {
|
|
635
642
|
return { kind: "gitBranchPolicy", rule, branchName, polarity, polarityInferred };
|
|
636
643
|
}
|
|
644
|
+
// A forbid scoped by a condition the literal checkers cannot evaluate.
|
|
645
|
+
//
|
|
646
|
+
// Raised by etoryoki on anthropics/claude-code#2544: "Never run `terraform
|
|
647
|
+
// apply` when you're on main" read as "never run it" flags every correct run
|
|
648
|
+
// elsewhere. A deterministic literal match sees only the command, not the
|
|
649
|
+
// "when/unless/if" that scopes it, so it accuses every occurrence. Routing
|
|
650
|
+
// these to judgment lets the condition actually be weighed — the same choice
|
|
651
|
+
// etoryoki made ("treat conditional or hedged sentences as not checkable
|
|
652
|
+
// instead of guessing"). Placed AFTER the branch check on purpose:
|
|
653
|
+
// gitBranchPolicy evaluates its own branch condition and must keep those.
|
|
654
|
+
if (polarity === "forbid" && CONDITIONAL_SCOPE.test(text)) {
|
|
655
|
+
return { kind: "judgment", rule };
|
|
656
|
+
}
|
|
637
657
|
// Route on ANY code-shaped literal, but check ONLY the code-shaped ones.
|
|
638
658
|
// This used to pass every literal through once one of them looked like
|
|
639
659
|
// code, so a rule mentioning `foo(` and `name` searched written files for
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { loadRules } from "./rules.js";
|
|
|
11
11
|
import { adviseRules } from "./checkability.js";
|
|
12
12
|
import { shadowedAgentsMd } from "./shadowedAgents.js";
|
|
13
13
|
import { partitionByAge, futureResult } from "./ruleAge.js";
|
|
14
|
+
import { auditSessions, renderComplianceReport } from "./report/complianceReport.js";
|
|
14
15
|
import { classifyRules } from "./checks/classify.js";
|
|
15
16
|
import { loadOverrides, saveOverride, clearOverride, staleOverrides, ruleFingerprint, OVERRIDES_PATH } from "./overrides.js";
|
|
16
17
|
import { runDeterministicChecks } from "./checks/deterministicChecks.js";
|
|
@@ -865,6 +866,16 @@ const PERIOD_MS = {
|
|
|
865
866
|
weekly: 7 * 24 * 60 * 60 * 1000,
|
|
866
867
|
monthly: 30 * 24 * 60 * 60 * 1000,
|
|
867
868
|
};
|
|
869
|
+
program
|
|
870
|
+
.command("report")
|
|
871
|
+
.description("Compliance report across your recent sessions (not just the latest): which policy rules were broken, where, with evidence. Deterministic, local, no network. The org-wide version runs via the Claude Compliance API for Enterprise orgs.")
|
|
872
|
+
.option("--last <n>", "how many recent sessions to audit", "25")
|
|
873
|
+
.option("--markdown", "output as markdown, for a report you can send")
|
|
874
|
+
.action(async (opts) => {
|
|
875
|
+
const n = Number.parseInt(String(opts.last), 10);
|
|
876
|
+
const r = await auditSessions(process.cwd(), Number.isFinite(n) ? n : 25);
|
|
877
|
+
console.log(renderComplianceReport(r, Boolean(opts.markdown)));
|
|
878
|
+
});
|
|
868
879
|
program
|
|
869
880
|
.command("digest")
|
|
870
881
|
.description("A non-technical summary of recent check runs (counts only, no rule text) — for a manager who doesn't have time to read 30 individual reports.")
|
|
@@ -5,6 +5,8 @@ import type { TranscriptEvent } from "../types.js";
|
|
|
5
5
|
* variant could keep its own global rules file under its own home dir.
|
|
6
6
|
*/
|
|
7
7
|
export declare function findClaudeHomeDirNames(): string[];
|
|
8
|
+
/** Every session file for this project, newest first. */
|
|
9
|
+
export declare function listAllSessionFiles(cwd: string): string[];
|
|
8
10
|
export declare function findLatestSessionFile(cwd: string): string | null;
|
|
9
11
|
export declare function parseLine(line: string): TranscriptEvent[];
|
|
10
12
|
/**
|
|
@@ -69,13 +69,16 @@ export function findClaudeHomeDirNames() {
|
|
|
69
69
|
return [];
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
/** Every session file for this project, newest first. */
|
|
73
|
+
export function listAllSessionFiles(cwd) {
|
|
73
74
|
const encoded = encodeProjectPath(cwd);
|
|
74
75
|
const sessionFiles = findClaudeHomeDirNames().flatMap((dirName) => listSessionFiles(join(homedir(), dirName, "projects", encoded)));
|
|
75
|
-
if (sessionFiles.length === 0)
|
|
76
|
-
return null;
|
|
77
76
|
sessionFiles.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
78
|
-
return sessionFiles
|
|
77
|
+
return sessionFiles;
|
|
78
|
+
}
|
|
79
|
+
export function findLatestSessionFile(cwd) {
|
|
80
|
+
const all = listAllSessionFiles(cwd);
|
|
81
|
+
return all.length > 0 ? all[0] : null;
|
|
79
82
|
}
|
|
80
83
|
function extractToolResultText(content) {
|
|
81
84
|
if (typeof content === "string")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CheckResult } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A compliance report over MANY sessions, not one.
|
|
4
|
+
*
|
|
5
|
+
* The free `check` reads a single latest session — one dev, one run. The
|
|
6
|
+
* enterprise question is different: "across every agent session in this org
|
|
7
|
+
* this month, which policy rules were broken, and where's the evidence?"
|
|
8
|
+
* This aggregates per-session results into that org-style summary. It runs on
|
|
9
|
+
* whatever sessions are reachable locally today; the same aggregation feeds
|
|
10
|
+
* the Compliance API (org-wide sessions) once an enterprise grants a key.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic by default — no LLM, no network — so it can run over hundreds
|
|
13
|
+
* of sessions fast. Judgment rules are surfaced as "needs review", never
|
|
14
|
+
* guessed at, same honesty as everywhere else.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionAudit {
|
|
17
|
+
session: string;
|
|
18
|
+
when: string;
|
|
19
|
+
events: number;
|
|
20
|
+
violations: CheckResult[];
|
|
21
|
+
}
|
|
22
|
+
export interface ComplianceReport {
|
|
23
|
+
sessionsChecked: number;
|
|
24
|
+
sessionsWithViolations: number;
|
|
25
|
+
totalViolations: number;
|
|
26
|
+
ruleCount: number;
|
|
27
|
+
byRule: {
|
|
28
|
+
title: string;
|
|
29
|
+
count: number;
|
|
30
|
+
}[];
|
|
31
|
+
sessions: SessionAudit[];
|
|
32
|
+
}
|
|
33
|
+
export declare function auditSessions(cwd: string, limit: number): Promise<ComplianceReport>;
|
|
34
|
+
/** A readable / pasteable compliance report. Markdown when `md` is set. */
|
|
35
|
+
export declare function renderComplianceReport(r: ComplianceReport, md?: boolean): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { loadRules } from "../rules.js";
|
|
3
|
+
import { evaluateSession } from "../evaluate.js";
|
|
4
|
+
import { readTranscriptFromFile, listAllSessionFiles } from "../parsers/transcriptParser.js";
|
|
5
|
+
function needsReview(rule) {
|
|
6
|
+
return {
|
|
7
|
+
ruleId: rule.id, ruleTitle: rule.title, ruleSource: rule.source,
|
|
8
|
+
status: "UNCLEAR", outcome: "not_run", method: "none", needsHuman: true,
|
|
9
|
+
evidence: "judgment rule — not graded without --llm",
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function auditSessions(cwd, limit) {
|
|
13
|
+
const rules = loadRules(cwd);
|
|
14
|
+
const files = listAllSessionFiles(cwd).slice(0, Math.max(1, limit));
|
|
15
|
+
const sessions = [];
|
|
16
|
+
const byRule = new Map();
|
|
17
|
+
let totalViolations = 0;
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
const events = readTranscriptFromFile(file);
|
|
20
|
+
if (events.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
const { results } = await evaluateSession(cwd, rules, events, false, needsReview);
|
|
23
|
+
const violations = results.filter((r) => r.status === "FAIL");
|
|
24
|
+
for (const v of violations)
|
|
25
|
+
byRule.set(v.ruleTitle, (byRule.get(v.ruleTitle) ?? 0) + 1);
|
|
26
|
+
totalViolations += violations.length;
|
|
27
|
+
const first = events.find((e) => e.timestamp)?.timestamp ?? "";
|
|
28
|
+
sessions.push({ session: basename(file).replace(/\.jsonl$/, ""), when: first.slice(0, 10), events: events.length, violations });
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
sessionsChecked: sessions.length,
|
|
32
|
+
sessionsWithViolations: sessions.filter((s) => s.violations.length > 0).length,
|
|
33
|
+
totalViolations,
|
|
34
|
+
ruleCount: rules.length,
|
|
35
|
+
byRule: [...byRule.entries()].map(([title, count]) => ({ title, count })).sort((a, b) => b.count - a.count),
|
|
36
|
+
sessions,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A readable / pasteable compliance report. Markdown when `md` is set. */
|
|
40
|
+
export function renderComplianceReport(r, md = false) {
|
|
41
|
+
const H = (s) => (md ? `## ${s}` : s);
|
|
42
|
+
const out = [];
|
|
43
|
+
out.push(md ? "# RuleReceipt — Compliance Report" : "RuleReceipt · Compliance Report");
|
|
44
|
+
out.push("");
|
|
45
|
+
out.push(`Checked ${r.sessionsChecked} session${r.sessionsChecked === 1 ? "" : "s"} against ${r.ruleCount} rules (project + global).`);
|
|
46
|
+
out.push("");
|
|
47
|
+
out.push(`${r.sessionsWithViolations} of ${r.sessionsChecked} sessions had at least one policy violation.`);
|
|
48
|
+
out.push(`${r.totalViolations} total violation${r.totalViolations === 1 ? "" : "s"} across ${r.byRule.length} distinct rule${r.byRule.length === 1 ? "" : "s"}.`);
|
|
49
|
+
out.push("");
|
|
50
|
+
if (r.byRule.length > 0) {
|
|
51
|
+
out.push(H("Most-violated rules"));
|
|
52
|
+
for (const { title, count } of r.byRule.slice(0, 10)) {
|
|
53
|
+
out.push(` ${String(count).padStart(3)}× ${title.replace(/\s+/g, " ").trim().slice(0, 70)}`);
|
|
54
|
+
}
|
|
55
|
+
out.push("");
|
|
56
|
+
}
|
|
57
|
+
const flagged = r.sessions.filter((s) => s.violations.length > 0);
|
|
58
|
+
if (flagged.length > 0) {
|
|
59
|
+
out.push(H("Sessions with violations"));
|
|
60
|
+
for (const s of flagged) {
|
|
61
|
+
out.push(` ${s.session.slice(0, 12)} ${s.when} — ${s.violations.length} violation${s.violations.length === 1 ? "" : "s"}`);
|
|
62
|
+
for (const v of s.violations.slice(0, 5)) {
|
|
63
|
+
out.push(` ✕ ${v.ruleTitle.replace(/\s+/g, " ").trim().slice(0, 66)}`);
|
|
64
|
+
out.push(` ${v.evidence.replace(/\s+/g, " ").trim().slice(0, 90)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
out.push("");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
out.push("No mechanical policy violations found in the sessions checked.");
|
|
71
|
+
out.push("");
|
|
72
|
+
}
|
|
73
|
+
out.push("Deterministic checks only. Judgment rules are not graded here (run per-session with --llm).");
|
|
74
|
+
out.push("Local sessions today; the same report runs org-wide via the Claude Compliance API for Enterprise orgs.");
|
|
75
|
+
return out.join("\n");
|
|
76
|
+
}
|