@warlock.js/ai-workspace 5.30.0 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -2
- package/README.md +34 -21
- package/cjs/index.cjs +3134 -315
- package/cjs/index.cjs.map +1 -1
- package/esm/backends/local.d.mts +1 -3
- package/esm/backends/local.d.mts.map +1 -1
- package/esm/backends/local.mjs +166 -28
- package/esm/backends/local.mjs.map +1 -1
- package/esm/backends/mock.d.mts +14 -9
- package/esm/backends/mock.d.mts.map +1 -1
- package/esm/backends/mock.mjs +34 -0
- package/esm/backends/mock.mjs.map +1 -1
- package/esm/contracts/index.d.mts +5 -4
- package/esm/contracts/tool-io.type.d.mts +216 -19
- package/esm/contracts/tool-io.type.d.mts.map +1 -1
- package/esm/contracts/workspace-backend.contract.d.mts +62 -4
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-ops.contract.d.mts +33 -6
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
- package/esm/contracts/workspace-policy.type.d.mts +136 -16
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
- package/esm/contracts/workspace.contract.d.mts +65 -13
- package/esm/contracts/workspace.contract.d.mts.map +1 -1
- package/esm/errors.d.mts +103 -17
- package/esm/errors.d.mts.map +1 -1
- package/esm/errors.mjs +22 -1
- package/esm/errors.mjs.map +1 -1
- package/esm/index.d.mts +14 -5
- package/esm/index.mjs +10 -2
- package/esm/ops.d.mts +21 -3
- package/esm/ops.d.mts.map +1 -1
- package/esm/ops.mjs +230 -64
- package/esm/ops.mjs.map +1 -1
- package/esm/policy/jail.mjs +73 -0
- package/esm/policy/jail.mjs.map +1 -0
- package/esm/policy/policy.d.mts +30 -20
- package/esm/policy/policy.d.mts.map +1 -1
- package/esm/policy/policy.mjs +64 -87
- package/esm/policy/policy.mjs.map +1 -1
- package/esm/policy/shell-check.d.mts +63 -0
- package/esm/policy/shell-check.d.mts.map +1 -0
- package/esm/policy/shell-check.mjs +638 -0
- package/esm/policy/shell-check.mjs.map +1 -0
- package/esm/policy/shell-parser.mjs +802 -0
- package/esm/policy/shell-parser.mjs.map +1 -0
- package/esm/policy/tokenize-command.d.mts +62 -0
- package/esm/policy/tokenize-command.d.mts.map +1 -0
- package/esm/policy/tokenize-command.mjs +76 -24
- package/esm/policy/tokenize-command.mjs.map +1 -1
- package/esm/shell-jobs.d.mts +45 -0
- package/esm/shell-jobs.d.mts.map +1 -0
- package/esm/shell-jobs.mjs +247 -0
- package/esm/shell-jobs.mjs.map +1 -0
- package/esm/todos.d.mts +22 -0
- package/esm/todos.d.mts.map +1 -0
- package/esm/todos.mjs +73 -0
- package/esm/todos.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +2 -3
- package/esm/tools/edit-file.d.mts.map +1 -1
- package/esm/tools/edit-file.mjs +2 -1
- package/esm/tools/edit-file.mjs.map +1 -1
- package/esm/tools/glob.d.mts +3 -4
- package/esm/tools/glob.d.mts.map +1 -1
- package/esm/tools/glob.mjs +2 -1
- package/esm/tools/glob.mjs.map +1 -1
- package/esm/tools/grep.d.mts +3 -4
- package/esm/tools/grep.d.mts.map +1 -1
- package/esm/tools/grep.mjs +2 -1
- package/esm/tools/grep.mjs.map +1 -1
- package/esm/tools/json-schemas.mjs +175 -0
- package/esm/tools/json-schemas.mjs.map +1 -0
- package/esm/tools/list-dir.d.mts +31 -0
- package/esm/tools/list-dir.d.mts.map +1 -0
- package/esm/tools/list-dir.mjs +50 -0
- package/esm/tools/list-dir.mjs.map +1 -0
- package/esm/tools/multi-edit.d.mts +37 -0
- package/esm/tools/multi-edit.d.mts.map +1 -0
- package/esm/tools/multi-edit.mjs +121 -0
- package/esm/tools/multi-edit.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +2 -3
- package/esm/tools/read-file.d.mts.map +1 -1
- package/esm/tools/read-file.mjs +2 -1
- package/esm/tools/read-file.mjs.map +1 -1
- package/esm/tools/run-shell.d.mts +36 -15
- package/esm/tools/run-shell.d.mts.map +1 -1
- package/esm/tools/run-shell.mjs +59 -53
- package/esm/tools/run-shell.mjs.map +1 -1
- package/esm/tools/run-tests.d.mts +11 -4
- package/esm/tools/run-tests.d.mts.map +1 -1
- package/esm/tools/run-tests.mjs +10 -3
- package/esm/tools/run-tests.mjs.map +1 -1
- package/esm/tools/schema.mjs +23 -18
- package/esm/tools/schema.mjs.map +1 -1
- package/esm/tools/shell-kill.d.mts +24 -0
- package/esm/tools/shell-kill.d.mts.map +1 -0
- package/esm/tools/shell-kill.mjs +36 -0
- package/esm/tools/shell-kill.mjs.map +1 -0
- package/esm/tools/shell-output.d.mts +29 -0
- package/esm/tools/shell-output.d.mts.map +1 -0
- package/esm/tools/shell-output.mjs +49 -0
- package/esm/tools/shell-output.mjs.map +1 -0
- package/esm/tools/todo.d.mts +42 -0
- package/esm/tools/todo.d.mts.map +1 -0
- package/esm/tools/todo.mjs +126 -0
- package/esm/tools/todo.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +2 -3
- package/esm/tools/write-file.d.mts.map +1 -1
- package/esm/tools/write-file.mjs +2 -1
- package/esm/tools/write-file.mjs.map +1 -1
- package/esm/workspace.d.mts +14 -11
- package/esm/workspace.d.mts.map +1 -1
- package/esm/workspace.mjs +108 -27
- package/esm/workspace.mjs.map +1 -1
- package/llms-full.txt +155 -34
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/skills/README.md +2 -2
- package/skills/build-loop-agent/SKILL.md +7 -7
- package/skills/index.md +6 -1
- package/skills/use-a-workspace/SKILL.md +148 -27
package/esm/ops.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { buildEnv, isCommandAllowed, resolveInJail } from \"./policy/policy\";\nimport type {\n EditFileInput,\n EditFileResult,\n ExecOptions,\n GrepMatch,\n GrepResult,\n ReadFileResult,\n RunShellResult,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n/**\n * Exit code reported for a command refused because its signal was already\n * aborted (the shell convention for an interrupted command). Callers should\n * branch on `cancelled`, not on this number.\n */\nconst CANCELLED_BEFORE_START_EXIT_CODE = 130;\n/**\n * Hard ceiling on `grep` pattern length. A model-controlled regex has no\n * legitimate reason to be this long; longer patterns are rejected outright\n * rather than compiled.\n */\nconst MAX_GREP_PATTERN_LENGTH = 200;\n/**\n * Hard ceiling on the number of characters of a single line handed to\n * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential\n * in input length, so bounding the input scanned per call bounds the\n * worst-case time a single pathological line can cost — lines longer than\n * this are skipped rather than scanned.\n */\nconst MAX_GREP_LINE_SCAN_LENGTH = 2000;\n\n/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */\nfunction linePrefix(lineNumber: number): string {\n return `${String(lineNumber).padStart(6, \" \")}\\t`;\n}\n\n/**\n * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a\n * character: the cut backs off over continuation bytes (`10xxxxxx`) to the\n * start of the character it would have split, so the result never carries\n * a replacement character.\n */\nfunction clipUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n let cut = Math.max(0, maxBytes);\n\n while (cut > 0 && ((bytes[cut] ?? 0) & 0xc0) === 0x80) {\n cut--;\n }\n\n return { value: bytes.subarray(0, cut).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Number a window of lines `cat -n` style, keeping the joined output within\n * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.\n * A line that does not fit whole is clipped on a character boundary; it is\n * still counted as returned when its prefix and at least one character fit.\n */\nfunction numberWindow(\n lines: string[],\n startLine: number,\n maxBytes: number | undefined,\n): { content: string; returnedLines: number; bytesCapped: boolean } {\n const parts: string[] = [];\n let used = 0;\n\n for (let index = 0; index < lines.length; index++) {\n const prefix = linePrefix(startLine + index);\n const numbered = prefix + lines[index];\n const separator = parts.length > 0 ? 1 : 0;\n\n if (maxBytes === undefined) {\n parts.push(numbered);\n\n continue;\n }\n\n const cost = separator + Buffer.byteLength(numbered, \"utf8\");\n\n if (used + cost <= maxBytes) {\n parts.push(numbered);\n used += cost;\n\n continue;\n }\n\n const room = maxBytes - used - separator - Buffer.byteLength(prefix, \"utf8\");\n const clipped = room > 0 ? clipUtf8(lines[index] ?? \"\", room).value : \"\";\n\n if (clipped.length > 0) {\n parts.push(prefix + clipped);\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: true };\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: false };\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a\n * character boundary. Returns the possibly-clipped string plus whether\n * clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n return clipUtf8(value, maxBytes);\n}\n\n/**\n * Minimal glob match over a `/`-separated relative path. Supports `**`\n * (spans separators), `*` (within a segment), and `?` (one non-separator\n * char) — enough for the workspace's `glob`/grep narrowing without\n * pulling in a runtime dependency.\n */\nfunction globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob[index];\n\n if (char === \"*\") {\n if (glob[index + 1] === \"*\") {\n source += \".*\";\n index++;\n\n if (glob[index + 1] === \"/\") {\n index++;\n }\n } else {\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.\n */\nconst QUANTIFIER_SOURCE = String.raw`[+*?]|\\{\\d*,?\\d*\\}`;\n\n/**\n * Heuristic catastrophic-backtracking detector: flags a quantified group\n * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —\n * the classic exponential-blowup shape. Not a full regex-safety analyzer\n * (it won't catch every ReDoS shape, e.g. quantified alternation like\n * `(a|a)+`), but it rejects the shape an agent is most likely to emit,\n * intentionally or via prompt injection.\n */\nconst NESTED_QUANTIFIER_PATTERN = new RegExp(\n String.raw`\\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\\)(?:${QUANTIFIER_SOURCE})`,\n);\n\n/**\n * Whether `pattern` is safe enough to compile and run against workspace\n * content: within the length cap and free of the nested-quantifier shape\n * that causes catastrophic regex backtracking (ReDoS).\n */\nfunction isSafeGrepPattern(pattern: string): boolean {\n if (pattern.length > MAX_GREP_PATTERN_LENGTH) {\n return false;\n }\n\n return !NESTED_QUANTIFIER_PATTERN.test(pattern);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\nclass Ops implements WorkspaceOps {\n /**\n * Tail of the mutation chain per resolved absolute path. Writes, edits and\n * removes on the same path run one after another, so two tool calls the\n * agent dispatches concurrently cannot both read the same content, both\n * pass the stale-hash guard and silently drop one edit: the second one\n * re-reads after the first has written.\n */\n private readonly pathLocks = new Map<string, Promise<void>>();\n\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n ) {}\n\n /** Run `task` after every earlier mutation of `absolutePath` has settled. */\n private async withPathLock<T>(absolutePath: string, task: () => Promise<T>): Promise<T> {\n const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();\n let release: () => void = () => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const tail = previous.then(() => current);\n\n this.pathLocks.set(absolutePath, tail);\n\n try {\n await previous;\n\n return await task();\n } finally {\n release();\n\n if (this.pathLocks.get(absolutePath) === tail) {\n this.pathLocks.delete(absolutePath);\n }\n }\n }\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<ReadFileResult> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the full content the backend returned (SHA-256 via\n // @warlock.js/fs), not the returned window and not the on-disk file —\n // the mock backend keeps content in memory, and the stale-edit guard\n // later hashes the backend's content too, so it must be the SAME\n // source of truth whatever window or byte cap applied.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n const maxBytes = this.policy.read?.maxBytes;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + Math.max(0, limit));\n const { content, returnedLines, bytesCapped } = numberWindow(\n window,\n offset,\n maxBytes === undefined ? undefined : Math.max(0, Math.floor(maxBytes)),\n );\n const endLine = offset + returnedLines - 1;\n\n return {\n content,\n startLine: offset,\n endLine,\n totalLines,\n truncated: bytesCapped || endLine < totalLines,\n bytesCapped,\n totalBytes: Buffer.byteLength(raw, \"utf8\"),\n hash,\n };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.withPathLock(absolutePath, async () => {\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n });\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n\n return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));\n }\n\n /** The read-check-replace-write of {@link editFile}, run under its path lock. */\n private async applyEdit(\n input: EditFileInput,\n absolutePath: string,\n relativePath: string,\n ): Promise<EditFileResult> {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const occurrences = countOccurrences(current, input.oldString);\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `The text to replace was not found in \"${input.path}\".`,\n { type: \"not-found\", path: relativePath || input.path, matches: 0 },\n );\n }\n\n if (occurrences > 1 && !input.replaceAll) {\n throw new WorkspaceEditError(\n `The text to replace is not unique in \"${input.path}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.`,\n { type: \"not-unique\", path: relativePath || input.path, matches: occurrences },\n );\n }\n\n const updated = input.replaceAll\n ? current.split(input.oldString).join(input.newString)\n : replaceFirst(current, input.oldString, input.newString);\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements: input.replaceAll ? occurrences : 1,\n hash: fs.hash.string(updated),\n };\n }\n\n public async exec(command: string, opts?: ExecOptions): Promise<RunShellResult> {\n if (!isCommandAllowed(this.policy, command)) {\n throw new WorkspacePolicyError(\n `Command is not permitted by the workspace shell policy: ${command}`,\n { type: \"denied-command\", command },\n );\n }\n\n const signal = opts?.signal;\n\n // Refuse an already-cancelled invocation here, so no backend — built-in\n // or custom — ever starts a command whose caller has given up on it.\n if (signal?.aborted) {\n return {\n exitCode: CANCELLED_BEFORE_START_EXIT_CODE,\n stdout: \"\",\n stderr: \"Command was not executed: the invocation was cancelled before it started.\",\n truncated: false,\n timedOut: false,\n cancelled: true,\n };\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildEnv(this.policy),\n ...(signal ? { signal } : {}),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n const cancelled = result.cancelled === true;\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut && !cancelled,\n cancelled,\n };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n if (!isSafeGrepPattern(pattern)) {\n throw new WorkspacePolicyError(\n `Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`,\n { type: \"unsafe-pattern\", pattern },\n );\n }\n\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index];\n\n // Cap the input size a single `RegExp#test` call scans: backtracking\n // cost is exponential in input length, so this bounds the worst-case\n // time even a pathological (but length/shape-allowed) pattern can\n // burn on any one line.\n if (line.length > MAX_GREP_LINE_SCAN_LENGTH) {\n continue;\n }\n\n if (regex.test(line)) {\n matches.push({ path: relativePath, line: index + 1, text: line });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n if (!denyPaths || denyPaths.length === 0) {\n return false;\n }\n\n return denyPaths.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n }\n}\n\n/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n): WorkspaceOps {\n return new Ops(backend, policy);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ops.mjs","names":[],"sources":["../../../../../../../packages/ai-workspace/src/ops.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fs } from \"@warlock.js/fs\";\nimport { WorkspaceEditError, WorkspacePolicyError } from \"./errors\";\nimport { globToRegExp, isInside, matchesAnyGlob } from \"./policy/jail\";\nimport { buildCommandEnv, resolveInJail } from \"./policy/policy\";\nimport { checkShellCommand } from \"./policy/shell-check\";\nimport { DEFAULT_MAX_BACKGROUND_JOBS, ShellJobs } from \"./shell-jobs\";\nimport type {\n EditFileInput,\n EditFileResult,\n ExecOptions,\n GrepMatch,\n GrepResult,\n ListDirEntry,\n ListDirEntryType,\n ListDirOptions,\n ListDirResult,\n MultiEditInput,\n MultiEditOperation,\n MultiEditResult,\n ReadFileResult,\n RunShellResult,\n ShellJobInfo,\n ShellOutputOptions,\n ShellOutputResult,\n StartShellOptions,\n WorkspaceBackend,\n WorkspaceOps,\n WorkspacePolicy,\n} from \"./contracts\";\n\n/** Default line window a read returns when the policy sets no `defaultLines`. */\nconst DEFAULT_READ_LINES = 2000;\n/** Hard ceiling on grep matches returned, so a broad pattern can't flood. */\nconst DEFAULT_MAX_GREP_MATCHES = 1000;\n/** Default per-command output byte cap when the policy sets none. */\nconst DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;\n/** Default `list.ignore` when the policy sets none. */\nconst DEFAULT_LIST_IGNORE: readonly string[] = [\"node_modules\", \".git\"];\n/** Default deepest `depth` a listing may ask for. */\nconst DEFAULT_LIST_MAX_DEPTH = 5;\n/** Default most entries one listing returns. */\nconst DEFAULT_LIST_MAX_ENTRIES = 500;\n/** Most edits one `multiEdit` call may carry. */\nexport const MAX_MULTI_EDITS = 100;\n/**\n * Exit code reported for a command refused because its signal was already\n * aborted (the shell convention for an interrupted command). Callers should\n * branch on `cancelled`, not on this number.\n */\nconst CANCELLED_BEFORE_START_EXIT_CODE = 130;\n/**\n * Hard ceiling on `grep` pattern length. A model-controlled regex has no\n * legitimate reason to be this long; longer patterns are rejected outright\n * rather than compiled.\n */\nconst MAX_GREP_PATTERN_LENGTH = 200;\n/**\n * Hard ceiling on the number of characters of a single line handed to\n * `RegExp#test` during a `grep`. Catastrophic backtracking is exponential\n * in input length, so bounding the input scanned per call bounds the\n * worst-case time a single pathological line can cost — lines longer than\n * this are skipped rather than scanned.\n */\nconst MAX_GREP_LINE_SCAN_LENGTH = 2000;\n\n/** The `cat -n` prefix for a line: right-aligned number (min width 6) and a tab. */\nfunction linePrefix(lineNumber: number): string {\n return `${String(lineNumber).padStart(6, \" \")}\\t`;\n}\n\n/**\n * Clip `value` to at most `maxBytes` UTF-8 bytes without splitting a\n * character: the cut backs off over continuation bytes (`10xxxxxx`) to the\n * start of the character it would have split, so the result never carries\n * a replacement character.\n */\nfunction clipUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } {\n const bytes = Buffer.from(value, \"utf8\");\n\n if (bytes.byteLength <= maxBytes) {\n return { value, truncated: false };\n }\n\n let cut = Math.max(0, maxBytes);\n\n while (cut > 0 && ((bytes[cut] ?? 0) & 0xc0) === 0x80) {\n cut--;\n }\n\n return { value: bytes.subarray(0, cut).toString(\"utf8\"), truncated: true };\n}\n\n/**\n * Number a window of lines `cat -n` style, keeping the joined output within\n * `maxBytes` UTF-8 bytes (prefixes and newlines included) when a cap is set.\n * A line that does not fit whole is clipped on a character boundary; it is\n * still counted as returned when its prefix and at least one character fit.\n */\nfunction numberWindow(\n lines: string[],\n startLine: number,\n maxBytes: number | undefined,\n): { content: string; returnedLines: number; bytesCapped: boolean } {\n const parts: string[] = [];\n let used = 0;\n\n for (let index = 0; index < lines.length; index++) {\n const prefix = linePrefix(startLine + index);\n const numbered = prefix + lines[index];\n const separator = parts.length > 0 ? 1 : 0;\n\n if (maxBytes === undefined) {\n parts.push(numbered);\n\n continue;\n }\n\n const cost = separator + Buffer.byteLength(numbered, \"utf8\");\n\n if (used + cost <= maxBytes) {\n parts.push(numbered);\n used += cost;\n\n continue;\n }\n\n const room = maxBytes - used - separator - Buffer.byteLength(prefix, \"utf8\");\n const clipped = room > 0 ? clipUtf8(lines[index] ?? \"\", room).value : \"\";\n\n if (clipped.length > 0) {\n parts.push(prefix + clipped);\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: true };\n }\n\n return { content: parts.join(\"\\n\"), returnedLines: parts.length, bytesCapped: false };\n}\n\n/**\n * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes), on a\n * character boundary. Returns the possibly-clipped string plus whether\n * clipping occurred.\n */\nfunction capOutput(value: string, maxBytes: number): { value: string; truncated: boolean } {\n return clipUtf8(value, maxBytes);\n}\n\n/**\n * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.\n */\nconst QUANTIFIER_SOURCE = String.raw`[+*?]|\\{\\d*,?\\d*\\}`;\n\n/**\n * Heuristic catastrophic-backtracking detector: flags a quantified group\n * that itself contains a quantifier (e.g. `(a+)+`, `(a*)*`, `(.+)*`) —\n * the classic exponential-blowup shape. Not a full regex-safety analyzer\n * (it won't catch every ReDoS shape, e.g. quantified alternation like\n * `(a|a)+`), but it rejects the shape an agent is most likely to emit,\n * intentionally or via prompt injection.\n */\nconst NESTED_QUANTIFIER_PATTERN = new RegExp(\n String.raw`\\([^()]*(?:${QUANTIFIER_SOURCE})[^()]*\\)(?:${QUANTIFIER_SOURCE})`,\n);\n\n/**\n * Whether `pattern` is safe enough to compile and run against workspace\n * content: within the length cap and free of the nested-quantifier shape\n * that causes catastrophic regex backtracking (ReDoS).\n */\nfunction isSafeGrepPattern(pattern: string): boolean {\n if (pattern.length > MAX_GREP_PATTERN_LENGTH) {\n return false;\n }\n\n return !NESTED_QUANTIFIER_PATTERN.test(pattern);\n}\n\n/**\n * The internal, single-instance implementation of {@link WorkspaceOps}.\n * Holds the backend + policy and is the one place the jail, command\n * gating, read caps, and the read-before-edit guard are enforced — both\n * the agent-facing tools and the human-facing direct methods funnel\n * through this object, so there is exactly one set of rules.\n */\n/**\n * State an ops layer shares with the other projections of the same\n * workspace (`readonly()`, `scope()`): the background commands it owns.\n */\nexport interface OpsRuntime {\n /** The workspace's background commands. */\n jobs: ShellJobs;\n}\n\nclass Ops implements WorkspaceOps {\n /**\n * Tail of the mutation chain per resolved absolute path. Writes, edits and\n * removes on the same path run one after another, so two tool calls the\n * agent dispatches concurrently cannot both read the same content, both\n * pass the stale-hash guard and silently drop one edit: the second one\n * re-reads after the first has written.\n */\n private readonly pathLocks = new Map<string, Promise<void>>();\n\n public constructor(\n private readonly backend: WorkspaceBackend,\n private readonly policy: WorkspacePolicy,\n private readonly runtime: OpsRuntime,\n ) {}\n\n /** Run `task` after every earlier mutation of `absolutePath` has settled. */\n private async withPathLock<T>(absolutePath: string, task: () => Promise<T>): Promise<T> {\n const previous = this.pathLocks.get(absolutePath) ?? Promise.resolve();\n let release: () => void = () => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n const tail = previous.then(() => current);\n\n this.pathLocks.set(absolutePath, tail);\n\n try {\n await previous;\n\n return await task();\n } finally {\n release();\n\n if (this.pathLocks.get(absolutePath) === tail) {\n this.pathLocks.delete(absolutePath);\n }\n }\n }\n\n /**\n * Recursively collect every file under `absDir` as absolute paths,\n * via the backend's `list` (so it works over disk or the in-memory\n * mock). Directories are descended; files are accumulated.\n */\n private async walkFiles(absDir: string): Promise<string[]> {\n const found: string[] = [];\n const entries = await this.backend.list(absDir);\n\n await Promise.all(\n entries.map(async (entry) => {\n // A child is a directory iff listing it succeeds; the backend\n // throws/returns for a file. Probe via `exists` + a list guard.\n const isDir = await this.isDirectory(entry);\n\n if (isDir) {\n const nested = await this.walkFiles(entry);\n found.push(...nested);\n } else {\n found.push(entry);\n }\n }),\n );\n\n return found;\n }\n\n /** Whether an absolute path is a directory, by attempting to list it. */\n private async isDirectory(absPath: string): Promise<boolean> {\n try {\n await this.backend.list(absPath);\n\n return true;\n } catch {\n return false;\n }\n }\n\n public async readFile(\n inputPath: string,\n opts?: { offset?: number; limit?: number },\n ): Promise<ReadFileResult> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n const raw = await this.backend.readFile(absolutePath);\n\n // Hash the full content the backend returned (SHA-256 via\n // @warlock.js/fs), not the returned window and not the on-disk file —\n // the mock backend keeps content in memory, and the stale-edit guard\n // later hashes the backend's content too, so it must be the SAME\n // source of truth whatever window or byte cap applied.\n const hash = fs.hash.string(raw);\n\n const lines = raw.split(\"\\n\");\n const totalLines = lines.length;\n\n const offset = Math.max(1, opts?.offset ?? 1);\n const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;\n const maxBytes = this.policy.read?.maxBytes;\n\n const startIndex = offset - 1;\n const window = lines.slice(startIndex, startIndex + Math.max(0, limit));\n const { content, returnedLines, bytesCapped } = numberWindow(\n window,\n offset,\n maxBytes === undefined ? undefined : Math.max(0, Math.floor(maxBytes)),\n );\n const endLine = offset + returnedLines - 1;\n\n return {\n content,\n startLine: offset,\n endLine,\n totalLines,\n truncated: bytesCapped || endLine < totalLines,\n bytesCapped,\n totalBytes: Buffer.byteLength(raw, \"utf8\"),\n hash,\n };\n }\n\n public async writeFile(\n inputPath: string,\n content: string,\n ): Promise<{ hash: string; bytesWritten: number }> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.withPathLock(absolutePath, async () => {\n // Ensure the parent directory exists before writing the leaf.\n const parent = path.dirname(absolutePath);\n await this.backend.mkdir(parent);\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n hash: fs.hash.string(content),\n bytesWritten: Buffer.byteLength(content, \"utf8\"),\n };\n });\n }\n\n public async editFile(input: EditFileInput): Promise<EditFileResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n\n return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));\n }\n\n /** The read-check-replace-write of {@link editFile}, run under its path lock. */\n private async applyEdit(\n input: EditFileInput,\n absolutePath: string,\n relativePath: string,\n ): Promise<EditFileResult> {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n // Stale-hash guard: if the caller pinned a hash and the file moved\n // underneath them, refuse so they re-read before clobbering.\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; the edit is stale.`,\n {\n type: \"stale-hash\",\n path: relativePath || input.path,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n const { content: updated, replacements } = applyReplacement(\n current,\n input,\n input.path,\n relativePath || input.path,\n );\n\n await this.backend.writeFile(absolutePath, updated);\n\n return {\n path: relativePath || input.path,\n replacements,\n hash: fs.hash.string(updated),\n };\n }\n\n public async multiEdit(input: MultiEditInput): Promise<MultiEditResult> {\n const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);\n const shownPath = relativePath || input.path;\n\n if (input.edits.length === 0 || input.edits.length > MAX_MULTI_EDITS) {\n throw new RangeError(\n `A multi-edit needs between 1 and ${MAX_MULTI_EDITS} edits (got ${input.edits.length}).`,\n );\n }\n\n return this.withPathLock(absolutePath, async () => {\n const current = await this.backend.readFile(absolutePath);\n const currentHash = fs.hash.string(current);\n\n if (input.expectHash !== undefined && input.expectHash !== currentHash) {\n throw new WorkspaceEditError(\n `File \"${input.path}\" changed since it was read; none of the edits were applied.`,\n {\n type: \"stale-hash\",\n path: shownPath,\n expectedHash: input.expectHash,\n actualHash: currentHash,\n },\n );\n }\n\n // Apply every edit in memory first; one failure leaves the file untouched.\n let content = current;\n let replacements = 0;\n\n input.edits.forEach((edit, editIndex) => {\n const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);\n\n content = applied.content;\n replacements += applied.replacements;\n });\n\n await this.backend.writeFile(absolutePath, content);\n\n return {\n path: shownPath,\n edits: input.edits.length,\n replacements,\n hash: fs.hash.string(content),\n };\n });\n }\n\n /**\n * Check a command line against the shell policy: every command in it\n * (argv or shell mode), then — shell mode — every redirection target and\n * `cd` directory against the jail with symlinks resolved.\n */\n private async assertCommandAllowed(command: string): Promise<void> {\n const verdict = checkShellCommand(this.policy, command);\n\n if (!verdict.allowed) {\n throw new WorkspacePolicyError(\n verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`,\n {\n type: \"denied-command\",\n command,\n ...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),\n ...(verdict.executable !== undefined ? { executable: verdict.executable } : {}),\n ...(verdict.path !== undefined ? { path: verdict.path } : {}),\n },\n );\n }\n\n for (const target of verdict.paths) {\n try {\n await resolveInJail(this.policy, target.path);\n } catch (error) {\n if (!(error instanceof WorkspacePolicyError)) {\n throw error;\n }\n\n throw new WorkspacePolicyError(\n target.access === \"cwd\"\n ? `\\`cd ${target.target}\\` resolves (through a symlink) outside the workspace jail or onto a denied path.`\n : `The redirection target \\`${target.target}\\` resolves (through a symlink) outside the workspace jail or onto a denied path.`,\n { type: \"denied-command\", reason: \"path-escape\", command, path: target.target },\n );\n }\n }\n\n if (this.runtime.jobs.isDisposed) {\n throw new WorkspacePolicyError(\n \"The workspace has been disposed; it cannot run new commands.\",\n { type: \"denied-command\", reason: \"not-allowed\", command },\n );\n }\n }\n\n public async exec(command: string, opts?: ExecOptions): Promise<RunShellResult> {\n await this.assertCommandAllowed(command);\n\n const signal = opts?.signal;\n\n // Refuse an already-cancelled invocation here, so no backend — built-in\n // or custom — ever starts a command whose caller has given up on it.\n if (signal?.aborted) {\n return {\n exitCode: CANCELLED_BEFORE_START_EXIT_CODE,\n stdout: \"\",\n stderr: \"Command was not executed: the invocation was cancelled before it started.\",\n truncated: false,\n timedOut: false,\n cancelled: true,\n };\n }\n\n const shell = this.policy.shell;\n const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;\n const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;\n const interpreter = shell?.interpreter ?? \"none\";\n\n const result = await this.backend.exec(command, {\n cwd: this.policy.cwd,\n timeoutMs,\n env: buildCommandEnv(this.policy),\n ...(signal ? { signal } : {}),\n ...(interpreter !== \"none\" ? { interpreter } : {}),\n });\n\n const stdout = capOutput(result.stdout, maxOutputBytes);\n const stderr = capOutput(result.stderr, maxOutputBytes);\n const cancelled = result.cancelled === true;\n\n return {\n exitCode: result.exitCode,\n stdout: stdout.value,\n stderr: stderr.value,\n truncated: stdout.truncated || stderr.truncated,\n timedOut: result.timedOut && !cancelled,\n cancelled,\n };\n }\n\n public async startShell(command: string, opts?: StartShellOptions): Promise<ShellJobInfo> {\n const background = this.policy.shell?.background;\n\n if (background === undefined) {\n throw new WorkspacePolicyError(\n \"Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.\",\n { type: \"denied-command\", reason: \"not-allowed\", command },\n );\n }\n\n await this.assertCommandAllowed(command);\n\n const start = this.backend.start?.bind(this.backend);\n\n if (start === undefined) {\n throw new WorkspacePolicyError(\"This workspace backend cannot run background commands.\", {\n type: \"denied-command\",\n reason: \"not-allowed\",\n command,\n });\n }\n\n const shell = this.policy.shell;\n const interpreter = shell?.interpreter ?? \"none\";\n const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);\n const env = buildCommandEnv(this.policy);\n\n return this.runtime.jobs.start({\n command,\n retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,\n maxJobs: background.maxJobs ?? DEFAULT_MAX_BACKGROUND_JOBS,\n ...(timeoutMs !== undefined ? { timeoutMs } : {}),\n launch: (onOutput) =>\n start(command, {\n cwd: this.policy.cwd,\n env,\n ...(interpreter !== \"none\" ? { interpreter } : {}),\n onOutput,\n }),\n });\n }\n\n public shellOutput(id: string, opts?: ShellOutputOptions): Promise<ShellOutputResult> {\n return this.runtime.jobs.output(id, opts);\n }\n\n public shellKill(id: string): Promise<ShellJobInfo> {\n return this.runtime.jobs.kill(id);\n }\n\n public listShells(): ShellJobInfo[] {\n return this.runtime.jobs.list();\n }\n\n public dispose(): Promise<void> {\n return this.runtime.jobs.dispose();\n }\n\n public async listDir(inputPath = \".\", opts?: ListDirOptions): Promise<ListDirResult> {\n const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(\n this.policy,\n inputPath,\n );\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const listPolicy = this.policy.list;\n const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));\n const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);\n const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));\n const ignore = [...(listPolicy?.ignore ?? DEFAULT_LIST_IGNORE), ...(opts?.ignore ?? [])];\n const insideCwd = isInside(root, jailRoot);\n\n if (!(await this.isDirectory(root))) {\n throw new Error(`\"${inputPath}\" is not a directory in the workspace.`);\n }\n\n const entries: ListDirEntry[] = [];\n let level: string[] = [root];\n let truncated = false;\n\n for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {\n const nextLevel: string[] = [];\n\n for (const directory of level) {\n let children: string[];\n\n try {\n children = await this.backend.list(directory);\n } catch {\n continue;\n }\n\n children.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));\n\n for (const child of children) {\n const relativeToJail = insideCwd\n ? path.relative(jailRoot, child).split(path.sep).join(\"/\")\n : child;\n\n if (\n insideCwd &&\n (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))\n ) {\n continue;\n }\n\n const relativeToRoot = path.relative(root, child).split(path.sep).join(\"/\");\n\n if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) {\n continue;\n }\n\n if (entries.length >= maxEntries) {\n truncated = true;\n\n break;\n }\n\n const described = await this.describe(child);\n\n entries.push({\n path: relativeToJail,\n type: described.type,\n ...(described.size !== undefined ? { size: described.size } : {}),\n depth: currentDepth,\n });\n\n if (described.type === \"directory\") {\n nextLevel.push(child);\n }\n }\n\n if (truncated) {\n break;\n }\n }\n\n if (truncated) {\n break;\n }\n\n level = nextLevel;\n }\n\n return { path: insideCwd ? rootRelative || \".\" : root, entries, truncated };\n }\n\n /** Kind and size of a listed entry; symlinks are reported, never followed. */\n private async describe(absPath: string): Promise<{ type: ListDirEntryType; size?: number }> {\n if (this.backend.stat) {\n try {\n const stats = await this.backend.stat(absPath);\n\n return stats.type === \"file\" ? { type: \"file\", size: stats.size } : { type: stats.type };\n } catch {\n return { type: \"other\" };\n }\n }\n\n // No `stat`: a child is a directory iff it can be listed, and it must\n // still resolve inside the jail before it is descended into.\n if (await this.isDirectory(absPath)) {\n try {\n await resolveInJail(this.policy, absPath);\n\n return { type: \"directory\" };\n } catch {\n return { type: \"other\" };\n }\n }\n\n return { type: \"file\" };\n }\n\n public async grep(\n pattern: string,\n opts?: { glob?: string; ignoreCase?: boolean },\n ): Promise<GrepResult> {\n if (!isSafeGrepPattern(pattern)) {\n throw new WorkspacePolicyError(\n `Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`,\n { type: \"unsafe-pattern\", pattern },\n );\n }\n\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const flags = opts?.ignoreCase ? \"i\" : \"\";\n const regex = new RegExp(pattern, flags);\n const globRegex = opts?.glob ? globToRegExp(opts.glob) : undefined;\n\n const files = await this.walkFiles(jailRoot);\n const matches: GrepMatch[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (globRegex && !globRegex.test(relativePath)) {\n continue;\n }\n\n // Skip files the deny list would block (e.g. `.git/**`).\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n let content: string;\n\n try {\n content = await this.backend.readFile(absFile);\n } catch {\n // Unreadable entry (vanished, binary handle) — skip, don't fail.\n continue;\n }\n\n const lines = content.split(\"\\n\");\n\n for (let index = 0; index < lines.length; index++) {\n const line = lines[index] ?? \"\";\n\n // Cap the input size a single `RegExp#test` call scans: backtracking\n // cost is exponential in input length, so this bounds the worst-case\n // time even a pathological (but length/shape-allowed) pattern can\n // burn on any one line.\n if (line.length > MAX_GREP_LINE_SCAN_LENGTH) {\n continue;\n }\n\n if (regex.test(line)) {\n matches.push({ path: relativePath, line: index + 1, text: line });\n\n if (matches.length >= DEFAULT_MAX_GREP_MATCHES) {\n return { matches, total: matches.length };\n }\n }\n }\n }\n\n return { matches, total: matches.length };\n }\n\n public async glob(pattern: string): Promise<string[]> {\n const { absolutePath: jailRoot } = await resolveInJail(this.policy, \".\");\n const regex = globToRegExp(pattern);\n\n const files = await this.walkFiles(jailRoot);\n const matched: string[] = [];\n\n for (const absFile of files) {\n const relativePath = path.relative(jailRoot, absFile).split(path.sep).join(\"/\");\n\n if (this.isDenied(relativePath)) {\n continue;\n }\n\n if (regex.test(relativePath)) {\n matched.push(relativePath);\n }\n }\n\n return matched.sort();\n }\n\n public async exists(inputPath: string): Promise<boolean> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n return this.backend.exists(absolutePath);\n }\n\n public async mkdir(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.backend.mkdir(absolutePath);\n }\n\n public async remove(inputPath: string): Promise<void> {\n const { absolutePath } = await resolveInJail(this.policy, inputPath);\n\n await this.withPathLock(absolutePath, () => this.backend.remove(absolutePath));\n }\n\n /** Whether a `/`-separated relative path hits the policy deny list. */\n private isDenied(relativePath: string): boolean {\n const denyPaths = this.policy.denyPaths;\n\n return (\n denyPaths !== undefined && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths)\n );\n }\n}\n\n/** The lower of two optional limits (either may be absent). */\nfunction lowerOf(\n policyLimit: number | undefined,\n requested: number | undefined,\n): number | undefined {\n if (requested === undefined || requested <= 0) {\n return policyLimit;\n }\n\n return policyLimit === undefined ? requested : Math.min(policyLimit, requested);\n}\n\n/**\n * Apply one exact-string replacement to `content` under the edit rules:\n * `oldString` must occur, and exactly once unless `replaceAll`.\n */\nfunction applyReplacement(\n content: string,\n edit: MultiEditOperation,\n inputPath: string,\n shownPath: string,\n editIndex?: number,\n): { content: string; replacements: number } {\n const occurrences = countOccurrences(content, edit.oldString);\n const which = editIndex === undefined ? \"\" : `Edit ${editIndex + 1}: `;\n const nothingApplied = editIndex === undefined ? \"\" : \" None of the edits were applied.\";\n const indexOption = editIndex === undefined ? {} : { editIndex };\n\n if (occurrences === 0) {\n throw new WorkspaceEditError(\n `${which}The text to replace was not found in \"${inputPath}\".${nothingApplied}`,\n { type: \"not-found\", path: shownPath, matches: 0, ...indexOption },\n );\n }\n\n if (occurrences > 1 && !edit.replaceAll) {\n throw new WorkspaceEditError(\n `${which}The text to replace is not unique in \"${inputPath}\" (${occurrences} matches); ` +\n `pass replaceAll or include more surrounding context.${nothingApplied}`,\n { type: \"not-unique\", path: shownPath, matches: occurrences, ...indexOption },\n );\n }\n\n return {\n content: edit.replaceAll\n ? content.split(edit.oldString).join(edit.newString)\n : replaceFirst(content, edit.oldString, edit.newString),\n replacements: edit.replaceAll ? occurrences : 1,\n };\n}\n\n/** Count non-overlapping occurrences of `needle` in `haystack`. */\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle === \"\") {\n return 0;\n }\n\n let count = 0;\n let from = 0;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n const index = haystack.indexOf(needle, from);\n\n if (index === -1) {\n break;\n }\n\n count++;\n from = index + needle.length;\n }\n\n return count;\n}\n\n/** Replace the first occurrence of `needle` with `replacement`. */\nfunction replaceFirst(haystack: string, needle: string, replacement: string): string {\n const index = haystack.indexOf(needle);\n\n if (index === -1) {\n return haystack;\n }\n\n return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);\n}\n\n/**\n * Create the policy-enforced operation layer over a backend.\n *\n * The returned {@link WorkspaceOps} is the single seam both the\n * agent-facing `.tools.*` factories and the human-facing direct methods\n * delegate to — one jail, one command-gate, one read-before-edit guard,\n * regardless of caller. Path inputs are workspace-relative and resolved\n * against `policy.cwd`; escapes and denied commands surface as typed\n * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.\n *\n * @param backend - The dumb IO executor (local disk or in-memory mock).\n * @param policy - The policy that bounds every operation.\n * @param runtime - State shared with other views of the same workspace\n * (its background commands); a fresh one when omitted.\n *\n * @example\n * const ops = createOps(localBackend, { cwd: \"/srv/api\", shell: { allow: [\"npm\"] } });\n * const { content, hash } = await ops.readFile(\"src/index.ts\");\n */\nexport function createOps(\n backend: WorkspaceBackend,\n policy: WorkspacePolicy,\n runtime?: OpsRuntime,\n): WorkspaceOps {\n return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-workspace/src/policy/jail.ts
|
|
4
|
+
/**
|
|
5
|
+
* Whether `child` is contained within `root` (or equals it), comparing
|
|
6
|
+
* absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`
|
|
7
|
+
* prefix-collision by comparing path segments, not string prefixes.
|
|
8
|
+
*/
|
|
9
|
+
function isInside(child, root) {
|
|
10
|
+
const relative = path.relative(root, child);
|
|
11
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Translate a glob (the small subset used by `denyPaths` and
|
|
15
|
+
* `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a
|
|
16
|
+
* `/`-separated relative path. `**` spans path separators; a single `*`
|
|
17
|
+
* does not.
|
|
18
|
+
*/
|
|
19
|
+
function globToRegExp(glob) {
|
|
20
|
+
let source = "";
|
|
21
|
+
for (let index = 0; index < glob.length; index++) {
|
|
22
|
+
const char = glob.charAt(index);
|
|
23
|
+
if (char === "*") {
|
|
24
|
+
if (glob.charAt(index + 1) === "*") {
|
|
25
|
+
source += ".*";
|
|
26
|
+
index++;
|
|
27
|
+
if (glob.charAt(index + 1) === "/") index++;
|
|
28
|
+
} else source += "[^/]*";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (char === "?") {
|
|
32
|
+
source += "[^/]";
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
return new RegExp(`^${source}$`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
41
|
+
* globs. A glob also covers everything beneath a matched directory
|
|
42
|
+
* (`".git/**"` covers `.git/config`), and a bare name without wildcards
|
|
43
|
+
* (`".git"`, `"node_modules"`) covers its contents.
|
|
44
|
+
*/
|
|
45
|
+
function matchesAnyGlob(relativePath, globs) {
|
|
46
|
+
return globs.some((glob) => {
|
|
47
|
+
if (globToRegExp(glob).test(relativePath)) return true;
|
|
48
|
+
if (!glob.includes("*") && !glob.includes("?")) {
|
|
49
|
+
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
50
|
+
return relativePath.startsWith(prefix);
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The jail check without touching the filesystem: is the absolute path
|
|
57
|
+
* `target` (already resolved lexically) inside `cwd` and off every
|
|
58
|
+
* `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT
|
|
59
|
+
* resolved here — the shell policy uses it to refuse obvious escapes
|
|
60
|
+
* synchronously, and `exec` re-checks every path with `resolveInJail`.
|
|
61
|
+
*/
|
|
62
|
+
function isInsideJailLexically(policy, target) {
|
|
63
|
+
const root = path.resolve(policy.cwd);
|
|
64
|
+
if (isInside(target, root)) {
|
|
65
|
+
const relative = path.relative(root, target).split(path.sep).join("/");
|
|
66
|
+
return !(policy.denyPaths && policy.denyPaths.length > 0 && matchesAnyGlob(relative, policy.denyPaths));
|
|
67
|
+
}
|
|
68
|
+
return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, path.resolve(allowRoot)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { globToRegExp, isInside, isInsideJailLexically, matchesAnyGlob };
|
|
73
|
+
//# sourceMappingURL=jail.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jail.mjs","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/jail.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { WorkspacePolicy } from \"../contracts\";\n\n/**\n * Whether `child` is contained within `root` (or equals it), comparing\n * absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`\n * prefix-collision by comparing path segments, not string prefixes.\n */\nexport function isInside(child: string, root: string): boolean {\n const relative = path.relative(root, child);\n\n return relative === \"\" || (!relative.startsWith(\"..\") && !path.isAbsolute(relative));\n}\n\n/**\n * Translate a glob (the small subset used by `denyPaths` and\n * `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a\n * `/`-separated relative path. `**` spans path separators; a single `*`\n * does not.\n */\nexport function globToRegExp(glob: string): RegExp {\n let source = \"\";\n\n for (let index = 0; index < glob.length; index++) {\n const char = glob.charAt(index);\n\n if (char === \"*\") {\n if (glob.charAt(index + 1) === \"*\") {\n // `**` — match across segments (and an optional trailing slash).\n source += \".*\";\n index++;\n\n if (glob.charAt(index + 1) === \"/\") {\n index++;\n }\n } else {\n // `*` — match within a single segment.\n source += \"[^/]*\";\n }\n\n continue;\n }\n\n if (char === \"?\") {\n source += \"[^/]\";\n\n continue;\n }\n\n // Escape everything else so it matches literally.\n source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n\n return new RegExp(`^${source}$`);\n}\n\n/**\n * Whether a workspace-relative (`/`-separated) path matches any of the\n * globs. A glob also covers everything beneath a matched directory\n * (`\".git/**\"` covers `.git/config`), and a bare name without wildcards\n * (`\".git\"`, `\"node_modules\"`) covers its contents.\n */\nexport function matchesAnyGlob(relativePath: string, globs: readonly string[]): boolean {\n return globs.some((glob) => {\n if (globToRegExp(glob).test(relativePath)) {\n return true;\n }\n\n if (!glob.includes(\"*\") && !glob.includes(\"?\")) {\n const prefix = glob.endsWith(\"/\") ? glob : `${glob}/`;\n\n return relativePath.startsWith(prefix);\n }\n\n return false;\n });\n}\n\n/**\n * The jail check without touching the filesystem: is the absolute path\n * `target` (already resolved lexically) inside `cwd` and off every\n * `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT\n * resolved here — the shell policy uses it to refuse obvious escapes\n * synchronously, and `exec` re-checks every path with `resolveInJail`.\n */\nexport function isInsideJailLexically(policy: WorkspacePolicy, target: string): boolean {\n const root = path.resolve(policy.cwd);\n\n if (isInside(target, root)) {\n const relative = path.relative(root, target).split(path.sep).join(\"/\");\n\n return !(\n policy.denyPaths &&\n policy.denyPaths.length > 0 &&\n matchesAnyGlob(relative, policy.denyPaths)\n );\n }\n\n return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, path.resolve(allowRoot)));\n}\n"],"mappings":";;;;;;;;AAQA,SAAgB,SAAS,OAAe,MAAuB;CAC7D,MAAM,WAAW,KAAK,SAAS,MAAM,KAAK;CAE1C,OAAO,aAAa,MAAO,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,KAAK,WAAW,QAAQ;AACpF;;;;;;;AAQA,SAAgB,aAAa,MAAsB;CACjD,IAAI,SAAS;CAEb,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EAChD,MAAM,OAAO,KAAK,OAAO,KAAK;EAE9B,IAAI,SAAS,KAAK;GAChB,IAAI,KAAK,OAAO,QAAQ,CAAC,MAAM,KAAK;IAElC,UAAU;IACV;IAEA,IAAI,KAAK,OAAO,QAAQ,CAAC,MAAM,KAC7B;GAEJ,OAEE,UAAU;GAGZ;EACF;EAEA,IAAI,SAAS,KAAK;GAChB,UAAU;GAEV;EACF;EAGA,UAAU,KAAK,QAAQ,qBAAqB,MAAM;CACpD;CAEA,OAAO,IAAI,OAAO,IAAI,OAAO,EAAE;AACjC;;;;;;;AAQA,SAAgB,eAAe,cAAsB,OAAmC;CACtF,OAAO,MAAM,MAAM,SAAS;EAC1B,IAAI,aAAa,IAAI,CAAC,CAAC,KAAK,YAAY,GACtC,OAAO;EAGT,IAAI,CAAC,KAAK,SAAS,GAAG,KAAK,CAAC,KAAK,SAAS,GAAG,GAAG;GAC9C,MAAM,SAAS,KAAK,SAAS,GAAG,IAAI,OAAO,GAAG,KAAK;GAEnD,OAAO,aAAa,WAAW,MAAM;EACvC;EAEA,OAAO;CACT,CAAC;AACH;;;;;;;;AASA,SAAgB,sBAAsB,QAAyB,QAAyB;CACtF,MAAM,OAAO,KAAK,QAAQ,OAAO,GAAG;CAEpC,IAAI,SAAS,QAAQ,IAAI,GAAG;EAC1B,MAAM,WAAW,KAAK,SAAS,MAAM,MAAM,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG;EAErE,OAAO,EACL,OAAO,aACP,OAAO,UAAU,SAAS,KAC1B,eAAe,UAAU,OAAO,SAAS;CAE7C;CAEA,QAAQ,OAAO,cAAc,CAAC,EAAC,CAAE,MAAM,cAAc,SAAS,QAAQ,KAAK,QAAQ,SAAS,CAAC,CAAC;AAChG"}
|
package/esm/policy/policy.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WorkspacePolicy } from "../contracts/workspace-policy.type.mjs";
|
|
2
|
+
import "../contracts/index.mjs";
|
|
2
3
|
//#region ../../packages/ai-workspace/src/policy/policy.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* The outcome of resolving a workspace-relative (or absolute) input path
|
|
@@ -6,7 +7,7 @@ import { WorkspacePolicy } from "../contracts/workspace-policy.type.mjs";
|
|
|
6
7
|
* touch, plus the workspace-relative form (POSIX-style, `/`-separated)
|
|
7
8
|
* the agent and tool results echo back.
|
|
8
9
|
*/
|
|
9
|
-
interface ResolvedPath {
|
|
10
|
+
export interface ResolvedPath {
|
|
10
11
|
/** Canonical absolute path (symlinks in existing ancestors collapsed). */
|
|
11
12
|
absolutePath: string;
|
|
12
13
|
/**
|
|
@@ -26,6 +27,17 @@ interface ResolvedPath {
|
|
|
26
27
|
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
27
28
|
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
28
29
|
*
|
|
30
|
+
* Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
|
|
31
|
+
* the two answer different questions. The confined reader refuses absolute
|
|
32
|
+
* paths, refuses every symbolic link (or follows only links inside its one
|
|
33
|
+
* root), requires the path to exist, and reads the file itself through a
|
|
34
|
+
* no-follow, identity-checked open. This jail must instead accept absolute
|
|
35
|
+
* paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
|
|
36
|
+
* symlinked `cwd`, links whose target stays inside, and paths that do not
|
|
37
|
+
* exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
|
|
38
|
+
* `exec`'s working directory and redirections use afterwards. Switching would
|
|
39
|
+
* change what agents may touch, so the workspace keeps this resolver.
|
|
40
|
+
*
|
|
29
41
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
30
42
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
31
43
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
@@ -34,32 +46,31 @@ interface ResolvedPath {
|
|
|
34
46
|
* @example
|
|
35
47
|
* const { absolutePath } = await resolveInJail(policy, "src/index.ts");
|
|
36
48
|
*/
|
|
37
|
-
declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Promise<ResolvedPath>;
|
|
49
|
+
export declare function resolveInJail(policy: WorkspacePolicy, inputPath: string): Promise<ResolvedPath>;
|
|
38
50
|
/**
|
|
39
|
-
* Whether a
|
|
51
|
+
* Whether a command line is permitted by the policy's `shell` sub-policy —
|
|
52
|
+
* `checkShellCommand(policy, command).allowed`.
|
|
40
53
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
51
|
-
* permitted. An absent `shell` block means no command may run at all.
|
|
54
|
+
* In argv mode (the default) the command must tokenize into a single argv
|
|
55
|
+
* (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
|
|
56
|
+
* spawns it with no shell, and unquoted metacharacters were exactly how an
|
|
57
|
+
* injected command chain used to ride past the allowlist. In shell mode
|
|
58
|
+
* (`interpreter: "sh"`) every simple command in the line is checked. Each
|
|
59
|
+
* argv's executable basename is matched against `shell.deny` (wins, case
|
|
60
|
+
* insensitive) then `shell.allow` (exhaustive when non-empty), then
|
|
61
|
+
* `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
|
|
62
|
+
* means no command may run at all.
|
|
52
63
|
*
|
|
53
|
-
* Returns a plain `boolean
|
|
54
|
-
* the
|
|
55
|
-
*
|
|
64
|
+
* Returns a plain `boolean`; use {@link checkShellCommand} for the reason
|
|
65
|
+
* and the model-facing message. Redirection targets are checked lexically
|
|
66
|
+
* here; `exec` re-checks them with symlinks resolved.
|
|
56
67
|
*
|
|
57
68
|
* @example
|
|
58
69
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
59
70
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
|
|
60
71
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
|
|
61
72
|
*/
|
|
62
|
-
declare function isCommandAllowed(policy: WorkspacePolicy, command: string): boolean;
|
|
73
|
+
export declare function isCommandAllowed(policy: WorkspacePolicy, command: string): boolean;
|
|
63
74
|
/**
|
|
64
75
|
* Build the exact environment a spawned process receives — `process.env`
|
|
65
76
|
* is **never** inherited wholesale. The result is
|
|
@@ -72,7 +83,6 @@ declare function isCommandAllowed(policy: WorkspacePolicy, command: string): boo
|
|
|
72
83
|
* buildEnv({ cwd, shell: { inheritEnv: ["PATH"], env: { CI: "1" } } });
|
|
73
84
|
* // → { PATH: <process PATH>, CI: "1" }
|
|
74
85
|
*/
|
|
75
|
-
declare function buildEnv(policy: WorkspacePolicy): Record<string, string>;
|
|
86
|
+
export declare function buildEnv(policy: WorkspacePolicy): Record<string, string>;
|
|
76
87
|
//#endregion
|
|
77
|
-
export { ResolvedPath, buildEnv, isCommandAllowed, resolveInJail };
|
|
78
88
|
//# sourceMappingURL=policy.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"policy.d.mts","names":[],"sources":["../../../../../../../../packages/ai-workspace/src/policy/policy.ts"],"mappings":";;;;;;;;;iBAaiB;;EAEf;;;;;;EAMA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAyEoB,cACpB,QAAQ,iBACR,oBACC,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;wBAqEK,iBAAiB,QAAQ,iBAAiB;;;;;;;;;;;;;wBAgB1C,SAAS,QAAQ,kBAAkB"}
|
package/esm/policy/policy.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WorkspacePolicyError } from "../errors.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { isInside, matchesAnyGlob } from "./jail.mjs";
|
|
3
|
+
import { checkShellCommand } from "./shell-check.mjs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { realpath } from "node:fs/promises";
|
|
5
6
|
|
|
@@ -26,55 +27,6 @@ async function canonicalize(target) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
/**
|
|
29
|
-
* Whether `child` is contained within `root` (or equals it), comparing
|
|
30
|
-
* canonical absolute paths. Guards against the `/srv/app-evil` vs
|
|
31
|
-
* `/srv/app` prefix-collision by anchoring on a path separator.
|
|
32
|
-
*/
|
|
33
|
-
function isInside(child, root) {
|
|
34
|
-
const relative = path.relative(root, child);
|
|
35
|
-
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
|
|
39
|
-
* `?`) into an anchored `RegExp` over a `/`-separated relative path.
|
|
40
|
-
* `**` spans path separators; a single `*` does not.
|
|
41
|
-
*/
|
|
42
|
-
function globToRegExp(glob) {
|
|
43
|
-
let source = "";
|
|
44
|
-
for (let index = 0; index < glob.length; index++) {
|
|
45
|
-
const char = glob[index];
|
|
46
|
-
if (char === "*") {
|
|
47
|
-
if (glob[index + 1] === "*") {
|
|
48
|
-
source += ".*";
|
|
49
|
-
index++;
|
|
50
|
-
if (glob[index + 1] === "/") index++;
|
|
51
|
-
} else source += "[^/]*";
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (char === "?") {
|
|
55
|
-
source += "[^/]";
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
59
|
-
}
|
|
60
|
-
return new RegExp(`^${source}$`);
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Whether a workspace-relative (`/`-separated) path matches any of the
|
|
64
|
-
* policy's `denyPaths` globs. A deny glob also blocks everything beneath
|
|
65
|
-
* a matched directory (`".git/**"` blocks `.git/config`).
|
|
66
|
-
*/
|
|
67
|
-
function matchesDeny(relativePath, denyPaths) {
|
|
68
|
-
return denyPaths.some((glob) => {
|
|
69
|
-
if (globToRegExp(glob).test(relativePath)) return true;
|
|
70
|
-
if (!glob.includes("*") && !glob.includes("?")) {
|
|
71
|
-
const prefix = glob.endsWith("/") ? glob : `${glob}/`;
|
|
72
|
-
return relativePath.startsWith(prefix);
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
30
|
* Resolve and jail a single input path against a {@link WorkspacePolicy}.
|
|
79
31
|
*
|
|
80
32
|
* The input is resolved against `policy.cwd`, canonicalized (symlinks in
|
|
@@ -84,6 +36,17 @@ function matchesDeny(relativePath, denyPaths) {
|
|
|
84
36
|
* `denyPaths` glob even while inside `cwd`, is rejected with a
|
|
85
37
|
* {@link WorkspacePolicyError} of type `"path-escape"`.
|
|
86
38
|
*
|
|
39
|
+
* Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
|
|
40
|
+
* the two answer different questions. The confined reader refuses absolute
|
|
41
|
+
* paths, refuses every symbolic link (or follows only links inside its one
|
|
42
|
+
* root), requires the path to exist, and reads the file itself through a
|
|
43
|
+
* no-follow, identity-checked open. This jail must instead accept absolute
|
|
44
|
+
* paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
|
|
45
|
+
* symlinked `cwd`, links whose target stays inside, and paths that do not
|
|
46
|
+
* exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
|
|
47
|
+
* `exec`'s working directory and redirections use afterwards. Switching would
|
|
48
|
+
* change what agents may touch, so the workspace keeps this resolver.
|
|
49
|
+
*
|
|
87
50
|
* @param policy - The bounding policy (its `cwd` is the jail root).
|
|
88
51
|
* @param inputPath - A workspace-relative or absolute path to resolve.
|
|
89
52
|
* @returns The canonical absolute path plus its `/`-separated relative form.
|
|
@@ -98,8 +61,9 @@ async function resolveInJail(policy, inputPath) {
|
|
|
98
61
|
const insideCwd = isInside(absolutePath, jailRoot);
|
|
99
62
|
const allowRoots = policy.allowPaths ?? [];
|
|
100
63
|
let insideAllow = false;
|
|
101
|
-
if (!insideCwd) {
|
|
102
|
-
|
|
64
|
+
if (!insideCwd) for (const root of allowRoots) {
|
|
65
|
+
const canonicalRoot = await canonicalize(root);
|
|
66
|
+
if (isInside(absolutePath, canonicalRoot)) {
|
|
103
67
|
insideAllow = true;
|
|
104
68
|
break;
|
|
105
69
|
}
|
|
@@ -110,7 +74,7 @@ async function resolveInJail(policy, inputPath) {
|
|
|
110
74
|
});
|
|
111
75
|
const relativeToJail = insideCwd ? path.relative(jailRoot, absolutePath).split(path.sep).join("/") : "";
|
|
112
76
|
if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
|
|
113
|
-
if (
|
|
77
|
+
if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
|
|
114
78
|
type: "path-escape",
|
|
115
79
|
path: inputPath
|
|
116
80
|
});
|
|
@@ -121,32 +85,22 @@ async function resolveInJail(policy, inputPath) {
|
|
|
121
85
|
};
|
|
122
86
|
}
|
|
123
87
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
|
|
127
|
-
* stripped).
|
|
128
|
-
*/
|
|
129
|
-
function executableBasename(firstToken) {
|
|
130
|
-
return path.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Whether a shell command is permitted by the policy's `shell` sub-policy.
|
|
88
|
+
* Whether a command line is permitted by the policy's `shell` sub-policy —
|
|
89
|
+
* `checkShellCommand(policy, command).allowed`.
|
|
134
90
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* allowlist); when `allow` is absent/empty, any non-denied command is
|
|
145
|
-
* permitted. An absent `shell` block means no command may run at all.
|
|
91
|
+
* In argv mode (the default) the command must tokenize into a single argv
|
|
92
|
+
* (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
|
|
93
|
+
* spawns it with no shell, and unquoted metacharacters were exactly how an
|
|
94
|
+
* injected command chain used to ride past the allowlist. In shell mode
|
|
95
|
+
* (`interpreter: "sh"`) every simple command in the line is checked. Each
|
|
96
|
+
* argv's executable basename is matched against `shell.deny` (wins, case
|
|
97
|
+
* insensitive) then `shell.allow` (exhaustive when non-empty), then
|
|
98
|
+
* `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
|
|
99
|
+
* means no command may run at all.
|
|
146
100
|
*
|
|
147
|
-
* Returns a plain `boolean
|
|
148
|
-
* the
|
|
149
|
-
*
|
|
101
|
+
* Returns a plain `boolean`; use {@link checkShellCommand} for the reason
|
|
102
|
+
* and the model-facing message. Redirection targets are checked lexically
|
|
103
|
+
* here; `exec` re-checks them with symlinks resolved.
|
|
150
104
|
*
|
|
151
105
|
* @example
|
|
152
106
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
|
|
@@ -154,15 +108,7 @@ function executableBasename(firstToken) {
|
|
|
154
108
|
* isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
|
|
155
109
|
*/
|
|
156
110
|
function isCommandAllowed(policy, command) {
|
|
157
|
-
|
|
158
|
-
if (!shell) return false;
|
|
159
|
-
const argv = tokenizeCommand(command);
|
|
160
|
-
if (argv === null) return false;
|
|
161
|
-
const executable = executableBasename(argv[0]);
|
|
162
|
-
if (executable === "") return false;
|
|
163
|
-
if (shell.deny && shell.deny.includes(executable)) return false;
|
|
164
|
-
if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
|
|
165
|
-
return true;
|
|
111
|
+
return checkShellCommand(policy, command).allowed;
|
|
166
112
|
}
|
|
167
113
|
/**
|
|
168
114
|
* Build the exact environment a spawned process receives — `process.env`
|
|
@@ -187,7 +133,38 @@ function buildEnv(policy) {
|
|
|
187
133
|
if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
|
|
188
134
|
return env;
|
|
189
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Variables removed from the environment of a shell-mode (`interpreter:
|
|
138
|
+
* "sh"`) command: each changes how the shell itself starts, parses or
|
|
139
|
+
* resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
|
|
140
|
+
* `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
|
|
141
|
+
* shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
|
|
142
|
+
*/
|
|
143
|
+
const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
|
|
144
|
+
"ENV",
|
|
145
|
+
"BASH_ENV",
|
|
146
|
+
"SHELLOPTS",
|
|
147
|
+
"BASHOPTS",
|
|
148
|
+
"CDPATH",
|
|
149
|
+
"IFS",
|
|
150
|
+
"PS4",
|
|
151
|
+
"GLOBIGNORE"
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* The environment a command actually receives: {@link buildEnv}, minus the
|
|
155
|
+
* shell start-up variables when the policy runs lines through a shell.
|
|
156
|
+
* Compared case-insensitively, because Windows environment names are.
|
|
157
|
+
*/
|
|
158
|
+
function buildCommandEnv(policy) {
|
|
159
|
+
const env = buildEnv(policy);
|
|
160
|
+
if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
|
|
161
|
+
for (const key of Object.keys(env)) {
|
|
162
|
+
const upper = key.toUpperCase();
|
|
163
|
+
if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
|
|
164
|
+
}
|
|
165
|
+
return env;
|
|
166
|
+
}
|
|
190
167
|
|
|
191
168
|
//#endregion
|
|
192
|
-
export { buildEnv, isCommandAllowed, resolveInJail };
|
|
169
|
+
export { buildCommandEnv, buildEnv, isCommandAllowed, resolveInJail };
|
|
193
170
|
//# sourceMappingURL=policy.mjs.map
|