crewhaus 0.1.8 → 0.2.0

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