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,139 @@
1
+ /**
2
+ * Item 49 — scope-audit drift watch: the persistence + baseline-diff +
3
+ * boundary-drift core behind `crewhaus doctor --philosophy-alignment
4
+ * --json/--baseline/--accept-baseline`, factored out of the entry file
5
+ * `index.ts` (which runs a top-level argv switch and so cannot be imported
6
+ * by a test without executing the CLI). Side-effect-free on import and
7
+ * directly unit-testable, mirroring `scope-audit.ts` / `security-digest.ts`.
8
+ *
9
+ * The problem this closes: `doctor --philosophy-alignment` audited boundary
10
+ * call sites and tool scopes but was run-and-forget print-only — a
11
+ * regression (or a NEW un-classified ingress) looked identical to
12
+ * known-accepted findings, so nobody could gate CI on it. Three pieces:
13
+ *
14
+ * 1. Stable finding identity. Every audit finding now carries a
15
+ * (class, file, symbol) triple; `findingId` hashes it so a finding
16
+ * keeps its id across runs even when the human-facing label or reason
17
+ * wording changes. Labels/reasons are deliberately NOT hashed. Renaming
18
+ * or moving a file (or renaming a symbol) mints a NEW id by design —
19
+ * the gate fails closed on the "new" finding and the baseline must be
20
+ * re-accepted with --accept-baseline.
21
+ *
22
+ * 2. Baselined diffs, following `@crewhaus/regression-runner`'s `gate()`
23
+ * shape (verdict + reason + report). `diffScopeAuditSnapshots` splits
24
+ * current findings into new / accepted / resolved against
25
+ * `.crewhaus/scope-audit/baseline.json`; the verdict fails ONLY on new
26
+ * findings — legacy accepted findings never block, so the gate can be
27
+ * adopted on a repo with known warts.
28
+ *
29
+ * 3. Boundary-site drift detection. The audit's canonical boundary list
30
+ * is hardcoded (six sites in index.ts) and checks only that KNOWN
31
+ * sites still call `classifyBoundary`; nothing caught a NEW
32
+ * cross-trust ingress that skipped classification. `detectBoundaryDrift`
33
+ * extends the same mechanism (read package sources, substring-match)
34
+ * with a small set of conservative ingress signals derived from the
35
+ * boundary-classifier TrustOrigin taxonomy, and flags packages that
36
+ * match a signal but never reference the classification fabric.
37
+ * Findings are REPORT-ONLY (warn, pass: true) in plain mode; only the
38
+ * --baseline gate fails on them, and only when they are NEW.
39
+ */
40
+ /** Finding classes the philosophy audit emits. `boundary-drift` is the new
41
+ * detector; the rest map 1:1 onto the pre-existing printed checks. */
42
+ export type PhilosophyFindingClass = "pillar-doc" | "package-presence" | "boundary-site" | "tool-scope" | "contributor-doc" | "boundary-drift";
43
+ export type PhilosophyFinding = {
44
+ readonly class: PhilosophyFindingClass;
45
+ /** Repo-relative path the finding is about ("" for repo-level checks). */
46
+ readonly file: string;
47
+ /** Stable symbol within (class, file) — a site/tool/signal name. */
48
+ readonly symbol: string;
49
+ readonly label: string;
50
+ readonly pass: boolean;
51
+ /** warn+pass renders as "~" — informational, never fails plain doctor. */
52
+ readonly warn?: boolean;
53
+ readonly reason?: string;
54
+ };
55
+ /**
56
+ * Stable finding id: sha256 over the (class, file, symbol) identity triple,
57
+ * truncated to 12 hex chars (48 bits — collision-safe at this cardinality
58
+ * and short enough to read in a diff). Labels and reasons are excluded on
59
+ * purpose: rewording a message must not orphan a baselined finding.
60
+ *
61
+ * The NUL separator makes the triple unambiguous (no field can contain it).
62
+ * It MUST stay the `\u0000` ESCAPE SEQUENCE in this source: a literal NUL
63
+ * byte makes git treat the whole module as a binary blob (unreviewable
64
+ * diffs) and invites silent id-invalidation by NUL-stripping tools. The
65
+ * escape and the literal produce identical runtime bytes, so every existing
66
+ * findingId and accepted baseline stays valid — scope-audit-drift.test.ts
67
+ * pins both the source byte-hygiene and a known id value.
68
+ */
69
+ export declare function findingId(f: Pick<PhilosophyFinding, "class" | "file" | "symbol">): string;
70
+ /** A finding as persisted in a snapshot/baseline file. */
71
+ export type PersistedFinding = PhilosophyFinding & {
72
+ readonly id: string;
73
+ };
74
+ export type ScopeAuditSnapshot = {
75
+ readonly version: 1;
76
+ readonly generatedAt: string;
77
+ /** Actionable findings only — see `isActionableFinding`. */
78
+ readonly findings: ReadonlyArray<PersistedFinding>;
79
+ };
80
+ export declare const SCOPE_AUDIT_RELPATH = ".crewhaus/scope-audit";
81
+ export declare const SCOPE_AUDIT_BASELINE_FILENAME = "baseline.json";
82
+ export declare function scopeAuditDir(rootDir: string): string;
83
+ export declare function scopeAuditBaselinePath(rootDir: string): string;
84
+ /** Dated snapshot path: `.crewhaus/scope-audit/<YYYY-MM-DD>.json` (UTC).
85
+ * Re-running on the same day overwrites — the snapshot is a deterministic
86
+ * function of the tree, so the latest run of the day is the record. */
87
+ export declare function scopeAuditSnapshotPath(rootDir: string, now: () => number): string;
88
+ /**
89
+ * What gets persisted (and therefore baselined/gated): hard failures, plus
90
+ * boundary-drift reports (which are warn-tier in plain mode but exactly the
91
+ * findings the drift watch exists to gate). Green checks and benign
92
+ * environment warns (e.g. "sibling ../docs not cloned") are excluded — they
93
+ * are not findings, and baselining them would make an absent docs checkout
94
+ * flip the gate.
95
+ */
96
+ export declare function isActionableFinding(f: PhilosophyFinding): boolean;
97
+ export declare function buildScopeAuditSnapshot(findings: ReadonlyArray<PhilosophyFinding>, now?: () => number): ScopeAuditSnapshot;
98
+ /** Thrown on a malformed baseline/snapshot file. The CLI routes it through
99
+ * `die()` — a gate whose accepted-set is unreadable must fail loudly, not
100
+ * silently treat everything (or nothing) as new. */
101
+ export declare class ScopeAuditBaselineError extends Error {
102
+ readonly name = "ScopeAuditBaselineError";
103
+ }
104
+ /** Read + validate a snapshot file; undefined when it does not exist. */
105
+ export declare function loadScopeAuditSnapshot(path: string): ScopeAuditSnapshot | undefined;
106
+ /** Gate verdict, following regression-runner's `gate()` shape
107
+ * (verdict / reason / report arrays). */
108
+ export type ScopeAuditGateVerdict = {
109
+ readonly verdict: "pass" | "fail";
110
+ readonly reason?: string;
111
+ /** In current but not the baseline — the ONLY thing that fails the gate. */
112
+ readonly newFindings: ReadonlyArray<PersistedFinding>;
113
+ /** In both — legacy accepted findings; never block. */
114
+ readonly acceptedFindings: ReadonlyArray<PersistedFinding>;
115
+ /** In the baseline but no longer current — fixed since acceptance. */
116
+ readonly resolvedFindings: ReadonlyArray<PersistedFinding>;
117
+ };
118
+ /**
119
+ * Diff the current snapshot against the last accepted baseline. Identity is
120
+ * the stable finding id, so wording changes don't churn the diff. A missing
121
+ * baseline (`undefined`) fails IF there is anything to accept — a gate that
122
+ * silently passed forever because nobody ever ran --accept-baseline would
123
+ * fabricate assurance — and passes on a genuinely clean tree.
124
+ */
125
+ export declare function diffScopeAuditSnapshots(baseline: ScopeAuditSnapshot | undefined, current: ScopeAuditSnapshot): ScopeAuditGateVerdict;
126
+ /**
127
+ * Scan `<repoRoot>/packages/<name>/src` for cross-trust ingress signals and
128
+ * flag packages that match one but never reference the classification
129
+ * fabric ({@link CLASSIFICATION_MARKERS}). Report-only by design: findings
130
+ * come back `pass: true, warn: true` so plain `doctor --philosophy-alignment`
131
+ * keeps exiting 0 on them; the --baseline gate is what fails, and only on
132
+ * findings that are NEW relative to the accepted baseline.
133
+ *
134
+ * Returns [] when `<repoRoot>/packages` does not exist (the audit is
135
+ * factory-repo-shaped, like the six-site check it extends).
136
+ */
137
+ export declare function detectBoundaryDrift(repoRoot: string): PhilosophyFinding[];
138
+ /** Render the gate verdict as the CLI's summary lines (baseline mode). */
139
+ export declare function renderGateReport(diff: ScopeAuditGateVerdict): ReadonlyArray<string>;
@@ -0,0 +1,260 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Stable finding id: sha256 over the (class, file, symbol) identity triple,
6
+ * truncated to 12 hex chars (48 bits — collision-safe at this cardinality
7
+ * and short enough to read in a diff). Labels and reasons are excluded on
8
+ * purpose: rewording a message must not orphan a baselined finding.
9
+ *
10
+ * The NUL separator makes the triple unambiguous (no field can contain it).
11
+ * It MUST stay the `\u0000` ESCAPE SEQUENCE in this source: a literal NUL
12
+ * byte makes git treat the whole module as a binary blob (unreviewable
13
+ * diffs) and invites silent id-invalidation by NUL-stripping tools. The
14
+ * escape and the literal produce identical runtime bytes, so every existing
15
+ * findingId and accepted baseline stays valid — scope-audit-drift.test.ts
16
+ * pins both the source byte-hygiene and a known id value.
17
+ */
18
+ export function findingId(f) {
19
+ return createHash("sha256")
20
+ .update(`${f.class}\u0000${f.file}\u0000${f.symbol}`)
21
+ .digest("hex")
22
+ .slice(0, 12);
23
+ }
24
+ export const SCOPE_AUDIT_RELPATH = ".crewhaus/scope-audit";
25
+ export const SCOPE_AUDIT_BASELINE_FILENAME = "baseline.json";
26
+ export function scopeAuditDir(rootDir) {
27
+ return join(rootDir, SCOPE_AUDIT_RELPATH);
28
+ }
29
+ export function scopeAuditBaselinePath(rootDir) {
30
+ return join(scopeAuditDir(rootDir), SCOPE_AUDIT_BASELINE_FILENAME);
31
+ }
32
+ /** Dated snapshot path: `.crewhaus/scope-audit/<YYYY-MM-DD>.json` (UTC).
33
+ * Re-running on the same day overwrites — the snapshot is a deterministic
34
+ * function of the tree, so the latest run of the day is the record. */
35
+ export function scopeAuditSnapshotPath(rootDir, now) {
36
+ return join(scopeAuditDir(rootDir), `${new Date(now()).toISOString().slice(0, 10)}.json`);
37
+ }
38
+ /**
39
+ * What gets persisted (and therefore baselined/gated): hard failures, plus
40
+ * boundary-drift reports (which are warn-tier in plain mode but exactly the
41
+ * findings the drift watch exists to gate). Green checks and benign
42
+ * environment warns (e.g. "sibling ../docs not cloned") are excluded — they
43
+ * are not findings, and baselining them would make an absent docs checkout
44
+ * flip the gate.
45
+ */
46
+ export function isActionableFinding(f) {
47
+ return !f.pass || f.class === "boundary-drift";
48
+ }
49
+ export function buildScopeAuditSnapshot(findings, now = () => Date.now()) {
50
+ const actionable = findings
51
+ .filter(isActionableFinding)
52
+ .map((f) => ({ ...f, id: findingId(f) }))
53
+ .sort((a, b) => a.id.localeCompare(b.id));
54
+ return { version: 1, generatedAt: new Date(now()).toISOString(), findings: actionable };
55
+ }
56
+ /** Thrown on a malformed baseline/snapshot file. The CLI routes it through
57
+ * `die()` — a gate whose accepted-set is unreadable must fail loudly, not
58
+ * silently treat everything (or nothing) as new. */
59
+ export class ScopeAuditBaselineError extends Error {
60
+ name = "ScopeAuditBaselineError";
61
+ }
62
+ /** Read + validate a snapshot file; undefined when it does not exist. */
63
+ export function loadScopeAuditSnapshot(path) {
64
+ if (!existsSync(path))
65
+ return undefined;
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(readFileSync(path, "utf8"));
69
+ }
70
+ catch (err) {
71
+ throw new ScopeAuditBaselineError(`${path} is not valid JSON (${err.message}) — re-accept with --accept-baseline`);
72
+ }
73
+ const snap = parsed;
74
+ if (snap.version !== 1 || !Array.isArray(snap.findings)) {
75
+ throw new ScopeAuditBaselineError(`${path} is not a v1 scope-audit snapshot — re-accept with --accept-baseline`);
76
+ }
77
+ return parsed;
78
+ }
79
+ /**
80
+ * Diff the current snapshot against the last accepted baseline. Identity is
81
+ * the stable finding id, so wording changes don't churn the diff. A missing
82
+ * baseline (`undefined`) fails IF there is anything to accept — a gate that
83
+ * silently passed forever because nobody ever ran --accept-baseline would
84
+ * fabricate assurance — and passes on a genuinely clean tree.
85
+ */
86
+ export function diffScopeAuditSnapshots(baseline, current) {
87
+ if (baseline === undefined) {
88
+ if (current.findings.length === 0) {
89
+ return { verdict: "pass", newFindings: [], acceptedFindings: [], resolvedFindings: [] };
90
+ }
91
+ return {
92
+ verdict: "fail",
93
+ reason: `no accepted baseline and ${current.findings.length} finding(s) present — review them and run \`crewhaus doctor --philosophy-alignment --accept-baseline\``,
94
+ newFindings: [...current.findings],
95
+ acceptedFindings: [],
96
+ resolvedFindings: [],
97
+ };
98
+ }
99
+ const baselineById = new Map(baseline.findings.map((f) => [f.id, f]));
100
+ const currentIds = new Set(current.findings.map((f) => f.id));
101
+ const newFindings = current.findings.filter((f) => !baselineById.has(f.id));
102
+ const acceptedFindings = current.findings.filter((f) => baselineById.has(f.id));
103
+ const resolvedFindings = baseline.findings.filter((f) => !currentIds.has(f.id));
104
+ if (newFindings.length > 0) {
105
+ return {
106
+ verdict: "fail",
107
+ reason: `${newFindings.length} NEW finding(s) not in the accepted baseline (${newFindings
108
+ .map((f) => f.id)
109
+ .join(", ")})`,
110
+ newFindings,
111
+ acceptedFindings,
112
+ resolvedFindings,
113
+ };
114
+ }
115
+ return { verdict: "pass", newFindings, acceptedFindings, resolvedFindings };
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Boundary-site drift detection
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Markers that a package participates in the classification fabric. Any of
122
+ * these appearing in a package's sources exempts it from drift findings:
123
+ * - classifyBoundary / boundary-classifier — the chokepoint itself;
124
+ * - classifyInbound — channel-adapter-base's wrapper around it;
125
+ * - chain-adapter-base — whose shared ingress helper classifies centrally,
126
+ * so concrete chain adapters routing through it are covered.
127
+ * Same substring mechanism as the existing six-site check in index.ts.
128
+ */
129
+ const CLASSIFICATION_MARKERS = [
130
+ "classifyBoundary",
131
+ "boundary-classifier",
132
+ "classifyInbound",
133
+ "chain-adapter-base",
134
+ ];
135
+ /**
136
+ * Conservative cross-trust ingress signals, derived from the
137
+ * boundary-classifier TrustOrigin taxonomy. Deliberately small: each signal
138
+ * is a strong indicator that server/peer/user-controlled content enters the
139
+ * process. (Weak signals — e.g. the literal "SKILL.md", which appears in
140
+ * emitters and doc comments across the tree — are excluded; the baseline
141
+ * mechanism handles honest false positives, but the detector should not
142
+ * manufacture them.)
143
+ */
144
+ const DRIFT_SIGNALS = [
145
+ {
146
+ symbol: "mcp-sdk-ingress",
147
+ origin: "mcp",
148
+ description: "imports the raw MCP SDK (server-controlled payloads enter here)",
149
+ contentNeedle: "@modelcontextprotocol/",
150
+ },
151
+ {
152
+ symbol: "federation-peer-ingress",
153
+ origin: "federation",
154
+ description: "consumes federation peer payloads (mTLS authenticates WHO, not WHAT)",
155
+ contentNeedle: "@crewhaus/federation-protocol",
156
+ },
157
+ {
158
+ symbol: "channel-inbound-ingress",
159
+ origin: "channel",
160
+ description: "normalizes inbound channel webhooks/messages",
161
+ packagePattern: /^channel-adapter-(?!base$).+/,
162
+ },
163
+ {
164
+ symbol: "chain-content-ingress",
165
+ origin: "chain",
166
+ description: "decodes chain RPC/event content",
167
+ packagePattern: /^chain-adapter-(?!base$).+/,
168
+ },
169
+ ];
170
+ /** Concatenate a package's non-test .ts sources (recursively under src/). */
171
+ function readPackageSources(srcDir) {
172
+ const chunks = [];
173
+ const walk = (dir) => {
174
+ for (const entry of readdirSync(dir).sort()) {
175
+ const full = join(dir, entry);
176
+ let isDir = false;
177
+ try {
178
+ isDir = statSync(full).isDirectory();
179
+ }
180
+ catch {
181
+ continue; // raced away / unreadable — skip, never crash the audit
182
+ }
183
+ if (isDir) {
184
+ walk(full);
185
+ }
186
+ else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts") && !entry.endsWith(".d.ts")) {
187
+ try {
188
+ chunks.push(readFileSync(full, "utf8"));
189
+ }
190
+ catch {
191
+ // Unreadable source — skip; the audit reports on what it can see.
192
+ }
193
+ }
194
+ }
195
+ };
196
+ walk(srcDir);
197
+ return chunks.join("\n");
198
+ }
199
+ /**
200
+ * Scan `<repoRoot>/packages/<name>/src` for cross-trust ingress signals and
201
+ * flag packages that match one but never reference the classification
202
+ * fabric ({@link CLASSIFICATION_MARKERS}). Report-only by design: findings
203
+ * come back `pass: true, warn: true` so plain `doctor --philosophy-alignment`
204
+ * keeps exiting 0 on them; the --baseline gate is what fails, and only on
205
+ * findings that are NEW relative to the accepted baseline.
206
+ *
207
+ * Returns [] when `<repoRoot>/packages` does not exist (the audit is
208
+ * factory-repo-shaped, like the six-site check it extends).
209
+ */
210
+ export function detectBoundaryDrift(repoRoot) {
211
+ const packagesDir = join(repoRoot, "packages");
212
+ if (!existsSync(packagesDir))
213
+ return [];
214
+ const findings = [];
215
+ for (const pkgName of readdirSync(packagesDir).sort()) {
216
+ const srcDir = join(packagesDir, pkgName, "src");
217
+ if (!existsSync(srcDir))
218
+ continue;
219
+ const source = readPackageSources(srcDir);
220
+ if (source === "")
221
+ continue;
222
+ const classified = CLASSIFICATION_MARKERS.some((m) => source.includes(m));
223
+ if (classified)
224
+ continue;
225
+ for (const signal of DRIFT_SIGNALS) {
226
+ const matches = signal.contentNeedle !== undefined
227
+ ? source.includes(signal.contentNeedle)
228
+ : signal.packagePattern.test(pkgName);
229
+ if (!matches)
230
+ continue;
231
+ const file = `packages/${pkgName}/src`;
232
+ findings.push({
233
+ class: "boundary-drift",
234
+ file,
235
+ symbol: signal.symbol,
236
+ label: `Pillar 3 drift — ${pkgName} ${signal.description} without classifyBoundary`,
237
+ pass: true,
238
+ warn: true,
239
+ reason: `${file} matches the "${signal.symbol}" cross-trust ingress signal (origin: ${signal.origin}) but never references the boundary-classification fabric — a new ingress that skips classifyBoundary is a security regression (AGENTS.md Pillar 3). If this ingress is classified downstream by design, accept it into the baseline with --accept-baseline.`,
240
+ });
241
+ }
242
+ }
243
+ return findings;
244
+ }
245
+ /** Render the gate verdict as the CLI's summary lines (baseline mode). */
246
+ export function renderGateReport(diff) {
247
+ const lines = [];
248
+ for (const f of diff.newFindings) {
249
+ lines.push(`✗ NEW [${f.id}] ${f.label}${f.reason !== undefined ? ` — ${f.reason}` : ""}`);
250
+ }
251
+ for (const f of diff.acceptedFindings) {
252
+ lines.push(`• accepted [${f.id}] ${f.label}`);
253
+ }
254
+ for (const f of diff.resolvedFindings) {
255
+ lines.push(`✓ resolved [${f.id}] ${f.label} — no longer present; re-accept to shrink the baseline`);
256
+ }
257
+ lines.push(`baseline gate: ${diff.verdict}${diff.reason !== undefined ? ` — ${diff.reason}` : ""} ` +
258
+ `(${diff.newFindings.length} new, ${diff.acceptedFindings.length} accepted, ${diff.resolvedFindings.length} resolved)`);
259
+ return lines;
260
+ }
@@ -0,0 +1,237 @@
1
+ import { type PromptInjectionResult } from "@crewhaus/prompt-injection-detector";
2
+ /** Every real regex-rule id in the current detector. A parsed notice id must
3
+ * be a member of this set to be admitted (drops forged/renamed ids). */
4
+ export declare const RULE_IDS: ReadonlySet<string>;
5
+ export type SinceWindow = {
6
+ readonly sinceMs: number;
7
+ readonly label: string;
8
+ };
9
+ /** `<N>d` trailing window (default all-time when undefined) — a small local
10
+ * parser so the module has no dependency on security-digest's copy. */
11
+ export declare function parseCorpusSince(value: string | undefined, now?: () => number): SinceWindow;
12
+ export type ObservedRuleHit = {
13
+ readonly rule: string;
14
+ /** How many redaction notices in the window named this rule. */
15
+ readonly count: number;
16
+ };
17
+ export type HarvestResult = {
18
+ /** Session event logs scanned. */
19
+ readonly sessionsScanned: number;
20
+ /** Redaction notices (malicious verdicts) found in the window. */
21
+ readonly redactions: number;
22
+ /** Per-rule observed block counts, ranked by count then id. */
23
+ readonly ruleHits: ReadonlyArray<ObservedRuleHit>;
24
+ /**
25
+ * Ids parsed out of notices that are NOT current rules (forged, or a rule
26
+ * that was renamed/removed since the notice was written). Reported so a
27
+ * removed-rule regression is visible; never turned into a corpus case.
28
+ */
29
+ readonly unknownIds: ReadonlyArray<string>;
30
+ };
31
+ /**
32
+ * Scan `<rootDir>/.crewhaus/sessions` for prompt-injection redaction notices
33
+ * and tally which detector rules fired. A missing dir yields an empty harvest
34
+ * (no throw — the command reports emptiness, it is not a gate on presence).
35
+ */
36
+ export declare function harvestBlockedAttempts(opts: {
37
+ readonly rootDir: string;
38
+ readonly window: SinceWindow;
39
+ }): HarvestResult;
40
+ /**
41
+ * A small, deterministic exemplar STRING that a given rule matches, built at
42
+ * runtime from parts so no attack literal is stored in this source (and so a
43
+ * secret-shaped token in the exemplar is assembled, not committed — GitHub
44
+ * push-protection). Each entry is a minimal phrase the corresponding
45
+ * REGEX_RULES pattern hits; the mapping is asserted by the tests, so a rule
46
+ * whose exemplar stops matching (a pattern edit) is caught immediately.
47
+ *
48
+ * Rules without a hand-written exemplar fall back to `undefined`; the corpus
49
+ * still records the observed rule but the check treats it as
50
+ * "exemplar-unavailable" rather than a regression (a missing exemplar is a
51
+ * corpus gap, not a detector failure).
52
+ */
53
+ export declare function exemplarForRule(ruleId: string): string | undefined;
54
+ export type CorpusCase = {
55
+ /** Detector rule this case guards against regressing. */
56
+ readonly rule: string;
57
+ /** Observed production block count that motivated the case. */
58
+ readonly observed: number;
59
+ /** Deterministic positive fixture the detector must still block. Assembled
60
+ * at runtime; never a harvested attack payload. */
61
+ readonly exemplar: string;
62
+ /**
63
+ * The exemplar's classification tier AT BUILD TIME (F2). `check` compares
64
+ * the CURRENT classification against this recorded baseline rather than a
65
+ * fixed "not clean" floor — a rule that used to earn `malicious` and now
66
+ * only reaches `suspicious` is real protection lost (only `malicious`
67
+ * redacts at runtime; `suspicious` content is passed through verbatim), so
68
+ * that drift must fail even though `suspicious !== clean`. A case built
69
+ * BEFORE this field existed (no `baselineTier` in the loaded JSON) falls
70
+ * back to the pre-F2 "not clean" floor — see `checkSecurityCorpus`.
71
+ */
72
+ readonly baselineTier: PromptInjectionResult["classification"];
73
+ };
74
+ export type SecurityCorpus = {
75
+ readonly version: 1;
76
+ readonly generatedAt: string;
77
+ readonly windowLabel: string;
78
+ /** Rules with a canonical exemplar that were observed blocking. */
79
+ readonly cases: ReadonlyArray<CorpusCase>;
80
+ /** Rules observed blocking but with no exemplar yet (corpus gaps). */
81
+ readonly withoutExemplar: ReadonlyArray<ObservedRuleHit>;
82
+ /** Notice ids that are not current rules (removed/renamed/forged). */
83
+ readonly unknownIds: ReadonlyArray<string>;
84
+ };
85
+ /**
86
+ * Build the regression corpus from a harvest. One case per observed rule that
87
+ * has a canonical exemplar; observed-but-exemplar-less rules are kept
88
+ * separately as gaps. Deterministic given (harvest, generatedAt) — `async`
89
+ * because each case's `baselineTier` (F2) is the exemplar's CURRENT
90
+ * classification, recorded once at build time so `check` can detect a later
91
+ * downward drift rather than only an absolute drop to `clean`.
92
+ */
93
+ export declare function buildSecurityCorpus(harvest: HarvestResult, windowLabel: string, generatedAt?: string): Promise<SecurityCorpus>;
94
+ export declare function corpusDir(rootDir: string): string;
95
+ export declare function corpusPath(rootDir: string): string;
96
+ export declare function candidateRulesPath(rootDir: string): string;
97
+ /** Thrown on a malformed corpus file; the CLI routes it through `die()`. */
98
+ export declare class SecurityCorpusError extends Error {
99
+ readonly name = "SecurityCorpusError";
100
+ }
101
+ export declare function loadSecurityCorpus(path: string): SecurityCorpus | undefined;
102
+ export type CorpusCheckCaseResult = {
103
+ readonly rule: string;
104
+ /** Current detector classification of the exemplar. */
105
+ readonly classification: PromptInjectionResult["classification"];
106
+ /** The tier this case was pinned at when the corpus was built (F2). Cases
107
+ * loaded from a pre-F2 corpus file have no recorded baseline; see
108
+ * `checkSecurityCorpus` for the fallback in that case. */
109
+ readonly baselineTier: PromptInjectionResult["classification"] | undefined;
110
+ /**
111
+ * Passed = the CURRENT tier has not dropped below the RECORDED baseline
112
+ * tier (F2: drift, not an absolute `=== malicious` floor).
113
+ *
114
+ * At runtime only `malicious` redacts a tool output; `suspicious` content
115
+ * is passed through to the model verbatim (only logged/warned). So a rule
116
+ * that used to earn `malicious` and has drifted down to `suspicious` is
117
+ * real protection lost, even though the exemplar is still "not clean" —
118
+ * `stillBlocked` catches that drift where a bare `!== "clean"` check would
119
+ * pass it. A rule whose baseline was only ever `suspicious` (a single
120
+ * medium-severity rule, or a multi-rule payload that only reaches
121
+ * `suspicious` on its own — the detector's probabilistic-OR score makes the
122
+ * exact tier of a canonical single-rule exemplar an implementation detail)
123
+ * still passes as long as it holds at `suspicious`; it only fails if it
124
+ * drops all the way to `clean`. Pre-F2 corpus cases with no recorded
125
+ * baseline fall back to that same "not clean" floor.
126
+ */
127
+ readonly stillBlocked: boolean;
128
+ };
129
+ export type CorpusCheckResult = {
130
+ readonly verdict: "pass" | "fail";
131
+ readonly checked: number;
132
+ readonly regressions: ReadonlyArray<CorpusCheckCaseResult>;
133
+ readonly holding: ReadonlyArray<CorpusCheckCaseResult>;
134
+ };
135
+ /**
136
+ * Run every corpus case against the CURRENT detector and fail if any case's
137
+ * classification has DRIFTED DOWN from its recorded `baselineTier` (F2) — a
138
+ * detector regression, whether that drop is malicious→suspicious (real
139
+ * protection lost: only `malicious` redacts at runtime) or a drop all the way
140
+ * to `clean` (the rule stopped catching the attack entirely). A case with no
141
+ * recorded baseline (loaded from a pre-F2 corpus file) falls back to the
142
+ * original "not clean" floor. Exit-code-usable: the CLI maps
143
+ * `verdict === "fail"` to a non-zero exit. Uses `classifyText` with
144
+ * production defaults (no LLM layer), so the check is deterministic and
145
+ * offline.
146
+ */
147
+ export declare function checkSecurityCorpus(corpus: SecurityCorpus): Promise<CorpusCheckResult>;
148
+ /**
149
+ * A tool_result the detector scored `suspicious` (not blocked) but which
150
+ * carries the shape of an attack — a NEAR-MISS. The runtime keeps suspicious
151
+ * content verbatim (it only warns), so unlike malicious redactions the
152
+ * suspicious payloads ARE durable in the session log. We cannot know which
153
+ * were truly hostile, so these feed CANDIDATE rules for HUMAN review — never
154
+ * an auto-merge into REGEX_RULES.
155
+ */
156
+ export type NearMiss = {
157
+ /** The (sanitized, redacted) suspicious content snippet. */
158
+ readonly snippet: string;
159
+ /** Structural signal that made it a near-miss (see NEAR_MISS_SIGNALS). */
160
+ readonly signal: string;
161
+ };
162
+ /**
163
+ * Deterministic structural signals for near-miss clustering. Each is a strong
164
+ * "looks like an instruction-injection attempt" shape that the current
165
+ * REGEX_RULES do NOT already hard-block. Kept conservative — a candidate rule
166
+ * a human ignores is cheap; a false candidate that gets merged is not (which
167
+ * is why merge is never automatic).
168
+ */
169
+ export declare const NEAR_MISS_SIGNALS: ReadonlyArray<{
170
+ signal: string;
171
+ test: RegExp;
172
+ }>;
173
+ /**
174
+ * Redact anything PII/secret-shaped from a near-miss snippet, run through the
175
+ * SAME strong detector set the dataset/knowledge synthesis paths use
176
+ * (`SYNTHESIZE_PII_DETECTORS`, plus JWT/PEM — see `CANDIDATE_SECRET_DETECTORS`)
177
+ * rather than the narrow ad hoc email/blob/digit patterns this used to apply.
178
+ * This is used ONLY for in-memory clustering (dedup/support counting); the
179
+ * PERSISTED `candidate-rules.json` never stores this snippet at all — see
180
+ * `hashDescriptor` and F1. Deterministic, no raw values retained.
181
+ */
182
+ export declare function redactSnippet(text: string): string;
183
+ /** First 16 hex chars of SHA-256(redacted snippet) — a stable, non-reversible
184
+ * descriptor for the reviewer's dedup/audit trail. Deliberately NOT an HMAC
185
+ * (no per-tenant secret is plumbed into this module; this hash is a display
186
+ * aid, not an allow-list key like `pii-redactor`'s `hmacValue`), and hashed
187
+ * AFTER redaction so it identifies a "shape" (redacted form), never a raw
188
+ * secret/PII value. */
189
+ export declare function hashDescriptor(redacted: string): string;
190
+ export type CandidateRule = {
191
+ /** Proposed stable rule id (namespaced `candidate-<signal>`). */
192
+ readonly id: string;
193
+ readonly signal: string;
194
+ /** How many distinct near-miss snippets clustered under this signal. */
195
+ readonly support: number;
196
+ /**
197
+ * Up to a few short, fully-redacted descriptors of clustered snippets, for
198
+ * the reviewer's context — NEVER a raw or lightly-redacted sample. Each
199
+ * entry is `hash:<16-hex>` `[<kinds redacted>]` `<short prefix>` where the
200
+ * prefix has already passed through `redactSnippet`'s strong secret+PII
201
+ * detector set and is clamped to `SAMPLE_PREFIX_LENGTH` chars. See F1.
202
+ */
203
+ readonly samples: ReadonlyArray<string>;
204
+ /** A conservative proposed regex SOURCE (string, never compiled/merged). */
205
+ readonly proposedPattern: string;
206
+ readonly note: string;
207
+ };
208
+ /** Build the persisted, hash-first descriptor for one clustered snippet. The
209
+ * snippet has ALREADY passed through `redactSnippet`'s strong detector set;
210
+ * this additionally truncates hard to `SAMPLE_PREFIX_LENGTH` so no full-length
211
+ * secret — redacted or not — round-trips into `candidate-rules.json`. */
212
+ export declare function describeSample(redactedSnippet: string): string;
213
+ export type CandidateRulesFile = {
214
+ readonly version: 1;
215
+ readonly generatedAt: string;
216
+ /** Minimum cluster support required to emit a candidate. */
217
+ readonly minSupport: number;
218
+ readonly candidates: ReadonlyArray<CandidateRule>;
219
+ };
220
+ /**
221
+ * Harvest suspicious (near-miss) tool outputs from durable session logs. These
222
+ * are `tool_result` contents that are NOT redaction notices but match a
223
+ * NEAR_MISS_SIGNAL. Content is sanitized + redacted at parse time.
224
+ */
225
+ export declare function harvestNearMisses(opts: {
226
+ readonly rootDir: string;
227
+ readonly window: SinceWindow;
228
+ }): NearMiss[];
229
+ /**
230
+ * Cluster near-misses by structural signal and emit CANDIDATE detector rules
231
+ * for any signal with >= minSupport DISTINCT snippets. Deterministic: the same
232
+ * near-misses always yield the same candidates. Output is a reviewed file the
233
+ * operator reads — it is NEVER merged into REGEX_RULES automatically.
234
+ */
235
+ export declare function clusterCandidateRules(nearMisses: ReadonlyArray<NearMiss>, minSupport?: number, generatedAt?: string): CandidateRulesFile;
236
+ export declare function renderCorpusBuildLines(corpus: SecurityCorpus): ReadonlyArray<string>;
237
+ export declare function renderCorpusCheckLines(result: CorpusCheckResult): ReadonlyArray<string>;