Files
maestro/src/engine/tools/core.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

1717 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
'.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, unknown>): 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<string, string> = {
'.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, unknown>): 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<string, unknown>): 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 済みの絶対パス)が `<workspace>/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/<name>/ が 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, <<DELIM ... DELIM) を除去
stripped = stripped.replace(/<<-?\s*'([^']+)'\n[\s\S]*?\n\1/g, '');
stripped = stripped.replace(/<<-?\s*"([^"]+)"\n[\s\S]*?\n\1/g, '');
stripped = stripped.replace(/<<-?\s*([A-Za-z_]\w*)\n[\s\S]*?\n\1/g, '');
// $(...) を除去 (ネスト対応)
stripped = stripped.replace(/\$\([^)]*(?:\([^)]*\)[^)]*)*\)/g, '');
// バッククォートを除去
stripped = stripped.replace(/`[^`]*`/g, '');
// プロセス置換を除去
stripped = stripped.replace(/[<>]\([^)]*\)/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: "<pkg>", 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<string, ToolDef> = {
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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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<typeof decodeFileText>;
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<string, number> | null {
const map = new Map<string, number>();
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<string, number> | 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<string, unknown>, 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<string, unknown>, 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<BashMode> {
// 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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
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;
}
}