@sema-agent/core
Version:
Stateless, task-oriented AI agent core
684 lines • 50.4 kB
TypeScript
import { type HomeViewSource } from "./read-deny-home.js";
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
import type { ExecutionEnv } from "../../internal/harness-types.js";
import { protectivePathTargetOf, type ToolCallLike } from "../../core/tool-registry.js";
/**
* CC parity: Read/Edit/Write take the file path as `file_path`. Read the target the same way
* EVERYWHERE the path is consumed from a tool-call's args — not just the tool body but ALSO every gate that
* extracts the write target to confine it (sensitive-path, session allowDirs, the fs-write gate). A gate that only
* read `args.path` would see `undefined` for a `file_path` call and either fail-OPEN (circumvent the guard — a real
* hole) or fail-closed (deny a legitimate write). Single source.
*
* The `path` arm is deliberately BROADER than what Read/Edit/Write accept: their schema requires
* `file_path` and rejects the retired `path` alias outright (fs-shared.ts FILE_PATH_PARAMS), but this reader is
* shared with gates that judge OTHER tool vocabularies — Glob's real `path` parameter, deployment-authored tools —
* and a gate must never see LESS than the tool it is confining might act on. So the asymmetry is the design: the
* schema decides what a caller may send, this decides what gets judged, and this side over-reads on purpose.
*/
export declare function fileArgPath(args: unknown): string | undefined;
/**
* The WRITE TARGET of a path-confinable write call, tool-aware — single source for every gate that
* confines a write target, same discipline as {@link fileArgPath} above:
*
* · canonical `NotebookEdit` → `notebook_path` FIRST, `file_path`/`path` only as fallback. The tool
* itself writes `notebook_path`; its schema tolerates extra keys, so a stray/decoy `file_path` on a
* NotebookEdit call must never become the judged target while the real write lands elsewhere
* (a gate that read `file_path` first would confine the decoy — the hole).
* · every other tool → `file_path`/`path` first ({@link fileArgPath}), `notebook_path` as the
* historical fallback (unchanged behavior for the pre-existing gate callers).
*
* `canonicalName` is the tool name as mounted, raw: there is no legacy normalizer, and this module
* stays dependency-free of the alias table — "NotebookEdit" is the canonical CC name and old→new
* normalization is identity for it.
*/
export declare function writeTargetPath(call: ToolCallLike, pt?: ReturnType<typeof protectivePathTargetOf>): string | undefined;
/**
* The "hand" file tools' safety invariants, ported from CC's `FileEditTool`: without these a weak
* model blind-edits / overwrites files (a safety property, not polish). This module is the
* pure/testable core (path resolution + the three edit invariants + content hashing); the tools in
* this dir compose it. Per-task state and the execution env are passed in (factory-closure wiring),
* never read from a shared/global.
*/
/** What we remember about a file the agent has read. */
export interface ReadEntry {
/** SHA-256 of the content at read time. Staleness is detected by content hash, NOT mtime — mtime is
* unreliable across containers/volumes/NFS and across a suspend→resume on another replica. */
hash: string;
/** Total line count at read time, and whether the read was truncated (for the edit truncation hint). */
totalLines: number;
truncated: boolean;
/** CC `isPartialView` parity: set ONLY when the auto-pagination branch fires — the model asked for
* the file by DEFAULT (no explicit offset/limit) and the token cap truncated it anyway. An EXPLICIT
* offset/limit slice that fits under the cap is NEVER marked this way, even though it is equally
* "not the whole file". Deliberately narrower than {@link truncated} above,
* which also fires for a fully-intentional, successful slice read (`start>1 || end<total`) — folding
* THIS flag into that broader one would make {@link OVERSIZE_READ_ESCAPE_HINT}'s documented promise
* ("read it in slices with explicit offset/limit to satisfy the read-first rule") false: a model paging
* through an oversized file on purpose would never be able to satisfy {@link requireRead} again. Optional/
* additive: undefined on every non-Read write-back (Write/Edit re-record the file they just fully wrote)
* and on every seeded/attachment entry — those are never partial by construction. */
isPartialView?: boolean;
/** The line window actually served to the model on the last read (the dedup stub's key). Lets
* read_file return a `file_unchanged` stub when the SAME window is re-requested and the content hash is
* unchanged — saving the re-transmitted body. Optional/additive: undefined (e.g. an entry seeded from an
* older checkpoint) just means "no dedup", never a false hit. */
view?: {
start: number;
end: number;
};
/** Millisecond timestamp of the last read/write-back that recorded this entry: the post-compaction
* attachment picks the most RECENTLY read files, the way CC sorts readFileState by timestamp.
* Optional/additive: undefined (an entry seeded from an older checkpoint) sorts last, never breaks. */
lastReadAt?: number;
/** CC 2.1.204 parity: TRUE when the Runner pre-seeded this entry because the file's FULL
* disk-verbatim text was injected into the model's context at startup
* (ProjectMemoryLoad.seededFiles — CC's own memory preload). A DEFAULT whole-file Read of an
* UNCHANGED seeded file
* answers with the already-in-context reminder instead of re-transmitting the body. Any real
* Read / edit write-back records a FRESH entry without this flag, so a file that changed on
* disk (hash mismatch) always serves real content and the dedup disarms permanently.
* Optional/additive: absent = normal entry. */
seededFromContext?: boolean;
/** The id of the tool call whose RESULT carried the body this entry dedups against: when the
* request-layer stale-result pass clears THAT transmission, the entry retires with it
* (`applyContextShrinkToReadFileState`, `tool-results-cleared` arm). Recorded only by the Read
* lanes that TRANSMIT a body — a write-back's content is the model's own arguments and a
* seeded/re-attached entry never rode a tool result, so neither carries this and neither is
* retired by a clear. Optional/additive: absent (an older checkpoint) never matches a cleared id. */
transmittedByToolCallId?: string;
}
/** Per-task record of which files have been read, keyed by canonical path: owned by prepare-task,
* closure-captured by the tools, and serialized into the durable checkpoint. */
export type ReadFileState = Map<string, ReadEntry>;
/** SHA-256 hex of UTF-8 content — the staleness/identity key. */
export declare function sha256(content: string): string;
/** A failed path resolution / invariant check, surfaced to the model as a self-correctable tool error. */
export interface FsViolation {
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid" | "read_path_denied" | "write_path_denied";
/** Set on the `not_read` violation raised by an AUTO-TRUNCATED read
* ({@link ReadEntry.isPartialView}) rather than by a never-read file. Both carry the same CC-verbatim
* message; only this one needs {@link PARTIAL_VIEW_READ_ESCAPE_HINT} appended, because for it a plain
* re-read is a fixpoint. */
partialView?: true;
/** Set on `path_not_in_root` and `read_path_denied` — the CANONICAL key the
* refused path resolved to (symlinks followed, tracked cwd applied), i.e. the value the fence
* actually judged. Rides into the tool result's `details` (see {@link violationDetails}) so a
* consumer can count/aggregate boundary refusals from fields instead of regexing the prose. */
target?: string;
/** Set ONLY on `path_not_in_root` — the containment roots in effect (primary first,
* then additionalDirectories), canonical form. */
roots?: readonly string[];
/** Set ONLY on `read_path_denied` — the deny-list pattern (verbatim entry text)
* the judged target matched. */
pattern?: string;
message: string;
}
/**
* Absolute in EITHER family: POSIX `/…` or drive-letter `C:\…`/`C:/…`.
*
* This is the ONE definition of "already absolute, therefore do not prepend a base". The failure mode
* of a private re-typing is not the duplication but the drift it invites: a family added at one
* coordinate leaves every other copy judging the same string differently, and this predicate gates a
* security fence (frozen-path deny, readonly-boundary containment) as well as URI key minting.
* Callers that need a family the predicate does not cover must widen it HERE, not locally.
*
* The UNC arm (`\\host\share\…`) is the shape that drift produced: held privately in one consumer, it
* made that consumer resolve a UNC path as absolute while a sibling prefixed it with the task root —
* two URI keys for one file. Folding it in here settles the disagreement in the direction the rest of
* this file takes: a literal `\\host\share\…` is win-form (see {@link isWinFormPath}) and `within()`
* lands it outside every POSIX/drive root, i.e. a location the working directory does not contain.
*/
export declare function isAbsolutePathForm(p: string): boolean;
/**
* Windows-FAMILY spelling of a path (extracted here so {@link canonicalizeTarget} judges
* the UNC family by the same rule): drive-rooted (`C:\…`/`C:/…`), UNC-backslash (`\\host\share`), or a
* RELATIVE path using `\` as its separator. Never keyed on `process.platform` — the exec env may be remote
* and of the other family than the host. A `/`-rooted path is POSIX-form even when a file NAME inside it
* contains a literal backslash (legal on POSIX), and `//host/share` is therefore POSIX-form too — which is
* the whole point: on POSIX that spelling is an ordinary absolute path, not a network share.
*
* This is the ONE definition of the win-family spelling; core/file-snapshot-store.ts conditions its
* manifest key / restore-target folds on this import rather than a private twin. core/ already depends
* on this module elsewhere, and this file's own imports are `node:crypto` plus a type, so there is no
* layering wall between them.
*/
export declare function isWinFormPath(p: string): boolean;
/**
* Expand a LEADING `~` (bare, or followed by a separator of either family) against `home`; every other
* spelling is returned untouched — a `~` anywhere but the front is an ordinary filename character.
*
* Lives here for the same reason `isWinFormPath` does: it is a path-FAMILY decision (which characters
* end the first component), and the domain gate holds this module as the one home for those. Callers
* that need it: the sensitive-path guard's data-root resolution, and — spelled privately for now —
* `tool-policy.ts`'s `lexicalPath`. NOT a resolution of relative paths: a caller that also needs that
* says so with its own `resolve`, because the two consumers of this value disagree about which cwd a
* relative spelling belongs to and that disagreement is theirs to state, not this helper's to hide.
*/
export declare function expandHomeTilde(p: string, home: string): string;
export declare function isBlockedDevicePath(key: string): boolean;
/**
* Purely LEXICAL path normalization: collapse repeated separators, drop `.` segments, and
* resolve `..` against the preceding segment. No filesystem access, no cwd: this is a spelling
* normalizer, not a resolver. (Deliberately NOT `canonicalizeNewPath`'s job — that one walks the real
* filesystem precisely because folding `..` lexically across a SYMLINKED parent is unsound. Use this one
* only where the question is "are these two strings the same name", never "which file is this".)
*
* It exists because {@link isBlockedDevicePath} is an exact-string membership test, and `//dev/zero`,
* `/dev/./zero` and `/dev/../dev/zero` all name the device `/dev/zero` to the kernel while spelling it
* three different ways. Callers that match a device list against a raw argument must normalize first or
* the list is a spelling filter rather than a device filter.
*
* Coverage — the union of every fold the callers need, held once here rather than re-implemented per
* consumer:
* · POSIX absolute — `/a/./b` → `/a/b`; `/..` is `/`, matching the kernel.
* · drive-letter (`C:\a\..\b`) and UNC (`\\host\share\..\x`) — prefix preserved, folding applies
* to the tail only, and the REJOIN uses the path's own native separator so one file cannot acquire
* two keys by separator form.
* · RELATIVE — stays relative (`dev/zero` therefore still never matches an absolute device entry:
* `dev/zero` under some other directory is an ordinary file), and a leading `..` is KEPT rather than
* popped into nothing — dropping it would move the path.
*
* FAMILY-awareness is the part a private copy tends to get wrong. A backslash is a separator only in a
* win-form path; in a POSIX path it is an ordinary filename character, and an unconditional `\`→`/`
* fold rewrites a POSIX file literally named `a\..\b` into a different location before the boundary
* check ever sees it — in both directions: false refusal and false allow.
*/
export declare function normalizeAbsPathLexically(p: string): string;
/**
* The READ-leg fold: removes `.` segments and duplicate
* separators ONLY — transformations that name the SAME file on every real filesystem, symlinks
* included. `..` is deliberately KEPT: `a/../x` and `x` are different files when `a` is a symlink,
* and this function's callers go on to OPEN the result (fileInfo/readText), not just to mint a
* comparison key. Ask "which file is this" here; ask "are these two strings the same name" with
* {@link normalizeAbsPathLexically}.
*/
export declare function foldRedundantPathSegments(p: string): string;
/** True if `path`'s extension is a known binary format (pure check — no I/O). Used by read_file and grep. */
export declare function hasBinaryExtension(path: string): boolean;
/** The MIME type to return an image file AS (a visual `ImageContent` block), or `undefined` if `path` is not a
* Read-supported image. Pure — no I/O. */
export declare function imageMimeForRead(path: string): string | undefined;
/** Verify the raw bytes actually ARE the image format the extension claims (magic-number sniff). Extension alone
* is forgeable: a `.png` holding text / an empty file / a truncated blob would otherwise become a malformed
* `ImageContent` that makes the NEXT provider request fail (400). Pure — no I/O. */
export declare function imageMagicMatches(bytes: Uint8Array, mimeType: string): boolean;
/** One contiguous byte pattern that must appear at a fixed offset for its signature to match. */
export interface BinaryMagicSegment {
/** Byte offset the pattern starts at — 0 for a leading signature, 257 for tar's `ustar`, and so on. */
readonly offset: number;
/** The exact bytes that must appear there. */
readonly bytes: readonly number[];
}
/** A file-format signature: EVERY segment must match for the format to be claimed. */
export interface BinaryMagicSignature {
/** Human-readable format label. It lands verbatim in the Read refusal, so it names what the file IS,
* never the extension it happened to be carrying. */
readonly format: string;
/** All segments must match. A second segment exists only to disambiguate a prefix that also spells
* plausible text (`BM`, `RIFF`, `FORM`): the payload word at the later offset is what decides. */
readonly segments: readonly BinaryMagicSegment[];
}
/**
* Magic-byte criteria for the formats {@link hasBinaryExtension} names. That list carries 103
* extensions; without this table only PDF (`pdfMagicMatches`, in the PDF lane) and the four API image
* formats ({@link imageMagicMatches}) have a content criterion, so for every other listed format the
* NAME is the only evidence available. Where the content IS reachable that leaves identification to
* the statistical fallback ({@link isBinaryContent}),
* which samples the first 8192 decoded characters and passes anything whose control-character ratio
* stays under 10% — a short archive/executable header followed by printable payload clears that bar
* and gets delivered as text.
*
* Shape: pure data, one table, `offset + bytes`. Adding a format is a row; the matcher
* ({@link binaryMagicFormat}) never changes. Rows are grouped by family so the next diff stays readable.
*
* Admission rule — a row exists only where the signature is DECIDABLE. A short ASCII prefix that also
* opens ordinary prose is admitted only with a disambiguating second segment (`BM` + the zeroed
* reserved fields, `RIFF`/`FORM` + the payload word); formats whose only fixed marker is such a prefix
* are deliberately absent, because a false positive here makes a real text file unreadable — the exact
* harm the name-only refusal was retired for. Absent on that ground: DOS/PE (`MZ`, with the PE header
* at a variable offset), SWF (`FWS`/`CWS` + a wide version byte), Blender (`BLENDER`), `.pyc` (a
* per-release magic), and PostScript/EPS (genuinely text). They stay covered by the statistical sniff.
*
* Converged consumer: the WebFetch lane (`tools/web.ts`'s `sniffBinarySignature`) used to carry its own
* private eight-format if-chain for HTTP bodies; it now reads this table via {@link binaryMagicFormat}
* (PDF handled the same way this table's own callers handle it — via `pdfMagicMatches`, not a row here).
*/
export declare const BINARY_MAGIC_SIGNATURES: readonly BinaryMagicSignature[];
/**
* Identify `bytes` by its file signature — the format label, or `undefined` when no row in
* {@link BINARY_MAGIC_SIGNATURES} matches. Pure — no I/O, and name-blind by construction (nothing here
* looks at a path), so it can be asked in either direction: a mis-named binary is caught, and a listed
* extension holding real text is NOT claimed.
*/
export declare function binaryMagicFormat(bytes: Uint8Array): string | undefined;
/**
* Content sniff for binary data (CC `isBinaryContent` parity): a NUL byte,
* or more than 10% non-printable characters in the sample, means binary. Second layer behind
* {@link hasBinaryExtension} — catches extension-less or mis-named binaries (a `Dockerfile` that is really
* a compiled blob) that would otherwise be read as mojibake. Plain text (incl. UTF-8, tabs/newlines) passes.
*/
export declare function isBinaryContent(sample: string): boolean;
/**
* CC's own UNC/network-path predicate, verbatim (`n.startsWith("\\\\") || n.startsWith("//")`). Pure.
*
* In CC this test is an EARLY ALLOW — it skips the binary-extension and device checks that follow it —
* and CC applies it to the NORMALIZED path, not the raw argument. See {@link canonicalizeTarget} for
* how sema mirrors that and for the two deliberate differences. This function stays a pure spelling
* test with no verdict of its own.
*/
export declare function isUncPath(path: string): boolean;
/**
* True when a WIN-FORM spelling carries a component Win32's own normalization would trim — a trailing dot
* or space on anything but the `.`/`..` specials (the same client-side strip {@link uncCanonicalKey}
* folds, stated once and read here rather than re-derived).
*
* Such a spelling is an ALIAS: `x. ` and `x` name ONE directory to the OS and two different strings to a
* lexical comparator. A caller that must reason about a spelling byte-wise — a directory offered to a
* person as the thing to declare, a common-prefix cut between two operands — cannot pick one of the two
* readings on the person's behalf, so it withholds. POSIX paths are never aliased this way: a trailing dot
* or space there is an ordinary filename character.
*
* The UNC ANCHOR is exempt for the reason the key minter states: a host's trailing dot is DNS (the FQDN
* root) and share-name normalization is the server's business; neither is the client-side strip.
*/
export declare function hasWin32AliasedComponent(p: string): boolean;
/** The two spelling families a tree can be in. Read from the ROOT's spelling (the cwd's when a deployment
* supplies no root) — never from `process.platform`: the exec env may be remote and of the other family. */
export type PathFamily = "win" | "posix";
/** The family of a call's tree; `undefined` when the call declares neither root nor cwd (a treeless call
* gets no family invented for it — see {@link pathIdentityOf} for what it can still read). */
export declare function pathFamilyOf(bases: {
readonly root: string;
readonly cwd?: string | undefined;
}): PathFamily;
export declare function pathFamilyOf(bases: {
readonly root?: string | undefined;
readonly cwd: string;
}): PathFamily;
export declare function pathFamilyOf(bases: {
readonly root?: string | undefined;
readonly cwd?: string | undefined;
}): PathFamily | undefined;
/** UNC under the win family: two leading separators, a host, a share — either separator, as `path.win32`
* reads it. The ONE UNC recognizer behind {@link isAbsoluteForFamily}, {@link pathIdentityOf} and
* {@link joinForFamily}. */
export declare function isUncSpelling(p: string): boolean;
/**
* FULLY QUALIFIED under the family. win: drive-rooted (`C:\`/`C:/`) or UNC — a `/x` / `\x` is rooted-not-
* qualified and joins the base's drive or share root ({@link joinForFamily}), a `C:rel` is drive-relative.
* posix: `/`-rooted. `undefined` (a treeless call): only the anchor-free `/`-rooted form, the one spelling
* that names a file without a tree.
*/
export declare function isAbsoluteForFamily(family: PathFamily | undefined, p: string): boolean;
/** The refusal words of {@link win32NamespaceScreen} — a closed set (docs/CLOSED-SETS.md). */
export declare const WIN32_NAMESPACE_REFUSALS: readonly ["device_namespace", "unmappable_namespace", "unc_on_posix_tree", "drive_relative"];
export type Win32NamespaceRefusal = (typeof WIN32_NAMESPACE_REFUSALS)[number];
/** What each refusal word says about the spelling (the message clause after `path "…" `). */
export declare const WIN32_NAMESPACE_REFUSAL_TEXT: {
readonly device_namespace: "is a Win32 device-namespace path (\\\\.\\ names a raw device object, not a file)";
readonly unmappable_namespace: "is a Win32 extended-length namespace path with no DOS-path equivalent (only \\\\?\\<drive>: and \\\\?\\UNC\\ forms name files)";
readonly unc_on_posix_tree: "is a UNC (\\\\host\\share) spelling, which names no file on a POSIX tree";
readonly drive_relative: "is drive-relative (a drive letter with no separator after the colon), a location no tree can locate";
};
/** The fence over the text table: `never` while every refusal word has a row. */
export type NamespaceRefusalTextCoversEveryWord = AssertAllKeysHandled<Exclude<Win32NamespaceRefusal, keyof typeof WIN32_NAMESPACE_REFUSAL_TEXT>>;
/**
* The Win32 namespace screen — run on every model-spelled target BEFORE the family join, at every site
* (the fs throat, the effective-target reader, the rule side, the glob splitter), so one spelling is
* refused with one word everywhere. `\\.\` ⇒ `device_namespace` (every family); `\\?\` mappable ⇒ the
* stripped spelling, which is screened ON (a `\\?\UNC\…` on a POSIX tree still refuses), unmappable ⇒
* `unmappable_namespace` (every family); a backslash UNC ⇒ passes under `win`, `unc_on_posix_tree` under
* `posix` (the canonicalizer's refusal boundary on POSIX trees is preserved: the family rule governs what
* is admitted, never what was already refused); `C:rel` under `win` ⇒ `drive_relative`. A treeless call
* passes a UNC spelling through — the identity then reads it as relative, as before.
*/
export declare function win32NamespaceScreen(spelled: string, family: PathFamily | undefined): {
readonly ok: true;
readonly spelled: string;
} | {
readonly ok: false;
readonly refuse: Win32NamespaceRefusal;
};
declare const PATH_IDENTITY: unique symbol;
/**
* The ONE comparison form of an absolute path (upstream's normal form): posix = the lexical-normal
* `/`-rooted path; win = `/c/rest` for a drive (letter lower-cased), `//host/share/rest` for UNC, `\`
* folded to `/`, `.`/`..` folded with the anchor preserved. A COMPARISON form — never a path to open, never
* compared against a native `C:\…` string. Branded: a spelling is not an identity until {@link pathIdentityOf}
* has read it.
*/
export type PathIdentity = string & {
readonly [PATH_IDENTITY]: true;
};
/** Why a spelling has no identity: a namespace refusal, `relative` (join it to a base first), or
* `unanchored` (a win `/x` with no tree root to take a drive from). */
export type PathIdentityRefusal = Win32NamespaceRefusal | "relative" | "unanchored";
export type PathIdentityReading = {
readonly ok: true;
readonly identity: PathIdentity;
} | {
readonly ok: false;
readonly reason: PathIdentityRefusal;
};
/**
* The identity of an ABSOLUTE spelling under the family ({@link PathIdentity}). `anchor` is the tree's root
* (or cwd) spelling: under `win` a `/`-rooted spelling whose first segment is not a drive letter
* (`/proj/x`) anchors on the root's drive or share, as `path.win32.resolve` anchors it on the current
* drive; `/c/x` and `/C:/x` are drive spellings (the cygdrive form and the rule grammar's `/`+body), and
* `/\\host\share\x` is the grammar's prefix before a UNC. A relative spelling is `relative` — callers join
* it to a base FIRST. A treeless call (`family` undefined) reads only the anchor-free `/`-rooted form, as
* the POSIX identity; every other spelling names nothing, which is what a missing base answered before.
*/
export declare function pathIdentityOf(spelled: string, family: PathFamily | undefined, anchor?: string): PathIdentityReading;
/** Is the identity a UNC one (`//host/share/…`)? Identities compare by anchor kind first: a UNC identity is
* never under a rooted one and vice versa (upstream's shape parity). */
export declare function isUncIdentity(identity: PathIdentity): boolean;
/** Is `s` already an identity under the family — the identity function is the identity on it? The
* runtime twin of the brand for values that arrive from outside the type system. */
export declare function isPathIdentity(s: string, family: PathFamily | undefined): s is PathIdentity;
/** Is the identity a DIRECTORY a rule may speak for — anything but the tree's bare root (`/`, a drive root
* `/c`, a share root `//host/share`), which admits nothing? */
export declare function isIdentityDir(identity: PathIdentity, family: PathFamily | undefined): boolean;
/** A spelling of an identity that the environment resolves as a path of the family, and that reads as that family on its
* own ({@link isWinFormPath}) — the one way back from the comparison form: posix, the identity; a win drive identity
* `/c/rest` ⇒ `c:\rest`; a win UNC identity `//host/share/rest` ⇒ `\\host\share\rest`. */
export declare function spellingOfIdentity(identity: PathIdentity, family: PathFamily | undefined): string;
/** Does the spelling carry a `.` or `..` segment under the family's separators (a backslash is a name byte on
* POSIX)? A rule body with one admits nothing: folding it could make it name a second directory. */
export declare function carriesDotSegment(spelled: string, family: PathFamily | undefined): boolean;
/** A model-spelled path bounded for error text (the spelling is unbounded input). */
export declare function clipSpelling(spelled: string): string;
/** A ROOTED (not qualified) spelling handed to the SHELL on a win tree (`/d/x`, `/etc`, `\\x`) is the shell's to
* map — the win32 adapter runs Git Bash, where `/d/x` is drive D and `/etc` the MSYS root, neither the cwd's
* drive root — so no lexical reader may resolve it (fail-closed: the operand is unresolvable, the cd mints
* nothing). The ONE statement of that rule, for every shell-facing resolver. */
export declare function isShellRootedSpellingUnmapped(family: PathFamily | undefined, spelled: string): boolean;
/** The win family's native spelling of a UNC written with any leading-separator mix (`//host/share/x`, `\\/host/…`
* ⇒ `\\\\host\\share\\x`), for a lexical normalizer that recognises the backslash form only; every other spelling is
* returned as is. */
export declare function nativeUncSpellingOf(family: PathFamily | undefined, spelled: string): string;
/**
* Fold a TRAILING separator run off a spelling that is about to become a KEY: `<dir>/adir`, `<dir>/adir/`
* and `<dir>/adir//` name one directory and must mint one key. The existing-path arm folds them already,
* as a side effect of asking `realpath`; the arm that mints a key for a path that does NOT exist yet
* rejoined the model's own tail verbatim, so the same not-yet-created directory arrived under two and
* three keys — and a byte-wise reader (the per-file read state, the write gate's per-key ledger) treats
* two keys as two files.
*
* Called on the ANSWER, at the canonicalizer's single exit, and never on a spelling about to be probed.
* Two things break if it moves earlier. A trailing separator is an ASSERTION that the target is a
* directory and the platform enforces it (`<existing regular file>/` answers `not_directory`, reported
* here as a refusal), so folding before the probe spends that refusal and the slash-suffixed spelling of
* a file resolves to the file. And the ancestor walk chooses which ancestor to canonicalize by SPLITTING
* the spelling it was handed: a leaf stripped of its separator first is skipped as though it had been
* resolved, so `<root>/link/` — a link to a directory outside the root — comes back as the in-root
* spelling instead of the destination. Folding the answer costs neither.
*
* A spelling whose remainder is the bare ANCHOR keeps the separator the platform writes on a root — `/`,
* `C:\`, `\\host\share\`. That run IS the root's own spelling rather than a tail nobody wrote, and
* stripping it renames the spelling to nothing, to a DRIVE-RELATIVE location, or to a share root written
* unlike every other root. The question is asked in the module's own vocabulary ({@link pathIdentityOf} +
* {@link isIdentityDir}: is anything left BELOW the anchor?), so the three families need no arm each.
* Interior runs are NOT touched here: the ancestor walk folds them by probing, and a run in the middle of
* a name is the platform's to read.
*
* Scanned backwards rather than matched: `/[\\/]+$/` against a model-supplied spelling is quadratic when a
* long separator run precedes a non-separator (measured: ~5s for 32k leading slashes, on the caller's
* thread), and a path spelling is unbounded input.
*/
export declare function foldTrailingSeparators(spelled: string, family: PathFamily): string;
/**
* Join a RELATIVE-OR-ROOTED spelling to a base under the family, without folding (`..` across a symlinked
* parent is the env's to resolve). win = `path.win32` semantics: `/x`/`\x` lands on the base's drive or
* share root, anything else under the base with the base's own separator. posix = `/`-join.
*/
export declare function joinForFamily(family: PathFamily, base: string, rel: string): string;
/** The family-aware LEXICAL view of a possibly-relative spelling against a resolution base (no I/O,
* `.`/`..` folded via {@link normalizeAbsPathLexically}): the SECOND deny-judgment view. It asks
* "does the NAME land on a guarded path", complementing the canonical
* view's "does the TARGET" — a symlink AT a guarded spelling resolves away from it canonically,
* and only this view still names it. */
export declare function lexicalViewOf(spelled: string, base: string): string;
/** A face's extra containment roots: a fixed list, or a getter over a run's live set (read once per resolution). */
export type RootsSeat = readonly string[] | (() => readonly string[] | undefined);
/**
* Resolve a model-supplied path to a canonical key AND enforce rootPath containment. Existing paths
* use `canonicalPath` (resolves symlinks, so the same file cannot get two keys). New paths
* canonicalize the **deepest existing ancestor** then rejoin the missing tail — a symlinked parent
* therefore cannot land the target outside root. Returns the canonical key, or a `path_not_in_root`
* violation. Defense-in-depth at the tool layer; NOT a substitute for a sandboxed `executionEnv`.
*/
export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRoots?: RootsSeat, exactFileReadExemption?: (canonicalKey: string) => boolean, readDeny?: {
readonly face?: "write";
readonly homeViews?: HomeViewSource;
matchTarget(canonicalKey: string, lexicalView?: string, opts?: {
readonly aliasResolved?: boolean;
}): {
pattern: string;
matchedView?: string;
} | null;
}, containmentFace?: "open" | "roots"): Promise<{
ok: true;
key: string;
} | {
ok: false;
violation: FsViolation;
}>;
/**
* Canonicalize a model-supplied path to its real on-disk target (symlinks resolved; for a path that
* doesn't exist yet, the deepest EXISTING ancestor is canonicalized then the missing tail rejoined —
* so a symlinked parent cannot land the target elsewhere). NO containment check — this is the pure
* canonicalization shared by `resolveKey` (which adds rootPath containment) and
* `createSensitivePathPolicy` (which matches the real target against a guarded list).
* A single source of truth for symlink resolution: a sensitive-path policy that matched the raw path
* instead of the real target would compare against the spelling, not the destination.
*
* @contract fs.canonical_target.probed_and_bounded — every model-spelled path is resolved by asking the
* execution environment where it really lands; no spelling shape is exempt from that, and the win-form
* UNC spelling stopped being the one that was. The ask is bounded by the tree's shared bounded-await
* deadline rather than by a timer of this module's own, and a wait that elapses or a probe that fails
* comes back as a refusal carrying no key — the spelling is never handed back as a substitute for the
* answer. What the deadline bounds is the CALLER'S wait: the underlying filesystem call is not
* cancellable and may go on occupying the runtime's I/O capacity until the kernel gives up.
*/
export declare function canonicalizeTarget(env: ExecutionEnv, path: string, signal: AbortSignal | undefined, family: PathFamily, baseCwd?: string): Promise<{
ok: true;
key: string;
aliasResolved?: true;
} | {
ok: false;
message: string;
unresolvedSymlink?: true;
refused?: Win32NamespaceRefusal;
}>;
/** Render a `FsViolation` as the model-facing tool error text (returned, never thrown — model retries). */
export declare function violationText(toolName: string, v: FsViolation): string;
/**
* Structured `details` for a rendered violation: non-`undefined` ONLY for the two codes with
* per-refusal coordinates worth counting (which canonical target, against which roots or which deny
* pattern). Designed to be passed straight as `errorResult`'s second argument by every renderer of a
* {@link resolveKey} violation — `undefined` keeps the detail-less shape for all other violation
* codes.
*
* `type` is the WIRE discriminator: the card projector (`createStructuredProjector`,
* tool-output-projection.ts) keys on `details.type`, so without it the card never reaches
* `tool_end.structured` and the "count from fields" promise above holds for in-process readers only.
* `code` carries the same value for consumers that read it.
*/
export declare function violationDetails(v: FsViolation): {
type: "path_not_in_root";
code: "path_not_in_root";
target: string;
roots: readonly string[];
} | {
type: "read_path_denied";
code: "read_path_denied";
target: string;
pattern: string;
} | undefined;
/**
* Does `p` sit inside ANY of the canonical containment roots? Exposed for the Bash
* tool's post-`cd` disclosure note (fs-bash.ts): the note reuses the SAME segment-aware `within`
* the fence itself uses, so the disclosure and the fence can never disagree about what counts as
* "inside". Pure/lexical — callers own any canonicalization of `p` (the tracked cwd stays raw by
* design; fs-bash canonical-confirms before claiming "outside").
*/
export declare function withinAnyRoot(rootsCanonical: readonly string[], p: string): boolean;
/**
* The escape-hatch tail for the `path_not_in_root` refusal: like its three sibling hints below, it
* names a sanctioned next step, so the model does not oscillate between "the boundary is hard" and
* hunting for a way around it.
*
* The card names the SANCTIONED way out and nothing else: widening the declaration
* (`additionalDirectories` / read-only `additionalReadDirectories`), or the deployment-level
* `readFace: "open"` (reads) / `writeFace: "open"` (writes).
*
* It deliberately does NOT name the shell as unconfined by this fence, even though that is a true
* statement rather than a recommendation. A live retest settled the question: a refused agent read
* such a sentence as the next step, went to the shell, and rewrote through it the very file the fence
* had just protected. A refusal card is a PROMPT surface — naming, at the moment of denial, a second
* tool that does not enforce the boundary is a recipe whatever its grammar, and the model cannot be
* relied on to hear "fact, not advice". The underlying fact is documented where humans read it (the
* shell is deliberately unconfined by this fence, and every shell call still passes the deployment's
* approval policy); it is simply not handed to the model inside a denial.
*/
export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s), ask for the directory to be added to the deployment's additionalDirectories \u2014 or additionalReadDirectories for read-only access; a deployment that wants reads or writes open everywhere can declare readFace / writeFace: \"open\" instead of listing directories.)";
/** inv 1 (read-before-edit): a file must have been read this task before it can be edited/overwritten.
* Message is CC 2.1.198 live-verbatim (all-tools-live-probe 2026-07-08 §2.1/§3.1/§5.1 — one message for
* Edit/Write/NotebookEdit: "before writing to it", not the old sema "before editing").
*
* CC parity (Edit and Write spell the read gate identically, `!p || p.isPartialView`): an
* AUTO-TRUNCATED read (the model asked for the file by default, no explicit offset/limit, and the token
* cap cut it anyway — {@link ReadEntry.isPartialView}) must NOT satisfy this gate. With `state.has(key)`
* alone as the whole check, the model could Write (full overwrite) or Edit a file having genuinely seen
* only its first page, silently destroying or blind-editing the unseen remainder. CC's own gate treats
* "never read" and "auto-truncated
* partial view" identically for BOTH tools — no softer rule for Edit (whose old_string match only touches
* a small snippet) versus Write (which replaces everything), so this doesn't carve one out either.
*
* Deliberately checks `isPartialView`, NOT the broader {@link ReadEntry.truncated} (which also fires for a
* fully-intentional, successful EXPLICIT offset/limit slice read) — CC's own `isPartialView` is scoped the
* same narrow way (only the auto-pagination case, never an explicit range that fit), and gating on the
* broader flag would make {@link OVERSIZE_READ_ESCAPE_HINT}'s documented promise false: a model deliberately
* paging through an oversized file via offset/limit would never be able to satisfy this gate again. */
export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
/**
* Escape-hatch parenthetical appended to the read-before-edit refusal when the target file is over
* the Read tool's whole-file byte cap: a bare "read it first" would be un-followable there (a default
* whole-file Read refuses such files → refusal loop). Same parenthetical pattern as the Write tool's
* encoding-deadlock hint; the CC-verbatim sentence stays untouched for the normal-size case.
*/
export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
/**
* The escape-hatch parenthetical for the OTHER shape of the read-before-edit refusal: the file was read,
* but the read was AUTO-PAGINATED by the output token cap ({@link ReadEntry.isPartialView}), so only the
* first page was ever seen. The oversize branch above has named its escape route since the fidelity
* sweep; this branch named none — and it is the worse of the two, because the refusal's own advice
* ("Read it first") is a FIXPOINT here: the file is UNDER the byte cap, so a default Read succeeds and
* auto-paginates identically, and Read's unchanged-since-last-read dedup then short-circuits before the
* state is rewritten. The gate is `isPartialView`, not the file size, so ANY explicit offset/limit slice
* that fits clears it — stated explicitly because nothing else in the transcript says so.
*/
export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
/**
* The escape-hatch parenthetical for the ONE shape of the read-first deadlock that nothing observable
* diagnoses: a file the Read tool REFUSES (binary /
* BOM-less UTF-16 / unknown encoding) can never satisfy read-before-write, and — unlike the oversize
* and partial-view shapes — leaves no trace the refusal renderer can stat for. It is therefore the
* renderer's FALLBACK hint (least specific, appended only when no sharper branch fired); appending it
* beside a sharper one would assert something false, since both of those shapes are files Read handled.
*
* The deadlock is a property of the READ GATE,
* not of Write — Edit and NotebookEdit hit the identical dead end.
*/
export declare const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
/**
* Edit "no-op" guard (CC FileEditTool parity): refuse an edit whose `old_string`
* equals `new_string` — without it the edit passes the match check and writes the file back unchanged (a
* silent no-op that wastes a turn and dirties mtime). Verbatim CC message so a CC-trained model recognizes it.
*/
export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
/** inv 2 (staleness): the file's current content hash must match what was recorded at read time.
*
* Takes `ReadEntry | undefined` and treats an ABSENT entry as stale (fail-closed). The three
* write-side callers prove the entry exists with `requireRead`, then re-read the file — an `await` — and
* only then reach here, so each was asserting a pre-await invariant across a suspension point with `!`.
* Nothing deletes read state mid-turn today (the compaction sweep runs at a turn boundary), so behaviour
* is unchanged; if that ever stops holding, the caller gets this tool's designed "read it again" refusal
* instead of a TypeError thrown from inside the tool. */
export declare function checkStale(entry: ReadEntry | undefined, currentHash: string): FsViolation | undefined;
/** Count non-overlapping occurrences of `needle` in `haystack` (`needle` must be non-empty). */
export declare function countOccurrences(haystack: string, needle: string): number;
/**
* Suggest a sibling filename for a missing path (CC `File does not exist… Did you mean X?` self-heal
* path, bundle :335883/:478419). Honest scope (1.253 双轨终审): this is the FALLBACK tier — an
* approximation of CC's Cxe same-directory scan plus a sema-added case-insensitive exact-name match
* (the case-typo class CC misses). Registered delta: CC's FIRST-tier suggester is a GATED
* sibling-tree REBASE (`join(cwd, relative(dirname(cwd), missing))`, only for a path under the cwd's
* parent but not under the cwd), NOT the "re-resolve the filename against cwd" this comment used to
* claim; the full geometry and the sema delta are recorded at the call site (fs-shared.ts
* `enoentMessage`), which is where the env probing lives. This function stays pure over a listed
* sibling set and corresponds to CC's SECOND tier (`Yrt`/findSimilarFile). (a) CC's findSimilarFile
* rule — same stem, different extension/name in the same directory; (b) the case-insensitive
* exact-name match (sema addition). Returns the suggested NAME (not a full path), or undefined.
*/
export declare function similarNameSuggestion(siblingNames: readonly string[], missingName: string): string | undefined;
/** Replace curly single/double quotes with their straight ASCII forms (CC qDa, 1:1 char mapping). */
export declare function normalizeQuotes(s: string): string;
/**
* Resolve `oldString` against `content` with the CC quote-forgiveness layer: exact match first; on a
* miss, match in the curly→straight normalized coordinate and return the ACTUAL file substring at
* that span (CC kOe). Returns undefined when even the normalized form does not appear.
*/
export declare function resolveQuoteMatch(content: string, oldString: string): string | undefined;
/**
* CC's forgiving-match layers 3+4: resolve `oldString` against `content` across the
* escape/literal spelling boundary. Returns the ACTUAL span present in `content` (which is what must be
* replaced), or `undefined` when neither spelling is present. Pure — call only after an exact match and
* the curly-quote layer have both missed.
*/
export declare function resolveEscapeMatch(content: string, oldString: string): string | undefined;
/**
* CC `YZu` verbatim: rewrite `newString` into the SAME spelling the matched
* span uses. Without this, a match found through {@link resolveEscapeMatch} would replace an escaped
* span with a literal-character replacement (or vice versa) and silently change the file's encoding
* convention at that spot. Hex case follows what the file itself used, per-character where known and by
* majority vote otherwise — CC's own rule, and the reason it counts upper/lower while walking the span.
*/
export declare function adaptNewStringEscapes(oldString: string, matchedOld: string, newString: string): string;
/** CC `KZu` (@366558): the old_string shapes for which the escape layers above were even attempted —
* gates the explanatory note appended to a no-match refusal so it is never a false claim. */
export declare function escapeMatchWasAttempted(oldString: string): boolean;
/** CC `KZu`'s note, verbatim. */
export declare const ESCAPE_MATCH_MISS_NOTE = "\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)";
/**
* When the quote-forgiving match resolved to a curly-quoted file span, convert the straight quotes in
* `newString` to the matching curly forms so the replacement stays style-consistent with the file
* (CC Dmt/yPp/TPp: openers vs closers by preceding char; a letter-adjacent single quote is an
* apostrophe U+2019). Call ONLY when the matched span differs from the model's old_string.
*/
export declare function adaptNewStringQuotes(matchedOld: string, newString: string): string;
/**
* The effective old_string a DELETION should replace: `oldString + "\n"` when new_string is empty,
* old_string doesn't already end with a newline, and the newline-suffixed form exists in the content;
* otherwise old_string unchanged (CC KDa parity). SINGLE (non-replace_all) edits only: a widened
* needle under replace_all would miss occurrences not followed by a newline and desync the
* replacement count — the Edit tool restricts the widening to single edits (1.253 双轨终审 MED).
*/
export declare function deletionOldString(content: string, oldString: string, newString: string): string;
/**
* inv 3 (edit uniqueness): `oldString` must match exactly once unless `replaceAll`. Zero → not found;
* >1 without replaceAll → ambiguous. `truncated` adds a hint that unseen content may hold more matches (S2).
* Messages are CC 2.1.198 live-verbatim (all-tools-live-probe 2026-07-08 §2.1): first sentence(s) byte-exact,
* then a `String: <needle>` echo line (CC form). The sema truncated-read hint survives as a parenthetical
* appended to the sentence (probe verdict: "truncated 提示可并入括注保留").
*/
/** How much of the needle a failed-match refusal spells back. The echo identifies WHICH string was
* searched for; the caller still holds the whole thing in the tool call above, so a prefix does the job
* a full copy was doing at any length the caller chose. Both arms use it — the not-found arm is the more
* common failure, so bounding only the multi-match arm would leave the stated bound defeated where it
* matters most (and these refusals return as tool results, which do not pass the loop-level budget). */
export declare const EDIT_ECHO_MAX_CHARS = 200;
export declare function checkEditMatch(content: string, oldString: string, replaceAll: boolean, truncated: boolean): FsViolation | undefined;
export {};