1717 lines
77 KiB
TypeScript
1717 lines
77 KiB
TypeScript
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;
|
||
}
|
||
}
|