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.
- package/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11601 -964
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- 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, "&")
|
|
534
|
+
.replace(/</g, "<")
|
|
535
|
+
.replace(/>/g, ">")
|
|
536
|
+
.replace(/"/g, """)
|
|
537
|
+
.replace(/'/g, "'");
|
|
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;
|