ai-runtime-engine 2.9.0 → 3.0.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/CHANGELOG.md +108 -0
- package/README.md +30 -0
- package/dist/agents/admit.d.ts +9 -1
- package/dist/agents/admit.js +10 -2
- package/dist/agents/envelope.d.ts +21 -0
- package/dist/agents/envelope.js +39 -5
- package/dist/agents/finding.d.ts +9 -3
- package/dist/agents/finding.js +14 -3
- package/dist/agents/worker.d.ts +3 -0
- package/dist/agents/worker.js +4 -1
- package/dist/cli/cli.js +8 -1
- package/dist/cli/commands/cleanup.js +11 -3
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/run.js +6 -0
- package/dist/cli/commands/skills.js +9 -2
- package/dist/cli/interactive/repl.js +12 -2
- package/dist/cli/interactive/session.d.ts +2 -0
- package/dist/cli/interactive/session.js +6 -2
- package/dist/config/schema.js +19 -1
- package/dist/conversations/conversations.d.ts +6 -1
- package/dist/conversations/conversations.js +15 -8
- package/dist/core/fallback/fallback.d.ts +7 -0
- package/dist/core/fallback/fallback.js +15 -2
- package/dist/core/health/monitor.d.ts +6 -0
- package/dist/core/health/monitor.js +15 -2
- package/dist/core/router/confidence.js +10 -5
- package/dist/core/router/dimensions.d.ts +3 -1
- package/dist/core/router/dimensions.js +15 -5
- package/dist/core/router/filter.js +25 -6
- package/dist/core/router/normalize.js +2 -0
- package/dist/core/router/router.js +16 -2
- package/dist/core/router/scorer.d.ts +3 -0
- package/dist/core/router/scorer.js +17 -2
- package/dist/discovery/openapi.js +3 -2
- package/dist/executions/agentTasks.d.ts +4 -4
- package/dist/generation/generateAdapter.js +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/mcp/protocol.js +4 -1
- package/dist/memory/bm25.d.ts +7 -0
- package/dist/memory/bm25.js +17 -1
- package/dist/memory/memory.d.ts +7 -1
- package/dist/memory/memory.js +18 -4
- package/dist/orchestration/orchestrator.d.ts +2 -1
- package/dist/orchestration/planner.d.ts +2 -1
- package/dist/plugin/ai.d.ts +6 -0
- package/dist/plugin/ai.js +17 -2
- package/dist/providers/estimate.d.ts +25 -0
- package/dist/providers/estimate.js +55 -0
- package/dist/providers/factory.d.ts +3 -0
- package/dist/providers/factory.js +26 -5
- package/dist/providers/httpClient.js +4 -0
- package/dist/providers/httpProvider.js +4 -3
- package/dist/providers/mock/mockProvider.js +4 -3
- package/dist/runtime/config.d.ts +4 -3
- package/dist/runtime/config.js +14 -23
- package/dist/runtime/events.d.ts +6 -0
- package/dist/runtime/runtime.d.ts +43 -5
- package/dist/runtime/runtime.js +133 -25
- package/dist/runtime/types.d.ts +8 -1
- package/dist/store/area.d.ts +1 -1
- package/dist/store/area.js +34 -10
- package/dist/store/crypto.d.ts +27 -13
- package/dist/store/crypto.js +101 -23
- package/dist/store/errors.d.ts +11 -0
- package/dist/store/errors.js +14 -0
- package/dist/store/store.d.ts +21 -1
- package/dist/store/store.js +74 -19
- package/dist/telemetry/sinks/file.js +4 -2
- package/dist/telemetry/sinks/otlp.d.ts +12 -2
- package/dist/telemetry/sinks/otlp.js +39 -24
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +4 -0
- package/dist/tools/builtins/shell.d.ts +30 -3
- package/dist/tools/builtins/shell.js +218 -7
- package/dist/tools/untrusted.d.ts +1 -1
- package/dist/tools/untrusted.js +5 -3
- package/dist/types.d.ts +14 -0
- package/dist/verification/verify.js +10 -3
- package/docs/GUIDE.md +66 -1
- package/docs/README.md +1 -1
- package/docs/architecture.md +5 -1
- package/docs/router.md +1 -1
- package/docs/security.md +26 -7
- package/package.json +4 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed store-layer errors. Decryption failures carry an HONEST code: an AEAD (GCM) authentication
|
|
3
|
+
* failure does NOT prove the key is wrong — it means wrong key OR tampering OR corruption, and the code
|
|
4
|
+
* says exactly that. Higher layers may infer "likely key mismatch" only from AGGREGATE evidence (most/
|
|
5
|
+
* all encrypted records failing at once), never from a single record.
|
|
6
|
+
*/
|
|
7
|
+
export class StoreDecryptError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'StoreDecryptError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/store/store.d.ts
CHANGED
|
@@ -64,8 +64,28 @@ export declare class RuntimeStore {
|
|
|
64
64
|
/**
|
|
65
65
|
* Advisory per-project lock. Best-effort: acquires an exclusive lock file, steals a stale one, and
|
|
66
66
|
* otherwise proceeds anyway (advisory). Hard mutual exclusion for executions is a Phase 7 lease.
|
|
67
|
+
*
|
|
68
|
+
* The lock carries an ownership TOKEN so stealing a stale lock is atomic (unlink + exclusive re-create —
|
|
69
|
+
* exactly one racer wins) and release only removes a lock we still hold, never a stealer's.
|
|
67
70
|
*/
|
|
68
71
|
withLock<T>(fn: () => T, staleMs?: number): T;
|
|
69
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* @internal Acquire the lock file exclusively, stealing it only if stale. Returns whether WE hold it
|
|
74
|
+
* (a live foreign lock ⇒ false ⇒ the caller proceeds advisory, unlocked). Exposed for deterministic
|
|
75
|
+
* concurrency tests; `hooks.afterStaleUnlink` runs between removing a stale lock and re-creating it,
|
|
76
|
+
* letting a test interleave a competing stealer in that exact window.
|
|
77
|
+
*/
|
|
78
|
+
acquireLock(lockPath: string, token: string, staleMs?: number, hooks?: {
|
|
79
|
+
afterStaleUnlink?: () => void;
|
|
80
|
+
}): boolean;
|
|
81
|
+
/** @internal Release a lock ONLY if its on-disk token matches ours — never delete a stealer's lock. */
|
|
82
|
+
releaseLock(lockPath: string, token: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* Aggregate integrity check across the project's DURABLE areas (plus the org area when configured).
|
|
85
|
+
* Covers memory (all scopes), conversations, indexes, and the record stores that hold real state —
|
|
86
|
+
* mcp, executions, artifacts. `cache` is intentionally excluded (disposable — `cleanup` clears it
|
|
87
|
+
* wholesale, so a checksum issue there is noise); `learning`/`preferences` are store/user-wide rather
|
|
88
|
+
* than project-scoped and are left to a future store-wide check.
|
|
89
|
+
*/
|
|
70
90
|
check(): IntegrityIssue[];
|
|
71
91
|
}
|
package/dist/store/store.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* per-project lock, and an aggregate integrity check. In stateless mode every area is a NullArea.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import { systemClock } from '../util/clock.js';
|
|
9
10
|
import { FileArea, NullArea } from './area.js';
|
|
@@ -98,10 +99,10 @@ export class RuntimeStore {
|
|
|
98
99
|
stampMeta() {
|
|
99
100
|
if (!this.enabled || this.metaStamped)
|
|
100
101
|
return;
|
|
101
|
-
mkdirSync(this.projectDir, { recursive: true });
|
|
102
|
+
mkdirSync(this.projectDir, { recursive: true, mode: 0o700 });
|
|
102
103
|
const metaPath = join(this.projectDir, 'meta.json');
|
|
103
104
|
if (!existsSync(metaPath))
|
|
104
|
-
writeFileSync(metaPath, JSON.stringify({ version: STORE_VERSION, projectId: this.projectId }));
|
|
105
|
+
writeFileSync(metaPath, JSON.stringify({ version: STORE_VERSION, projectId: this.projectId }), { mode: 0o600 });
|
|
105
106
|
this.metaStamped = true;
|
|
106
107
|
}
|
|
107
108
|
/** The stored version, or undefined when absent — a migration hook seam for future versions. */
|
|
@@ -119,43 +120,97 @@ export class RuntimeStore {
|
|
|
119
120
|
/**
|
|
120
121
|
* Advisory per-project lock. Best-effort: acquires an exclusive lock file, steals a stale one, and
|
|
121
122
|
* otherwise proceeds anyway (advisory). Hard mutual exclusion for executions is a Phase 7 lease.
|
|
123
|
+
*
|
|
124
|
+
* The lock carries an ownership TOKEN so stealing a stale lock is atomic (unlink + exclusive re-create —
|
|
125
|
+
* exactly one racer wins) and release only removes a lock we still hold, never a stealer's.
|
|
122
126
|
*/
|
|
123
127
|
withLock(fn, staleMs = 30_000) {
|
|
124
128
|
if (!this.enabled)
|
|
125
129
|
return fn();
|
|
126
|
-
mkdirSync(this.projectDir, { recursive: true });
|
|
130
|
+
mkdirSync(this.projectDir, { recursive: true, mode: 0o700 });
|
|
127
131
|
const lockPath = join(this.projectDir, '.lock');
|
|
128
|
-
|
|
132
|
+
const token = randomBytes(16).toString('hex');
|
|
133
|
+
const held = this.acquireLock(lockPath, token, staleMs);
|
|
129
134
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
return fn();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (held)
|
|
139
|
+
this.releaseLock(lockPath, token);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* @internal Acquire the lock file exclusively, stealing it only if stale. Returns whether WE hold it
|
|
144
|
+
* (a live foreign lock ⇒ false ⇒ the caller proceeds advisory, unlocked). Exposed for deterministic
|
|
145
|
+
* concurrency tests; `hooks.afterStaleUnlink` runs between removing a stale lock and re-creating it,
|
|
146
|
+
* letting a test interleave a competing stealer in that exact window.
|
|
147
|
+
*/
|
|
148
|
+
acquireLock(lockPath, token, staleMs = 30_000, hooks) {
|
|
149
|
+
const content = JSON.stringify({ pid: process.pid, token, acquiredAt: this.clock.now() });
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(lockPath, content, { flag: 'wx', mode: 0o600 }); // exclusive create — no lock held
|
|
152
|
+
return true;
|
|
132
153
|
}
|
|
133
154
|
catch {
|
|
134
|
-
//
|
|
155
|
+
// A lock exists. Steal ONLY if stale, and atomically: remove it, then race an exclusive re-create —
|
|
156
|
+
// exactly one concurrent stealer's `wx` can succeed.
|
|
157
|
+
let stale;
|
|
158
|
+
try {
|
|
159
|
+
stale = this.clock.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false; // vanished/unreadable mid-check — proceed advisory, unlocked
|
|
163
|
+
}
|
|
164
|
+
if (!stale)
|
|
165
|
+
return false; // a live lock — do not steal; proceed advisory, unlocked
|
|
135
166
|
try {
|
|
136
|
-
|
|
137
|
-
if (age > staleMs) {
|
|
138
|
-
writeFileSync(lockPath, String(process.pid));
|
|
139
|
-
held = true;
|
|
140
|
-
}
|
|
167
|
+
rmSync(lockPath); // drop the stale lock (a competing stealer may have already removed it)
|
|
141
168
|
}
|
|
142
169
|
catch {
|
|
143
|
-
/*
|
|
170
|
+
/* already gone — still race the exclusive create below */
|
|
171
|
+
}
|
|
172
|
+
hooks?.afterStaleUnlink?.();
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(lockPath, content, { flag: 'wx', mode: 0o600 });
|
|
175
|
+
return true; // won the steal
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false; // a competing stealer won the race — proceed advisory, unlocked
|
|
144
179
|
}
|
|
145
180
|
}
|
|
181
|
+
}
|
|
182
|
+
/** @internal Release a lock ONLY if its on-disk token matches ours — never delete a stealer's lock. */
|
|
183
|
+
releaseLock(lockPath, token) {
|
|
146
184
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
finally {
|
|
150
|
-
if (held && existsSync(lockPath))
|
|
185
|
+
const cur = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
186
|
+
if (cur.token === token)
|
|
151
187
|
rmSync(lockPath);
|
|
152
188
|
}
|
|
189
|
+
catch {
|
|
190
|
+
/* lock gone or unreadable — nothing of ours to release */
|
|
191
|
+
}
|
|
153
192
|
}
|
|
154
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Aggregate integrity check across the project's DURABLE areas (plus the org area when configured).
|
|
195
|
+
* Covers memory (all scopes), conversations, indexes, and the record stores that hold real state —
|
|
196
|
+
* mcp, executions, artifacts. `cache` is intentionally excluded (disposable — `cleanup` clears it
|
|
197
|
+
* wholesale, so a checksum issue there is noise); `learning`/`preferences` are store/user-wide rather
|
|
198
|
+
* than project-scoped and are left to a future store-wide check.
|
|
199
|
+
*/
|
|
155
200
|
check() {
|
|
156
201
|
if (!this.enabled)
|
|
157
202
|
return [];
|
|
158
203
|
const org = this.orgDir ? this.memory('organization').check() : [];
|
|
159
|
-
return [
|
|
204
|
+
return [
|
|
205
|
+
...this.memory('project').check(),
|
|
206
|
+
...this.memory('user').check(),
|
|
207
|
+
...this.memory('repository').check(),
|
|
208
|
+
...org,
|
|
209
|
+
...this.conversations().check(),
|
|
210
|
+
...this.indexes().check(),
|
|
211
|
+
...this.mcp().check(),
|
|
212
|
+
...this.executions().check(),
|
|
213
|
+
...this.artifacts().check(),
|
|
214
|
+
];
|
|
160
215
|
}
|
|
161
216
|
}
|
|
@@ -11,7 +11,9 @@ export class FileSink {
|
|
|
11
11
|
constructor(path) {
|
|
12
12
|
this.path = path;
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
// Owner-only by default (0700 dir / 0600 file): the spool commonly lives under `.ai-runtime/` and
|
|
15
|
+
// carries routing/perf metadata. Mode applies only on creation; POSIX-only, a no-op on Windows.
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
15
17
|
}
|
|
16
18
|
catch {
|
|
17
19
|
/* ignore — a telemetry setup failure must not break the router */
|
|
@@ -19,7 +21,7 @@ export class FileSink {
|
|
|
19
21
|
}
|
|
20
22
|
emit(event) {
|
|
21
23
|
try {
|
|
22
|
-
appendFileSync(this.path, `${JSON.stringify(redact(event))}\n
|
|
24
|
+
appendFileSync(this.path, `${JSON.stringify(redact(event))}\n`, { mode: 0o600 });
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
25
27
|
/* a telemetry write must never fail a run */
|
|
@@ -17,15 +17,25 @@ export interface OtlpSinkOptions {
|
|
|
17
17
|
authHeader?: string;
|
|
18
18
|
/** Flush when this many events are buffered (default 20). */
|
|
19
19
|
batchSize?: number;
|
|
20
|
+
/** Per-POST deadline (ms, default 5000). Bounds the shutdown drain so a stalled collector can never
|
|
21
|
+
* hang process exit — mirrors the AbortSignal.timeout every provider HTTP call already uses. */
|
|
22
|
+
timeoutMs?: number;
|
|
20
23
|
}
|
|
21
24
|
export declare class OtlpSink implements TelemetrySink {
|
|
22
25
|
private readonly endpoint;
|
|
23
26
|
private readonly fetchImpl;
|
|
24
27
|
private readonly authHeader?;
|
|
25
28
|
private readonly batchSize;
|
|
29
|
+
private readonly timeoutMs;
|
|
26
30
|
private buffer;
|
|
31
|
+
/** In-flight POSTs, so `flush()` (called on shutdown) can await them instead of dropping them. */
|
|
32
|
+
private readonly inflight;
|
|
27
33
|
constructor(opts: OtlpSinkOptions);
|
|
28
34
|
emit(event: TelemetryEvent): void;
|
|
29
|
-
/**
|
|
30
|
-
|
|
35
|
+
/**
|
|
36
|
+
* POST any buffered batch, then await every in-flight POST. Awaitable so `close()` truly drains before
|
|
37
|
+
* exit — a short-lived process that emitted fewer than `batchSize` events no longer loses them. Every
|
|
38
|
+
* network error is still swallowed; telemetry must never fail a run.
|
|
39
|
+
*/
|
|
40
|
+
flush(): Promise<void>;
|
|
31
41
|
}
|
|
@@ -35,42 +35,57 @@ export class OtlpSink {
|
|
|
35
35
|
fetchImpl;
|
|
36
36
|
authHeader;
|
|
37
37
|
batchSize;
|
|
38
|
+
timeoutMs;
|
|
38
39
|
buffer = [];
|
|
40
|
+
/** In-flight POSTs, so `flush()` (called on shutdown) can await them instead of dropping them. */
|
|
41
|
+
inflight = new Set();
|
|
39
42
|
constructor(opts) {
|
|
40
43
|
this.endpoint = opts.endpoint;
|
|
41
44
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
42
45
|
if (opts.authHeader)
|
|
43
46
|
this.authHeader = opts.authHeader;
|
|
44
47
|
this.batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : 20;
|
|
48
|
+
this.timeoutMs = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 5_000;
|
|
45
49
|
}
|
|
46
50
|
emit(event) {
|
|
47
51
|
this.buffer.push(redact(event));
|
|
52
|
+
// At the batch threshold, kick off a POST but don't block the caller (telemetry is off the hot path).
|
|
48
53
|
if (this.buffer.length >= this.batchSize)
|
|
49
|
-
this.flush();
|
|
54
|
+
void this.flush();
|
|
50
55
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
/**
|
|
57
|
+
* POST any buffered batch, then await every in-flight POST. Awaitable so `close()` truly drains before
|
|
58
|
+
* exit — a short-lived process that emitted fewer than `batchSize` events no longer loses them. Every
|
|
59
|
+
* network error is still swallowed; telemetry must never fail a run.
|
|
60
|
+
*/
|
|
61
|
+
async flush() {
|
|
62
|
+
if (this.buffer.length > 0) {
|
|
63
|
+
const batch = this.buffer;
|
|
64
|
+
this.buffer = [];
|
|
65
|
+
const payload = {
|
|
66
|
+
resourceLogs: [
|
|
67
|
+
{
|
|
68
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: 'ai-runtime' } }] },
|
|
69
|
+
scopeLogs: [{ scope: { name: 'ai-runtime.router' }, logRecords: batch.map(toLogRecord) }],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
const headers = { 'content-type': 'application/json' };
|
|
74
|
+
if (this.authHeader)
|
|
75
|
+
headers.authorization = this.authHeader;
|
|
76
|
+
const post = (async () => {
|
|
77
|
+
try {
|
|
78
|
+
// A bounded signal so a stalled collector can never hang the awaited shutdown drain.
|
|
79
|
+
await this.fetchImpl(this.endpoint, { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* a telemetry export must never fail a run (a timeout abort lands here too) */
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
this.inflight.add(post);
|
|
86
|
+
void post.finally(() => this.inflight.delete(post));
|
|
74
87
|
}
|
|
88
|
+
// Drain in-flight POSTs (including the one just started). allSettled: a failed POST never blocks close.
|
|
89
|
+
await Promise.allSettled([...this.inflight]);
|
|
75
90
|
}
|
|
76
91
|
}
|
|
@@ -6,6 +6,9 @@ import type { TelemetryEvent } from '../types.js';
|
|
|
6
6
|
export interface TelemetrySink {
|
|
7
7
|
emit(event: TelemetryEvent): void;
|
|
8
8
|
events?(): TelemetryEvent[];
|
|
9
|
+
/** Optional: drain any buffered/in-flight export before shutdown. Called by AI.close() / Runtime.close().
|
|
10
|
+
* A sink with nothing to drain (memory/null/file) omits it; a batching/network sink implements it. */
|
|
11
|
+
flush?(): void | Promise<void>;
|
|
9
12
|
}
|
|
10
13
|
export declare class MemorySink implements TelemetrySink {
|
|
11
14
|
private readonly limit;
|
|
@@ -33,4 +36,6 @@ export declare class MultiSink implements TelemetrySink {
|
|
|
33
36
|
constructor(sinks: TelemetrySink[]);
|
|
34
37
|
emit(event: TelemetryEvent): void;
|
|
35
38
|
events(): TelemetryEvent[];
|
|
39
|
+
/** Drain every child sink that can flush; one failing flush never blocks the others. */
|
|
40
|
+
flush(): Promise<void>;
|
|
36
41
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shell tool — allowlist-FIRST: a command runs only if `shell.enabled` AND its argv[0] is in the
|
|
3
|
-
* allowlist; anything else needs interactive approval.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* allowlist; anything else needs interactive approval. Two denylists are backstops that require approval
|
|
4
|
+
* EVEN when allowlisted: destructive commands (rm -rf, git reset --hard, force-push, sudo, …) and
|
|
5
|
+
* eval-capable commands (interpreters with inline code, package/script runners, container exec, argv
|
|
6
|
+
* indirection). Runs in the workspace with a timeout, abort signal, and a secret-free environment.
|
|
6
7
|
*/
|
|
7
8
|
import type { Tool } from '../tool.js';
|
|
8
9
|
import type { CommandRunner } from '../runner.js';
|
|
@@ -12,6 +13,32 @@ import type { CommandRunner } from '../runner.js';
|
|
|
12
13
|
* `rm --recursive --force` and `git push +main` are caught, not just `rm -rf`.
|
|
13
14
|
*/
|
|
14
15
|
export declare function isDestructive(command: string, args?: string[]): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether a command can execute ARBITRARY code through its arguments — an interpreter, a package/script
|
|
18
|
+
* runner, an exec-wrapper, a container escape, or an argv-indirection tool. The allowlist only matches
|
|
19
|
+
* argv[0], so allowlisting `npm` or `node` would otherwise silently grant `npm exec …`/`node -e …` = full
|
|
20
|
+
* code execution outside the jail. Such invocations are escalated to human approval (like `isDestructive`),
|
|
21
|
+
* never hard-denied.
|
|
22
|
+
*
|
|
23
|
+
* BEST-EFFORT, NOT EXHAUSTIVE. This is a denylist of well-known code-execution vectors; it cannot cover
|
|
24
|
+
* every interpreter, wrapper, or runner in existence. Allowlisting an interpreter (python, node, awk, …),
|
|
25
|
+
* an exec-wrapper (nice, timeout, …), or a package manager INHERENTLY grants code execution — operators
|
|
26
|
+
* should allowlist specific leaf tools, not code-execution engines. `docs/security.md` states this limit.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NOT flagged (documented scope): `npm|pnpm|yarn install|ci|add|rebuild|link` and lifecycle
|
|
29
|
+
* scripts `npm test|start|stop|restart` — installing dependencies runs their lifecycle scripts BY DESIGN
|
|
30
|
+
* and is the single most common allowlisted operation; and a plain `make`/`make <target>` (the workspace's
|
|
31
|
+
* own Makefile, same trust domain as `npm test`). Flagging those would prompt on the common case while
|
|
32
|
+
* `node script.js` stays open — theater, not security. A NON-default makefile (`make -f`) IS flagged.
|
|
33
|
+
*
|
|
34
|
+
* KNOWN RESIDUAL (accepted, verified by an adversarial bypass hunt): running a SCRIPT FILE via an
|
|
35
|
+
* interpreter (`tclsh x.tcl`, `deno run x.ts`) is not flagged — that is the same file-execution case as
|
|
36
|
+
* `node build.js`, deliberately allowed; dependency-install lifecycle scripts (above); the obscure sed/
|
|
37
|
+
* `rename` `e`-flag exec (reliable detection needs full script parsing); and git aliases / custom
|
|
38
|
+
* `git-<x>` subcommands (require a pre-existing gitconfig alias or a git-* binary the model cannot plant
|
|
39
|
+
* from inside the jail). These reflect the best-effort nature above; `docs/security.md` states the limit.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isEvalCapable(command: string, args?: string[]): boolean;
|
|
15
42
|
export declare function createShellTool(runner?: CommandRunner): Tool;
|
|
16
43
|
/** The shell tool with the default (real) runner. */
|
|
17
44
|
export declare const shellTool: Tool;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shell tool — allowlist-FIRST: a command runs only if `shell.enabled` AND its argv[0] is in the
|
|
3
|
-
* allowlist; anything else needs interactive approval.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* allowlist; anything else needs interactive approval. Two denylists are backstops that require approval
|
|
4
|
+
* EVEN when allowlisted: destructive commands (rm -rf, git reset --hard, force-push, sudo, …) and
|
|
5
|
+
* eval-capable commands (interpreters with inline code, package/script runners, container exec, argv
|
|
6
|
+
* indirection). Runs in the workspace with a timeout, abort signal, and a secret-free environment.
|
|
6
7
|
*/
|
|
8
|
+
import { resolve, relative, isAbsolute } from 'node:path';
|
|
9
|
+
import { realpathSync } from 'node:fs';
|
|
7
10
|
import { authorize, denied } from '../tool.js';
|
|
8
11
|
import { defaultRunner, safeEnv } from '../runner.js';
|
|
9
12
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -51,6 +54,206 @@ export function isDestructive(command, args = []) {
|
|
|
51
54
|
}
|
|
52
55
|
return DESTRUCTIVE_STRING.some((re) => re.test(full));
|
|
53
56
|
}
|
|
57
|
+
/** Version-suffixed interpreters are ubiquitous (`python3.12`, `python3.11`) — match them all. */
|
|
58
|
+
const isPython = (cmd) => /^python(?:\d+(?:\.\d+)?)?$/.test(cmd);
|
|
59
|
+
/**
|
|
60
|
+
* Whether argv carries any of the given short (single-letter) or long eval flags, in ANY spelling the
|
|
61
|
+
* real interpreter accepts: bare (`-e`, `--eval`), attached (`-eCODE`, `-cCODE`, `--eval=CODE`), or
|
|
62
|
+
* clustered short (`-pe`). Exact-equality matching missed every attached/clustered form — which the
|
|
63
|
+
* binaries all execute — so match structurally (mirrors `hasRecursiveFlag`'s prefix matching above).
|
|
64
|
+
*/
|
|
65
|
+
function hasEvalFlag(args, short, long) {
|
|
66
|
+
return args.some((a) => {
|
|
67
|
+
if (a.startsWith('--'))
|
|
68
|
+
return long.includes(a.slice(2).split('=')[0] ?? '');
|
|
69
|
+
if (a.startsWith('-') && a.length > 1) {
|
|
70
|
+
const body = a.slice(1);
|
|
71
|
+
if (short.includes(body[0]))
|
|
72
|
+
return true; // -e, -eCODE, -cCODE, -mMOD
|
|
73
|
+
if (/^[A-Za-z]+$/.test(body))
|
|
74
|
+
return body.split('').some((c) => short.includes(c)); // clustered -pe
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a gated subcommand token appears ANYWHERE in argv. Robust to leading global options that take a
|
|
81
|
+
* value (`npm -C <dir> exec …`, `docker --context <c> run …`), which shift the subcommand out of args[0].
|
|
82
|
+
* Over-escalation (a package literally named `run`) only prompts — it never denies — so scanning is safe.
|
|
83
|
+
*/
|
|
84
|
+
function hasSubcommand(args, gated) {
|
|
85
|
+
return args.some((a) => gated.includes(a));
|
|
86
|
+
}
|
|
87
|
+
/** pnpm/yarn also RUN package.json scripts directly (`yarn build` == `yarn run build`), so a leading
|
|
88
|
+
* bare token that is not a known package-management builtin is a script invocation and must escalate.
|
|
89
|
+
* Kept in parity with npm's excluded lifecycle scripts (test/start/stop/restart). */
|
|
90
|
+
const YARN_PNPM_SAFE = new Set([
|
|
91
|
+
'install', 'i', 'add', 'remove', 'rm', 'up', 'update', 'upgrade', 'why', 'list', 'ls', 'info', 'outdated',
|
|
92
|
+
'audit', 'dedupe', 'init', 'link', 'unlink', 'pack', 'publish', 'version', 'config', 'cache', 'licenses',
|
|
93
|
+
'import', 'login', 'logout', 'store', 'fetch', 'patch', 'set', 'bin', 'test', 'start', 'stop', 'restart',
|
|
94
|
+
]);
|
|
95
|
+
/**
|
|
96
|
+
* Interpreters whose "run a file" use (e.g. `node build.js`) is benign but whose inline-code/preload/
|
|
97
|
+
* module flags execute arbitrary code. Keyed basename → the code-bearing short/long flags. Completing an
|
|
98
|
+
* interpreter here (e.g. node/ruby `-r`, perl `-M`) closes the same class the -e/-c gates already cover.
|
|
99
|
+
*/
|
|
100
|
+
const INTERPRETER_EVAL_FLAGS = [
|
|
101
|
+
{ match: (c) => c === 'node' || c === 'nodejs' || c === 'bun', short: ['e', 'p', 'r'], long: ['eval', 'print', 'require'] },
|
|
102
|
+
{ match: isPython, short: ['c', 'm'], long: [] },
|
|
103
|
+
{ match: (c) => c === 'perl', short: ['e', 'E', 'M'], long: [] },
|
|
104
|
+
{ match: (c) => c === 'ruby', short: ['e', 'r'], long: ['require'] },
|
|
105
|
+
{ match: (c) => c === 'php', short: ['r', 'R'], long: ['run'] },
|
|
106
|
+
{ match: (c) => c === 'lua' || c === 'luajit', short: ['e'], long: [] },
|
|
107
|
+
{ match: (c) => c === 'Rscript' || c === 'R', short: ['e'], long: ['eval'] },
|
|
108
|
+
{ match: (c) => c === 'julia', short: ['e', 'E'], long: ['eval'] },
|
|
109
|
+
{ match: (c) => c === 'elixir' || c === 'iex', short: ['e'], long: ['eval'] },
|
|
110
|
+
{ match: (c) => c === 'ghc' || c === 'runghc' || c === 'runhaskell', short: ['e'], long: [] },
|
|
111
|
+
{ match: (c) => c === 'groovy' || c === 'scala', short: ['e'], long: ['eval'] },
|
|
112
|
+
{ match: (c) => c === 'osascript', short: ['e'], long: [] },
|
|
113
|
+
];
|
|
114
|
+
/**
|
|
115
|
+
* Wrapper commands whose entire purpose is to run ANOTHER command passed as an argument — so the argv0
|
|
116
|
+
* allowlist check never sees the real command. Flagged whenever they carry a non-flag argument (env/xargs
|
|
117
|
+
* are handled with the always-runners above). This is a curated set of the well-known wrappers, not an
|
|
118
|
+
* exhaustive one — see the isEvalCapable docstring on the best-effort nature of this backstop.
|
|
119
|
+
*/
|
|
120
|
+
const EXEC_WRAPPERS = new Set([
|
|
121
|
+
'nice', 'nohup', 'timeout', 'stdbuf', 'setsid', 'taskset', 'chrt', 'ionice', 'time', 'flock', 'setarch',
|
|
122
|
+
'unshare', 'nsenter', 'strace', 'ltrace', 'valgrind', 'watch', 'script', 'runuser', 'su', 'parallel',
|
|
123
|
+
'setpriv',
|
|
124
|
+
]);
|
|
125
|
+
const isAwk = (cmd) => cmd === 'awk' || cmd === 'gawk' || cmd === 'mawk' || cmd === 'nawk';
|
|
126
|
+
/**
|
|
127
|
+
* Whether a command can execute ARBITRARY code through its arguments — an interpreter, a package/script
|
|
128
|
+
* runner, an exec-wrapper, a container escape, or an argv-indirection tool. The allowlist only matches
|
|
129
|
+
* argv[0], so allowlisting `npm` or `node` would otherwise silently grant `npm exec …`/`node -e …` = full
|
|
130
|
+
* code execution outside the jail. Such invocations are escalated to human approval (like `isDestructive`),
|
|
131
|
+
* never hard-denied.
|
|
132
|
+
*
|
|
133
|
+
* BEST-EFFORT, NOT EXHAUSTIVE. This is a denylist of well-known code-execution vectors; it cannot cover
|
|
134
|
+
* every interpreter, wrapper, or runner in existence. Allowlisting an interpreter (python, node, awk, …),
|
|
135
|
+
* an exec-wrapper (nice, timeout, …), or a package manager INHERENTLY grants code execution — operators
|
|
136
|
+
* should allowlist specific leaf tools, not code-execution engines. `docs/security.md` states this limit.
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT flagged (documented scope): `npm|pnpm|yarn install|ci|add|rebuild|link` and lifecycle
|
|
139
|
+
* scripts `npm test|start|stop|restart` — installing dependencies runs their lifecycle scripts BY DESIGN
|
|
140
|
+
* and is the single most common allowlisted operation; and a plain `make`/`make <target>` (the workspace's
|
|
141
|
+
* own Makefile, same trust domain as `npm test`). Flagging those would prompt on the common case while
|
|
142
|
+
* `node script.js` stays open — theater, not security. A NON-default makefile (`make -f`) IS flagged.
|
|
143
|
+
*
|
|
144
|
+
* KNOWN RESIDUAL (accepted, verified by an adversarial bypass hunt): running a SCRIPT FILE via an
|
|
145
|
+
* interpreter (`tclsh x.tcl`, `deno run x.ts`) is not flagged — that is the same file-execution case as
|
|
146
|
+
* `node build.js`, deliberately allowed; dependency-install lifecycle scripts (above); the obscure sed/
|
|
147
|
+
* `rename` `e`-flag exec (reliable detection needs full script parsing); and git aliases / custom
|
|
148
|
+
* `git-<x>` subcommands (require a pre-existing gitconfig alias or a git-* binary the model cannot plant
|
|
149
|
+
* from inside the jail). These reflect the best-effort nature above; `docs/security.md` states the limit.
|
|
150
|
+
*/
|
|
151
|
+
export function isEvalCapable(command, args = []) {
|
|
152
|
+
const cmd = baseName(command);
|
|
153
|
+
const sub = args.find((a) => !a.startsWith('-')); // effective subcommand (skips leading -flags)
|
|
154
|
+
const hasNonFlagArg = args.some((a) => !a.startsWith('-'));
|
|
155
|
+
// Shells: the only reason to invoke one from an argv-based tool is expansion/chaining we cannot inspect.
|
|
156
|
+
if (cmd === 'sh' || cmd === 'bash' || cmd === 'zsh' || cmd === 'dash' || cmd === 'ksh' || cmd === 'fish' || cmd === 'tcsh' || cmd === 'csh' || cmd === 'rc')
|
|
157
|
+
return true;
|
|
158
|
+
// Registry/script runners and argv-indirection tools: arbitrary code regardless of args.
|
|
159
|
+
if (cmd === 'npx' || cmd === 'pnpx' || cmd === 'bunx' || cmd === 'corepack' || cmd === 'env' || cmd === 'xargs')
|
|
160
|
+
return true;
|
|
161
|
+
// Exec-wrappers: run another command passed as their argument (the argv0 check never sees it).
|
|
162
|
+
if (EXEC_WRAPPERS.has(cmd) && hasNonFlagArg)
|
|
163
|
+
return true;
|
|
164
|
+
// Interpreters flagged on a code/eval/preload flag (glued/clustered forms included — see hasEvalFlag).
|
|
165
|
+
for (const it of INTERPRETER_EVAL_FLAGS)
|
|
166
|
+
if (it.match(cmd) && hasEvalFlag(args, it.short, it.long))
|
|
167
|
+
return true;
|
|
168
|
+
// The awk family takes its PROGRAM as the first positional (or gawk's -e/--source): always inline code.
|
|
169
|
+
// A -f/--file program is file-based (treated like a script file, unflagged, mirroring `node build.js`).
|
|
170
|
+
if (isAwk(cmd) && !args.some((a) => a === '-f' || a === '--file' || a.startsWith('--file=')) && (hasNonFlagArg || args.some((a) => a === '-e' || a === '--source' || a.startsWith('--source='))))
|
|
171
|
+
return true;
|
|
172
|
+
// deno: `eval`/`task`, inline eval flags, a remote URL target, or an allow-all/allow-run/allow-ffi grant.
|
|
173
|
+
if (cmd === 'deno' && (sub === 'eval' || sub === 'task' || hasEvalFlag(args, ['e', 'A'], ['eval', 'allow-all', 'allow-run', 'allow-ffi']) || args.some((a) => /^https?:\/\//.test(a))))
|
|
174
|
+
return true;
|
|
175
|
+
// Package-manager run/exec/init/create (fetch-and-run or run a package.json script), and a config
|
|
176
|
+
// mutation that can repoint the script shell. install/ci/add/… stay unflagged — see docstring.
|
|
177
|
+
if (cmd === 'npm' && hasSubcommand(args, ['run', 'run-script', 'exec', 'x', 'init', 'create', 'explore']))
|
|
178
|
+
return true;
|
|
179
|
+
if (cmd === 'npm' && args.includes('config') && args.some((a) => a === 'set' || a === 'edit'))
|
|
180
|
+
return true;
|
|
181
|
+
if (cmd === 'bun' && hasSubcommand(args, ['run', 'x', 'exec', 'create']))
|
|
182
|
+
return true;
|
|
183
|
+
if (cmd === 'pnpm' || cmd === 'yarn') {
|
|
184
|
+
if (hasSubcommand(args, ['run', 'exec', 'dlx', 'create', 'node', 'x']))
|
|
185
|
+
return true;
|
|
186
|
+
// Direct script form: `yarn <script>` with no `run`. Only when it is the leading bare token (a global
|
|
187
|
+
// flag's value is never mistaken for a script — those runner forms are caught by the scan above).
|
|
188
|
+
if (sub !== undefined && args[0] === sub && !YARN_PNPM_SAFE.has(sub))
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
// Containers: `run`/`exec` execute an arbitrary command inside/as a container.
|
|
192
|
+
if ((cmd === 'docker' || cmd === 'podman' || cmd === 'nerdctl') && hasSubcommand(args, ['run', 'exec']))
|
|
193
|
+
return true;
|
|
194
|
+
// find -exec/-execdir/-ok/-okdir run a command per match.
|
|
195
|
+
if (cmd === 'find' && args.some((a) => a === '-exec' || a === '-execdir' || a === '-ok' || a === '-okdir'))
|
|
196
|
+
return true;
|
|
197
|
+
// git: config/exec-path injection, and rebase's per-commit command exec.
|
|
198
|
+
if (cmd === 'git') {
|
|
199
|
+
if (args.some((a) => a === '-c' || a.startsWith('--exec-path')))
|
|
200
|
+
return true;
|
|
201
|
+
if (args[0] === 'rebase' && args.some((a) => a === '-x' || a === '--exec' || a.startsWith('--exec=')))
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
// make loading a NON-default makefile runs an arbitrary recipe file (a plain `make`/`make <target>`
|
|
205
|
+
// uses the workspace's own Makefile and stays unflagged — see docstring).
|
|
206
|
+
if (cmd === 'make' && args.some((a) => a === '-f' || /^-f./.test(a) || a === '--file' || a === '--makefile' || a.startsWith('--file=') || a.startsWith('--makefile=')))
|
|
207
|
+
return true;
|
|
208
|
+
// Remote-exec tools with a LOCAL-command option (ProxyCommand/LocalCommand, rsync's remote-shell).
|
|
209
|
+
if ((cmd === 'ssh' || cmd === 'scp' || cmd === 'sftp') && args.some((a, i) => (a === '-o' && /command\s*=/i.test(args[i + 1] ?? '')) || /^-o(Proxy|Local)Command/i.test(a) || /^(Proxy|Local)Command\s*=/i.test(a)))
|
|
210
|
+
return true;
|
|
211
|
+
if (cmd === 'rsync' && args.some((a) => a === '-e' || a === '--rsh' || a.startsWith('--rsh=')))
|
|
212
|
+
return true;
|
|
213
|
+
// Non-interactive editor command execution (`vim -c '!cmd'`, `emacs --batch --eval`).
|
|
214
|
+
if ((cmd === 'vim' || cmd === 'vi' || cmd === 'nvim' || cmd === 'ex' || cmd === 'view') && args.some((a) => a === '-c' || a === '--cmd' || a === '-S' || a.startsWith('+')))
|
|
215
|
+
return true;
|
|
216
|
+
if (cmd === 'emacs' && args.some((a) => a === '--eval' || a === '--batch' || a === '-batch' || a === '--script'))
|
|
217
|
+
return true;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
/** Canonicalize a path (resolving symlinks AND case on case-insensitive filesystems) when it exists;
|
|
221
|
+
* fall back to a lexical resolve when it does not (a not-yet-created path, or a synthetic test root). */
|
|
222
|
+
function canonical(p) {
|
|
223
|
+
try {
|
|
224
|
+
return realpathSync(p);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return resolve(p);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether an absolute path resolves at or under the workspace root (where the model can write files, so
|
|
232
|
+
* its basename must NOT be trusted against the allowlist). realpath is used so a workspace-internal file
|
|
233
|
+
* reached via a symlink or a case-variant path (`/users/…` vs `/Users/…` on a case-insensitive FS) is
|
|
234
|
+
* still recognized as inside — a lexical compare alone missed both.
|
|
235
|
+
*/
|
|
236
|
+
function isInsideWorkspace(root, command) {
|
|
237
|
+
const rel = relative(canonical(root), canonical(command));
|
|
238
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Allowlist matching. An exact string match always wins (back-compat). For an ABSOLUTE path (…/npm,
|
|
242
|
+
* C:\…\npm) the basename is also matched against the allowlist, so a real `/usr/bin/npm` counts as `npm`
|
|
243
|
+
* — UNLESS the path resolves INSIDE the workspace, where the model can write an executable and point at
|
|
244
|
+
* it (the same reason a relative `./bin/npm` never matches). Workspace-local and relative paths must go
|
|
245
|
+
* through interactive approval.
|
|
246
|
+
*/
|
|
247
|
+
function allowlistMatch(list, command, workspaceRoot) {
|
|
248
|
+
if (list.includes(command))
|
|
249
|
+
return true;
|
|
250
|
+
if (/^(\/|[A-Za-z]:[\\/]|\\\\)/.test(command)) {
|
|
251
|
+
if (workspaceRoot && isInsideWorkspace(workspaceRoot, command))
|
|
252
|
+
return false;
|
|
253
|
+
return list.includes(baseName(command));
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
54
257
|
export function createShellTool(runner = defaultRunner) {
|
|
55
258
|
return {
|
|
56
259
|
id: 'shell',
|
|
@@ -64,15 +267,23 @@ export function createShellTool(runner = defaultRunner) {
|
|
|
64
267
|
if (!ctx.permissions.shell.enabled)
|
|
65
268
|
return denied('PERMISSION', 'shell execution is not enabled');
|
|
66
269
|
const full = [command, ...args].join(' ');
|
|
67
|
-
const allowlisted = ctx.permissions.shell.allowedCommands.
|
|
270
|
+
const allowlisted = allowlistMatch(ctx.permissions.shell.allowedCommands, command, ctx.workspaceRoot);
|
|
68
271
|
const dangerous = isDestructive(command, args);
|
|
272
|
+
const evalCapable = isEvalCapable(command, args);
|
|
273
|
+
// Destructive OR eval-capable ⇒ a human must confirm even when the base command is allowlisted.
|
|
274
|
+
const mustConfirm = dangerous || evalCapable;
|
|
69
275
|
const allowed = await authorize(ctx, allowlisted, {
|
|
70
276
|
action: `run shell command: ${full}`,
|
|
71
|
-
risk:
|
|
72
|
-
alwaysConfirm:
|
|
277
|
+
risk: mustConfirm ? 'high' : 'medium',
|
|
278
|
+
alwaysConfirm: mustConfirm,
|
|
73
279
|
});
|
|
74
280
|
if (!allowed) {
|
|
75
|
-
|
|
281
|
+
const why = dangerous
|
|
282
|
+
? `destructive command needs approval: ${full}`
|
|
283
|
+
: evalCapable
|
|
284
|
+
? `eval-capable command needs approval: ${full}`
|
|
285
|
+
: `command not allowlisted: ${command}`;
|
|
286
|
+
return denied('PERMISSION', why);
|
|
76
287
|
}
|
|
77
288
|
const res = await runner.run(command, args, {
|
|
78
289
|
cwd: ctx.workspaceRoot,
|