@xaccefy/pi-casefile 0.1.6 → 0.1.8
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 +37 -54
- package/package.json +3 -6
- package/src/index.ts +94 -206
- package/src/ledger.ts +151 -128
- package/src/poc-runner.ts +327 -46
- package/src/sqlite-compat/index.ts +5 -0
- package/mcp/server.ts +0 -371
- package/src/sqlite-compat.ts +0 -33
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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
const isJavaScript = ext === ".js" || ext === ".mjs" || ext === ".cjs";
|
|
139
|
+
const extKey = EXTENSION_MAP[ext];
|
|
27
140
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
const image = isPython ? "python:3.12-slim" : isJavaScript ? "node:22-slim" : "alpine";
|
|
268
|
+
try {
|
|
269
|
+
copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
|
|
39
270
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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:
|
|
58
|
-
maxBuffer:
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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:
|
|
75
|
-
maxBuffer:
|
|
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
|
|
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
|
+
}
|