crewhaus 0.1.7 → 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 +246 -0
  51. package/dist/feedback.js +612 -0
  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 +11727 -740
  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 -64
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Item 66 — `crewhaus onchain tune` / `onchain sentinel`: mine wallet-engine
3
+ * transaction receipts + simulation history to (a) propose a tuned
4
+ * `transaction_policy` from the observed legitimate spend distribution, and
5
+ * (b) flag anomalous spend against a learned baseline (an alert-watchdog for
6
+ * on-chain value).
7
+ *
8
+ * Input is a receipt-history JSONL — one record per broadcast, carrying the
9
+ * fields wallet-engine already surfaces on `BroadcastReceipt` + the tx it
10
+ * signed: `{ ts, walletId, chainId, contractId, valueWei, status, simulated }`.
11
+ * NO private key material is ever read — `keyRef` stays a reference and never
12
+ * enters this module; a receipt carries only a public tx hash, a contract id,
13
+ * and a wei value.
14
+ *
15
+ * `transaction_policy` is optimizer-whitelisted for the onchain / onchain-game
16
+ * targets (OPTIMIZABLE_PATHS), so the tuner emits VALIDATED `SpecPatch`es there
17
+ * (a `crewhaus optimize --write-back`-compatible edit). For a non-whitelisted
18
+ * target the tuner degrades to advice-only (the proposal is printed, no patch).
19
+ *
20
+ * Everything here is side-effect-free (the CLI entry file reads the history
21
+ * JSONL + writes patches/report). Deterministic: same receipts → same proposal.
22
+ */
23
+ /** Thrown on malformed history / bad flags. The CLI routes it through `die()`;
24
+ * tests assert on `.message`. */
25
+ export declare class OnchainTuneError extends Error {
26
+ readonly name = "OnchainTuneError";
27
+ }
28
+ /** One parsed receipt-history record. `valueWei` is the native-token value the
29
+ * tx moved, as a decimal or 0x-hex string (BigInt-parsed downstream). */
30
+ export type ReceiptRecord = {
31
+ readonly ts: number;
32
+ readonly walletId: string;
33
+ readonly chainId: string;
34
+ /** The spec-level contract binding id the tx targeted (symbolic allowlist). */
35
+ readonly contractId: string;
36
+ readonly valueWei: bigint;
37
+ /** "0x1" success / "0x0" revert / null pending — mirrors BroadcastReceipt. */
38
+ readonly status: "0x1" | "0x0" | null;
39
+ /** Whether a pre-broadcast simulation ran + succeeded. */
40
+ readonly simulated: boolean;
41
+ };
42
+ /** The current transaction_policy (subset the tuner reads/proposes). */
43
+ export type CurrentPolicy = {
44
+ readonly maxValueWei?: string;
45
+ readonly allowedContracts?: readonly string[];
46
+ readonly simulationRequired?: boolean;
47
+ };
48
+ /**
49
+ * Parse a receipt-history JSONL blob. Skips blank lines; throws on a
50
+ * structurally invalid record so a corrupt history is loud. A receipt with no
51
+ * `contractId` (a raw-address send — already forbidden by wallet-engine) is
52
+ * rejected so the tuner never learns an allowlist from un-symbolic sends.
53
+ */
54
+ export declare function parseReceiptHistory(jsonl: string): ReceiptRecord[];
55
+ export type ProposePolicyOptions = {
56
+ /** Headroom multiplier over the observed max legitimate spend for the
57
+ * proposed maxValueWei cap. Default 1.25 (25% headroom). */
58
+ readonly capMarginPct?: number;
59
+ /** Whether the source spec's target has transaction_policy whitelisted for
60
+ * optimizer write-back (onchain / onchain-game). When false, the tuner still
61
+ * computes the proposal but emits no SpecPatch (advice-only). */
62
+ readonly optimizable: boolean;
63
+ /** The spec target (for the SpecPatch discriminator). */
64
+ readonly target: string;
65
+ };
66
+ /** The tuner's proposal: the derived caps + allowlist and, when whitelisted,
67
+ * the validated transaction_policy block to write back. The CLI wraps this in
68
+ * a SpecPatch with the right op (`add` vs `replace`) once it has seen whether
69
+ * the raw YAML already carries a transaction_policy block. */
70
+ export type PolicyProposal = {
71
+ /** Proposed native-token ceiling (wei, decimal string). undefined when no
72
+ * successful spend was observed (nothing to bound). */
73
+ readonly proposedMaxValueWei?: string;
74
+ /** Contract ids observed in successful sends — the proposed allowlist. */
75
+ readonly proposedAllowedContracts: readonly string[];
76
+ /** Human-readable derivation notes. */
77
+ readonly rationale: string;
78
+ /** The transaction_policy block to write back (+ where + why), or undefined
79
+ * for advice-only. `op` is left to the CLI (it depends on raw-YAML presence). */
80
+ readonly patch?: {
81
+ readonly target: string;
82
+ readonly path: readonly string[];
83
+ readonly value: Record<string, unknown>;
84
+ readonly rationale: string;
85
+ };
86
+ /** Warnings the author should see (e.g. current cap is too tight vs observed). */
87
+ readonly warnings: readonly string[];
88
+ };
89
+ /**
90
+ * Propose a tuned transaction_policy from the observed legitimate spend. The cap
91
+ * is `max(successful valueWei) × capMargin`, rounded up; the allowlist is the
92
+ * set of contract ids that produced a successful send. When the target
93
+ * whitelists `transaction_policy` for the optimizer, a validated `replace`
94
+ * SpecPatch on the whole block is returned so `optimize --write-back` can apply
95
+ * it; otherwise the proposal is advice-only.
96
+ */
97
+ export declare function proposePolicy(receipts: readonly ReceiptRecord[], current: CurrentPolicy, opts: ProposePolicyOptions): PolicyProposal;
98
+ /** Per-contract spend baseline (successful sends only). All in wei. */
99
+ export type SpendBaseline = {
100
+ readonly perContract: Readonly<Record<string, {
101
+ count: number;
102
+ maxWei: bigint;
103
+ meanWei: bigint;
104
+ }>>;
105
+ /** Global ceiling across all contracts (max successful spend). */
106
+ readonly globalMaxWei: bigint;
107
+ };
108
+ /**
109
+ * Learn a spend baseline from successful receipts. Per contract we track count,
110
+ * max, and mean (integer wei). The map key is the raw contractId — contract
111
+ * ids are `[A-Za-z0-9_.-]`-ish spec identifiers, so a `|`-joined composite key
112
+ * is never needed and no control/NUL byte ever enters a key.
113
+ */
114
+ export declare function learnSpendBaseline(receipts: readonly ReceiptRecord[]): SpendBaseline;
115
+ /** One flagged anomaly. */
116
+ export type SpendAnomaly = {
117
+ readonly ts: number;
118
+ readonly contractId: string;
119
+ readonly valueWei: bigint;
120
+ readonly reason: string;
121
+ };
122
+ export type SentinelOptions = {
123
+ /** How many times over the per-contract observed max counts as anomalous.
124
+ * Default 2 (a spend > 2× the largest previously-seen legit send). */
125
+ readonly maxMultiple?: number;
126
+ /** Also flag a send to a contract id never seen in the baseline. Default true. */
127
+ readonly flagUnknownContracts?: boolean;
128
+ };
129
+ /**
130
+ * Detect anomalous spend in `candidate` receipts against a learned baseline.
131
+ * Flags: (a) a send to a contract id absent from the baseline, and (b) a value
132
+ * exceeding `maxMultiple ×` that contract's observed max. Deterministic; the
133
+ * baseline itself is never mutated. keyRef / private keys never enter here.
134
+ */
135
+ export declare function detectAnomalies(candidate: readonly ReceiptRecord[], baseline: SpendBaseline, opts?: SentinelOptions): SpendAnomaly[];
136
+ /**
137
+ * Detect anomalous spend when candidate and baseline are the SAME history
138
+ * (`crewhaus onchain sentinel` invoked without `--baseline`) — the "self-
139
+ * compare" mode.
140
+ *
141
+ * A naive `learnSpendBaseline(history)` + `detectAnomalies(history, ...)`
142
+ * self-masks: a receipt's own value is folded into its contract's baseline
143
+ * max, so a lone 100× spike can never exceed `maxMultiple × (its own value)`.
144
+ * The value-ceiling check — the headline sentinel feature — is silently
145
+ * defeated in exactly the default (no-`--baseline`) invocation.
146
+ *
147
+ * Fix: leave-one-out. For each candidate receipt, the ceiling is computed
148
+ * from the max successful spend over every OTHER successful receipt for that
149
+ * contract — the receipt under test never contributes to its own ceiling. A
150
+ * lone spike is then measured against the normal population and correctly
151
+ * flagged; a normal history (every value within range of its peers) still
152
+ * flags nothing. Unknown-contract detection needs no leave-one-out (a
153
+ * contract either has ≥1 OTHER successful receipt or it doesn't — a receipt
154
+ * is never "unknown relative to itself" in a way that matters here, since a
155
+ * lone successful send to a contract is, by definition, its own baseline of
156
+ * one and cannot be a value anomaly against itself); this path only ever
157
+ * calls detectAnomalies-style value/unknown logic per receipt with a leave-
158
+ * one-out baseline substituted in.
159
+ */
160
+ export declare function detectAnomaliesSelfCompare(history: readonly ReceiptRecord[], opts?: SentinelOptions): SpendAnomaly[];
161
+ /** Render a plain-text tune report. */
162
+ export declare function renderTuneReport(proposal: PolicyProposal): string;
163
+ /** Render a plain-text sentinel report. */
164
+ export declare function renderSentinelReport(anomalies: readonly SpendAnomaly[]): string;
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Item 66 — `crewhaus onchain tune` / `onchain sentinel`: mine wallet-engine
3
+ * transaction receipts + simulation history to (a) propose a tuned
4
+ * `transaction_policy` from the observed legitimate spend distribution, and
5
+ * (b) flag anomalous spend against a learned baseline (an alert-watchdog for
6
+ * on-chain value).
7
+ *
8
+ * Input is a receipt-history JSONL — one record per broadcast, carrying the
9
+ * fields wallet-engine already surfaces on `BroadcastReceipt` + the tx it
10
+ * signed: `{ ts, walletId, chainId, contractId, valueWei, status, simulated }`.
11
+ * NO private key material is ever read — `keyRef` stays a reference and never
12
+ * enters this module; a receipt carries only a public tx hash, a contract id,
13
+ * and a wei value.
14
+ *
15
+ * `transaction_policy` is optimizer-whitelisted for the onchain / onchain-game
16
+ * targets (OPTIMIZABLE_PATHS), so the tuner emits VALIDATED `SpecPatch`es there
17
+ * (a `crewhaus optimize --write-back`-compatible edit). For a non-whitelisted
18
+ * target the tuner degrades to advice-only (the proposal is printed, no patch).
19
+ *
20
+ * Everything here is side-effect-free (the CLI entry file reads the history
21
+ * JSONL + writes patches/report). Deterministic: same receipts → same proposal.
22
+ */
23
+ /** Thrown on malformed history / bad flags. The CLI routes it through `die()`;
24
+ * tests assert on `.message`. */
25
+ export class OnchainTuneError extends Error {
26
+ name = "OnchainTuneError";
27
+ }
28
+ function parseWeiString(raw, ctx) {
29
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 0)
30
+ return BigInt(raw);
31
+ if (typeof raw === "string" && /^(0x[0-9a-fA-F]+|[0-9]+)$/.test(raw.trim())) {
32
+ return BigInt(raw.trim());
33
+ }
34
+ throw new OnchainTuneError(`${ctx}: valueWei must be a non-negative wei decimal or 0x-hex string`);
35
+ }
36
+ /**
37
+ * Parse a receipt-history JSONL blob. Skips blank lines; throws on a
38
+ * structurally invalid record so a corrupt history is loud. A receipt with no
39
+ * `contractId` (a raw-address send — already forbidden by wallet-engine) is
40
+ * rejected so the tuner never learns an allowlist from un-symbolic sends.
41
+ */
42
+ export function parseReceiptHistory(jsonl) {
43
+ const out = [];
44
+ const lines = jsonl.split("\n");
45
+ for (let i = 0; i < lines.length; i++) {
46
+ const line = lines[i];
47
+ if (line === undefined || line.trim() === "")
48
+ continue;
49
+ let obj;
50
+ try {
51
+ obj = JSON.parse(line);
52
+ }
53
+ catch {
54
+ throw new OnchainTuneError(`receipt history line ${i + 1}: not valid JSON`);
55
+ }
56
+ if (obj === null || typeof obj !== "object") {
57
+ throw new OnchainTuneError(`receipt history line ${i + 1}: not an object`);
58
+ }
59
+ const rec = obj;
60
+ const contractId = rec["contractId"];
61
+ if (typeof contractId !== "string" || contractId.trim() === "") {
62
+ throw new OnchainTuneError(`receipt history line ${i + 1}: missing contractId (raw-address sends are not tunable)`);
63
+ }
64
+ const ts = typeof rec["ts"] === "number" ? rec["ts"] : i;
65
+ const walletId = typeof rec["walletId"] === "string" ? rec["walletId"] : "wallet";
66
+ const chainId = typeof rec["chainId"] === "string" ? rec["chainId"] : "chain";
67
+ const valueWei = parseWeiString(rec["valueWei"] ?? "0", `receipt history line ${i + 1}`);
68
+ const statusRaw = rec["status"];
69
+ const status = statusRaw === "0x1" || statusRaw === "0x0" ? statusRaw : null;
70
+ const simulated = rec["simulated"] === true;
71
+ out.push({ ts, walletId, chainId, contractId, valueWei, status, simulated });
72
+ }
73
+ return out;
74
+ }
75
+ /** Only receipts that actually settled successfully count as "legitimate" spend
76
+ * the policy should permit. Reverts + pending are excluded from the baseline. */
77
+ function successfulReceipts(receipts) {
78
+ return receipts.filter((r) => r.status === "0x1");
79
+ }
80
+ /**
81
+ * Propose a tuned transaction_policy from the observed legitimate spend. The cap
82
+ * is `max(successful valueWei) × capMargin`, rounded up; the allowlist is the
83
+ * set of contract ids that produced a successful send. When the target
84
+ * whitelists `transaction_policy` for the optimizer, a validated `replace`
85
+ * SpecPatch on the whole block is returned so `optimize --write-back` can apply
86
+ * it; otherwise the proposal is advice-only.
87
+ */
88
+ export function proposePolicy(receipts, current, opts) {
89
+ const margin = opts.capMarginPct ?? 1.25;
90
+ if (margin <= 1) {
91
+ throw new OnchainTuneError(`capMarginPct must be > 1 (got ${margin})`);
92
+ }
93
+ const ok = successfulReceipts(receipts);
94
+ const warnings = [];
95
+ // Allowlist = contract ids with ≥1 successful send, sorted + deduped.
96
+ const allowed = [...new Set(ok.map((r) => r.contractId))].sort();
97
+ // Cap = ceil(maxObservedWei * margin). BigInt-safe: scale the margin into an
98
+ // integer numerator/denominator to avoid float wei.
99
+ let proposedMaxValueWei;
100
+ let capRationale = "no successful sends observed — cannot bound spend";
101
+ if (ok.length > 0) {
102
+ let maxWei = 0n;
103
+ for (const r of ok)
104
+ if (r.valueWei > maxWei)
105
+ maxWei = r.valueWei;
106
+ // margin as basis points to stay integer: e.g. 1.25 → 12500 / 10000.
107
+ const bps = BigInt(Math.round(margin * 10_000));
108
+ const cap = (maxWei * bps + 9999n) / 10000n; // ceil-div
109
+ proposedMaxValueWei = cap.toString();
110
+ capRationale = `max observed successful spend ${maxWei} wei × ${margin} = ${cap} wei cap`;
111
+ // If the current cap is BELOW the observed legitimate max, flag it: real
112
+ // spend is being denied. If it's absent, note the newly-derived ceiling.
113
+ if (current.maxValueWei !== undefined) {
114
+ const curCap = parseWeiString(current.maxValueWei, "current transaction_policy.maxValueWei");
115
+ if (curCap < maxWei) {
116
+ warnings.push(`current maxValueWei ${curCap} wei is BELOW observed legitimate spend ${maxWei} wei — legitimate sends are being denied`);
117
+ }
118
+ }
119
+ }
120
+ // Contract ids that appear in the CURRENT allowlist but never in a successful
121
+ // send are candidates for removal (least-privilege). Advisory only.
122
+ if (current.allowedContracts !== undefined) {
123
+ const unused = current.allowedContracts.filter((c) => !allowed.includes(c));
124
+ if (unused.length > 0) {
125
+ warnings.push(`current allowedContracts includes never-used ids ${JSON.stringify(unused)} — consider removing (least privilege)`);
126
+ }
127
+ }
128
+ const excludedNote = ok.length < receipts.length
129
+ ? ` (${receipts.length - ok.length} reverted/pending receipts excluded)`
130
+ : "";
131
+ const rationale = `${capRationale}; allowlist derived from ${allowed.length} contract id(s) with successful sends${excludedNote}`;
132
+ const proposal = {
133
+ ...(proposedMaxValueWei !== undefined ? { proposedMaxValueWei } : {}),
134
+ proposedAllowedContracts: allowed,
135
+ rationale,
136
+ warnings,
137
+ };
138
+ if (opts.optimizable && proposedMaxValueWei !== undefined) {
139
+ // Whole-block replace so the write-back preserves the other policy fields.
140
+ const value = {
141
+ ...(current.simulationRequired !== undefined
142
+ ? { simulationRequired: current.simulationRequired }
143
+ : { simulationRequired: true }),
144
+ maxValueWei: proposedMaxValueWei,
145
+ allowedContracts: allowed,
146
+ };
147
+ return {
148
+ ...proposal,
149
+ patch: {
150
+ target: opts.target,
151
+ path: ["transaction_policy"],
152
+ value,
153
+ rationale: `onchain tune: ${rationale}`,
154
+ },
155
+ };
156
+ }
157
+ return proposal;
158
+ }
159
+ /**
160
+ * Learn a spend baseline from successful receipts. Per contract we track count,
161
+ * max, and mean (integer wei). The map key is the raw contractId — contract
162
+ * ids are `[A-Za-z0-9_.-]`-ish spec identifiers, so a `|`-joined composite key
163
+ * is never needed and no control/NUL byte ever enters a key.
164
+ */
165
+ export function learnSpendBaseline(receipts) {
166
+ const ok = successfulReceipts(receipts);
167
+ const acc = new Map();
168
+ let globalMaxWei = 0n;
169
+ for (const r of ok) {
170
+ const cur = acc.get(r.contractId) ?? { count: 0, maxWei: 0n, sumWei: 0n };
171
+ cur.count += 1;
172
+ cur.sumWei += r.valueWei;
173
+ if (r.valueWei > cur.maxWei)
174
+ cur.maxWei = r.valueWei;
175
+ acc.set(r.contractId, cur);
176
+ if (r.valueWei > globalMaxWei)
177
+ globalMaxWei = r.valueWei;
178
+ }
179
+ const perContract = {};
180
+ for (const [id, v] of acc) {
181
+ perContract[id] = {
182
+ count: v.count,
183
+ maxWei: v.maxWei,
184
+ meanWei: v.count > 0 ? v.sumWei / BigInt(v.count) : 0n,
185
+ };
186
+ }
187
+ return { perContract, globalMaxWei };
188
+ }
189
+ /**
190
+ * Detect anomalous spend in `candidate` receipts against a learned baseline.
191
+ * Flags: (a) a send to a contract id absent from the baseline, and (b) a value
192
+ * exceeding `maxMultiple ×` that contract's observed max. Deterministic; the
193
+ * baseline itself is never mutated. keyRef / private keys never enter here.
194
+ */
195
+ export function detectAnomalies(candidate, baseline, opts = {}) {
196
+ const maxMultiple = BigInt(Math.max(1, Math.round(opts.maxMultiple ?? 2)));
197
+ const flagUnknown = opts.flagUnknownContracts !== false;
198
+ const anomalies = [];
199
+ for (const r of candidate) {
200
+ const base = baseline.perContract[r.contractId];
201
+ if (base === undefined) {
202
+ if (flagUnknown) {
203
+ anomalies.push({
204
+ ts: r.ts,
205
+ contractId: r.contractId,
206
+ valueWei: r.valueWei,
207
+ reason: `send to contract id "${r.contractId}" never seen in the learned baseline`,
208
+ });
209
+ }
210
+ continue;
211
+ }
212
+ const ceiling = base.maxWei * maxMultiple;
213
+ if (r.valueWei > ceiling) {
214
+ anomalies.push({
215
+ ts: r.ts,
216
+ contractId: r.contractId,
217
+ valueWei: r.valueWei,
218
+ reason: `spend ${r.valueWei} wei exceeds ${maxMultiple}× the observed max ${base.maxWei} wei for "${r.contractId}"`,
219
+ });
220
+ }
221
+ }
222
+ return anomalies;
223
+ }
224
+ /**
225
+ * Detect anomalous spend when candidate and baseline are the SAME history
226
+ * (`crewhaus onchain sentinel` invoked without `--baseline`) — the "self-
227
+ * compare" mode.
228
+ *
229
+ * A naive `learnSpendBaseline(history)` + `detectAnomalies(history, ...)`
230
+ * self-masks: a receipt's own value is folded into its contract's baseline
231
+ * max, so a lone 100× spike can never exceed `maxMultiple × (its own value)`.
232
+ * The value-ceiling check — the headline sentinel feature — is silently
233
+ * defeated in exactly the default (no-`--baseline`) invocation.
234
+ *
235
+ * Fix: leave-one-out. For each candidate receipt, the ceiling is computed
236
+ * from the max successful spend over every OTHER successful receipt for that
237
+ * contract — the receipt under test never contributes to its own ceiling. A
238
+ * lone spike is then measured against the normal population and correctly
239
+ * flagged; a normal history (every value within range of its peers) still
240
+ * flags nothing. Unknown-contract detection needs no leave-one-out (a
241
+ * contract either has ≥1 OTHER successful receipt or it doesn't — a receipt
242
+ * is never "unknown relative to itself" in a way that matters here, since a
243
+ * lone successful send to a contract is, by definition, its own baseline of
244
+ * one and cannot be a value anomaly against itself); this path only ever
245
+ * calls detectAnomalies-style value/unknown logic per receipt with a leave-
246
+ * one-out baseline substituted in.
247
+ */
248
+ export function detectAnomaliesSelfCompare(history, opts = {}) {
249
+ const maxMultiple = BigInt(Math.max(1, Math.round(opts.maxMultiple ?? 2)));
250
+ const flagUnknown = opts.flagUnknownContracts !== false;
251
+ const ok = successfulReceipts(history);
252
+ // Precompute, per contract, the sorted successful wei values so a
253
+ // leave-one-out max can be derived in O(log n) per receipt instead of an
254
+ // O(n) rescan. (n is a receipt-history JSONL — expected small, but this
255
+ // keeps the self-compare path cheap regardless.)
256
+ const byContract = new Map();
257
+ for (const r of ok) {
258
+ const arr = byContract.get(r.contractId);
259
+ if (arr === undefined)
260
+ byContract.set(r.contractId, [r.valueWei]);
261
+ else
262
+ arr.push(r.valueWei);
263
+ }
264
+ const anomalies = [];
265
+ for (const r of history) {
266
+ const contractValues = byContract.get(r.contractId);
267
+ if (contractValues === undefined) {
268
+ // Only reachable for a non-successful (reverted/pending) receipt whose
269
+ // contract never had a successful send — genuinely unknown.
270
+ if (flagUnknown) {
271
+ anomalies.push({
272
+ ts: r.ts,
273
+ contractId: r.contractId,
274
+ valueWei: r.valueWei,
275
+ reason: `send to contract id "${r.contractId}" never seen in the learned baseline`,
276
+ });
277
+ }
278
+ continue;
279
+ }
280
+ if (r.status !== "0x1") {
281
+ // Reverted/pending receipts aren't part of the "legitimate spend"
282
+ // baseline and aren't value-checked here — mirrors the two-history
283
+ // path, where only successfulReceipts() feed learnSpendBaseline.
284
+ continue;
285
+ }
286
+ // Leave-one-out: the ceiling for THIS receipt excludes its own value —
287
+ // one occurrence of it is removed, then the max of the rest is taken.
288
+ let excludedOne = false;
289
+ let looMax = 0n;
290
+ for (const v of contractValues) {
291
+ if (!excludedOne && v === r.valueWei) {
292
+ excludedOne = true;
293
+ continue;
294
+ }
295
+ if (v > looMax)
296
+ looMax = v;
297
+ }
298
+ if (!excludedOne) {
299
+ // Defensive: r.valueWei should always be in contractValues (it was
300
+ // pushed there while building the map). If not, fall back to the full
301
+ // max rather than under-count.
302
+ for (const v of contractValues)
303
+ if (v > looMax)
304
+ looMax = v;
305
+ }
306
+ // A contract with only this one successful receipt has no "other"
307
+ // population to compare against — nothing to flag as a ceiling breach.
308
+ if (contractValues.length < 2)
309
+ continue;
310
+ const ceiling = looMax * maxMultiple;
311
+ if (r.valueWei > ceiling) {
312
+ anomalies.push({
313
+ ts: r.ts,
314
+ contractId: r.contractId,
315
+ valueWei: r.valueWei,
316
+ reason: `spend ${r.valueWei} wei exceeds ${maxMultiple}× the observed max ${looMax} wei for "${r.contractId}" (leave-one-out)`,
317
+ });
318
+ }
319
+ }
320
+ return anomalies;
321
+ }
322
+ /** Render a plain-text tune report. */
323
+ export function renderTuneReport(proposal) {
324
+ const lines = [];
325
+ lines.push("onchain policy tuner:");
326
+ lines.push(` proposed maxValueWei: ${proposal.proposedMaxValueWei ?? "(none — no successful spend)"}`);
327
+ lines.push(` proposed allowedContracts: [${proposal.proposedAllowedContracts.join(", ")}]`);
328
+ lines.push(` ${proposal.rationale}`);
329
+ for (const w of proposal.warnings)
330
+ lines.push(` ! ${w}`);
331
+ lines.push(proposal.patch !== undefined
332
+ ? " → validated transaction_policy patch emitted (apply with optimize --write-back)"
333
+ : " → advice-only (transaction_policy is not optimizer-whitelisted for this target)");
334
+ return `${lines.join("\n")}\n`;
335
+ }
336
+ /** Render a plain-text sentinel report. */
337
+ export function renderSentinelReport(anomalies) {
338
+ if (anomalies.length === 0) {
339
+ return "onchain spend sentinel: no anomalies vs the learned baseline.\n";
340
+ }
341
+ const lines = [`onchain spend sentinel: ${anomalies.length} anomaly(ies)`];
342
+ for (const a of anomalies) {
343
+ lines.push(` ANOMALY ${a.contractId} @ts=${a.ts}: ${a.reason}`);
344
+ }
345
+ return `${lines.join("\n")}\n`;
346
+ }
@@ -0,0 +1,126 @@
1
+ import type { PermissionRule, RuleType } from "@crewhaus/permission-engine";
2
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
3
+ import { type SessionEvents } from "./advise-rules";
4
+ export type AskAggregate = {
5
+ readonly toolName: string;
6
+ asks: number;
7
+ approved: number;
8
+ denied: number;
9
+ /** Sampled operative-arg values from adjacent tool_use inputs (deduped,
10
+ * capped). Empty ⇒ no arg pattern derivable → propose a bare tool glob. */
11
+ readonly argSamples: string[];
12
+ };
13
+ /**
14
+ * The operative input field(s) per built-in tool — the one a permission
15
+ * arg-glob constrains. Re-exported from `@crewhaus/tool-permission-matcher`
16
+ * (the SINGLE source of truth) so a suggested pattern always targets the SAME
17
+ * field the matcher checks. This was hand-copied here (a silent-desync risk);
18
+ * now a matcher edit propagates automatically. Keyed by the PascalCase runtime
19
+ * `.name`.
20
+ */
21
+ export declare const OPERATIVE_ARG_FIELDS: Readonly<Record<string, readonly string[]>>;
22
+ /** Max distinct operative-arg samples kept per tool (bounds report size). */
23
+ export declare const MAX_ARG_SAMPLES = 5;
24
+ /**
25
+ * Fold sessions into per-tool ask aggregates. Reads `permission` lines for
26
+ * the ask counts and `tool_use` lines for the operative-arg samples. Pure;
27
+ * tolerant of old-vintage logs (no permission lines → empty result).
28
+ */
29
+ export declare function aggregateAsks(sessions: ReadonlyArray<SessionEvents>): ReadonlyMap<string, AskAggregate>;
30
+ export type PermissionSuggestion = {
31
+ readonly rule: PermissionRule;
32
+ /** Why: "recurring-approved" | "recurring-denied". */
33
+ readonly reason: "recurring-approved" | "recurring-denied";
34
+ readonly toolName: string;
35
+ readonly readOnly: boolean;
36
+ readonly evidence: ReadonlyArray<string>;
37
+ /** The count the ranking sorts on (approvals or denials). */
38
+ readonly weight: number;
39
+ };
40
+ export type SuggestThresholds = {
41
+ /** Minimum ask prompts for a tool before any rule is proposed. */
42
+ readonly minAsks: number;
43
+ /** Fraction of asks approved at/above which an alwaysAllow is proposed. */
44
+ readonly approveRate: number;
45
+ /** Fraction of asks denied at/above which an alwaysAsk tightening fires. */
46
+ readonly denyRate: number;
47
+ };
48
+ export declare const DEFAULT_SUGGEST_THRESHOLDS: SuggestThresholds;
49
+ /** Build a name → readOnly lookup from the resolvable tool map (keyed by the
50
+ * RegisteredTool `.name`, which is what the ask aggregate is keyed by). */
51
+ export declare function readOnlyByName(toolMap: Readonly<Record<string, RegisteredTool>>): ReadonlyMap<string, boolean>;
52
+ /**
53
+ * Compile a permission pattern for a tool. When exactly one operative-arg
54
+ * value recurred we emit a tool+arg glob (`Bash(git status)`); a small set of
55
+ * distinct values stays a bare tool glob (a per-value rule set would be noise
56
+ * — the human can tighten). Tools with no operative field (or no samples) get
57
+ * the bare tool name.
58
+ *
59
+ * SAFETY (glob widening): the observed value is a LITERAL the human approved,
60
+ * not a glob the human authored. tool-permission-matcher treats `*`/`?` in an
61
+ * arg-glob as wildcards, so splicing a raw value that contains them would make
62
+ * the suggested rule match unapproved siblings (`Bash(npm run test:*)` matching
63
+ * `Bash(npm run test:PRODUCTION-DELETE)`). We `escapeGlobLiteral` the value so
64
+ * the rule matches ONLY the approved string. A value containing `(`/`)` cannot
65
+ * be represented in the matcher's `Tool(arg)` format (its paren split isn't
66
+ * escape-aware), so we fall back to a bare tool glob rather than emit a rule
67
+ * that misparses — the reviewer can hand-write a tighter one.
68
+ */
69
+ export declare function patternFor(agg: AskAggregate): string;
70
+ /**
71
+ * Rank ask aggregates into reviewable permission suggestions. Deterministic:
72
+ * - recurring-APPROVED tools (100% approved, ≥ minAsks) → `alwaysAllow`,
73
+ * read-only tools FIRST (lowest blast radius), then by approval count;
74
+ * - recurring-DENIED tools (deny rate ≥ denyRate) → `alwaysAsk` tightening
75
+ * (NOT alwaysDeny — a human denied a specific call, not the whole tool),
76
+ * by denial count.
77
+ * Read-only-ness comes from the tool map; an unknown tool defaults to false
78
+ * (treated as non-read-only ⇒ ranked after read-only grants).
79
+ */
80
+ export declare function rankSuggestions(aggregates: ReadonlyMap<string, AskAggregate>, readOnly: ReadonlyMap<string, boolean>, thresholds?: SuggestThresholds): PermissionSuggestion[];
81
+ export type SettingsPermissionRule = {
82
+ readonly type: RuleType;
83
+ readonly pattern: string;
84
+ };
85
+ /**
86
+ * Read the existing `permissions.rules` out of a parsed `.crewhaus/settings.json`
87
+ * root (the EXACT shape `buildRuleSet` in index.ts consumes). Tolerant of a
88
+ * missing/foreign file: anything that isn't the expected shape yields `[]`.
89
+ */
90
+ export declare function existingSettingsRules(settingsRoot: unknown): SettingsPermissionRule[];
91
+ export type PermissionsDiff = {
92
+ /** The suggestions not already covered by an identical existing rule. */
93
+ readonly additions: ReadonlyArray<SettingsPermissionRule>;
94
+ /** Suggestions dropped because an identical rule already exists. */
95
+ readonly alreadyPresent: ReadonlyArray<SettingsPermissionRule>;
96
+ /** The full rule list after applying additions (existing ++ additions). */
97
+ readonly merged: ReadonlyArray<SettingsPermissionRule>;
98
+ };
99
+ /**
100
+ * Compute the additive diff between the existing settings rules and the
101
+ * ranked suggestions. A suggestion whose (type, pattern) already exists is
102
+ * reported as already-present, never duplicated. Order-preserving: existing
103
+ * rules first, then new additions in suggestion rank order.
104
+ */
105
+ export declare function diffPermissions(existing: ReadonlyArray<SettingsPermissionRule>, suggestions: ReadonlyArray<PermissionSuggestion>): PermissionsDiff;
106
+ /**
107
+ * Render the settings.json `permissions` block that `--apply` would write,
108
+ * MERGING into the existing settings root so unrelated top-level keys
109
+ * (`hooks`, skills, …) survive. Returns the full new root object; the CLI
110
+ * JSON-stringifies it. Pure — never touches disk.
111
+ */
112
+ export declare function applyToSettingsRoot(settingsRoot: unknown, merged: ReadonlyArray<SettingsPermissionRule>): Record<string, unknown>;
113
+ /** Human-readable suggestion lines for the CLI text mode. */
114
+ export declare function formatSuggestionLines(suggestions: ReadonlyArray<PermissionSuggestion>): string[];
115
+ /**
116
+ * A unified-diff-ish view of the settings.json permissions change: existing
117
+ * rules as context, additions as `+` lines. Purely for human review — this
118
+ * is NOT applied unless the user confirms `--apply` interactively.
119
+ */
120
+ export declare function formatSettingsDiff(diff: PermissionsDiff): string[];
121
+ /**
122
+ * True when a pattern contains a glob wildcard (`*`/`?`) that is NOT
123
+ * backslash-escaped — i.e. one the matcher will treat as widening. A `\\*`/`\\?`
124
+ * (a literal escaped by `escapeGlobLiteral`) does not count.
125
+ */
126
+ export declare function hasUnescapedWildcard(pattern: string): boolean;