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.
Files changed (85) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +30 -0
  3. package/dist/agents/admit.d.ts +9 -1
  4. package/dist/agents/admit.js +10 -2
  5. package/dist/agents/envelope.d.ts +21 -0
  6. package/dist/agents/envelope.js +39 -5
  7. package/dist/agents/finding.d.ts +9 -3
  8. package/dist/agents/finding.js +14 -3
  9. package/dist/agents/worker.d.ts +3 -0
  10. package/dist/agents/worker.js +4 -1
  11. package/dist/cli/cli.js +8 -1
  12. package/dist/cli/commands/cleanup.js +11 -3
  13. package/dist/cli/commands/doctor.js +1 -1
  14. package/dist/cli/commands/run.js +6 -0
  15. package/dist/cli/commands/skills.js +9 -2
  16. package/dist/cli/interactive/repl.js +12 -2
  17. package/dist/cli/interactive/session.d.ts +2 -0
  18. package/dist/cli/interactive/session.js +6 -2
  19. package/dist/config/schema.js +19 -1
  20. package/dist/conversations/conversations.d.ts +6 -1
  21. package/dist/conversations/conversations.js +15 -8
  22. package/dist/core/fallback/fallback.d.ts +7 -0
  23. package/dist/core/fallback/fallback.js +15 -2
  24. package/dist/core/health/monitor.d.ts +6 -0
  25. package/dist/core/health/monitor.js +15 -2
  26. package/dist/core/router/confidence.js +10 -5
  27. package/dist/core/router/dimensions.d.ts +3 -1
  28. package/dist/core/router/dimensions.js +15 -5
  29. package/dist/core/router/filter.js +25 -6
  30. package/dist/core/router/normalize.js +2 -0
  31. package/dist/core/router/router.js +16 -2
  32. package/dist/core/router/scorer.d.ts +3 -0
  33. package/dist/core/router/scorer.js +17 -2
  34. package/dist/discovery/openapi.js +3 -2
  35. package/dist/executions/agentTasks.d.ts +4 -4
  36. package/dist/generation/generateAdapter.js +3 -1
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.js +3 -2
  39. package/dist/mcp/protocol.js +4 -1
  40. package/dist/memory/bm25.d.ts +7 -0
  41. package/dist/memory/bm25.js +17 -1
  42. package/dist/memory/memory.d.ts +7 -1
  43. package/dist/memory/memory.js +18 -4
  44. package/dist/orchestration/orchestrator.d.ts +2 -1
  45. package/dist/orchestration/planner.d.ts +2 -1
  46. package/dist/plugin/ai.d.ts +6 -0
  47. package/dist/plugin/ai.js +17 -2
  48. package/dist/providers/estimate.d.ts +25 -0
  49. package/dist/providers/estimate.js +55 -0
  50. package/dist/providers/factory.d.ts +3 -0
  51. package/dist/providers/factory.js +26 -5
  52. package/dist/providers/httpClient.js +4 -0
  53. package/dist/providers/httpProvider.js +4 -3
  54. package/dist/providers/mock/mockProvider.js +4 -3
  55. package/dist/runtime/config.d.ts +4 -3
  56. package/dist/runtime/config.js +14 -23
  57. package/dist/runtime/events.d.ts +6 -0
  58. package/dist/runtime/runtime.d.ts +43 -5
  59. package/dist/runtime/runtime.js +133 -25
  60. package/dist/runtime/types.d.ts +8 -1
  61. package/dist/store/area.d.ts +1 -1
  62. package/dist/store/area.js +34 -10
  63. package/dist/store/crypto.d.ts +27 -13
  64. package/dist/store/crypto.js +101 -23
  65. package/dist/store/errors.d.ts +11 -0
  66. package/dist/store/errors.js +14 -0
  67. package/dist/store/store.d.ts +21 -1
  68. package/dist/store/store.js +74 -19
  69. package/dist/telemetry/sinks/file.js +4 -2
  70. package/dist/telemetry/sinks/otlp.d.ts +12 -2
  71. package/dist/telemetry/sinks/otlp.js +39 -24
  72. package/dist/telemetry/telemetry.d.ts +5 -0
  73. package/dist/telemetry/telemetry.js +4 -0
  74. package/dist/tools/builtins/shell.d.ts +30 -3
  75. package/dist/tools/builtins/shell.js +218 -7
  76. package/dist/tools/untrusted.d.ts +1 -1
  77. package/dist/tools/untrusted.js +5 -3
  78. package/dist/types.d.ts +14 -0
  79. package/dist/verification/verify.js +10 -3
  80. package/docs/GUIDE.md +66 -1
  81. package/docs/README.md +1 -1
  82. package/docs/architecture.md +5 -1
  83. package/docs/router.md +1 -1
  84. package/docs/security.md +26 -7
  85. 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
+ }
@@ -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
- /** Aggregate integrity check across the project areas (plus the org area when configured). */
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
  }
@@ -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
- let held = false;
132
+ const token = randomBytes(16).toString('hex');
133
+ const held = this.acquireLock(lockPath, token, staleMs);
129
134
  try {
130
- writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
131
- held = true;
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
- // Held already steal if stale.
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
- const age = this.clock.now() - statSync(lockPath).mtimeMs;
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
- /* proceed advisory */
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
- return fn();
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
- /** Aggregate integrity check across the project areas (plus the org area when configured). */
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 [...this.memory('project').check(), ...this.memory('user').check(), ...this.memory('repository').check(), ...org, ...this.conversations().check(), ...this.indexes().check()];
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
- mkdirSync(dirname(path), { recursive: true });
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
- /** POST the buffered batch. Fire-and-forget; every error is swallowed. */
30
- flush(): void;
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
- /** POST the buffered batch. Fire-and-forget; every error is swallowed. */
52
- flush() {
53
- if (this.buffer.length === 0)
54
- return;
55
- const batch = this.buffer;
56
- this.buffer = [];
57
- const payload = {
58
- resourceLogs: [
59
- {
60
- resource: { attributes: [{ key: 'service.name', value: { stringValue: 'ai-runtime' } }] },
61
- scopeLogs: [{ scope: { name: 'ai-runtime.router' }, logRecords: batch.map(toLogRecord) }],
62
- },
63
- ],
64
- };
65
- const headers = { 'content-type': 'application/json' };
66
- if (this.authHeader)
67
- headers.authorization = this.authHeader;
68
- try {
69
- // Fire-and-forget: never await, never let a rejection escape (telemetry must not fail a run).
70
- void Promise.resolve(this.fetchImpl(this.endpoint, { method: 'POST', headers, body: JSON.stringify(payload) })).catch(() => { });
71
- }
72
- catch {
73
- /* a telemetry export must never fail a run */
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
  }
@@ -60,4 +60,8 @@ export class MultiSink {
60
60
  }
61
61
  return [];
62
62
  }
63
+ /** Drain every child sink that can flush; one failing flush never blocks the others. */
64
+ async flush() {
65
+ await Promise.allSettled(this.sinks.map((sink) => sink.flush?.()));
66
+ }
63
67
  }
@@ -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. A destructive-command denylist is a backstop
4
- * that requires approval EVEN when allowlisted (rm -rf, git reset --hard, force-push, sudo, …). Runs
5
- * in the workspace with a timeout, abort signal, and a secret-free environment.
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. A destructive-command denylist is a backstop
4
- * that requires approval EVEN when allowlisted (rm -rf, git reset --hard, force-push, sudo, …). Runs
5
- * in the workspace with a timeout, abort signal, and a secret-free environment.
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.includes(command);
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: dangerous ? 'high' : 'medium',
72
- alwaysConfirm: dangerous, // destructive commands need a human even when allowlisted
277
+ risk: mustConfirm ? 'high' : 'medium',
278
+ alwaysConfirm: mustConfirm,
73
279
  });
74
280
  if (!allowed) {
75
- return denied('PERMISSION', dangerous ? `destructive command needs approval: ${full}` : `command not allowlisted: ${command}`);
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,