@wrongstack/tools 0.5.6 → 0.6.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/dist/bash.d.ts +2 -1
- package/dist/bash.js +361 -3
- package/dist/bash.js.map +1 -1
- package/dist/builtin.js +408 -12
- package/dist/builtin.js.map +1 -1
- package/dist/circuit-breaker.d.ts +111 -0
- package/dist/circuit-breaker.js +150 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/exec.js +355 -4
- package/dist/exec.js.map +1 -1
- package/dist/fetch.js.map +1 -1
- package/dist/grep.js +9 -4
- package/dist/grep.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +412 -13
- package/dist/index.js.map +1 -1
- package/dist/logs.js +9 -4
- package/dist/logs.js.map +1 -1
- package/dist/pack.js +408 -12
- package/dist/pack.js.map +1 -1
- package/dist/process-registry.d.ts +112 -0
- package/dist/process-registry.js +327 -0
- package/dist/process-registry.js.map +1 -0
- package/dist/replace.js +9 -4
- package/dist/replace.js.map +1 -1
- package/dist/scaffold.js +2 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +10 -2
package/dist/logs.js
CHANGED
|
@@ -5,12 +5,17 @@ import * as path from 'path';
|
|
|
5
5
|
// src/logs.ts
|
|
6
6
|
|
|
7
7
|
// src/_regex.ts
|
|
8
|
-
var MAX_PATTERN_LEN =
|
|
8
|
+
var MAX_PATTERN_LEN = 256;
|
|
9
9
|
var DANGEROUS_PATTERNS = [
|
|
10
|
-
/(\([^)]*[+*][^)]*\))[+*]/,
|
|
11
10
|
// (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier
|
|
12
|
-
/(\(
|
|
13
|
-
|
|
11
|
+
/(\([^)]*[+*][^)]*\))[+*]/,
|
|
12
|
+
/(\(\?:[^)]*[+*][^)]*\))[+*]/,
|
|
13
|
+
// Adjacent quantifiers: a++ a*+
|
|
14
|
+
/[+*]{2,}/,
|
|
15
|
+
// Quantifier on alternation with length 2+
|
|
16
|
+
/\([^|)]+\|[^)]+\)[+*][+*]/,
|
|
17
|
+
// Greedy quantifier inside lookahead/lookbehind — (?!.*a+)
|
|
18
|
+
/[\(\[][^)\]]*[+*][^)\]]*[\)\]][^)]*\?\??/
|
|
14
19
|
];
|
|
15
20
|
function compileUserRegex(pattern, flags) {
|
|
16
21
|
if (typeof pattern !== "string") {
|
package/dist/logs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/_regex.ts","../src/_util.ts","../src/logs.ts"],"names":["resolve","path"],"mappings":"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","file":"logs.js","sourcesContent":["/**\n * Compile a user-supplied regex with conservative bounds against ReDoS.\n *\n * Node's regex engine (V8) is backtracking-based and cannot interrupt a\n * synchronous match — a pattern like `(a+)+$` against a sufficiently long\n * line will pin a worker for seconds. The executor's outer `timeoutMs` only\n * fires between async boundaries, so a long regex eval inside a sync loop\n * is uninterruptible.\n *\n * We can't fully prevent ReDoS without an alternative engine (re2-wasm), but\n * we can sharply limit the blast radius:\n *\n * 1. Cap pattern length — practically all legitimate user patterns are\n * under 256 characters. A 4 KB pattern is almost certainly malicious\n * or a copy-paste accident.\n * 2. Reject patterns containing the most obvious super-linear structures.\n * This is a coarse filter (false-positives are likely; we accept that\n * for hostile-input contexts).\n *\n * Callers should additionally bound the *subject* length (e.g. by capping\n * line size before matching).\n */\n\nconst MAX_PATTERN_LEN = 512;\n\n// Heuristics for catastrophic-backtracking constructs. Not exhaustive; bias\n// toward false-positives in tools that accept LLM-generated input.\nconst DANGEROUS_PATTERNS: ReadonlyArray<RegExp> = [\n /(\\([^)]*[+*][^)]*\\))[+*]/, // (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier\n /(\\(\\?:[^)]*[+*][^)]*\\))[+*]/, // same, with non-capturing group\n];\n\nexport interface CompileResult {\n ok: true;\n regex: RegExp;\n}\n\nexport interface CompileFail {\n ok: false;\n reason: string;\n}\n\nexport function compileUserRegex(pattern: string, flags: string): CompileResult | CompileFail {\n if (typeof pattern !== 'string') {\n return { ok: false, reason: 'pattern must be a string' };\n }\n if (pattern.length === 0) {\n return { ok: false, reason: 'pattern is empty' };\n }\n if (pattern.length > MAX_PATTERN_LEN) {\n return { ok: false, reason: `pattern exceeds ${MAX_PATTERN_LEN} characters` };\n }\n for (const rx of DANGEROUS_PATTERNS) {\n if (rx.test(pattern)) {\n return {\n ok: false,\n reason:\n 'pattern looks vulnerable to catastrophic backtracking — rewrite without nested quantifiers',\n };\n }\n }\n try {\n return { ok: true, regex: new RegExp(pattern, flags) };\n } catch (err) {\n return {\n ok: false,\n reason: err instanceof Error ? err.message : 'invalid regex',\n };\n }\n}\n\n/**\n * Truncate a subject line to a safe length for synchronous regex eval.\n * The cap is conservative; tools that need exact-line matching against very\n * long lines should use ripgrep externally rather than the native walker.\n */\nexport const MAX_SUBJECT_LEN = 64 * 1024;\n\nexport function capSubject(line: string): string {\n return line.length > MAX_SUBJECT_LEN ? line.slice(0, MAX_SUBJECT_LEN) : line;\n}\n","import * as path from 'node:path';\nimport type { Context } from '@wrongstack/core';\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.cwd, input);\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const root = path.resolve(ctx.projectRoot);\n const target = path.resolve(absPath);\n const rel = path.relative(root, target);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(`Path \"${absPath}\" is outside project root \"${root}\"`);\n }\n return target;\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n","import { spawn } from 'node:child_process';\r\nimport { buildChildEnv } from '@wrongstack/core';\r\nimport type { Tool } from '@wrongstack/core';\r\nimport { compileUserRegex } from './_regex.js';\r\nimport { safeResolve } from './_util.js';\r\n\r\ninterface LogsInput {\r\n service?: string;\r\n path?: string;\r\n lines?: number;\r\n stream?: boolean;\r\n filter?: string;\r\n since?: '1h' | '6h' | '24h' | 'all';\r\n cwd?: string;\r\n}\r\n\r\ninterface LogEntry {\r\n timestamp: string;\r\n level: string;\r\n message: string;\r\n source?: string;\r\n}\r\n\r\ninterface LogsOutput {\r\n source: string;\r\n entries: LogEntry[];\r\n total: number;\r\n truncated: boolean;\r\n stream_mode: boolean;\r\n}\r\n\r\nexport const logsTool: Tool<LogsInput, LogsOutput> = {\r\n name: 'logs',\r\n category: 'Logs',\r\n description:\r\n 'Stream or fetch logs from a service or file. Supports Docker, systemd, or plain log files.',\r\n usageHint:\r\n 'Set `service` for Docker/systemd, `path` for file. `lines` limits output. `stream` for tail -f behavior. `filter` regex filters lines.',\r\n permission: 'confirm',\r\n mutating: false,\r\n timeoutMs: 30_000,\r\n inputSchema: {\r\n type: 'object',\r\n properties: {\r\n service: {\r\n type: 'string',\r\n description: 'Service name for Docker or systemd journal',\r\n },\r\n path: {\r\n type: 'string',\r\n description: 'Path to log file (alternative to service)',\r\n },\r\n lines: {\r\n type: 'integer',\r\n description: 'Number of log lines to fetch (default: 100, 0 for all)',\r\n minimum: 0,\r\n maximum: 10000,\r\n },\r\n stream: {\r\n type: 'boolean',\r\n description: 'Stream logs continuously (like tail -f) (default: false)',\r\n },\r\n filter: {\r\n type: 'string',\r\n description: 'Regex pattern to filter log lines',\r\n },\r\n since: {\r\n type: 'string',\r\n enum: ['1h', '6h', '24h', 'all'],\r\n description: 'Only show logs since duration',\r\n },\r\n cwd: { type: 'string', description: 'Working directory (default: cwd)' },\r\n },\r\n },\r\n async execute(input, ctx, opts) {\r\n const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;\r\n const lines = input.lines ?? 100;\r\n let filterRe: RegExp | null = null;\r\n if (input.filter) {\r\n const compiled = compileUserRegex(input.filter, 'i');\r\n if (!compiled.ok) {\r\n throw new Error(`logs: ${compiled.reason}`);\r\n }\r\n filterRe = compiled.regex;\r\n }\r\n\r\n if (input.service) {\r\n return await dockerLogs(input.service, lines, filterRe, cwd, opts.signal);\r\n }\r\n\r\n if (input.path) {\r\n return await fileLogs(safeResolve(input.path, ctx), lines, filterRe, input.stream ?? false);\r\n }\r\n\r\n return {\r\n source: 'none',\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n };\r\n },\r\n};\r\n\r\nasync function dockerLogs(\r\n service: string,\r\n lines: number,\r\n filterRe: RegExp | null,\r\n cwd: string,\r\n signal: AbortSignal,\r\n since?: string,\r\n): Promise<LogsOutput> {\r\n const args = ['logs'];\r\n if (lines > 0) args.push('--tail', String(lines));\r\n if (since) {\r\n const sinceMap: Record<string, string> = { '1h': '1h', '6h': '6h', '24h': '24h' };\r\n args.push('--since', sinceMap[since] ?? '1h');\r\n }\r\n // Validate service name to prevent container name injection.\r\n // Docker container names are limited to [a-zA-Z0-9][a-zA-Z0-9._-]+.\r\n if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]+$/.test(service)) {\r\n return {\r\n source: `docker:${service}`,\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n };\r\n }\r\n args.push('--timestamps', service);\r\n\r\n return new Promise((resolve) => {\r\n let stdout = '';\r\n let stderr = '';\r\n const MAX = 200_000;\r\n\r\n const child = spawn('docker', args, { cwd, signal, env: buildChildEnv(), stdio: ['ignore', 'pipe', 'pipe'] });\r\n child.stdout?.on('data', (c) => {\r\n if (stdout.length < MAX) stdout += c.toString();\r\n });\r\n child.stderr?.on('data', (c) => {\r\n if (stderr.length < MAX) stderr += c.toString();\r\n });\r\n child.on('close', (code) => {\r\n const output = stdout + stderr;\r\n const entries = parseLogLines(output, filterRe);\r\n resolve({\r\n source: `docker:${service}`,\r\n entries,\r\n total: entries.length,\r\n truncated: output.length >= MAX,\r\n stream_mode: false,\r\n });\r\n });\r\n child.on('error', (e) =>\r\n resolve({\r\n source: `docker:${service}`,\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n }),\r\n );\r\n });\r\n}\r\n\r\n// Hard cap on tail-window size — `lines: 0` historically meant \"all\" and\r\n// happily buffered an entire multi-GB log into memory. Cap at 100k lines;\r\n// callers that need more should narrow with `filter`.\r\nconst MAX_TAIL_LINES = 100_000;\r\n\r\nasync function fileLogs(\r\n path: string,\r\n lines: number,\r\n filterRe: RegExp | null,\r\n stream: boolean,\r\n): Promise<LogsOutput> {\r\n const { createInterface } = await import('node:readline');\r\n const { createReadStream } = await import('node:fs');\r\n const entries: LogEntry[] = [];\r\n\r\n // Effective tail window: clamp to MAX_TAIL_LINES; treat 0 / negative as\r\n // \"max window\" rather than \"unlimited\" so a malicious /proc/kcore path\r\n // cannot OOM the worker.\r\n const effLines = lines > 0 ? Math.min(lines, MAX_TAIL_LINES) : MAX_TAIL_LINES;\r\n // Rolling window backed by a fixed-size circular buffer — at most\r\n // `effLines` strings live in memory regardless of file size.\r\n const window: string[] = new Array(effLines);\r\n let writeIdx = 0;\r\n let totalLines = 0;\r\n\r\n const rl = createInterface({\r\n input: createReadStream(path),\r\n crlfDelay: Number.POSITIVE_INFINITY,\r\n });\r\n\r\n for await (const line of rl) {\r\n if (filterRe && !filterRe.test(line)) continue;\r\n window[writeIdx] = line;\r\n writeIdx = (writeIdx + 1) % effLines;\r\n totalLines++;\r\n }\r\n\r\n // Read the window back in arrival order.\r\n const ordered: string[] = [];\r\n const start = totalLines >= effLines ? writeIdx : 0;\r\n const count = Math.min(totalLines, effLines);\r\n for (let i = 0; i < count; i++) {\r\n const v = window[(start + i) % effLines];\r\n if (v !== undefined) ordered.push(v);\r\n }\r\n\r\n for (const line of ordered) {\r\n const parsed = parseLine(line);\r\n if (parsed) entries.push(parsed);\r\n }\r\n\r\n return {\r\n source: path,\r\n entries,\r\n total: entries.length,\r\n truncated: totalLines > effLines,\r\n stream_mode: stream,\r\n };\r\n}\r\n\r\nfunction parseLogLines(output: string, filterRe: RegExp | null): LogEntry[] {\r\n const lines = output.split('\\n').filter(Boolean);\r\n const entries: LogEntry[] = [];\r\n\r\n for (const line of lines) {\r\n if (filterRe && !filterRe.test(line)) continue;\r\n const parsed = parseLine(line);\r\n if (parsed) entries.push(parsed);\r\n }\r\n\r\n return entries;\r\n}\r\n\r\nfunction parseLine(line: string): LogEntry | null {\r\n const tsRe = /^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z?)\\s+(?:\\[?(\\w+)\\]?)\\s*(.*)/;\r\n const match = tsRe.exec(line);\r\n\r\n if (match) {\r\n return {\r\n timestamp: match[1] ?? '',\r\n level: match[2]?.toLowerCase() ?? 'info',\r\n message: match[3] ?? '',\r\n };\r\n }\r\n\r\n const levelRe = /(?:ERROR|WARN|INFO|DEBUG|TRACE)\\s+(.*)/i;\r\n const levelMatch = levelRe.exec(line);\r\n\r\n if (levelMatch) {\r\n return {\r\n timestamp: '',\r\n level: levelMatch[1]?.toLowerCase() ?? 'info',\r\n message: levelMatch[2] ?? line,\r\n };\r\n }\r\n\r\n return {\r\n timestamp: '',\r\n level: 'info',\r\n message: line,\r\n };\r\n}\r\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/_regex.ts","../src/_util.ts","../src/logs.ts"],"names":["resolve","path"],"mappings":"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","file":"logs.js","sourcesContent":["/**\n * Compile a user-supplied regex with conservative bounds against ReDoS.\n *\n * Node's regex engine (V8) is backtracking-based and cannot interrupt a\n * synchronous match — a pattern like `(a+)+$` against a sufficiently long\n * line will pin a worker for seconds. The executor's outer `timeoutMs` only\n * fires between async boundaries, so a long regex eval inside a sync loop\n * is uninterruptible.\n *\n * We can't fully prevent ReDoS without an alternative engine (re2-wasm), but\n * we can sharply limit the blast radius:\n *\n * 1. Cap pattern length — practically all legitimate user patterns are\n * under 256 characters. A 4 KB pattern is almost certainly malicious\n * or a copy-paste accident.\n * 2. Reject patterns containing the most obvious super-linear structures.\n * This is a coarse filter (false-positives are likely; we accept that\n * for hostile-input contexts).\n *\n * Callers should additionally bound the *subject* length (e.g. by capping\n * line size before matching).\n */\n\nconst MAX_PATTERN_LEN = 256;\n\n// Heuristics for catastrophic-backtracking constructs. Not exhaustive; bias\n// toward false-positives in tools that accept LLM-generated input.\nconst DANGEROUS_PATTERNS: ReadonlyArray<RegExp> = [\n // (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier\n /(\\([^)]*[+*][^)]*\\))[+*]/,\n /(\\(\\?:[^)]*[+*][^)]*\\))[+*]/,\n // Adjacent quantifiers: a++ a*+\n /[+*]{2,}/,\n // Quantifier on alternation with length 2+\n /\\([^|)]+\\|[^)]+\\)[+*][+*]/,\n // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)\n /[\\(\\[][^)\\]]*[+*][^)\\]]*[\\)\\]][^)]*\\?\\??/,\n];\n\nexport interface CompileResult {\n ok: true;\n regex: RegExp;\n}\n\nexport interface CompileFail {\n ok: false;\n reason: string;\n}\n\nexport function compileUserRegex(pattern: string, flags: string): CompileResult | CompileFail {\n if (typeof pattern !== 'string') {\n return { ok: false, reason: 'pattern must be a string' };\n }\n if (pattern.length === 0) {\n return { ok: false, reason: 'pattern is empty' };\n }\n if (pattern.length > MAX_PATTERN_LEN) {\n return { ok: false, reason: `pattern exceeds ${MAX_PATTERN_LEN} characters` };\n }\n for (const rx of DANGEROUS_PATTERNS) {\n if (rx.test(pattern)) {\n return {\n ok: false,\n reason:\n 'pattern looks vulnerable to catastrophic backtracking — rewrite without nested quantifiers',\n };\n }\n }\n try {\n return { ok: true, regex: new RegExp(pattern, flags) };\n } catch (err) {\n return {\n ok: false,\n reason: err instanceof Error ? err.message : 'invalid regex',\n };\n }\n}\n\n/**\n * Truncate a subject line to a safe length for synchronous regex eval.\n * The cap is conservative; tools that need exact-line matching against very\n * long lines should use ripgrep externally rather than the native walker.\n */\nexport const MAX_SUBJECT_LEN = 64 * 1024;\n\nexport function capSubject(line: string): string {\n return line.length > MAX_SUBJECT_LEN ? line.slice(0, MAX_SUBJECT_LEN) : line;\n}\n","import * as path from 'node:path';\nimport type { Context } from '@wrongstack/core';\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.cwd, input);\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const root = path.resolve(ctx.projectRoot);\n const target = path.resolve(absPath);\n const rel = path.relative(root, target);\n if (rel.startsWith('..') || path.isAbsolute(rel)) {\n throw new Error(`Path \"${absPath}\" is outside project root \"${root}\"`);\n }\n return target;\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n","import { spawn } from 'node:child_process';\r\nimport { buildChildEnv } from '@wrongstack/core';\r\nimport type { Tool } from '@wrongstack/core';\r\nimport { compileUserRegex } from './_regex.js';\r\nimport { safeResolve } from './_util.js';\r\n\r\ninterface LogsInput {\r\n service?: string;\r\n path?: string;\r\n lines?: number;\r\n stream?: boolean;\r\n filter?: string;\r\n since?: '1h' | '6h' | '24h' | 'all';\r\n cwd?: string;\r\n}\r\n\r\ninterface LogEntry {\r\n timestamp: string;\r\n level: string;\r\n message: string;\r\n source?: string;\r\n}\r\n\r\ninterface LogsOutput {\r\n source: string;\r\n entries: LogEntry[];\r\n total: number;\r\n truncated: boolean;\r\n stream_mode: boolean;\r\n}\r\n\r\nexport const logsTool: Tool<LogsInput, LogsOutput> = {\r\n name: 'logs',\r\n category: 'Logs',\r\n description:\r\n 'Stream or fetch logs from a service or file. Supports Docker, systemd, or plain log files.',\r\n usageHint:\r\n 'Set `service` for Docker/systemd, `path` for file. `lines` limits output. `stream` for tail -f behavior. `filter` regex filters lines.',\r\n permission: 'confirm',\r\n mutating: false,\r\n timeoutMs: 30_000,\r\n inputSchema: {\r\n type: 'object',\r\n properties: {\r\n service: {\r\n type: 'string',\r\n description: 'Service name for Docker or systemd journal',\r\n },\r\n path: {\r\n type: 'string',\r\n description: 'Path to log file (alternative to service)',\r\n },\r\n lines: {\r\n type: 'integer',\r\n description: 'Number of log lines to fetch (default: 100, 0 for all)',\r\n minimum: 0,\r\n maximum: 10000,\r\n },\r\n stream: {\r\n type: 'boolean',\r\n description: 'Stream logs continuously (like tail -f) (default: false)',\r\n },\r\n filter: {\r\n type: 'string',\r\n description: 'Regex pattern to filter log lines',\r\n },\r\n since: {\r\n type: 'string',\r\n enum: ['1h', '6h', '24h', 'all'],\r\n description: 'Only show logs since duration',\r\n },\r\n cwd: { type: 'string', description: 'Working directory (default: cwd)' },\r\n },\r\n },\r\n async execute(input, ctx, opts) {\r\n const cwd = input.cwd ? safeResolve(input.cwd, ctx) : ctx.cwd;\r\n const lines = input.lines ?? 100;\r\n let filterRe: RegExp | null = null;\r\n if (input.filter) {\r\n const compiled = compileUserRegex(input.filter, 'i');\r\n if (!compiled.ok) {\r\n throw new Error(`logs: ${compiled.reason}`);\r\n }\r\n filterRe = compiled.regex;\r\n }\r\n\r\n if (input.service) {\r\n return await dockerLogs(input.service, lines, filterRe, cwd, opts.signal);\r\n }\r\n\r\n if (input.path) {\r\n return await fileLogs(safeResolve(input.path, ctx), lines, filterRe, input.stream ?? false);\r\n }\r\n\r\n return {\r\n source: 'none',\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n };\r\n },\r\n};\r\n\r\nasync function dockerLogs(\r\n service: string,\r\n lines: number,\r\n filterRe: RegExp | null,\r\n cwd: string,\r\n signal: AbortSignal,\r\n since?: string,\r\n): Promise<LogsOutput> {\r\n const args = ['logs'];\r\n if (lines > 0) args.push('--tail', String(lines));\r\n if (since) {\r\n const sinceMap: Record<string, string> = { '1h': '1h', '6h': '6h', '24h': '24h' };\r\n args.push('--since', sinceMap[since] ?? '1h');\r\n }\r\n // Validate service name to prevent container name injection.\r\n // Docker container names are limited to [a-zA-Z0-9][a-zA-Z0-9._-]+.\r\n if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]+$/.test(service)) {\r\n return {\r\n source: `docker:${service}`,\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n };\r\n }\r\n args.push('--timestamps', service);\r\n\r\n return new Promise((resolve) => {\r\n let stdout = '';\r\n let stderr = '';\r\n const MAX = 200_000;\r\n\r\n const child = spawn('docker', args, { cwd, signal, env: buildChildEnv(), stdio: ['ignore', 'pipe', 'pipe'] });\r\n child.stdout?.on('data', (c) => {\r\n if (stdout.length < MAX) stdout += c.toString();\r\n });\r\n child.stderr?.on('data', (c) => {\r\n if (stderr.length < MAX) stderr += c.toString();\r\n });\r\n child.on('close', (code) => {\r\n const output = stdout + stderr;\r\n const entries = parseLogLines(output, filterRe);\r\n resolve({\r\n source: `docker:${service}`,\r\n entries,\r\n total: entries.length,\r\n truncated: output.length >= MAX,\r\n stream_mode: false,\r\n });\r\n });\r\n child.on('error', (e) =>\r\n resolve({\r\n source: `docker:${service}`,\r\n entries: [],\r\n total: 0,\r\n truncated: false,\r\n stream_mode: false,\r\n }),\r\n );\r\n });\r\n}\r\n\r\n// Hard cap on tail-window size — `lines: 0` historically meant \"all\" and\r\n// happily buffered an entire multi-GB log into memory. Cap at 100k lines;\r\n// callers that need more should narrow with `filter`.\r\nconst MAX_TAIL_LINES = 100_000;\r\n\r\nasync function fileLogs(\r\n path: string,\r\n lines: number,\r\n filterRe: RegExp | null,\r\n stream: boolean,\r\n): Promise<LogsOutput> {\r\n const { createInterface } = await import('node:readline');\r\n const { createReadStream } = await import('node:fs');\r\n const entries: LogEntry[] = [];\r\n\r\n // Effective tail window: clamp to MAX_TAIL_LINES; treat 0 / negative as\r\n // \"max window\" rather than \"unlimited\" so a malicious /proc/kcore path\r\n // cannot OOM the worker.\r\n const effLines = lines > 0 ? Math.min(lines, MAX_TAIL_LINES) : MAX_TAIL_LINES;\r\n // Rolling window backed by a fixed-size circular buffer — at most\r\n // `effLines` strings live in memory regardless of file size.\r\n const window: string[] = new Array(effLines);\r\n let writeIdx = 0;\r\n let totalLines = 0;\r\n\r\n const rl = createInterface({\r\n input: createReadStream(path),\r\n crlfDelay: Number.POSITIVE_INFINITY,\r\n });\r\n\r\n for await (const line of rl) {\r\n if (filterRe && !filterRe.test(line)) continue;\r\n window[writeIdx] = line;\r\n writeIdx = (writeIdx + 1) % effLines;\r\n totalLines++;\r\n }\r\n\r\n // Read the window back in arrival order.\r\n const ordered: string[] = [];\r\n const start = totalLines >= effLines ? writeIdx : 0;\r\n const count = Math.min(totalLines, effLines);\r\n for (let i = 0; i < count; i++) {\r\n const v = window[(start + i) % effLines];\r\n if (v !== undefined) ordered.push(v);\r\n }\r\n\r\n for (const line of ordered) {\r\n const parsed = parseLine(line);\r\n if (parsed) entries.push(parsed);\r\n }\r\n\r\n return {\r\n source: path,\r\n entries,\r\n total: entries.length,\r\n truncated: totalLines > effLines,\r\n stream_mode: stream,\r\n };\r\n}\r\n\r\nfunction parseLogLines(output: string, filterRe: RegExp | null): LogEntry[] {\r\n const lines = output.split('\\n').filter(Boolean);\r\n const entries: LogEntry[] = [];\r\n\r\n for (const line of lines) {\r\n if (filterRe && !filterRe.test(line)) continue;\r\n const parsed = parseLine(line);\r\n if (parsed) entries.push(parsed);\r\n }\r\n\r\n return entries;\r\n}\r\n\r\nfunction parseLine(line: string): LogEntry | null {\r\n const tsRe = /^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z?)\\s+(?:\\[?(\\w+)\\]?)\\s*(.*)/;\r\n const match = tsRe.exec(line);\r\n\r\n if (match) {\r\n return {\r\n timestamp: match[1] ?? '',\r\n level: match[2]?.toLowerCase() ?? 'info',\r\n message: match[3] ?? '',\r\n };\r\n }\r\n\r\n const levelRe = /(?:ERROR|WARN|INFO|DEBUG|TRACE)\\s+(.*)/i;\r\n const levelMatch = levelRe.exec(line);\r\n\r\n if (levelMatch) {\r\n return {\r\n timestamp: '',\r\n level: levelMatch[1]?.toLowerCase() ?? 'info',\r\n message: levelMatch[2] ?? line,\r\n };\r\n }\r\n\r\n return {\r\n timestamp: '',\r\n level: 'info',\r\n message: line,\r\n };\r\n}\r\n"]}
|
package/dist/pack.js
CHANGED
|
@@ -232,6 +232,323 @@ function parseAuditOutput(json, exitCode) {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// src/circuit-breaker.ts
|
|
236
|
+
var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
237
|
+
var DEFAULT_SLOW_CALL_THRESHOLD_MS = 6e4;
|
|
238
|
+
var DEFAULT_MAX_SLOW_CALLS = 3;
|
|
239
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
240
|
+
var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
|
|
241
|
+
var DEFAULT_COOLDOWN_MS = 3e4;
|
|
242
|
+
var CircuitBreaker = class {
|
|
243
|
+
maxConsecutiveFailures;
|
|
244
|
+
slowCallThresholdMs;
|
|
245
|
+
maxSlowCalls;
|
|
246
|
+
windowMs;
|
|
247
|
+
maxCallsPerWindow;
|
|
248
|
+
cooldownMs;
|
|
249
|
+
state = "closed";
|
|
250
|
+
consecutiveFailures = 0;
|
|
251
|
+
window = [];
|
|
252
|
+
lastFailureAt = null;
|
|
253
|
+
lastSlowAt = null;
|
|
254
|
+
/** Timestamp when the breaker was opened (for cooldown calculation). */
|
|
255
|
+
openedAt = null;
|
|
256
|
+
/** Timestamp when the last call ran (for half-open gate). */
|
|
257
|
+
lastCallAt = null;
|
|
258
|
+
constructor(config = {}) {
|
|
259
|
+
this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
|
|
260
|
+
this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
|
|
261
|
+
this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
|
|
262
|
+
this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
263
|
+
this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
|
|
264
|
+
this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Returns true if the circuit allows a new call to proceed.
|
|
268
|
+
* When false, callers should abort the tool call and return a
|
|
269
|
+
* circuit-breaker error instead of spawning a process.
|
|
270
|
+
*/
|
|
271
|
+
get canProceed() {
|
|
272
|
+
this._checkStateTransition();
|
|
273
|
+
return this.state !== "open";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Snapshot of the current breaker state for observability (`/kill`).
|
|
277
|
+
*/
|
|
278
|
+
snapshot() {
|
|
279
|
+
this._checkStateTransition();
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
let cooldownRemaining = null;
|
|
282
|
+
if (this.openedAt !== null && this.state === "open") {
|
|
283
|
+
const elapsed = now - this.openedAt;
|
|
284
|
+
cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
state: this.state,
|
|
288
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
289
|
+
slowCallsInWindow: this.window.filter((c) => c.slow).length,
|
|
290
|
+
callsInWindow: this.window.length,
|
|
291
|
+
windowMs: this.windowMs,
|
|
292
|
+
cooldownRemainingMs: cooldownRemaining,
|
|
293
|
+
lastFailureAt: this.lastFailureAt,
|
|
294
|
+
lastSlowAt: this.lastSlowAt
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Call this BEFORE spawning a bash/exec process.
|
|
299
|
+
* Returns true if the call is allowed; false if the breaker is open.
|
|
300
|
+
* When false, callers MUST NOT spawn a process.
|
|
301
|
+
*/
|
|
302
|
+
beforeCall() {
|
|
303
|
+
this._checkStateTransition();
|
|
304
|
+
if (this.state === "open") return false;
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Call this AFTER a bash/exec process finishes (success or failure).
|
|
309
|
+
* `durationMs` is the wall-clock time the process ran.
|
|
310
|
+
* `failed` is true when the process returned a non-zero exit code or
|
|
311
|
+
* threw an exception before spawning.
|
|
312
|
+
*/
|
|
313
|
+
afterCall(durationMs, failed) {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
this.lastCallAt = now;
|
|
316
|
+
if (this.state === "half-open") {
|
|
317
|
+
if (failed) {
|
|
318
|
+
this._trip();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this._reset();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this._pruneWindow(now);
|
|
325
|
+
const slow = durationMs >= this.slowCallThresholdMs;
|
|
326
|
+
this.window.push({ at: now, failed, slow });
|
|
327
|
+
if (failed) {
|
|
328
|
+
this.consecutiveFailures++;
|
|
329
|
+
this.lastFailureAt = now;
|
|
330
|
+
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
331
|
+
this._trip();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.consecutiveFailures = 0;
|
|
336
|
+
if (slow) {
|
|
337
|
+
this.lastSlowAt = now;
|
|
338
|
+
const slowCount = this.window.filter((c) => c.slow).length;
|
|
339
|
+
if (slowCount >= this.maxSlowCalls) {
|
|
340
|
+
this._trip();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const callCount = this.window.length;
|
|
344
|
+
if (callCount >= this.maxCallsPerWindow) {
|
|
345
|
+
this._trip();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Force the breaker open. Used by /kill force and Ctrl+C. */
|
|
349
|
+
forceOpen() {
|
|
350
|
+
this._trip();
|
|
351
|
+
}
|
|
352
|
+
/** Force a reset to closed. Used by tests and /kill reset. */
|
|
353
|
+
forceReset() {
|
|
354
|
+
this._reset();
|
|
355
|
+
}
|
|
356
|
+
_trip() {
|
|
357
|
+
if (this.state === "open") return;
|
|
358
|
+
this.state = "open";
|
|
359
|
+
this.openedAt = Date.now();
|
|
360
|
+
}
|
|
361
|
+
_reset() {
|
|
362
|
+
this.state = "closed";
|
|
363
|
+
this.consecutiveFailures = 0;
|
|
364
|
+
this.window = [];
|
|
365
|
+
this.openedAt = null;
|
|
366
|
+
}
|
|
367
|
+
/** Transition from open → half-open when cooldown elapses. */
|
|
368
|
+
_checkStateTransition() {
|
|
369
|
+
if (this.state !== "open" || this.openedAt === null) return;
|
|
370
|
+
const elapsed = Date.now() - this.openedAt;
|
|
371
|
+
if (elapsed >= this.cooldownMs) {
|
|
372
|
+
this.state = "half-open";
|
|
373
|
+
this.openedAt = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
_pruneWindow(now) {
|
|
377
|
+
const cutoff = now - this.windowMs;
|
|
378
|
+
this.window = this.window.filter((c) => c.at >= cutoff);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/process-registry.ts
|
|
383
|
+
var DEFAULT_GRACE_MS = 2e3;
|
|
384
|
+
var ProcessRegistryImpl = class {
|
|
385
|
+
processes = /* @__PURE__ */ new Map();
|
|
386
|
+
breaker;
|
|
387
|
+
constructor(breakerConfig) {
|
|
388
|
+
this.breaker = new CircuitBreaker(breakerConfig);
|
|
389
|
+
}
|
|
390
|
+
register(info) {
|
|
391
|
+
this.processes.set(info.pid, { ...info, killed: false });
|
|
392
|
+
}
|
|
393
|
+
/** Unregister a process by PID. Called on 'close' / 'exit' events. */
|
|
394
|
+
unregister(pid) {
|
|
395
|
+
this.processes.delete(pid);
|
|
396
|
+
}
|
|
397
|
+
/** Get a single process by PID. */
|
|
398
|
+
get(pid) {
|
|
399
|
+
return this.processes.get(pid);
|
|
400
|
+
}
|
|
401
|
+
/** Get all tracked processes. */
|
|
402
|
+
list() {
|
|
403
|
+
return Array.from(this.processes.values());
|
|
404
|
+
}
|
|
405
|
+
/** Get processes filtered by name (e.g. 'bash', 'exec'). */
|
|
406
|
+
byName(name) {
|
|
407
|
+
return this.list().filter((p) => p.name === name);
|
|
408
|
+
}
|
|
409
|
+
/** Get processes filtered by session. */
|
|
410
|
+
bySession(sessionId) {
|
|
411
|
+
return this.list().filter((p) => p.sessionId === sessionId);
|
|
412
|
+
}
|
|
413
|
+
/** Count of active (non-killed) processes. */
|
|
414
|
+
get activeCount() {
|
|
415
|
+
let n = 0;
|
|
416
|
+
for (const p of this.processes.values()) {
|
|
417
|
+
if (!p.killed) n++;
|
|
418
|
+
}
|
|
419
|
+
return n;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Combined stats for observability — used by /ps and the TUI status bar.
|
|
423
|
+
*/
|
|
424
|
+
stats() {
|
|
425
|
+
return {
|
|
426
|
+
activeCount: this.activeCount,
|
|
427
|
+
totalCount: this.processes.size,
|
|
428
|
+
breaker: this.breaker.snapshot()
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Returns true if the circuit allows a new bash/exec call to proceed.
|
|
433
|
+
* When false, callers MUST NOT spawn a process.
|
|
434
|
+
*/
|
|
435
|
+
get canProceed() {
|
|
436
|
+
return this.breaker.canProceed;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Called before spawning a process. Returns true if allowed; false if
|
|
440
|
+
* the circuit breaker is open.
|
|
441
|
+
*/
|
|
442
|
+
beforeCall() {
|
|
443
|
+
return this.breaker.beforeCall();
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Called after a process finishes. `durationMs` is wall-clock time;
|
|
447
|
+
* `failed` is true for non-zero exit codes.
|
|
448
|
+
*/
|
|
449
|
+
afterCall(durationMs, failed) {
|
|
450
|
+
this.breaker.afterCall(durationMs, failed);
|
|
451
|
+
}
|
|
452
|
+
/** Force-open the circuit breaker (Ctrl+C, /kill force). */
|
|
453
|
+
forceBreakerOpen() {
|
|
454
|
+
this.breaker.forceOpen();
|
|
455
|
+
}
|
|
456
|
+
/** Force-reset the circuit breaker to closed (/kill reset). */
|
|
457
|
+
forceBreakerReset() {
|
|
458
|
+
this.breaker.forceReset();
|
|
459
|
+
}
|
|
460
|
+
/** Kill a single process by PID.
|
|
461
|
+
*
|
|
462
|
+
* On POSIX: sends SIGTERM to the *process group* (-pid) so that
|
|
463
|
+
* runaway grandchild processes (`sleep 9999 & disown`) are also killed.
|
|
464
|
+
* After `graceMs` a SIGKILL is sent if the process hasn't exited.
|
|
465
|
+
*
|
|
466
|
+
* On Windows: `child.kill()` maps to TerminateProcess — process groups
|
|
467
|
+
* are not meaningfully supported. A second `force=true` call sends
|
|
468
|
+
* SIGKILL (which maps to TerminateProcess again — the distinction is
|
|
469
|
+
* in the exit code, not the signal).
|
|
470
|
+
*
|
|
471
|
+
* Returns true if the process was found and kill was attempted.
|
|
472
|
+
*/
|
|
473
|
+
kill(pid, opts = {}) {
|
|
474
|
+
const p = this.processes.get(pid);
|
|
475
|
+
if (!p) return false;
|
|
476
|
+
if (p.killed) return true;
|
|
477
|
+
const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
|
|
478
|
+
const isWin = os.platform() === "win32";
|
|
479
|
+
if (isWin) {
|
|
480
|
+
try {
|
|
481
|
+
p.child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
482
|
+
} catch {
|
|
483
|
+
}
|
|
484
|
+
p.killed = true;
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
if (force) {
|
|
489
|
+
try {
|
|
490
|
+
process.kill(-pid, "SIGKILL");
|
|
491
|
+
} catch {
|
|
492
|
+
p.child.kill("SIGKILL");
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
try {
|
|
496
|
+
process.kill(-pid, "SIGTERM");
|
|
497
|
+
} catch {
|
|
498
|
+
p.child.kill("SIGTERM");
|
|
499
|
+
}
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
if (this.processes.has(pid) && !p.child.killed) {
|
|
502
|
+
try {
|
|
503
|
+
process.kill(-pid, "SIGKILL");
|
|
504
|
+
} catch {
|
|
505
|
+
try {
|
|
506
|
+
p.child.kill("SIGKILL");
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}, graceMs);
|
|
512
|
+
timer.unref?.();
|
|
513
|
+
}
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
p.killed = true;
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Kill all tracked processes.
|
|
521
|
+
* Returns the PIDs that were kill()ed.
|
|
522
|
+
*/
|
|
523
|
+
killAll(opts = {}) {
|
|
524
|
+
const pids = Array.from(this.processes.keys());
|
|
525
|
+
const killed = [];
|
|
526
|
+
for (const pid of pids) {
|
|
527
|
+
if (this.kill(pid, opts)) killed.push(pid);
|
|
528
|
+
}
|
|
529
|
+
return killed;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Kill all processes for a specific session.
|
|
533
|
+
* Returns the PIDs that were kill()ed.
|
|
534
|
+
*/
|
|
535
|
+
killSession(sessionId, opts = {}) {
|
|
536
|
+
const pids = this.bySession(sessionId).map((p) => p.pid);
|
|
537
|
+
const killed = [];
|
|
538
|
+
for (const pid of pids) {
|
|
539
|
+
if (this.kill(pid, opts)) killed.push(pid);
|
|
540
|
+
}
|
|
541
|
+
return killed;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
var _registry;
|
|
545
|
+
function getProcessRegistry() {
|
|
546
|
+
if (!_registry) {
|
|
547
|
+
_registry = new ProcessRegistryImpl();
|
|
548
|
+
}
|
|
549
|
+
return _registry;
|
|
550
|
+
}
|
|
551
|
+
|
|
235
552
|
// src/bash.ts
|
|
236
553
|
var MAX_OUTPUT = 32768;
|
|
237
554
|
var DEFAULT_TIMEOUT = 3e4;
|
|
@@ -270,12 +587,27 @@ var bashTool = {
|
|
|
270
587
|
},
|
|
271
588
|
async *executeStream(input, ctx, opts) {
|
|
272
589
|
if (!input?.command) throw new Error("bash: command is required");
|
|
590
|
+
const registry = getProcessRegistry();
|
|
591
|
+
if (!registry.beforeCall()) {
|
|
592
|
+
yield {
|
|
593
|
+
type: "final",
|
|
594
|
+
output: {
|
|
595
|
+
output: "",
|
|
596
|
+
exit_code: 1,
|
|
597
|
+
timed_out: false,
|
|
598
|
+
pid: null,
|
|
599
|
+
error: "bash: circuit breaker open \u2014 too many consecutive failures or slow calls. Use /kill to inspect or /kill reset to recover."
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
273
604
|
const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT, 6e5));
|
|
274
605
|
const isWin = os.platform() === "win32";
|
|
275
606
|
const shell = isWin ? process.env["COMSPEC"] ?? "cmd.exe" : process.env["SHELL"] ?? "/bin/bash";
|
|
276
607
|
const args = isWin ? ["/c", input.command] : ["-c", input.command];
|
|
277
608
|
const env = buildChildEnv(ctx.session?.id);
|
|
278
609
|
const detached = isWin ? !!input.background : true;
|
|
610
|
+
const startedAt = Date.now();
|
|
279
611
|
if (input.background) {
|
|
280
612
|
let buf2 = "";
|
|
281
613
|
let truncated = false;
|
|
@@ -286,7 +618,18 @@ var bashTool = {
|
|
|
286
618
|
detached: true,
|
|
287
619
|
signal: opts.signal
|
|
288
620
|
});
|
|
289
|
-
const
|
|
621
|
+
const pid2 = child2.pid;
|
|
622
|
+
if (typeof pid2 === "number") {
|
|
623
|
+
registry.register({
|
|
624
|
+
pid: pid2,
|
|
625
|
+
name: "bash",
|
|
626
|
+
command: input.command,
|
|
627
|
+
startedAt: Date.now(),
|
|
628
|
+
sessionId: ctx.session?.id,
|
|
629
|
+
child: child2
|
|
630
|
+
});
|
|
631
|
+
child2.on("close", () => registry.unregister(pid2));
|
|
632
|
+
}
|
|
290
633
|
child2.stdout?.on("data", (chunk) => {
|
|
291
634
|
if (!truncated) {
|
|
292
635
|
const remain = MAX_OUTPUT - buf2.length;
|
|
@@ -306,15 +649,16 @@ var bashTool = {
|
|
|
306
649
|
}
|
|
307
650
|
});
|
|
308
651
|
child2.on("close", () => {
|
|
652
|
+
registry.afterCall(Date.now() - startedAt, false);
|
|
309
653
|
});
|
|
310
|
-
if (typeof
|
|
654
|
+
if (typeof pid2 === "number") child2.unref();
|
|
311
655
|
yield {
|
|
312
656
|
type: "final",
|
|
313
657
|
output: {
|
|
314
658
|
output: truncated ? buf2.slice(0, MAX_OUTPUT) + "\u2026[truncated]" : buf2,
|
|
315
659
|
exit_code: null,
|
|
316
660
|
timed_out: false,
|
|
317
|
-
pid
|
|
661
|
+
pid: pid2
|
|
318
662
|
}
|
|
319
663
|
};
|
|
320
664
|
return;
|
|
@@ -326,6 +670,17 @@ var bashTool = {
|
|
|
326
670
|
detached,
|
|
327
671
|
signal: opts.signal
|
|
328
672
|
});
|
|
673
|
+
const pid = child.pid;
|
|
674
|
+
if (typeof pid === "number") {
|
|
675
|
+
registry.register({
|
|
676
|
+
pid,
|
|
677
|
+
name: "bash",
|
|
678
|
+
command: input.command,
|
|
679
|
+
startedAt: Date.now(),
|
|
680
|
+
sessionId: ctx.session?.id,
|
|
681
|
+
child
|
|
682
|
+
});
|
|
683
|
+
}
|
|
329
684
|
let buf = "";
|
|
330
685
|
let pending = "";
|
|
331
686
|
let timedOut = false;
|
|
@@ -408,10 +763,13 @@ var bashTool = {
|
|
|
408
763
|
});
|
|
409
764
|
child.on("error", (err) => {
|
|
410
765
|
for (const t of timers) clearTimeout(t);
|
|
766
|
+
registry.afterCall(Date.now() - startedAt, true);
|
|
411
767
|
push({ kind: "error", err });
|
|
412
768
|
});
|
|
413
769
|
child.on("close", (code) => {
|
|
414
770
|
for (const t of timers) clearTimeout(t);
|
|
771
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
772
|
+
registry.afterCall(Date.now() - startedAt, code !== 0 && code !== null);
|
|
415
773
|
push({ kind: "end", code });
|
|
416
774
|
});
|
|
417
775
|
try {
|
|
@@ -989,14 +1347,21 @@ var BLOCKED_ARG_PATTERNS = {
|
|
|
989
1347
|
// go run could execute arbitrary .go files; -ldflags could inject build-time code
|
|
990
1348
|
go: [/^-ldflags$/],
|
|
991
1349
|
// bun --preload is similar to node --require
|
|
992
|
-
bun: [/^--preload$/],
|
|
1350
|
+
bun: [/^--preload$/, /^run$/, /^bunx$/, /^create$/, /^init$/],
|
|
993
1351
|
// docker build/run can create containers with host access;
|
|
994
1352
|
// only allow read-only commands (ps, images, version)
|
|
995
1353
|
docker: [/^build$/, /^run$/, /^exec$/, /^push$/, /^pull$/],
|
|
996
1354
|
// find -exec/-ok/-execdir execute arbitrary commands
|
|
997
1355
|
find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
|
|
998
1356
|
// rm -rf / is catastrophic — block root and home targets
|
|
999
|
-
rm: [/^\/$/, /^\/\*$/, /^~$/]
|
|
1357
|
+
rm: [/^\/$/, /^\/\*$/, /^~$/],
|
|
1358
|
+
// npm run/exec/create/pack/publish can execute arbitrary scripts or publish malware
|
|
1359
|
+
npm: [/^run$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
|
|
1360
|
+
// pnpm run/dlx/exec/create can execute arbitrary scripts
|
|
1361
|
+
pnpm: [/^run$/, /^dlx$/, /^exec$/, /^create$/, /^init$/, /^pack$/, /^publish$/, /^deploy$/],
|
|
1362
|
+
// npx should only be used for --version; any package name is a vector for
|
|
1363
|
+
// malicious package execution (typosquatting, dependency confusion)
|
|
1364
|
+
npx: [/^[^\s]+$/]
|
|
1000
1365
|
};
|
|
1001
1366
|
function validateArgs(cmd, args) {
|
|
1002
1367
|
const blocked = BLOCKED_ARG_PATTERNS[cmd];
|
|
@@ -1029,6 +1394,18 @@ var execTool = {
|
|
|
1029
1394
|
required: ["command"]
|
|
1030
1395
|
},
|
|
1031
1396
|
async execute(input, ctx, opts) {
|
|
1397
|
+
const registry = getProcessRegistry();
|
|
1398
|
+
if (!registry.canProceed) {
|
|
1399
|
+
return {
|
|
1400
|
+
command: input.command,
|
|
1401
|
+
args: input.args ?? [],
|
|
1402
|
+
stdout: "",
|
|
1403
|
+
stderr: "Circuit breaker is open \u2014 too many consecutive failures. Use /kill reset to recover.",
|
|
1404
|
+
exitCode: 1,
|
|
1405
|
+
truncated: false,
|
|
1406
|
+
allowed: false
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1032
1409
|
const cmd = input.command.trim();
|
|
1033
1410
|
if (!cmd)
|
|
1034
1411
|
return {
|
|
@@ -1088,15 +1465,23 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
|
|
|
1088
1465
|
let stdout = "";
|
|
1089
1466
|
let stderr = "";
|
|
1090
1467
|
let killed = false;
|
|
1468
|
+
const startedAt = Date.now();
|
|
1091
1469
|
const child = spawn(cmd, args, {
|
|
1092
1470
|
cwd,
|
|
1093
1471
|
signal,
|
|
1094
1472
|
env: buildChildEnv(sessionId),
|
|
1095
1473
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1096
1474
|
});
|
|
1475
|
+
const registry = getProcessRegistry();
|
|
1476
|
+
const pid = child.pid;
|
|
1477
|
+
if (typeof pid === "number") {
|
|
1478
|
+
const fullCommand = `${cmd} ${args.join(" ")}`;
|
|
1479
|
+
registry.register({ pid, name: "exec", command: fullCommand, startedAt: Date.now(), sessionId, child });
|
|
1480
|
+
}
|
|
1097
1481
|
const timer = setTimeout(() => {
|
|
1098
1482
|
killed = true;
|
|
1099
|
-
|
|
1483
|
+
if (typeof pid === "number") registry.kill(pid);
|
|
1484
|
+
else child.kill("SIGTERM");
|
|
1100
1485
|
}, timeout);
|
|
1101
1486
|
child.stdout?.on("data", (chunk) => {
|
|
1102
1487
|
if (stdout.length < MAX_OUTPUT2) stdout += chunk.toString();
|
|
@@ -1106,18 +1491,24 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId) {
|
|
|
1106
1491
|
});
|
|
1107
1492
|
child.on("close", (code) => {
|
|
1108
1493
|
clearTimeout(timer);
|
|
1494
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
1495
|
+
const durationMs = Date.now() - startedAt;
|
|
1496
|
+
const exitCode = killed ? 124 : code ?? 1;
|
|
1497
|
+
registry.afterCall(durationMs, exitCode !== 0);
|
|
1109
1498
|
resolve5({
|
|
1110
1499
|
command: cmd,
|
|
1111
1500
|
args,
|
|
1112
1501
|
stdout: stdout.slice(0, MAX_OUTPUT2),
|
|
1113
1502
|
stderr: stderr.slice(0, MAX_OUTPUT2),
|
|
1114
|
-
exitCode
|
|
1503
|
+
exitCode,
|
|
1115
1504
|
truncated: stdout.length >= MAX_OUTPUT2 || stderr.length >= MAX_OUTPUT2,
|
|
1116
1505
|
allowed: true
|
|
1117
1506
|
});
|
|
1118
1507
|
});
|
|
1119
1508
|
child.on("error", (err) => {
|
|
1120
1509
|
clearTimeout(timer);
|
|
1510
|
+
if (typeof pid === "number") registry.unregister(pid);
|
|
1511
|
+
registry.afterCall(Date.now() - startedAt, true);
|
|
1121
1512
|
resolve5({
|
|
1122
1513
|
command: cmd,
|
|
1123
1514
|
args,
|
|
@@ -1761,12 +2152,17 @@ async function readGitignore(dir) {
|
|
|
1761
2152
|
}
|
|
1762
2153
|
|
|
1763
2154
|
// src/_regex.ts
|
|
1764
|
-
var MAX_PATTERN_LEN =
|
|
2155
|
+
var MAX_PATTERN_LEN = 256;
|
|
1765
2156
|
var DANGEROUS_PATTERNS = [
|
|
1766
|
-
/(\([^)]*[+*][^)]*\))[+*]/,
|
|
1767
2157
|
// (a+)+, (.*)+, etc — nested quantifier on a group with internal quantifier
|
|
1768
|
-
/(\(
|
|
1769
|
-
|
|
2158
|
+
/(\([^)]*[+*][^)]*\))[+*]/,
|
|
2159
|
+
/(\(\?:[^)]*[+*][^)]*\))[+*]/,
|
|
2160
|
+
// Adjacent quantifiers: a++ a*+
|
|
2161
|
+
/[+*]{2,}/,
|
|
2162
|
+
// Quantifier on alternation with length 2+
|
|
2163
|
+
/\([^|)]+\|[^)]+\)[+*][+*]/,
|
|
2164
|
+
// Greedy quantifier inside lookahead/lookbehind — (?!.*a+)
|
|
2165
|
+
/[\(\[][^)\]]*[+*][^)\]]*[\)\]][^)]*\?\??/
|
|
1770
2166
|
];
|
|
1771
2167
|
function compileUserRegex(pattern, flags) {
|
|
1772
2168
|
if (typeof pattern !== "string") {
|
|
@@ -3344,7 +3740,7 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
|
|
|
3344
3740
|
const fullPath = target;
|
|
3345
3741
|
if (!dryRun) {
|
|
3346
3742
|
await fs9.mkdir(path.dirname(fullPath), { recursive: true });
|
|
3347
|
-
await
|
|
3743
|
+
await atomicWrite(fullPath, substituteVars(content, name, vars));
|
|
3348
3744
|
}
|
|
3349
3745
|
files.push(resolvedPath);
|
|
3350
3746
|
filesCreated++;
|