@crewhaus/target-onchain 0.1.4 → 0.1.6

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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Catalog F2 `target-onchain` — §47.
3
+ *
4
+ * Codegen for the `onchain` event-driven daemon. The emitted bundle is
5
+ * a single `agent.ts` file that:
6
+ *
7
+ * 1. Constructs the configured chain adapters from `chains[]`
8
+ * (slice-0 `@crewhaus/chain-adapter-evm` is the only adapter
9
+ * shipped today).
10
+ * 2. Constructs the wallet engine when `wallets[]` is non-empty
11
+ * (uses the `LocalSignerStub` only when an explicit `local`
12
+ * custody wallet is declared; KMS/HSM bridges are pluggable).
13
+ * 3. Subscribes to every trigger in `triggers[]` — event triggers
14
+ * poll via `eth_getLogs`, block triggers via `eth_blockNumber`,
15
+ * address triggers via filtered `eth_getLogs` on Transfer-shaped
16
+ * topics.
17
+ * 4. Dedupes inbound events by `(txHash, logIndex)` (or block
18
+ * number for block triggers) within `idempotencyWindowMs`.
19
+ * 5. Classifies the decoded event payload via
20
+ * `classifyBoundary({origin: "chain"})` before injecting into
21
+ * the model context (defense in depth — the chain adapter
22
+ * already classified the RPC body).
23
+ * 6. Runs one `runChatLoop({singleTurn: true})` per accepted
24
+ * event with the decoded payload as the user message.
25
+ * 7. Emits JSON events to stdout for each trigger fire +
26
+ * agent-run-start / agent-run-end.
27
+ * 8. Installs SIGTERM/SIGINT handlers that complete in-flight
28
+ * handlers before exit.
29
+ *
30
+ * Slice 2 ships the codegen — the emitted file references runtime
31
+ * packages by name. The first integration test compiles a fixture
32
+ * spec to a bundle, asserts the file contents, and (in a follow-up
33
+ * slice) boots the bundle against an in-process anvil fork.
34
+ */
35
+ import { CrewhausError } from "@crewhaus/errors";
36
+ import type { Bundle, IrChainV0 } from "@crewhaus/ir";
37
+ export declare class TargetEmitError extends CrewhausError {
38
+ readonly name = "TargetEmitError";
39
+ constructor(message: string, cause?: unknown);
40
+ }
41
+ export declare function emitOnchain(ir: IrChainV0): Bundle;
package/dist/index.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Catalog F2 `target-onchain` — §47.
3
+ *
4
+ * Codegen for the `onchain` event-driven daemon. The emitted bundle is
5
+ * a single `agent.ts` file that:
6
+ *
7
+ * 1. Constructs the configured chain adapters from `chains[]`
8
+ * (slice-0 `@crewhaus/chain-adapter-evm` is the only adapter
9
+ * shipped today).
10
+ * 2. Constructs the wallet engine when `wallets[]` is non-empty
11
+ * (uses the `LocalSignerStub` only when an explicit `local`
12
+ * custody wallet is declared; KMS/HSM bridges are pluggable).
13
+ * 3. Subscribes to every trigger in `triggers[]` — event triggers
14
+ * poll via `eth_getLogs`, block triggers via `eth_blockNumber`,
15
+ * address triggers via filtered `eth_getLogs` on Transfer-shaped
16
+ * topics.
17
+ * 4. Dedupes inbound events by `(txHash, logIndex)` (or block
18
+ * number for block triggers) within `idempotencyWindowMs`.
19
+ * 5. Classifies the decoded event payload via
20
+ * `classifyBoundary({origin: "chain"})` before injecting into
21
+ * the model context (defense in depth — the chain adapter
22
+ * already classified the RPC body).
23
+ * 6. Runs one `runChatLoop({singleTurn: true})` per accepted
24
+ * event with the decoded payload as the user message.
25
+ * 7. Emits JSON events to stdout for each trigger fire +
26
+ * agent-run-start / agent-run-end.
27
+ * 8. Installs SIGTERM/SIGINT handlers that complete in-flight
28
+ * handlers before exit.
29
+ *
30
+ * Slice 2 ships the codegen — the emitted file references runtime
31
+ * packages by name. The first integration test compiles a fixture
32
+ * spec to a bundle, asserts the file contents, and (in a follow-up
33
+ * slice) boots the bundle against an in-process anvil fork.
34
+ */
35
+ import { CrewhausError } from "@crewhaus/errors";
36
+ import { escapeJsonString } from "@crewhaus/infra-utils";
37
+ export class TargetEmitError extends CrewhausError {
38
+ name = "TargetEmitError";
39
+ constructor(message, cause) {
40
+ super("compiler", message, cause);
41
+ }
42
+ }
43
+ function renderSecretRef(ref) {
44
+ if (ref.kind === "literal")
45
+ return JSON.stringify(ref.value);
46
+ return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(`missing required env var ${ref.name}`)}); })()`;
47
+ }
48
+ /**
49
+ * #159 (CWE-798) — a wallet signing key must never be a bare literal in
50
+ * the emitted DO-NOT-EDIT artifact. The compiler's `lowerWalletKeyRef`
51
+ * already rejects literal keyRefs at lower time; this is the
52
+ * defense-in-depth guard at the emit boundary (e.g. for a hand-built IR
53
+ * that bypassed the lowerer). Env refs pass; only `kms://` / `hsm://`
54
+ * key handles are permitted as literals.
55
+ */
56
+ const KEY_HANDLE_RE = /^(kms|hsm):\/\/.+/;
57
+ function renderWalletKeyRef(walletId, ref) {
58
+ if (ref.kind === "literal" && !KEY_HANDLE_RE.test(ref.value)) {
59
+ throw new TargetEmitError(`wallet "${walletId}" keyRef is a literal signing key; refusing to embed it in the generated bundle. Use an environment reference or a kms:// / hsm:// handle.`);
60
+ }
61
+ return renderSecretRef(ref);
62
+ }
63
+ function renderChain(chain) {
64
+ const rpcs = chain.rpcUrls.map(renderSecretRef).join(", ");
65
+ const finality = chain.finality.kind === "confirmations"
66
+ ? `{ kind: "confirmations", count: ${chain.finality.count} }`
67
+ : `{ kind: ${JSON.stringify(chain.finality.kind)} }`;
68
+ return [
69
+ " {",
70
+ ` chainId: ${JSON.stringify(chain.id)},`,
71
+ ` rpcUrls: [${rpcs}],`,
72
+ ` rpcPolicy: ${JSON.stringify(chain.rpcPolicy)},`,
73
+ ` finality: ${finality},`,
74
+ ` reorgTolerant: ${chain.reorgTolerant},`,
75
+ " }",
76
+ ].join("\n");
77
+ }
78
+ function renderWallet(w) {
79
+ const keyRefStr = w.keyRef !== undefined ? `,\n keyRef: ${renderWalletKeyRef(w.id, w.keyRef)}` : "";
80
+ return [
81
+ " {",
82
+ ` id: ${JSON.stringify(w.id)},`,
83
+ ` chainId: ${JSON.stringify(w.chainId)},`,
84
+ ` custody: ${JSON.stringify(w.custody)},`,
85
+ ` signingPolicy: ${JSON.stringify(w.signingPolicy)}${keyRefStr ? "" : ""}`,
86
+ ...(keyRefStr ? [keyRefStr.replace(/^,\n/, "")] : []),
87
+ " }",
88
+ ].join("\n");
89
+ }
90
+ function renderContract(c) {
91
+ return [
92
+ " {",
93
+ ` id: ${JSON.stringify(c.id)},`,
94
+ ` chainId: ${JSON.stringify(c.chainId)},`,
95
+ ` address: ${JSON.stringify(c.address)},`,
96
+ ` abiRef: ${JSON.stringify(c.abiRef)},`,
97
+ " }",
98
+ ].join("\n");
99
+ }
100
+ function renderTrigger(t) {
101
+ if (t.kind === "event") {
102
+ const filter = t.filter !== undefined ? `,\n filter: ${JSON.stringify(t.filter)}` : "";
103
+ return ` { kind: "event", chainId: ${JSON.stringify(t.chainId)}, contract: ${JSON.stringify(t.contract)}, event: ${JSON.stringify(t.event)}${filter} }`;
104
+ }
105
+ if (t.kind === "block") {
106
+ return ` { kind: "block", chainId: ${JSON.stringify(t.chainId)}, scanIntervalMs: ${t.scanIntervalMs} }`;
107
+ }
108
+ return ` { kind: "address", chainId: ${JSON.stringify(t.chainId)}, address: ${JSON.stringify(t.address)}, direction: ${JSON.stringify(t.direction)} }`;
109
+ }
110
+ export function emitOnchain(ir) {
111
+ if (ir.chains.length === 0) {
112
+ throw new TargetEmitError("onchain target requires at least one chain binding");
113
+ }
114
+ if (ir.triggers.length === 0) {
115
+ throw new TargetEmitError("onchain target requires at least one trigger");
116
+ }
117
+ // Validate trigger references against chains[].
118
+ const chainIds = new Set(ir.chains.map((c) => c.id));
119
+ for (const t of ir.triggers) {
120
+ if (!chainIds.has(t.chainId)) {
121
+ throw new TargetEmitError(`trigger references chainId "${t.chainId}" not declared in chains[]`);
122
+ }
123
+ }
124
+ const policy = ir.transactionPolicy;
125
+ // #151 — resolve the declared contracts[] (id -> address) into the policy so
126
+ // the wallet-engine can bind `tx.to` to the address registered for a claimed
127
+ // contractId. Without this map a whitelisted id can still be pointed at an
128
+ // arbitrary address.
129
+ const contractAddresses = {};
130
+ for (const c of ir.contracts) {
131
+ contractAddresses[c.id] = c.address;
132
+ }
133
+ const policyLiteral = JSON.stringify({
134
+ defaultWriteApproval: policy.defaultWriteApproval,
135
+ allowedContracts: policy.allowedContracts,
136
+ simulationRequired: policy.simulationRequired,
137
+ ...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
138
+ ...(policy.maxValueWei !== undefined ? { maxValueWei: policy.maxValueWei } : {}),
139
+ ...(ir.contracts.length > 0 ? { contractAddresses } : {}),
140
+ });
141
+ const chains = ir.chains.map(renderChain).join(",\n");
142
+ const wallets = ir.wallets.map(renderWallet).join(",\n");
143
+ const contracts = ir.contracts.map(renderContract).join(",\n");
144
+ const triggers = ir.triggers.map(renderTrigger).join(",\n");
145
+ const instructions = escapeJsonString(ir.agent.instructions);
146
+ const name = escapeJsonString(ir.name);
147
+ // Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
148
+ // a block comment lets a crafted spec.name containing `*/` (or a newline) break
149
+ // out of the comment and inject top-level code — RCE on build/run (#147).
150
+ const content = `// Generated by @crewhaus/target-onchain (§47 onchain daemon).
151
+ // Compiled from spec: ${name}
152
+ // DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
153
+ import { classifyBoundary } from "@crewhaus/boundary-classifier";
154
+ import { createEvmAdapter } from "@crewhaus/chain-adapter-evm";
155
+
156
+ export const SPEC_NAME = ${name};
157
+ export const AGENT_MODEL = ${JSON.stringify(ir.agent.model)};
158
+ export const AGENT_INSTRUCTIONS = ${instructions};
159
+ export const IDEMPOTENCY_WINDOW_MS = ${ir.idempotencyWindowMs};
160
+
161
+ export const CHAINS = [
162
+ ${chains}
163
+ ];
164
+
165
+ export const WALLETS = [
166
+ ${wallets}
167
+ ];
168
+
169
+ export const CONTRACTS = [
170
+ ${contracts}
171
+ ];
172
+
173
+ export const TRANSACTION_POLICY = ${policyLiteral};
174
+
175
+ export const TRIGGERS = [
176
+ ${triggers}
177
+ ];
178
+
179
+ /**
180
+ * Bootstrap the adapter map keyed by chainId. The runtime calls this
181
+ * once on daemon start; downstream tools (tool-evm, tool-evm-tx,
182
+ * permission-tokengated) resolve adapters through the returned map.
183
+ */
184
+ export function buildAdapters(): Map<string, ReturnType<typeof createEvmAdapter>> {
185
+ const m = new Map<string, ReturnType<typeof createEvmAdapter>>();
186
+ for (const c of CHAINS) {
187
+ m.set(c.chainId, createEvmAdapter(c));
188
+ }
189
+ return m;
190
+ }
191
+
192
+ /**
193
+ * Defense-in-depth: re-classify the decoded event payload (the chain
194
+ * adapter already classified the RPC envelope, but this catches any
195
+ * decoded-event content that an attacker may have planted in event
196
+ * indexed args).
197
+ */
198
+ export async function acceptOrRedact(payload: string): Promise<{ accept: boolean; text: string }> {
199
+ const verdict = await classifyBoundary(payload, { origin: "chain" });
200
+ if (verdict.action === "redact") {
201
+ return { accept: false, text: verdict.redacted ?? "[chain payload redacted]" };
202
+ }
203
+ return { accept: true, text: payload };
204
+ }
205
+
206
+ // Slice-2 emits the adapter wiring + trigger metadata. The full event-
207
+ // subscription loop (poll for new logs, dedupe, dispatch to the agent)
208
+ // is wired in target-onchain v1 once the runtime's runChatLoop accepts
209
+ // the structured payload directly. Until then, the emitted bundle is a
210
+ // callable module that downstream daemons or tests import.
211
+ `;
212
+ return { files: [{ path: "agent.ts", content }] };
213
+ }
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@crewhaus/target-onchain",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Codegen for the §47 `onchain` event-driven daemon. Emits a self-contained agent.ts that subscribes to chain triggers, classifies inbound events, dedupes by tx-hash + log-index, and runs one agent turn per event.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4",
16
- "@crewhaus/infra-utils": "0.1.4",
17
- "@crewhaus/ir": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6",
19
+ "@crewhaus/infra-utils": "0.1.6",
20
+ "@crewhaus/ir": "0.1.6"
18
21
  },
19
22
  "license": "Apache-2.0",
20
23
  "author": {
@@ -34,5 +37,5 @@
34
37
  "publishConfig": {
35
38
  "access": "public"
36
39
  },
37
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
40
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
38
41
  }
package/src/index.test.ts DELETED
@@ -1,270 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { IrChainV0 } from "@crewhaus/ir";
3
- import { TargetEmitError, emitOnchain } from "./index";
4
-
5
- function baseIr(overrides: Partial<IrChainV0> = {}): IrChainV0 {
6
- return {
7
- version: 0,
8
- name: "treasury-watch",
9
- target: "onchain",
10
- agent: { model: "claude-opus-4-7", instructions: "Watch the treasury." },
11
- chains: [
12
- {
13
- id: "base-mainnet",
14
- kind: "evm",
15
- rpcUrls: [{ kind: "env", name: "BASE_RPC" }],
16
- rpcPolicy: "single",
17
- finality: { kind: "confirmations", count: 12 },
18
- reorgTolerant: true,
19
- },
20
- ],
21
- wallets: [],
22
- contracts: [
23
- {
24
- id: "treasury",
25
- chainId: "base-mainnet",
26
- address: "0xtreasury",
27
- abiRef: "abi://safe",
28
- },
29
- ],
30
- transactionPolicy: {
31
- defaultWriteApproval: "required",
32
- allowedContracts: [],
33
- simulationRequired: true,
34
- },
35
- triggers: [
36
- {
37
- kind: "event",
38
- chainId: "base-mainnet",
39
- contract: "treasury",
40
- event: "ExecutionSuccess",
41
- },
42
- ],
43
- idempotencyWindowMs: 60_000,
44
- tools: [],
45
- toolConfigs: {},
46
- mcp_servers: {},
47
- permissions: { rules: [] },
48
- compaction: {},
49
- ...overrides,
50
- };
51
- }
52
-
53
- describe("emitOnchain — happy path", () => {
54
- test("emits a single agent.ts with the daemon metadata", () => {
55
- const bundle = emitOnchain(baseIr());
56
- expect(bundle.files).toHaveLength(1);
57
- const file = bundle.files[0];
58
- expect(file?.path).toBe("agent.ts");
59
- expect(file?.content).toContain('SPEC_NAME = "treasury-watch"');
60
- expect(file?.content).toContain('AGENT_MODEL = "claude-opus-4-7"');
61
- expect(file?.content).toContain('AGENT_INSTRUCTIONS = "Watch the treasury."');
62
- expect(file?.content).toContain("IDEMPOTENCY_WINDOW_MS = 60000");
63
- expect(file?.content).toContain('chainId: "base-mainnet"');
64
- expect(file?.content).toContain("createEvmAdapter");
65
- expect(file?.content).toContain('event: "ExecutionSuccess"');
66
- expect(file?.content).toContain("classifyBoundary");
67
- expect(file?.content).toContain('origin: "chain"');
68
- });
69
-
70
- test("env-ref secrets render as process.env lookups", () => {
71
- const bundle = emitOnchain(baseIr());
72
- const content = bundle.files[0]?.content ?? "";
73
- expect(content).toContain('process.env["BASE_RPC"]');
74
- expect(content).toContain("missing required env var BASE_RPC");
75
- });
76
-
77
- test("literal secrets render verbatim", () => {
78
- const ir = baseIr();
79
- const firstChain = ir.chains[0];
80
- if (firstChain === undefined) throw new Error("baseIr should produce a chain");
81
- const bundle = emitOnchain({
82
- ...ir,
83
- chains: [
84
- {
85
- ...firstChain,
86
- rpcUrls: [{ kind: "literal", value: "https://rpc.example.com" }],
87
- },
88
- ],
89
- });
90
- expect(bundle.files[0]?.content).toContain("https://rpc.example.com");
91
- });
92
-
93
- test("renders the three trigger kinds", () => {
94
- const bundle = emitOnchain(
95
- baseIr({
96
- triggers: [
97
- {
98
- kind: "event",
99
- chainId: "base-mainnet",
100
- contract: "treasury",
101
- event: "ExecutionSuccess",
102
- },
103
- { kind: "block", chainId: "base-mainnet", scanIntervalMs: 30_000 },
104
- {
105
- kind: "address",
106
- chainId: "base-mainnet",
107
- address: "0xwhale",
108
- direction: "both",
109
- },
110
- ],
111
- }),
112
- );
113
- const c = bundle.files[0]?.content ?? "";
114
- expect(c).toContain('kind: "event"');
115
- expect(c).toContain('kind: "block"');
116
- expect(c).toContain('kind: "address"');
117
- expect(c).toContain("scanIntervalMs: 30000");
118
- expect(c).toContain('direction: "both"');
119
- });
120
-
121
- test("renders transaction_policy literal", () => {
122
- const bundle = emitOnchain(
123
- baseIr({
124
- transactionPolicy: {
125
- defaultWriteApproval: "required",
126
- allowedContracts: ["treasury"],
127
- simulationRequired: true,
128
- maxValueUsd: 5000,
129
- },
130
- }),
131
- );
132
- const c = bundle.files[0]?.content ?? "";
133
- expect(c).toContain('"allowedContracts":["treasury"]');
134
- expect(c).toContain('"maxValueUsd":5000');
135
- });
136
-
137
- // SECURITY: maxValueWei is the ONLY native-value cap wallet-engine can
138
- // enforce (maxValueUsd hard-throws with no oracle); it must reach the
139
- // emitted TRANSACTION_POLICY literal so the ceiling is actually applied.
140
- test("renders transaction_policy.maxValueWei into the emitted literal", () => {
141
- const bundle = emitOnchain(
142
- baseIr({
143
- transactionPolicy: {
144
- defaultWriteApproval: "policy",
145
- allowedContracts: [],
146
- simulationRequired: true,
147
- maxValueWei: "1000000000000000000",
148
- },
149
- }),
150
- );
151
- const c = bundle.files[0]?.content ?? "";
152
- expect(c).toContain('"maxValueWei":"1000000000000000000"');
153
- });
154
-
155
- // #151 activation — the policy must carry a resolved contractId -> address
156
- // map so the wallet-engine can bind tx.to to the declared contract.
157
- test("populates transaction_policy.contractAddresses from declared contracts[]", () => {
158
- const bundle = emitOnchain(
159
- baseIr({
160
- contracts: [
161
- { id: "treasury", chainId: "base-mainnet", address: "0xTREASURY", abiRef: "abi://safe" },
162
- { id: "vault", chainId: "base-mainnet", address: "0xVAULT", abiRef: "abi://erc20" },
163
- ],
164
- }),
165
- );
166
- const c = bundle.files[0]?.content ?? "";
167
- expect(c).toContain('"contractAddresses":{"treasury":"0xTREASURY","vault":"0xVAULT"}');
168
- });
169
-
170
- test("omits contractAddresses when no contracts are declared", () => {
171
- const bundle = emitOnchain(baseIr({ contracts: [] }));
172
- const c = bundle.files[0]?.content ?? "";
173
- expect(c).not.toContain("contractAddresses");
174
- });
175
-
176
- // #159 (CWE-798) — a wallet keyRef that is an env ref or a kms:// / hsm://
177
- // handle is fine; a bare literal (esp. a raw hex private key) must not be
178
- // baked into the emitted artifact.
179
- test("renders a wallet env-ref keyRef as a process.env lookup", () => {
180
- const bundle = emitOnchain(
181
- baseIr({
182
- wallets: [
183
- {
184
- id: "treasurer",
185
- chainId: "base-mainnet",
186
- custody: "kms",
187
- signingPolicy: "policy-gated",
188
- keyRef: { kind: "env", name: "TREASURER_KEY" },
189
- },
190
- ],
191
- }),
192
- );
193
- const c = bundle.files[0]?.content ?? "";
194
- expect(c).toContain('process.env["TREASURER_KEY"]');
195
- });
196
-
197
- test("renders a kms:// keyRef handle verbatim", () => {
198
- const bundle = emitOnchain(
199
- baseIr({
200
- wallets: [
201
- {
202
- id: "treasurer",
203
- chainId: "base-mainnet",
204
- custody: "kms",
205
- signingPolicy: "policy-gated",
206
- keyRef: { kind: "literal", value: "kms://aws/treasurer-key" },
207
- },
208
- ],
209
- }),
210
- );
211
- const c = bundle.files[0]?.content ?? "";
212
- expect(c).toContain('"kms://aws/treasurer-key"');
213
- });
214
-
215
- test("rejects a literal (hex private key) wallet keyRef", () => {
216
- expect(() =>
217
- emitOnchain(
218
- baseIr({
219
- wallets: [
220
- {
221
- id: "treasurer",
222
- chainId: "base-mainnet",
223
- custody: "local",
224
- signingPolicy: "automated",
225
- keyRef: {
226
- kind: "literal",
227
- value: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
228
- },
229
- },
230
- ],
231
- }),
232
- ),
233
- ).toThrow(TargetEmitError);
234
- });
235
- });
236
-
237
- describe("emitOnchain — validation", () => {
238
- test("rejects empty chains[]", () => {
239
- expect(() => emitOnchain(baseIr({ chains: [] }))).toThrow(TargetEmitError);
240
- });
241
-
242
- test("rejects empty triggers[]", () => {
243
- expect(() => emitOnchain(baseIr({ triggers: [] }))).toThrow(TargetEmitError);
244
- });
245
-
246
- test("rejects a trigger that references an undeclared chainId", () => {
247
- expect(() =>
248
- emitOnchain(
249
- baseIr({
250
- triggers: [{ kind: "block", chainId: "polygon-mainnet", scanIntervalMs: 10_000 }],
251
- }),
252
- ),
253
- ).toThrow(/not declared in chains/);
254
- });
255
- });
256
-
257
- describe("emitOnchain — spec-name codegen injection (#147)", () => {
258
- test("a crafted name cannot break out of the header comment", () => {
259
- // Block-comment escape: a `*/` (plus a newline) in the name would, with a
260
- // raw `/* … */` header, terminate the comment and inject top-level code.
261
- const evil = "safe */ globalThis.__PWNED_ONCHAIN__ = 1; /*\nmore";
262
- const content = emitOnchain(baseIr({ name: evil })).files[0]?.content ?? "";
263
- // Header is now a `//` line comment using the JSON-escaped name.
264
- expect(content).toMatch(/^\/\/ Compiled from spec:/m);
265
- // The payload only ever appears inside the escaped SPEC_NAME string and the
266
- // comment — never as a top-level statement at column 0.
267
- expect(content).not.toMatch(/^globalThis\.__PWNED_ONCHAIN__/m);
268
- expect(content).not.toContain("*/\nglobalThis");
269
- });
270
- });
package/src/index.ts DELETED
@@ -1,234 +0,0 @@
1
- /**
2
- * Catalog F2 `target-onchain` — §47.
3
- *
4
- * Codegen for the `onchain` event-driven daemon. The emitted bundle is
5
- * a single `agent.ts` file that:
6
- *
7
- * 1. Constructs the configured chain adapters from `chains[]`
8
- * (slice-0 `@crewhaus/chain-adapter-evm` is the only adapter
9
- * shipped today).
10
- * 2. Constructs the wallet engine when `wallets[]` is non-empty
11
- * (uses the `LocalSignerStub` only when an explicit `local`
12
- * custody wallet is declared; KMS/HSM bridges are pluggable).
13
- * 3. Subscribes to every trigger in `triggers[]` — event triggers
14
- * poll via `eth_getLogs`, block triggers via `eth_blockNumber`,
15
- * address triggers via filtered `eth_getLogs` on Transfer-shaped
16
- * topics.
17
- * 4. Dedupes inbound events by `(txHash, logIndex)` (or block
18
- * number for block triggers) within `idempotencyWindowMs`.
19
- * 5. Classifies the decoded event payload via
20
- * `classifyBoundary({origin: "chain"})` before injecting into
21
- * the model context (defense in depth — the chain adapter
22
- * already classified the RPC body).
23
- * 6. Runs one `runChatLoop({singleTurn: true})` per accepted
24
- * event with the decoded payload as the user message.
25
- * 7. Emits JSON events to stdout for each trigger fire +
26
- * agent-run-start / agent-run-end.
27
- * 8. Installs SIGTERM/SIGINT handlers that complete in-flight
28
- * handlers before exit.
29
- *
30
- * Slice 2 ships the codegen — the emitted file references runtime
31
- * packages by name. The first integration test compiles a fixture
32
- * spec to a bundle, asserts the file contents, and (in a follow-up
33
- * slice) boots the bundle against an in-process anvil fork.
34
- */
35
- import { CrewhausError } from "@crewhaus/errors";
36
- import { escapeJsonString } from "@crewhaus/infra-utils";
37
- import type { Bundle, IrChainV0, IrSecretRef } from "@crewhaus/ir";
38
-
39
- export class TargetEmitError extends CrewhausError {
40
- override readonly name = "TargetEmitError";
41
- constructor(message: string, cause?: unknown) {
42
- super("compiler", message, cause);
43
- }
44
- }
45
-
46
- function renderSecretRef(ref: IrSecretRef): string {
47
- if (ref.kind === "literal") return JSON.stringify(ref.value);
48
- return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(
49
- `missing required env var ${ref.name}`,
50
- )}); })()`;
51
- }
52
-
53
- /**
54
- * #159 (CWE-798) — a wallet signing key must never be a bare literal in
55
- * the emitted DO-NOT-EDIT artifact. The compiler's `lowerWalletKeyRef`
56
- * already rejects literal keyRefs at lower time; this is the
57
- * defense-in-depth guard at the emit boundary (e.g. for a hand-built IR
58
- * that bypassed the lowerer). Env refs pass; only `kms://` / `hsm://`
59
- * key handles are permitted as literals.
60
- */
61
- const KEY_HANDLE_RE = /^(kms|hsm):\/\/.+/;
62
- function renderWalletKeyRef(walletId: string, ref: IrSecretRef): string {
63
- if (ref.kind === "literal" && !KEY_HANDLE_RE.test(ref.value)) {
64
- throw new TargetEmitError(
65
- `wallet "${walletId}" keyRef is a literal signing key; refusing to embed it in the generated bundle. Use an environment reference or a kms:// / hsm:// handle.`,
66
- );
67
- }
68
- return renderSecretRef(ref);
69
- }
70
-
71
- function renderChain(chain: IrChainV0["chains"][number]): string {
72
- const rpcs = chain.rpcUrls.map(renderSecretRef).join(", ");
73
- const finality =
74
- chain.finality.kind === "confirmations"
75
- ? `{ kind: "confirmations", count: ${chain.finality.count} }`
76
- : `{ kind: ${JSON.stringify(chain.finality.kind)} }`;
77
- return [
78
- " {",
79
- ` chainId: ${JSON.stringify(chain.id)},`,
80
- ` rpcUrls: [${rpcs}],`,
81
- ` rpcPolicy: ${JSON.stringify(chain.rpcPolicy)},`,
82
- ` finality: ${finality},`,
83
- ` reorgTolerant: ${chain.reorgTolerant},`,
84
- " }",
85
- ].join("\n");
86
- }
87
-
88
- function renderWallet(w: IrChainV0["wallets"][number]): string {
89
- const keyRefStr =
90
- w.keyRef !== undefined ? `,\n keyRef: ${renderWalletKeyRef(w.id, w.keyRef)}` : "";
91
- return [
92
- " {",
93
- ` id: ${JSON.stringify(w.id)},`,
94
- ` chainId: ${JSON.stringify(w.chainId)},`,
95
- ` custody: ${JSON.stringify(w.custody)},`,
96
- ` signingPolicy: ${JSON.stringify(w.signingPolicy)}${keyRefStr ? "" : ""}`,
97
- ...(keyRefStr ? [keyRefStr.replace(/^,\n/, "")] : []),
98
- " }",
99
- ].join("\n");
100
- }
101
-
102
- function renderContract(c: IrChainV0["contracts"][number]): string {
103
- return [
104
- " {",
105
- ` id: ${JSON.stringify(c.id)},`,
106
- ` chainId: ${JSON.stringify(c.chainId)},`,
107
- ` address: ${JSON.stringify(c.address)},`,
108
- ` abiRef: ${JSON.stringify(c.abiRef)},`,
109
- " }",
110
- ].join("\n");
111
- }
112
-
113
- function renderTrigger(t: IrChainV0["triggers"][number]): string {
114
- if (t.kind === "event") {
115
- const filter = t.filter !== undefined ? `,\n filter: ${JSON.stringify(t.filter)}` : "";
116
- return ` { kind: "event", chainId: ${JSON.stringify(t.chainId)}, contract: ${JSON.stringify(t.contract)}, event: ${JSON.stringify(t.event)}${filter} }`;
117
- }
118
- if (t.kind === "block") {
119
- return ` { kind: "block", chainId: ${JSON.stringify(t.chainId)}, scanIntervalMs: ${t.scanIntervalMs} }`;
120
- }
121
- return ` { kind: "address", chainId: ${JSON.stringify(t.chainId)}, address: ${JSON.stringify(t.address)}, direction: ${JSON.stringify(t.direction)} }`;
122
- }
123
-
124
- export function emitOnchain(ir: IrChainV0): Bundle {
125
- if (ir.chains.length === 0) {
126
- throw new TargetEmitError("onchain target requires at least one chain binding");
127
- }
128
- if (ir.triggers.length === 0) {
129
- throw new TargetEmitError("onchain target requires at least one trigger");
130
- }
131
- // Validate trigger references against chains[].
132
- const chainIds = new Set(ir.chains.map((c) => c.id));
133
- for (const t of ir.triggers) {
134
- if (!chainIds.has(t.chainId)) {
135
- throw new TargetEmitError(
136
- `trigger references chainId "${t.chainId}" not declared in chains[]`,
137
- );
138
- }
139
- }
140
-
141
- const policy = ir.transactionPolicy;
142
- // #151 — resolve the declared contracts[] (id -> address) into the policy so
143
- // the wallet-engine can bind `tx.to` to the address registered for a claimed
144
- // contractId. Without this map a whitelisted id can still be pointed at an
145
- // arbitrary address.
146
- const contractAddresses: Record<string, string> = {};
147
- for (const c of ir.contracts) {
148
- contractAddresses[c.id] = c.address;
149
- }
150
- const policyLiteral = JSON.stringify({
151
- defaultWriteApproval: policy.defaultWriteApproval,
152
- allowedContracts: policy.allowedContracts,
153
- simulationRequired: policy.simulationRequired,
154
- ...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
155
- ...(policy.maxValueWei !== undefined ? { maxValueWei: policy.maxValueWei } : {}),
156
- ...(ir.contracts.length > 0 ? { contractAddresses } : {}),
157
- });
158
-
159
- const chains = ir.chains.map(renderChain).join(",\n");
160
- const wallets = ir.wallets.map(renderWallet).join(",\n");
161
- const contracts = ir.contracts.map(renderContract).join(",\n");
162
- const triggers = ir.triggers.map(renderTrigger).join(",\n");
163
-
164
- const instructions = escapeJsonString(ir.agent.instructions);
165
- const name = escapeJsonString(ir.name);
166
-
167
- // Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
168
- // a block comment lets a crafted spec.name containing `*/` (or a newline) break
169
- // out of the comment and inject top-level code — RCE on build/run (#147).
170
- const content = `// Generated by @crewhaus/target-onchain (§47 onchain daemon).
171
- // Compiled from spec: ${name}
172
- // DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
173
- import { classifyBoundary } from "@crewhaus/boundary-classifier";
174
- import { createEvmAdapter } from "@crewhaus/chain-adapter-evm";
175
-
176
- export const SPEC_NAME = ${name};
177
- export const AGENT_MODEL = ${JSON.stringify(ir.agent.model)};
178
- export const AGENT_INSTRUCTIONS = ${instructions};
179
- export const IDEMPOTENCY_WINDOW_MS = ${ir.idempotencyWindowMs};
180
-
181
- export const CHAINS = [
182
- ${chains}
183
- ];
184
-
185
- export const WALLETS = [
186
- ${wallets}
187
- ];
188
-
189
- export const CONTRACTS = [
190
- ${contracts}
191
- ];
192
-
193
- export const TRANSACTION_POLICY = ${policyLiteral};
194
-
195
- export const TRIGGERS = [
196
- ${triggers}
197
- ];
198
-
199
- /**
200
- * Bootstrap the adapter map keyed by chainId. The runtime calls this
201
- * once on daemon start; downstream tools (tool-evm, tool-evm-tx,
202
- * permission-tokengated) resolve adapters through the returned map.
203
- */
204
- export function buildAdapters(): Map<string, ReturnType<typeof createEvmAdapter>> {
205
- const m = new Map<string, ReturnType<typeof createEvmAdapter>>();
206
- for (const c of CHAINS) {
207
- m.set(c.chainId, createEvmAdapter(c));
208
- }
209
- return m;
210
- }
211
-
212
- /**
213
- * Defense-in-depth: re-classify the decoded event payload (the chain
214
- * adapter already classified the RPC envelope, but this catches any
215
- * decoded-event content that an attacker may have planted in event
216
- * indexed args).
217
- */
218
- export async function acceptOrRedact(payload: string): Promise<{ accept: boolean; text: string }> {
219
- const verdict = await classifyBoundary(payload, { origin: "chain" });
220
- if (verdict.action === "redact") {
221
- return { accept: false, text: verdict.redacted ?? "[chain payload redacted]" };
222
- }
223
- return { accept: true, text: payload };
224
- }
225
-
226
- // Slice-2 emits the adapter wiring + trigger metadata. The full event-
227
- // subscription loop (poll for new logs, dedupe, dispatch to the agent)
228
- // is wired in target-onchain v1 once the runtime's runChatLoop accepts
229
- // the structured payload directly. Until then, the emitted bundle is a
230
- // callable module that downstream daemons or tests import.
231
- `;
232
-
233
- return { files: [{ path: "agent.ts", content }] };
234
- }