@gpzhang2001/sharpkit-sandbox 0.2.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/src/index.ts ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Docker sandbox capability for the sharpkit pentest suite: the
3
+ * `ctx.pentestSandbox` service (decision D1: `ctx.subprocess` + docker CLI,
4
+ * argv built by the pure spec module). Port of strix runtime/session_manager
5
+ * `create_or_reuse` + `cleanup` semantics: sessions cached by scan id,
6
+ * container lifecycle over the CLI, extra files staged under the temp dir
7
+ * (a remote docker daemon resolves bind sources on its own filesystem), and
8
+ * a lazy Caido bootstrap that runs concurrently with scan start. Teardown is
9
+ * registered once via ctx.effect and stops every live session best-effort.
10
+ * @module @gpzhang2001/sharpkit-sandbox
11
+ */
12
+
13
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
14
+ import { mkdtemp, rm } from 'node:fs/promises'
15
+ import { homedir, tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { Context, Service } from '@deepseek-ai/cordis'
18
+ import type Schema from '@deepseek-ai/schemastery'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import { bootstrapCaido, CaidoBootstrap } from './caido.ts'
21
+ import {
22
+ buildBindMounts,
23
+ collidesWithRoots,
24
+ extraFileRelPath,
25
+ stagingDirName,
26
+ type FsProbe,
27
+ type SandboxSourceSpec,
28
+ } from './mounts.ts'
29
+ import {
30
+ buildCreateArgv,
31
+ buildContainerEnv,
32
+ buildImageInspectArgv,
33
+ buildNetworkIpArgv,
34
+ buildPortArgv,
35
+ buildPullArgv,
36
+ buildStartArgv,
37
+ parsePortOutput,
38
+ type SandboxCreateSpec,
39
+ } from './spec.ts'
40
+ import {
41
+ DockerCliSandboxSession,
42
+ runCollectArgv,
43
+ stageExtraFiles,
44
+ type PentestSandboxSession,
45
+ } from './session.ts'
46
+
47
+ export type {
48
+ PentestSandboxSession,
49
+ SandboxExecOptions,
50
+ SandboxExecResult,
51
+ SandboxTtyProcess,
52
+ } from './session.ts'
53
+ export type { CaidoBootstrap, CaidoEndpoint } from './caido.ts'
54
+ export type { SandboxSourceSpec } from './mounts.ts'
55
+ export { SandboxProcessId, SandboxSessionId } from './brand.ts'
56
+ export type { SandboxProcessId as SandboxProcessIdType, SandboxSessionId as SandboxSessionIdType } from './brand.ts'
57
+
58
+ declare module '@deepseek-ai/cordis' {
59
+ interface Context {
60
+ pentestSandbox: PentestSandboxService
61
+ }
62
+ }
63
+
64
+ /** One extra file materialized into the workspace before container start. */
65
+ export interface SandboxExtraFile {
66
+ /** Absolute container path under the workspace root. */
67
+ readonly containerPath: string
68
+ /** File content; strings are encoded UTF-8. */
69
+ readonly content: string | Uint8Array
70
+ }
71
+
72
+ /** Options for {@link PentestSandboxService.createSession}. */
73
+ export interface SandboxSessionOptions {
74
+ /** Cache key; repeated ids reuse the live session (strix create_or_reuse). */
75
+ readonly scanId: string
76
+ /** Source trees to bind-mount read-write into the workspace. */
77
+ readonly sources?: readonly SandboxSourceSpec[]
78
+ /** Extra files staged on the host and mounted read-only into the workspace. */
79
+ readonly extraFiles?: readonly SandboxExtraFile[]
80
+ }
81
+
82
+ /** Deployment-tunable configuration (cordis resolves defaults before apply). */
83
+ export interface Config {
84
+ /** Sandbox image reference. */
85
+ readonly image?: string
86
+ /** Terminate grace for every docker CLI tree the session owns. */
87
+ readonly containerGraceMs?: number
88
+ /** Container workspace root (protocol constant with the image). */
89
+ readonly workspaceRoot?: string
90
+ /** Existing docker network to attach to (publishes no ports; strix sandbox-network mode). */
91
+ readonly network?: string
92
+ /** Resource limits (strix STRIX_SANDBOX_* env knobs, now config). */
93
+ readonly memLimit?: string
94
+ readonly shmSize?: string
95
+ readonly cpus?: number
96
+ readonly pidsLimit?: number
97
+ /** Log rotation; max-size 0/off/none/unlimited disables (strix default "50m"/3). */
98
+ readonly logMaxSize?: string
99
+ readonly logMaxFile?: number
100
+ /** Run labels forwarded to the container (strix STRIX_RUN_ID/RUN_TYPE). */
101
+ readonly runLabelId?: string
102
+ readonly runLabelType?: string
103
+ /** Caido guest-login retry budget (the readiness probe). */
104
+ readonly caidoLoginAttempts?: number
105
+ /** Per-attempt login exec timeout. */
106
+ readonly caidoLoginTimeoutMs?: number
107
+ /** Default timeout for exec and docker CLI helper calls. */
108
+ readonly defaultExecTimeoutMs?: number
109
+ /** Collect window for exec stdout/stderr (bytes; larger streams go lossy). */
110
+ readonly execCollectMaxBytes?: number
111
+ }
112
+
113
+ /** Resolved shape cordis hands the constructor after schema defaults. */
114
+ type ResolvedConfig = Required<Omit<Config, 'network' | 'memLimit' | 'shmSize' | 'cpus' | 'pidsLimit' | 'runLabelId' | 'runLabelType'>>
115
+ & Pick<Config, 'network' | 'memLimit' | 'shmSize' | 'cpus' | 'pidsLimit' | 'runLabelId' | 'runLabelType'>
116
+
117
+ /** Container-side Caido port (protocol constant with the image, not a tunable). */
118
+ const CAIDO_PORT = 48080
119
+
120
+ /** Keep-alive command (protocol with the image entrypoint, which execs it). */
121
+ const KEEPALIVE_COMMAND = ['tail', '-f', '/dev/null'] as const
122
+
123
+ /**
124
+ * The pentest sandbox service: creates, caches, and reuses docker-CLI
125
+ * sandbox sessions. Load as a plugin after a subprocess provider; it
126
+ * registers as `ctx.pentestSandbox` (one per context).
127
+ */
128
+ export class PentestSandboxService extends Service {
129
+ static inject = ['subprocess']
130
+
131
+ static Config: Schema<Config> = z.object({
132
+ image: z.string().default('ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2'),
133
+ containerGraceMs: z.number().default(10_000),
134
+ workspaceRoot: z.string().default('/workspace'),
135
+ network: z.string(),
136
+ memLimit: z.string(),
137
+ shmSize: z.string(),
138
+ cpus: z.number(),
139
+ pidsLimit: z.number(),
140
+ logMaxSize: z.string().default('50m'),
141
+ logMaxFile: z.number().default(3),
142
+ runLabelId: z.string(),
143
+ runLabelType: z.string(),
144
+ caidoLoginAttempts: z.number().default(10),
145
+ caidoLoginTimeoutMs: z.number().default(15_000),
146
+ defaultExecTimeoutMs: z.number().default(120_000),
147
+ execCollectMaxBytes: z.number().default(1_048_576),
148
+ })
149
+
150
+ private readonly config: ResolvedConfig
151
+ private readonly sessions = new Map<string, PentestSandboxSession>()
152
+
153
+ /** node:fs-backed mount facts; a handful of sync calls on a few paths. */
154
+ private readonly probe: FsProbe = {
155
+ // pathlib.Path.resolve parity: expanduser + symlink-following canonicalization.
156
+ resolve: path => realpathSync(path.startsWith('~/') ? join(homedir(), path.slice(2)) : path),
157
+ exists: path => existsSync(path),
158
+ isDirectory: path => {
159
+ try {
160
+ return statSync(path).isDirectory()
161
+ } catch {
162
+ return false
163
+ }
164
+ },
165
+ isFile: path => {
166
+ try {
167
+ return statSync(path).isFile()
168
+ } catch {
169
+ return false
170
+ }
171
+ },
172
+ readTextFile: path => {
173
+ try {
174
+ return readFileSync(path, 'utf8')
175
+ } catch {
176
+ return null
177
+ }
178
+ },
179
+ }
180
+
181
+ constructor(ctx: Context, config: Config = {}) {
182
+ super(ctx, 'pentestSandbox')
183
+ this.config = config as ResolvedConfig
184
+ void ctx.effect(() => async () => {
185
+ for (const session of this.sessions.values()) {
186
+ await session.stop()
187
+ }
188
+ this.sessions.clear()
189
+ }, 'pentest-sandbox session teardown')
190
+ }
191
+
192
+ /**
193
+ * Create (or reuse) the sandbox session for a scan id — strix
194
+ * `create_or_reuse` parity, including lazy Caido bootstrap.
195
+ * @param options - scan identity, sources, extra files.
196
+ * @returns the live session.
197
+ */
198
+ async createSession(options: SandboxSessionOptions): Promise<PentestSandboxSession> {
199
+ const cached = this.sessions.get(options.scanId)
200
+ if (cached !== undefined) {
201
+ this.ctx.logger.debug(`pentest-sandbox: reusing session for scan ${options.scanId}`)
202
+ return cached
203
+ }
204
+ const config = this.config
205
+ const subprocess = this.ctx.subprocess
206
+ const cli = { timeoutMs: config.defaultExecTimeoutMs, graceMs: config.containerGraceMs, collectMaxBytes: config.execCollectMaxBytes }
207
+ let stagingDir: string | undefined
208
+ let containerId: string | undefined
209
+ try {
210
+ const mounts = buildBindMounts(options.sources ?? [], this.probe, config.workspaceRoot)
211
+ const extraMounts = await this.stageExtraFiles(options, config.workspaceRoot)
212
+ if (extraMounts.stagingDir !== undefined) stagingDir = extraMounts.stagingDir
213
+ const allMounts = [...mounts, ...extraMounts.mounts].sort(
214
+ (a, b) => a.target.split('/').length - b.target.split('/').length || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0),
215
+ )
216
+ const labels: Record<string, string> = {}
217
+ if (config.runLabelId !== undefined) labels['sharpkit-run-id'] = config.runLabelId
218
+ if (config.runLabelType !== undefined) labels['sharpkit-run-type'] = config.runLabelType
219
+ const spec: SandboxCreateSpec = {
220
+ image: config.image,
221
+ command: KEEPALIVE_COMMAND,
222
+ env: buildContainerEnv({
223
+ caidoPort: CAIDO_PORT,
224
+ platform: process.platform,
225
+ uid: typeof process.getuid === 'function' ? process.getuid() : undefined,
226
+ gid: typeof process.getgid === 'function' ? process.getgid() : undefined,
227
+ }),
228
+ bindMounts: allMounts,
229
+ caidoPort: CAIDO_PORT,
230
+ network: config.network,
231
+ caps: ['NET_ADMIN', 'NET_RAW'],
232
+ extraHosts: { 'host.docker.internal': 'host-gateway' },
233
+ resourceLimits: {
234
+ memLimit: config.memLimit,
235
+ shmSize: config.shmSize,
236
+ cpus: config.cpus,
237
+ pidsLimit: config.pidsLimit,
238
+ },
239
+ logMaxSize: config.logMaxSize,
240
+ logMaxFile: config.logMaxFile,
241
+ labels,
242
+ }
243
+ await this.ensureImage(spec.image, cli)
244
+ const created = await runCollectArgv(subprocess, buildCreateArgv(spec), cli)
245
+ if (created.exitCode !== 0) throw new Error(`docker create failed (exit ${created.exitCode}): ${created.stderr.slice(0, 500)}`)
246
+ containerId = created.stdout.trim().split('\n').at(-1)?.trim() ?? ''
247
+ if (containerId === '') throw new Error('docker create produced no container id')
248
+ const started = await runCollectArgv(subprocess, buildStartArgv(containerId), cli)
249
+ if (started.exitCode !== 0) throw new Error(`docker start failed (exit ${started.exitCode}): ${started.stderr.slice(0, 500)}`)
250
+ const hostBaseUrl = await this.resolveCaidoHostUrl(containerId, cli)
251
+ const id = containerId
252
+ const bootstrap = new CaidoBootstrap(signal =>
253
+ bootstrapCaido(
254
+ (command, timeoutMs) =>
255
+ runCollectArgv(subprocess, ['docker', 'exec', '-i', id, 'bash', '-lc', command], { ...cli, timeoutMs }).then(result => ({
256
+ ok: result.exitCode === 0,
257
+ exitCode: result.exitCode,
258
+ stdout: result.stdout,
259
+ stderr: result.stderr,
260
+ })),
261
+ fetch,
262
+ { containerBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`, hostBaseUrl },
263
+ {
264
+ attempts: config.caidoLoginAttempts,
265
+ timeoutMs: config.caidoLoginTimeoutMs,
266
+ sleep: ms => new Promise(resolveSleep => setTimeout(resolveSleep, ms)),
267
+ signal,
268
+ },
269
+ ),
270
+ )
271
+ const session = new DockerCliSandboxSession({
272
+ subprocess,
273
+ logger: this.ctx.logger,
274
+ containerId,
275
+ scanId: options.scanId,
276
+ containerCaidoBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,
277
+ hostCaidoBaseUrl: hostBaseUrl,
278
+ bootstrap,
279
+ stagingDir,
280
+ graceMs: config.containerGraceMs,
281
+ defaultExecTimeoutMs: config.defaultExecTimeoutMs,
282
+ collectMaxBytes: config.execCollectMaxBytes,
283
+ })
284
+ this.sessions.set(options.scanId, session)
285
+ return session
286
+ } catch (error) {
287
+ // strix parity: drop staging, best-effort container removal, re-raise.
288
+ if (stagingDir !== undefined) await rm(stagingDir, { recursive: true, force: true }).catch(() => {})
289
+ if (containerId !== undefined) {
290
+ await runCollectArgv(subprocess, ['docker', 'rm', '-f', containerId], cli).catch(() => undefined)
291
+ }
292
+ throw error
293
+ }
294
+ }
295
+
296
+ /** Stop and forget one session (idempotent; strix `cleanup`). */
297
+ async destroySession(scanId: string): Promise<void> {
298
+ const session = this.sessions.get(scanId)
299
+ if (session === undefined) {
300
+ this.ctx.logger.debug(`pentest-sandbox: no session to clean for scan ${scanId}`)
301
+ return
302
+ }
303
+ this.sessions.delete(scanId)
304
+ await session.stop()
305
+ }
306
+
307
+ /** Validate + stage extra files (strix skip-and-warn semantics). */
308
+ private async stageExtraFiles(
309
+ options: SandboxSessionOptions,
310
+ workspaceRoot: string,
311
+ ): Promise<{ mounts: ReturnType<typeof buildBindMounts>; stagingDir?: string }> {
312
+ const extraFiles = options.extraFiles ?? []
313
+ if (extraFiles.length === 0) return { mounts: [] }
314
+ const sourceRoots = options.sources?.map(source => source.workspaceSubdir) ?? []
315
+ const placed: string[] = []
316
+ const items: { rel: string; content: Uint8Array }[] = []
317
+ for (const file of extraFiles) {
318
+ const rel = extraFileRelPath(file.containerPath, workspaceRoot)
319
+ if (rel === null) {
320
+ this.ctx.logger.warn(`pentest-sandbox: skipping invalid extra file path ${file.containerPath}`)
321
+ continue
322
+ }
323
+ if (collidesWithRoots(rel, [...sourceRoots, ...placed])) {
324
+ this.ctx.logger.warn(`pentest-sandbox: skipping colliding extra file ${file.containerPath}`)
325
+ continue
326
+ }
327
+ placed.push(rel)
328
+ items.push({ rel, content: typeof file.content === 'string' ? new TextEncoder().encode(file.content) : file.content })
329
+ }
330
+ if (items.length === 0) return { mounts: [] }
331
+ const stagingDir = await mkdtemp(`${tmpdir()}/pentest-extra-files-${stagingDirName(options.scanId)}-`)
332
+ return { mounts: await stageExtraFiles(stagingDir, items, workspaceRoot), stagingDir }
333
+ }
334
+
335
+ /** Pull the image when missing (strix image_exists → pull). */
336
+ private async ensureImage(image: string, cli: { timeoutMs: number; graceMs: number; collectMaxBytes: number }): Promise<void> {
337
+ const present = await runCollectArgv(this.ctx.subprocess, buildImageInspectArgv(image), { ...cli, timeoutMs: 60_000 })
338
+ if (present.exitCode === 0) return
339
+ this.ctx.logger.info(`pentest-sandbox: pulling image ${image}`)
340
+ const pulled = await runCollectArgv(this.ctx.subprocess, buildPullArgv(image), { ...cli, timeoutMs: 1_800_000 })
341
+ if (pulled.exitCode !== 0) throw new Error(`docker pull failed (exit ${pulled.exitCode}): ${pulled.stderr.slice(0, 500)}`)
342
+ }
343
+
344
+ /** Resolve the host-side Caido base URL for a started container. */
345
+ private async resolveCaidoHostUrl(
346
+ containerId: string,
347
+ cli: { timeoutMs: number; graceMs: number; collectMaxBytes: number },
348
+ ): Promise<string> {
349
+ const config = this.config
350
+ if (config.network !== undefined && config.network !== '') {
351
+ const inspected = await runCollectArgv(this.ctx.subprocess, buildNetworkIpArgv(containerId, config.network), cli)
352
+ if (inspected.exitCode !== 0) throw new Error(`docker inspect (network ip) failed (exit ${inspected.exitCode}): ${inspected.stderr.slice(0, 500)}`)
353
+ const ip = inspected.stdout.trim()
354
+ if (ip === '') throw new Error(`container has no address on network ${config.network}`)
355
+ const host = ip.includes(':') ? `[${ip}]` : ip
356
+ return `http://${host}:${CAIDO_PORT}`
357
+ }
358
+ const port = await runCollectArgv(this.ctx.subprocess, buildPortArgv(containerId, CAIDO_PORT), cli)
359
+ if (port.exitCode !== 0) throw new Error(`docker port failed (exit ${port.exitCode}): ${port.stderr.slice(0, 500)}`)
360
+ const endpoint = parsePortOutput(port.stdout)[0]
361
+ if (endpoint === undefined) throw new Error(`caido port ${CAIDO_PORT} is not published for container ${containerId}`)
362
+ return `http://${endpoint.host}:${endpoint.port}`
363
+ }
364
+ }
365
+
366
+ export default PentestSandboxService
package/src/mounts.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Bind-mount assembly: workspace source mapping, protected-metadata mounts
3
+ * (`.git` / `.agents` / `.codex`, git-worktree gitdir pointers), extra-file
4
+ * staging path rules, and collision detection. Faithful port of strix
5
+ * session_manager.py `build_bind_mounts` (:54-66), `_metadata_mounts`
6
+ * (:230-245), `_gitdir_from_pointer` (:248-260), `_extra_file_rel_path`
7
+ * (:80-97), `_collides_with_source_root` (:111-124), and the staging-dir
8
+ * sanitizer (:172-180). Filesystem facts arrive through an injectable probe
9
+ * so every branch is unit-testable without touching a real tree.
10
+ * @module @gpzhang2001/sharpkit-sandbox/mounts
11
+ */
12
+
13
+ import type { SandboxBindMount } from './spec.ts'
14
+
15
+ /** One source tree mounted into the sandbox workspace. */
16
+ export interface SandboxSourceSpec {
17
+ /** Directory name under the workspace root (`/workspace/<subdir>`). */
18
+ readonly workspaceSubdir: string
19
+ /** Host path of the tree to mount. */
20
+ readonly sourcePath: string
21
+ /** Mount `.git`/`.agents`/`.codex` (and worktree gitdir) read-only on top. */
22
+ readonly protectMetadata?: boolean
23
+ }
24
+
25
+ /** Injected filesystem facts; the real service passes node:fs-backed probes. */
26
+ export interface FsProbe {
27
+ /** Resolve a path to its canonical absolute form (symlinks followed). */
28
+ resolve(path: string): string
29
+ exists(path: string): boolean
30
+ isDirectory(path: string): boolean
31
+ /** Whether the path exists as a regular file (a worktree `.git` pointer). */
32
+ isFile(path: string): boolean
33
+ /** Read a small text file, or null when unreadable (pointer parse is best-effort). */
34
+ readTextFile(path: string): string | null
35
+ }
36
+
37
+ /** Metadata names that get their own read-only overlay mount. */
38
+ export const PROTECTED_METADATA_NAMES = ['.git', '.agents', '.codex'] as const
39
+
40
+ /**
41
+ * Whether `child` is `parent` itself or underneath it, POSIX-style.
42
+ * @param parent - candidate ancestor path, already canonical.
43
+ * @param child - candidate descendant path, already canonical.
44
+ */
45
+ export function isSubpath(parent: string, child: string): boolean {
46
+ return child === parent || child.startsWith(`${parent}/`)
47
+ }
48
+
49
+ /**
50
+ * Parse a git worktree `.git` pointer file for its `gitdir:` line.
51
+ * @param content - the raw pointer file text.
52
+ * @param base - directory of the pointer file, for relative gitdir values.
53
+ * @param resolve - canonicalizer for the candidate path.
54
+ * @returns the resolved gitdir, or null when absent/malformed.
55
+ */
56
+ export function parseGitdirPointer(content: string, base: string, resolve: (path: string) => string): string | null {
57
+ for (const rawLine of content.split('\n')) {
58
+ const line = rawLine.trimEnd()
59
+ const separator = line.indexOf(':')
60
+ if (separator === -1) continue
61
+ const prefix = line.slice(0, separator).trim()
62
+ if (prefix !== 'gitdir') continue
63
+ const value = line.slice(separator + 1).trim()
64
+ if (value === '') continue
65
+ return resolve(value.startsWith('/') ? value : `${base}/${value}`)
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Build the read-only metadata overlay mounts for one source tree (strix
72
+ * `_metadata_mounts`): each protected name that exists in the tree is mounted
73
+ * read-only at `<target>/<name>`; a file-shaped `.git` (worktree pointer)
74
+ * additionally gets its resolved gitdir mounted read-only when the gitdir
75
+ * stays inside the tree.
76
+ * @param tree - canonical host path of the source tree.
77
+ * @param target - container mount target of the tree (e.g. `/workspace/app`).
78
+ * @param probe - filesystem facts.
79
+ * @returns the overlay mounts (possibly empty).
80
+ */
81
+ export function metadataMounts(tree: string, target: string, probe: FsProbe): SandboxBindMount[] {
82
+ const mounts: SandboxBindMount[] = []
83
+ for (const name of PROTECTED_METADATA_NAMES) {
84
+ const path = `${tree}/${name}`
85
+ if (!probe.exists(path)) continue
86
+ const isDir = probe.isDirectory(path)
87
+ if (!isDir && !probe.isFile(path)) continue
88
+ const resolved = probe.resolve(path)
89
+ if (!isSubpath(tree, resolved)) continue
90
+ mounts.push({ source: resolved, target: `${target}/${name}`, readOnly: true })
91
+ if (!isDir) {
92
+ const content = probe.readTextFile(path)
93
+ if (content === null) continue
94
+ const gitdir = parseGitdirPointer(content, path.substring(0, path.lastIndexOf('/')), probe.resolve)
95
+ if (gitdir === null || !probe.exists(gitdir) || !isSubpath(tree, gitdir)) continue
96
+ const relative = gitdir.slice(tree.length + 1)
97
+ mounts.push({ source: gitdir, target: `${target}/${relative}`, readOnly: true })
98
+ }
99
+ }
100
+ return mounts
101
+ }
102
+
103
+ /**
104
+ * Build every bind mount for the session's sources: workspace mounts plus
105
+ * metadata overlays, sorted shallowest-target-first so nested targets land on
106
+ * top (strix sort by `/` count).
107
+ * @param sources - the source specs; entries missing either path part are skipped.
108
+ * @param probe - filesystem facts.
109
+ * @param workspaceRoot - container workspace root (default `/workspace`).
110
+ * @returns the sorted mounts.
111
+ */
112
+ export function buildBindMounts(
113
+ sources: readonly SandboxSourceSpec[],
114
+ probe: FsProbe,
115
+ workspaceRoot: string,
116
+ ): SandboxBindMount[] {
117
+ const mounts: SandboxBindMount[] = []
118
+ for (const source of sources) {
119
+ if (source.workspaceSubdir === '' || source.sourcePath === '') continue
120
+ const resolved = probe.resolve(source.sourcePath)
121
+ const target = `${workspaceRoot}/${source.workspaceSubdir}`
122
+ mounts.push({ source: resolved, target, readOnly: false })
123
+ if (source.protectMetadata === true) mounts.push(...metadataMounts(resolved, target, probe))
124
+ }
125
+ return mounts.sort((a, b) => targetDepth(a.target) - targetDepth(b.target) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
126
+ }
127
+
128
+ /** Path depth = number of `/` separators (strix ordering metric). */
129
+ function targetDepth(target: string): number {
130
+ let depth = 0
131
+ for (const char of target) {
132
+ if (char === '/') depth++
133
+ }
134
+ return depth
135
+ }
136
+
137
+ /**
138
+ * Validate an extra file's container path (strix `_extra_file_rel_path`):
139
+ * must live under the workspace root, have no empty/`.`/`..` segments, and
140
+ * carry no control characters.
141
+ * @param containerPath - the requested absolute container path.
142
+ * @param workspaceRoot - container workspace root (default `/workspace`).
143
+ * @returns the workspace-relative path, or null when invalid.
144
+ */
145
+ export function extraFileRelPath(containerPath: string, workspaceRoot: string): string | null {
146
+ const prefix = `${workspaceRoot}/`
147
+ if (!containerPath.startsWith(prefix)) return null
148
+ const rel = containerPath.slice(prefix.length).replace(/^\/+/, '')
149
+ if (rel === '') return null
150
+ const segments = rel.split('/')
151
+ for (const segment of segments) {
152
+ if (segment === '' || segment === '.' || segment === '..') return null
153
+ for (const char of segment) {
154
+ const code = char.codePointAt(0)
155
+ if (code === undefined || code < 0x20 || code === 0x7f) return null
156
+ }
157
+ }
158
+ return rel
159
+ }
160
+
161
+ /**
162
+ * Whether a candidate workspace-relative path collides with any source root
163
+ * or previously placed extra file (strix `_collides_with_source_root`,
164
+ * ancestor relationships included).
165
+ * @param rel - candidate workspace-relative path.
166
+ * @param roots - existing roots (subdirs and placed extra files), workspace-relative.
167
+ */
168
+ export function collidesWithRoots(rel: string, roots: readonly string[]): boolean {
169
+ return roots.some(root => rel === root || rel.startsWith(`${root}/`) || root.startsWith(`${rel}/`))
170
+ }
171
+
172
+ /**
173
+ * Sanitize a scan id for a staging directory name (strix keeps `[alnum]-_.`,
174
+ * everything else becomes `-`).
175
+ * @param scanId - the raw scan id.
176
+ * @returns the sanitized name fragment (empty collapses to a single `-`).
177
+ */
178
+ export function stagingDirName(scanId: string): string {
179
+ let safe = ''
180
+ for (const char of scanId) {
181
+ safe += /[A-Za-z0-9]/.test(char) || char === '-' || char === '_' || char === '.' ? char : '-'
182
+ }
183
+ return safe === '' ? '-' : safe
184
+ }
185
+
186
+ /**
187
+ * The staged host path for one extra file (strix: numbered subdir + basename,
188
+ * so same-basename files cannot clobber each other).
189
+ * @param stagingDir - the session's staging directory.
190
+ * @param index - the extra file's placement index.
191
+ * @param rel - the validated workspace-relative path.
192
+ * @returns the host file path to write the content to.
193
+ */
194
+ export function stagedFilePath(stagingDir: string, index: number, rel: string): string {
195
+ const basename = rel.slice(rel.lastIndexOf('/') + 1)
196
+ return `${stagingDir}/${index}/${basename}`
197
+ }