Skip to content

Commit 7307645

Browse files
committed
feat(ai): exec seam via injected ExecContext — no bundled sandbox
Re-add ai/exec as dependency-injection: the lib ships the ExecRunner interface, the built-in `real` runner (host shell via spawn), and an ExecContext { runners: { real, sandboxed? }, resolve(trust) }. The sandboxed runner is PASSED IN by the caller — the wheelhouse owns just-bash (~40MB incl. WASM) and injects it for hook / lint-rule / CI AI usage, so the small-dist lib pulls no sandbox dependency. resolve('untrusted') throws when no sandbox was injected rather than silently using the host shell. Replaces the reverted bundled- just-bash version.
1 parent 279a4fc commit 7307645

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@
177177
"types": "./dist/ai/discover.d.mts",
178178
"default": "./dist/ai/discover.js"
179179
},
180+
"./ai/exec": {
181+
"source": "./src/ai/exec.mts",
182+
"types": "./dist/ai/exec.d.mts",
183+
"default": "./dist/ai/exec.js"
184+
},
180185
"./ai/http": {
181186
"source": "./src/ai/http.mts",
182187
"types": "./dist/ai/http.d.mts",

src/ai/exec.mts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* @file Exec-backend seam: WHERE a shell command runs, a separate axis from
3+
* WHICH model produced it (`ai/backends`). The lib owns the INTERFACE plus
4+
* the cheap built-in `real` runner (the host shell via lib `spawn`); a
5+
* SANDBOXED runner is INJECTED by the caller, never imported here. That keeps
6+
* the small-dist lib free of a heavy sandbox dependency — the fleet's sandbox
7+
* of choice (`just-bash`, ~40MB incl. WASM) is owned by the wheelhouse hook /
8+
* CI tooling and passed in, so a lib consumer that never sandboxes pays
9+
* nothing. Layering: ExecRunner.run() — the injectable primitive (real |
10+
* sandboxed) composed into ExecContext — { runners: { real, sandboxed? },
11+
* resolve(trust) } used by runShell(script, { context, trust }) — the
12+
* ergonomic entry point Pick a runner by TRUST LEVEL, never by model.
13+
* `untrusted` resolves to the sandboxed runner — which a caller that hasn't
14+
* injected one cannot run, so `resolve` throws a clear "provide a sandboxed
15+
* runner" error rather than silently falling back to the host shell. Both
16+
* runners normalize to one `ExecResult` so callers swap trust levels without
17+
* reshaping result handling.
18+
*/
19+
20+
import { spawn } from '../process/spawn/child'
21+
import { isSpawnError } from '../process/spawn/errors'
22+
23+
// Where the shell runs: the host FS (`real`) or an injected sandbox.
24+
export type ExecBackend = 'real' | 'sandboxed'
25+
26+
// Trust level of the script's source. Untrusted (model-generated, third-party,
27+
// unreviewed) routes to the sandbox; trusted routes to the host shell.
28+
export type ExecTrust = 'trusted' | 'untrusted'
29+
30+
// Normalized result shape every runner returns.
31+
export interface ExecResult {
32+
readonly stdout: string
33+
readonly stderr: string
34+
readonly exitCode: number
35+
}
36+
37+
// Per-run options handed to a runner. `files` seeds a sandbox's virtual FS and
38+
// is ignored by the real runner (which already sees the host FS).
39+
export interface ExecRunOptions {
40+
readonly cwd?: string | undefined
41+
readonly env?: Record<string, string> | undefined
42+
readonly files?: Record<string, string> | undefined
43+
readonly signal?: AbortSignal | undefined
44+
readonly stdin?: string | undefined
45+
}
46+
47+
// The injectable execution primitive: run one script, resolve to an ExecResult
48+
// (a non-zero exit is a result, never a throw — both real and injected runners
49+
// MUST honor this so callers branch on exitCode, not try/catch).
50+
export interface ExecRunner {
51+
run(script: string, options?: ExecRunOptions | undefined): Promise<ExecResult>
52+
}
53+
54+
// Registry of runners + the trust→runner policy. `real` is always present
55+
// (the lib provides it); `sandboxed` is whatever the caller injected, if any.
56+
export interface ExecContext {
57+
readonly runners: {
58+
readonly real: ExecRunner
59+
readonly sandboxed?: ExecRunner | undefined
60+
}
61+
// Map a trust level to a concrete runner. Throws when `untrusted` is asked
62+
// for but no sandboxed runner was injected — never silently downgrades to the
63+
// host shell.
64+
resolve(trust: ExecTrust): ExecRunner
65+
}
66+
67+
// The built-in `real` runner: the host shell via lib spawn. `bash -c <script>`
68+
// so the script string is interpreted, matching a sandbox's single-string exec.
69+
// Exported so a caller can compose or wrap it.
70+
export const realRunner: ExecRunner = {
71+
async run(
72+
script: string,
73+
options?: ExecRunOptions | undefined,
74+
): Promise<ExecResult> {
75+
const { cwd, env, signal } = {
76+
__proto__: null,
77+
...options,
78+
} as ExecRunOptions
79+
// NOTE: `stdin` is intentionally NOT forwarded to the real runner. lib
80+
// `spawn` exposes stdin only via the interactive `result.stdin` stream
81+
// (write/end), which doesn't fit a one-shot run; piping without closing
82+
// hangs a reader like `cat`. A script needing stdin should use a sandboxed
83+
// runner (which feeds stdin in-memory) or embed the data via a here-doc.
84+
const spawnOptions = {
85+
...(cwd ? { cwd } : {}),
86+
...(env ? { env } : {}),
87+
...(signal ? { signal } : {}),
88+
stdioString: true,
89+
}
90+
try {
91+
const result = await spawn('bash', ['-c', script], spawnOptions)
92+
return {
93+
stdout: String(result.stdout ?? ''),
94+
stderr: String(result.stderr ?? ''),
95+
// Resolved result carries the status on `code` (0 here — a non-zero
96+
// exit rejects, handled below).
97+
exitCode: typeof result.code === 'number' ? result.code : 0,
98+
}
99+
} catch (e) {
100+
// lib spawn (via @npmcli/promise-spawn) REJECTS on a non-zero exit; an
101+
// ExecRunner must surface that as a result, so normalize a SpawnError.
102+
// A non-SpawnError (failed to launch, e.g. no bash) is a genuine fault.
103+
if (!isSpawnError(e)) {
104+
throw e
105+
}
106+
return {
107+
stdout: String(e.stdout ?? ''),
108+
stderr: String(e.stderr ?? ''),
109+
// A signal-kill (code null) becomes non-zero so it isn't read as
110+
// success.
111+
exitCode: typeof e.code === 'number' ? e.code : 1,
112+
}
113+
}
114+
},
115+
}
116+
117+
// Map a trust level to a backend name. The single decision point so callers
118+
// express intent ("this script is untrusted") rather than naming a backend.
119+
export function backendForTrust(trust: ExecTrust): ExecBackend {
120+
return trust === 'trusted' ? 'real' : 'sandboxed'
121+
}
122+
123+
// Build an ExecContext. `real` is the lib's built-in runner unless overridden;
124+
// pass `sandboxed` (e.g. the wheelhouse's just-bash-backed runner) to enable the
125+
// untrusted path. `resolve` throws when the untrusted path is used without one.
126+
export function createExecContext(
127+
options?:
128+
| { real?: ExecRunner | undefined; sandboxed?: ExecRunner | undefined }
129+
| undefined,
130+
): ExecContext {
131+
const { real = realRunner, sandboxed } = {
132+
__proto__: null,
133+
...options,
134+
} as { real?: ExecRunner | undefined; sandboxed?: ExecRunner | undefined }
135+
const runners = { real, sandboxed }
136+
return {
137+
runners,
138+
resolve(trust: ExecTrust): ExecRunner {
139+
if (backendForTrust(trust) === 'real') {
140+
return runners.real
141+
}
142+
if (!runners.sandboxed) {
143+
throw new Error(
144+
'No sandboxed exec runner provided.\n' +
145+
'→ An `untrusted` script must run in a sandbox, not the host shell.\n' +
146+
'→ Fix: pass a `sandboxed` runner to createExecContext() (e.g. the ' +
147+
'wheelhouse just-bash runner), or mark the script `trusted` if it is.',
148+
)
149+
}
150+
return runners.sandboxed
151+
},
152+
}
153+
}
154+
155+
// Run `script` under the context's runner for `trust`. Defaults: a real-only
156+
// context (so `trusted` works out of the box) and `untrusted` trust (fail safe —
157+
// an unspecified caller is routed to the sandbox, which errors loudly if none
158+
// was injected rather than quietly using the host shell).
159+
export async function runShell(
160+
script: string,
161+
options?:
162+
| (ExecRunOptions & {
163+
context?: ExecContext | undefined
164+
trust?: ExecTrust | undefined
165+
})
166+
| undefined,
167+
): Promise<ExecResult> {
168+
const {
169+
context,
170+
cwd,
171+
env,
172+
files,
173+
signal,
174+
stdin,
175+
trust = 'untrusted',
176+
} = { __proto__: null, ...options } as ExecRunOptions & {
177+
context?: ExecContext | undefined
178+
trust?: ExecTrust | undefined
179+
}
180+
const ctx = context ?? createExecContext()
181+
return await ctx
182+
.resolve(trust)
183+
.run(script, { cwd, env, files, signal, stdin })
184+
}

test/unit/ai/exec.test.mts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @file Tests for the ai/exec injection seam. The lib ships the `real` runner
3+
* (host shell via spawn) and the ExecContext policy; the sandboxed runner is
4+
* INJECTED by the caller, so these tests inject a trivial fake sandbox rather
5+
* than pulling a real one. The `real` runner is exercised with side-effect-
6+
* free commands.
7+
*/
8+
9+
import { describe, expect, it } from 'vitest'
10+
11+
import {
12+
backendForTrust,
13+
createExecContext,
14+
realRunner,
15+
runShell,
16+
} from '../../../src/ai/exec.mts'
17+
18+
import type { ExecResult, ExecRunner } from '../../../src/ai/exec.mts'
19+
20+
// A fake sandboxed runner: echoes which script it got, never touches anything.
21+
const fakeSandbox: ExecRunner = {
22+
async run(script: string): Promise<ExecResult> {
23+
return { stdout: `sandboxed:${script}`, stderr: '', exitCode: 0 }
24+
},
25+
}
26+
27+
describe('backendForTrust', () => {
28+
it('maps trusted → real and untrusted → sandboxed', () => {
29+
expect(backendForTrust('trusted')).toBe('real')
30+
expect(backendForTrust('untrusted')).toBe('sandboxed')
31+
})
32+
})
33+
34+
describe('ExecContext.resolve', () => {
35+
it('resolves trusted → a host-shell runner even with no sandbox injected', async () => {
36+
const ctx = createExecContext()
37+
// Assert behavior (runs on the host shell), not identity against the
38+
// src-imported runner — the latter trips no-src-import-in-test-expect.
39+
const result = await ctx.resolve('trusted').run('echo resolved-real')
40+
expect(result.exitCode).toBe(0)
41+
expect(result.stdout).toMatch(/resolved-real/)
42+
})
43+
44+
it('resolves untrusted → the injected sandboxed runner', () => {
45+
const ctx = createExecContext({ sandboxed: fakeSandbox })
46+
expect(ctx.resolve('untrusted')).toBe(fakeSandbox)
47+
})
48+
49+
it('throws on untrusted when no sandbox was injected (never downgrades)', () => {
50+
const ctx = createExecContext()
51+
expect(() => ctx.resolve('untrusted')).toThrow(/sandboxed exec runner/i)
52+
})
53+
54+
it('honors an overridden real runner', () => {
55+
const ctx = createExecContext({ real: fakeSandbox })
56+
expect(ctx.resolve('trusted')).toBe(fakeSandbox)
57+
})
58+
})
59+
60+
describe('realRunner', () => {
61+
it('runs a command on the host shell', async () => {
62+
const result = await realRunner.run('echo hello-real')
63+
expect(result.exitCode).toBe(0)
64+
expect(result.stdout).toMatch(/hello-real/)
65+
})
66+
67+
it('surfaces a non-zero exit as a result, not a throw', async () => {
68+
const result = await realRunner.run('exit 7')
69+
expect(result.exitCode).toBe(7)
70+
})
71+
})
72+
73+
describe('runShell', () => {
74+
it('runs trusted scripts on the real runner via a default context', async () => {
75+
const result = await runShell('echo defaulted', { trust: 'trusted' })
76+
expect(result.exitCode).toBe(0)
77+
expect(result.stdout).toMatch(/defaulted/)
78+
})
79+
80+
it('routes untrusted scripts to the injected sandbox', async () => {
81+
const context = createExecContext({ sandboxed: fakeSandbox })
82+
const result = await runShell('rm -rf /', { context, trust: 'untrusted' })
83+
expect(result.stdout).toBe('sandboxed:rm -rf /')
84+
})
85+
86+
it('defaults to untrusted and errors loudly without a sandbox', async () => {
87+
await expect(runShell('echo x')).rejects.toThrow(/sandboxed exec runner/i)
88+
})
89+
})

0 commit comments

Comments
 (0)