CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
priorReadEnforcement.ts331 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace