@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/LICENSE +201 -0
- package/README.md +50 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +321 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1145 -0
- package/lib/index.js.map +1 -0
- package/package.json +48 -0
- package/src/brand.ts +24 -0
- package/src/caido.ts +257 -0
- package/src/index.ts +366 -0
- package/src/mounts.ts +197 -0
- package/src/session.ts +444 -0
- package/src/spec.ts +263 -0
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
|
+
}
|