import * as fs from 'fs'; import * as path from 'path'; import * as child_process from 'child_process'; import { ToolDef } from '../../llm/openai-compat.js'; import { logger } from '../../logger.js'; import { mermaidCheckHint } from './mermaid-blocks.js'; import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.js'; import { resolveBashModeFrom, type BashExecMode } from './sandbox-status.js'; import { resolveSandboxOverlay } from '../python-packages.js'; import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES, stripLeadingBom } from './binary-detect.js'; import { detectTextEncoding, decodeFileText, encodeFileText, UTF8_BOM_LABEL } from './text-decode.js'; import { recordVersion, resolveWriteTarget } from './conflict-guard.js'; import { recordFileVersion } from '../../workspace-history.js'; import type { ToolsConfig, PolicyFireClaim, PolicyApprovalBinding, PolicyApprovalMatch, PolicyApprovalConsume, PolicyRuntime, ToolContext, MissionBriefValue, MissionBriefIO, TaskConversationComment, TaskConversationIO, WorkspaceTaskSearchIO, FileProvenanceSourceKind, FileProvenanceRecord, FileProvenanceListFilters, FileProvenanceIO, FileProvenanceEvent, ToolResult, } from './tool-context.js'; /** * ツールの受け渡し用の型は `tool-context.ts` へ移した(issue #1161 段 G-1)。 * ここからの再輸出は旧ルートとして残す。取り込み元をこのファイルにしたままの * 呼び出し側(テストを含む)がそのまま動く。 */ export type { ToolsConfig, PolicyFireClaim, PolicyApprovalBinding, PolicyApprovalMatch, PolicyApprovalConsume, PolicyRuntime, ToolContext, MissionBriefValue, MissionBriefIO, TaskConversationComment, TaskConversationIO, WorkspaceTaskSearchIO, FileProvenanceSourceKind, FileProvenanceRecord, FileProvenanceListFilters, FileProvenanceIO, FileProvenanceEvent, ToolResult, } from './tool-context.js'; const BLOCKED_BINARY_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', ]); // ドキュメント系: 拡張子から office.ts の抽出ハンドラへ内部ディスパッチする。 // Read が唯一の入口で、旧 ReadExcel / ReadDocx / ReadPdf / ReadPPTX は登録ツールとしては廃止。 const DOCUMENT_EXTENSION_TOOL: Record = { '.xlsx': 'ReadExcel', '.xls': 'ReadExcel', '.xlsm': 'ReadExcel', '.docx': 'ReadDocx', '.doc': 'ReadDocx', '.pptx': 'ReadPPTX', '.ppt': 'ReadPPTX', '.pdf': 'ReadPdf', }; // Outlook メール: 添付保存の副作用があるため office.ts の ReadMsg ハンドラへ委譲する。 const MSG_EXTENSIONS = new Set(['.msg']); /** テキスト読み取り専用の範囲パラメータ。 */ const TEXT_ONLY_RANGE_KEYS = ['offset', 'limit', 'byte_offset', 'byte_length']; /** * .msg で弾く範囲パラメータ(#1165 段 B)。 * * offset / limit は **抽出した本文の行範囲として実際に効く**ようになったので * 弾かない。byte_offset / byte_length はファイル上のバイト位置で、.msg は * OLE2 の複合ファイルなので抽出した本文とは対応しない。渡されても意味のある * 範囲にならないため、これまでどおり弾く。 */ const MSG_REJECTED_RANGE_KEYS = ['byte_offset', 'byte_length']; /** * offset/limit/byte_offset/byte_length はテキスト読み取り専用。PDF/Office の * 抽出ハンドラはこれらを読まないため、指定しても効かず「常に先頭から」になる。 * * これらが渡されたら **読まずにエラーを返す**。以前は注記を先頭に足したうえで * 本文も返していたが、エージェントは注記を読み飛ばして本文を「これで全部」と * 扱い、大きな PDF の先頭数ページだけで作業を進めていた(#1009)。 * 出力があるかぎり、それは成功として扱われる。範囲指定が効かないなら、 * 部分的な本文を渡すより何も渡さないほうが安全。 * * 戻り値はエラー本文。該当が無ければ null。 */ export function documentRangeRejection(ext: string, input: Record): string | null { const rejectKeys = ext === '.msg' ? MSG_REJECTED_RANGE_KEYS : TEXT_ONLY_RANGE_KEYS; const used = rejectKeys.filter((key) => input[key] !== undefined); if (used.length === 0) return null; // 形式ごとに「代わりに何を渡すか」を具体的に書く。ReadToolDoc を見に行けと // だけ言うと、もう一往復増えるうえ従わないことがある。 const perFormat: Record = { '.pdf': 'ページを指定するには page_range を使ってください(例: Read({ file_path, page_range: "5-10" }))。' + '特定の語の周辺だけ読むなら query が使えます。', '.xlsx': 'sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。', '.xls': 'sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。', '.xlsm': 'sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。', '.docx': '量を絞るなら max_paragraphs を使ってください(例: Read({ file_path, max_paragraphs: 200 }))。', '.doc': '量を絞るなら max_paragraphs を使ってください(例: Read({ file_path, max_paragraphs: 200 }))。', '.pptx': 'スライドを指定するには page_range を使ってください(例: Read({ file_path, page_range: "2-4" }))。', '.ppt': 'スライドを指定するには page_range を使ってください(例: Read({ file_path, page_range: "2-4" }))。', '.msg': '本文を行で絞るには offset と limit を使ってください(例: Read({ file_path, offset: 100, limit: 50 }))。', }; const specific = perFormat[ext] ?? 'この形式で使える範囲指定は ReadToolDoc({ name: "Read" }) を参照してください。'; return `Read は ${ext} を読みませんでした。${used.join(' / ')} はテキスト読み取り専用で、` + `この形式では効きません(指定しても常に先頭から読まれます)。${specific}`; } /** * ドキュメント専用の範囲パラメータをテキストファイルに渡された場合の拒否本文。 * * #1009 の逆向き。テキスト読みの経路はこれらを一度も見ないので、指定しても * **黙って全文が返る**。エージェントは「5〜10 ページ目を取れた」と思って先へ * 進む。#1009 で確認したとおり、出力がある限りエージェントはそれを成功として * 扱う。あちらは注記だけは出ていたが、こちらは注記すら出ないので状態としては * 悪い。 * * 宣言に無いパラメータ(sheet / range / max_paragraphs / query)も対象に含める。 * ツールのドキュメントとヘルプが「Excel は sheet と range で」と案内している * 以上、モデルは普通に渡してくる。宣言の有無は「来ない」の根拠にならない。 * * slide_range / slideRange も同じ理由で含める。宣言にも説明にも出していないが、 * PowerPoint 側は別名として受け続けているので、モデルはこの名前で渡してくる。 */ export function textRangeRejection(ext: string, input: Record): string | null { const used = ['page_range', 'sheet', 'range', 'max_paragraphs', 'query', 'slide_range', 'slideRange'].filter( (key) => input[key] !== undefined, ); if (used.length === 0) return null; return `Read は ${ext || 'このファイル'} を読みませんでした。${used.join(' / ')} は` + 'PDF や Office 文書のためのパラメータで、テキストファイルには効きません' + '(指定しても全文が返ります)。行で絞るなら offset と limit を、' + 'バイト位置で絞るなら byte_offset と byte_length を使ってください' + '(例: Read({ file_path, offset: 100, limit: 50 }))。' + '中身を探すなら Grep が使えます。'; } /** * ドキュメント形式に対して、その形式では効かない範囲パラメータが来ていたら、 * 抽出を走らせる前に弾く。ハンドラを呼んでから弾くと、無駄な抽出コストを * 払ったうえで捨てることになる。 */ function rejectTextRangeParams(ext: string, input: Record): ToolResult | null { const message = documentRangeRejection(ext, input); return message ? { output: message, isError: true } : null; } // 一般的に Read で開く価値が無いバイナリ拡張子(誘導先なし) const BLOCKED_OPAQUE_BINARY_EXTENSIONS = new Set([ '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar', '.exe', '.dll', '.so', '.dylib', '.o', '.obj', '.a', '.lib', '.bin', '.dat', '.db', '.sqlite', '.sqlite3', '.ttf', '.otf', '.woff', '.woff2', '.eot', '.mp3', '.mp4', '.m4a', '.m4v', '.mov', '.avi', '.mkv', '.webm', '.wav', '.flac', '.ogg', '.opus', '.ico', '.tiff', '.tif', '.class', '.jar', '.war', '.pyc', '.pyo', '.wasm', ]); function readHeadBytes(filePath: string, maxBytes: number): Buffer | null { try { const fd = fs.openSync(filePath, 'r'); try { const stat = fs.fstatSync(fd); const size = Math.min(stat.size, maxBytes); const buf = Buffer.alloc(size); if (size > 0) fs.readSync(fd, buf, 0, size, 0); return buf; } finally { fs.closeSync(fd); } } catch { return null; } } /** * Encode Write content for disk. When overwriting an existing file that is * readable non-UTF-8 text (e.g. Shift_JIS), keep that encoding so a full * rewrite does not silently flip the file to UTF-8. New files and UTF-8 files * are written as UTF-8. Encoding is judged from the whole existing file (an * ASCII prefix must not hide a Shift_JIS tail). If the new content has chars the * original encoding cannot represent, returns an error instead of a lossy save. */ function encodeWriteContent( resolved: string, content: string, existedBefore: boolean, ): { bytes: Buffer } | { error: string } { if (existedBefore) { try { const decoded = decodeFileText(fs.readFileSync(resolved)); if (!decoded.binary && decoded.encoding !== 'utf-8') { const bytes = encodeFileText(content, decoded.encoding); if (bytes === null) { return { error: `内容に ${decoded.encoding} で表現できない文字(絵文字など)が含まれるため、破損を避けて保存を中止しました。` + `既存ファイルの文字コードは ${decoded.encoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`, }; } return { bytes }; } } catch { /* fall through to UTF-8 */ } } return { bytes: Buffer.from(content, 'utf-8') }; } const BASH_MAX_BUFFER_BYTES = 10 * 1024 * 1024; // Read the whole file into memory to detect its encoding only up to this size. // Beyond it we fall back to head-only detection (byte-range's main use is huge // minified/log files, which are UTF-8/ASCII) so a 2 GB target cannot OOM us. const FULL_DETECT_MAX_BYTES = 25 * 1024 * 1024; // --- コンテキスト予算に基づく切り詰め --- const CHARS_TO_TOKENS = 1.5; // 保守的(ASCII worst case: 1 char ≈ 1.5 tokens) const ABSOLUTE_MAX_OUTPUT_TOKENS = 60_000; // contextManager 不在時の絶対上限 const MIN_RETURNED_TOKENS = 2_000; // 切り詰め下限(小さすぎても意味がないので最低限返す) const ENCODED_PAYLOAD_MAX_OUTPUT_TOKENS = 2_000; // base64/data URL を含む HTML 等は通常より強く抑制 const TRUNCATION_NOTICE_RESERVE_CHARS = 600; // 注記分の予約 export function estimateTokensFromChars(chars: number): number { return Math.ceil(chars * CHARS_TO_TOKENS); } export function estimateCharsForTokenBudget(tokens: number): number { return Math.floor(tokens / CHARS_TO_TOKENS); } /** * 残コンテキストから「1 回の tool result に使って良い」トークン予算を決める。 * - contextManager があれば getAvailableTokens の 50% を目安にする * - なければ ABSOLUTE_MAX_OUTPUT_TOKENS を返す */ export function getToolOutputBudgetTokens(ctx: ToolContext): number { const cm = ctx.contextManager; if (cm) { const available = cm.getAvailableTokens(); // 半分を 1 回のツール出力に割り当て(残り半分は会話履歴や次の思考用) const budget = Math.floor(available * 0.5); return Math.max(MIN_RETURNED_TOKENS, Math.min(budget, ABSOLUTE_MAX_OUTPUT_TOKENS)); } return ABSOLUTE_MAX_OUTPUT_TOKENS; } /** * コンテンツが改行構造を持つかを判定する。 * - 1KB あたり 1 本以上の改行があれば「行指向」とみなす * - 行指向なら line 単位で切り詰め、そうでなければ byte/char 単位で切り詰める */ function isLineOriented(content: string): boolean { if (content.length < 500) return content.includes('\n'); const newlines = (content.match(/\n/g) || []).length; return newlines >= content.length / 1024; } function looksLikeLargeEncodedPayload(content: string): boolean { if (content.length < 8_000) return false; if (/data:[^;,\s]+;base64,[A-Za-z0-9+/=\s]{2000,}/.test(content)) return true; if (/base64[,:"'\s]+[A-Za-z0-9+/=\s]{2000,}/i.test(content)) return true; return false; } function capBudgetForEncodedPayload(content: string, budgetTokens: number): number { if (!looksLikeLargeEncodedPayload(content)) return budgetTokens; return Math.min(budgetTokens, ENCODED_PAYLOAD_MAX_OUTPUT_TOKENS); } /** * 文字列が budget を超える場合、先頭側を残して切り詰め、統一書式の注記を付ける。 * 行指向コンテンツは改行境界で、そうでなければ文字境界で切る。 * 注記には元サイズ・返却サイズ・続きの読み方を含める。 */ export function truncateToBudget( content: string, budgetTokens: number, options: { sourceLabel: string; // "file.txt" など、何のコンテンツか totalLines?: number; // 元の行数(分かる場合) totalBytes?: number; // 元のバイト数(分かる場合) continuationHint?: string; // 続きの読み方(上書き)。未指定時は行指向/非行指向で自動選択 baseLine?: number; // content が元データの何行目から始まるか(offset 付きで切り出した場合) }, ): { text: string; truncated: boolean; returnedTokensEstimate: number; cutCharIndex: number } { const totalChars = content.length; const totalTokens = estimateTokensFromChars(totalChars); if (totalTokens <= budgetTokens) { return { text: content, truncated: false, returnedTokensEstimate: totalTokens, cutCharIndex: totalChars }; } const budgetChars = estimateCharsForTokenBudget(budgetTokens) - TRUNCATION_NOTICE_RESERVE_CHARS; const safeBudgetChars = Math.max(1000, budgetChars); const lineOriented = isLineOriented(content); let cutIndex = Math.min(safeBudgetChars, content.length); if (lineOriented) { const lastNewline = content.lastIndexOf('\n', cutIndex); if (lastNewline > safeBudgetChars * 0.8) { cutIndex = lastNewline; } } const truncatedText = content.slice(0, cutIndex); const returnedTokens = estimateTokensFromChars(truncatedText.length); const returnedLines = truncatedText.split('\n').length; const returnedBytes = Buffer.byteLength(truncatedText, 'utf-8'); const sizeParts: string[] = []; sizeParts.push(`推定 ${totalTokens.toLocaleString()} tokens`); if (options.totalLines !== undefined) sizeParts.push(`${options.totalLines.toLocaleString()} 行`); if (options.totalBytes !== undefined) sizeParts.push(`${options.totalBytes.toLocaleString()} bytes`); // content 自体が offset 付きで切り出されたものなら、次に渡すべき offset は // 元データ上の絶対位置。切り出した先頭からの行数をそのまま出すと、実際には // 読み終えた場所より手前を指す番号を勧めることになる。 const nextLine = (options.baseLine ?? 0) + returnedLines; const defaultHint = lineOriented ? `続きを読むには Read(offset=${nextLine}, limit=...) で次の範囲を指定するか、Grep/Bash(head/tail/grep) で必要箇所だけ抽出してください` : `このファイルは改行が少なく行指定が効きません。続きは Read(byte_offset=${returnedBytes}, byte_length=...) で指定するか、Grep で必要箇所を抽出してください`; const hint = options.continuationHint ?? defaultHint; const returnedDescr = lineOriented ? `先頭 ${returnedLines.toLocaleString()} 行` : `先頭 ${returnedBytes.toLocaleString()} bytes`; const notice = `[自動切り詰め] ${options.sourceLabel} は大きすぎるため (${sizeParts.join(' / ')})、` + `残コンテキスト予算 ${budgetTokens.toLocaleString()} tokens に収まる ${returnedDescr} ` + `(約 ${returnedTokens.toLocaleString()} tokens) のみ返却しました。\n` + `${hint}\n` + `--- 以下、${returnedDescr} ---\n`; return { text: notice + truncatedText, truncated: true, returnedTokensEstimate: estimateTokensFromChars((notice + truncatedText).length), cutCharIndex: cutIndex, }; } // --- パスガード --- export function resolveAndGuard(workspacePath: string, filePath: string): string { const root = path.resolve(workspacePath); const resolved = path.resolve(root, filePath); // 1) 字句的な封じ込めチェック(従来動作) if (!resolved.startsWith(root + path.sep) && resolved !== root) { throw new Error(`Path traversal detected: "${filePath}" is outside workspace`); } // 2) symlink 脱出チェック。ワークスペース root が実在するときだけ行う // (未作成 root を渡すテスト等の挙動を保つため)。 if (fs.existsSync(root)) { const realRoot = fs.realpathSync(root); // resolved から上に辿り、実在する最も深い祖先を見つける let probe = resolved; while (!fs.existsSync(probe) && path.dirname(probe) !== probe) { probe = path.dirname(probe); } const realProbe = fs.realpathSync(probe); if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) { throw new Error(`Path traversal detected (symlink escape): "${filePath}" resolves outside workspace`); } } return resolved; } /** * ワークスペース直下の閲覧専用ディレクトリ名。ここ配下のファイルは Read 可・ * Write/Edit 不可。ユーザーが「エージェントに編集されたくないファイル」を置く領域。 */ export const READONLY_WORKSPACE_DIR = 'readonly'; /** * `resolved`(resolveAndGuard 済みの絶対パス)が `/readonly/` 配下なら * 例外を投げる。Read は許可し、Write/Edit/上書きだけを拒否する。 * * 注意: Bash はシェル経由でこのガードを迂回できる(完全サンドボックスは別範囲)。 * システムプロンプトでも readonly/ への書込禁止を指示し、二層で主要動線を塞ぐ。 */ export function assertWritable(workspacePath: string, resolved: string): void { const root = path.resolve(workspacePath); const readonlyRoot = path.join(root, READONLY_WORKSPACE_DIR); if (resolved === readonlyRoot || resolved.startsWith(readonlyRoot + path.sep)) { const rel = path.relative(root, resolved).split(path.sep).join('/'); throw new Error( `"${rel}" は readonly/(閲覧のみ)配下です。ここはユーザー専用の領域で書き込み・編集できません。成果物は output/ に書いてください。`, ); } } function normalizeWorkspaceRelativePath(workspacePath: string, targetPath: string): string { return path.relative(path.resolve(workspacePath), path.resolve(targetPath)).split(path.sep).join('/'); } /** * Skills live OUTSIDE the file jail (`data/spaces/{id}/skills`, sibling of * `files/`) and only appear under `{workspace}/skills/{name}/` after ReadSkill * materializes a directory-type skill. Agents often reach for `Read("skills/…")` * out of habit and hit a bare ENOENT with no signpost. Given the * workspace-relative path of a failed Read, return an actionable hint pointing * at ReadSkill/ListSkills — or null when the path is not under `skills/`. * * `relPath` is already resolveAndGuard-normalized (jail-confined, forward * slashes, no `..` escape), so the first segment is a safe skill-name candidate. */ function skillReadEnoentHint(ctx: ToolContext, relPath: string): string | null { const segments = relPath.split('/').filter(Boolean); if (segments[0] !== 'skills') return null; const skillName = segments[1]; // `Read("skills")` itself, or no name segment → generic guidance. if (!skillName) { return '`skills/` はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください。'; } // Already materialized (dir exists) but the requested sub-file is missing: // do NOT misdirect toward ReadSkill — the path itself is wrong. if (fs.existsSync(path.join(ctx.workspacePath, 'skills', skillName))) { return `スキル "${skillName}" は既に workspace の skills/${skillName}/ に展開済みですが、指定したファイル "${relPath}" が見つかりません。skills/${skillName}/ 配下の正しいパスを Glob か Bash の \`ls\` で確認してください。`; } // Known skill, not yet materialized → the ReadSkill gateway. const folder = ctx.folderContext ? { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId } : { root: ctx.skillCatalog?.getUserRoot() ?? '', leaf: ctx.userId ?? 'local' }; const known = ctx.skillCatalog?.getForFolder(folder.root, folder.leaf).some((s) => s.name === skillName); if (known) { return `スキル "${skillName}" は Read では直接読めません(まだ展開されていません)。ReadSkill({ name: "${skillName}" }) を呼ぶと本文が返り、ディレクトリ型スキルなら skills/${skillName}/ に展開されて以後 Read で個別ファイルを読めます。`; } // Under skills/ but no catalog match → generic guidance. return `"${relPath}" は見つかりません。\`skills/\` 配下はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください(ディレクトリ型スキルは ReadSkill 後に skills// が Read 可能になります)。`; } function normalizeAllowedPrefix(prefix: string): string { return prefix.replace(/^\/+/, '').replace(/\/+$/, ''); } export function resolveOutputPathWithin(workspacePath: string, requestedPath: string, allowedPrefixes: string[]): string { const resolved = resolveAndGuard(workspacePath, requestedPath); const relative = normalizeWorkspaceRelativePath(workspacePath, resolved); const normalizedPrefixes = allowedPrefixes.map(normalizeAllowedPrefix).filter(Boolean); const isAllowed = normalizedPrefixes.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`)); if (!isAllowed) { throw new Error(`Output path "${requestedPath}" must be within one of: ${normalizedPrefixes.join(', ')}`); } return resolved; } // --- コマンドホワイトリスト --- export const DEFAULT_ALLOWED_COMMANDS: string[] = [ 'cat', 'head', 'tail', 'wc', 'sort', 'uniq', 'awk', 'sed', 'grep', 'jq', 'ls', 'find', 'echo', 'date', 'diff', 'tr', 'cut', 'paste', 'tee', 'mkdir', 'cp', 'mv', 'touch', 'basename', 'dirname', 'realpath', 'stat', 'file', 'which', 'yes', 'true', 'false', 'test', 'expr', 'python3', 'node', ]; /** * コマンド文字列からサブコマンドの先頭トークンを抽出する。 * パイプ (|)、セミコロン (;)、&&、||、改行 (\n) によって連結された各サブコマンドを分割し、 * さらにバッククォートや $() によるコマンド置換内のコマンドも抽出する。 * プロセス置換 <(...) や >(...) も検出する。 * ここ文字列 <<< も検出する。 */ export function extractCommandTokens(command: string): string[] { const tokens: string[] = []; // プロセス置換 <(...) や >(...) を検出して内部を再帰抽出 const processSubstPattern = /[<>]\(([^)]*(?:\([^)]*\)[^)]*)*)\)/g; let psMatch: RegExpExecArray | null; while ((psMatch = processSubstPattern.exec(command)) !== null) { const inner = psMatch[1] ?? ''; tokens.push(...extractCommandTokens(inner)); } // $(...) のネストを適切に処理: 深さを追跡しながら抽出 function extractDollarParens(cmd: string): string[] { const inner: string[] = []; let i = 0; while (i < cmd.length) { if (cmd[i] === '$' && cmd[i + 1] === '(') { let depth = 1; let start = i + 2; i = start; while (i < cmd.length && depth > 0) { if (cmd[i] === '(') depth++; else if (cmd[i] === ')') depth--; i++; } const content = cmd.slice(start, i - 1); inner.push(content); // 再帰的にネストされた $() を処理 inner.push(...extractDollarParens(content)); } else { i++; } } return inner; } const dollarParenContents = extractDollarParens(command); for (const inner of dollarParenContents) { tokens.push(...extractCommandTokens(inner)); } // バッククォート内を抽出して再帰 const backtickPattern = /`([^`]*)`/g; let btMatch: RegExpExecArray | null; while ((btMatch = backtickPattern.exec(command)) !== null) { const inner = btMatch[1] ?? ''; tokens.push(...extractCommandTokens(inner)); } // $(...) および `...` およびプロセス置換を除去してからオペレータで分割 // パイプ(|)、セミコロン(;)、&&、||(論理OR)、改行(\n)、<<<(here string) let stripped = command; // ヒアドキュメント (<< 'DELIM' ... DELIM, << "DELIM" ... DELIM, <]\([^)]*\)/g, ''); // <<< を除去 (here string) stripped = stripped.replace(/<<<\s*\S+/g, ''); // オペレータで分割: ||, &&, |, ;, \n const parts = stripped.split(/\|\||&&|\||\n|;/); for (const part of parts) { const trimmed = part.trim(); if (trimmed.length === 0) continue; // 先頭トークン: 環境変数割り当て (KEY=VALUE) をスキップして最初の単語を取得 const words = trimmed.split(/\s+/); for (const word of words) { if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue; // env var assignment tokens.push(word); break; } } return tokens; } /** * インストール系コマンドパターンを検出してブロックする。 * apt/pip/npm 等によるパッケージインストールを防止する。 */ const BLOCKED_INSTALL_PATTERNS: RegExp[] = [ /\bpython3?\s+-m\s+pip\s+install\b/, /\bpip3?\s+install\b/, /\bapt(?:-get)?\s+install\b/, /\bnpm\s+install\b/, /\bnpm\s+i\b/, /\byarn\s+add\b/, /\bcurl\s+.*\|\s*(?:ba)?sh\b/, /\bwget\s+.*\|\s*(?:ba)?sh\b/, ]; /** * Packages baked into the runtime python (see runtime/python-requirements.txt). * Listed by IMPORT name (what `import` takes), with the pip name in parens where * it differs — e.g. you write `import docx`, not `import python-docx`. */ export const PREINSTALLED_HINT = 'pypdf, fitz(pymupdf), pdfplumber, docx(python-docx), pptx(python-pptx), openpyxl, ' + 'xlsxwriter, xlrd, odf(odfpy), striprtf, bs4(beautifulsoup4), lxml, markdownify, ' + 'markdown, numpy, pandas, tabulate, dateutil(python-dateutil), matplotlib, ' + 'PIL(Pillow), charset_normalizer, yaml(PyYAML)'; export function checkBlockedInstallPatterns(command: string): void { for (const pattern of BLOCKED_INSTALL_PATTERNS) { if (pattern.test(command)) { throw new Error( `Package installation is not available in the sandbox. ` + `These are preinstalled and importable: ${PREINSTALLED_HINT}. ` + `If you need another package, request it with RequestPackage({ name: "", reason: "..." }) ` + `— an approver installs it into this workspace and the run resumes. Do not retry the install here.` ); } } } const SAFE_ABS_PATH_PREFIXES = [ '/tmp/', '/dev/null', '/dev/stdin', '/dev/stdout', '/dev/stderr', '/usr/bin/', '/usr/local/bin/', '/usr/lib/', '/usr/share/', '/usr/include/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/proc/self/', ]; const SAFE_EXACT_ABS_PATHS = new Set([ '/tmp', '/dev/null', '/dev/stdin', '/dev/stdout', '/dev/stderr', ]); function isAbsPathAllowed(token: string, workspacePath: string): boolean { const resolvedWorkspace = path.resolve(workspacePath); // Resolve relative tokens against the workspace (the bash cwd). const resolved = path.isAbsolute(token) ? path.resolve(token) : path.resolve(resolvedWorkspace, token); if (resolved === resolvedWorkspace || resolved.startsWith(resolvedWorkspace + path.sep)) { return true; } if (SAFE_EXACT_ABS_PATHS.has(token) || SAFE_EXACT_ABS_PATHS.has(resolved)) { return true; } return SAFE_ABS_PATH_PREFIXES.some((prefix) => resolved.startsWith(prefix) || token.startsWith(prefix) ); } export function checkBashPathScope(command: string, workspacePath: string): void { // Defense-in-depth path confinement for the non-bwrap (hardened) bash path. // Tokenize on shell operators/whitespace, strip quotes and VAR=/--flag= // prefixes, then confine any token that looks like a filesystem path. // A token "looks like a path" if it is absolute or contains a "/" or is "..". // Resolution is workspace-relative, so an in-workspace token (incl. regex // patterns / URLs that happen to contain "/") resolves inside the workspace // and passes; only tokens that escape the workspace (and aren't a safe // system prefix) are rejected. This catches descend-then-escape // (output/../../x) and flag/var-prefixed traversal (--file=../../x). const rawWords = command.split(new RegExp('[\\s|;&<>()\\x60]+')); for (const raw of rawWords) { if (!raw) continue; // Strip surrounding quotes. let token = raw.replace(/^['"]+/, '').replace(/['"]+$/, ''); // Strip a leading VAR= or --flag= / -f= prefix (keep the value). token = token.replace(/^--?[A-Za-z0-9][A-Za-z0-9-]*=/, ''); token = token.replace(/^[A-Za-z_][A-Za-z0-9_]*=/, ''); if (!token) continue; const looksLikePath = token.startsWith('/') || token.includes('/') || token === '..'; if (!looksLikePath) continue; if (!isAbsPathAllowed(token, workspacePath)) { throw new Error( `パス "${token}" は workspace 外を参照しています。workspace 内の相対パスを使用してください ` + `(許可: ${workspacePath} 配下, /tmp, /usr/bin 等)` ); } } } export function checkAllowedCommand(command: string, allowedCommands: string[]): void { // まずインストールパターンをチェック checkBlockedInstallPatterns(command); const tokens = extractCommandTokens(command); for (const token of tokens) { // パスが含まれる場合はベース名で比較 (例: /usr/bin/grep → grep) const base = path.basename(token); if (!allowedCommands.includes(base)) { throw new Error(`Command not allowed: "${base}" is not in the allowed commands list`); } } } // --- ツール定義 --- const READ_DEF: ToolDef = { type: 'function', function: { name: 'Read', description: 'テキストや Excel/Word/PDF/PowerPoint/Outlook メール(.msg) を拡張子で自動判定して読み込む。テキストは offset/limit(行)・byte_offset/byte_length(バイト)、PDF と PowerPoint はページ/スライド送りに page_range、Excel は sheet/range、.msg は本文の行に offset/limit で範囲指定する(範囲指定は形式ごとに専用。合わない組み合わせはエラーになり本文は返らない)。ファイルの行数/バイト数の外を指す offset・byte_offset、実在しないページ/スライドを指す page_range もエラーになる(本文は返らない)。エラー文にそのファイルの実際の行数・バイト数・ページ数・スライド枚数が入るので、続きが必要ならその範囲内で指定し直す。大きすぎる場合は自動で切り詰める。詳細は ReadToolDoc({ name: "Read" })。', parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' }, offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)。行数以上を指定するとエラー。テキストと .msg 本文のみ有効(PDF/Office に渡すとエラー)' }, limit: { type: 'number', description: '読む最大行数 (省略時は全行)。テキストと .msg 本文のみ有効(PDF/Office に渡すとエラー)' }, byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量テキスト向け。ファイルサイズ以上を指定するとエラー。offset/limit と排他。テキスト読みのみ有効(PDF/Office/.msg に渡すとエラー)' }, byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用。テキスト読みのみ有効(PDF/Office/.msg に渡すとエラー)' }, page_range: { type: 'string', description: 'PDF のページ範囲 / PowerPoint のスライド範囲(例 "5-10" や "3")。PDF・PowerPoint を途中から/一部だけ読むときはこれを使う(offset/limit ではなく)。実在しないページ/スライドを指すとエラー。PDF・PowerPoint 専用(テキストファイルに渡すとエラー)' }, }, required: ['file_path'], }, }, }; const WRITE_DEF: ToolDef = { type: 'function', function: { name: 'Write', description: 'ファイルを書き込む。親ディレクトリが存在しない場合は自動作成する。', parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' }, content: { type: 'string', description: '書き込む内容' }, }, required: ['file_path', 'content'], }, }, }; const EDIT_DEF: ToolDef = { type: 'function', function: { name: 'Edit', description: 'ファイル内の old_string を new_string に置換する (最初の一致のみ)。', parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' }, old_string: { type: 'string', description: '置換前の文字列 (完全一致)' }, new_string: { type: 'string', description: '置換後の文字列' }, }, required: ['file_path', 'old_string', 'new_string'], }, }, }; const BASH_DEF: ToolDef = { type: 'function', function: { name: 'Bash', description: 'シェルコマンドを実行する。cwd は workspace ルート。workspace 外への絶対パス (/root/, /etc/ 等) は拒否される (例外: /tmp, /usr/bin 等)。パッケージインストール(apt/pip/npm 等)は禁止。詳細は ReadToolDoc({ name: "Bash" })。', parameters: { type: 'object', properties: { command: { type: 'string', description: '実行するシェルコマンド' }, timeout: { type: 'number', description: 'タイムアウト秒数 (デフォルト: 30, 最大: 300)' }, }, required: ['command'], }, }, }; const GLOB_DEF: ToolDef = { type: 'function', function: { name: 'Glob', description: 'ファイルパターン (glob) で workspace 内を検索する。', parameters: { type: 'object', properties: { pattern: { type: 'string', description: 'glob パターン (例: **/*.ts)' }, path: { type: 'string', description: '検索の起点ディレクトリ (省略時は workspace ルート)' }, }, required: ['pattern'], }, }, }; const GREP_DEF: ToolDef = { type: 'function', function: { name: 'Grep', description: 'workspace 内のファイルを再帰的にテキスト検索する。', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '正規表現パターン' }, path: { type: 'string', description: '検索の起点ディレクトリ (省略時は workspace ルート)' }, glob: { type: 'string', description: 'ファイル名フィルタ (例: *.ts)' }, }, required: ['pattern'], }, }, }; export const ALL_TOOL_DEFS: Record = { Read: READ_DEF, Write: WRITE_DEF, Edit: EDIT_DEF, Bash: BASH_DEF, Glob: GLOB_DEF, Grep: GREP_DEF, }; // ツール定義一覧を返す (allowedTools と editAllowed に応じてフィルタリング) export function getToolDefs(allowedTools: string[], editAllowed: boolean): ToolDef[] { return allowedTools .filter((name) => { if (!editAllowed && (name === 'Write' || name === 'Edit')) return false; return name in ALL_TOOL_DEFS; }) .map((name) => ALL_TOOL_DEFS[name]!); } // --- ツール実行実装 --- /** * テキストの実際の行数。末尾の改行は行を作らない("a\nb\n" は 2 行)。 * 空ファイルは 0 行。split('\n').length は末尾の空要素を数えてしまうため、 * offset の範囲検証にはこちらを使う("a\nb\n" に offset=2 は範囲外)。 */ export function countTextLines(content: string): number { if (content === '') return 0; const parts = content.split('\n'); return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length; } async function executRead(input: Record, ctx: ToolContext): Promise { const filePath = input['file_path'] as string; const offset = typeof input['offset'] === 'number' ? input['offset'] : 0; const limit = typeof input['limit'] === 'number' ? input['limit'] : undefined; const byteOffset = typeof input['byte_offset'] === 'number' ? input['byte_offset'] : undefined; const byteLength = typeof input['byte_length'] === 'number' ? input['byte_length'] : undefined; let resolved: string; try { resolved = resolveAndGuard(ctx.workspacePath, filePath); } catch (e) { return { output: (e as Error).message, isError: true }; } const ext = path.extname(resolved).toLowerCase(); if (BLOCKED_BINARY_EXTENSIONS.has(ext)) { return { output: `Read cannot open binary image files like "${filePath}". Use ReadImage to view the image.`, isError: true, }; } // ドキュメント (Office/PDF) は拡張子から抽出ハンドラへディスパッチする。office.ts は // core.ts を静的 import するため、循環を避けて動的 import する。 const documentTool = DOCUMENT_EXTENSION_TOOL[ext]; if (documentTool) { const rejected = rejectTextRangeParams(ext, input); if (rejected) return rejected; const office = await import('./office.js'); const result = await office.executeTool(documentTool, { ...input, path: filePath }, ctx); return result ?? { output: `Read: 内部ディスパッチ (${ext}) が結果を返しませんでした`, isError: true }; } if (MSG_EXTENSIONS.has(ext)) { const rejected = rejectTextRangeParams(ext, input); if (rejected) return rejected; const office = await import('./office.js'); const result = await office.executeTool('ReadMsg', input, ctx); return result ?? { output: `Read: 内部ディスパッチ (.msg) が結果を返しませんでした`, isError: true }; } if (BLOCKED_OPAQUE_BINARY_EXTENSIONS.has(ext)) { return { output: `Read cannot open binary files like "${filePath}". バイナリデータを読み込むと LLM コンテキストが壊れるので拒否しました。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`, isError: true, }; } // ここから先はテキスト読み。ドキュメント専用のパラメータが来ていたら、 // 読む前に弾く(#1041)。全文を返すと「指定した範囲が取れた」と誤読される。 { const message = textRangeRejection(ext, input); if (message) return { output: message, isError: true }; } // 1. ファイルサイズ取得 + ディレクトリ拒否 let totalBytes = 0; try { const st = fs.statSync(resolved); // ディレクトリは Read 不可。readFileSync(dir) は EISDIR を投げる。これを // ここで弾かないと、後続の競合検知 recordVersion()(persistent ワークスペース // で ON)が dir を readFileSync して EISDIR を投げ、ジョブごとクラッシュする。 // ツールエラー (isError) として返せばエージェントは回復できる。 if (st.isDirectory()) { return { output: `Read: "${filePath}" はディレクトリです。ファイルパスを指定してください(一覧は Glob か Bash の \`ls\` を使用)。`, isError: true, }; } totalBytes = st.size; } catch (e) { // A missing file under `skills/` almost always means the agent tried to // Read a skill directly instead of going through ReadSkill. Return an // actionable hint instead of a bare ENOENT so the agent can recover. if ((e as NodeJS.ErrnoException).code === 'ENOENT') { const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved); const hint = skillReadEnoentHint(ctx, relPath); if (hint) return { output: hint, isError: true }; } return { output: `Read error: ${(e as Error).message}`, isError: true }; } // 拡張子フォールバック: 先頭 8KB を sniff。真のバイナリ(magic byte / NUL / // 高制御文字比率)は拒否するが、UTF-8 でデコードできないだけの非 UTF-8 テキスト // (Shift_JIS/CP932・EUC-JP 等)は検出して UTF-8 に変換して読む。拡張子の // ホワイト/ブラックリストで漏れた未知バイナリを止めつつ、日本語 txt/CSV を救う。 let readEncoding = 'utf-8'; if (totalBytes > 0) { const head = readHeadBytes(resolved, SNIFF_HEAD_BYTES); const verdict = head ? detectTextEncoding(head) : { binary: false as const, encoding: 'utf-8' }; if (verdict.binary) { return { output: `Read cannot open "${filePath}" (binary content detected in head ${SNIFF_HEAD_BYTES} bytes). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`, isError: true, }; } readEncoding = verdict.encoding; } const budgetTokens = getToolOutputBudgetTokens(ctx); const budgetBytes = estimateCharsForTokenBudget(budgetTokens); // バイト範囲指定は UTF-8 のみ対応。非 UTF-8 のバイトオフセットは元エンコの // バイト境界を指し、多バイト文字が途中で割れる。黙って先頭を返すと契約違反に // なるので、明示的にエラーで断り、行範囲(offset/limit)へ誘導する。 // エンコ判定は先頭 8KB だけでなく全文で行う(先頭 ASCII・末尾 Shift_JIS を // 取りこぼさない)。ただしバイト範囲の主用途は巨大 minified/ログ(UTF-8/ASCII) // なので、OOM を避けて一定サイズ超のファイルは先頭判定にフォールバックする。 if (byteOffset !== undefined || byteLength !== undefined) { let rangeEncoding = readEncoding; if (totalBytes > 0 && totalBytes <= FULL_DETECT_MAX_BYTES) { try { const verdict = detectTextEncoding(fs.readFileSync(resolved)); if (!verdict.binary) rangeEncoding = verdict.encoding; } catch { /* keep the head-based estimate */ } } if (rangeEncoding !== 'utf-8') { return { output: `Read: "${filePath}" は ${rangeEncoding} テキストです。バイト範囲指定 (byte_offset/byte_length) は UTF-8 ファイルのみ対応しています。` + `offset/limit で行範囲を指定してください(全文は UTF-8 に変換して読み込めます)。`, isError: true, }; } // ファイルサイズの外を指すバイト位置は失敗として返す (#1170)。以前は検出して // おきながら isError: false で返していたため、エージェントは「読めたが中身が // 空だった」と受け取り、続きがあるものとして進んでいた。 // 復旧手順は本文に書かない。ツール定義の description 側に置く (#1165)。 const start = Math.max(0, byteOffset ?? 0); if (start >= totalBytes) { return { output: `Read: byte_offset ${start} は "${filePath}" の範囲外です(このファイルは ${totalBytes} バイト)`, isError: true, }; } } // テキスト行読みの前処理: 本文の読み込み・バイナリ判定・offset の範囲検証を // ここで済ませる。本文を返さずエラーで終わる分岐は、下の recordVersion より // 前に置く必要がある(見ていない版を「見た」ことにしないため)。 let fileText = ''; let fileEncoding = 'utf-8'; if (byteOffset === undefined && byteLength === undefined) { // 全文でエンコを確定する(先頭 8KB が ASCII でも末尾が Shift_JIS のことがある)。 let decodedFile: ReturnType; try { decodedFile = decodeFileText(fs.readFileSync(resolved)); } catch (e) { return { output: `Read error: ${(e as Error).message}`, isError: true }; } if (decodedFile.binary) { return { output: `Read cannot open "${filePath}" (binary content detected). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`, isError: true, }; } fileEncoding = decodedFile.encoding; fileText = stripLeadingBom(decodedFile.text); // 行数の外を指す offset は失敗として返す (#1170)。切り出しは何も検証して // おらず、行数以上の offset でも空文字列を成功として返していた。 // offset 未指定 (= 先頭から) はこれまでどおり成功のまま扱う。 if (typeof input['offset'] === 'number') { const lineCount = countTextLines(fileText); if (offset >= lineCount) { return { output: `Read: offset ${offset} は "${filePath}" の範囲外です(このファイルは ${lineCount} 行、offset は 0 起点)`, isError: true, }; } } } // 競合検知 (persistent workspace): Read はファイルを変更しないので、ここで // 現在のディスク版を「エージェントが見た版」として台帳に記録する。後続 Write は // この版を基準に外部変更を検出する。OFF (既定) のときは完全に no-op。 // 内容を返さずエラーで終わる分岐(バイナリ拒否・非 UTF-8 の byte 範囲など)の // 後に置くこと。エラー時に記録すると、見ていない版を「見た」ことにして後続 // Write の競合検知をすり抜けさせてしまう。 if (ctx.conflictDetection) { const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved); recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx)); } // 2. バイト範囲指定が優先(改行のないファイル向け) if (byteOffset !== undefined || byteLength !== undefined) { const start = Math.max(0, byteOffset ?? 0); // 範囲外 (start >= totalBytes) は上のブロックで弾いている。 // byte_length が未指定または予算より大きければ budget にクリップ const requested = byteLength ?? (totalBytes - start); const effectiveLength = Math.min(requested, budgetBytes, totalBytes - start); const buf = Buffer.alloc(effectiveLength); try { const fd = fs.openSync(resolved, 'r'); try { fs.readSync(fd, buf, 0, effectiveLength, start); } finally { fs.closeSync(fd); } } catch (e) { return { output: `Read error: ${(e as Error).message}`, isError: true }; } // BOM は先頭にしか現れない。範囲の先頭から読むときだけ剥がす。 const text = start === 0 ? stripLeadingBom(buf.toString('utf-8')) : buf.toString('utf-8'); const capped = effectiveLength < requested; if (capped) { const nextOffset = start + effectiveLength; const notice = `[自動切り詰め] ${filePath} からバイト範囲 [${start}, ${start + requested}) が要求されましたが、` + `残コンテキスト予算 ${budgetTokens.toLocaleString()} tokens に収まる [${start}, ${nextOffset}) (${effectiveLength.toLocaleString()} bytes) のみ返却しました。\n` + `続きは Read(byte_offset=${nextOffset}, byte_length=...) で指定してください\n` + `--- 以下、要求範囲の先頭 ${effectiveLength.toLocaleString()} bytes ---\n`; return { output: notice + text, isError: false }; } return { output: text, isError: false }; } // 3. 行単位読み込み(既存挙動)+ 事前切り詰め // 本文の読み込み・バイナリ判定・offset 検証は recordVersion より前で済ませてある。 try { const content = fileText; const lines = content.split('\n'); const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset); const slicedText = sliced.join('\n'); const effectiveBudgetTokens = capBudgetForEncodedPayload(slicedText, budgetTokens); const { text, truncated } = truncateToBudget(slicedText, effectiveBudgetTokens, { sourceLabel: filePath, totalLines: lines.length, totalBytes, baseLine: offset, continuationHint: looksLikeLargeEncodedPayload(slicedText) ? 'base64/data URL を含む大きな内容です。全文を読む代わりに Grep や Read(offset/limit)、Read(byte_offset/byte_length) で必要箇所だけ抽出してください' : undefined, }); if (truncated) { logger.info(`[read] truncated file=${filePath} original_lines=${lines.length} original_bytes=${totalBytes} budget_tokens=${effectiveBudgetTokens}`); } // UTF-8(BOM 付き含む)は変換なしなので注記しない。非 UTF-8 のみ注記する。 const encNote = fileEncoding === 'utf-8' || fileEncoding === UTF8_BOM_LABEL ? '' : `[検出エンコーディング: ${fileEncoding} → UTF-8 に変換して表示しています。ファイルはディスク上では ${fileEncoding} のままです]\n`; return { output: encNote + text, isError: false }; } catch (e) { return { output: `Read error: ${(e as Error).message}`, isError: true }; } } /** * Best-effort provenance recorder. Wrapped so a ledger failure (bad DB, unbound * recorder, etc.) can NEVER break tool execution — mirrors recordToolRequest. */ function recordProvenanceSafe(ctx: ToolContext, ev: FileProvenanceEvent): void { // issue #989 Phase 1: この関数は Write / Edit / Bash 生成物の「エージェントが // ファイルに触れた」単一関所なので、git 履歴の記録をここへ相乗りさせる。 // provenance コールバックの配線有無に依存しないよう early-return より前に置く。 // 履歴 repo は runtimeDir 配下(= エージェントが書けない場所)に作る。 // recordFileVersion は非同期キューに積むだけで、失敗は内部で握る。 recordFileVersion({ workspacePath: ctx.workspacePath, runtimeDir: ctx.runtimeDir, relPath: ev.relPath, source: ev.sourceKind ?? 'unknown', jobId: ctx.jobId, }); if (!ctx.recordFileProvenance) return; try { ctx.recordFileProvenance(ev); } catch (e) { logger.debug(`[provenance] record skipped: ${(e as Error).message}`); } } /** * Snapshot mtime (ms) of every file under output/. Kept intentionally small: * Bash provenance only covers the output/ subtree (the deliverables dir) so a * command that rewrites a large input/ or logs/ tree does not trigger a scan. */ // Provenance snapshots run automatically on every Bash call (unlike Glob's // on-demand walk), so they must stay cheap. Skip heavy generated dirs and bail // past a file cap (returns null → caller skips Bash provenance for this call) // rather than paying an O(files) synchronous stat storm on a large output/ tree // (e.g. a git clone or npm install). Adversarial-review D1. const PROV_SNAPSHOT_IGNORE = new Set(['.git', 'node_modules', '.venv', '__pycache__', '.cache']); const PROV_SNAPSHOT_FILE_CAP = 2000; function snapshotOutputMtimes(workspacePath: string): Map | null { const map = new Map(); const stack = [path.join(workspacePath, 'output')]; while (stack.length > 0) { const dir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (!PROV_SNAPSHOT_IGNORE.has(entry.name)) stack.push(full); continue; } if (map.size >= PROV_SNAPSHOT_FILE_CAP) return null; // too large → skip provenance try { map.set(path.relative(workspacePath, full), fs.statSync(full).mtimeMs); } catch { /* file vanished between walk and stat */ } } } return map; } /** After a Bash command, record new output/ files (bash_generated) + changed ones (modify). */ function recordBashOutputProvenance(ctx: ToolContext, before: Map | null): void { const after = snapshotOutputMtimes(ctx.workspacePath); if (!before || !after) return; // tree too large / unavailable → best-effort skip for (const [relPath, mtime] of after) { const prev = before.get(relPath); if (prev === undefined) { recordProvenanceSafe(ctx, { relPath, event: 'create', sourceKind: 'bash_generated' }); } else if (prev !== mtime) { recordProvenanceSafe(ctx, { relPath, event: 'modify', sourceKind: 'bash_generated' }); } } } function executeWrite(input: Record, ctx: ToolContext): ToolResult { if (!ctx.editAllowed) { return { output: 'Write is not allowed: edit flag is false', isError: true }; } const filePath = input['file_path'] as string; const content = input['content'] as string; let resolved: string; try { // The workspace itself is the boundary (resolveAndGuard rejects path // traversal outside it). Both creating and overwriting are allowed // anywhere inside the workspace — previously new files were restricted to // output/, which was inconsistent with overwrite (allowed anywhere) and // produced confusing failures for legitimate writes to other subdirs. resolved = resolveAndGuard(ctx.workspacePath, filePath); assertWritable(ctx.workspacePath, resolved); } catch (e) { return { output: (e as Error).message, isError: true }; } // Provenance: a new file → this task is the creator (agent_output); an // existing file → this task is only the last modifier. Captured before write. const existedBefore = fs.existsSync(resolved); // 既存の非 UTF-8 ファイルはエンコを保持して書き(Read/Edit と揃える)、表現 // 不能文字は破損保存せず拒否する。競合検知パスは対象ファイルを退避(move)して // しまうため、判定はこの時点(退避前)に済ませ、両パスで同じバイト列を書く。 const encoded = encodeWriteContent(resolved, content, existedBefore); if ('error' in encoded) { return { output: `Write error: ${encoded.error}`, isError: true }; } // 競合検知 (persistent workspace): 対象がエージェントの見た版から外部変更 // されている / そもそも読まれていない場合は、古いファイルを old/ へ退避してから上書きする。 // OFF (既定) のときはこのブロックを完全にスキップし、従来挙動を保つ。 if (ctx.conflictDetection) { const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved); const resolution = resolveWriteTarget(ctx.workspacePath, relPath, content, conflictDirOf(ctx)); let writeResolved = resolved; if (resolution.conflict) { try { // 書込先を再ガード (念のためルート内であることを保証)。 writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath); } catch (e) { return { output: (e as Error).message, isError: true }; } } try { fs.mkdirSync(path.dirname(writeResolved), { recursive: true }); fs.writeFileSync(writeResolved, encoded.bytes); // 書き込んだパスの版を台帳に記録 (このエージェントの最新版)。 recordVersion(ctx.workspacePath, resolution.targetRelPath, conflictDirOf(ctx)); // Provenance: a conflict wrote a fresh (renamed) file → create; otherwise // it followed the plain existed-before semantics. recordProvenanceSafe(ctx, { relPath: resolution.targetRelPath, event: resolution.conflict || !existedBefore ? 'create' : 'modify', sourceKind: 'agent_output', }); if (resolution.conflict) { const archived = resolution.archivedRelPath ?? 'old/'; return { output: `⚠️ 競合を検知したため古いファイルを「${archived}」へ退避し、` + `新しい内容を「${resolution.targetRelPath}」として保存しました。` + ` Written ${content.length} bytes to ${resolution.targetRelPath}` + // 図の確認を促す一言は、退避先へ書いた場合も同じように要る。 // 付け忘れると、この経路だけ図が未検査のまま完了扱いになる。 (mermaidCheckHint(resolution.targetRelPath, content) ?? ''), isError: false, }; } return { output: `Written ${content.length} bytes to ${filePath}` + (mermaidCheckHint(filePath, content) ?? ''), isError: false, }; } catch (e) { return { output: `Write error: ${(e as Error).message}`, isError: true }; } } try { fs.mkdirSync(path.dirname(resolved), { recursive: true }); fs.writeFileSync(resolved, encoded.bytes); recordProvenanceSafe(ctx, { relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved), event: existedBefore ? 'modify' : 'create', sourceKind: 'agent_output', }); return { output: `Written ${content.length} bytes to ${filePath}` + (mermaidCheckHint(filePath, content) ?? ''), isError: false, }; } catch (e) { return { output: `Write error: ${(e as Error).message}`, isError: true }; } } function executeEdit(input: Record, ctx: ToolContext): ToolResult { if (!ctx.editAllowed) { return { output: 'Edit is not allowed: edit flag is false', isError: true }; } const filePath = input['file_path'] as string; const oldString = input['old_string'] as string; const newString = input['new_string'] as string; let resolved: string; try { resolved = resolveAndGuard(ctx.workspacePath, filePath); assertWritable(ctx.workspacePath, resolved); } catch (e) { return { output: (e as Error).message, isError: true }; } try { // Read が非 UTF-8 テキストを UTF-8 に変換して見せるので、Edit も同じ変換を // かけて old_string を照合しないと一致しない。書き戻しは元エンコを保持する // (Shift_JIS のファイルは Shift_JIS のまま保存し、外部ツールの前提を壊さない)。 // エンコ判定は全文で行う(先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ)。 const raw = fs.readFileSync(resolved); const decoded = decodeFileText(raw); const editEncoding = decoded.binary ? 'utf-8' : decoded.encoding; const original = decoded.binary ? raw.toString('utf-8') : decoded.text; const idx = original.indexOf(oldString); if (idx === -1) { return { output: `Edit error: old_string not found in ${filePath}`, isError: true }; } const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length); // 元エンコで表現できない文字(Shift_JIS に絵文字等)が入ると iconv は '?' に // 置換して例外を投げない。往復一致を確認し、失われるなら保存せずエラーにする。 const outBytes = encodeFileText(updated, editEncoding); if (outBytes === null) { return { output: `Edit error: 置換後の内容に ${editEncoding} で表現できない文字(絵文字など)が含まれるため、破損を避けて保存を中止しました。ファイルの文字コードは ${editEncoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`, isError: true, }; } fs.writeFileSync(resolved, outBytes); // 競合検知: Edit は in-place 書き換えなので、書き込んだ版を台帳に記録するだけ // (Edit は old_string 一致を要求するので、そもそも盲目上書きの問題は起きない)。 if (ctx.conflictDetection) { const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved); recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx)); } // Provenance: Edit only ever modifies an existing file; the creator is kept. recordProvenanceSafe(ctx, { relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved), event: 'modify', sourceKind: 'agent_edit', }); // Same reminder as Write, counted over the WHOLE file: the replacement text // alone would say "1 diagram" for a file that has three. return { output: `Edited ${filePath}: replaced ${oldString.length} chars` + (mermaidCheckHint(filePath, updated) ?? ''), isError: false, }; } catch (e) { return { output: `Edit error: ${(e as Error).message}`, isError: true }; } } /** * 実行ログ root を解決する(計画5)。ctx.runtimeDir があればそれ、 * 無ければ従来の `workspacePath/logs`(後方互換)。runtime_dir は files jail * の外になりうるため resolveAndGuard を経由させず直接書く。 */ export function runtimeLogsDir(ctx: ToolContext): string { return ctx.runtimeDir ?? path.join(ctx.workspacePath, 'logs'); } /** * 競合台帳 dir を解決する(計画5)。ctx.conflictDir があればそれ、無ければ * 後方互換で `workspacePath/.conflict`(files ツリーの外)。 */ function conflictDirOf(ctx: ToolContext): string { return ctx.conflictDir ?? path.join(ctx.workspacePath, '.conflict'); } /** * What actually happened for this command (#1215) — recorded per command so the * record answers "was the network really blocked?" instead of forcing a reader * to guess from a config value that may have changed since. */ interface BashExecRecord { mode: BashExecMode; networkBlocked: boolean; } function logBashHistory( logsDir: string, command: string, isError: boolean, durationMs: number, exec: BashExecRecord, extra?: { blocked?: boolean; outputBytes?: number }, ): void { try { fs.mkdirSync(logsDir, { recursive: true }); const entry = { timestamp: new Date().toISOString(), command, isError, durationMs, mode: exec.mode, networkBlocked: exec.networkBlocked, ...extra, }; fs.appendFileSync( path.join(logsDir, 'bash-history.jsonl'), JSON.stringify(entry) + '\n', 'utf-8', ); } catch { // best-effort logging } } type BashMode = BashExecMode; async function resolveBashMode(ctx: ToolContext): Promise { // Same rule (and same probe) the settings banner and the audit row read. return resolveBashModeFrom(ctx.bashSandbox ?? 'auto', await isBwrapAvailable()); } async function executeBash(input: Record, ctx: ToolContext): Promise { const command = input['command'] as string; let timeoutSec = typeof input['timeout'] === 'number' ? input['timeout'] : 30; timeoutSec = Math.min(timeoutSec, 300); const startedAt = Date.now(); const logsDir = runtimeLogsDir(ctx); // Decide the mode up front so every history line — including the ones written // for rejected commands — records what this run would actually have used. const mode = await resolveBashMode(ctx); const exec: BashExecRecord = { mode, networkBlocked: mode === 'sandboxed' && ctx.bashAllowNetwork !== true, }; const budgetTokens = getToolOutputBudgetTokens(ctx); const capOutput = (raw: string, label: string): string => { // cat 等で BOM 付きファイルを出力したときの先頭 U+FEFF を剥がす(stderr は無害)。 raw = stripLeadingBom(raw); const { text, truncated } = truncateToBudget(raw, budgetTokens, { sourceLabel: `Bash ${label}`, continuationHint: 'コマンド出力が大きすぎます。cat/less で全文を取得する代わりに、head/tail/grep/awk/sed で必要箇所だけに絞ってください', }); if (truncated) { logger.info(`[bash] truncated ${label} original_chars=${raw.length} budget_tokens=${budgetTokens}`); } return text; }; // Install commands are never supported (packages are pre-baked). Reject in // EVERY mode — including bashUnrestricted, which previously skipped this. try { checkBlockedInstallPatterns(command); } catch (e) { logBashHistory(logsDir, command, true, Date.now() - startedAt, exec, { blocked: true }); return { output: (e as Error).message, isError: true }; } // Provenance (best-effort, SMALL): snapshot the output/ file set + mtimes so // new/changed deliverables can be attributed to this task after the command // runs. Limitation: only output/ is diffed (not input/ or logs/), so a // command writing elsewhere is not tracked — a deliberate trade-off to keep // the scan bounded. const provBefore = ctx.recordFileProvenance ? snapshotOutputMtimes(ctx.workspacePath) : null; const applyWhitelist = !ctx.bashUnrestricted; // Per-space Python package overlay (admin-installed wheels). Bound read-only + // put on PYTHONPATH so python3 can import them; the packages were fetched // out-of-band, not here, so the overlay works even when the sandbox keeps // --unshare-net (network is per-workspace since #1015, so it may be open). Exposed // ONLY in the bwrap-sandboxed path — the hardened (no-bwrap) path has neither // the read-only bind nor network isolation, so surfacing an overlay there // would be fail-open (codex P1). Feature therefore requires bwrap. const pyOverlay = mode === 'sandboxed' ? resolveSandboxOverlay(ctx.pythonPackagesDir) : null; if (mode === 'sandboxed') { const skillBinds = ctx.skillCatalog?.getSkillBinds?.(ctx.userId ?? 'local') ?? []; const binds = pyOverlay ? [...skillBinds, pyOverlay.bind] : skillBinds; if (applyWhitelist) { try { checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS); } catch (e) { logBashHistory(logsDir, command, true, Date.now() - startedAt, exec, { blocked: true }); return { output: (e as Error).message, isError: true }; } } const result: SandboxedBashResult = await executeSandboxedBash( command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, binds, ctx.bashAllowNetwork === true, pyOverlay?.sandboxPythonPath, ); const out = result.isError ? result.output : capOutput(result.output, 'stdout'); logBashHistory(logsDir, command, result.isError, Date.now() - startedAt, exec, { outputBytes: Buffer.byteLength(out, 'utf-8'), }); if (provBefore) recordBashOutputProvenance(ctx, provBefore); return { output: out, isError: result.isError }; } // mode === 'hardened': no bwrap. Whitelist (unless unrestricted) + path scope + env scrub. try { if (applyWhitelist) checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS); checkBashPathScope(command, ctx.workspacePath); } catch (e) { logBashHistory(logsDir, command, true, Date.now() - startedAt, exec, { blocked: true }); return { output: (e as Error).message, isError: true }; } return await new Promise((resolve) => { if (ctx.abortSignal?.aborted) { logBashHistory(logsDir, command, true, Date.now() - startedAt, exec); resolve({ output: 'Cancelled before bash launch', isError: true }); return; } child_process.exec( command, { cwd: ctx.workspacePath, timeout: timeoutSec * 1000, encoding: 'utf-8', maxBuffer: BASH_MAX_BUFFER_BYTES, signal: ctx.abortSignal, env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets (no overlay: hardened has no isolation) }, (error, stdout, stderr) => { if (!error) { const out = capOutput(stdout, 'stdout'); logBashHistory(logsDir, command, false, Date.now() - startedAt, exec, { outputBytes: Buffer.byteLength(out, 'utf-8'), }); if (provBefore) recordBashOutputProvenance(ctx, provBefore); resolve({ output: out, isError: false }); return; } const msg = error.message ?? String(error); const execError = error as Error & { signal?: NodeJS.Signals | null; killed?: boolean; code?: string }; const cappedStdout = stdout ? capOutput(stdout, 'stdout') : ''; const cappedStderr = stderr ? capOutput(stderr, 'stderr') : ''; const details: string[] = [cappedStdout, cappedStderr]; if (execError.code === 'ABORT_ERR' || ctx.abortSignal?.aborted) { details.push('Cancelled by user request'); } else if (execError.killed) { details.push(`Command timed out after ${timeoutSec}s`); } if (execError.signal) { details.push(`Signal: ${execError.signal}`); } const output = [...details, msg].filter(Boolean).join('\n'); logBashHistory(logsDir, command, true, Date.now() - startedAt, exec, { outputBytes: Buffer.byteLength(output, 'utf-8'), }); // A failed command can still have written files before erroring. if (provBefore) recordBashOutputProvenance(ctx, provBefore); resolve({ output, isError: true }); }, ); }); } // 簡易 glob: minimatch 相当の実装 (** と * のみサポート) function expandBraces(pattern: string): string[] { const match = pattern.match(/^(.*?)\{([^}]+)\}(.*)$/); if (!match) return [pattern]; const [, prefix, alternatives, suffix] = match; const results: string[] = []; for (const alt of alternatives.split(',')) { results.push(...expandBraces(`${prefix}${alt}${suffix}`)); } return results; } function globMatch(pattern: string, filePath: string): boolean { // ブレース展開 {a,b,c} をサポート const expanded = expandBraces(pattern); return expanded.some((pat) => { // **/ → 0個以上のディレクトリ(ルート直下にもマッチ) // ** → 任意のパスセグメント列 // * → 単一セグメント内の任意文字列 const regexStr = pat .replace(/\*\*\//g, '%%GLOBSTAR_SLASH%%') .replace(/\*\*/g, '%%GLOBSTAR%%') .split('%%GLOBSTAR_SLASH%%') .map((segment) => segment .split('%%GLOBSTAR%%') .map((part) => part .split('*') .map((s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')) .join('[^/]*'), ) .join('.*'), ) .join('(.+/)?'); const regex = new RegExp(`^${regexStr}$`); return regex.test(filePath); }); } function collectFiles(dir: string, base: string): string[] { const results: string[] = []; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } for (const entry of entries) { const full = path.join(dir, entry.name); const rel = path.relative(base, full); if (entry.isDirectory()) { results.push(...collectFiles(full, base)); } else { results.push(rel); } } return results; } function executeGlob(input: Record, ctx: ToolContext): ToolResult { const pattern = input['pattern'] as string; const searchPath = input['path'] as string | undefined; let baseDir: string; try { baseDir = searchPath ? resolveAndGuard(ctx.workspacePath, searchPath) : path.resolve(ctx.workspacePath); } catch (e) { return { output: (e as Error).message, isError: true }; } const allFiles = collectFiles(baseDir, baseDir); const matched = allFiles.filter((f) => globMatch(pattern, f)); if (matched.length === 0) { return { output: '(no files matched)', isError: false }; } return { output: matched.join('\n'), isError: false }; } function executeGrep(input: Record, ctx: ToolContext): ToolResult { const pattern = input['pattern'] as string; const searchPath = input['path'] as string | undefined; const globFilter = input['glob'] as string | undefined; let baseDir: string; try { baseDir = searchPath ? resolveAndGuard(ctx.workspacePath, searchPath) : path.resolve(ctx.workspacePath); } catch (e) { return { output: (e as Error).message, isError: true }; } let regex: RegExp; try { regex = new RegExp(pattern); } catch (e) { return { output: `Invalid regex: ${(e as Error).message}`, isError: true }; } const allFiles = collectFiles(baseDir, baseDir); const filteredFiles = globFilter ? allFiles.filter((f) => globMatch(globFilter, path.basename(f))) : allFiles; const results: string[] = []; for (const rel of filteredFiles) { const full = path.join(baseDir, rel); let content: string; try { const raw = fs.readFileSync(full); // 真バイナリ(png 等)はスキップ。無条件に utf-8 デコードすると、パターンが // バイナリ中の ASCII 断片(IHDR/IDAT 等)にマッチしてバイナリ行が結果に混入し、 // LLM コンテキストを壊す。非 UTF-8 テキストは検出したエンコで変換して検索する。 // エンコ判定は全文で行う(先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ)。 const decoded = decodeFileText(raw); if (decoded.binary) continue; content = stripLeadingBom(decoded.text); } catch { continue; } const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i]!)) { results.push(`${rel}:${i + 1}: ${lines[i]}`); } } } if (results.length === 0) { return { output: '(no matches)', isError: false }; } // Read と Bash は予算切り詰めがあるのに Grep だけ無制限だった。緩いパターン // 1回で数十万文字が会話に流れ込み、プロンプトを preflight 上限際まで押し込む // 起爆剤になっていたので、同じトークン予算で切り詰める。 const budgetTokens = getToolOutputBudgetTokens(ctx); const raw = results.join('\n'); const { text, truncated } = truncateToBudget(raw, budgetTokens, { sourceLabel: `Grep 結果 (pattern: ${pattern})`, totalLines: results.length, continuationHint: 'マッチが多すぎます。pattern をより具体的にする、glob でファイルを絞る、path でディレクトリを絞る、のいずれかで検索範囲を狭めてください', }); if (truncated) { logger.info(`[grep] truncated matches=${results.length} original_chars=${raw.length} budget_tokens=${budgetTokens}`); } return { output: text, isError: false }; } // コアツールを実行 (Read, Write, Edit, Bash, Glob, Grep) // null を返す場合はこのモジュールでは処理しないことを意味する export async function executeCoreTools( name: string, input: Record, ctx: ToolContext, ): Promise { logger.debug(`[tools/core] executing ${name}`); switch (name) { case 'Read': return await executRead(input, ctx); case 'Write': return executeWrite(input, ctx); case 'Edit': return executeEdit(input, ctx); case 'Bash': return await executeBash(input, ctx); case 'Glob': return executeGlob(input, ctx); case 'Grep': return executeGrep(input, ctx); default: return null; } }