feat(tooling): add governed local PI subagent executor
This commit is contained in:
@@ -12,6 +12,11 @@
|
|||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "codegraph",
|
"command": "codegraph",
|
||||||
"args": ["serve", "--mcp", "--path", "${workspaceFolder}"]
|
"args": ["serve", "--mcp", "--path", "${workspaceFolder}"]
|
||||||
|
},
|
||||||
|
"pi-subagents": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "node",
|
||||||
|
"args": ["scripts/mcp/pi-subagents-mcp.mjs"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Route PI analysis/review/test subagent work through scripts/pi_subagents.py (not Cursor native agents).
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# PI subagents(工程工具)
|
||||||
|
|
||||||
|
本机 PI 子任务走 `scripts/pi_subagents.py` + 模型配置 `.doc/ops/2026-09-24-subagent-model-config.md`。
|
||||||
|
|
||||||
|
## 自动派发(主 Agent)
|
||||||
|
|
||||||
|
1. **预览配置**:`pi_subagent_roles` / CLI `roles`(不调模型)。
|
||||||
|
2. **按阶段选角色**:research → planning → reviews → diagnosis → test_run / acceptance_check → report;实施/修复仍由 Cursor Auto。
|
||||||
|
3. **派发 `run`**:携带 task_id、role_id、唯一 request_id、goal、authorization_ref、冻结 source_sha、input_files、timeout;test 角色另附 approved_commands + plan_digest。
|
||||||
|
4. **租约**:报告路径 `path:.doc/test-reports/assets/pi-subagents/<task>/<request>`;裸调拒绝。
|
||||||
|
5. **取回**:`pi_subagent_result` / CLI `result`;`run_status` 与 `verdict`/`review_state` 分离——模型成功仅 `pending`,测试 pass 仅来自批准命令证据。
|
||||||
|
6. **门禁**:审查/测试结论须带回报告路径与 receipt 字段;不得用模型叙事冒充 PASS。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- **Cursor Auto**:唯一代码实施车道;本规则不把 implementation 派给 PI。
|
||||||
|
- **禁止**:自动换模型、M3 关键角色、伪造 lease、递归把本修复任务再派给 PI worker、`pkill -f` 广谱杀进程。
|
||||||
|
- **MCP**:`.cursor/mcp.json` → `pi-subagents`(roles/run/result);executor 来自 MCP 安装根。
|
||||||
@@ -6,13 +6,53 @@ Zero-dependency Node MCP wrappers used by Cursor (see [`.cursor/mcp.json`](../..
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `agentctl` | `agentctl-mcp.mjs` | Lease / docs apply / validate / queue |
|
| `agentctl` | `agentctl-mcp.mjs` | Lease / docs apply / validate / queue |
|
||||||
| `jev` | `jev-mcp.mjs` | Typed decide/health against local or remote jev-service |
|
| `jev` | `jev-mcp.mjs` | Typed decide/health against local or remote jev-service |
|
||||||
|
| `pi-subagents` | `pi-subagents-mcp.mjs` | Local PI subagent roles/run/result (engineering tool; not product Runner) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from repo root
|
# from repo root
|
||||||
node scripts/mcp/agentctl-mcp.mjs
|
node scripts/mcp/agentctl-mcp.mjs
|
||||||
node scripts/mcp/jev-mcp.mjs
|
node scripts/mcp/jev-mcp.mjs
|
||||||
|
node scripts/mcp/pi-subagents-mcp.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## pi-subagents
|
||||||
|
|
||||||
|
Markdown model config is the sole editable source: `.doc/ops/2026-09-24-subagent-model-config.md`.
|
||||||
|
|
||||||
|
| Tool | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `pi_subagent_roles` | Read-only catalog; never invokes PI; executor binary from **this server's INSTALL_ROOT** |
|
||||||
|
| `pi_subagent_run` | Non-blocking; request JSON in OS tmp; wraps **discovered main worktree** `scripts/agentctl.py with` (governance SSOT lease entry) + **INSTALL_ROOT** `scripts/pi_subagents.py run` |
|
||||||
|
| `pi_subagent_result` | Read-only receipt; `running` is not `isError`; terminal failure/`UNKNOWN_REQUEST` after a settled supervisor rejection surfaces the child's typed error (ROLE_DISABLED, LEASE_REQUIRED, …) |
|
||||||
|
|
||||||
|
**Install vs main:** When the MCP server lives in a linked task worktree, INSTALL_ROOT may differ from the repo main tip. Cross-repo worktrees (different `git-common-dir`) are refused. Optional `config` is worktree-relative registered model config only. `request.config_path` is rejected (use tool `config`).
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -B scripts/pi_subagents.py roles --config .doc/ops/2026-09-24-subagent-model-config.md
|
||||||
|
python3 -B scripts/agentctl.py with --task <id> \
|
||||||
|
--resource path:.doc/test-reports/assets/pi-subagents/<id>/<request_id> -- \
|
||||||
|
python3 -B scripts/pi_subagents.py run --request /path/to/req.json \
|
||||||
|
--config .doc/ops/2026-09-24-subagent-model-config.md
|
||||||
|
python3 -B scripts/pi_subagents.py result --task <id> --request-id <request_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The executor resolves `agentctl` helpers from the git main worktree scripts directory (not a stale colocated copy beside itself when running from a feature worktree).
|
||||||
|
|
||||||
|
Scoped reads: `scripts/pi-subagent-readonly.ts` — builtin `path` only; `@`/`~` denied; directory walks must contain only allowlisted regular files. Approved tests use a separate snapshot; model evidence stays pristine.
|
||||||
|
|
||||||
|
### Cursor CLI compatibility (pi-subagents)
|
||||||
|
|
||||||
|
Real Cursor CLI `2026.09.18` (and `2026.09.23` verified here) does **not** expand `${workspaceFolder}` in MCP `args`. The `pi-subagents` entry therefore uses workspace-relative `args: ["scripts/mcp/pi-subagents-mcp.mjs"]` with `type: "stdio"` / `command: "node"`. Absolute `${workspaceFolder}/…` paths fail with `Cannot find module '…/${workspaceFolder}/scripts/mcp/pi-subagents-mcp.mjs'`. After `agent mcp enable pi-subagents`, `agent mcp list-tools pi-subagents` must report exactly three tools: `pi_subagent_roles`, `pi_subagent_run`, `pi_subagent_result`. Older `agentctl` / `jev` entries may still use `${workspaceFolder}` until separately corrected.
|
||||||
|
|
||||||
|
### Limitations (v1)
|
||||||
|
|
||||||
|
- No shared environment ops (`SHARED_ENV_UNSUPPORTED`).
|
||||||
|
- Process-group governance, not an OS sandbox.
|
||||||
|
- Unit suite uses fake PI; Cursor UI MCP connectivity requires reload after checkout and host smoke.
|
||||||
|
- Host owns live paid-model checks.
|
||||||
|
|
||||||
## jev credentials (not in git)
|
## jev credentials (not in git)
|
||||||
|
|
||||||
- `~/.secrets/jev/api.key` (chmod 600) or env `JEV_API_KEY`
|
- `~/.secrets/jev/api.key` (chmod 600) or env `JEV_API_KEY`
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* pi-subagents MCP (stdio, zero deps).
|
||||||
|
* Tools: pi_subagent_roles, pi_subagent_run, pi_subagent_result.
|
||||||
|
*
|
||||||
|
* - Trusted executor: INSTALL_ROOT/scripts/pi_subagents.py (this server install).
|
||||||
|
* - Authoritative agentctl: discovered main worktree scripts/agentctl.py (governance SSOT).
|
||||||
|
* - worktree must share git-common-dir with INSTALL_ROOT.
|
||||||
|
* - Request JSON only under private OS tmp (no pre-lease repo writes).
|
||||||
|
*/
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const INSTALL_ROOT = path.resolve(__dirname, '../..');
|
||||||
|
const MAX_PAYLOAD = 2 * 1024 * 1024;
|
||||||
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
|
||||||
|
const active = new Map(); // key -> { child, digest, stdout, stderr, exitCode, rejection }
|
||||||
|
|
||||||
|
function gitCommonDir(cwd) {
|
||||||
|
const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (r.status !== 0) return null;
|
||||||
|
return r.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitMainWorktree(cwd) {
|
||||||
|
const list = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
||||||
|
if (list.status !== 0) return null;
|
||||||
|
const first = /^worktree (.+)$/m.exec(list.stdout || '');
|
||||||
|
return first ? first[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateId(value, label) {
|
||||||
|
if (typeof value !== 'string' || !ID_RE.test(value) || value === '.' || value === '..') {
|
||||||
|
throw new Error(`invalid ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRepo(worktree) {
|
||||||
|
if (!worktree || typeof worktree !== 'string') {
|
||||||
|
throw new Error('worktree is required');
|
||||||
|
}
|
||||||
|
const wt = path.resolve(worktree);
|
||||||
|
const common = gitCommonDir(wt);
|
||||||
|
if (!common) throw new Error(`not a git worktree: ${wt}`);
|
||||||
|
const installCommon = gitCommonDir(INSTALL_ROOT);
|
||||||
|
if (!installCommon) throw new Error('INSTALL_ROOT is not a git worktree');
|
||||||
|
if (common !== installCommon) {
|
||||||
|
throw new Error('worktree does not share INSTALL_ROOT git-common-dir (cross-repo refused)');
|
||||||
|
}
|
||||||
|
// Authoritative main = first worktree list entry; executor stays at INSTALL_ROOT.
|
||||||
|
const discovered = gitMainWorktree(INSTALL_ROOT) || INSTALL_ROOT;
|
||||||
|
return { main: discovered, discoveredMain: discovered, worktree: wt, common };
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentctlPath(main) {
|
||||||
|
return path.join(main, 'scripts', 'agentctl.py');
|
||||||
|
}
|
||||||
|
|
||||||
|
function executorPath() {
|
||||||
|
return path.join(INSTALL_ROOT, 'scripts', 'pi_subagents.py');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeConfigPath(worktree, config) {
|
||||||
|
const defaultRel = '.doc/ops/2026-09-24-subagent-model-config.md';
|
||||||
|
const rel = config || defaultRel;
|
||||||
|
if (typeof rel !== 'string' || !rel.trim()) throw new Error('invalid config path');
|
||||||
|
if (path.isAbsolute(rel)) throw new Error('config must be worktree-relative');
|
||||||
|
if (rel.includes('\0') || rel.split(path.sep).includes('..')) {
|
||||||
|
throw new Error('config path traversal refused');
|
||||||
|
}
|
||||||
|
const abs = path.resolve(worktree, rel);
|
||||||
|
const normWt = worktree.endsWith(path.sep) ? worktree : worktree + path.sep;
|
||||||
|
if (!(abs === worktree || abs.startsWith(normWt))) {
|
||||||
|
throw new Error('config escapes worktree');
|
||||||
|
}
|
||||||
|
// refuse symlink config
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(abs).isSymbolicLink()) throw new Error('config symlink refused');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message === 'config symlink refused') throw e;
|
||||||
|
// missing checked by executor
|
||||||
|
}
|
||||||
|
// only the registered config file name
|
||||||
|
if (path.basename(abs) !== '2026-09-24-subagent-model-config.md') {
|
||||||
|
throw new Error('config must be the registered model config file');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadDigest(request) {
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSync(argv, { cwd, timeout = 120000, env } = {}) {
|
||||||
|
const r = spawnSync(argv[0], argv.slice(1), {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
maxBuffer: 8 * 1024 * 1024,
|
||||||
|
env: env || process.env,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: r.status === 0,
|
||||||
|
code: r.status ?? 1,
|
||||||
|
stdout: r.stdout || '',
|
||||||
|
stderr: r.stderr || '',
|
||||||
|
error: r.error ? String(r.error) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(payload) {
|
||||||
|
const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
||||||
|
return { content: [{ type: 'text', text: body }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errResult(msg) {
|
||||||
|
const body = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
||||||
|
return { content: [{ type: 'text', text: body }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLine(text) {
|
||||||
|
const lines = String(text || '').split(/\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(lines[i]);
|
||||||
|
} catch {
|
||||||
|
/* continue */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeErr(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
|
||||||
|
.slice(0, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killActiveTree(meta) {
|
||||||
|
if (!meta?.child) return;
|
||||||
|
const child = meta.child;
|
||||||
|
try {
|
||||||
|
if (child.pid) {
|
||||||
|
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* not a group */ }
|
||||||
|
try { child.kill('SIGTERM'); } catch { /* */ }
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (child.exitCode === null && child.pid) {
|
||||||
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* */ }
|
||||||
|
try { child.kill('SIGKILL'); } catch { /* */ }
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reapAll() {
|
||||||
|
for (const meta of active.values()) {
|
||||||
|
killActiveTree(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: 'pi_subagent_roles',
|
||||||
|
description: 'Read-only: list PI subagent roles/models from markdown config (does not invoke PI)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
worktree: { type: 'string', description: 'Absolute path to registered task worktree' },
|
||||||
|
task_id: { type: 'string' },
|
||||||
|
config: { type: 'string', description: 'Optional config path relative to worktree' },
|
||||||
|
},
|
||||||
|
required: ['worktree', 'task_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pi_subagent_run',
|
||||||
|
description: 'Start one PI subagent run under main-worktree agentctl lease (non-blocking)',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
worktree: { type: 'string' },
|
||||||
|
task_id: { type: 'string' },
|
||||||
|
request: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'PI subagent request (task_id optional; injected from outer task_id)',
|
||||||
|
properties: {
|
||||||
|
task_id: { type: 'string', description: 'Optional; must match outer task_id when set' },
|
||||||
|
role_id: { type: 'string' },
|
||||||
|
request_id: { type: 'string' },
|
||||||
|
goal: { type: 'string' },
|
||||||
|
authorization_ref: { type: 'string' },
|
||||||
|
source_sha: { type: 'string', description: 'Full commit hash' },
|
||||||
|
input_files: { type: 'array', items: { type: 'string' } },
|
||||||
|
timeout_seconds: { type: 'integer' },
|
||||||
|
approved_commands: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'array', items: { type: 'string' } },
|
||||||
|
description: 'test_run/acceptance_check only',
|
||||||
|
},
|
||||||
|
plan_digest: { type: 'string', description: 'sha256 hex for test roles' },
|
||||||
|
model_override: { type: 'object' },
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'role_id', 'request_id', 'goal', 'authorization_ref',
|
||||||
|
'source_sha', 'input_files', 'timeout_seconds',
|
||||||
|
],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
config: { type: 'string', description: 'Worktree-relative registered model config path' },
|
||||||
|
},
|
||||||
|
required: ['worktree', 'task_id', 'request'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pi_subagent_result',
|
||||||
|
description: 'Read durable receipt for a request_id (does not invoke PI). running is not isError; terminal failure is.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
worktree: { type: 'string' },
|
||||||
|
task_id: { type: 'string' },
|
||||||
|
request_id: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['worktree', 'task_id', 'request_id'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function callTool(name, args = {}) {
|
||||||
|
try {
|
||||||
|
if (name === 'pi_subagent_roles') {
|
||||||
|
validateId(args.task_id, 'task_id');
|
||||||
|
const { worktree } = resolveRepo(args.worktree);
|
||||||
|
const config = safeConfigPath(worktree, args.config);
|
||||||
|
const r = runSync(['python3', '-B', executorPath(), 'roles', '--config', config], {
|
||||||
|
cwd: worktree,
|
||||||
|
});
|
||||||
|
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
||||||
|
if (!r.ok) return errResult(payload || sanitizeErr(r.stderr || r.stdout || r.error));
|
||||||
|
return textResult(payload);
|
||||||
|
}
|
||||||
|
if (name === 'pi_subagent_result') {
|
||||||
|
validateId(args.task_id, 'task_id');
|
||||||
|
validateId(args.request_id, 'request_id');
|
||||||
|
const { worktree } = resolveRepo(args.worktree);
|
||||||
|
const key = `${args.task_id}/${args.request_id}`;
|
||||||
|
const meta = active.get(key);
|
||||||
|
const r = runSync([
|
||||||
|
'python3', '-B', executorPath(), 'result',
|
||||||
|
'--task', args.task_id, '--request-id', args.request_id,
|
||||||
|
], { cwd: worktree });
|
||||||
|
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
||||||
|
|
||||||
|
const surfaceRejection = () => {
|
||||||
|
if (meta?.rejection) return errResult(meta.rejection);
|
||||||
|
if (meta && meta.exitCode !== null && meta.exitCode !== 0) {
|
||||||
|
const errPayload = parseJsonLine(meta.stderr()) || parseJsonLine(meta.stdout()) || {
|
||||||
|
ok: false,
|
||||||
|
code: 'SUPERVISOR_FAILED',
|
||||||
|
message: sanitizeErr(meta.stderr() || meta.stdout() || 'supervisor failed'),
|
||||||
|
exit_code: meta.exitCode,
|
||||||
|
};
|
||||||
|
return errResult(errPayload);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
const rej = surfaceRejection();
|
||||||
|
if (rej) return rej;
|
||||||
|
if (meta && meta.exitCode === null) {
|
||||||
|
return textResult({
|
||||||
|
ok: true,
|
||||||
|
run_status: 'running',
|
||||||
|
task_id: args.task_id,
|
||||||
|
request_id: args.request_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errResult({
|
||||||
|
ok: false,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: sanitizeErr(r.stderr || r.stdout || r.error || 'no receipt'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Settled supervisor rejection with no durable receipt (UNKNOWN_REQUEST).
|
||||||
|
if (payload.code === 'UNKNOWN_REQUEST') {
|
||||||
|
const rej = surfaceRejection();
|
||||||
|
if (rej) return rej;
|
||||||
|
// Child still in flight: do not treat as terminal unknown.
|
||||||
|
if (meta && meta.exitCode === null) {
|
||||||
|
return textResult({
|
||||||
|
ok: true,
|
||||||
|
run_status: 'running',
|
||||||
|
task_id: args.task_id,
|
||||||
|
request_id: args.request_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errResult(payload);
|
||||||
|
}
|
||||||
|
if (!r.ok && payload.code && payload.code !== 'UNKNOWN_REQUEST') {
|
||||||
|
return errResult(payload);
|
||||||
|
}
|
||||||
|
// In-flight: never isError
|
||||||
|
if (payload.run_status === 'running') {
|
||||||
|
return textResult(payload);
|
||||||
|
}
|
||||||
|
if (payload.ok === false || (payload.run_status && payload.run_status !== 'succeeded')) {
|
||||||
|
return errResult(payload);
|
||||||
|
}
|
||||||
|
return textResult(payload);
|
||||||
|
}
|
||||||
|
if (name === 'pi_subagent_run') {
|
||||||
|
validateId(args.task_id, 'task_id');
|
||||||
|
const { main, worktree } = resolveRepo(args.worktree);
|
||||||
|
const request = args.request;
|
||||||
|
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||||
|
return errResult({ ok: false, code: 'INVALID_REQUEST', message: 'request object required' });
|
||||||
|
}
|
||||||
|
if (request.task_id && request.task_id !== args.task_id) {
|
||||||
|
return errResult({ ok: false, code: 'TASK_MISMATCH', message: 'request.task_id must match task_id' });
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(request, 'config_path')) {
|
||||||
|
return errResult({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_REQUEST',
|
||||||
|
message: 'request.config_path is not supported; use tool config argument',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request.task_id = args.task_id;
|
||||||
|
const requestId = request.request_id;
|
||||||
|
try {
|
||||||
|
validateId(requestId, 'request_id');
|
||||||
|
} catch (e) {
|
||||||
|
return errResult({ ok: false, code: 'INVALID_REQUEST', message: String(e.message || e) });
|
||||||
|
}
|
||||||
|
const digest = payloadDigest(request);
|
||||||
|
const key = `${args.task_id}/${requestId}`;
|
||||||
|
const existing = active.get(key);
|
||||||
|
if (existing && existing.exitCode === null) {
|
||||||
|
if (existing.digest === digest) {
|
||||||
|
return textResult({
|
||||||
|
ok: true, accepted: true, running: true,
|
||||||
|
task_id: args.task_id, request_id: requestId, request_digest: digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errResult({
|
||||||
|
ok: false,
|
||||||
|
code: 'REQUEST_DIGEST_MISMATCH',
|
||||||
|
message: 'active request_id with different payload',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = safeConfigPath(worktree, args.config);
|
||||||
|
} catch (e) {
|
||||||
|
return errResult({ ok: false, code: 'CONFIG_INVALID', message: String(e.message || e) });
|
||||||
|
}
|
||||||
|
const resource = `path:.doc/test-reports/assets/pi-subagents/${args.task_id}/${requestId}`;
|
||||||
|
// Private OS tmp only — never write into worktree before lease.
|
||||||
|
let tmpDir;
|
||||||
|
let reqPath;
|
||||||
|
try {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-mcp-'));
|
||||||
|
reqPath = path.join(tmpDir, `${requestId}.json`);
|
||||||
|
fs.writeFileSync(reqPath, JSON.stringify(request), { mode: 0o600 });
|
||||||
|
} catch (e) {
|
||||||
|
return errResult({
|
||||||
|
ok: false,
|
||||||
|
code: 'TMP_WRITE_FAILED',
|
||||||
|
message: sanitizeErr(String(e.message || e)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
child = spawn(
|
||||||
|
'python3',
|
||||||
|
[
|
||||||
|
'-B', agentctlPath(main),
|
||||||
|
'with', '--task', args.task_id, '--resource', resource, '--',
|
||||||
|
'python3', '-B', executorPath(),
|
||||||
|
'run', '--request', reqPath, '--config', config,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: worktree,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: true, // own process group for cleanup
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||||
|
return errResult({
|
||||||
|
ok: false,
|
||||||
|
code: 'SPAWN_FAILED',
|
||||||
|
message: sanitizeErr(String(e.message || e)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (c) => {
|
||||||
|
stdout += c.toString('utf8');
|
||||||
|
if (stdout.length > MAX_PAYLOAD) stdout = stdout.slice(-MAX_PAYLOAD);
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (c) => {
|
||||||
|
stderr += c.toString('utf8');
|
||||||
|
if (stderr.length > MAX_PAYLOAD) stderr = stderr.slice(-MAX_PAYLOAD);
|
||||||
|
});
|
||||||
|
const meta = {
|
||||||
|
child,
|
||||||
|
digest,
|
||||||
|
stdout: () => stdout,
|
||||||
|
stderr: () => stderr,
|
||||||
|
exitCode: null,
|
||||||
|
rejection: null,
|
||||||
|
tmpDir,
|
||||||
|
reqPath,
|
||||||
|
};
|
||||||
|
child.on('error', (err) => {
|
||||||
|
meta.exitCode = 1;
|
||||||
|
meta.rejection = {
|
||||||
|
ok: false,
|
||||||
|
code: 'SPAWN_FAILED',
|
||||||
|
message: sanitizeErr(String(err.message || err)),
|
||||||
|
};
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
meta.exitCode = code;
|
||||||
|
if (code && code !== 0) {
|
||||||
|
const parsed = parseJsonLine(stderr) || parseJsonLine(stdout);
|
||||||
|
if (parsed && parsed.code) {
|
||||||
|
meta.rejection = {
|
||||||
|
ok: false,
|
||||||
|
code: parsed.code,
|
||||||
|
message: sanitizeErr(parsed.message || parsed.code),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
meta.rejection = {
|
||||||
|
ok: false,
|
||||||
|
code: 'SUPERVISOR_FAILED',
|
||||||
|
message: sanitizeErr(stderr || stdout || `exit ${code}`),
|
||||||
|
exit_code: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||||||
|
});
|
||||||
|
active.set(key, meta);
|
||||||
|
return textResult({
|
||||||
|
ok: true,
|
||||||
|
accepted: true,
|
||||||
|
running: true,
|
||||||
|
task_id: args.task_id,
|
||||||
|
request_id: requestId,
|
||||||
|
request_digest: digest,
|
||||||
|
resource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errResult(`unknown tool: ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
return errResult({ ok: false, code: 'MCP_ERROR', message: sanitizeErr(String(e?.message || e)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(msg) {
|
||||||
|
const body = JSON.stringify(msg);
|
||||||
|
if (Buffer.byteLength(body, 'utf8') > MAX_PAYLOAD) {
|
||||||
|
const id = msg?.id;
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id, error: { code: -32600, message: 'response too large' },
|
||||||
|
}) + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stdout.write(body + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(msg) {
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
if (msg.method && msg.id === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id, method, params } = msg;
|
||||||
|
try {
|
||||||
|
if (method === 'initialize') {
|
||||||
|
send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: params?.protocolVersion || '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'pi-subagents', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'tools/call') {
|
||||||
|
const name = params?.name;
|
||||||
|
const args = params?.arguments || {};
|
||||||
|
const raw = JSON.stringify(args);
|
||||||
|
if (Buffer.byteLength(raw, 'utf8') > MAX_PAYLOAD) {
|
||||||
|
send({ jsonrpc: '2.0', id, result: errResult('arguments exceed payload bound') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = callTool(name, args);
|
||||||
|
send({ jsonrpc: '2.0', id, result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'ping') {
|
||||||
|
send({ jsonrpc: '2.0', id, result: {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||||
|
} catch (e) {
|
||||||
|
send({ jsonrpc: '2.0', id, error: { code: -32000, message: String(e?.message || e) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
if (buf.length + chunk.length > MAX_PAYLOAD * 4) {
|
||||||
|
buf = Buffer.alloc(0);
|
||||||
|
process.stderr.write('payload overflow; reset buffer\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
while (true) {
|
||||||
|
const headerEnd = buf.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd !== -1) {
|
||||||
|
const header = buf.slice(0, headerEnd).toString('utf8');
|
||||||
|
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
||||||
|
if (!m) {
|
||||||
|
buf = buf.slice(headerEnd + 4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const len = Number(m[1]);
|
||||||
|
if (len > MAX_PAYLOAD) {
|
||||||
|
buf = buf.slice(headerEnd + 4);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = headerEnd + 4;
|
||||||
|
if (buf.length < start + len) return;
|
||||||
|
const body = buf.slice(start, start + len).toString('utf8');
|
||||||
|
buf = buf.slice(start + len);
|
||||||
|
try { handle(JSON.parse(body)); } catch (e) {
|
||||||
|
process.stderr.write(`parse error: ${e}\n`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nl = buf.indexOf('\n');
|
||||||
|
if (nl === -1) return;
|
||||||
|
const line = buf.slice(0, nl).toString('utf8').trim();
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
if (!line || line.startsWith('Content-Length')) continue;
|
||||||
|
try { handle(JSON.parse(line)); } catch (e) {
|
||||||
|
process.stderr.write(`ndjson parse error: ${e}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal) {
|
||||||
|
process.stderr.write(`pi-subagents-mcp shutting down (${signal})\n`);
|
||||||
|
reapAll();
|
||||||
|
setTimeout(() => process.exit(0), 500).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.on('end', () => { reapAll(); process.exit(0); });
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* PI subagent readonly scope guard.
|
||||||
|
*
|
||||||
|
* Deny-by-default: only read/grep/find/ls. Builtin schema uses a single `path`
|
||||||
|
* field (read requires it; grep/find/ls default to "."). Reject unknown
|
||||||
|
* path-carrier fields. Enforce snapshot realpath + selected-file allowlist;
|
||||||
|
* directory targets must contain only allowlisted regular files (bounded walk).
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ALLOWED_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
||||||
|
const UNKNOWN_PATH_KEYS = new Set([
|
||||||
|
"file", "filename", "directory", "dir", "root", "cwd", "paths",
|
||||||
|
]);
|
||||||
|
const MAX_WALK_ENTRIES = 256;
|
||||||
|
|
||||||
|
function snapshotRoot(): string | null {
|
||||||
|
const raw = process.env.PI_SUBAGENT_SNAPSHOT_ROOT;
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedFiles(): Set<string> | null {
|
||||||
|
const raw = process.env.PI_SUBAGENT_ALLOWED_FILES;
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.map((p: unknown) => String(p)));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject PI resolveToCwd / home-expansion inputs before any join. */
|
||||||
|
function forbiddenPrefix(raw: string): string | null {
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^\uFEFF/, "")
|
||||||
|
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "");
|
||||||
|
const trimmed = cleaned.trim();
|
||||||
|
if (!trimmed) return "empty path";
|
||||||
|
if (trimmed.startsWith("@") || trimmed.startsWith("~")) {
|
||||||
|
return "@ or ~ prefix refused";
|
||||||
|
}
|
||||||
|
if (/^[\uFF20\u0040]/.test(trimmed) || /^[\uFF5E\u007E]/.test(trimmed)) {
|
||||||
|
return "unicode @/~ lookalike refused";
|
||||||
|
}
|
||||||
|
if (trimmed.includes("\0") || trimmed.includes("\n") || trimmed.includes("\r")) {
|
||||||
|
return "illegal characters";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contained(root: string, candidate: string): { ok: boolean; resolved?: string; reason?: string } {
|
||||||
|
const prefixErr = forbiddenPrefix(candidate);
|
||||||
|
if (prefixErr) return { ok: false, reason: prefixErr };
|
||||||
|
|
||||||
|
const abs = path.isAbsolute(candidate)
|
||||||
|
? path.normalize(candidate)
|
||||||
|
: path.resolve(root, candidate);
|
||||||
|
|
||||||
|
let cur = abs;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(cur).isSymbolicLink()) {
|
||||||
|
return { ok: false, reason: "symlink segment refused" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* missing leaf checked below */
|
||||||
|
}
|
||||||
|
const parent = path.dirname(cur);
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
return { ok: false, reason: "path does not exist" };
|
||||||
|
}
|
||||||
|
const st = fs.lstatSync(abs);
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
return { ok: false, reason: "symlink target refused" };
|
||||||
|
}
|
||||||
|
resolved = fs.realpathSync(abs);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "realpath failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.realpathSync(resolved) !== resolved) {
|
||||||
|
return { ok: false, reason: "realpath unstable" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "realpath recheck failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normRoot = root.endsWith(path.sep) ? root : root + path.sep;
|
||||||
|
if (!(resolved === root || resolved.startsWith(normRoot))) {
|
||||||
|
return { ok: false, reason: "escapes snapshot root" };
|
||||||
|
}
|
||||||
|
return { ok: true, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relUnder(root: string, resolved: string): string | null {
|
||||||
|
const rel = path.relative(root, resolved);
|
||||||
|
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileAllowlisted(root: string, resolved: string, allow: Set<string> | null): boolean {
|
||||||
|
if (!allow) return true;
|
||||||
|
const rel = relUnder(root, resolved);
|
||||||
|
return !!rel && allow.has(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory listing/search: every regular file under path must be allowlisted. */
|
||||||
|
function directoryAllowOk(root: string, resolved: string, allow: Set<string> | null): { ok: boolean; reason?: string } {
|
||||||
|
if (!allow) return { ok: true };
|
||||||
|
let count = 0;
|
||||||
|
const stack = [resolved];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "directory unreadable" };
|
||||||
|
}
|
||||||
|
for (const ent of entries) {
|
||||||
|
count += 1;
|
||||||
|
if (count > MAX_WALK_ENTRIES) {
|
||||||
|
return { ok: false, reason: "directory walk exceeded bound" };
|
||||||
|
}
|
||||||
|
const child = path.join(dir, ent.name);
|
||||||
|
let st: fs.Stats;
|
||||||
|
try {
|
||||||
|
st = fs.lstatSync(child);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: "lstat failed" };
|
||||||
|
}
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
return { ok: false, reason: "symlink descendant refused" };
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
stack.push(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!st.isFile()) {
|
||||||
|
return { ok: false, reason: "special file refused" };
|
||||||
|
}
|
||||||
|
if (!fileAllowlisted(root, fs.realpathSync(child), allow)) {
|
||||||
|
return { ok: false, reason: "directory contains non-allowlisted file" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exact snapshot root: path.relative(root,root) is "" so relUnder is null.
|
||||||
|
// Inventory above already enforced allowlist; permit root listing/search
|
||||||
|
// (including empty allowlisted snapshot). fileAllowlisted(root,root) stays false.
|
||||||
|
if (resolved === root) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
// Empty dir is ok only if it is an ancestor of some allowlisted file
|
||||||
|
const rel = relUnder(root, resolved);
|
||||||
|
if (rel === null) return { ok: false, reason: "directory outside root" };
|
||||||
|
if (count === 0) {
|
||||||
|
for (const f of allow) {
|
||||||
|
if (f === rel || f.startsWith(rel + path.sep)) return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, reason: "empty directory not covering allowlist" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function piSubagentReadonly(pi: {
|
||||||
|
on: (event: string, handler: (event: any, ctx: any) => any) => void;
|
||||||
|
}) {
|
||||||
|
pi.on("tool_call", async (event: { toolName: string; input?: Record<string, unknown> }) => {
|
||||||
|
const name = event.toolName;
|
||||||
|
if (!ALLOWED_TOOLS.has(name)) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: `Tool "${name}" is denied by pi-subagent-readonly (allowlist: read,grep,find,ls)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const root = snapshotRoot();
|
||||||
|
if (!root) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: "PI_SUBAGENT_SNAPSHOT_ROOT is unset; refusing all file tools",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const allow = allowedFiles();
|
||||||
|
const input = event.input || {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (UNKNOWN_PATH_KEYS.has(key)) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: `Unsupported path carrier field "${key}" (builtin tools use path only)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read requires path; grep/find/ls default to "."
|
||||||
|
let rawPath: string;
|
||||||
|
if (typeof input.path === "string" && input.path.trim()) {
|
||||||
|
rawPath = input.path;
|
||||||
|
} else if (name === "read") {
|
||||||
|
return { block: true, reason: 'read requires input.path' };
|
||||||
|
} else {
|
||||||
|
rawPath = ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.basename(rawPath.replace(/^[@~]+/, ""));
|
||||||
|
if (
|
||||||
|
base === ".env" ||
|
||||||
|
base.startsWith(".env.") ||
|
||||||
|
base === "auth.json" ||
|
||||||
|
rawPath.split(path.sep).includes(".git") ||
|
||||||
|
rawPath.split(path.sep).includes(".secrets") ||
|
||||||
|
rawPath.split(path.sep).includes("data")
|
||||||
|
) {
|
||||||
|
return { block: true, reason: `Path "${rawPath}" is denied by policy` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = contained(root, rawPath);
|
||||||
|
if (!check.ok || !check.resolved) {
|
||||||
|
return {
|
||||||
|
block: true,
|
||||||
|
reason: `Path "${rawPath}" refused (${check.reason || "policy"})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let st: fs.Stats;
|
||||||
|
try {
|
||||||
|
st = fs.lstatSync(check.resolved);
|
||||||
|
} catch {
|
||||||
|
return { block: true, reason: "stat failed" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
const dirOk = directoryAllowOk(root, check.resolved, allow);
|
||||||
|
if (!dirOk.ok) {
|
||||||
|
return { block: true, reason: dirOk.reason || "directory allowlist failed" };
|
||||||
|
}
|
||||||
|
} else if (st.isFile()) {
|
||||||
|
if (!fileAllowlisted(root, check.resolved, allow)) {
|
||||||
|
return { block: true, reason: `Path "${rawPath}" not in selected-file allowlist` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { block: true, reason: "special file refused" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize the only supported field
|
||||||
|
input.path = check.resolved;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user