crewhaus 0.1.8 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +173 -0
  25. package/dist/dataset-mine.js +404 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11664 -963
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -0,0 +1,656 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ /**
4
+ * Item 48 — `crewhaus security digest` plumbing, factored out of the entry
5
+ * file `index.ts` (which runs a top-level argv switch and so cannot be
6
+ * imported by a test without executing the CLI). Side-effect-free on import
7
+ * and directly unit-testable, mirroring `audit-verify.ts` / `retention.ts`.
8
+ *
9
+ * The problem this closes: warn-tier egress verdicts, boundary redactions,
10
+ * injection hits, justification denials and circuit events all accumulate in
11
+ * `.crewhaus/audit` JSONL and on the TraceEventBus, but nothing triages them
12
+ * — answering "are we being probed, or over-blocking?" meant hand-grepping.
13
+ * This module walks the durable stores and emits a ranked rollup; the live
14
+ * per-run tally lives in `@crewhaus/runtime-core`'s observability module
15
+ * (CREWHAUS_SECURITY_DIGEST=1).
16
+ *
17
+ * WHAT THE AUDIT KINDS ACTUALLY CARRY TODAY (verified by grepping for
18
+ * writers, 2026-07 — the digest reports on what has writers and keeps the
19
+ * declared-but-writerless kinds visible instead of fabricating sections):
20
+ *
21
+ * kinds WITH writers:
22
+ * - permission_justification_evaluated runtime-core's intent gate
23
+ * (payload { toolName, justification, verdict, reason, judgeModel,
24
+ * confidence? }) — powers the top-denied-tools + judge-deny-rate rollup.
25
+ * - policy_decision policy-engine's auditPolicyDecision (payload
26
+ * { toolName, sideEffect, decision, reason, tenantId, matchedRule }) —
27
+ * deny/audit-and-allow rows only by default.
28
+ * - egress_decision runtime-core's egress classifier (AUTOMATION-
29
+ * OPPORTUNITIES item 20; payload { sinkId, sinkScope, verdict,
30
+ * originsFound, matchCount }) — appended durably on every NON-PASS
31
+ * (warn/block) verdict when the egress audit sink is wired, so the
32
+ * rollup carries warn/block egress history; pass verdicts stay
33
+ * ephemeral on the trace bus and leave no audit record.
34
+ * - model_call, gateway_request, secrets_rotation, secrets_access,
35
+ * deployment_action, retention_enforcement, alert_raised (item-31
36
+ * alert watchdog's `AlertAuditSink`, see alert-sink.ts; payload
37
+ * { sessionId, metric, observed, threshold, baselineSessions, detail })
38
+ * — operational kinds; the digest counts them per-kind (countsByKind)
39
+ * but builds no verdict rollup from them.
40
+ *
41
+ * kinds DECLARED in @crewhaus/audit-log but with NO writer anywhere in the
42
+ * tree today: `tool_classification`, `session_fork`, `tenancy_context`. The
43
+ * digest still aggregates records of these kinds by their documented payload
44
+ * shape so the rollup lights up the day a writer lands, and reports them
45
+ * under `absentDeclaredKinds` so "0 rows" reads as "no durable writer yet",
46
+ * not "nothing happened". (egress_decision graduated to the writer list
47
+ * above once runtime-core began appending it on warn/block verdicts; a
48
+ * window with no warn/block egress still lists it as absent — meaning "no
49
+ * warn/block egress in the window", not "no writer".)
50
+ *
51
+ * Because block-tier verdicts DO leave durable residue in the session event
52
+ * logs (`.crewhaus/sessions/<id>.jsonl` `tool_result` events carry the
53
+ * `[justification denied]` / `[egress denied]` / `[blocked by hook]` notices,
54
+ * and injection redactions replace the tool output with
55
+ * `[tool output redacted: prompt injection detected: <rule-ids>]`), the
56
+ * digest ALSO scans session JSONLs in the window — that is where injection
57
+ * rule-id hit counts come from today.
58
+ *
59
+ * TRUST BOUNDARY (adversarial-review F1): everything parsed out of session
60
+ * `tool_result` content is attacker-reachable — any tool that returns
61
+ * malicious output can start it with a forged denial/redaction notice, so
62
+ * the session-derived counters are HEURISTIC (best-effort), never
63
+ * authoritative; the report says so explicitly and keeps them in their own
64
+ * section, separate from the audit-derived rollups. And because a forged
65
+ * notice can carry terminal escape codes or newlines aimed at the
66
+ * operator's terminal, every content-derived string is neutralized at PARSE
67
+ * time ({@link sanitizeContentString}: C0+C1 controls incl. ESC/newlines
68
+ * stripped, length clamped) so every renderer — text, JSON, HTML, --notify
69
+ * payload — sees only sanitized values. Audit-record strings get the same
70
+ * treatment as defense in depth (judge reasons can embed model-generated
71
+ * text). The HTML renderer additionally HTML-escapes, as before.
72
+ *
73
+ * HTML output: apps/cli already depends on `@crewhaus/eval-report`, but its
74
+ * render.ts keeps `shell`/`escapeHtml` private to the eval-report shapes.
75
+ * Rather than widen that package's public API for a CLI-side concern, the
76
+ * digest ships a small local renderer following the same dependency-free
77
+ * style (inline CSS variables, no external assets).
78
+ *
79
+ * Webhook notify: `--notify <url>` POSTs the JSON digest with plain `fetch`
80
+ * — deliberately NOT via `@crewhaus/channel-adapter-slack`, to keep the CLI
81
+ * dependency graph clean (the adapter pulls in channel-runtime concerns the
82
+ * digest doesn't need). Slack incoming webhooks reject arbitrary JSON, so
83
+ * point `--notify` at a receiver that accepts the digest shape, or wrap it:
84
+ * a one-line proxy that forwards `{ text: renderSecurityDigestText(...) }`
85
+ * satisfies Slack.
86
+ */
87
+ const MS_PER_DAY = 86_400_000;
88
+ const AUDIT_RELPATH = ".crewhaus/audit";
89
+ const SESSIONS_RELPATH = ".crewhaus/sessions";
90
+ const AUDIT_DAY_FILE_REGEX = /^(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
91
+ const SESSION_EVENT_LOG_REGEX = /^sess_[0-9a-f]{16}\.jsonl$/;
92
+ /** How many entries each ranked "top …" list carries. */
93
+ const TOP_N = 10;
94
+ /**
95
+ * Every kind declared by `@crewhaus/audit-log`. Kept as a local literal list
96
+ * (rather than importing a value — the package only exports the TYPE) so the
97
+ * digest can report declared kinds that produced zero records in the window.
98
+ *
99
+ * `satisfies ReadonlyArray<AuditKind>` only checks that every entry HERE is a
100
+ * valid `AuditKind` — it is a subset check, not an exhaustiveness check. It
101
+ * does NOT catch a NEW kind added to the `AuditKind` union that this list
102
+ * forgets to list (F7: `alert_raised`, added by the item-31 alert watchdog,
103
+ * compiled cleanly for a while despite being absent here). Keep this list in
104
+ * sync BY HAND whenever `AuditKind` gains a member.
105
+ */
106
+ export const DECLARED_AUDIT_KINDS = [
107
+ "policy_decision",
108
+ "model_call",
109
+ "tool_classification",
110
+ "gateway_request",
111
+ "session_fork",
112
+ "tenancy_context",
113
+ "secrets_rotation",
114
+ "secrets_access",
115
+ "deployment_action",
116
+ "egress_decision",
117
+ "permission_justification_evaluated",
118
+ "retention_enforcement",
119
+ "governance_approval",
120
+ "governance_proposal",
121
+ "alert_raised",
122
+ "slo_mitigation",
123
+ ];
124
+ /** Thrown by `parseSinceFlag` on an unparseable `--since`. The CLI entry
125
+ * file catches it and routes the message through `die()`; tests assert on
126
+ * `.message` without the process exiting. */
127
+ export class InvalidSinceFlagError extends Error {
128
+ value;
129
+ name = "InvalidSinceFlagError";
130
+ constructor(value) {
131
+ super(`invalid --since "${value}" — expected a day window like 7d or 30d, or an ISO date/datetime (e.g. 2026-06-01 or 2026-06-01T00:00:00Z)`);
132
+ this.value = value;
133
+ }
134
+ }
135
+ /**
136
+ * Parse the `--since` flag: `<N>d` (a trailing day window, default `7d`) or
137
+ * an ISO date/datetime (bare dates are UTC midnight per `Date.parse`).
138
+ */
139
+ export function parseSinceFlag(value, now = () => Date.now()) {
140
+ const raw = value ?? "7d";
141
+ const dayMatch = /^(\d+)d$/.exec(raw);
142
+ if (dayMatch !== null) {
143
+ const days = Number(dayMatch[1]);
144
+ if (days <= 0)
145
+ throw new InvalidSinceFlagError(raw);
146
+ return { sinceMs: now() - days * MS_PER_DAY, label: raw };
147
+ }
148
+ const ms = Date.parse(raw);
149
+ if (Number.isNaN(ms))
150
+ throw new InvalidSinceFlagError(raw);
151
+ return { sinceMs: ms, label: raw };
152
+ }
153
+ function asObject(payload) {
154
+ return payload !== null && typeof payload === "object" && !Array.isArray(payload)
155
+ ? payload
156
+ : undefined;
157
+ }
158
+ /** Length clamp for content-derived strings in the digest (reasons, names). */
159
+ const MAX_CONTENT_STRING_LENGTH = 256;
160
+ /** Tighter clamp for injection rule ids — real ids are short slugs. */
161
+ const MAX_RULE_ID_LENGTH = 64;
162
+ // Matches C0 controls (U+0000–U+001F, incl. ESC/newline/CR), DEL (U+007F)
163
+ // and C1 controls (U+0080–U+009F, incl. CSI/OSC single-byte forms).
164
+ // eslint-disable-next-line no-control-regex
165
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-character detection/sanitization
166
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g;
167
+ /**
168
+ * Neutralize a string parsed out of stored session/audit content before it
169
+ * enters the digest (adversarial-review F1). Session `tool_result` content
170
+ * is attacker-reachable — a malicious tool output can embed ANSI escape
171
+ * sequences (rewrite the operator's terminal, forge "ALL CLEAR" lines) or
172
+ * newlines (inject whole fake report lines) into anything the text renderer
173
+ * prints raw. Applied at PARSE time so text, JSON, HTML and the --notify
174
+ * payload all carry only sanitized values: strips C0/C1 control characters
175
+ * (including ESC and line breaks) and clamps the length.
176
+ */
177
+ export function sanitizeContentString(value, maxLength = MAX_CONTENT_STRING_LENGTH) {
178
+ const stripped = value.replace(CONTROL_CHAR_PATTERN, "");
179
+ return stripped.length > maxLength ? `${stripped.slice(0, maxLength)}…` : stripped;
180
+ }
181
+ /** `YYYY-MM-DD` day file → epoch ms of the LAST instant of that UTC day
182
+ * (mirrors retention.ts): a day overlaps the window when its newest
183
+ * possible record does. Undefined for labels that don't round-trip. */
184
+ function endOfUtcDayMs(y, m, d) {
185
+ const startMs = Date.UTC(Number(y), Number(m) - 1, Number(d));
186
+ const roundTrip = new Date(startMs).toISOString().slice(0, 10);
187
+ if (roundTrip !== `${y}-${m}-${d}`)
188
+ return undefined;
189
+ return startMs + MS_PER_DAY - 1;
190
+ }
191
+ function rankEntries(map, score, build) {
192
+ return [...map.entries()]
193
+ .sort((a, b) => score(b[1]) - score(a[1]) || a[0].localeCompare(b[0]))
194
+ .slice(0, TOP_N)
195
+ .map(([name, v]) => build(name, v));
196
+ }
197
+ /**
198
+ * Walk `<rootDir>/.crewhaus/audit` + `<rootDir>/.crewhaus/sessions` and
199
+ * build the windowed rollup. Missing stores yield an empty (but complete)
200
+ * digest — the command is a report, not a gate, so it never throws on an
201
+ * absent or partially-corrupt store; malformed lines are counted instead.
202
+ */
203
+ export function buildSecurityDigest(opts) {
204
+ const rootDir = resolve(opts.rootDir);
205
+ const now = opts.now ?? (() => Date.now());
206
+ const auditDir = join(rootDir, AUDIT_RELPATH);
207
+ const { sinceMs } = opts.window;
208
+ let recordsScanned = 0;
209
+ let recordsInWindow = 0;
210
+ let malformedLines = 0;
211
+ const countsByKind = new Map();
212
+ // Justification rollup accumulators.
213
+ let jEvaluated = 0;
214
+ let jAllowed = 0;
215
+ let jDenied = 0;
216
+ const deniedTools = new Map();
217
+ const judges = new Map();
218
+ // Egress rollup accumulators — fed by egress_decision records (written on
219
+ // warn/block verdicts; see module header).
220
+ let ePassed = 0;
221
+ let eWarned = 0;
222
+ let eBlocked = 0;
223
+ const sinks = new Map();
224
+ const origins = new Map();
225
+ // Policy rollup accumulators.
226
+ let pDenied = 0;
227
+ let pAuditAndAllow = 0;
228
+ let pOther = 0;
229
+ const policyDeniedTools = new Map();
230
+ if (existsSync(auditDir)) {
231
+ const dayFiles = readdirSync(auditDir)
232
+ .filter((f) => {
233
+ const m = AUDIT_DAY_FILE_REGEX.exec(f);
234
+ if (m === null)
235
+ return false;
236
+ const end = endOfUtcDayMs(m[1], m[2], m[3]);
237
+ return end !== undefined && end >= sinceMs;
238
+ })
239
+ .sort();
240
+ for (const file of dayFiles) {
241
+ for (const raw of readFileSync(join(auditDir, file), "utf8").split("\n")) {
242
+ if (raw === "")
243
+ continue;
244
+ let record;
245
+ try {
246
+ record = JSON.parse(raw);
247
+ }
248
+ catch {
249
+ malformedLines += 1;
250
+ continue;
251
+ }
252
+ recordsScanned += 1;
253
+ if (typeof record.ts !== "number" || record.ts < sinceMs)
254
+ continue;
255
+ recordsInWindow += 1;
256
+ const kind = typeof record.kind === "string" ? record.kind : "(unknown)";
257
+ countsByKind.set(kind, (countsByKind.get(kind) ?? 0) + 1);
258
+ if (kind === "permission_justification_evaluated") {
259
+ const p = (asObject(record.payload) ?? {});
260
+ const toolName = typeof p.toolName === "string" ? sanitizeContentString(p.toolName) : "(unknown tool)";
261
+ const judgeModel = typeof p.judgeModel === "string"
262
+ ? sanitizeContentString(p.judgeModel)
263
+ : "(unknown judge)";
264
+ const denied = p.verdict === "deny";
265
+ jEvaluated += 1;
266
+ if (denied)
267
+ jDenied += 1;
268
+ else
269
+ jAllowed += 1;
270
+ const judge = judges.get(judgeModel) ?? { evaluated: 0, denied: 0 };
271
+ judge.evaluated += 1;
272
+ if (denied)
273
+ judge.denied += 1;
274
+ judges.set(judgeModel, judge);
275
+ if (denied) {
276
+ const t = deniedTools.get(toolName) ?? {
277
+ denials: 0,
278
+ judges: new Set(),
279
+ confidences: [],
280
+ };
281
+ t.denials += 1;
282
+ t.judges.add(judgeModel);
283
+ if (typeof p.confidence === "number")
284
+ t.confidences.push(p.confidence);
285
+ if (typeof p.reason === "string")
286
+ t.lastReason = sanitizeContentString(p.reason);
287
+ deniedTools.set(toolName, t);
288
+ }
289
+ }
290
+ else if (kind === "egress_decision") {
291
+ const p = (asObject(record.payload) ?? {});
292
+ const sinkId = typeof p.sinkId === "string" ? sanitizeContentString(p.sinkId) : "(unknown sink)";
293
+ const verdict = p.verdict;
294
+ if (verdict === "warn")
295
+ eWarned += 1;
296
+ else if (verdict === "block")
297
+ eBlocked += 1;
298
+ else
299
+ ePassed += 1;
300
+ if (verdict === "warn" || verdict === "block") {
301
+ const s = sinks.get(sinkId) ?? { warned: 0, blocked: 0, origins: new Set() };
302
+ if (verdict === "warn")
303
+ s.warned += 1;
304
+ else
305
+ s.blocked += 1;
306
+ if (Array.isArray(p.originsFound)) {
307
+ for (const o of p.originsFound) {
308
+ if (typeof o === "string") {
309
+ const origin = sanitizeContentString(o);
310
+ s.origins.add(origin);
311
+ origins.set(origin, (origins.get(origin) ?? 0) + 1);
312
+ }
313
+ }
314
+ }
315
+ sinks.set(sinkId, s);
316
+ }
317
+ }
318
+ else if (kind === "policy_decision") {
319
+ const p = (asObject(record.payload) ?? {});
320
+ if (p.decision === "deny") {
321
+ pDenied += 1;
322
+ const toolName = typeof p.toolName === "string" ? sanitizeContentString(p.toolName) : "(unknown tool)";
323
+ policyDeniedTools.set(toolName, (policyDeniedTools.get(toolName) ?? 0) + 1);
324
+ }
325
+ else if (p.decision === "audit-and-allow") {
326
+ pAuditAndAllow += 1;
327
+ }
328
+ else {
329
+ pOther += 1;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+ const sessions = scanSessionDenials(join(rootDir, SESSIONS_RELPATH), sinceMs);
336
+ const absentDeclaredKinds = DECLARED_AUDIT_KINDS.filter((k) => !countsByKind.has(k));
337
+ return {
338
+ version: 1,
339
+ generatedAt: new Date(now()).toISOString(),
340
+ since: new Date(sinceMs).toISOString(),
341
+ windowLabel: opts.window.label,
342
+ rootDir,
343
+ audit: {
344
+ dir: auditDir,
345
+ recordsScanned,
346
+ recordsInWindow,
347
+ malformedLines,
348
+ countsByKind: Object.fromEntries([...countsByKind.entries()].sort()),
349
+ absentDeclaredKinds,
350
+ },
351
+ justification: {
352
+ evaluated: jEvaluated,
353
+ allowed: jAllowed,
354
+ denied: jDenied,
355
+ denyRate: jEvaluated > 0 ? jDenied / jEvaluated : null,
356
+ topDeniedTools: rankEntries(deniedTools, (t) => t.denials, (toolName, t) => ({
357
+ toolName,
358
+ denials: t.denials,
359
+ judges: [...t.judges].sort(),
360
+ meanConfidence: t.confidences.length > 0
361
+ ? t.confidences.reduce((a, b) => a + b, 0) / t.confidences.length
362
+ : null,
363
+ ...(t.lastReason !== undefined ? { lastReason: t.lastReason } : {}),
364
+ })),
365
+ byJudge: [...judges.entries()]
366
+ .sort((a, b) => b[1].evaluated - a[1].evaluated || a[0].localeCompare(b[0]))
367
+ .map(([judgeModel, j]) => ({ judgeModel, evaluated: j.evaluated, denied: j.denied })),
368
+ },
369
+ egress: {
370
+ decisions: ePassed + eWarned + eBlocked,
371
+ passed: ePassed,
372
+ warned: eWarned,
373
+ blocked: eBlocked,
374
+ topSinks: rankEntries(sinks, (s) => s.blocked * 2 + s.warned, (sinkId, s) => ({
375
+ sinkId,
376
+ warned: s.warned,
377
+ blocked: s.blocked,
378
+ origins: [...s.origins].sort(),
379
+ })),
380
+ topOrigins: rankEntries(origins, (c) => c, (name, count) => ({ name, count })),
381
+ },
382
+ policy: {
383
+ decisions: pDenied + pAuditAndAllow + pOther,
384
+ denied: pDenied,
385
+ auditAndAllow: pAuditAndAllow,
386
+ topDeniedTools: rankEntries(policyDeniedTools, (c) => c, (name, count) => ({ name, count })),
387
+ },
388
+ sessions,
389
+ };
390
+ }
391
+ /** Prefixes the runtime writes into `tool_result` event contents on the
392
+ * block tier — the durable residue the session scan keys on. Kept in one
393
+ * place so the digest and its tests cite the same literals as
394
+ * `@crewhaus/runtime-core`'s denial messages. */
395
+ export const SESSION_DENIAL_MARKERS = {
396
+ justification: "[justification denied]",
397
+ egress: "[egress denied]",
398
+ hook: "[blocked by hook]",
399
+ };
400
+ /** The runtime's redaction notice is a single line; excluding `\n`/`\r` from
401
+ * the rule-id capture keeps a forged notice from smuggling multi-line
402
+ * content into the parse (the ids are additionally sanitized + clamped). */
403
+ const REDACTION_NOTICE_REGEX = /^\[tool output redacted: prompt injection detected: ([^\]\n\r]*)\]/;
404
+ function scanSessionDenials(sessionsDir, sinceMs) {
405
+ let scanned = 0;
406
+ let justificationDenials = 0;
407
+ let egressBlocks = 0;
408
+ let hookBlocks = 0;
409
+ let injectionRedactions = 0;
410
+ const ruleHits = new Map();
411
+ if (existsSync(sessionsDir)) {
412
+ for (const file of readdirSync(sessionsDir).sort()) {
413
+ if (!SESSION_EVENT_LOG_REGEX.test(file))
414
+ continue;
415
+ scanned += 1;
416
+ for (const raw of readFileSync(join(sessionsDir, file), "utf8").split("\n")) {
417
+ if (raw === "")
418
+ continue;
419
+ let event;
420
+ try {
421
+ event = JSON.parse(raw);
422
+ }
423
+ catch {
424
+ continue; // the digest triages; event-log integrity is not its job
425
+ }
426
+ if (event.kind !== "tool_result")
427
+ continue;
428
+ if (typeof event.ts !== "number" || event.ts < sinceMs)
429
+ continue;
430
+ const payload = asObject(event.payload);
431
+ const content = payload?.["content"];
432
+ if (typeof content !== "string")
433
+ continue;
434
+ if (content.startsWith(SESSION_DENIAL_MARKERS.justification))
435
+ justificationDenials += 1;
436
+ else if (content.startsWith(SESSION_DENIAL_MARKERS.egress))
437
+ egressBlocks += 1;
438
+ else if (content.startsWith(SESSION_DENIAL_MARKERS.hook))
439
+ hookBlocks += 1;
440
+ else {
441
+ const m = REDACTION_NOTICE_REGEX.exec(content);
442
+ if (m !== null) {
443
+ injectionRedactions += 1;
444
+ for (const rule of m[1].split(",")) {
445
+ // Rule ids come from attacker-reachable tool_result content —
446
+ // neutralize before they can reach a renderer (F1).
447
+ const id = sanitizeContentString(rule.trim(), MAX_RULE_ID_LENGTH);
448
+ if (id !== "")
449
+ ruleHits.set(id, (ruleHits.get(id) ?? 0) + 1);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }
455
+ }
456
+ return {
457
+ scanned,
458
+ justificationDenials,
459
+ egressBlocks,
460
+ hookBlocks,
461
+ injectionRedactions,
462
+ injectionRuleHits: [...ruleHits.entries()]
463
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
464
+ .slice(0, TOP_N)
465
+ .map(([name, count]) => ({ name, count })),
466
+ };
467
+ }
468
+ /** Render the digest as the CLI's indented text summary lines. */
469
+ export function renderSecurityDigestText(d) {
470
+ const lines = [];
471
+ const pct = (r) => (r === null ? "n/a" : `${(r * 100).toFixed(1)}%`);
472
+ lines.push(`window: ${d.windowLabel} (since ${d.since}) — ${d.audit.recordsInWindow} audit record(s) in window (${d.audit.recordsScanned} scanned${d.audit.malformedLines > 0 ? `, ${d.audit.malformedLines} malformed line(s) skipped` : ""})`);
473
+ lines.push(`justification gate: ${d.justification.evaluated} evaluated, ${d.justification.denied} denied (deny rate ${pct(d.justification.denyRate)})`);
474
+ for (const t of d.justification.topDeniedTools) {
475
+ lines.push(` ✗ ${t.toolName} — ${t.denials} denial(s) [judge=${t.judges.join(", ")}${t.meanConfidence !== null ? `, mean confidence ${t.meanConfidence.toFixed(2)}` : ""}]${t.lastReason !== undefined ? ` — last: ${t.lastReason}` : ""}`);
476
+ }
477
+ for (const j of d.justification.byJudge) {
478
+ lines.push(` • judge ${j.judgeModel}: ${j.evaluated} evaluated, ${j.denied} denied`);
479
+ }
480
+ if (d.egress.decisions > 0) {
481
+ lines.push(`egress: ${d.egress.decisions} decision(s) — ${d.egress.warned} warned, ${d.egress.blocked} blocked`);
482
+ for (const s of d.egress.topSinks) {
483
+ lines.push(` ✗ sink ${s.sinkId} — ${s.warned} warned / ${s.blocked} blocked (origins: ${s.origins.join(", ") || "n/a"})`);
484
+ }
485
+ for (const o of d.egress.topOrigins)
486
+ lines.push(` • origin ${o.name}: ${o.count} hit(s)`);
487
+ }
488
+ else {
489
+ lines.push("egress: no egress_decision records in window — records are written on warn/block verdicts; live per-run verdicts via CREWHAUS_SECURITY_DIGEST=1");
490
+ }
491
+ lines.push(`policy engine: ${d.policy.decisions} audited decision(s) — ${d.policy.denied} denied, ${d.policy.auditAndAllow} audit-and-allow`);
492
+ for (const t of d.policy.topDeniedTools)
493
+ lines.push(` ✗ ${t.name} — ${t.count} denial(s)`);
494
+ // Content-derived section — kept apart from the audit-derived rollups
495
+ // above: these markers live in tool_result content, which any malicious
496
+ // tool output can forge (adversarial-review F1).
497
+ lines.push(`sessions — content-derived, heuristic (${d.sessions.scanned} event log(s)): ${d.sessions.justificationDenials} justification denial notice(s), ${d.sessions.egressBlocks} egress block(s), ${d.sessions.hookBlocks} hook block(s), ${d.sessions.injectionRedactions} injection redaction(s)`);
498
+ for (const r of d.sessions.injectionRuleHits) {
499
+ lines.push(` ✗ injection rule ${r.name}: ${r.count} hit(s)`);
500
+ }
501
+ lines.push(" ~ session-derived counts are heuristic (parsed from tool_result content, which tool output can forge); audit-derived counts above are authoritative");
502
+ const kindCounts = Object.entries(d.audit.countsByKind)
503
+ .map(([k, n]) => `${k}=${n}`)
504
+ .join(", ");
505
+ lines.push(`audit kinds in window: ${kindCounts === "" ? "none" : kindCounts}`);
506
+ if (d.audit.absentDeclaredKinds.length > 0) {
507
+ lines.push(` ~ declared kinds with no records: ${d.audit.absentDeclaredKinds.join(", ")}`);
508
+ }
509
+ return lines;
510
+ }
511
+ // ---------------------------------------------------------------------------
512
+ // HTML rendering — local, dependency-free, following the visual style of
513
+ // packages/eval-report/src/render.ts (whose shell/escapeHtml are private to
514
+ // the eval-report shapes; see the module header for why we don't widen them).
515
+ // ---------------------------------------------------------------------------
516
+ const STYLE = `
517
+ :root {
518
+ --bg: #0f1115; --fg: #e6e6e6; --muted: #999; --pass: #4caf50; --fail: #ef5350;
519
+ --card: #1a1d23; --border: #333; --link: #61dafb;
520
+ }
521
+ * { box-sizing: border-box; }
522
+ body { font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--fg); margin: 0; padding: 24px; line-height: 1.5; }
523
+ h1 { margin: 0 0 16px; }
524
+ h2 { margin: 24px 0 12px; }
525
+ .meta { color: var(--muted); font-size: 13px; margin-bottom: 24px; }
526
+ .aggregate { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin-bottom: 24px; }
527
+ .aggregate .card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 12px; }
528
+ .aggregate .label { color: var(--muted); font-size: 12px; text-transform: uppercase; }
529
+ .aggregate .value { font-size: 22px; font-weight: 600; margin-top: 4px; }
530
+ table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 6px; overflow: hidden; margin-bottom: 16px; }
531
+ th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 14px; }
532
+ th { background: #14171c; }
533
+ tr:last-child td { border-bottom: none; }
534
+ .fail { color: var(--fail); font-weight: 600; }
535
+ .note { color: var(--muted); font-size: 13px; }
536
+ `;
537
+ function escapeHtml(s) {
538
+ return s
539
+ .replace(/&/g, "&amp;")
540
+ .replace(/</g, "&lt;")
541
+ .replace(/>/g, "&gt;")
542
+ .replace(/"/g, "&quot;")
543
+ .replace(/'/g, "&#39;");
544
+ }
545
+ function card(label, value) {
546
+ return `<div class="card"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div></div>`;
547
+ }
548
+ function tableOf(headers, rows) {
549
+ if (rows.length === 0)
550
+ return '<p class="note">none</p>';
551
+ return `<table>
552
+ <thead><tr>${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}</tr></thead>
553
+ <tbody>${rows
554
+ .map((r) => `<tr>${r.map((c) => `<td>${escapeHtml(c)}</td>`).join("")}</tr>`)
555
+ .join("")}</tbody>
556
+ </table>`;
557
+ }
558
+ /** Render the digest as a self-contained HTML page (no external assets). */
559
+ export function renderSecurityDigestHtml(d) {
560
+ const pct = (r) => (r === null ? "n/a" : `${(r * 100).toFixed(1)}%`);
561
+ const body = `
562
+ <h1>Security digest</h1>
563
+ <p class="meta">Window ${escapeHtml(d.windowLabel)} (since ${escapeHtml(d.since)}) · generated ${escapeHtml(d.generatedAt)} · ${escapeHtml(d.rootDir)}</p>
564
+ <section class="aggregate">
565
+ ${card("Audit records", String(d.audit.recordsInWindow))}
566
+ ${card("Justification denials", String(d.justification.denied))}
567
+ ${card("Judge deny rate", pct(d.justification.denyRate))}
568
+ ${card("Egress warned", String(d.egress.warned))}
569
+ ${card("Egress blocked", String(d.egress.blocked))}
570
+ ${card("Policy denials", String(d.policy.denied))}
571
+ ${card("Injection redactions", String(d.sessions.injectionRedactions))}
572
+ </section>
573
+ <h2>Top denied tools (justification gate)</h2>
574
+ ${tableOf(["Tool", "Denials", "Judges", "Mean confidence", "Last reason"], d.justification.topDeniedTools.map((t) => [
575
+ t.toolName,
576
+ String(t.denials),
577
+ t.judges.join(", "),
578
+ t.meanConfidence !== null ? t.meanConfidence.toFixed(2) : "n/a",
579
+ t.lastReason ?? "",
580
+ ]))}
581
+ <h2>Judges</h2>
582
+ ${tableOf(["Judge", "Evaluated", "Denied"], d.justification.byJudge.map((j) => [j.judgeModel, String(j.evaluated), String(j.denied)]))}
583
+ <h2>Egress sinks (warn/block)</h2>
584
+ ${d.egress.decisions === 0
585
+ ? '<p class="note">no egress_decision records in window — records are written on warn/block verdicts; live per-run verdicts via CREWHAUS_SECURITY_DIGEST=1</p>'
586
+ : tableOf(["Sink", "Warned", "Blocked", "Origins"], d.egress.topSinks.map((s) => [
587
+ s.sinkId,
588
+ String(s.warned),
589
+ String(s.blocked),
590
+ s.origins.join(", "),
591
+ ]))}
592
+ <h2>Policy denials</h2>
593
+ ${tableOf(["Tool", "Denials"], d.policy.topDeniedTools.map((t) => [t.name, String(t.count)]))}
594
+ <h2>Injection rule hits (session redaction notices)</h2>
595
+ ${tableOf(["Rule", "Hits"], d.sessions.injectionRuleHits.map((r) => [r.name, String(r.count)]))}
596
+ <p class="note">Session-derived counts are heuristic — parsed from tool_result content, which tool output can forge; audit-derived counts are authoritative.</p>
597
+ <h2>Audit kinds in window</h2>
598
+ ${tableOf(["Kind", "Records"], Object.entries(d.audit.countsByKind).map(([k, n]) => [k, String(n)]))}
599
+ <p class="note">Declared kinds with no records in window: ${escapeHtml(d.audit.absentDeclaredKinds.join(", ") || "none")}</p>
600
+ `;
601
+ return `<!doctype html>
602
+ <html><head>
603
+ <meta charset="utf-8">
604
+ <title>Security digest ${escapeHtml(d.windowLabel)}</title>
605
+ <style>${STYLE}</style>
606
+ </head>
607
+ <body>
608
+ ${body}
609
+ </body></html>`;
610
+ }
611
+ /** Thrown by `notifySecurityDigest` on a malformed URL or non-2xx response.
612
+ * The CLI routes it through `die()` — a scheduled digest whose notification
613
+ * silently failed would fabricate assurance, so it exits 1 loudly
614
+ * (mirroring audit-verify's requested-but-unconsulted-anchor stance). */
615
+ export class NotifyError extends Error {
616
+ name = "NotifyError";
617
+ }
618
+ /**
619
+ * POST the JSON digest to a webhook. Plain `fetch`, no channel-adapter
620
+ * dependency (see module header); Slack users wrap the payload — incoming
621
+ * webhooks accept `{ text }`, not arbitrary JSON.
622
+ *
623
+ * A plain-http URL is allowed (local tunnels/dev receivers) but warned about
624
+ * on stderr (adversarial-review F3): the digest is security telemetry —
625
+ * denial counts, tool names, judge reasons — and posting it cleartext leaks
626
+ * it to every on-path observer. `warn` is injectable for tests.
627
+ */
628
+ export async function notifySecurityDigest(url, digest, fetchImpl = fetch, warn = (line) => process.stderr.write(`${line}\n`)) {
629
+ let parsed;
630
+ try {
631
+ parsed = new URL(url);
632
+ }
633
+ catch {
634
+ throw new NotifyError(`invalid --notify URL "${url}"`);
635
+ }
636
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
637
+ throw new NotifyError(`--notify only supports http(s) URLs (got "${parsed.protocol}//")`);
638
+ }
639
+ if (parsed.protocol === "http:") {
640
+ warn(`warning: --notify ${url} uses plain http — the digest is security telemetry and will transit unencrypted; prefer an https webhook`);
641
+ }
642
+ let res;
643
+ try {
644
+ res = await fetchImpl(url, {
645
+ method: "POST",
646
+ headers: { "content-type": "application/json" },
647
+ body: JSON.stringify(digest),
648
+ });
649
+ }
650
+ catch (err) {
651
+ throw new NotifyError(`--notify POST to ${url} failed: ${err.message}`);
652
+ }
653
+ if (!res.ok) {
654
+ throw new NotifyError(`--notify POST to ${url} returned ${res.status} ${res.statusText}`);
655
+ }
656
+ }