basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2026 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs';8import type { FileReadCache } from '../services/fileReadCache.js';9import { ToolErrorType } from './tool-error.js';10import { ToolNames } from './tool-names.js';11 12/**13 * Error thrown by `getConfirmationDetails()` when it needs to surface14 * a structured `ToolErrorType` to the scheduler instead of letting15 * the throw collapse into a generic `UNHANDLED_EXCEPTION`. Originally16 * introduced for prior-read enforcement (hence the file location)17 * but now also carries other content-derived `calculateEdit` errors18 * — `EDIT_NO_OCCURRENCE_FOUND`, `EDIT_EXPECTED_OCCURRENCE_MISMATCH`,19 * `EDIT_NO_CHANGE`, `ATTEMPT_TO_CREATE_EXISTING_FILE` — through the20 * confirmation path so they keep their proper error code instead of21 * being reported as "unhandled exception".22 *23 * Caught by `coreToolScheduler` via the `errorType` instance field.24 *25 * Naming note: kept generic (`StructuredToolError`) rather than26 * `PriorReadEnforcementError` so the name matches the broader set of27 * `ToolErrorType` values it actually carries — an oncall engineer28 * seeing this in a log paired with `edit_no_occurrence_found` should29 * not have to wonder what prior-read has to do with it.30 */31export class StructuredToolError extends Error {32 override readonly name = 'StructuredToolError';33 constructor(34 message: string,35 readonly errorType: ToolErrorType,36 ) {37 super(message);38 }39}40 41/**42 * Result of checking whether a tool that mutates an existing file is43 * cleared to proceed based on the session FileReadCache.44 *45 * - `ok: true` — the model has legitimately read the file in this46 * session and the on-disk fingerprint still matches.47 * - `ok: false` — the call must be rejected. `type` selects the48 * error code; `rawMessage` is the model-facing prose; `displayMessage`49 * is the short user-facing form.50 *51 * The decision is structured (rather than a `ToolResult` or thrown52 * error) so each caller can route it into the shape its surrounding53 * code expects — a `CalculatedEdit.error` from EditTool's54 * `calculateEdit`, a thrown error from `getConfirmationDetails`, or a55 * `ToolResult` from `execute`.56 */57export type PriorReadDecision =58 | { ok: true }59 | {60 ok: false;61 type: ToolErrorType;62 rawMessage: string;63 displayMessage: string;64 };65 66/**67 * Verb used in the user-facing prose ("editing" / "overwriting").68 * Kept as a parameter rather than baked into the tool because EditTool69 * and WriteFileTool word their messages slightly differently and we70 * do not want a future divergence to silently weaken the boundary.71 */72export type PriorReadVerb = 'editing' | 'overwriting';73 74/**75 * Options for {@link checkPriorRead}.76 *77 * - `expectExisting`: when true, an `ENOENT` from the stat call78 * rejects with `FILE_CHANGED_SINCE_READ` instead of returning79 * `ok: true`. Use this for the post-read and pre-write recheck80 * calls — at those points the model has already committed to81 * mutating an existing path, so a disappeared file is a stale-read82 * drift, not a "the file genuinely never existed" disappearance83 * race. The default (`expectExisting: false`) is the pre-read84 * behaviour: ENOENT means "go ahead and create".85 *86 * **Do not re-introduce a `requireFullRead` (or any "stricter for87 * WriteFile than Edit") option here.** PR #3932 added one with the88 * rationale that WriteFile's overwrite path needs more evidence than89 * Edit's `old_string`-matched in-place change; PR #4002 removed it90 * because the truncate-tool-output limit makes "fully read" an91 * impossible precondition on files larger than the limit, producing92 * the deadlock issue #3945 reported. The contract now matches Claude93 * Code's `readFileState`: any prior read clears enforcement for both94 * tools, the mtime/size drift check is the safety net.95 *96 * There is no built-in "stricter than this" mode. `fileReadCacheDisabled:97 * true` is the OPPOSITE — it bypasses the cache (and thus prior-read98 * enforcement) entirely, ceding the safety net to whatever99 * application-level overwrite-protection the operator wires up100 * (lockfiles, content hashing, atomic temp-file rename, etc.). Users101 * who want STRICTER built-in enforcement than the residual #2499 risk102 * accepts have no flag here today; file a feature request.103 *104 * See the docstring on {@link checkPriorRead} for the full rationale105 * and the residual #2499 risk it accepts.106 */107export interface CheckPriorReadOptions {108 expectExisting?: boolean;109}110 111/**112 * Test whether a mutating tool is cleared to proceed against113 * `filePath` based on the session FileReadCache.114 *115 * Approval requires more than `cache.check === 'fresh'`: the recorded116 * read must also have been (a) stamped with `lastReadAt` and117 * (b) `lastReadCacheable` (i.e. plain text, not binary / image /118 * audio / video / PDF / notebook — those return a structured payload119 * the Edit / WriteFile tools cannot mutate as text).120 *121 * `lastReadCacheable` is purely about content type, not completeness.122 * A truncated or partial text read still records `lastReadCacheable:123 * true` because the bytes the model saw were text. Whether the model124 * has seen *every* byte is recorded on `lastReadWasFull` for the125 * Read fast-path; we do NOT consult it for enforcement, because the126 * truncate-tool-output limit makes "fully read" an impossible127 * precondition on files larger than the limit (issue #3945).128 * Aligning with Claude Code's `readFileState`: any prior read clears129 * enforcement for both Edit and WriteFile; the mtime/size drift130 * check above is the only gate that distinguishes "the model has131 * seen current bytes" from "the model has seen older bytes", and it132 * fires identically for both tools. Issue #2499 (model hallucinates133 * unread bytes on overwrite) is the residual risk this stance134 * accepts, mitigated by the drift check. There is no built-in135 * stricter mode — `fileReadCacheDisabled: true` is an OPT-OUT (it136 * bypasses enforcement entirely so application-level locking can137 * take over), not an opt-in to anything stricter.138 *139 * Stat policy: `ENOENT` means the path disappeared between the140 * caller's `fileExists` check and now — a disappearance race that is141 * harmless for our purposes (the downstream write will resurface the142 * absence as its own error). Any other stat error (`EACCES`, `EBUSY`,143 * NFS hiccup, …) is fail-closed: returning `ok: true` would re-open144 * the blind-write path the helper exists to block, since a transient145 * stat failure does not imply the subsequent read/write will fail.146 *147 * Note on `recordWrite` interaction: when a tool *creates* a file via148 * Edit (`old_string === ''`) or WriteFile (new path), the FileReadCache149 * `recordWrite` call seeds `lastReadAt` / `lastReadCacheable` on the150 * brand-new entry, so a subsequent edit on that same file passes here151 * without an intervening explicit Read. The model authored those bytes;152 * for the purposes of prior-read enforcement that counts as having153 * seen them.154 */155export async function checkPriorRead(156 cache: FileReadCache,157 filePath: string,158 verb: PriorReadVerb,159 options: CheckPriorReadOptions = {},160): Promise<PriorReadDecision> {161 let stats: fs.Stats;162 try {163 stats = await fs.promises.stat(filePath);164 } catch (err) {165 const code = (err as NodeJS.ErrnoException | undefined)?.code;166 if (code === 'ENOENT') {167 if (options.expectExisting) {168 // Post-read or pre-write: the file existed at planning time169 // but disappeared before this recheck. That is not a benign170 // disappearance race — it is the original target going away171 // from under the model. Reject so the caller does not172 // silently fall through to the new-file path with stale173 // bytes.174 const raw =175 `File ${filePath} disappeared after the model read it ` +176 `(stat now returns ENOENT). Re-read with the ${ToolNames.READ_FILE} ` +177 `tool — the path may have been deleted or moved — before ` +178 `retrying ${verb} it.`;179 return {180 ok: false,181 type: ToolErrorType.FILE_CHANGED_SINCE_READ,182 rawMessage: raw,183 displayMessage: `file disappeared after last read; re-run ${ToolNames.READ_FILE} first.`,184 };185 }186 // Pre-read disappearance race vs the caller's fileExists check.187 // Let the downstream write path surface the absence — synthesising188 // a "you must read first" message here would be misleading.189 return { ok: true };190 }191 // Any other stat failure: fail closed. We cannot prove the file192 // has been read; treating that as approval would silently bypass193 // enforcement on transient metadata errors that don't prevent194 // the subsequent write from succeeding. Use a distinct195 // PRIOR_READ_VERIFICATION_FAILED code (rather than196 // EDIT_REQUIRES_PRIOR_READ) because the model may have197 // legitimately read this file — we just cannot verify it.198 // Operators monitoring on error codes can route the two199 // populations separately.200 const raw =201 `Could not stat ${filePath} to verify prior read (${code ?? 'unknown error'}). ` +202 `Re-read with the ${ToolNames.READ_FILE} tool, then retry ${verb} it.`;203 const verbDisplay =204 verb === 'editing' ? 'editing this file' : 'overwriting this file';205 return {206 ok: false,207 type: ToolErrorType.PRIOR_READ_VERIFICATION_FAILED,208 rawMessage: raw,209 displayMessage: `cannot verify prior read of ${filePath}; re-run ${ToolNames.READ_FILE} before ${verbDisplay}.`,210 };211 }212 // Directory and other non-regular paths get dedicated rejections213 // with structured ToolErrorType codes — never `ok: true`. Falling214 // through to readTextFile would either block (FIFO),215 // over-allocate (/dev/urandom), or throw a plain Error that the216 // confirmation path collapses into UNHANDLED_EXCEPTION (e.g.217 // EISDIR on WriteFile.getConfirmationDetails, which never reaches218 // execute()'s explicit EISDIR mapping).219 if (stats.isDirectory()) {220 const verbBare = verb === 'editing' ? 'edit' : 'overwrite';221 const raw =222 `${filePath} is a directory. The Edit / WriteFile tools only ` +223 `operate on regular files. Use a different mechanism (e.g. ` +224 `the shell tool) if you need to ${verbBare} the contents of ` +225 `this directory.`;226 return {227 ok: false,228 type: ToolErrorType.TARGET_IS_DIRECTORY,229 rawMessage: raw,230 displayMessage: `path is a directory; cannot ${verbBare} via this tool.`,231 };232 }233 if (!stats.isFile()) {234 const verbBare = verb === 'editing' ? 'edit' : 'overwrite';235 const raw =236 `${filePath} is a FIFO / socket / character or block device. ` +237 `The Edit / WriteFile tools only operate on regular files; ` +238 `the ${ToolNames.READ_FILE} tool also rejects these targets. ` +239 `Use a different mechanism (e.g. shell tool with the appropriate ` +240 `command) if you need to ${verbBare} this path.`;241 return {242 ok: false,243 type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,244 rawMessage: raw,245 displayMessage: `special file; cannot ${verbBare} via this tool.`,246 };247 }248 const status = cache.check(stats);249 if (250 status.state === 'fresh' &&251 status.entry.lastReadAt !== undefined &&252 status.entry.lastReadCacheable253 ) {254 return { ok: true };255 }256 if (status.state === 'stale') {257 const raw =258 `File ${filePath} has been modified since you last read it ` +259 `(mtime or size changed). Re-read it with the ${ToolNames.READ_FILE} ` +260 `tool before ${verb} it to ensure your changes are based on current ` +261 `content.`;262 return {263 ok: false,264 type: ToolErrorType.FILE_CHANGED_SINCE_READ,265 rawMessage: raw,266 displayMessage: `file changed since last read; re-run ${ToolNames.READ_FILE} first.`,267 };268 }269 // Differentiate "fresh but the recorded read was non-cacheable"270 // (binary / image / audio / video / PDF / notebook) from "never271 // read at all". Telling the model to "re-read with read_file" for272 // a binary file would loop forever because that read would also273 // leave `lastReadCacheable === false`.274 if (275 status.state === 'fresh' &&276 status.entry.lastReadAt !== undefined &&277 !status.entry.lastReadCacheable278 ) {279 // Both raw and displayMessage use the bare verb (`edit` /280 // `overwrite`) rather than the gerund — the noun phrase281 // "cannot editing via this tool" would be ungrammatical, and282 // both strings need to read correctly on the EditTool path283 // (where "overwrite" would be the wrong verb for an in-place284 // edit) and the WriteFileTool path (where "overwrite" is285 // correct).286 const verbBare = verb === 'editing' ? 'edit' : 'overwrite';287 const raw =288 `File ${filePath} is a binary / image / audio / video / PDF / ` +289 `notebook payload that the ${ToolNames.READ_FILE} tool returns ` +290 `as a structured value rather than as plain text. The Edit / ` +291 `WriteFile tools cannot mutate that payload safely — re-reading ` +292 `it would not change this. If this is a Jupyter notebook (.ipynb), ` +293 `use the ${ToolNames.NOTEBOOK_EDIT} tool for cell-level edits after ` +294 `reading it. For other non-text files, use a different mechanism ` +295 `(e.g. shell tool with an appropriate writer) if you need to ` +296 `${verbBare} it.`;297 return {298 ok: false,299 type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,300 rawMessage: raw,301 displayMessage: `non-text payload; cannot ${verbBare} via this tool.`,302 };303 }304 // unknown: the model has never read this file in this session.305 const verbBare = verb === 'editing' ? 'edit' : 'overwrite';306 const verbDisplay =307 verb === 'editing' ? 'editing this file' : 'overwriting this file';308 // Tool-specific guidance on partial reads. Edit can use a partial309 // read (the model only needs to have seen `old_string`-bearing310 // bytes; the rest of the file passes through untouched). WriteFile311 // OVERWRITES — the model is replacing the entire file, so a312 // partial read leaves any unseen bytes as collateral damage. The313 // mtime/size drift check still catches the worst case (#2499314 // hallucinated-bytes risk), but recommending a partial read here315 // would actively encourage the foot-gun.316 const partialReadGuidance =317 verb === 'editing'318 ? `(a partial read with offset / limit is fine — you only need to have seen the bytes you intend to ${verbBare})`319 : `(read the full file — overwriting replaces every byte, so any unseen bytes would be discarded)`;320 const raw =321 `File ${filePath} has not been read in this session. ` +322 `Use the ${ToolNames.READ_FILE} tool first to load the current ` +323 `content ${partialReadGuidance} before ${verb} it.`;324 return {325 ok: false,326 type: ToolErrorType.EDIT_REQUIRES_PRIOR_READ,327 rawMessage: raw,328 displayMessage: `${ToolNames.READ_FILE} required before ${verbDisplay}.`,329 };330}331 