@xaccefy/pi-casefile 0.1.6 → 0.1.7

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/src/poc-runner.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { extname } from "node:path";
2
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
5
 
5
6
  export type PocRun = {
6
7
  path: string;
@@ -10,77 +11,357 @@ export type PocRun = {
10
11
  sandbox: boolean;
11
12
  };
12
13
 
13
- /**
14
- * Run a PoC script. Supports docker sandbox (default) or local execution.
15
- *
16
- * Python (.py), JavaScript (.js/.mjs/.cjs), and shell (.sh) scripts are supported.
17
- * Unsupported extensions are rejected with a clear error.
18
- */
19
- export function runPoc(pocPath: string, useSandbox = true): PocRun {
20
- if (!existsSync(pocPath)) {
21
- throw new Error(`PoC not found on disk: ${pocPath}`);
14
+ export type PocLanguage = {
15
+ /** Docker image used when running inside the sandbox. */
16
+ image: string;
17
+ /** Shell command to run an interpreted PoC. {{file}} is replaced with the source path. */
18
+ run?: string;
19
+ /** Shell command to build and run a compiled PoC. {{file}}, {{bin}}, {{class}} replaced. */
20
+ buildRun?: string;
21
+ /** Files that, when present in the project root, identify this project type. */
22
+ projectMarkers?: string[];
23
+ };
24
+
25
+ /** Minimal built-in defaults. Users can override/extend via env. */
26
+ const BUILTIN_LANGUAGES: Record<string, PocLanguage> = {
27
+ python: {
28
+ image: "python:3.12-slim",
29
+ run: "python3 {{file}}",
30
+ projectMarkers: ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"],
31
+ },
32
+ node: {
33
+ image: "node:22-slim",
34
+ run: "node {{file}}",
35
+ projectMarkers: ["package.json"],
36
+ },
37
+ shell: {
38
+ image: "alpine",
39
+ run: "sh {{file}}",
40
+ },
41
+ };
42
+
43
+ /** Extension to language key. Unknown extensions can be supplied by the user. */
44
+ const EXTENSION_MAP: Record<string, string> = {
45
+ ".py": "python",
46
+ ".js": "node",
47
+ ".mjs": "node",
48
+ ".cjs": "node",
49
+ ".ts": "node",
50
+ ".sh": "shell",
51
+ ".bash": "shell",
52
+ ".zsh": "shell",
53
+ };
54
+
55
+ const OUTPUT_MAX_CHARS = 4000;
56
+ const TIMEOUT_MS = 30_000;
57
+ const MAX_BUFFER = 8 * 1024 * 1024;
58
+
59
+ function getProjectRoot(): string {
60
+ const envRoot = process.env.PI_POC_ROOT?.trim();
61
+ if (envRoot) return resolve(envRoot);
62
+
63
+ let curr = resolve(process.cwd());
64
+ for (let i = 0; i < 20; i++) {
65
+ if (existsSync(join(curr, ".git"))) return curr;
66
+ const parent = dirname(curr);
67
+ if (parent === curr) break;
68
+ curr = parent;
69
+ }
70
+ return resolve(process.cwd());
71
+ }
72
+
73
+ function loadLanguages(): Record<string, PocLanguage> {
74
+ const languages = { ...BUILTIN_LANGUAGES };
75
+
76
+ const envOverride = process.env.PI_POC_LANGUAGES?.trim();
77
+ if (envOverride) {
78
+ try {
79
+ const extra = JSON.parse(envOverride) as Record<string, PocLanguage>;
80
+ for (const [key, lang] of Object.entries(extra)) {
81
+ if (lang?.image) languages[key] = lang;
82
+ }
83
+ } catch {
84
+ // Malformed env JSON is ignored.
85
+ }
22
86
  }
23
87
 
88
+ return languages;
89
+ }
90
+
91
+ function detectProjectType(languages: Record<string, PocLanguage>): string | undefined {
92
+ const root = getProjectRoot();
93
+ for (const [key, lang] of Object.entries(languages)) {
94
+ for (const marker of lang.projectMarkers ?? []) {
95
+ if (existsSync(join(root, marker))) return key;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ function parseShebang(pocPath: string): string | undefined {
102
+ try {
103
+ const head = readFileSync(pocPath, "utf8").split(/\r?\n/)[0];
104
+ if (!head.startsWith("#!")) return undefined;
105
+ const trimmed = head.slice(2).trim();
106
+ // "#!/usr/bin/env python3" -> "python3"
107
+ // "#!/usr/bin/python3" -> "python3"
108
+ const parts = trimmed.split(/\s+/);
109
+ if (parts[0] === "/usr/bin/env" || parts[0] === "/bin/env") {
110
+ return parts[1];
111
+ }
112
+ return basename(parts[0]);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ function interpreterToLanguage(
119
+ interpreter: string,
120
+ languages: Record<string, PocLanguage>,
121
+ ): string | undefined {
122
+ const bin = basename(interpreter).toLowerCase();
123
+ for (const [key, lang] of Object.entries(languages)) {
124
+ if (lang.run) {
125
+ const runBin = lang.run.split(" ")[0].toLowerCase();
126
+ if (runBin === bin) return key;
127
+ }
128
+ if (lang.buildRun) {
129
+ const buildBin = lang.buildRun.split(" ")[0].toLowerCase();
130
+ if (buildBin === bin) return key;
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ function resolveLanguage(pocPath: string): { key: string; language: PocLanguage } {
137
+ const languages = loadLanguages();
24
138
  const ext = extname(pocPath).toLowerCase();
25
- const isPython = ext === ".py";
26
- const isJavaScript = ext === ".js" || ext === ".mjs" || ext === ".cjs";
139
+ const extKey = EXTENSION_MAP[ext];
27
140
 
28
- if (!isPython && !isJavaScript && ext !== ".sh") {
29
- throw new Error(
30
- `Unsupported PoC extension "${ext}". Supported: .py (Python), .js/.mjs/.cjs (Node), .sh (shell)`,
31
- );
141
+ // 1. Shebang overrides everything.
142
+ const shebang = parseShebang(pocPath);
143
+ if (shebang) {
144
+ const shebangKey = interpreterToLanguage(shebang, languages);
145
+ if (shebangKey) return { key: shebangKey, language: languages[shebangKey] };
32
146
  }
33
147
 
148
+ // 2. Project type detection.
149
+ const projectType = detectProjectType(languages);
150
+ if (projectType && languages[projectType]) {
151
+ // If the file extension matches the project type or type has no extension restrictions, use it.
152
+ return { key: projectType, language: languages[projectType] };
153
+ }
154
+
155
+ // 3. Extension-based fallback.
156
+ if (extKey && languages[extKey]) {
157
+ return { key: extKey, language: languages[extKey] };
158
+ }
159
+
160
+ // 4. Unknown extension: allow env override specifying a single language key.
161
+ const envDefault = process.env.PI_POC_DEFAULT_LANGUAGE?.trim();
162
+ if (envDefault && languages[envDefault]) {
163
+ return { key: envDefault, language: languages[envDefault] };
164
+ }
165
+
166
+ const supported = Object.keys(languages).sort().join(", ");
167
+ throw new Error(
168
+ `Cannot determine PoC language for "${pocPath}". ` +
169
+ `Detected extension: "${ext || "none"}". ` +
170
+ `Supported/adapted languages: ${supported}. ` +
171
+ `Add a shebang, use a known extension, or set PI_POC_DEFAULT_LANGUAGE or PI_POC_LANGUAGES.`,
172
+ );
173
+ }
174
+
175
+ function validatePocPath(pocPath: string): string {
176
+ if (!pocPath || typeof pocPath !== "string") {
177
+ throw new Error("PoC path must be a non-empty string");
178
+ }
179
+
180
+ if (!isAbsolute(pocPath)) {
181
+ throw new Error(`PoC path must be absolute: ${pocPath}`);
182
+ }
183
+
184
+ const normalized = resolve(pocPath);
185
+ if (normalized.includes("\0")) {
186
+ throw new Error("PoC path contains null bytes");
187
+ }
188
+
189
+ const parts = normalized.split(/[\\/]/);
190
+ if (parts.includes("..")) {
191
+ throw new Error(`PoC path contains traversal segments: ${pocPath}`);
192
+ }
193
+
194
+ const root = getProjectRoot();
195
+ if (!normalized.startsWith(`${root}/`) && normalized !== root) {
196
+ const allowAbsolute = process.env.PI_POC_ALLOW_ABSOLUTE === "1";
197
+ if (!allowAbsolute) {
198
+ throw new Error(
199
+ `PoC path must be under the project workspace (${root}). ` +
200
+ `Set PI_POC_ALLOW_ABSOLUTE=1 to allow arbitrary absolute paths.`,
201
+ );
202
+ }
203
+ }
204
+
205
+ if (!existsSync(normalized)) {
206
+ throw new Error(`PoC not found on disk: ${pocPath}`);
207
+ }
208
+
209
+ return normalized;
210
+ }
211
+
212
+ function sanitizeOutput(output: string): string {
213
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
214
+ const nulls = /\x00/g;
215
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
216
+ const ansi = /\x1b\[[0-9;]*[a-zA-Z]/g;
217
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
218
+ const ctrl = /[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]+/g;
219
+
220
+ return output
221
+ .replace(/\r\n/g, "\n")
222
+ .replace(/\r/g, "\n")
223
+ .replace(nulls, "")
224
+ .replace(ansi, "")
225
+ .replace(ctrl, "")
226
+ .slice(0, OUTPUT_MAX_CHARS);
227
+ }
228
+
229
+ function buildDockerArgs(image: string, command: string, workspaceDir: string): string[] {
230
+ return [
231
+ "run",
232
+ "--rm",
233
+ "--network",
234
+ "none",
235
+ "--read-only",
236
+ "--cap-drop",
237
+ "ALL",
238
+ "--security-opt",
239
+ "no-new-privileges",
240
+ "--user",
241
+ "1000:1000",
242
+ "-v",
243
+ `${workspaceDir}:/workspace:rw`,
244
+ image,
245
+ "sh",
246
+ "-c",
247
+ command,
248
+ ];
249
+ }
250
+
251
+ function renderCommand(template: string, pocPath: string, inSandbox: boolean): string {
252
+ const sourceName = basename(pocPath);
253
+ const className = sourceName.replace(/\.[^.]+$/i, "");
254
+ const targetPath = inSandbox ? `/workspace/${sourceName}` : pocPath;
255
+ const binPath = inSandbox ? "/workspace/poc" : join(dirname(pocPath), "poc");
256
+
257
+ return template
258
+ .replace(/{{file}}/g, targetPath)
259
+ .replace(/{{bin}}/g, binPath)
260
+ .replace(/{{class}}/g, className);
261
+ }
262
+
263
+ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
34
264
  const ranAt = new Date().toISOString();
265
+ const sourceName = basename(pocPath);
266
+ const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
35
267
 
36
- // Resolve the runner command and container image for the file type
37
- const runner = isPython ? "python3" : isJavaScript ? "node" : "sh";
38
- const image = isPython ? "python:3.12-slim" : isJavaScript ? "node:22-slim" : "alpine";
268
+ try {
269
+ copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
39
270
 
40
- if (useSandbox) {
41
- const containerPath = `/workspace/poc${ext}`;
42
-
43
- const args = [
44
- "run",
45
- "--rm",
46
- "--network",
47
- "none",
48
- "-v",
49
- `${pocPath}:${containerPath}:ro`,
50
- image,
51
- runner,
52
- containerPath,
53
- ];
54
-
55
- const result = spawnSync("docker", args, {
271
+ let command: string;
272
+ if (language.buildRun) {
273
+ command = renderCommand(language.buildRun, pocPath, true);
274
+ } else if (language.run) {
275
+ command = renderCommand(language.run, pocPath, true);
276
+ } else {
277
+ throw new Error("Language config has no run or buildRun command");
278
+ }
279
+
280
+ const result = spawnSync("docker", buildDockerArgs(language.image, command, workspaceDir), {
56
281
  encoding: "utf8",
57
- timeout: 30000,
58
- maxBuffer: 8 * 1024 * 1024,
282
+ timeout: TIMEOUT_MS,
283
+ maxBuffer: MAX_BUFFER,
59
284
  });
60
285
 
61
- const output = (result.stdout ?? "") + (result.stderr ?? "");
286
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
62
287
  return {
63
288
  path: pocPath,
64
289
  exitCode: result.status ?? (result.signal ? 1 : 0),
65
- output: output.slice(0, 4000),
290
+ output,
66
291
  ranAt,
67
292
  sandbox: true,
68
293
  };
294
+ } finally {
295
+ try {
296
+ rmSync(workspaceDir, { recursive: true, force: true });
297
+ } catch {
298
+ // Best-effort cleanup.
299
+ }
300
+ }
301
+ }
302
+
303
+ function runLocal(pocPath: string, language: PocLanguage): PocRun {
304
+ const ranAt = new Date().toISOString();
305
+
306
+ if (language.buildRun) {
307
+ throw new Error(
308
+ "Compiled PoC languages require the Docker sandbox. " +
309
+ "Run PromoteFinding with local:false or use an interpreted PoC.",
310
+ );
69
311
  }
70
312
 
71
- // Local execution
72
- const result = spawnSync(runner, [pocPath], {
313
+ if (!language.run) {
314
+ throw new Error("Language config has no run command");
315
+ }
316
+
317
+ // For local execution, split the run command into binary and args.
318
+ const command = renderCommand(language.run, pocPath, false);
319
+ const [interpreter, ...args] = command.split(" ");
320
+ // Note: the file path is already substituted in the args if {{file}} was used.
321
+ // We just need to spawn.
322
+
323
+ const result = spawnSync(interpreter, args, {
73
324
  encoding: "utf8",
74
- timeout: 30000,
75
- maxBuffer: 8 * 1024 * 1024,
325
+ timeout: TIMEOUT_MS,
326
+ maxBuffer: MAX_BUFFER,
76
327
  });
77
328
 
78
- const output = (result.stdout ?? "") + (result.stderr ?? "");
329
+ const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? ""));
79
330
  return {
80
331
  path: pocPath,
81
332
  exitCode: result.status ?? (result.signal ? 1 : 0),
82
- output: output.slice(0, 4000),
333
+ output,
83
334
  ranAt,
84
335
  sandbox: false,
85
336
  };
86
337
  }
338
+
339
+ /**
340
+ * Run a PoC script in an adaptive, language-aware sandbox or locally.
341
+ *
342
+ * Language detection (in order):
343
+ * 1. Shebang line in the PoC file.
344
+ * 2. Project type markers in the workspace root (e.g., package.json, go.mod, Cargo.toml).
345
+ * 3. File extension.
346
+ * 4. PI_POC_DEFAULT_LANGUAGE environment variable.
347
+ *
348
+ * Users can extend or override language definitions via:
349
+ * - `.pi/poc-languages.json` in the project root.
350
+ * - `PI_POC_LANGUAGES` environment variable (JSON object).
351
+ *
352
+ * Security:
353
+ * - PoC paths must be absolute and under the project workspace by default.
354
+ * - Docker sandbox runs with no network, read-only root FS, dropped caps,
355
+ * no new privileges, and an unprivileged user.
356
+ * - Local execution is restricted to interpreted languages.
357
+ */
358
+ export function runPoc(pocPath: string, useSandbox = true): PocRun {
359
+ const normalized = validatePocPath(pocPath);
360
+ const { language } = resolveLanguage(normalized);
361
+
362
+ if (useSandbox) {
363
+ return runSandboxed(normalized, language);
364
+ }
365
+
366
+ return runLocal(normalized, language);
367
+ }