@mindburn/helm-mastra 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @mindburn/helm-mastra
2
+
3
+ HELM governance adapter for [Mastra](https://mastra.ai) agent framework with native Daytona sandbox integration.
4
+
5
+ ## What it does
6
+
7
+ Wraps Mastra's Daytona sandbox with HELM governance:
8
+
9
+ 1. Every sandbox `exec` is evaluated against HELM policy first
10
+ 2. Denied commands never reach the sandbox
11
+ 3. Receipts form a deterministic proof chain
12
+
13
+ ## Quick start
14
+
15
+ ```typescript
16
+ import { HelmMastraSandbox } from "@mindburn/helm-mastra";
17
+
18
+ const sandbox = new HelmMastraSandbox({
19
+ baseUrl: "http://localhost:8080",
20
+ daytonaApiKey: process.env.DAYTONA_API_KEY,
21
+ });
22
+
23
+ const result = await sandbox.exec({
24
+ command: ["python3", "-c", 'print("governed exec")'],
25
+ });
26
+
27
+ console.log(result.stdout); // "governed exec\n"
28
+ console.log(result.receipt.requestHash); // "sha256:..."
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ | Option | Default | Description |
34
+ | ----------------- | ------------------------ | ------------------------ |
35
+ | `baseUrl` | required | HELM kernel URL |
36
+ | `daytonaApiKey` | required | Daytona API key |
37
+ | `daytonaUrl` | `https://api.daytona.io` | Daytona API URL |
38
+ | `failClosed` | `true` | Deny on HELM errors |
39
+ | `defaultLanguage` | `python3` | Default exec language |
40
+ | `execTimeout` | `30000` | Per-command timeout (ms) |
41
+
42
+ ## License
43
+
44
+ Apache-2.0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @mindburn/helm-mastra
3
+ *
4
+ * HELM governance adapter for Mastra agent framework.
5
+ * Wraps Mastra's Daytona sandbox integration with HELM governance,
6
+ * providing policy enforcement, receipt chains, and sandbox preflight.
7
+ *
8
+ * Architecture:
9
+ * Mastra tool → HelmMastraSandbox → HELM governance → Daytona sandbox
10
+ *
11
+ * Usage:
12
+ * ```ts
13
+ * import { HelmMastraSandbox } from '@mindburn/helm-mastra';
14
+ *
15
+ * const sandbox = new HelmMastraSandbox({
16
+ * helmUrl: 'http://localhost:8080',
17
+ * daytonaApiKey: 'dtn-xxx',
18
+ * });
19
+ *
20
+ * // Use within Mastra tools
21
+ * const result = await sandbox.exec({
22
+ * command: ['python3', '-c', 'print("hello")'],
23
+ * });
24
+ * ```
25
+ */
26
+ import type { HelmClientConfig, Receipt } from '@mindburn/helm';
27
+ /** Configuration for the HELM Mastra sandbox adapter. */
28
+ export interface HelmMastraSandboxConfig extends HelmClientConfig {
29
+ /** Daytona API key for sandbox operations. */
30
+ daytonaApiKey: string;
31
+ /** Daytona API URL. Default: https://api.daytona.io */
32
+ daytonaUrl?: string;
33
+ /** If true, deny execution on governance errors (fail-closed). Default: true. */
34
+ failClosed?: boolean;
35
+ /** Default language for code execution. Default: 'python3'. */
36
+ defaultLanguage?: string;
37
+ /** Per-command timeout in milliseconds. Default: 30000. */
38
+ execTimeout?: number;
39
+ /** If true, collect receipts. Default: true. */
40
+ collectReceipts?: boolean;
41
+ /** Callback invoked after each successful execution with its receipt. */
42
+ onReceipt?: (receipt: SandboxReceipt) => void;
43
+ /** Callback invoked when execution is denied. */
44
+ onDeny?: (denial: SandboxDenial) => void;
45
+ }
46
+ /** A receipt for a governed sandbox execution. */
47
+ export interface SandboxReceipt {
48
+ command: string[];
49
+ receipt: Receipt;
50
+ requestHash: string;
51
+ outputHash: string;
52
+ exitCode: number;
53
+ durationMs: number;
54
+ }
55
+ /** Details of a denied sandbox execution. */
56
+ export interface SandboxDenial {
57
+ command: string[];
58
+ reasonCode: string;
59
+ message: string;
60
+ }
61
+ /** Sandbox execution request. */
62
+ export interface ExecRequest {
63
+ command: string[];
64
+ env?: Record<string, string>;
65
+ workDir?: string;
66
+ timeout?: number;
67
+ }
68
+ /** Sandbox execution result. */
69
+ export interface ExecResult {
70
+ exitCode: number;
71
+ stdout: string;
72
+ stderr: string;
73
+ durationMs: number;
74
+ timedOut: boolean;
75
+ receipt: SandboxReceipt;
76
+ }
77
+ /** File operations. */
78
+ export interface SandboxFile {
79
+ path: string;
80
+ content: string;
81
+ }
82
+ export declare class HelmSandboxDenyError extends Error {
83
+ readonly denial: SandboxDenial;
84
+ constructor(denial: SandboxDenial);
85
+ }
86
+ /**
87
+ * HelmMastraSandbox wraps Mastra's Daytona sandbox with HELM governance.
88
+ *
89
+ * Each sandbox operation goes through HELM's governance plane:
90
+ * 1. HELM evaluates the tool call intent against policy
91
+ * 2. If approved, the command is forwarded to Daytona
92
+ * 3. A receipt is produced for the execution
93
+ */
94
+ export declare class HelmMastraSandbox {
95
+ private readonly helmClient;
96
+ private readonly daytonaUrl;
97
+ private readonly daytonaApiKey;
98
+ private readonly failClosed;
99
+ private readonly defaultLanguage;
100
+ private readonly execTimeout;
101
+ private readonly collectReceipts;
102
+ private readonly onReceipt?;
103
+ private readonly onDeny?;
104
+ private readonly receipts;
105
+ private sandboxId;
106
+ private lastLamportClock;
107
+ constructor(config: HelmMastraSandboxConfig);
108
+ /**
109
+ * Initialize the sandbox. Must be called before exec.
110
+ */
111
+ init(): Promise<void>;
112
+ /**
113
+ * Execute a command in the sandbox with HELM governance.
114
+ */
115
+ exec(req: ExecRequest): Promise<ExecResult>;
116
+ /**
117
+ * Write a file to the sandbox.
118
+ */
119
+ writeFile(path: string, content: string): Promise<void>;
120
+ /**
121
+ * Read a file from the sandbox.
122
+ */
123
+ readFile(path: string): Promise<string>;
124
+ /**
125
+ * Destroy the sandbox.
126
+ */
127
+ destroy(): Promise<void>;
128
+ /**
129
+ * Get collected receipts.
130
+ */
131
+ getReceipts(): ReadonlyArray<SandboxReceipt>;
132
+ /**
133
+ * Clear collected receipts.
134
+ */
135
+ clearReceipts(): void;
136
+ private static resolveReceiptStatus;
137
+ private nextLamportClock;
138
+ }
139
+ export default HelmMastraSandbox;
package/dist/index.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @mindburn/helm-mastra
3
+ *
4
+ * HELM governance adapter for Mastra agent framework.
5
+ * Wraps Mastra's Daytona sandbox integration with HELM governance,
6
+ * providing policy enforcement, receipt chains, and sandbox preflight.
7
+ *
8
+ * Architecture:
9
+ * Mastra tool → HelmMastraSandbox → HELM governance → Daytona sandbox
10
+ *
11
+ * Usage:
12
+ * ```ts
13
+ * import { HelmMastraSandbox } from '@mindburn/helm-mastra';
14
+ *
15
+ * const sandbox = new HelmMastraSandbox({
16
+ * helmUrl: 'http://localhost:8080',
17
+ * daytonaApiKey: 'dtn-xxx',
18
+ * });
19
+ *
20
+ * // Use within Mastra tools
21
+ * const result = await sandbox.exec({
22
+ * command: ['python3', '-c', 'print("hello")'],
23
+ * });
24
+ * ```
25
+ */
26
+ import { HelmClient, HelmApiError } from '@mindburn/helm';
27
+ import { createHash } from 'node:crypto';
28
+ // ── Errors ──────────────────────────────────────────────────────
29
+ export class HelmSandboxDenyError extends Error {
30
+ denial;
31
+ constructor(denial) {
32
+ super(`HELM denied sandbox exec: ${denial.reasonCode} — ${denial.message}`);
33
+ this.name = 'HelmSandboxDenyError';
34
+ this.denial = denial;
35
+ }
36
+ }
37
+ // ── Sandbox ─────────────────────────────────────────────────────
38
+ /**
39
+ * HelmMastraSandbox wraps Mastra's Daytona sandbox with HELM governance.
40
+ *
41
+ * Each sandbox operation goes through HELM's governance plane:
42
+ * 1. HELM evaluates the tool call intent against policy
43
+ * 2. If approved, the command is forwarded to Daytona
44
+ * 3. A receipt is produced for the execution
45
+ */
46
+ export class HelmMastraSandbox {
47
+ helmClient;
48
+ daytonaUrl;
49
+ daytonaApiKey;
50
+ failClosed;
51
+ defaultLanguage;
52
+ execTimeout;
53
+ collectReceipts;
54
+ onReceipt;
55
+ onDeny;
56
+ receipts = [];
57
+ sandboxId = null;
58
+ lastLamportClock = -1;
59
+ constructor(config) {
60
+ this.helmClient = new HelmClient(config);
61
+ this.daytonaUrl = config.daytonaUrl ?? 'https://api.daytona.io';
62
+ this.daytonaApiKey = config.daytonaApiKey;
63
+ this.failClosed = config.failClosed ?? true;
64
+ this.defaultLanguage = config.defaultLanguage ?? 'python3';
65
+ this.execTimeout = config.execTimeout ?? 30_000;
66
+ this.collectReceipts = config.collectReceipts ?? true;
67
+ this.onReceipt = config.onReceipt;
68
+ this.onDeny = config.onDeny;
69
+ }
70
+ /**
71
+ * Initialize the sandbox. Must be called before exec.
72
+ */
73
+ async init() {
74
+ // Create a Daytona sandbox.
75
+ const resp = await fetch(`${this.daytonaUrl}/sandbox`, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ Authorization: `Bearer ${this.daytonaApiKey}`,
80
+ },
81
+ body: JSON.stringify({
82
+ language: this.defaultLanguage,
83
+ timeout: Math.floor(this.execTimeout / 1000),
84
+ }),
85
+ });
86
+ if (!resp.ok) {
87
+ throw new Error(`Daytona sandbox creation failed: ${resp.status}`);
88
+ }
89
+ const data = (await resp.json());
90
+ this.sandboxId = data.sandboxId;
91
+ }
92
+ /**
93
+ * Execute a command in the sandbox with HELM governance.
94
+ */
95
+ async exec(req) {
96
+ if (!this.sandboxId) {
97
+ await this.init();
98
+ }
99
+ const startMs = Date.now();
100
+ // Step 1: HELM governance evaluation.
101
+ // Use chatCompletionsWithReceipt to get kernel-issued governance metadata.
102
+ let governanceStatus = '';
103
+ let governanceReasonCode = '';
104
+ let kernelReceiptId = '';
105
+ let kernelDecisionId = '';
106
+ let kernelProofGraphNode = '';
107
+ let kernelLamportClock = 0;
108
+ let kernelSignature = '';
109
+ let kernelOutputHash = '';
110
+ let governanceEvaluated = false;
111
+ try {
112
+ const { response: governanceResp, governance } = await this.helmClient.chatCompletionsWithReceipt({
113
+ model: 'helm-governance',
114
+ messages: [
115
+ {
116
+ role: 'user',
117
+ content: JSON.stringify({
118
+ type: 'sandbox_exec_intent',
119
+ provider: 'daytona',
120
+ sandbox_id: this.sandboxId,
121
+ command: req.command,
122
+ env: req.env,
123
+ }),
124
+ },
125
+ ],
126
+ tools: [
127
+ {
128
+ type: 'function',
129
+ function: {
130
+ name: 'sandbox_exec',
131
+ description: 'Execute command in Daytona sandbox',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ command: { type: 'array', items: { type: 'string' } },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ ],
141
+ });
142
+ // Extract kernel governance metadata
143
+ governanceStatus = governance.status;
144
+ governanceReasonCode = governance.reasonCode;
145
+ kernelReceiptId = governance.receiptId;
146
+ kernelDecisionId = governance.decisionId;
147
+ kernelProofGraphNode = governance.proofGraphNode;
148
+ kernelLamportClock = governance.lamportClock;
149
+ kernelSignature = governance.signature;
150
+ kernelOutputHash = governance.outputHash;
151
+ const choice = governanceResp.choices?.[0];
152
+ const kernelDenied = governanceStatus === 'DENIED' || governanceStatus === 'PEP_VALIDATION_FAILED';
153
+ if (kernelDenied || (!choice || (choice.finish_reason === 'stop' && !choice.message?.tool_calls))) {
154
+ const denial = {
155
+ command: req.command,
156
+ reasonCode: governanceReasonCode || 'DENY_POLICY_VIOLATION',
157
+ message: choice?.message?.content ?? 'Sandbox exec denied by HELM governance',
158
+ };
159
+ this.onDeny?.(denial);
160
+ throw new HelmSandboxDenyError(denial);
161
+ }
162
+ governanceEvaluated = true;
163
+ }
164
+ catch (error) {
165
+ if (error instanceof HelmSandboxDenyError)
166
+ throw error;
167
+ if (error instanceof HelmApiError) {
168
+ const denial = {
169
+ command: req.command,
170
+ reasonCode: error.reasonCode,
171
+ message: error.message,
172
+ };
173
+ this.onDeny?.(denial);
174
+ if (this.failClosed)
175
+ throw new HelmSandboxDenyError(denial);
176
+ governanceStatus = 'PENDING';
177
+ governanceReasonCode = error.reasonCode;
178
+ }
179
+ if (this.failClosed)
180
+ throw error;
181
+ governanceStatus = governanceStatus || 'PENDING';
182
+ governanceReasonCode = governanceReasonCode || 'ERROR_INTERNAL';
183
+ }
184
+ // Step 2: Execute on Daytona.
185
+ const cmd = req.command.join(' ');
186
+ const execResp = await fetch(`${this.daytonaUrl}/sandbox/${this.sandboxId}/process/execute`, {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ Authorization: `Bearer ${this.daytonaApiKey}`,
191
+ },
192
+ body: JSON.stringify({
193
+ command: cmd,
194
+ env: req.env,
195
+ cwd: req.workDir,
196
+ timeout: req.timeout ? Math.floor(req.timeout / 1000) : Math.floor(this.execTimeout / 1000),
197
+ }),
198
+ });
199
+ if (!execResp.ok) {
200
+ throw new Error(`Daytona exec failed: ${execResp.status}`);
201
+ }
202
+ const result = (await execResp.json());
203
+ // Step 3: Build receipt from kernel-issued data (NOT fabricated).
204
+ const durationMs = Date.now() - startMs;
205
+ const requestHash = 'sha256:' + createHash('sha256').update(JSON.stringify(req)).digest('hex');
206
+ const outputHash = 'sha256:' + createHash('sha256').update(result.output || '').digest('hex');
207
+ const lamportClock = this.nextLamportClock(kernelLamportClock);
208
+ const receiptStatus = HelmMastraSandbox.resolveReceiptStatus(governanceStatus, governanceEvaluated);
209
+ const receiptToken = `${requestHash.slice(7, 19)}-${lamportClock}`;
210
+ const receipt = {
211
+ command: req.command,
212
+ receipt: {
213
+ receipt_id: kernelReceiptId || `mastra-${receiptToken}`,
214
+ decision_id: kernelDecisionId || `decision-${receiptToken}`,
215
+ effect_id: kernelProofGraphNode || `exec-${receiptToken}`,
216
+ status: receiptStatus,
217
+ reason_code: governanceReasonCode || (governanceEvaluated ? 'ALLOW' : 'ERROR_INTERNAL'),
218
+ output_hash: kernelOutputHash || outputHash,
219
+ blob_hash: '',
220
+ prev_hash: '',
221
+ lamport_clock: lamportClock,
222
+ signature: kernelSignature || '',
223
+ timestamp: new Date().toISOString(),
224
+ principal: governanceEvaluated ? 'helm-kernel' : 'helm-fail-open',
225
+ },
226
+ requestHash,
227
+ outputHash,
228
+ exitCode: result.exitCode,
229
+ durationMs,
230
+ };
231
+ if (this.collectReceipts) {
232
+ this.receipts.push(receipt);
233
+ }
234
+ this.onReceipt?.(receipt);
235
+ return {
236
+ exitCode: result.exitCode,
237
+ stdout: result.output,
238
+ stderr: result.errors,
239
+ durationMs: result.durationMs,
240
+ timedOut: result.timedOut,
241
+ receipt,
242
+ };
243
+ }
244
+ /**
245
+ * Write a file to the sandbox.
246
+ */
247
+ async writeFile(path, content) {
248
+ if (!this.sandboxId)
249
+ await this.init();
250
+ const resp = await fetch(`${this.daytonaUrl}/sandbox/${this.sandboxId}/filesystem?path=${encodeURIComponent(path)}`, {
251
+ method: 'PUT',
252
+ headers: {
253
+ 'Content-Type': 'application/octet-stream',
254
+ Authorization: `Bearer ${this.daytonaApiKey}`,
255
+ },
256
+ body: content,
257
+ });
258
+ if (!resp.ok) {
259
+ throw new Error(`Daytona write file failed: ${resp.status}`);
260
+ }
261
+ }
262
+ /**
263
+ * Read a file from the sandbox.
264
+ */
265
+ async readFile(path) {
266
+ if (!this.sandboxId)
267
+ await this.init();
268
+ const resp = await fetch(`${this.daytonaUrl}/sandbox/${this.sandboxId}/filesystem?path=${encodeURIComponent(path)}`, {
269
+ method: 'GET',
270
+ headers: {
271
+ Authorization: `Bearer ${this.daytonaApiKey}`,
272
+ },
273
+ });
274
+ if (!resp.ok) {
275
+ throw new Error(`Daytona read file failed: ${resp.status}`);
276
+ }
277
+ return resp.text();
278
+ }
279
+ /**
280
+ * Destroy the sandbox.
281
+ */
282
+ async destroy() {
283
+ if (!this.sandboxId)
284
+ return;
285
+ await fetch(`${this.daytonaUrl}/sandbox/${this.sandboxId}`, {
286
+ method: 'DELETE',
287
+ headers: { Authorization: `Bearer ${this.daytonaApiKey}` },
288
+ });
289
+ this.sandboxId = null;
290
+ }
291
+ /**
292
+ * Get collected receipts.
293
+ */
294
+ getReceipts() {
295
+ return this.receipts;
296
+ }
297
+ /**
298
+ * Clear collected receipts.
299
+ */
300
+ clearReceipts() {
301
+ this.receipts.length = 0;
302
+ }
303
+ static resolveReceiptStatus(governanceStatus, governanceEvaluated) {
304
+ if (!governanceEvaluated) {
305
+ return 'PENDING';
306
+ }
307
+ if (governanceStatus === 'DENIED' || governanceStatus === 'PEP_VALIDATION_FAILED') {
308
+ return 'DENIED';
309
+ }
310
+ if (governanceStatus === 'PENDING') {
311
+ return 'PENDING';
312
+ }
313
+ return 'APPROVED';
314
+ }
315
+ nextLamportClock(kernelLamportClock) {
316
+ const nextLamportClock = kernelLamportClock > this.lastLamportClock
317
+ ? kernelLamportClock
318
+ : this.lastLamportClock + 1;
319
+ this.lastLamportClock = nextLamportClock;
320
+ return nextLamportClock;
321
+ }
322
+ }
323
+ export default HelmMastraSandbox;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,379 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { HelmMastraSandbox, HelmSandboxDenyError } from './index.js';
3
+ // ── Helpers ─────────────────────────────────────────────────────
4
+ function jsonResponse(body, status = 200) {
5
+ return new Response(JSON.stringify(body), {
6
+ status,
7
+ headers: { 'Content-Type': 'application/json' },
8
+ });
9
+ }
10
+ function createSandboxResponse(sandboxId = 'sbx-1234') {
11
+ return jsonResponse({ sandboxId });
12
+ }
13
+ function execResponse(output = 'hello\n', exitCode = 0) {
14
+ return jsonResponse({
15
+ output,
16
+ errors: '',
17
+ exitCode,
18
+ timedOut: false,
19
+ durationMs: 42,
20
+ });
21
+ }
22
+ function governanceApproveResponse() {
23
+ return jsonResponse({
24
+ id: `chatcmpl-${Date.now()}`,
25
+ object: 'chat.completion',
26
+ created: Date.now(),
27
+ model: 'helm-governance',
28
+ choices: [
29
+ {
30
+ index: 0,
31
+ message: {
32
+ role: 'assistant',
33
+ content: null,
34
+ tool_calls: [
35
+ {
36
+ id: `call_${Date.now()}`,
37
+ type: 'function',
38
+ function: { name: 'sandbox_exec', arguments: '{}' },
39
+ },
40
+ ],
41
+ },
42
+ finish_reason: 'tool_calls',
43
+ },
44
+ ],
45
+ });
46
+ }
47
+ function governanceDenyResponse(reason = 'Sandbox exec denied by HELM governance') {
48
+ return jsonResponse({
49
+ id: `chatcmpl-${Date.now()}`,
50
+ object: 'chat.completion',
51
+ created: Date.now(),
52
+ model: 'helm-governance',
53
+ choices: [
54
+ {
55
+ index: 0,
56
+ message: { role: 'assistant', content: reason },
57
+ finish_reason: 'stop',
58
+ },
59
+ ],
60
+ });
61
+ }
62
+ function helmApiErrorResponse(status, reasonCode = 'DENY_POLICY_VIOLATION', message = 'denied') {
63
+ return jsonResponse({
64
+ error: {
65
+ message,
66
+ type: 'permission_denied',
67
+ code: 'DENY',
68
+ reason_code: reasonCode,
69
+ },
70
+ }, status);
71
+ }
72
+ // ── Tests ───────────────────────────────────────────────────────
73
+ describe('HelmMastraSandbox', () => {
74
+ let fetchSpy;
75
+ beforeEach(() => {
76
+ fetchSpy = vi.fn();
77
+ vi.stubGlobal('fetch', fetchSpy);
78
+ });
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+ function makeSandbox(overrides = {}) {
83
+ return new HelmMastraSandbox({
84
+ baseUrl: 'http://helm:8080',
85
+ daytonaApiKey: 'dtn-test-key',
86
+ daytonaUrl: 'http://daytona:3000',
87
+ ...overrides,
88
+ });
89
+ }
90
+ // ── Sandbox creation ───────────────────────────────────
91
+ describe('init', () => {
92
+ it('creates a Daytona sandbox with correct auth', async () => {
93
+ fetchSpy.mockResolvedValue(createSandboxResponse());
94
+ const sandbox = makeSandbox();
95
+ await sandbox.init();
96
+ expect(fetchSpy).toHaveBeenCalledWith('http://daytona:3000/sandbox', expect.objectContaining({
97
+ method: 'POST',
98
+ headers: expect.objectContaining({
99
+ Authorization: 'Bearer dtn-test-key',
100
+ }),
101
+ }));
102
+ });
103
+ it('throws on Daytona creation failure', async () => {
104
+ fetchSpy.mockResolvedValue(new Response('fail', { status: 500 }));
105
+ const sandbox = makeSandbox();
106
+ await expect(sandbox.init()).rejects.toThrow('Daytona sandbox creation failed: 500');
107
+ });
108
+ });
109
+ // ── Governance-approved execution ──────────────────────
110
+ describe('exec (approved)', () => {
111
+ it('executes command through HELM governance then Daytona', async () => {
112
+ // Call 1: Daytona create (auto-init)
113
+ // Call 2: HELM governance
114
+ // Call 3: Daytona exec
115
+ fetchSpy
116
+ .mockResolvedValueOnce(createSandboxResponse('sbx-1'))
117
+ .mockResolvedValueOnce(governanceApproveResponse())
118
+ .mockResolvedValueOnce(execResponse('world\n', 0));
119
+ const sandbox = makeSandbox();
120
+ const result = await sandbox.exec({ command: ['echo', 'world'] });
121
+ expect(result.stdout).toBe('world\n');
122
+ expect(result.exitCode).toBe(0);
123
+ expect(result.timedOut).toBe(false);
124
+ });
125
+ it('sends governance intent with sandbox provider metadata', async () => {
126
+ fetchSpy
127
+ .mockResolvedValueOnce(createSandboxResponse('sbx-meta'))
128
+ .mockResolvedValueOnce(governanceApproveResponse())
129
+ .mockResolvedValueOnce(execResponse());
130
+ const sandbox = makeSandbox();
131
+ await sandbox.exec({ command: ['ls', '-la'] });
132
+ // Call index 1 is HELM governance
133
+ const helmBody = JSON.parse(fetchSpy.mock.calls[1][1].body);
134
+ const intent = JSON.parse(helmBody.messages[0].content);
135
+ expect(intent.type).toBe('sandbox_exec_intent');
136
+ expect(intent.provider).toBe('daytona');
137
+ expect(intent.sandbox_id).toBe('sbx-meta');
138
+ expect(intent.command).toEqual(['ls', '-la']);
139
+ });
140
+ });
141
+ // ── Denial path ────────────────────────────────────────
142
+ describe('exec (denied)', () => {
143
+ it('throws HelmSandboxDenyError when governance denies', async () => {
144
+ fetchSpy
145
+ .mockResolvedValueOnce(createSandboxResponse())
146
+ .mockResolvedValueOnce(governanceDenyResponse('Not allowed'));
147
+ const sandbox = makeSandbox();
148
+ await expect(sandbox.exec({ command: ['rm', '-rf', '/'] })).rejects.toThrow(HelmSandboxDenyError);
149
+ });
150
+ it('denial error contains command and reason', async () => {
151
+ fetchSpy
152
+ .mockResolvedValueOnce(createSandboxResponse())
153
+ .mockResolvedValueOnce(governanceDenyResponse('Blocked by policy'));
154
+ const sandbox = makeSandbox();
155
+ try {
156
+ await sandbox.exec({ command: ['dangerous'] });
157
+ expect.unreachable('should have thrown');
158
+ }
159
+ catch (e) {
160
+ const err = e;
161
+ expect(err.denial.command).toEqual(['dangerous']);
162
+ expect(err.denial.reasonCode).toBe('DENY_POLICY_VIOLATION');
163
+ expect(err.name).toBe('HelmSandboxDenyError');
164
+ }
165
+ });
166
+ it('invokes onDeny callback', async () => {
167
+ fetchSpy
168
+ .mockResolvedValueOnce(createSandboxResponse())
169
+ .mockResolvedValueOnce(governanceDenyResponse());
170
+ const denials = [];
171
+ const sandbox = makeSandbox({ onDeny: (d) => denials.push(d) });
172
+ try {
173
+ await sandbox.exec({ command: ['test'] });
174
+ }
175
+ catch { /* expected */ }
176
+ expect(denials).toHaveLength(1);
177
+ });
178
+ it('never reaches Daytona on denial', async () => {
179
+ fetchSpy
180
+ .mockResolvedValueOnce(createSandboxResponse())
181
+ .mockResolvedValueOnce(governanceDenyResponse());
182
+ const sandbox = makeSandbox();
183
+ try {
184
+ await sandbox.exec({ command: ['test'] });
185
+ }
186
+ catch { /* expected */ }
187
+ // Only 2 calls: Daytona create + HELM governance. No Daytona exec.
188
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
189
+ });
190
+ });
191
+ // ── Fail-closed behavior ───────────────────────────────
192
+ describe('fail-closed', () => {
193
+ it('throws on HELM API 500 when failClosed=true (default)', async () => {
194
+ fetchSpy
195
+ .mockResolvedValueOnce(createSandboxResponse())
196
+ .mockResolvedValueOnce(helmApiErrorResponse(500));
197
+ const sandbox = makeSandbox();
198
+ await expect(sandbox.exec({ command: ['echo'] })).rejects.toThrow();
199
+ });
200
+ it('throws on HELM network error when failClosed=true', async () => {
201
+ fetchSpy
202
+ .mockResolvedValueOnce(createSandboxResponse())
203
+ .mockRejectedValueOnce(new Error('ECONNREFUSED'));
204
+ const sandbox = makeSandbox();
205
+ await expect(sandbox.exec({ command: ['echo'] })).rejects.toThrow('ECONNREFUSED');
206
+ });
207
+ it('marks fail-open execution as pending instead of approved', async () => {
208
+ fetchSpy
209
+ .mockResolvedValueOnce(createSandboxResponse())
210
+ .mockResolvedValueOnce(helmApiErrorResponse(500))
211
+ .mockResolvedValueOnce(execResponse('ok', 0));
212
+ const sandbox = makeSandbox({ failClosed: false });
213
+ const result = await sandbox.exec({ command: ['echo', 'ok'] });
214
+ expect(result.stdout).toBe('ok');
215
+ expect(result.receipt.receipt.status).toBe('PENDING');
216
+ expect(result.receipt.receipt.principal).toBe('helm-fail-open');
217
+ });
218
+ });
219
+ // ── Receipt collection ─────────────────────────────────
220
+ describe('receipt collection', () => {
221
+ it('collects receipt with deterministic request hash', async () => {
222
+ fetchSpy
223
+ .mockResolvedValueOnce(createSandboxResponse())
224
+ .mockResolvedValueOnce(governanceApproveResponse())
225
+ .mockResolvedValueOnce(execResponse('out', 0));
226
+ const sandbox = makeSandbox();
227
+ const result = await sandbox.exec({ command: ['echo', 'out'] });
228
+ expect(result.receipt).toBeDefined();
229
+ expect(result.receipt.requestHash).toMatch(/^sha256:[a-f0-9]{64}$/);
230
+ expect(result.receipt.outputHash).toMatch(/^sha256:[a-f0-9]{64}$/);
231
+ expect(result.receipt.receipt.status).toBe('APPROVED');
232
+ });
233
+ it('same input produces same request hash', async () => {
234
+ fetchSpy
235
+ .mockResolvedValueOnce(createSandboxResponse())
236
+ .mockResolvedValueOnce(governanceApproveResponse())
237
+ .mockResolvedValueOnce(execResponse('a'))
238
+ .mockResolvedValueOnce(governanceApproveResponse())
239
+ .mockResolvedValueOnce(execResponse('a'));
240
+ const sandbox = makeSandbox();
241
+ const r1 = await sandbox.exec({ command: ['echo', 'deterministic'] });
242
+ const r2 = await sandbox.exec({ command: ['echo', 'deterministic'] });
243
+ expect(r1.receipt.requestHash).toBe(r2.receipt.requestHash);
244
+ });
245
+ it('different output produces different output hash', async () => {
246
+ fetchSpy
247
+ .mockResolvedValueOnce(createSandboxResponse())
248
+ .mockResolvedValueOnce(governanceApproveResponse())
249
+ .mockResolvedValueOnce(execResponse('output_A'))
250
+ .mockResolvedValueOnce(governanceApproveResponse())
251
+ .mockResolvedValueOnce(execResponse('output_B'));
252
+ const sandbox = makeSandbox();
253
+ const r1 = await sandbox.exec({ command: ['echo', 'A'] });
254
+ const r2 = await sandbox.exec({ command: ['echo', 'B'] });
255
+ expect(r1.receipt.outputHash).not.toBe(r2.receipt.outputHash);
256
+ });
257
+ it('invokes onReceipt callback', async () => {
258
+ fetchSpy
259
+ .mockResolvedValueOnce(createSandboxResponse())
260
+ .mockResolvedValueOnce(governanceApproveResponse())
261
+ .mockResolvedValueOnce(execResponse());
262
+ const receipts = [];
263
+ const sandbox = makeSandbox({ onReceipt: (r) => receipts.push(r) });
264
+ await sandbox.exec({ command: ['echo'] });
265
+ expect(receipts).toHaveLength(1);
266
+ });
267
+ it('getReceipts returns accumulated receipts', async () => {
268
+ fetchSpy
269
+ .mockResolvedValueOnce(createSandboxResponse())
270
+ .mockResolvedValueOnce(governanceApproveResponse())
271
+ .mockResolvedValueOnce(execResponse())
272
+ .mockResolvedValueOnce(governanceApproveResponse())
273
+ .mockResolvedValueOnce(execResponse());
274
+ const sandbox = makeSandbox();
275
+ await sandbox.exec({ command: ['cmd1'] });
276
+ await sandbox.exec({ command: ['cmd2'] });
277
+ expect(sandbox.getReceipts()).toHaveLength(2);
278
+ });
279
+ it('clearReceipts empties the collection', async () => {
280
+ fetchSpy
281
+ .mockResolvedValueOnce(createSandboxResponse())
282
+ .mockResolvedValueOnce(governanceApproveResponse())
283
+ .mockResolvedValueOnce(execResponse());
284
+ const sandbox = makeSandbox();
285
+ await sandbox.exec({ command: ['echo'] });
286
+ expect(sandbox.getReceipts()).toHaveLength(1);
287
+ sandbox.clearReceipts();
288
+ expect(sandbox.getReceipts()).toHaveLength(0);
289
+ });
290
+ it('receipt lamport clock increments', async () => {
291
+ fetchSpy
292
+ .mockResolvedValueOnce(createSandboxResponse())
293
+ .mockResolvedValueOnce(governanceApproveResponse())
294
+ .mockResolvedValueOnce(execResponse())
295
+ .mockResolvedValueOnce(governanceApproveResponse())
296
+ .mockResolvedValueOnce(execResponse());
297
+ const sandbox = makeSandbox();
298
+ await sandbox.exec({ command: ['echo', 'a'] });
299
+ await sandbox.exec({ command: ['echo', 'b'] });
300
+ const receipts = sandbox.getReceipts();
301
+ expect(receipts[0].receipt.lamport_clock).toBe(0);
302
+ expect(receipts[1].receipt.lamport_clock).toBe(1);
303
+ });
304
+ });
305
+ // ── Sandbox lifecycle ──────────────────────────────────
306
+ describe('lifecycle', () => {
307
+ it('auto-initializes on first exec', async () => {
308
+ fetchSpy
309
+ .mockResolvedValueOnce(createSandboxResponse('auto-init'))
310
+ .mockResolvedValueOnce(governanceApproveResponse())
311
+ .mockResolvedValueOnce(execResponse());
312
+ const sandbox = makeSandbox();
313
+ // Don't call init() explicitly
314
+ await sandbox.exec({ command: ['echo'] });
315
+ // First call should be Daytona create
316
+ expect(fetchSpy.mock.calls[0][0]).toBe('http://daytona:3000/sandbox');
317
+ });
318
+ it('destroy() sends DELETE and clears sandbox ID', async () => {
319
+ fetchSpy
320
+ .mockResolvedValueOnce(createSandboxResponse('destroy-me'))
321
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
322
+ const sandbox = makeSandbox();
323
+ await sandbox.init();
324
+ await sandbox.destroy();
325
+ const deleteCall = fetchSpy.mock.calls[1];
326
+ expect(deleteCall[0]).toBe('http://daytona:3000/sandbox/destroy-me');
327
+ expect(deleteCall[1].method).toBe('DELETE');
328
+ });
329
+ it('destroy() is idempotent when not initialized', async () => {
330
+ const sandbox = makeSandbox();
331
+ await sandbox.destroy(); // should not throw
332
+ expect(fetchSpy).not.toHaveBeenCalled();
333
+ });
334
+ });
335
+ // ── File operations ────────────────────────────────────
336
+ describe('file operations', () => {
337
+ it('writeFile sends PUT with content', async () => {
338
+ fetchSpy
339
+ .mockResolvedValueOnce(createSandboxResponse('fs-test'))
340
+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
341
+ const sandbox = makeSandbox();
342
+ await sandbox.init();
343
+ await sandbox.writeFile('/app/test.py', 'print("hello")');
344
+ const writeCall = fetchSpy.mock.calls[1];
345
+ expect(writeCall[0]).toContain('/sandbox/fs-test/filesystem');
346
+ expect(writeCall[0]).toContain('path=%2Fapp%2Ftest.py');
347
+ expect(writeCall[1].method).toBe('PUT');
348
+ expect(writeCall[1].body).toBe('print("hello")');
349
+ });
350
+ it('readFile sends GET and returns content', async () => {
351
+ fetchSpy
352
+ .mockResolvedValueOnce(createSandboxResponse('fs-read'))
353
+ .mockResolvedValueOnce(new Response('file content', { status: 200 }));
354
+ const sandbox = makeSandbox();
355
+ await sandbox.init();
356
+ const content = await sandbox.readFile('/app/out.txt');
357
+ expect(content).toBe('file content');
358
+ const readCall = fetchSpy.mock.calls[1];
359
+ expect(readCall[0]).toContain('path=%2Fapp%2Fout.txt');
360
+ expect(readCall[1].method).toBe('GET');
361
+ });
362
+ it('writeFile throws on failure', async () => {
363
+ fetchSpy
364
+ .mockResolvedValueOnce(createSandboxResponse())
365
+ .mockResolvedValueOnce(new Response('fail', { status: 500 }));
366
+ const sandbox = makeSandbox();
367
+ await sandbox.init();
368
+ await expect(sandbox.writeFile('/bad', 'data')).rejects.toThrow('Daytona write file failed: 500');
369
+ });
370
+ it('readFile throws on failure', async () => {
371
+ fetchSpy
372
+ .mockResolvedValueOnce(createSandboxResponse())
373
+ .mockResolvedValueOnce(new Response('fail', { status: 404 }));
374
+ const sandbox = makeSandbox();
375
+ await sandbox.init();
376
+ await expect(sandbox.readFile('/missing')).rejects.toThrow('Daytona read file failed: 404');
377
+ });
378
+ });
379
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@mindburn/helm-mastra",
3
+ "version": "1.0.2",
4
+ "description": "HELM governance adapter for Mastra agent framework — sandbox-aware",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "files": ["dist"],
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "prepublishOnly": "npm run build",
12
+ "test": "vitest",
13
+ "lint": "biome lint ."
14
+ },
15
+ "dependencies": {
16
+ "@mindburn/helm": "^1.0.1"
17
+ },
18
+ "peerDependencies": {
19
+ "@mastra/core": ">=0.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@biomejs/biome": "^2.4.1",
23
+ "typescript": "^5.4.0",
24
+ "vitest": "^4.0.18"
25
+ },
26
+ "overrides": {
27
+ "rollup": "^4.59.0"
28
+ },
29
+ "license": "Apache-2.0",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/Mindburn-Labs/helm-oss",
33
+ "directory": "sdk/ts/mastra"
34
+ },
35
+ "keywords": ["helm", "mastra", "daytona", "governance", "sandbox"]
36
+ }