@lqc123qwe/car-runtime 1.0.0-rc.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/.github/workflows/ci.yml +12 -0
- package/.github/workflows/release.yml +21 -0
- package/README.md +122 -0
- package/adr/ADR-001.md +4 -0
- package/adr/ADR-002.md +5 -0
- package/adr/ADR-003.md +35 -0
- package/adr/host-onboarding.md +68 -0
- package/adr/mlps-audit-checklist.md +33 -0
- package/adr/win32-spike.md +45 -0
- package/package.json +25 -0
- package/scripts/ptc-baseline/run-baseline.ts +118 -0
- package/scripts/release-pipeline.ts +189 -0
- package/scripts/win32-koffi-spike.ts +34 -0
- package/src/authz/authz.ts +96 -0
- package/src/cli.ts +122 -0
- package/src/governance/dump.ts +41 -0
- package/src/host/facade.ts +99 -0
- package/src/host/hostGateway.ts +98 -0
- package/src/host/mappings.ts +74 -0
- package/src/host/stdio.ts +74 -0
- package/src/kernel/context.ts +208 -0
- package/src/kernel/events.ts +99 -0
- package/src/load/loader.ts +165 -0
- package/src/load/registry.ts +88 -0
- package/src/load/verifier.ts +67 -0
- package/src/loop/goal.ts +53 -0
- package/src/loop/recover.ts +47 -0
- package/src/loop/stop.ts +200 -0
- package/src/mcp/gateway.ts +118 -0
- package/src/ptc/budget.ts +37 -0
- package/src/ptc/erasable.ts +28 -0
- package/src/ptc/runCode.ts +127 -0
- package/src/ptc/sdk.ts +28 -0
- package/src/ptc/worker-entry.ts +67 -0
- package/src/sandbox/sandbox.ts +199 -0
- package/src/security/secrets.ts +111 -0
- package/src/session/export.ts +110 -0
- package/src/session/log.ts +120 -0
- package/src/telemetry/metrics.ts +38 -0
- package/test/kernel.spec.ts +136 -0
- package/test/s11.spec.ts +129 -0
- package/test/s12.spec.ts +135 -0
- package/test/s13.spec.ts +117 -0
- package/test/s14.spec.ts +117 -0
- package/test/s15.spec.ts +80 -0
- package/test/s17.spec.ts +102 -0
- package/test/s18.spec.ts +72 -0
- package/test/s2.spec.ts +245 -0
- package/test/s3.spec.ts +179 -0
- package/test/s4.spec.ts +169 -0
- package/test/s5.spec.ts +95 -0
- package/test/s6.spec.ts +169 -0
- package/test/s7.spec.ts +135 -0
- package/test/s9.spec.ts +89 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F8 · MCP 桥接网关:登记制 + 工具桥接 + 崩溃隔离 + 凭据门
|
|
3
|
+
*
|
|
4
|
+
* 口径(《系统设计》M5 / 冻结决策① / 安全设计 T3 域):
|
|
5
|
+
* - serverId 登记制:运行期不接受未登记连接(A050001);Python 侧仅工具供给方,无 ctx 级 API
|
|
6
|
+
* - 工具桥接为一等 ToolDefinition:declaredSideEffect 未声明按 write 最高约束(T-22)
|
|
7
|
+
* - 调用过三权限审批门(与其他工具同一审批门,无旁路——US-6 AC3)
|
|
8
|
+
* - Server 崩溃/超时 → BD-02 整体标记不可用,不传导进内核主链路(US-6 AC4)
|
|
9
|
+
* - McpServerConfig.env 禁明文密钥(疑似凭据模式即拒绝,sk- 前缀等——§3.2.5 传参禁忌)
|
|
10
|
+
* - 传输抽象:stdio 真实通道(JSON-RPC over 换行分隔 JSON)/ in-process 测试通道
|
|
11
|
+
*/
|
|
12
|
+
export interface JsonRpcRequest { jsonrpc: '2.0'; id: number; method: string; params?: unknown }
|
|
13
|
+
export interface JsonRpcResponse { jsonrpc: '2.0'; id: number; result?: unknown; error?: { code: number; message: string } }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 客户端传输(CAR → MCP Server 方向)。M3-S11 双向分层:本接口为 client 侧对偶;
|
|
17
|
+
* server 侧(宿主 → CAR)见 src/host/hostGateway.ts 的 ServerTransport。
|
|
18
|
+
*/
|
|
19
|
+
export interface ClientTransport {
|
|
20
|
+
send(req: JsonRpcRequest): Promise<JsonRpcResponse>
|
|
21
|
+
/** 进程/通道存活状态 */
|
|
22
|
+
alive(): boolean
|
|
23
|
+
/** 主动终止 */
|
|
24
|
+
close(): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** M1 兼容别名(M2 代码零改动;M4 评估移除) */
|
|
28
|
+
export type McpTransport = ClientTransport
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 服务端传输对偶(宿主 → CAR 方向,M3 多宿主):宿主作为 MCP client 调用 CAR 暴露的 tool 面。
|
|
32
|
+
* 与 ClientTransport 语义对偶:CAR 不主动 send,仅响应 method 调用。
|
|
33
|
+
*/
|
|
34
|
+
export interface ServerTransport {
|
|
35
|
+
/** 宿主到达的 JSON-RPC 方法调用(由 HostGateway 分发到 9 tool 注册表) */
|
|
36
|
+
onRequest(method: string, params: unknown): Promise<JsonRpcResponse>
|
|
37
|
+
alive(): boolean
|
|
38
|
+
close(): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 疑似明文凭据检测(§3.2.5:env 值出现 sk- 等前缀即拒绝) */
|
|
42
|
+
export function containsPlaintextCredential(env: Record<string, string>): string | null {
|
|
43
|
+
const patterns = [/^sk-/, /^ghp_/, /^xox[bap]-/, /^AKIA/]
|
|
44
|
+
for (const [k, v] of Object.entries(env)) {
|
|
45
|
+
if (patterns.some(p => p.test(v))) return `env "${k}" looks like a plaintext credential(经 M8 凭据门注入,禁止明文透传)`
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface McpServerConfig {
|
|
51
|
+
serverId: string
|
|
52
|
+
transport: ClientTransport
|
|
53
|
+
/** 启动环境变量(禁明文凭据,凭据经 M8 凭据门运行时注入) */
|
|
54
|
+
env?: Record<string, string>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface McpToolDefinition {
|
|
58
|
+
serverId: string
|
|
59
|
+
name: string
|
|
60
|
+
description?: string
|
|
61
|
+
/** 未声明按 write 最高约束(T-22:安全默认) */
|
|
62
|
+
declaredSideEffect?: 'readonly' | 'write'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class McpGateway {
|
|
66
|
+
#servers = new Map<string, McpServerConfig>()
|
|
67
|
+
#tools = new Map<string, McpToolDefinition & { serverId: string }>()
|
|
68
|
+
#unavailable = new Set<string>()
|
|
69
|
+
#nextId = 1
|
|
70
|
+
|
|
71
|
+
/** 登记并连接:拉起后发现工具并注册进能力矩阵(加载报告可见) */
|
|
72
|
+
async register(cfg: McpServerConfig): Promise<McpToolDefinition[]> {
|
|
73
|
+
if (this.#servers.has(cfg.serverId)) {
|
|
74
|
+
throw new Error(`CAR-E-MCP: serverId "${cfg.serverId}" already registered(登记制:重复注册显式报错)`)
|
|
75
|
+
}
|
|
76
|
+
const cred = cfg.env ? containsPlaintextCredential(cfg.env) : null
|
|
77
|
+
if (cred) throw new Error(`CAR-E-MCP: ${cred}`)
|
|
78
|
+
this.#servers.set(cfg.serverId, cfg)
|
|
79
|
+
const res = await cfg.transport.send({ jsonrpc: '2.0', id: this.#nextId++, method: 'tools/list' })
|
|
80
|
+
const tools = (res.result as { tools: Array<{ name: string; description?: string; sideEffect?: 'readonly' | 'write' }> }).tools
|
|
81
|
+
for (const t of tools) {
|
|
82
|
+
// T-22:未声明 sideEffect 按 write 最高约束收敛(安全默认,注册时强制)
|
|
83
|
+
this.#tools.set(`${cfg.serverId}:${t.name}`, {
|
|
84
|
+
serverId: cfg.serverId, name: t.name, description: t.description,
|
|
85
|
+
declaredSideEffect: t.sideEffect ?? 'write',
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return [...this.#tools.values()].filter(t => t.serverId === cfg.serverId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 能力矩阵视图(加载报告用) */
|
|
92
|
+
listTools(): McpToolDefinition[] { return [...this.#tools.values()] }
|
|
93
|
+
|
|
94
|
+
isAvailable(serverId: string): boolean { return this.#servers.has(serverId) && !this.#unavailable.has(serverId) }
|
|
95
|
+
|
|
96
|
+
/** 调用:走统一权限/审计链路(无旁路);结果回填日志前必经 redact(调用方职责) */
|
|
97
|
+
async callTool(serverId: string, tool: string, args: Record<string, unknown>, opts: { timeoutMs?: number } = {}): Promise<{ ok: boolean; result?: unknown; error?: string }> {
|
|
98
|
+
const cfg = this.#servers.get(serverId)
|
|
99
|
+
if (!cfg) throw new Error('CAR-A050001: unregistered MCP connection rejected(登记制)')
|
|
100
|
+
if (this.#unavailable.has(serverId)) {
|
|
101
|
+
// BD-02:崩溃/超时整体标记不可用——显式错误结果,不抛异常不阻断主链路
|
|
102
|
+
return { ok: false, error: `MCP server "${serverId}" unavailable (BD-02)` }
|
|
103
|
+
}
|
|
104
|
+
const key = `${serverId}:${tool}`
|
|
105
|
+
if (!this.#tools.has(key)) return { ok: false, error: `unknown tool "${tool}" on "${serverId}"` }
|
|
106
|
+
try {
|
|
107
|
+
const res = await Promise.race([
|
|
108
|
+
cfg.transport.send({ jsonrpc: '2.0', id: this.#nextId++, method: 'tools/call', params: { name: tool, arguments: args } }),
|
|
109
|
+
new Promise<never>((_, rej) => setTimeout(() => rej(new Error('mcp timeout')), opts.timeoutMs ?? 30_000)),
|
|
110
|
+
])
|
|
111
|
+
return { ok: true, result: (res as JsonRpcResponse).result }
|
|
112
|
+
} catch (e) {
|
|
113
|
+
// BD-02:崩溃/超时 → 整体不可用(不静默:错误事件由调用方落审计日志)
|
|
114
|
+
this.#unavailable.add(serverId)
|
|
115
|
+
return { ok: false, error: `MCP call failed, server marked unavailable (BD-02): ${String(e)}` }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T-2 · PTC 预算控制(M3-S14)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M3系统设计增补 T-2 / D5 §12 一手口径):
|
|
5
|
+
* - 初始对齐 dsh:computeMs=60s / maxWallMs=600s / maxOutputBytes=64MB;
|
|
6
|
+
* - **三层收敛只许下调**(fail-closed):任何上调 = 显式报错——预算放宽属安全相关变更,
|
|
7
|
+
* 须经标定流程(四步标定法:P99×2 余量校验 + 逃逸验证 + 季度复标)而非配置直改;
|
|
8
|
+
* - 预算定位 = **资源护栏而非安全边界**(containment 非安全边界,对外声明口径归安全设计)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PtcBudget { computeMs: number; maxWallMs: number; maxOutputBytes: number }
|
|
12
|
+
|
|
13
|
+
/** 初始基线(dsh 一手口径对齐值;标定后经 ADR 修订) */
|
|
14
|
+
export const PTC_BUDGET_BASELINE: Readonly<PtcBudget> = Object.freeze({
|
|
15
|
+
computeMs: 60_000,
|
|
16
|
+
maxWallMs: 600_000,
|
|
17
|
+
maxOutputBytes: 64 * 1024 * 1024,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 预算收敛校验:用户覆盖值只许 ≤ 基线(逐项);上调 = CAR-E-BUDGET 显式报错。
|
|
22
|
+
* 返回收敛后的完整预算(未覆盖项取基线)。
|
|
23
|
+
*/
|
|
24
|
+
export function convergeBudget(override?: Partial<PtcBudget>): PtcBudget {
|
|
25
|
+
const out: PtcBudget = { ...PTC_BUDGET_BASELINE }
|
|
26
|
+
if (!override) return out
|
|
27
|
+
for (const key of ['computeMs', 'maxWallMs', 'maxOutputBytes'] as const) {
|
|
28
|
+
const v = override[key]
|
|
29
|
+
if (v === undefined) continue
|
|
30
|
+
if (!Number.isFinite(v) || v <= 0) throw new Error(`CAR-E-BUDGET: ${key} must be a positive number, got ${v}`)
|
|
31
|
+
if (v > PTC_BUDGET_BASELINE[key]) {
|
|
32
|
+
throw new Error(`CAR-E-BUDGET: ${key}=${v} exceeds baseline ${PTC_BUDGET_BASELINE[key]}——预算只许下调(fail-closed),上调须经四步标定流程修订基线`)
|
|
33
|
+
}
|
|
34
|
+
out[key] = v
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T-2 · erasable-only TS 静态检查(M3-S14 双挂点:loader 提交链 + run_code 入口)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M3系统设计增补 T-2 / dsh 源码一手口径 code-runtime-worker-thread/src/index.ts:306-308):
|
|
5
|
+
* - PTC 程序体与插件提交链只接受 erasable TS(Node type-stripping 可直接剥离的语法)——
|
|
6
|
+
* 禁止 enum / namespace / module / 构造器参数属性 / import=require 等需转换的语法;
|
|
7
|
+
* - 双挂点保证「无 worker 即无执行,漏检面 = 0」:loader 在插件装载时检查源码,
|
|
8
|
+
* run_code 在 worker 启动前检查程序体——两处调用同一函数(单一事实源)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** erasable 语法黑名单(启发式正则;完整 AST 检查随 jiti 实装升级为语法级) */
|
|
12
|
+
const NON_ERASABLE: Array<{ name: string; re: RegExp }> = [
|
|
13
|
+
{ name: 'enum 声明', re: /(?:^|\n)\s*(?:export\s+)?(?:const\s+)?enum\s+[A-Za-z_$]/ },
|
|
14
|
+
{ name: 'namespace 声明', re: /(?:^|\n)\s*(?:export\s+)?namespace\s+[A-Za-z_$]/ },
|
|
15
|
+
{ name: 'module 声明', re: /(?:^|\n)\s*(?:export\s+)?module\s+[A-Za-z_$"'`]/ },
|
|
16
|
+
{ name: '构造器参数属性', re: /constructor\s*\(\s*(?:public|private|protected|readonly)\s+[A-Za-z_$]/ },
|
|
17
|
+
{ name: 'import=require', re: /import\s+[A-Za-z_$][\w$]*\s*=\s*require\s*\(/ },
|
|
18
|
+
{ name: 'export=require', re: /export\s*=\s*/ },
|
|
19
|
+
{ name: '装饰器(实验性,非 erasable)', re: /@\s*[A-Za-z_$][\w$]*\s*\(/ },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** erasable-only 检查:违规 = 显式报错(冲突链定位到语法名;v1 为启发式,行号随 AST 升级) */
|
|
23
|
+
export function checkErasableOnly(code: string): { ok: boolean; violation?: string } {
|
|
24
|
+
for (const { name, re } of NON_ERASABLE) {
|
|
25
|
+
if (re.test(code)) return { ok: false, violation: `non-erasable TS: ${name}(PTC 程序体与插件提交链仅接受 erasable TS,见 M3系统设计增补 T-2)` }
|
|
26
|
+
}
|
|
27
|
+
return { ok: true }
|
|
28
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F16 · PTC(Programmatic Tool Calling)执行器(M3-S14)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M3系统设计增补 T-2 / 决议③ PTC 落位 / dsh 一手口径):
|
|
5
|
+
* - run_code 签名:code + description **双必填**(description = 授权门「凭什么」人审凭据,无默认值);
|
|
6
|
+
* - 隔离:每次新 worker(独立无跨运行状态);containment 非安全边界 → **强制 declaredSideEffect='write'**
|
|
7
|
+
* 走 stop.ts 既有权限门(S15 authorizationId='ptc-'+id 幂等集成);
|
|
8
|
+
* - erasable-only 双挂点之一(本入口);挂点二在 loader 提交链(mountPlugin);
|
|
9
|
+
* - **预算到期不掐 turn**:超限 = 工具错误结果交回模型(TurnEndReason 六值零扩展,ADR-001 兼容);
|
|
10
|
+
* - 并发护栏(自研五维):并发 4 / 子调用超时 30s / 总 worker 存活由本执行器串行门管控。
|
|
11
|
+
*/
|
|
12
|
+
import { Worker } from 'node:worker_threads'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { convergeBudget, type PtcBudget } from './budget.ts'
|
|
16
|
+
import { checkErasableOnly } from './erasable.ts'
|
|
17
|
+
import { redactSecrets } from '../security/secrets.ts'
|
|
18
|
+
|
|
19
|
+
export interface PtcRequest { code: string; description: string; toolCallId: string; budget?: Partial<PtcBudget> }
|
|
20
|
+
export interface PtcResult { ok: boolean; result?: unknown; error?: string; wallMs: number; outputBytes: number; budgetExceeded?: boolean
|
|
21
|
+
/** F12 出站覆盖命中数(secrets 脱敏在回填上下文前强制执行) */ secretsRedacted?: number }
|
|
22
|
+
|
|
23
|
+
export interface ToolBridge { run(args: unknown): Promise<unknown> }
|
|
24
|
+
|
|
25
|
+
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'worker-entry.ts')
|
|
26
|
+
const MAX_CONCURRENT = 4
|
|
27
|
+
let active = 0
|
|
28
|
+
|
|
29
|
+
/** PTC 程序执行:每次新 worker;子调用经消息桥回主线程执行工具(宿主权限门生效面) */
|
|
30
|
+
export interface RunCodeOpts { tools: Map<string, ToolBridge>; audit?: (d: Record<string, unknown>) => void
|
|
31
|
+
/** 授权门(S15):返回 false = 拒绝(无 worker 启动,审计留痕);authorizationId 幂等由 authz 服务承载 */ authorize?: (req: PtcRequest) => Promise<boolean> }
|
|
32
|
+
|
|
33
|
+
export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcResult> {
|
|
34
|
+
// 双必填(description 为授权凭据——S15 授权门消费)
|
|
35
|
+
if (!req.code?.trim()) throw new Error('CAR-E-PTC: code is required(run_code 双必填)')
|
|
36
|
+
if (!req.description?.trim()) throw new Error('CAR-E-PTC: description is required——授权门人审凭据(无默认值,M3系统设计增补 T-2)')
|
|
37
|
+
// 授权门前置(无 worker 启动):拒绝 = 工具错误结果 + 审计留痕(authorizationId='ptc-'+id 幂等键)
|
|
38
|
+
if (opts.authorize) {
|
|
39
|
+
const granted = await opts.authorize(req)
|
|
40
|
+
if (!granted) {
|
|
41
|
+
opts.audit?.({ kind: 'ptc-denied', toolCallId: req.toolCallId, authorizationId: 'ptc-' + req.toolCallId, description: req.description })
|
|
42
|
+
return { ok: false, error: 'authorization-denied (ptc)——授权拒绝落审计,程序未执行', wallMs: 0, outputBytes: 0 }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// erasable-only 挂点(入口)
|
|
46
|
+
const era = checkErasableOnly(req.code)
|
|
47
|
+
if (!era.ok) throw new Error(`CAR-E-PTC: ${era.violation}`)
|
|
48
|
+
const budget = convergeBudget(req.budget)
|
|
49
|
+
if (active >= MAX_CONCURRENT) {
|
|
50
|
+
return { ok: false, error: 'CAR-E-PTC: concurrency guard (max=4)——超限拒绝留痕', wallMs: 0, outputBytes: 0 }
|
|
51
|
+
}
|
|
52
|
+
const started = Date.now()
|
|
53
|
+
active++
|
|
54
|
+
opts.audit?.({ kind: 'ptc-start', description: req.description, toolCallId: req.toolCallId, budget })
|
|
55
|
+
try {
|
|
56
|
+
return await new Promise<PtcResult>((resolve) => {
|
|
57
|
+
const worker = new Worker(WORKER_PATH, {
|
|
58
|
+
workerData: { code: req.code, budget },
|
|
59
|
+
resourceLimits: { maxOldGenerationSizeMb: 256, maxYoungGenerationSizeMb: 32 },
|
|
60
|
+
})
|
|
61
|
+
// 主线程侧兜底(worker 内计时器失效时强杀——双层预算)
|
|
62
|
+
const killer = setTimeout(() => {
|
|
63
|
+
void worker.terminate()
|
|
64
|
+
resolve({ ok: false, error: `budget-exceeded (maxWallMs=${budget.maxWallMs}, main-side guard)`, wallMs: Date.now() - started, outputBytes: 0, budgetExceeded: true })
|
|
65
|
+
}, budget.maxWallMs + 1000)
|
|
66
|
+
worker.on('message', (m: { type: string; callId?: string; name?: string; args?: unknown; result?: unknown; error?: string }) => {
|
|
67
|
+
if (m.type === 'tool') {
|
|
68
|
+
const def = opts.tools.get(m.name!)
|
|
69
|
+
// 工具异常必须回传为 tool-result error(供程序体 try/catch 恢复)——
|
|
70
|
+
// 无 catch 会成为 unhandled rejection 崩溃主进程(S19 评测 E1 捕获)
|
|
71
|
+
void (def
|
|
72
|
+
? def.run(m.args).then(
|
|
73
|
+
r => worker.postMessage({ type: 'tool-result', callId: m.callId, result: r }),
|
|
74
|
+
(e: unknown) => worker.postMessage({ type: 'tool-result', callId: m.callId, error: String(e) }),
|
|
75
|
+
)
|
|
76
|
+
: Promise.resolve(worker.postMessage({ type: 'tool-result', callId: m.callId, error: `unknown tool "${m.name}"` })))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (m.type === 'done') {
|
|
80
|
+
clearTimeout(killer)
|
|
81
|
+
// F12 出站覆盖:worker 输出回填上下文前强制 redact(M3安全设计增补 T-4)
|
|
82
|
+
let result = m.result
|
|
83
|
+
let secretsRedacted = 0
|
|
84
|
+
if (result !== undefined) {
|
|
85
|
+
const serialized = JSON.stringify(result)
|
|
86
|
+
const r = redactSecrets(serialized)
|
|
87
|
+
secretsRedacted = r.redacted
|
|
88
|
+
if (r.redacted > 0) {
|
|
89
|
+
result = JSON.parse(r.text)
|
|
90
|
+
opts.audit?.({ kind: 'ptc-secrets-redacted', toolCallId: req.toolCallId, hits: r.redacted })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
resolve({
|
|
94
|
+
ok: !m.error,
|
|
95
|
+
result,
|
|
96
|
+
error: m.error,
|
|
97
|
+
wallMs: Date.now() - started,
|
|
98
|
+
outputBytes: Buffer.byteLength(JSON.stringify(result ?? null)),
|
|
99
|
+
budgetExceeded: !!m.error?.startsWith('budget-exceeded'),
|
|
100
|
+
secretsRedacted,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
worker.on('error', (e) => { clearTimeout(killer); resolve({ ok: false, error: String(e), wallMs: Date.now() - started, outputBytes: 0 }) })
|
|
105
|
+
worker.on('exit', (code) => { clearTimeout(killer); active-- })
|
|
106
|
+
// exit 可能先于 done(budget killer)——兜底 resolve 幂等由 Promise 语义保证
|
|
107
|
+
})
|
|
108
|
+
} finally {
|
|
109
|
+
// active 递归在 exit 事件处理;此处为异常路径兜底
|
|
110
|
+
setTimeout(() => { active = Math.max(0, active - 0) }, 0)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** PTC 作为一等 ToolDefinition:强制 write 侧效应(授权门无旁路),接入 runTurn 工具面 */
|
|
115
|
+
export function makePtcToolDefinition(opts: { tools: Map<string, ToolBridge>; audit?: (d: Record<string, unknown>) => void }) {
|
|
116
|
+
return {
|
|
117
|
+
name: 'run_code',
|
|
118
|
+
description: '执行一段 erasable TypeScript 程序(PTC)——程序体内经 tools.<name>(args) 调用已注册工具',
|
|
119
|
+
// T-22 语义:PTC 与 bash 同信任级别 → 强制 write 最高约束(不可声明为 readonly)
|
|
120
|
+
declaredSideEffect: 'write' as const,
|
|
121
|
+
run: async (args: { code: string; description: string; toolCallId?: string }) => {
|
|
122
|
+
const r = await runCode({ code: args.code, description: args.description, toolCallId: args.toolCallId ?? 'ptc-' + Date.now().toString(36) }, opts)
|
|
123
|
+
if (!r.ok) throw new Error(r.error)
|
|
124
|
+
return r.result
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/ptc/sdk.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T-2 · PTC TS SDK 渲染(M3-S15)
|
|
3
|
+
*
|
|
4
|
+
* 口径(M3系统设计增补 T-2 / dsh 一手口径 tools/src/index.ts:26,97):
|
|
5
|
+
* - SDK = 从工具注册表快照渲染进 system prompt 的 TS 声明(**非 .d.ts 文件生成**);
|
|
6
|
+
* - **声明面/授权面一致性红线**:渲染源与授权消费源必须同一 Map 快照——模型看到的工具集合
|
|
7
|
+
* 即授权门放行的集合(多渲一个 = 无授权执行面,少渲一个 = 授权白给);
|
|
8
|
+
* - 参数形状以 description + 可选 paramHint 声明(v1 不做 JSON Schema→TS 深度转换,S16 评估)。
|
|
9
|
+
*/
|
|
10
|
+
import type { ToolBridge } from './runCode.ts'
|
|
11
|
+
|
|
12
|
+
export interface SdkToolDef { name: string; description?: string; /** 参数形状提示(TS 字面量),如 '{ x: number; y: number }' */ paramHint?: string }
|
|
13
|
+
|
|
14
|
+
/** 渲染 SDK 声明:注入 PTC 程序体上文的 TS 类型面(模型据此写代码) */
|
|
15
|
+
export function renderSdk(defs: Iterable<SdkToolDef>): string {
|
|
16
|
+
const lines = ['// CAR PTC SDK —— 以下声明与授权门消费同一注册表快照(声明面=授权面)', 'declare const tools: {']
|
|
17
|
+
for (const d of defs) {
|
|
18
|
+
const hint = d.paramHint ?? 'args: unknown'
|
|
19
|
+
lines.push(` ${JSON.stringify(d.name)}: (${hint}) => Promise<unknown>${d.description ? ` // ${d.description}` : ''}`)
|
|
20
|
+
}
|
|
21
|
+
lines.push('};')
|
|
22
|
+
return lines.join('\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 从 ToolBridge 注册表快照渲染(包装:接受可选 description/paramHint 的注册表条目) */
|
|
26
|
+
export function renderSdkFromRegistry(registry: Map<string, ToolBridge & { description?: string; paramHint?: string }>): string {
|
|
27
|
+
return renderSdk([...registry.entries()].map(([name, d]) => ({ name, description: d.description, paramHint: d.paramHint })))
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTC worker 入口(M3-S14):隔离执行 PTC 程序体
|
|
3
|
+
*
|
|
4
|
+
* 协议:
|
|
5
|
+
* - workerData: { code, budget };
|
|
6
|
+
* - 程序体 = async 函数体(顶层 await/return 支持,dsh 一手口径);
|
|
7
|
+
* - tools proxy:await tools.<name>(args) → postMessage {type:'tool'} → 主线程执行 → {type:'tool-result'} 回传;
|
|
8
|
+
* - 预算:maxWallMs 计时超限 → done(error='budget-exceeded (maxWallMs)');maxOutputBytes 序列化后检查;
|
|
9
|
+
* - done 消息单发即退——程序体 = 一次原子 toolCall(收口点唯一在 executor 返回)。
|
|
10
|
+
*/
|
|
11
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
12
|
+
|
|
13
|
+
const port = parentPort!
|
|
14
|
+
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
|
|
15
|
+
|
|
16
|
+
port.on('message', (m: { type: string; callId?: string; result?: unknown; error?: string }) => {
|
|
17
|
+
if (m.type === 'tool-result' && m.callId) {
|
|
18
|
+
const p = pending.get(m.callId)
|
|
19
|
+
if (!p) return
|
|
20
|
+
pending.delete(m.callId)
|
|
21
|
+
if (m.error) p.reject(new Error(m.error))
|
|
22
|
+
else p.resolve(m.result)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const tools: Record<string, (args: unknown) => Promise<unknown>> = new Proxy({}, {
|
|
27
|
+
get: (_, name: string) => (args: unknown) => new Promise((resolve, reject) => {
|
|
28
|
+
const callId = Math.random().toString(36).slice(2)
|
|
29
|
+
pending.set(callId, { resolve, reject })
|
|
30
|
+
port.postMessage({ type: 'tool', callId, name, args })
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const budget = (workerData as { budget: { maxWallMs: number; maxOutputBytes: number } }).budget
|
|
35
|
+
const finish = (msg: { type: 'done'; result?: unknown; error?: string }) => {
|
|
36
|
+
port.postMessage(msg)
|
|
37
|
+
setTimeout(() => process.exit(0), 50) // 让消息冲刷
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const wallTimer = setTimeout(() => finish({ type: 'done', error: `budget-exceeded (maxWallMs=${budget.maxWallMs})——预算到期为资源护栏,不掐 turn(工具错误结果交回模型)` }), budget.maxWallMs)
|
|
41
|
+
|
|
42
|
+
void (async () => {
|
|
43
|
+
try {
|
|
44
|
+
const code = (workerData as { code: string }).code
|
|
45
|
+
// erasable-only 的执行端:eval 前剥离类型(Node 22.13+ 内置 stripTypeScriptTypes)——
|
|
46
|
+
// 程序体是 erasable TS(含类型注解),new Function 是纯 JS 求值器,不剥离则注解即 SyntaxError
|
|
47
|
+
const { stripTypeScriptTypes } = await import('node:module')
|
|
48
|
+
// stripTypeScriptTypes 以「模块」语义解析——片段含顶层 return 会报 ERR_INVALID_TYPESCRIPT_SYNTAX;
|
|
49
|
+
// 故先包成 export default 箭头函数再剥离,剥离后还原为 return 表达式(程序体含字面 export default 不受支持,见 erasable 禁项)
|
|
50
|
+
const wrapped = 'export default async () => {\n' + code + '\n}'
|
|
51
|
+
const js = stripTypeScriptTypes(wrapped, { mode: 'strip' })
|
|
52
|
+
const body = js.replace(/^export default /, 'return ')
|
|
53
|
+
// 程序体 = async 函数体(顶层 await/return);tools 为受控 proxy(仅显式调用,无宿主 import 面)
|
|
54
|
+
const fn = new Function('tools', '"use strict";\n' + body + '\n') as (t: typeof tools) => () => Promise<unknown>
|
|
55
|
+
const result = await fn(tools)()
|
|
56
|
+
clearTimeout(wallTimer)
|
|
57
|
+
const out = JSON.stringify(result ?? null)
|
|
58
|
+
if (Buffer.byteLength(out) > budget.maxOutputBytes) {
|
|
59
|
+
finish({ type: 'done', error: `budget-exceeded (maxOutputBytes=${budget.maxOutputBytes})` })
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
finish({ type: 'done', result: JSON.parse(out) })
|
|
63
|
+
} catch (e) {
|
|
64
|
+
clearTimeout(wallTimer)
|
|
65
|
+
finish({ type: 'done', error: String(e) })
|
|
66
|
+
}
|
|
67
|
+
})()
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 · 沙箱执行器:能力探测 + 三档模式 + 降级路径(BD-01)+ 逃逸回归接口
|
|
3
|
+
*
|
|
4
|
+
* 口径(《系统设计》F3 / 安全设计 §5.3 / Q-04 定稿 / 冻结决策② + M2系统设计增补 T-3):
|
|
5
|
+
* - 平台后端:linux = landlock-run(dsh 同款独立 C11 可执行:--probe 探测、--ro/--rw -- argv、
|
|
6
|
+
* fail-closed)+ seccomp 过滤集;darwin = Seatbelt(sandbox-exec 动态生成 profile,M2-S7 实装);
|
|
7
|
+
* win32 = 受限 token + Job Object(koffi FFI,S7 出 spike 方案、S8 实装——dsh win32 为 koffi 纯 JS,
|
|
8
|
+
* sandbox-local/src/index.ts:160-161 源码级已核实)。
|
|
9
|
+
* 容器内 Landlock 不可用 → 探测失败走同一降级路径(SR-01/19 口径)
|
|
10
|
+
* - 降级态约束(Q-04):写类操作强制确认模式(full 亦受限)、env-read 一律拒绝、
|
|
11
|
+
* 降级事件 + 每次降级态授权带 degraded=true 留痕、静默降级 = AL-03 P1(计数必须为 0)
|
|
12
|
+
* - 执行:argv 数组形式(A03 红线:禁 shell -c 拼接)、单命令 300s 上限、0 自动重试
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
|
|
16
|
+
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
|
|
17
|
+
export type Capability = 'fs-write' | 'net' | 'exec' | 'mcp' | 'env-read'
|
|
18
|
+
|
|
19
|
+
export interface ProbeResult {
|
|
20
|
+
platform: NodeJS.Platform
|
|
21
|
+
landlock: boolean
|
|
22
|
+
seccomp: boolean
|
|
23
|
+
degraded: boolean
|
|
24
|
+
/** 降级原因(审计留痕用);未降级为 null */
|
|
25
|
+
reason: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SandboxAuditSink {
|
|
29
|
+
(event: { kind: 'sandbox-degraded' | 'sandbox-denied' | 'sandbox-exec'; detail: Record<string, unknown>; ts: number }): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 能力探测:Linux 用 landlock-run --probe;其余平台直接降级(可插拔真实探测) */
|
|
33
|
+
export async function probeCapabilities(opts: {
|
|
34
|
+
landlockRunPath?: string
|
|
35
|
+
platform?: NodeJS.Platform
|
|
36
|
+
} = {}): Promise<ProbeResult> {
|
|
37
|
+
const platform = opts.platform ?? process.platform
|
|
38
|
+
if (platform !== 'linux' || !opts.landlockRunPath) {
|
|
39
|
+
return {
|
|
40
|
+
platform,
|
|
41
|
+
landlock: false, seccomp: false, degraded: true,
|
|
42
|
+
reason: platform === 'linux'
|
|
43
|
+
? 'landlock-run binary not available(BD-01:需编译 native/landlock-run 或提供路径)'
|
|
44
|
+
: `platform ${platform} 沙箱原语未实装(S3:Linux 先行,经中间确认②)`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const probe = await new Promise<{ ok: boolean }>((resolve) => {
|
|
48
|
+
try {
|
|
49
|
+
const child = spawn(opts.landlockRunPath!, ['--probe'], { stdio: 'ignore' })
|
|
50
|
+
child.on('exit', (code) => resolve({ ok: code === 0 }))
|
|
51
|
+
child.on('error', () => resolve({ ok: false }))
|
|
52
|
+
} catch { resolve({ ok: false }) }
|
|
53
|
+
})
|
|
54
|
+
return probe.ok
|
|
55
|
+
? { platform, landlock: true, seccomp: true, degraded: false, reason: null }
|
|
56
|
+
: { platform, landlock: false, seccomp: false, degraded: true, reason: 'landlock-run probe failed(内核 <5.13 或 LSM 未启用)' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ExecRequest {
|
|
60
|
+
argv: string[]
|
|
61
|
+
/** 声明所需能力(能力标签 = 跨域请求唯一凭证) */
|
|
62
|
+
capabilities: Capability[]
|
|
63
|
+
mode: SandboxMode
|
|
64
|
+
/** 运行权限模式(F4):readonly/confirm/full——降级态下 full 亦受限为 confirm */
|
|
65
|
+
permissionMode: 'readonly' | 'confirm' | 'full'
|
|
66
|
+
authorize?: (req: ExecRequest) => Promise<boolean>
|
|
67
|
+
timeoutMs?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ExecResult { ok: boolean; stdout: string; stderr: string; code: number | null; degraded: boolean; denied?: string }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* M2-S7 · Seatbelt profile 生成器(darwin 后端)
|
|
74
|
+
*
|
|
75
|
+
* 口径:参照 Codex sandbox-exec 动态生成 profile(SR-19,源码级已核实)+ dsh darwin=['seatbelt'] 路由。
|
|
76
|
+
* 生成规则(fail-closed:默认全部拒绝,仅显式 allow):
|
|
77
|
+
* - 全模式:允许进程基本运行(process-exec/process-fork)+ /usr,/bin,/sbin,/System 只读;
|
|
78
|
+
* - read-only / workspace-write:拒绝 network*(deny-by-default 网络);
|
|
79
|
+
* - workspace-write:仅 {workspace} 与 CAR_HOME 临时目录允许 file-write*,.git 强制只读(受保护路径);
|
|
80
|
+
* - danger-full-access:不生成 profile(直跑,与 Codex 三档一致)。
|
|
81
|
+
*/
|
|
82
|
+
export function buildSeatbeltProfile(mode: SandboxMode, workspace: string): string {
|
|
83
|
+
if (mode === 'danger-full-access') throw new Error('CAR-E-SANDBOX: danger-full-access does not use a seatbelt profile(直跑语义)')
|
|
84
|
+
const ws = workspace.replace(/"/g, '\\"')
|
|
85
|
+
const lines = [
|
|
86
|
+
'(version 1)',
|
|
87
|
+
'(deny default)', // fail-closed
|
|
88
|
+
';; 进程基本运行',
|
|
89
|
+
'(allow process-fork)',
|
|
90
|
+
'(allow process-exec)',
|
|
91
|
+
';; 系统只读面',
|
|
92
|
+
'(allow file-read* (subpath "/usr"))',
|
|
93
|
+
'(allow file-read* (subpath "/bin"))',
|
|
94
|
+
'(allow file-read* (subpath "/sbin"))',
|
|
95
|
+
'(allow file-read* (subpath "/System"))',
|
|
96
|
+
'(allow file-read* (subpath "/private/var/db/dyld"))',
|
|
97
|
+
';; 系统运行必需',
|
|
98
|
+
'(allow sysctl-read)',
|
|
99
|
+
'(allow mach-lookup)',
|
|
100
|
+
'(allow iokit-open)',
|
|
101
|
+
]
|
|
102
|
+
if (mode === 'workspace-write') {
|
|
103
|
+
lines.push(
|
|
104
|
+
`;; 工作区可写(.git 强制只读——受保护路径)`,
|
|
105
|
+
`(allow file-write* (subpath "${ws}"))`,
|
|
106
|
+
`(deny file-write* (subpath "${ws}/.git"))`,
|
|
107
|
+
`;; 工作区读取`,
|
|
108
|
+
`(allow file-read* (subpath "${ws}"))`,
|
|
109
|
+
`;; 临时目录(编译器/子进程必需)`,
|
|
110
|
+
'(allow file-write* (subpath "/private/tmp"))',
|
|
111
|
+
'(allow file-read* (subpath "/private/tmp"))',
|
|
112
|
+
'(allow file-write* (subpath "/private/var/tmp"))',
|
|
113
|
+
)
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(
|
|
116
|
+
`;; read-only:工作区仅读`,
|
|
117
|
+
`(allow file-read* (subpath "${ws}"))`,
|
|
118
|
+
'(allow file-read* (subpath "/private/tmp"))',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
lines.push(';; deny-by-default 网络(写类能力需显式豁免才可放行,本模板一律拒绝)', '(deny network*)')
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class SandboxExecutor {
|
|
126
|
+
#probe: ProbeResult
|
|
127
|
+
#audit: SandboxAuditSink
|
|
128
|
+
#landlockRunPath?: string
|
|
129
|
+
#workspace: string
|
|
130
|
+
|
|
131
|
+
constructor(opts: { probe: ProbeResult; audit: SandboxAuditSink; landlockRunPath?: string; workspace: string }) {
|
|
132
|
+
this.#probe = opts.probe
|
|
133
|
+
this.#audit = opts.audit
|
|
134
|
+
this.#landlockRunPath = opts.landlockRunPath
|
|
135
|
+
this.#workspace = opts.workspace
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get degraded(): boolean { return this.#probe.degraded }
|
|
139
|
+
|
|
140
|
+
/** Q-04 定稿:降级态运行约束判定(在授权门之前生效) */
|
|
141
|
+
effectivePermission(permissionMode: ExecRequest['permissionMode'], capabilities: Capability[]): { mode: 'readonly' | 'confirm' | 'full'; denied: string | null } {
|
|
142
|
+
if (!this.degraded) return { mode: permissionMode, denied: null }
|
|
143
|
+
const writeish = capabilities.some(c => c === 'fs-write' || c === 'net' || c === 'exec' || c === 'mcp')
|
|
144
|
+
if (capabilities.includes('env-read')) return { mode: 'confirm', denied: 'env-read denied in degraded sandbox(Q-04:一律拒绝)' }
|
|
145
|
+
if (writeish) return { mode: 'confirm', denied: null } // full 亦受限为 confirm
|
|
146
|
+
return { mode: permissionMode === 'full' ? 'confirm' : permissionMode, denied: null }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 沙箱内执行:argv 数组、写类能力需授权、降级态 Q-04 约束、审计全量留痕 */
|
|
150
|
+
async exec(req: ExecRequest): Promise<ExecResult> {
|
|
151
|
+
const { mode: effMode, denied: degradedDenial } = this.effectivePermission(req.permissionMode, req.capabilities)
|
|
152
|
+
if (degradedDenial) {
|
|
153
|
+
this.#audit({ kind: 'sandbox-denied', detail: { argv: req.argv, reason: degradedDenial, degraded: true }, ts: Date.now() })
|
|
154
|
+
return { ok: false, stdout: '', stderr: degradedDenial, code: -1, degraded: true, denied: degradedDenial }
|
|
155
|
+
}
|
|
156
|
+
// 权限门:写类能力在 readonly 一律拒;confirm 需审批(降级态强制 confirm)
|
|
157
|
+
const writeish = req.capabilities.some(c => c === 'fs-write' || c === 'net' || c === 'exec' || c === 'mcp')
|
|
158
|
+
if (writeish && effMode !== 'full') {
|
|
159
|
+
const granted = effMode === 'confirm' ? await (req.authorize?.(req) ?? Promise.resolve(false)) : false
|
|
160
|
+
if (!granted) {
|
|
161
|
+
this.#audit({ kind: 'sandbox-denied', detail: { argv: req.argv, mode: effMode, degraded: this.degraded }, ts: Date.now() })
|
|
162
|
+
return { ok: false, stdout: '', stderr: `authorization-denied (mode=${effMode}${this.degraded ? ', degraded' : ''})`, code: -1, degraded: this.degraded, denied: 'authorization-denied' }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (this.degraded) {
|
|
166
|
+
// 降级态执行:无内核背书——仅透传执行并留痕(授权门已按 Q-04 收紧)
|
|
167
|
+
this.#audit({ kind: 'sandbox-exec', detail: { argv: req.argv, degraded: true }, ts: Date.now() })
|
|
168
|
+
return this.#spawn(req.argv, req.timeoutMs, true)
|
|
169
|
+
}
|
|
170
|
+
// 正常态:Linux landlock-run 包装(self-restrict-then-exec,fail-closed)
|
|
171
|
+
if (process.platform === 'linux' && this.#landlockRunPath) {
|
|
172
|
+
const ro = req.mode === 'read-only'
|
|
173
|
+
const wrapped = [this.#landlockRunPath, ro ? '--ro' : '--rw', '--', ...req.argv]
|
|
174
|
+
return this.#spawn(wrapped, req.timeoutMs, false)
|
|
175
|
+
}
|
|
176
|
+
// 正常态:macOS Seatbelt(sandbox-exec 动态 profile,M2-S7)
|
|
177
|
+
if (process.platform === 'darwin' && req.mode !== 'danger-full-access') {
|
|
178
|
+
const profile = buildSeatbeltProfile(req.mode, this.#workspace)
|
|
179
|
+
return this.#spawn(['sandbox-exec', '-p', profile, ...req.argv], req.timeoutMs, false)
|
|
180
|
+
}
|
|
181
|
+
return this.#spawn(req.argv, req.timeoutMs, false)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#spawn(argv: string[], timeoutMs = 300_000, degraded: boolean): Promise<ExecResult> {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const child = spawn(argv[0], argv.slice(1), { cwd: this.#workspace, shell: false, env: { ...process.env } })
|
|
187
|
+
let stdout = '', stderr = ''
|
|
188
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
189
|
+
child.stdout?.on('data', d => { stdout += d })
|
|
190
|
+
child.stderr?.on('data', d => { stderr += d })
|
|
191
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, stdout, stderr: String(e), code: -1, degraded }) })
|
|
192
|
+
child.on('exit', (code) => {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
this.#audit({ kind: 'sandbox-exec', detail: { argv, code, degraded }, ts: Date.now() })
|
|
195
|
+
resolve({ ok: code === 0, stdout, stderr, code, degraded })
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|