sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+532 -60
View File
@@ -7,7 +7,9 @@ import { logger } from '../../logger.js';
import type { SearchFilterConfig } from '../../config.js';
import type { ContextManager } from '../context-manager.js';
import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.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';
export interface ToolsConfig {
@@ -87,6 +89,13 @@ export interface ToolContext {
* Phase 1 では値を配線するだけで、まだ誰も参照しない(no-op、後方互換)。
*/
spaceId?: string;
/**
* このジョブのスペースが持つ Python パッケージ overlay の `current` シンボリック
* リンクパス(`{root}/{spaceKey}/current`)。admin が入れた wheels を read-only
* bind + PYTHONPATH で sandbox に露出する。存在しない(未インストール)なら
* undefined。worker が spaceId + config から事前解決して渡す。
*/
pythonPackagesDir?: string;
/**
* ローカルタスク ID (string)。BrowseWeb / InteractiveBrowse が
* per-task noVNC session を使うときのキー。subtask 実行のときは親の
@@ -156,6 +165,34 @@ export interface ToolContext {
* aren't bound to a local_task leave this unset.
*/
missionBrief?: MissionBriefIO;
/**
* Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
* Set by piece-runner only when bound to a local task (mirrors missionBrief);
* unset for subtasks / gitea-issue runs so the tools degrade to a friendly
* no-op instead of leaking other tasks' logs.
*/
taskConversation?: TaskConversationIO;
/**
* Workspace-wide task search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
* Bound by the worker to this run's local task + owner (scopes FTS search to the
* same space the owner can see); unset for subtask / gitea-issue runs so the tool
* degrades to a no-context message instead of leaking other users' tasks.
*/
workspaceTaskSearch?: WorkspaceTaskSearchIO;
/**
* File-provenance READ side for GetFileProvenance / ListWorkspaceFiles.
* Bound to this run's workspace_path by the worker (mirrors taskConversation);
* unset for subtask / gitea-issue runs so the tools degrade to a no-op.
*/
fileProvenance?: FileProvenanceIO;
/**
* File-provenance WRITE side: best-effort recorder invoked by Write / Edit /
* Bash when a workspace file is created or modified. Bound by the worker to
* task/job/space + workspace_path; piece + movement injected by piece-runner.
* MUST never throw into tool execution (callers wrap in try/catch). Undefined
* (unit tests / non-persistent runs) => recording is skipped.
*/
recordFileProvenance?: (ev: FileProvenanceEvent) => void;
/** Decrypted Playwright storageState for the browser session profile bound to this job. */
browserSessionState?: object;
/** Profile id for audit/expiry callbacks. */
@@ -165,11 +202,12 @@ export interface ToolContext {
/** Worker-provided callback invoked when BrowseWeb detects auth expiry. */
onAuthExpired?: (profileId: number, reason: string) => void;
/**
* Phase 4: per-movement SSH connection allowlist forwarded from piece YAML
* `allowed_ssh_connections`. UUID list, or `['*']` for "any registered
* connection". undefined = SSH tools (Phase 7) will reject with
* `no_allowed_connections_declared` before any other check (so we don't
* leak the existence of connections the piece can't use).
* SSH connection allowlist for this job — the workspace/space-scoped UUID list
* resolved once per job by the worker (`connectionRepo.listBySpace(spaceId)`)
* and applied to every movement. Per-piece `allowed_ssh_connections` was
* removed in the tool-consolidation work. undefined/empty = SSH tools reject
* (fail-closed) before any other check, so we don't leak the existence of
* connections outside this workspace.
*/
allowedSshConnections?: string[];
/**
@@ -180,7 +218,7 @@ export interface ToolContext {
pieceName?: string;
/**
* Tool-request mechanism: the current movement's name + its effective
* allowed-tool set (shared_tools ∪ allowed_tools ∪ META_TOOLS), so RequestTool
* allowed-tool set (workspace tool policy ∪ per-task grants ∪ META_TOOLS), so RequestTool
* can tell "already available" from "not allowed here", and the passive
* blocked-call capture knows which movement to attribute. Plumbed by
* piece-runner from the prepared movement.
@@ -237,8 +275,8 @@ export interface ToolContext {
* Spaces foundation (plan 3): conflict detection for persistent (shared)
* workspaces. When `true`, Read/Edit/Write record per-file content versions
* in a workspace sidecar ledger, and Write to a file that changed on disk
* since the agent last saw it (or was never read) is diverted to a
* "(競合コピー N)" copy instead of blind-overwriting. Default `undefined`
* since the agent last saw it (or was never read) archives the old file under
* old/ before writing the new content in place. Default `undefined`
* (= OFF): Read/Write/Edit behave byte-identically to legacy. Only the
* worker sets it `true`, for persistent local-task workspaces.
*/
@@ -256,6 +294,9 @@ export interface MissionBriefValue {
done: string;
open: string;
clarifications: string;
user_constraints?: string;
decisions?: string;
current_focus?: string;
}
export interface MissionBriefIO {
@@ -263,6 +304,125 @@ export interface MissionBriefIO {
update(patch: Partial<MissionBriefValue>): MissionBriefValue | null;
}
/** Minimal comment shape the conversation tools need (subset of LocalTaskComment). */
export interface TaskConversationComment {
id: number;
author: string;
kind: string;
body: string;
createdAt: string;
}
/**
* Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
* Bound to a single local task (comments) + this run's transcript path so the
* tools can never reach another task's logs. Constructed by the worker layer
* (which owns the repo) and threaded through ToolContext, mirroring MissionBriefIO.
*/
export interface TaskConversationIO {
listComments(): Promise<TaskConversationComment[]>;
/** Absolute path to this run's transcript.jsonl (may not exist yet). */
transcriptPath?: string;
}
/**
* Workspace-wide task-comment search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
* Scoped by the worker layer to the current task's space + owner; the tool can only
* ever search/read within that scope. `fts5Available` lets the tool degrade gracefully
* (e.g. LIKE-based fallback messaging) when the FTS5 index table doesn't exist.
*/
export interface WorkspaceTaskSearchIO {
fts5Available: boolean;
search(
query: string,
opts: { limit?: number; kind?: string; author?: string; taskId?: number },
): Promise<
Array<{
taskId: number;
taskTitle: string;
commentId: number;
author: string;
kind: string;
createdAt: string;
text: string;
}>
>;
around(
commentId: number,
before: number,
after: number,
): Promise<{
taskId: number;
taskTitle: string;
comments: Array<{
commentId: number;
author: string;
kind: string;
createdAt: string;
body: string;
isCenter: boolean;
}>;
} | null>;
}
/** How a workspace file first came to exist (its creator's kind). */
export type FileProvenanceSourceKind =
| 'user_input'
| 'agent_output'
| 'agent_edit'
| 'bash_generated'
| 'subtask_output'
| 'imported_existing'
| 'unknown';
/** Compact provenance record for one workspace file (no file contents). */
export interface FileProvenanceRecord {
relPath: string;
sourceKind: FileProvenanceSourceKind;
createdByTaskId: number | null;
createdByJobId: string | null;
createdByPiece: string | null;
createdByMovement: string | null;
firstSeenAt: string | null;
lastModifiedByTaskId: number | null;
lastModifiedByJobId: string | null;
lastModifiedAt: string | null;
checksum: string | null;
note: string | null;
}
export interface FileProvenanceListFilters {
/** workspace-relative path prefix, e.g. 'output/'. */
pathPrefix?: string;
sourceKind?: string;
createdByTaskId?: number;
lastModifiedByTaskId?: number;
/** When false, unknown / imported_existing rows are excluded. Default true. */
includeUnknown?: boolean;
limit?: number;
}
/**
* Read side of the provenance ledger, bound by the worker to a SINGLE
* workspace_path so the agent tools (GetFileProvenance / ListWorkspaceFiles)
* can only ever see the current run's workspace — never another workspace's
* (and thus never another user's) file lineage. Unset for subtask / gitea-issue
* runs, in which case the tools degrade to a friendly no-op.
*/
export interface FileProvenanceIO {
get(relPath: string): FileProvenanceRecord | null;
list(filters?: FileProvenanceListFilters): FileProvenanceRecord[];
}
/** Write side: a best-effort recorder bound by the worker to task/job/space. */
export interface FileProvenanceEvent {
relPath: string;
event: 'create' | 'modify';
/** Creator kind — only consulted when this event first inserts the row. */
sourceKind?: FileProvenanceSourceKind;
checksum?: string;
}
export interface ToolResult {
output: string;
isError: boolean;
@@ -279,12 +439,9 @@ const BLOCKED_BINARY_EXTENSIONS = new Set([
'.bmp',
]);
const BLOCKED_DOCUMENT_EXTENSIONS = new Set([
'.pdf',
]);
// Office 系: 拡張子から専用ツールに誘導する
const OFFICE_EXTENSION_TOOL: Record<string, string> = {
// ドキュメント系: 拡張子から office.ts の抽出ハンドラへ内部ディスパッチする。
// Read が唯一の入口で、旧 ReadExcel / ReadDocx / ReadPdf / ReadPPTX は登録ツールとしては廃止。
const DOCUMENT_EXTENSION_TOOL: Record<string, string> = {
'.xlsx': 'ReadExcel',
'.xls': 'ReadExcel',
'.xlsm': 'ReadExcel',
@@ -292,8 +449,45 @@ const OFFICE_EXTENSION_TOOL: Record<string, string> = {
'.doc': 'ReadDocx',
'.pptx': 'ReadPPTX',
'.ppt': 'ReadPPTX',
'.pdf': 'ReadPdf',
};
// Outlook メール: 添付保存の副作用があるため office.ts の ReadMsg ハンドラへ委譲する。
const MSG_EXTENSIONS = new Set(['.msg']);
/**
* offset/limit/byte_offset/byte_length はテキスト読み取り専用。PDF/Office/.msg の
* 抽出ハンドラはこれらを読まないため、指定しても黙って無視され「常に先頭から」に
* なる(PDF を offset でずらして読もうとして毎回 1 ページ目に戻る典型不具合)。
* ドキュメント形式にこれらが渡されたら、無視する代わりに正しい範囲指定パラメータ
* (PDF=page_range、Excel=sheet/range 等)へ誘導する注記を返す。該当が無ければ null。
*/
export function documentRangeHint(ext: string, input: Record<string, unknown>): string | null {
const usedTextRange =
input['offset'] !== undefined ||
input['limit'] !== undefined ||
input['byte_offset'] !== undefined ||
input['byte_length'] !== undefined;
if (!usedTextRange) return null;
const perFormat: Record<string, string> = {
'.pdf':
'PDF はページを指定して読むには page_range を使ってください(例: Read({ file_path, page_range: "5-10" }))。特定の語の周辺だけ読むなら query も使えます。',
'.xlsx': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
'.xls': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
'.xlsm': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
};
const specific = perFormat[ext] ?? 'この形式での範囲指定方法は ReadToolDoc({ name: "Read" }) を参照してください。';
return `ℹ️ offset/limit/byte_offset はテキスト読み取り専用で、この形式(${ext})では無視されました。${specific}`;
}
/** Prepend the range-param steering note (if any) to a document read result. */
function withDocumentRangeHint(ext: string, input: Record<string, unknown>, base: ToolResult): ToolResult {
const hint = documentRangeHint(ext, input);
if (!hint) return base;
return { ...base, output: `${hint}\n\n${base.output}` };
}
// 一般的に Read で開く価値が無いバイナリ拡張子(誘導先なし)
const BLOCKED_OPAQUE_BINARY_EXTENSIONS = new Set([
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
@@ -323,14 +517,47 @@ function readHeadBytes(filePath: string, maxBytes: number): Buffer | null {
}
}
function looksLikeBinaryByContent(filePath: string): boolean {
const head = readHeadBytes(filePath, SNIFF_HEAD_BYTES);
if (!head) return false;
return looksLikeBinaryBytes(head).binary;
/**
* 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)
@@ -506,6 +733,46 @@ function normalizeWorkspaceRelativePath(workspacePath: string, targetPath: strin
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(/\/+$/, '');
}
@@ -753,15 +1020,16 @@ const READ_DEF: ToolDef = {
type: 'function',
function: {
name: 'Read',
description: 'ファイルを読み込む。offset/limit で行範囲、byte_offset/byte_length でバイト範囲を指定可能。大きすぎる場合はコンテキストに収まる範囲に自動切り詰めされる。',
description: 'テキストや Excel/Word/PDF/PowerPoint/Outlook メール(.msg) を拡張子で自動判定して読み込む。テキストは offset/limit(行)・byte_offset/byte_length(バイト)、PDF はページ送りに page_range、Excel は sheet/range で範囲指定する(offset/limit はテキスト専用)。大きすぎる場合は自動で切り詰める。詳細は ReadToolDoc({ name: "Read" })。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' },
offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)' },
limit: { type: 'number', description: '読む最大行数 (省略時は全行)' },
byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量ファイル向け。offset/limit と排他' },
byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用' },
offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)。テキスト読みのみ有効(PDF/Office では無視される)' },
limit: { type: 'number', description: '読む最大行数 (省略時は全行)。テキスト読みのみ有効(PDF/Office では無視される)' },
byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量テキスト向け。offset/limit と排他。テキスト読みのみ有効' },
byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用。テキスト読みのみ有効' },
page_range: { type: 'string', description: 'PDF のページ範囲(例 "5-10" や "3")。PDF を途中から/一部だけ読むときはこれを使う(offset/limit ではなく)' },
},
required: ['file_path'],
},
@@ -871,7 +1139,7 @@ export function getToolDefs(allowedTools: string[], editAllowed: boolean): ToolD
// --- ツール実行実装 ---
function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
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;
@@ -892,18 +1160,20 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
isError: true,
};
}
if (BLOCKED_DOCUMENT_EXTENSIONS.has(ext)) {
return {
output: `Read cannot open binary document files like "${filePath}". Use ReadPdf to extract text from PDF files.`,
isError: true,
};
// ドキュメント (Office/PDF) は拡張子から抽出ハンドラへディスパッチする。office.ts は
// core.ts を静的 import するため、循環を避けて動的 import する。
const documentTool = DOCUMENT_EXTENSION_TOOL[ext];
if (documentTool) {
const office = await import('./office.js');
const result = await office.executeTool(documentTool, { ...input, path: filePath }, ctx);
const base = result ?? { output: `Read: 内部ディスパッチ (${ext}) が結果を返しませんでした`, isError: true };
return withDocumentRangeHint(ext, input, base);
}
const officeTool = OFFICE_EXTENSION_TOOL[ext];
if (officeTool) {
return {
output: `Read cannot open Office files like "${filePath}". Use ${officeTool} to extract text from this file.`,
isError: true,
};
if (MSG_EXTENSIONS.has(ext)) {
const office = await import('./office.js');
const result = await office.executeTool('ReadMsg', input, ctx);
const base = result ?? { output: `Read: 内部ディスパッチ (.msg) が結果を返しませんでした`, isError: true };
return withDocumentRangeHint(ext, input, base);
}
if (BLOCKED_OPAQUE_BINARY_EXTENSIONS.has(ext)) {
return {
@@ -928,29 +1198,74 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
}
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 し binary(magic byte / NUL / 不正 UTF-8 /
// 制御文字比率)と判定したら拒否する。拡張子のホワイト/ブラックリストで漏れた未知のバイナリを止める。
if (totalBytes > 0 && looksLikeBinaryByContent(resolved)) {
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,
};
// 拡張子フォールバック: 先頭 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,
};
}
}
// 競合検知 (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));
}
const budgetTokens = getToolOutputBudgetTokens(ctx);
const budgetBytes = estimateCharsForTokenBudget(budgetTokens);
// 2. バイト範囲指定が優先(改行のないファイル向け)
if (byteOffset !== undefined || byteLength !== undefined) {
const start = Math.max(0, byteOffset ?? 0);
@@ -988,7 +1303,17 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
// 3. 行単位読み込み(既存挙動)+ 事前切り詰め
try {
const content = stripLeadingBom(fs.readFileSync(resolved, 'utf-8'));
const rawFull = fs.readFileSync(resolved);
// 全文でエンコを確定する(先頭 8KB が ASCII でも末尾が Shift_JIS のことがある)。
const decodedFile = decodeFileText(rawFull);
if (decodedFile.binary) {
return {
output: `Read cannot open "${filePath}" (binary content detected). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
isError: true,
};
}
const fileEncoding = decodedFile.encoding;
const content = stripLeadingBom(decodedFile.text);
const lines = content.split('\n');
const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset);
const slicedText = sliced.join('\n');
@@ -1005,12 +1330,85 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
if (truncated) {
logger.info(`[read] truncated file=${filePath} original_lines=${lines.length} original_bytes=${totalBytes} budget_tokens=${effectiveBudgetTokens}`);
}
return { output: text, isError: false };
// 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 {
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 };
@@ -1031,8 +1429,20 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
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);
@@ -1040,7 +1450,7 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
let writeResolved = resolved;
if (resolution.conflict) {
try {
// 競合コピー名を再ガード (念のためルート内であることを保証)。
// 書込先を再ガード (念のためルート内であることを保証)。
writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath);
} catch (e) {
return { output: (e as Error).message, isError: true };
@@ -1048,14 +1458,22 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
}
try {
fs.mkdirSync(path.dirname(writeResolved), { recursive: true });
fs.writeFileSync(writeResolved, content, 'utf-8');
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 name = resolution.targetRelPath.split('/').pop() ?? resolution.targetRelPath;
const archived = resolution.archivedRelPath ?? 'old/';
return {
output:
`⚠️ 競合を検知したため「${name}」として保存しました(元ファイルは保持)。` +
`⚠️ 競合を検知したため古いファイルを「${archived}」へ退避し、` +
`新しい内容を「${resolution.targetRelPath}」として保存しました。` +
` Written ${content.length} bytes to ${resolution.targetRelPath}`,
isError: false,
};
@@ -1068,7 +1486,12 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
try {
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, 'utf-8');
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}`, isError: false };
} catch (e) {
return { output: `Write error: ${(e as Error).message}`, isError: true };
@@ -1092,19 +1515,41 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
}
try {
const original = fs.readFileSync(resolved, 'utf-8');
// 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);
fs.writeFileSync(resolved, updated, 'utf-8');
// 元エンコで表現できない文字(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',
});
return { output: `Edited ${filePath}: replaced ${oldString.length} chars`, isError: false };
} catch (e) {
return { output: `Edit error: ${(e as Error).message}`, isError: true };
@@ -1194,11 +1639,27 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
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 mode = await resolveBashMode(ctx);
const applyWhitelist = !ctx.bashUnrestricted;
// Per-space Python package overlay (admin-installed wheels). Bound read-only +
// put on PYTHONPATH so python3 can import them; the agent's sandbox keeps
// --unshare-net (the packages were fetched out-of-band, not here). 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);
@@ -1208,13 +1669,14 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
}
}
const result: SandboxedBashResult = await executeSandboxedBash(
command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, skillBinds,
ctx.bashAllowNetwork === true,
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, {
outputBytes: Buffer.byteLength(out, 'utf-8'),
});
if (provBefore) recordBashOutputProvenance(ctx, provBefore);
return { output: out, isError: result.isError };
}
@@ -1241,7 +1703,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
encoding: 'utf-8',
maxBuffer: BASH_MAX_BUFFER_BYTES,
signal: ctx.abortSignal,
env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets
env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets (no overlay: hardened has no isolation)
},
(error, stdout, stderr) => {
if (!error) {
@@ -1249,6 +1711,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
logBashHistory(logsDir, command, false, Date.now() - startedAt, {
outputBytes: Buffer.byteLength(out, 'utf-8'),
});
if (provBefore) recordBashOutputProvenance(ctx, provBefore);
resolve({ output: out, isError: false });
return;
}
@@ -1270,6 +1733,8 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
logBashHistory(logsDir, command, true, Date.now() - startedAt, {
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 });
},
);
@@ -1389,7 +1854,14 @@ function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResu
const full = path.join(baseDir, rel);
let content: string;
try {
content = stripLeadingBom(fs.readFileSync(full, 'utf-8'));
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;
}
@@ -1418,7 +1890,7 @@ export async function executeCoreTools(
switch (name) {
case 'Read':
return executRead(input, ctx);
return await executRead(input, ctx);
case 'Write':
return executeWrite(input, ctx);
case 'Edit':