pi-enclave 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,1136 @@
1
+ // src/index.ts
2
+ import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
3
+
4
+ // src/config.ts
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
6
+ import { dirname, join, resolve } from "path";
7
+ import { fileURLToPath } from "url";
8
+ import { parse as parseTOML } from "smol-toml";
9
+ import * as v from "valibot";
10
+ var __dirname = dirname(fileURLToPath(import.meta.url));
11
+ function templatePath(...segments) {
12
+ return join(__dirname, "..", "templates", ...segments);
13
+ }
14
+ var SecretDef = v.union([
15
+ // Disable a secret inherited from a parent config
16
+ v.literal(false),
17
+ // Command source: run a host command, use stdout as value
18
+ v.object({
19
+ command: v.string(),
20
+ hosts: v.array(v.string())
21
+ }),
22
+ // Env source: read from a host environment variable
23
+ v.object({
24
+ env: v.string(),
25
+ hosts: v.array(v.string())
26
+ })
27
+ ]);
28
+ var EnvDef = v.union([
29
+ // Static value
30
+ v.string(),
31
+ // From a host command
32
+ v.object({ command: v.string() }),
33
+ // From a host environment variable
34
+ v.object({ env: v.string() })
35
+ ]);
36
+ var UnmatchedPolicy = v.picklist(["prompt", "deny", "allow"]);
37
+ var GraphQLPolicy = v.object({
38
+ endpoint: v.string(),
39
+ allow: v.object({
40
+ query: v.optional(v.array(v.string()), []),
41
+ mutation: v.optional(v.array(v.string()), [])
42
+ }),
43
+ unmatched: v.optional(UnmatchedPolicy)
44
+ });
45
+ var HostAllow = v.record(v.string(), v.array(v.string()));
46
+ var HostDef = v.object({
47
+ /** Which secret to inject for requests to this host (references a key in [secrets]) */
48
+ secret: v.optional(v.string()),
49
+ /** Allowed HTTP method + path patterns */
50
+ allow: v.optional(HostAllow, {}),
51
+ /** Paths that are always denied (overrides allow) */
52
+ deny: v.optional(v.array(v.string()), []),
53
+ /** What happens for requests that don't match allow or deny */
54
+ unmatched: v.optional(UnmatchedPolicy, "allow"),
55
+ /** GraphQL-specific policy for a specific endpoint */
56
+ graphql: v.optional(GraphQLPolicy)
57
+ });
58
+ var MountDef = v.object({
59
+ path: v.string(),
60
+ readonly: v.optional(v.boolean(), false)
61
+ });
62
+ var GitCredentialDef = v.object({
63
+ host: v.string(),
64
+ username: v.string(),
65
+ secret: v.string()
66
+ });
67
+ var EnclaveFileConfig = v.object({
68
+ root: v.optional(v.boolean(), false),
69
+ enabled: v.optional(v.boolean()),
70
+ packages: v.optional(v.array(v.string())),
71
+ mounts: v.optional(v.array(MountDef), []),
72
+ env: v.optional(v.record(v.string(), EnvDef), {}),
73
+ secrets: v.optional(v.record(v.string(), SecretDef), {}),
74
+ "git-credentials": v.optional(v.array(GitCredentialDef), []),
75
+ hosts: v.optional(v.record(v.string(), HostDef), {}),
76
+ setup: v.optional(v.string())
77
+ });
78
+ var DEFAULT_PACKAGES = ["git", "curl", "jq"];
79
+ function readTomlFile(path2) {
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(path2, "utf-8");
83
+ } catch (err) {
84
+ if (err.code === "ENOENT") return void 0;
85
+ throw new Error(`pi-enclave: failed to read config at ${path2}: ${err.message}`);
86
+ }
87
+ let parsed;
88
+ try {
89
+ parsed = parseTOML(raw);
90
+ } catch (err) {
91
+ throw new Error(`pi-enclave: invalid TOML at ${path2}: ${err.message}`);
92
+ }
93
+ try {
94
+ return v.parse(EnclaveFileConfig, parsed);
95
+ } catch (err) {
96
+ throw new Error(`pi-enclave: invalid config at ${path2}: ${err.message}`);
97
+ }
98
+ }
99
+ function collectConfigFiles(cwd) {
100
+ const layers = [];
101
+ const globalPath = globalConfigPath();
102
+ const globalConfig = readTomlFile(globalPath);
103
+ if (globalConfig) {
104
+ layers.push({ path: globalPath, config: globalConfig });
105
+ }
106
+ const dropInDir = globalDropInDir();
107
+ if (existsSync(dropInDir)) {
108
+ const files = readdirSync(dropInDir).filter((f) => f.endsWith(".toml")).sort();
109
+ for (const file of files) {
110
+ const filePath = join(dropInDir, file);
111
+ const config = readTomlFile(filePath);
112
+ if (config) {
113
+ layers.push({ path: filePath, config });
114
+ }
115
+ }
116
+ }
117
+ const ancestors = [];
118
+ let dir = resolve(cwd);
119
+ const seen = /* @__PURE__ */ new Set();
120
+ while (!seen.has(dir)) {
121
+ seen.add(dir);
122
+ const configPath = join(dir, ".pi", "enclave.toml");
123
+ const config = readTomlFile(configPath);
124
+ if (config) {
125
+ ancestors.push({ path: configPath, config });
126
+ if (config.root) break;
127
+ }
128
+ const parent = dirname(dir);
129
+ if (parent === dir) break;
130
+ dir = parent;
131
+ }
132
+ ancestors.reverse();
133
+ layers.push(...ancestors);
134
+ return layers;
135
+ }
136
+ function mergeConfigs(layers) {
137
+ const merged = {
138
+ root: false,
139
+ enabled: void 0,
140
+ packages: [],
141
+ mounts: [],
142
+ env: {},
143
+ secrets: {},
144
+ "git-credentials": [],
145
+ hosts: {}
146
+ };
147
+ const mountsByPath = /* @__PURE__ */ new Map();
148
+ const gitCredsByHost = /* @__PURE__ */ new Map();
149
+ const setupScripts = [];
150
+ for (const layer of layers) {
151
+ if (layer.enabled !== void 0) {
152
+ merged.enabled = layer.enabled;
153
+ }
154
+ if (layer.packages) {
155
+ for (const pkg of layer.packages) {
156
+ if (!merged.packages.includes(pkg)) {
157
+ merged.packages.push(pkg);
158
+ }
159
+ }
160
+ }
161
+ if (layer.mounts) {
162
+ for (const mount of layer.mounts) {
163
+ mountsByPath.set(mount.path, mount);
164
+ }
165
+ }
166
+ if (layer.env) {
167
+ for (const [key, val] of Object.entries(layer.env)) {
168
+ merged.env[key] = val;
169
+ }
170
+ }
171
+ if (layer.secrets) {
172
+ for (const [key, val] of Object.entries(layer.secrets)) {
173
+ merged.secrets[key] = val;
174
+ }
175
+ }
176
+ if (layer["git-credentials"]) {
177
+ for (const cred of layer["git-credentials"]) {
178
+ gitCredsByHost.set(cred.host, cred);
179
+ }
180
+ }
181
+ if (layer.hosts) {
182
+ for (const [key, val] of Object.entries(layer.hosts)) {
183
+ merged.hosts[key] = val;
184
+ }
185
+ }
186
+ if (layer.setup) {
187
+ setupScripts.push(layer.setup);
188
+ }
189
+ }
190
+ merged.mounts = [...mountsByPath.values()];
191
+ merged["git-credentials"] = [...gitCredsByHost.values()];
192
+ if (setupScripts.length > 0) {
193
+ merged.setup = setupScripts.join("\n");
194
+ }
195
+ return merged;
196
+ }
197
+ function resolveHostPolicies(config) {
198
+ const result = /* @__PURE__ */ new Map();
199
+ for (const [hostname, hostDef] of Object.entries(config.hosts ?? {})) {
200
+ const allows = /* @__PURE__ */ new Map();
201
+ for (const [method, patterns] of Object.entries(hostDef.allow ?? {})) {
202
+ allows.set(method.toUpperCase(), patterns);
203
+ }
204
+ const hostUnmatched = hostDef.unmatched ?? "allow";
205
+ let graphql;
206
+ if (hostDef.graphql) {
207
+ graphql = {
208
+ endpoint: hostDef.graphql.endpoint,
209
+ allow: {
210
+ query: hostDef.graphql.allow.query ?? [],
211
+ mutation: hostDef.graphql.allow.mutation ?? []
212
+ },
213
+ // GraphQL inherits host unmatched if not set
214
+ unmatched: hostDef.graphql.unmatched ?? hostUnmatched
215
+ };
216
+ }
217
+ result.set(hostname, {
218
+ hostname,
219
+ allows,
220
+ deny: hostDef.deny ?? [],
221
+ unmatched: hostUnmatched,
222
+ graphql
223
+ });
224
+ }
225
+ return result;
226
+ }
227
+ function loadConfig(cwd) {
228
+ const layers = collectConfigFiles(cwd);
229
+ const merged = mergeConfigs(layers.map((l) => l.config));
230
+ const policies = resolveHostPolicies(merged);
231
+ const projectPath = join(cwd, ".pi", "enclave.toml");
232
+ const hasProjectConfig = existsSync(projectPath);
233
+ if (merged.mounts) {
234
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
235
+ merged.mounts = merged.mounts.map((m) => ({
236
+ ...m,
237
+ path: m.path.startsWith("~/") ? join(home, m.path.slice(2)) : m.path === "~" ? home : m.path
238
+ }));
239
+ }
240
+ return { merged, policies, hasProjectConfig };
241
+ }
242
+ function globalConfigPath() {
243
+ return join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".pi", "agent", "extensions", "pi-enclave.toml");
244
+ }
245
+ function globalDropInDir() {
246
+ return join(process.env.HOME ?? process.env.USERPROFILE ?? "~", ".pi", "agent", "extensions", "pi-enclave.d");
247
+ }
248
+ function projectConfigPath(cwd) {
249
+ return join(cwd, ".pi", "enclave.toml");
250
+ }
251
+ function tildify(p) {
252
+ const home = process.env.HOME ?? process.env.USERPROFILE;
253
+ if (!home) return p;
254
+ if (p === home) return "~";
255
+ if (p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`;
256
+ return p;
257
+ }
258
+ function ensureGlobalConfig() {
259
+ const created = [];
260
+ const configPath = globalConfigPath();
261
+ if (!existsSync(configPath)) {
262
+ mkdirSync(dirname(configPath), { recursive: true });
263
+ cpSync(templatePath("pi-enclave.toml"), configPath);
264
+ created.push(configPath);
265
+ }
266
+ const dropInDir = globalDropInDir();
267
+ mkdirSync(dropInDir, { recursive: true });
268
+ const templateDropInDir = templatePath("pi-enclave.d");
269
+ for (const file of readdirSync(templateDropInDir).filter((f) => f.endsWith(".toml"))) {
270
+ const dest = join(dropInDir, file);
271
+ if (!existsSync(dest)) {
272
+ cpSync(join(templateDropInDir, file), dest);
273
+ created.push(dest);
274
+ }
275
+ }
276
+ return created;
277
+ }
278
+ function initProjectConfig(cwd) {
279
+ const configPath = projectConfigPath(cwd);
280
+ if (existsSync(configPath)) return false;
281
+ const dir = dirname(configPath);
282
+ mkdirSync(dir, { recursive: true });
283
+ cpSync(templatePath("project.toml"), configPath);
284
+ return true;
285
+ }
286
+ function addPackageToConfig(cwd, pkg, target) {
287
+ const configPath = target === "global" ? globalConfigPath() : projectConfigPath(cwd);
288
+ let existing = {};
289
+ try {
290
+ const content = readFileSync(configPath, "utf-8");
291
+ const parsed = parseTOML(content);
292
+ const result = v.safeParse(EnclaveFileConfig, parsed);
293
+ if (result.success) existing = result.output;
294
+ } catch {
295
+ }
296
+ const packages = existing.packages ?? [...DEFAULT_PACKAGES];
297
+ if (!packages.includes(pkg)) {
298
+ packages.push(pkg);
299
+ }
300
+ const dir = dirname(configPath);
301
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
302
+ const packagesLine = `packages = [${packages.map((p) => `"${p}"`).join(", ")}]`;
303
+ if (existsSync(configPath)) {
304
+ const raw = readFileSync(configPath, "utf-8");
305
+ if (raw.match(/^packages\s*=/m)) {
306
+ writeFileSync(configPath, raw.replace(/^packages\s*=.*$/m, packagesLine), "utf-8");
307
+ } else {
308
+ writeFileSync(configPath, `${packagesLine}
309
+ ${raw}`, "utf-8");
310
+ }
311
+ } else {
312
+ writeFileSync(configPath, `${packagesLine}
313
+ `, "utf-8");
314
+ }
315
+ }
316
+
317
+ // src/secrets.ts
318
+ import { execSync } from "child_process";
319
+ function commandExists(cmd) {
320
+ try {
321
+ execSync(`which ${cmd}`, { stdio: "ignore" });
322
+ return true;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+ function runCommand(command) {
328
+ try {
329
+ const result = execSync(command, {
330
+ encoding: "utf-8",
331
+ stdio: ["ignore", "pipe", "ignore"],
332
+ timeout: 1e4
333
+ });
334
+ const trimmed = result.trim();
335
+ return trimmed || void 0;
336
+ } catch {
337
+ return void 0;
338
+ }
339
+ }
340
+ function resolveSource(name, source) {
341
+ if (source === false) return void 0;
342
+ if ("command" in source) {
343
+ const binary = source.command.split(/\s+/)[0];
344
+ if (binary && !commandExists(binary)) return void 0;
345
+ const value = runCommand(source.command);
346
+ if (!value) return void 0;
347
+ return { name, value, hosts: source.hosts };
348
+ }
349
+ if ("env" in source) {
350
+ const value = process.env[source.env];
351
+ if (!value) return void 0;
352
+ return { name, value, hosts: source.hosts };
353
+ }
354
+ return void 0;
355
+ }
356
+ function resolveSecrets(configSecrets = {}) {
357
+ const resolved = [];
358
+ for (const [name, source] of Object.entries(configSecrets)) {
359
+ const secret = resolveSource(name, source);
360
+ if (secret) resolved.push(secret);
361
+ }
362
+ return resolved;
363
+ }
364
+ function resolveEnv(configEnv = {}) {
365
+ const resolved = {};
366
+ for (const [name, def] of Object.entries(configEnv)) {
367
+ if (typeof def === "string") {
368
+ resolved[name] = def;
369
+ continue;
370
+ }
371
+ if ("command" in def) {
372
+ const binary = def.command.split(/\s+/)[0];
373
+ if (binary && !commandExists(binary)) continue;
374
+ const value = runCommand(def.command);
375
+ if (value) resolved[name] = value;
376
+ continue;
377
+ }
378
+ if ("env" in def) {
379
+ const value = process.env[def.env];
380
+ if (value) resolved[name] = value;
381
+ }
382
+ }
383
+ return resolved;
384
+ }
385
+
386
+ // src/tools.ts
387
+ import path from "path";
388
+ function shQuote(value) {
389
+ return `'${value.replace(/'/g, "'\\''")}'`;
390
+ }
391
+ function createVmReadOps(vm) {
392
+ return {
393
+ readFile: async (p) => {
394
+ const r = await vm.exec(["/bin/cat", p]);
395
+ if (!r.ok) throw new Error(`cat failed (${r.exitCode}): ${r.stderr}`);
396
+ return r.stdoutBuffer;
397
+ },
398
+ access: async (p) => {
399
+ const r = await vm.exec(["/bin/sh", "-lc", `test -r ${shQuote(p)}`]);
400
+ if (!r.ok) throw new Error(`not readable: ${p}`);
401
+ },
402
+ detectImageMimeType: async (p) => {
403
+ try {
404
+ const r = await vm.exec(["/bin/sh", "-lc", `file --mime-type -b ${shQuote(p)}`]);
405
+ if (!r.ok) return null;
406
+ const m = r.stdout.trim();
407
+ return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+ };
413
+ }
414
+ function createVmWriteOps(vm) {
415
+ return {
416
+ writeFile: async (p, content) => {
417
+ const dir = path.posix.dirname(p);
418
+ const b64 = Buffer.from(content, "utf8").toString("base64");
419
+ const script = ["set -eu", `mkdir -p ${shQuote(dir)}`, `echo ${shQuote(b64)} | base64 -d > ${shQuote(p)}`].join(
420
+ "\n"
421
+ );
422
+ const r = await vm.exec(["/bin/sh", "-lc", script]);
423
+ if (!r.ok) throw new Error(`write failed (${r.exitCode}): ${r.stderr}`);
424
+ },
425
+ mkdir: async (dir) => {
426
+ const r = await vm.exec(["/bin/mkdir", "-p", dir]);
427
+ if (!r.ok) throw new Error(`mkdir failed (${r.exitCode}): ${r.stderr}`);
428
+ }
429
+ };
430
+ }
431
+ function createVmEditOps(vm) {
432
+ const r = createVmReadOps(vm);
433
+ const w = createVmWriteOps(vm);
434
+ return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
435
+ }
436
+ var ENV_PASSTHROUGH = /* @__PURE__ */ new Set(["TERM", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "EDITOR", "VISUAL", "PAGER"]);
437
+ function sanitizeEnv(env) {
438
+ if (!env) return void 0;
439
+ const out = {};
440
+ for (const [k, v2] of Object.entries(env)) {
441
+ if (typeof v2 === "string" && ENV_PASSTHROUGH.has(k)) out[k] = v2;
442
+ }
443
+ return Object.keys(out).length > 0 ? out : void 0;
444
+ }
445
+ function createVmBashOps(vm) {
446
+ return {
447
+ exec: async (command, cwd, { onData, signal, timeout, env }) => {
448
+ const ac = new AbortController();
449
+ const onAbort = () => ac.abort();
450
+ signal?.addEventListener("abort", onAbort, { once: true });
451
+ let timedOut = false;
452
+ const timer = timeout && timeout > 0 ? setTimeout(() => {
453
+ timedOut = true;
454
+ ac.abort();
455
+ }, timeout * 1e3) : void 0;
456
+ try {
457
+ const proc = vm.exec(["/bin/sh", "-lc", command], {
458
+ cwd,
459
+ signal: ac.signal,
460
+ env: sanitizeEnv(env),
461
+ stdout: "pipe",
462
+ stderr: "pipe"
463
+ });
464
+ for await (const chunk of proc.output()) {
465
+ onData(chunk.data);
466
+ }
467
+ const r = await proc;
468
+ return { exitCode: r.exitCode };
469
+ } catch (err) {
470
+ if (signal?.aborted) throw new Error("aborted");
471
+ if (timedOut) throw new Error(`timeout:${timeout}`);
472
+ throw err;
473
+ } finally {
474
+ if (timer) clearTimeout(timer);
475
+ signal?.removeEventListener("abort", onAbort);
476
+ }
477
+ }
478
+ };
479
+ }
480
+
481
+ // src/vm.ts
482
+ import { execSync as execSync2 } from "child_process";
483
+ import {
484
+ RealFSProvider,
485
+ ShadowProvider,
486
+ VM,
487
+ createHttpHooks,
488
+ createShadowPathPredicate
489
+ } from "@earendil-works/gondolin";
490
+
491
+ // src/graphql.ts
492
+ import { parse as parse2 } from "graphql";
493
+ function parseGraphQLBody(body) {
494
+ let json;
495
+ try {
496
+ json = JSON.parse(body);
497
+ } catch {
498
+ return void 0;
499
+ }
500
+ const query = json.query;
501
+ if (typeof query !== "string") return void 0;
502
+ let doc;
503
+ try {
504
+ doc = parse2(query);
505
+ } catch {
506
+ return void 0;
507
+ }
508
+ const operations = [];
509
+ for (const def of doc.definitions) {
510
+ if (def.kind !== "OperationDefinition") continue;
511
+ const opDef = def;
512
+ const fields = [];
513
+ for (const sel of opDef.selectionSet.selections) {
514
+ if (sel.kind === "Field") {
515
+ fields.push(sel.name.value);
516
+ }
517
+ }
518
+ operations.push({
519
+ type: opDef.operation,
520
+ name: opDef.name?.value,
521
+ fields
522
+ });
523
+ }
524
+ return operations;
525
+ }
526
+ function checkGraphQLPolicy(operations, allow) {
527
+ const denied = [];
528
+ const deniedFields = [];
529
+ for (const op of operations) {
530
+ const patterns = op.type === "query" ? allow.query : op.type === "mutation" ? allow.mutation : void 0;
531
+ if (!patterns || patterns.length === 0) {
532
+ denied.push(op);
533
+ deniedFields.push(...op.fields);
534
+ continue;
535
+ }
536
+ const unmatchedFields = op.fields.filter((field) => !patterns.some((p) => globMatch(p, field)));
537
+ if (unmatchedFields.length > 0) {
538
+ denied.push(op);
539
+ deniedFields.push(...unmatchedFields);
540
+ }
541
+ }
542
+ if (denied.length === 0) return { allowed: true };
543
+ return { allowed: false, denied, deniedFields };
544
+ }
545
+ function globMatch(pattern, value) {
546
+ if (pattern === "*") return true;
547
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
548
+ return new RegExp(`^${escaped}$`, "i").test(value);
549
+ }
550
+
551
+ // src/policy.ts
552
+ function matchPath(pattern, path2) {
553
+ const normPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
554
+ const normPath = path2.startsWith("/") ? path2 : `/${path2}`;
555
+ const patternParts = normPattern.split("/").filter(Boolean);
556
+ const pathParts = normPath.split("/").filter(Boolean);
557
+ let pi = 0;
558
+ let pp = 0;
559
+ while (pi < patternParts.length && pp < pathParts.length) {
560
+ const pat = patternParts[pi];
561
+ if (pat === "**") {
562
+ return true;
563
+ }
564
+ if (pat === "*") {
565
+ pi++;
566
+ pp++;
567
+ continue;
568
+ }
569
+ if (pat !== pathParts[pp]) {
570
+ return false;
571
+ }
572
+ pi++;
573
+ pp++;
574
+ }
575
+ if (pi < patternParts.length && patternParts[pi] === "**") {
576
+ return true;
577
+ }
578
+ return pi === patternParts.length && pp === pathParts.length;
579
+ }
580
+ function checkPolicy(policy, method, path2) {
581
+ const upperMethod = method.toUpperCase();
582
+ for (const pattern of policy.deny) {
583
+ if (matchPath(pattern, path2)) {
584
+ return "deny";
585
+ }
586
+ }
587
+ const allowedPatterns = policy.allows.get(upperMethod);
588
+ if (allowedPatterns) {
589
+ for (const pattern of allowedPatterns) {
590
+ if (matchPath(pattern, path2)) {
591
+ return "allow";
592
+ }
593
+ }
594
+ }
595
+ return policy.unmatched;
596
+ }
597
+ function evaluateRequest(policies, hostname, method, path2) {
598
+ const policy = policies.get(hostname);
599
+ if (!policy) return "allow";
600
+ return checkPolicy(policy, method, path2);
601
+ }
602
+
603
+ // src/vm.ts
604
+ var EnclaveVM = class {
605
+ vm;
606
+ closed = false;
607
+ options;
608
+ /** Access the underlying Gondolin VM for creating tool operations. */
609
+ get rawVm() {
610
+ if (!this.vm) throw new Error("pi-enclave: VM not started");
611
+ return this.vm;
612
+ }
613
+ constructor(options) {
614
+ this.options = options;
615
+ }
616
+ /**
617
+ * Start the VM. Call once before exec().
618
+ */
619
+ async start() {
620
+ if (this.vm) return;
621
+ if (this.closed) throw new Error("pi-enclave: VM has been closed");
622
+ const { workspaceDir, secrets, allowedHosts, policies, onPolicyPrompt } = this.options;
623
+ this.options.onStatus?.("Starting VM...");
624
+ const secretDefs = {};
625
+ for (const secret of secrets) {
626
+ secretDefs[secret.name] = {
627
+ hosts: secret.hosts,
628
+ value: secret.value
629
+ };
630
+ }
631
+ const hookOptions = {
632
+ secrets: secretDefs,
633
+ blockInternalRanges: true
634
+ };
635
+ const secretHosts = secrets.flatMap((s) => s.hosts);
636
+ const needsAllowlist = allowedHosts || secretHosts.length > 0;
637
+ if (needsAllowlist) {
638
+ hookOptions.allowedHosts = [...allowedHosts ?? [], "dl-cdn.alpinelinux.org", ...secretHosts];
639
+ }
640
+ if (policies.size > 0) {
641
+ hookOptions.isRequestAllowed = async (request) => {
642
+ const url = new URL(request.url);
643
+ const hostPolicy = policies.get(url.hostname);
644
+ if (hostPolicy?.graphql && request.method === "POST" && url.pathname === hostPolicy.graphql.endpoint) {
645
+ return true;
646
+ }
647
+ const decision = evaluateRequest(policies, url.hostname, request.method, url.pathname);
648
+ if (decision === "allow") return true;
649
+ if (decision === "deny") return false;
650
+ if (decision === "prompt" && onPolicyPrompt) {
651
+ return onPolicyPrompt(request.method, request.url, url.hostname);
652
+ }
653
+ return false;
654
+ };
655
+ }
656
+ hookOptions.onRequest = async (request) => {
657
+ if (request.method !== "POST") return;
658
+ const url = new URL(request.url);
659
+ const hostPolicy = policies.get(url.hostname);
660
+ if (!hostPolicy?.graphql || url.pathname !== hostPolicy.graphql.endpoint) return;
661
+ const gqlPolicy = hostPolicy.graphql;
662
+ let bodyText;
663
+ try {
664
+ const cloned = request.clone();
665
+ bodyText = await cloned.text();
666
+ } catch {
667
+ return;
668
+ }
669
+ const operations = parseGraphQLBody(bodyText);
670
+ if (!operations) {
671
+ if (gqlPolicy.unmatched === "allow") return;
672
+ return new Response(
673
+ JSON.stringify({ errors: [{ message: "Blocked by pi-enclave: unparseable GraphQL body" }] }),
674
+ { status: 403, headers: { "Content-Type": "application/json" } }
675
+ );
676
+ }
677
+ if (operations.length === 0) return;
678
+ const result = checkGraphQLPolicy(operations, gqlPolicy.allow);
679
+ if (result.allowed) return;
680
+ if (gqlPolicy.unmatched === "allow") return;
681
+ if (gqlPolicy.unmatched === "deny") {
682
+ return new Response(
683
+ JSON.stringify({ errors: [{ message: `Blocked by pi-enclave: ${result.deniedFields.join(", ")}` }] }),
684
+ { status: 403, headers: { "Content-Type": "application/json" } }
685
+ );
686
+ }
687
+ if (onPolicyPrompt) {
688
+ const fieldList = result.deniedFields.join(", ");
689
+ const allowed = await onPolicyPrompt(
690
+ "GRAPHQL",
691
+ `${url.hostname}${gqlPolicy.endpoint}: ${fieldList}`,
692
+ url.hostname
693
+ );
694
+ if (!allowed) {
695
+ return new Response(JSON.stringify({ errors: [{ message: `Blocked by pi-enclave: ${fieldList}` }] }), {
696
+ status: 403,
697
+ headers: { "Content-Type": "application/json" }
698
+ });
699
+ }
700
+ } else {
701
+ return new Response(JSON.stringify({ errors: [{ message: "Blocked by pi-enclave policy" }] }), {
702
+ status: 403,
703
+ headers: { "Content-Type": "application/json" }
704
+ });
705
+ }
706
+ };
707
+ const { httpHooks, env } = createHttpHooks(hookOptions);
708
+ const realFs = new RealFSProvider(workspaceDir);
709
+ const shadowedFs = new ShadowProvider(realFs, {
710
+ shouldShadow: createShadowPathPredicate(["/.pi/enclave.toml"]),
711
+ writeMode: "deny"
712
+ });
713
+ const mounts = {
714
+ [workspaceDir]: shadowedFs
715
+ };
716
+ for (const extra of this.options.extraMounts) {
717
+ if (mounts[extra.path]) continue;
718
+ const provider = new RealFSProvider(extra.path);
719
+ if (extra.readonly) {
720
+ mounts[extra.path] = new ShadowProvider(provider, {
721
+ shouldShadow: () => true,
722
+ writeMode: "deny"
723
+ });
724
+ } else {
725
+ mounts[extra.path] = provider;
726
+ }
727
+ }
728
+ this.vm = await VM.create({
729
+ httpHooks,
730
+ env: {
731
+ ...env,
732
+ ...this.options.extraEnv,
733
+ HOME: "/root",
734
+ TERM: "xterm-256color"
735
+ },
736
+ vfs: {
737
+ mounts
738
+ },
739
+ sessionLabel: "pi-enclave"
740
+ });
741
+ this.options.onStatus?.("VM started, installing packages...");
742
+ const packages = this.options.packages;
743
+ if (packages.length > 0) {
744
+ this.options.onStatus?.(`Installing packages: ${packages.join(", ")}...`);
745
+ const result = await this.vm.exec(`apk add --no-progress ${packages.map(shellEscape).join(" ")}`);
746
+ if (!result.ok) {
747
+ this.options.onStatus?.(`Sandbox active (package install warning: ${result.stderr.trim().split("\n").pop()})`);
748
+ return;
749
+ }
750
+ }
751
+ for (const cred of this.options.gitCredentials) {
752
+ await this.vm.exec(
753
+ `git config --global credential.https://${shellEscape(cred.host)}.helper '!f() { echo "username=${shellEscape(cred.username)}"; echo "password=$${cred.secret}"; }; f'`
754
+ );
755
+ }
756
+ if (this.options.setupScript) {
757
+ this.options.onStatus?.("Running setup...");
758
+ const result = await this.vm.exec(this.options.setupScript);
759
+ if (!result.ok) {
760
+ this.options.onStatus?.(`Sandbox active (setup warning: ${result.stderr.trim().split("\n").pop()})`);
761
+ return;
762
+ }
763
+ }
764
+ this.options.onStatus?.("VM ready");
765
+ }
766
+ /**
767
+ * Install additional packages at runtime.
768
+ */
769
+ async installPackage(pkg) {
770
+ if (!this.vm) throw new Error("pi-enclave: VM not started");
771
+ return this.vm.exec(`apk add --no-progress ${shellEscape(pkg)}`);
772
+ }
773
+ /**
774
+ * Check if the VM is running.
775
+ */
776
+ get isRunning() {
777
+ return this.vm !== void 0 && !this.closed;
778
+ }
779
+ /**
780
+ * Shut down the VM.
781
+ */
782
+ async close() {
783
+ if (this.closed) return;
784
+ this.closed = true;
785
+ if (this.vm) {
786
+ await this.vm.close();
787
+ this.vm = void 0;
788
+ }
789
+ }
790
+ };
791
+ function shellEscape(s) {
792
+ return `'${s.replace(/'/g, "'\\''")}'`;
793
+ }
794
+ function checkQemuAvailable() {
795
+ try {
796
+ execSync2("which qemu-system-aarch64", { stdio: "ignore" });
797
+ return { available: true };
798
+ } catch {
799
+ const platform = process.platform;
800
+ let installHint;
801
+ if (platform === "darwin") {
802
+ installHint = "Install with: brew install qemu";
803
+ } else if (platform === "linux") {
804
+ installHint = "Install with: sudo apt install qemu-system-aarch64 (Debian/Ubuntu) or sudo pacman -S qemu-full (Arch)";
805
+ } else {
806
+ installHint = "QEMU is required but your platform may not be supported.";
807
+ }
808
+ return {
809
+ available: false,
810
+ message: `pi-enclave requires QEMU but qemu-system-aarch64 was not found.
811
+ ${installHint}`
812
+ };
813
+ }
814
+ }
815
+
816
+ // src/index.ts
817
+ function extractPkgName(versionedName) {
818
+ const match = versionedName.match(/^(.+?)-\d/);
819
+ return match ? match[1] : versionedName;
820
+ }
821
+ async function handleAddPackage(query, enclaveVm, cwd, ctx) {
822
+ const vm = enclaveVm.rawVm;
823
+ const q = shQuote(query);
824
+ let exactResult = await vm.exec(`apk search --exact ${q}`);
825
+ if (!exactResult.ok && exactResult.stderr.includes("No such file")) {
826
+ await vm.exec("apk update --quiet");
827
+ exactResult = await vm.exec(`apk search --exact ${q}`);
828
+ }
829
+ let targetPkg;
830
+ if (exactResult.ok && exactResult.stdout.trim()) {
831
+ const infoResult = await vm.exec(`apk info ${q}`);
832
+ const info = infoResult.ok ? infoResult.stdout.trim() : `Package: ${query}`;
833
+ const ok = await ctx.ui.confirm(`\u26E9\u2002Install ${query}?`, info);
834
+ if (ok) targetPkg = query;
835
+ } else {
836
+ const searchResult = await vm.exec(`apk search ${q}`);
837
+ const rawLines = searchResult.ok ? searchResult.stdout.trim().split("\n").filter(Boolean) : [];
838
+ const filtered = rawLines.filter(
839
+ (l) => !/-(?:doc|dev|lang|dbg|bash-completion|zsh-completion|fish-completion|pyc)-\d/.test(l)
840
+ );
841
+ if (filtered.length === 0) {
842
+ ctx.ui.notify(`No packages found for "${query}".`, "warning");
843
+ return;
844
+ }
845
+ const pkgNames = filtered.slice(0, 10).map(extractPkgName);
846
+ const uniqueNames = [...new Set(pkgNames)];
847
+ const options = [];
848
+ for (const name of uniqueNames) {
849
+ const descResult = await vm.exec(`apk info --description ${shQuote(name)}`);
850
+ const desc = descResult.ok ? descResult.stdout.trim().split("\n").find((l) => !l.includes("description:") && l.trim()) : void 0;
851
+ options.push(desc ? `${name} \u2014 ${desc.trim()}` : name);
852
+ }
853
+ options.push("Cancel");
854
+ const choice = await ctx.ui.select(`\u26E9\u2002No exact match for "${query}". Select a package:`, options);
855
+ if (!choice || choice === "Cancel") return;
856
+ targetPkg = choice.split(" \u2014 ")[0];
857
+ }
858
+ if (!targetPkg) return;
859
+ const installResult = await enclaveVm.installPackage(targetPkg);
860
+ if (!installResult.ok) {
861
+ ctx.ui.notify(`\u274C Failed to install ${targetPkg}: ${installResult.stderr.slice(0, 200)}`, "error");
862
+ return;
863
+ }
864
+ const saveChoice = await ctx.ui.select(`\u2705 Installed ${targetPkg}. Save to config?`, [
865
+ "Save to project (.pi/enclave.toml)",
866
+ `Save to global (${tildify(globalConfigPath())})`,
867
+ "Don't save (this session only)"
868
+ ]);
869
+ if (saveChoice === "Save to project (.pi/enclave.toml)") {
870
+ addPackageToConfig(cwd, targetPkg, "project");
871
+ ctx.ui.notify(`\u26E9\u2002Added ${targetPkg} to .pi/enclave.toml`);
872
+ } else if (saveChoice?.startsWith("Save to global")) {
873
+ addPackageToConfig(cwd, targetPkg, "global");
874
+ ctx.ui.notify(`\u26E9\u2002Added ${targetPkg} to ${tildify(globalConfigPath())}`);
875
+ }
876
+ }
877
+ var SESSION_ENTRY_TYPE = "enclave:active";
878
+ function index_default(pi) {
879
+ const qemu = checkQemuAvailable();
880
+ if (!qemu.available) {
881
+ pi.on("session_start", (_event, ctx) => {
882
+ ctx.ui.notify(qemu.message, "error");
883
+ });
884
+ return;
885
+ }
886
+ ensureGlobalConfig();
887
+ const localCwd = process.cwd();
888
+ const { merged, policies, hasProjectConfig } = loadConfig(localCwd);
889
+ const packages = merged.packages?.length ? merged.packages : DEFAULT_PACKAGES;
890
+ const extraMounts = merged.mounts ?? [];
891
+ const gitCredentials = merged["git-credentials"] ?? [];
892
+ const allowedHosts = void 0;
893
+ const extraEnv = resolveEnv(merged.env);
894
+ const setupScript = merged.setup;
895
+ let secrets;
896
+ try {
897
+ secrets = resolveSecrets(merged.secrets);
898
+ } catch (err) {
899
+ pi.on("session_start", (_event, ctx) => {
900
+ ctx.ui.notify(`pi-enclave: failed to resolve secrets: ${err.message}`, "error");
901
+ });
902
+ return;
903
+ }
904
+ const configEnabled = merged.enabled;
905
+ let sessionOverride;
906
+ function isActive() {
907
+ if (sessionOverride !== void 0) return sessionOverride;
908
+ return configEnabled === true;
909
+ }
910
+ function getSessionActivation(ctx) {
911
+ const entries = ctx.sessionManager.getEntries();
912
+ for (let i = entries.length - 1; i >= 0; i--) {
913
+ const entry = entries[i];
914
+ if (entry.type === "custom" && entry.customType === SESSION_ENTRY_TYPE) {
915
+ return entry.data;
916
+ }
917
+ }
918
+ return void 0;
919
+ }
920
+ let enclaveVm;
921
+ let vmStarting;
922
+ async function shutdownVm() {
923
+ if (enclaveVm) {
924
+ await enclaveVm.close();
925
+ enclaveVm = void 0;
926
+ }
927
+ vmStarting = void 0;
928
+ }
929
+ async function ensureVm(ctx) {
930
+ if (!isActive()) return null;
931
+ if (enclaveVm?.isRunning) return enclaveVm;
932
+ if (vmStarting) return vmStarting;
933
+ vmStarting = (async () => {
934
+ ctx.ui.setStatus("enclave", "\u26E9\u2002Starting VM...");
935
+ const instance = new EnclaveVM({
936
+ workspaceDir: localCwd,
937
+ packages,
938
+ extraMounts,
939
+ secrets,
940
+ gitCredentials,
941
+ extraEnv,
942
+ setupScript,
943
+ allowedHosts,
944
+ policies,
945
+ onPolicyPrompt: async (method, url, hostname) => {
946
+ if (!ctx.hasUI) return false;
947
+ return ctx.ui.confirm(
948
+ "Network request needs approval",
949
+ `${method} ${url}
950
+ Host: ${hostname}
951
+
952
+ Allow this request?`
953
+ );
954
+ },
955
+ onStatus: (message) => {
956
+ ctx.ui.setStatus("enclave", `\u26E9\u2002${message}`);
957
+ }
958
+ });
959
+ await instance.start();
960
+ enclaveVm = instance;
961
+ ctx.ui.setStatus("enclave", "\u26E9\u2002Sandbox active");
962
+ return instance;
963
+ })();
964
+ return vmStarting;
965
+ }
966
+ pi.on("session_start", async (_event, ctx) => {
967
+ const prev = getSessionActivation(ctx);
968
+ if (prev !== void 0) {
969
+ sessionOverride = prev;
970
+ }
971
+ if (isActive()) {
972
+ ensureVm(ctx);
973
+ } else if (configEnabled === void 0 && sessionOverride === void 0) {
974
+ ctx.ui.notify("\u26E9\u2002pi-enclave is installed but not enabled. Run /enclave init to set up.");
975
+ }
976
+ });
977
+ pi.on("session_switch", async (_event, ctx) => {
978
+ await shutdownVm();
979
+ ctx.ui.setStatus("enclave", void 0);
980
+ sessionOverride = getSessionActivation(ctx);
981
+ if (isActive()) {
982
+ ensureVm(ctx);
983
+ }
984
+ });
985
+ const localRead = createReadTool(localCwd);
986
+ const localWrite = createWriteTool(localCwd);
987
+ const localEdit = createEditTool(localCwd);
988
+ const localBash = createBashTool(localCwd);
989
+ pi.registerTool({
990
+ ...localRead,
991
+ async execute(id, params, signal, onUpdate, ctx) {
992
+ const vm = await ensureVm(ctx);
993
+ if (!vm) return localRead.execute(id, params, signal, onUpdate);
994
+ return createReadTool(localCwd, { operations: createVmReadOps(vm.rawVm) }).execute(id, params, signal, onUpdate);
995
+ }
996
+ });
997
+ pi.registerTool({
998
+ ...localWrite,
999
+ async execute(id, params, signal, onUpdate, ctx) {
1000
+ const vm = await ensureVm(ctx);
1001
+ if (!vm) return localWrite.execute(id, params, signal, onUpdate);
1002
+ return createWriteTool(localCwd, { operations: createVmWriteOps(vm.rawVm) }).execute(
1003
+ id,
1004
+ params,
1005
+ signal,
1006
+ onUpdate
1007
+ );
1008
+ }
1009
+ });
1010
+ pi.registerTool({
1011
+ ...localEdit,
1012
+ async execute(id, params, signal, onUpdate, ctx) {
1013
+ const vm = await ensureVm(ctx);
1014
+ if (!vm) return localEdit.execute(id, params, signal, onUpdate);
1015
+ return createEditTool(localCwd, { operations: createVmEditOps(vm.rawVm) }).execute(id, params, signal, onUpdate);
1016
+ }
1017
+ });
1018
+ pi.registerTool({
1019
+ ...localBash,
1020
+ async execute(id, params, signal, onUpdate, ctx) {
1021
+ const vm = await ensureVm(ctx);
1022
+ if (!vm) return localBash.execute(id, params, signal, onUpdate);
1023
+ return createBashTool(localCwd, { operations: createVmBashOps(vm.rawVm) }).execute(id, params, signal, onUpdate);
1024
+ }
1025
+ });
1026
+ pi.on("user_bash", (_event, _ctx) => {
1027
+ if (!enclaveVm?.isRunning) return;
1028
+ return { operations: createVmBashOps(enclaveVm.rawVm) };
1029
+ });
1030
+ pi.on("before_agent_start", async (_event, _ctx) => {
1031
+ if (!isActive()) return;
1032
+ return {
1033
+ message: {
1034
+ customType: "enclave:info",
1035
+ content: [
1036
+ {
1037
+ type: "text",
1038
+ text: "Commands run inside an isolated Alpine Linux VM (pi-enclave). If a command is not found, ask the user to install it with `/enclave add <tool-name>`. Package names may differ from binary names (e.g. `github-cli` for `gh`)."
1039
+ }
1040
+ ],
1041
+ display: false
1042
+ }
1043
+ };
1044
+ });
1045
+ pi.registerCommand("enclave", {
1046
+ description: "Manage enclave: /enclave [status|init|on|off|restart|add <pkg>]",
1047
+ handler: async (args, ctx) => {
1048
+ const parts = args.trim().split(/\s+/);
1049
+ const subcommand = parts[0] || "status";
1050
+ switch (subcommand) {
1051
+ case "status": {
1052
+ if (enclaveVm?.isRunning) {
1053
+ const secretNames = secrets.map((s) => s.name).join(", ") || "none";
1054
+ ctx.ui.notify(
1055
+ `\u26E9\u2002pi-enclave: Sandbox active
1056
+ Config: ${hasProjectConfig ? ".pi/enclave.toml" : "none (using defaults)"}
1057
+ Packages: ${packages.join(", ")}
1058
+ Secrets: ${secretNames}`
1059
+ );
1060
+ } else {
1061
+ const label = isActive() ? "enabled, VM starts on next tool use" : "not enabled";
1062
+ ctx.ui.notify(`\u26E9\u2002pi-enclave: ${label}`);
1063
+ }
1064
+ break;
1065
+ }
1066
+ case "init": {
1067
+ const createdGlobal = ensureGlobalConfig();
1068
+ const createdProject = initProjectConfig(localCwd);
1069
+ const messages = [];
1070
+ for (const path2 of createdGlobal) {
1071
+ messages.push(`Created ${tildify(path2)}`);
1072
+ }
1073
+ if (createdProject) {
1074
+ messages.push("Created .pi/enclave.toml with enabled = true");
1075
+ sessionOverride = true;
1076
+ pi.appendEntry(SESSION_ENTRY_TYPE, true);
1077
+ } else {
1078
+ messages.push(".pi/enclave.toml already exists");
1079
+ }
1080
+ ctx.ui.notify(`\u26E9\u2002${messages.join("\n")}`);
1081
+ if (createdProject) {
1082
+ ctx.ui.notify("Reload with /reload to apply the new config.", "info");
1083
+ }
1084
+ break;
1085
+ }
1086
+ case "on": {
1087
+ sessionOverride = true;
1088
+ pi.appendEntry(SESSION_ENTRY_TYPE, true);
1089
+ ctx.ui.notify("\u26E9\u2002pi-enclave enabled for this session. VM starts on next tool use.");
1090
+ break;
1091
+ }
1092
+ case "off": {
1093
+ sessionOverride = false;
1094
+ pi.appendEntry(SESSION_ENTRY_TYPE, false);
1095
+ await shutdownVm();
1096
+ ctx.ui.setStatus("enclave", void 0);
1097
+ ctx.ui.notify("\u26E9\u2002pi-enclave disabled for this session. Tools run on the host.");
1098
+ break;
1099
+ }
1100
+ case "restart": {
1101
+ await shutdownVm();
1102
+ ctx.ui.notify("\u26E9\u2002pi-enclave: VM will restart on next tool use.");
1103
+ break;
1104
+ }
1105
+ case "add": {
1106
+ const query = parts.slice(1).join(" ");
1107
+ if (!query) {
1108
+ ctx.ui.notify("Usage: /enclave add <package-name>", "warning");
1109
+ return;
1110
+ }
1111
+ if (!isActive()) {
1112
+ sessionOverride = true;
1113
+ pi.appendEntry(SESSION_ENTRY_TYPE, true);
1114
+ }
1115
+ const vmForAdd = await ensureVm(ctx);
1116
+ if (!vmForAdd) {
1117
+ ctx.ui.notify("Failed to start VM.", "error");
1118
+ return;
1119
+ }
1120
+ await handleAddPackage(query, vmForAdd, localCwd, ctx);
1121
+ break;
1122
+ }
1123
+ default:
1124
+ ctx.ui.notify("Usage: /enclave [status|init|on|off|restart|add <pkg>]", "warning");
1125
+ }
1126
+ }
1127
+ });
1128
+ pi.on("session_shutdown", async (_event, ctx) => {
1129
+ if (!enclaveVm) return;
1130
+ ctx.ui.setStatus("enclave", "\u26E9\u2002Stopping sandbox...");
1131
+ await shutdownVm();
1132
+ });
1133
+ }
1134
+ export {
1135
+ index_default as default
1136
+ };