@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.
@@ -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 }