@nifrajs/agent 2.12.0
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 +26 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +61 -0
- package/dist/events.js.map +1 -0
- package/dist/execution-policy.d.ts +43 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +185 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +409 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/events.ts +85 -0
- package/src/execution-policy.ts +241 -0
- package/src/index.ts +803 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
defineExecutionPolicy,
|
|
5
|
+
type ExecutionPolicy,
|
|
6
|
+
type ExecutionPolicyAdapter,
|
|
7
|
+
} from "@nifrajs/core/execution-policy"
|
|
8
|
+
|
|
9
|
+
/** This statement is intentionally repeated in the API and runtime result. */
|
|
10
|
+
export const LOCAL_PROCESS_LIMITATION = "The local adapter is NOT a security boundary."
|
|
11
|
+
|
|
12
|
+
const SIGKILL_GRACE_MS = 2000
|
|
13
|
+
|
|
14
|
+
export interface LocalProcessAdapterOptions {
|
|
15
|
+
readonly cwd?: string
|
|
16
|
+
/** Only these inherited environment names are copied. Default: PATH, LANG, and LC_ALL. */
|
|
17
|
+
readonly envAllowlist?: readonly string[]
|
|
18
|
+
readonly maxOutputBytes?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocalProcessRequest {
|
|
22
|
+
readonly command: string
|
|
23
|
+
readonly args?: readonly string[]
|
|
24
|
+
readonly cwd?: string
|
|
25
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
26
|
+
readonly capability?: string
|
|
27
|
+
readonly policy: ExecutionPolicy
|
|
28
|
+
readonly signal?: AbortSignal
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LocalProcessResult {
|
|
32
|
+
readonly ok: boolean
|
|
33
|
+
readonly exitCode: number | null
|
|
34
|
+
readonly signal?: string
|
|
35
|
+
readonly stdout: string
|
|
36
|
+
readonly stderr: string
|
|
37
|
+
readonly timedOut: boolean
|
|
38
|
+
readonly cancelled: boolean
|
|
39
|
+
readonly limitations: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class LocalProcessPolicyError extends Error {
|
|
43
|
+
constructor(readonly code: "policy_unsatisfied" | "invalid_request" | "cancelled") {
|
|
44
|
+
super(`local process: ${code}. ${LOCAL_PROCESS_LIMITATION}`)
|
|
45
|
+
this.name = "LocalProcessPolicyError"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LocalProcessAdapter extends ExecutionPolicyAdapter {
|
|
50
|
+
run(request: LocalProcessRequest): Promise<LocalProcessResult>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run a command with the host controls available to a normal child process. The local adapter is NOT
|
|
55
|
+
* a security boundary. Without OS-level sandboxing it contains crashes and accidents, not hostile code.
|
|
56
|
+
*/
|
|
57
|
+
export function createLocalProcessAdapter(
|
|
58
|
+
options: LocalProcessAdapterOptions = {},
|
|
59
|
+
): LocalProcessAdapter {
|
|
60
|
+
const cwd = options.cwd ?? process.cwd()
|
|
61
|
+
const allowlist = new Set(options.envAllowlist ?? ["PATH", "LANG", "LC_ALL"])
|
|
62
|
+
const maxOutputBytes = options.maxOutputBytes ?? 1024 * 1024
|
|
63
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
|
|
64
|
+
throw new RangeError("local process: maxOutputBytes must be a positive safe integer")
|
|
65
|
+
}
|
|
66
|
+
const adapter: LocalProcessAdapter = {
|
|
67
|
+
name: "local-process",
|
|
68
|
+
canSatisfy(policyInput) {
|
|
69
|
+
const policy = defineExecutionPolicy(policyInput)
|
|
70
|
+
return (
|
|
71
|
+
policy.filesystem === "cwd" &&
|
|
72
|
+
policy.network === "allow" &&
|
|
73
|
+
policy.capabilityCeiling.length > 0
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
limitations(policyInput) {
|
|
77
|
+
const policy = defineExecutionPolicy(policyInput)
|
|
78
|
+
const limitations = [LOCAL_PROCESS_LIMITATION]
|
|
79
|
+
if (policy.filesystem !== "cwd") limitations.push("filesystem-scope-not-enforced")
|
|
80
|
+
if (policy.network === "deny") limitations.push("network-denial-not-enforced")
|
|
81
|
+
limitations.push("capability-ceiling-is-admission-only")
|
|
82
|
+
return Object.freeze(limitations)
|
|
83
|
+
},
|
|
84
|
+
async run(request) {
|
|
85
|
+
const policy = defineExecutionPolicy(request.policy)
|
|
86
|
+
if (
|
|
87
|
+
typeof request.command !== "string" ||
|
|
88
|
+
request.command.trim() === "" ||
|
|
89
|
+
request.args?.some((arg) => typeof arg !== "string")
|
|
90
|
+
) {
|
|
91
|
+
throw new LocalProcessPolicyError("invalid_request")
|
|
92
|
+
}
|
|
93
|
+
if (!(await adapter.canSatisfy(policy)))
|
|
94
|
+
throw new LocalProcessPolicyError("policy_unsatisfied")
|
|
95
|
+
if (
|
|
96
|
+
request.capability !== undefined &&
|
|
97
|
+
!policy.capabilityCeiling.includes(request.capability)
|
|
98
|
+
) {
|
|
99
|
+
throw new LocalProcessPolicyError("policy_unsatisfied")
|
|
100
|
+
}
|
|
101
|
+
if (request.signal?.aborted === true) throw new LocalProcessPolicyError("cancelled")
|
|
102
|
+
const processCwd = resolve(cwd, request.cwd ?? ".")
|
|
103
|
+
if (policy.filesystem === "cwd" && !isWithin(processCwd, cwd))
|
|
104
|
+
throw new LocalProcessPolicyError("policy_unsatisfied")
|
|
105
|
+
const env = filteredEnv(request.env ?? {})
|
|
106
|
+
return spawnProcess({
|
|
107
|
+
command: request.command,
|
|
108
|
+
args: request.args ?? [],
|
|
109
|
+
cwd: processCwd,
|
|
110
|
+
env,
|
|
111
|
+
policy,
|
|
112
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
113
|
+
maxOutputBytes,
|
|
114
|
+
limitations: adapter.limitations(policy),
|
|
115
|
+
})
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
return Object.freeze(adapter)
|
|
119
|
+
|
|
120
|
+
function filteredEnv(
|
|
121
|
+
values: Readonly<Record<string, string | undefined>>,
|
|
122
|
+
): Record<string, string> {
|
|
123
|
+
const result: Record<string, string> = {}
|
|
124
|
+
for (const name of allowlist) {
|
|
125
|
+
const value = values[name] ?? process.env[name]
|
|
126
|
+
if (value !== undefined) result[name] = value
|
|
127
|
+
}
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isWithin(path: string, root: string): boolean {
|
|
133
|
+
const resolvedPath = resolve(path)
|
|
134
|
+
const resolvedRoot = resolve(root)
|
|
135
|
+
const distance = relative(resolvedRoot, resolvedPath)
|
|
136
|
+
return (
|
|
137
|
+
distance === "" ||
|
|
138
|
+
(distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance))
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface SpawnInput {
|
|
143
|
+
readonly command: string
|
|
144
|
+
readonly args: readonly string[]
|
|
145
|
+
readonly cwd: string
|
|
146
|
+
readonly env: Readonly<Record<string, string>>
|
|
147
|
+
readonly policy: ExecutionPolicy
|
|
148
|
+
readonly signal?: AbortSignal
|
|
149
|
+
readonly maxOutputBytes: number
|
|
150
|
+
readonly limitations: readonly string[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
let child: ReturnType<typeof spawn>
|
|
156
|
+
try {
|
|
157
|
+
child = spawn(input.command, [...input.args], {
|
|
158
|
+
cwd: input.cwd,
|
|
159
|
+
env: input.env,
|
|
160
|
+
shell: false,
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
})
|
|
163
|
+
} catch {
|
|
164
|
+
reject(new LocalProcessPolicyError("invalid_request"))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const stdout: Uint8Array[] = []
|
|
168
|
+
const stderr: Uint8Array[] = []
|
|
169
|
+
let timedOut = false
|
|
170
|
+
let cancelled = false
|
|
171
|
+
let settled = false
|
|
172
|
+
const outputUsed = { value: 0 }
|
|
173
|
+
const append = (target: Uint8Array[], chunk: Uint8Array): void => {
|
|
174
|
+
const remaining = input.maxOutputBytes - outputUsed.value
|
|
175
|
+
if (remaining <= 0) return
|
|
176
|
+
const selected = chunk.byteLength <= remaining ? chunk : chunk.slice(0, remaining)
|
|
177
|
+
target.push(selected)
|
|
178
|
+
outputUsed.value += selected.byteLength
|
|
179
|
+
}
|
|
180
|
+
if (child.stdout === null || child.stderr === null) {
|
|
181
|
+
child.kill()
|
|
182
|
+
reject(new LocalProcessPolicyError("invalid_request"))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
child.stdout.on("data", (chunk: Uint8Array) => append(stdout, chunk))
|
|
186
|
+
child.stderr.on("data", (chunk: Uint8Array) => append(stderr, chunk))
|
|
187
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
188
|
+
const terminate = (): void => {
|
|
189
|
+
child.kill("SIGTERM")
|
|
190
|
+
// A child that ignores SIGTERM would otherwise leave this promise pending forever,
|
|
191
|
+
// making policy.timeMs advisory instead of a bound.
|
|
192
|
+
killTimer ??= setTimeout(() => child.kill("SIGKILL"), SIGKILL_GRACE_MS)
|
|
193
|
+
}
|
|
194
|
+
const timer = setTimeout(() => {
|
|
195
|
+
timedOut = true
|
|
196
|
+
terminate()
|
|
197
|
+
}, input.policy.timeMs)
|
|
198
|
+
const cancel = (): void => {
|
|
199
|
+
cancelled = true
|
|
200
|
+
terminate()
|
|
201
|
+
}
|
|
202
|
+
input.signal?.addEventListener("abort", cancel, { once: true })
|
|
203
|
+
child.once("error", () => {
|
|
204
|
+
if (settled) return
|
|
205
|
+
settled = true
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
208
|
+
reject(new LocalProcessPolicyError("invalid_request"))
|
|
209
|
+
})
|
|
210
|
+
child.once("close", (exitCode, signal) => {
|
|
211
|
+
if (settled) return
|
|
212
|
+
settled = true
|
|
213
|
+
clearTimeout(timer)
|
|
214
|
+
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
215
|
+
resolve({
|
|
216
|
+
ok: !timedOut && !cancelled && exitCode === 0,
|
|
217
|
+
exitCode,
|
|
218
|
+
...(signal === null ? {} : { signal }),
|
|
219
|
+
stdout: new TextDecoder().decode(concat(stdout)),
|
|
220
|
+
stderr: new TextDecoder().decode(concat(stderr)),
|
|
221
|
+
timedOut,
|
|
222
|
+
cancelled,
|
|
223
|
+
limitations: input.limitations,
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
|
230
|
+
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0)
|
|
231
|
+
const result = new Uint8Array(size)
|
|
232
|
+
let offset = 0
|
|
233
|
+
for (const chunk of chunks) {
|
|
234
|
+
result.set(chunk, offset)
|
|
235
|
+
offset += chunk.byteLength
|
|
236
|
+
}
|
|
237
|
+
return result
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type { ExecutionPolicy, ExecutionPolicyAdapter }
|
|
241
|
+
export { defineExecutionPolicy }
|