This commit is contained in:
@@ -7,7 +7,7 @@ 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 { looksLikeBinaryBytes, SNIFF_HEAD_BYTES } from './binary-detect.js';
|
||||
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES, stripLeadingBom } from './binary-detect.js';
|
||||
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';
|
||||
|
||||
export interface ToolsConfig {
|
||||
@@ -115,6 +115,17 @@ export interface ToolContext {
|
||||
mcpQuotaState?: { files: number; bytes: number };
|
||||
runIsolatedLlm?: (messages: Message[]) => Promise<string>;
|
||||
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
|
||||
/**
|
||||
* 相 B: 直列・文脈節約型サブエージェント実行。delegate ツールから呼ぶ。
|
||||
* 親ループ内で同期的にサブエージェントを完了まで走らせ最終結果のみ返す。
|
||||
* agent-loop が深さ上限未満のときだけ注入する(未設定 = delegate 使用不可)。
|
||||
*/
|
||||
runDelegate?: (params: { description: string; prompt: string }) => Promise<{ result: string; status: 'success' | 'aborted' | 'needs_user_input' }>;
|
||||
/** 相 B: 現在の委譲ネスト深さ(0=トップ)。runDelegate がサブに +1 して渡す。 */
|
||||
delegationDepth?: number;
|
||||
/** 相 D0: 現在の delegate run の安定 ID。runDelegateSubAgent がサブ ctx に設定し、
|
||||
* ネストした delegate の parentRunId 導出に使う(イベントの可観測性復元用)。 */
|
||||
delegateRunId?: string;
|
||||
contextManager?: ContextManager; // Read 系 / Bash の出力サイズをコンテキスト残量に合わせて切り詰めるために使用
|
||||
/**
|
||||
* Traceability T-1: structured event log writer. Optional at the
|
||||
@@ -456,6 +467,30 @@ export function resolveAndGuard(workspacePath: string, filePath: string): string
|
||||
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('/');
|
||||
}
|
||||
@@ -925,7 +960,8 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
} catch (e) {
|
||||
return { output: `Read error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
const text = buf.toString('utf-8');
|
||||
// BOM は先頭にしか現れない。範囲の先頭から読むときだけ剥がす。
|
||||
const text = start === 0 ? stripLeadingBom(buf.toString('utf-8')) : buf.toString('utf-8');
|
||||
const capped = effectiveLength < requested;
|
||||
if (capped) {
|
||||
const nextOffset = start + effectiveLength;
|
||||
@@ -941,7 +977,7 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
|
||||
// 3. 行単位読み込み(既存挙動)+ 事前切り詰め
|
||||
try {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
const content = stripLeadingBom(fs.readFileSync(resolved, 'utf-8'));
|
||||
const lines = content.split('\n');
|
||||
const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset);
|
||||
const slicedText = sliced.join('\n');
|
||||
@@ -979,6 +1015,7 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
|
||||
// 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 };
|
||||
}
|
||||
@@ -1038,6 +1075,7 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||||
assertWritable(ctx.workspacePath, resolved);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
@@ -1123,6 +1161,8 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
|
||||
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:
|
||||
@@ -1338,7 +1378,7 @@ function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
const full = path.join(baseDir, rel);
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(full, 'utf-8');
|
||||
content = stripLeadingBom(fs.readFileSync(full, 'utf-8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user