/** * File-tree primitives for the skills store: walk, hash, copy, remove — POSIX-relative paths, * sorted, symlinks never followed. * * NO-FOLLOW EVERYWHERE (design v3 §API; codex review of #480). A plugin tree is copied INTO the * daemon's root and later handed to worker spawns, so a link anywhere on a path the store reads or * writes would turn "copy the plugin" into "copy whatever the link reaches" and "write this skill * file" into "write wherever the link points". Therefore: * * - `walkFiles` refuses a symlinked ROOT (the skill dir itself replaced by a link) and skips * symlink entries (never followed, never copied, never hashed); `walkTree` is the variant a * VERIFICATION uses — it ENUMERATES link entries with their link text (still never followed) * so an injected link cannot hide from `current`'s hash (codex round 5); a PRUNED directory * (`SKIP_DIR_NAMES`: `.venv`, `node_modules`, `__pycache__`) is descended for CLASSIFICATION * only — every entry beneath it is reported with its kind and marked `pruned`, and stays out of * every file, hash and copy view (codex round 10: a link under `effective/node_modules/` used * to be invisible to the validators because the walk stopped at the directory); * - `copyFiles` PREFLIGHTS every destination — each `rel` must be a safe relative path (no `..`, * `.`, empty or separator-carrying segment, no absolute/drive prefix, no NUL: * `assertSafeRelSegments`) and symlink-free (`assertNoSymlinkComponents`) — BEFORE it creates a * parent or writes a byte, independent of where the record came from (codex round 4: a * manifest-sourced skill name became a copilot-view path `views/…//SKILL.md`, and a * `../…` name would have joined out of the staging dir). A refused record writes NOTHING — * not the records ahead of it either; * - `writeFileAtomic` refuses a symlink at the target, opens its temp file with `O_EXCL` * (fails on ANY pre-existing entry, a pre-planted symlink included) and `O_NOFOLLOW` under an * unpredictable name, and preserves the target's mode bits (an executable support script * stays executable across an in-place edit or a replace). * * # TOCTOU discipline inside the skills root (design v3.5 §3; codex round 8) * * The lstat walk is necessary, not sufficient: every file open inside the root uses `O_NOFOLLOW` * where the platform has it (`readFileNoFollow`, `copyFileNoFollow`, `writeFileAtomic`'s temp * file), and the lstat-then-open pattern is closed EVERYWHERE — Windows has no `O_NOFOLLOW` — with a * post-open identity check (`fstat` dev/ino against the lstat result: the entry opened IS the entry * the walk judged); copies read their SOURCE through such an open, never by path * (`copyFileSync(path)` follows a source swapped for a link between enumeration and copy); a staged * tree is re-walked (lstat) and re-hashed AFTER the copy and BEFORE the rename into place * (store.ts `stagedTreeProblem`, `captureBaseline`, `publishSerialized`). Residual, documented: a * directory component swapped between the walk and the rename by another writer with access to the * crew-owned state home — mitigated by the single-writer daemon and the post-operation verification, * not eliminated. */ import { type Stats } from 'node:fs'; /** One regular file: `rel` is POSIX-relative to the walk root, `abs` its on-disk path. */ export interface FileRecord { rel: string; abs: string; } /** * Directory names PRUNED from every file, hash and copy view (build caches, vendored deps, the * per-baseline `uv sync` env): nothing beneath one is bundle content — never copied into a baseline * or a generation, never hashed. The walk still DESCENDS them for classification (codex round 10), * so a validator sees what such a directory holds (`TreeEntry.pruned`, `TreeListing.pruned`). */ export declare const SKIP_DIR_NAMES: ReadonlySet; /** File names never copied into the root (Finder noise). */ export declare const SKIP_FILE_NAMES: ReadonlySet; /** A step-wise computation: each `yield` is a point where a driver may let the event loop turn; the return value is the answer. */ export type Steps = Generator; /** Decides, at each step, whether the running slice has spent its budget and the loop must turn. */ export interface Pacer { /** `null` while the slice still has budget (no yield); otherwise a promise that settles once the event loop has turned. */ tick(): Promise | null; } /** The time one synchronous slice may hold the event loop before `paced` lets it turn. */ export declare const PACE_SLICE_MS = 15; /** * A pacer that yields through `setImmediate` once `sliceMs` of wall time passed since the loop * last turned — a FULL loop iteration (timers, I/O poll, check), so pending sockets are served; a * bare `await` would only drain microtasks and let nothing else through. */ export declare function budgetPacer(sliceMs?: number): Pacer; /** A pacer that never yields — `paced` under it behaves exactly like `drain` (tests, and callers that hold no loop). */ export declare const NO_PACE: Pacer; /** Run the steps to completion synchronously. */ export declare function drain(steps: Steps): T; /** Run the steps on the event loop, letting it turn whenever `pacer` says the slice is spent. */ export declare function paced(steps: Steps, pacer: Pacer): Promise; export declare function toPosix(p: string): string; /** A path the store would have to FOLLOW a symlink to reach — refused, never judged lexically. */ export declare class SymlinkComponentError extends Error { readonly path: string; readonly component: string; constructor(path: string, component: string); } /** A relative path whose SHAPE could join out of its root (`..`, an absolute piece, a separator in a * segment, a NUL) — refused before any lstat, whatever produced it. */ export declare class UnsafePathSegmentError extends Error { readonly path: string; readonly reason: string; constructor(path: string, reason: string); } /** * Why ONE path component is not a safe segment, or `null`. A segment may not be empty, `.` or `..`, * may not carry a separator (`/` or `\`), a NUL, or a drive prefix (`C:`). The ONE rule every * joined component in the store goes through (contain.ts, `copyFiles`, the view generator). */ export declare function unsafeSegmentReason(seg: string): string | null; /** * Split a POSIX-relative path into segments, refusing anything that could escape: empty, absolute * (`/…`), drive-prefixed, backslashes, NUL, and a `.` / `..` / empty segment. Throws * `UnsafePathSegmentError`. Lexical only — the caller walks the result for symlinks. */ export declare function assertSafeRelSegments(rel: string): string[]; /** * `join(root, ...segments)` after an lstat walk that refuses a symlink at ANY component — the * leaf included. A component that does not exist yet ends the walk (a not-yet-written leaf, or * its new parent dirs — the write creates them); so does a component whose parent turned out to * be a REGULAR FILE (ENOTDIR — nothing below a file exists, and no link can be crossed through * one; whether the caller may write there is the caller's preflight, `containedDestinations`). * Any other filesystem error propagates: an unreadable component is not judged lexically. `root` * itself is not walked — the caller decides what its root is (the store walks from the SKILLS * root down, so a skill dir replaced by a link is a component, not a root). */ export declare function assertNoSymlinkComponents(root: string, segments: ReadonlyArray): string; /** How the walk classifies one entry — from lstat, never following a link. */ export type EntryKind = 'file' | 'dir' | 'symlink' | 'other'; /** * One entry under the walk root: `rel` POSIX-relative, `kind` from lstat; `target` is a symlink's * link text (never followed). `pruned` is the `rel` of the OUTERMOST `SKIP_DIR_NAMES` directory the * entry sits beneath — such an entry is classified (a validator judges it) but is part of NO file, * hash or copy view — or `null` for an entry of the tree the store carries. The pruned-name * directory itself is a carried `dir` entry (`pruned: null`), reported like any directory. */ export interface TreeEntry { rel: string; abs: string; kind: EntryKind; target?: string; pruned: string | null; } /** * THE walker (design v3 no-follow everywhere; codex round 9 on #480): every entry under `root` — * the root itself excluded — classified by lstat as a regular file, a directory, a symlink or * something else (a socket, fifo, device …), sorted by `rel`, never following a link and never * descending into one. NOTHING is invisible: an empty directory is an entry, a symlink is an entry * with its link text, a special node is an entry of kind `other`. A directory named in * `SKIP_DIR_NAMES` (`.venv`, `node_modules`, `__pycache__`) is reported AND descended (codex round * 10): every entry beneath it is classified like any other and marked `pruned` with that * directory's `rel`, so a validator SEES a link or a special node planted there — while the file, * hash and copy views (`walkFiles`, `walkTree`'s `files` / `links` / `dirs` / `others`, and * therefore `hashTree` and `copyFiles`) exclude the whole subtree exactly as before: nothing under * a pruned directory is ever copied or hashed. `skipDir(rel)` is the caller's CLOSURE prune (the * bundle walk drops `scripts/` dev tooling with it): content outside the closure is not reported * and not descended — it is never reached, so never copied either. Every other view derives from * this one. A missing `root` yields `[]` — callers treat optional plugin dirs (`schemas/`) as * empty, not as errors; a `root` that IS a symlink is refused (`readdirSync` would follow it, and * "list this skill's own files" must never enumerate a tree outside the store). */ export declare function walkEntries(root: string, skipDir?: (rel: string) => boolean): TreeEntry[]; /** `walkEntries` as steps — one `yield` per entry classified (crew#852). */ export declare function walkEntriesSteps(root: string, skipDir?: (rel: string) => boolean): Steps; /** * Whether a walk entry is a regular file the store CARRIES — copies, hashes, records: a regular * file outside every pruned directory that is not Finder noise (`SKIP_FILE_NAMES`). */ export declare function isCarriedFile(e: TreeEntry): boolean; /** * Every regular file under `root`, sorted by `rel` — `walkEntries` filtered to the files the store * carries (`isCarriedFile`: nothing beneath a pruned directory, no Finder noise). `skipDir(rel)` * prunes a subtree; `SKIP_DIR_NAMES` / `SKIP_FILE_NAMES` always apply. */ export declare function walkFiles(root: string, skipDir?: (rel: string) => boolean): FileRecord[]; /** One symlink entry: `rel` POSIX-relative to the walk root, `target` its link text as read (never followed). */ export interface LinkRecord { rel: string; abs: string; target: string; } /** * A whole tree as a verification must see it: the carried files, every symlink (with its link * text), every DIRECTORY (empty ones included — codex round 9: an extra empty directory used to be * invisible to the snapshot hash and to the copilot-view check) and every special node. */ export interface TreeListing { files: FileRecord[]; links: LinkRecord[]; /** Every carried directory entry's `rel`, sorted — a pruned-name directory itself included (it is an entry of the tree), nothing beneath one. */ dirs: string[]; /** Carried entries that are neither a file, a directory nor a symlink (sockets, fifos, devices …). */ others: TreeEntry[]; /** * Every entry BENEATH a pruned-name directory, with its kind (codex round 10) — classified for a * validator to judge, part of no file, link, directory or hash view above. `TreeEntry.pruned` * names the pruned directory each sits under. */ pruned: TreeEntry[]; } /** * `walkEntries` split into what a verification consumes: files (Finder noise excluded), links * (listed with their text, never followed or descended — codex round 5: a verification that skips * links leaves an injected outside-pointing link invisible to the hash), directories and special * nodes — each of these OUTSIDE every pruned directory — plus `pruned`: what the pruned * directories hold. `SKIP_DIR_NAMES` (`.venv` / `node_modules` / `__pycache__`) keeps the * subtree out of `files` / `links` / `dirs` / `others` (so out of every hash and copy) exactly as * before, but the subtree is now WALKED and reported under `pruned` (codex round 10); the * pruned-name directory entry itself is in `dirs`; a LINK bearing one of those names is listed like * any link (the snapshot's `.venv` link is exactly that). `skipDir(rel)` is the caller's closure * prune (the bundle walk drops the `scripts/` dev tooling with it — content there is never reached, * and never copied either). */ export declare function walkTree(root: string, skipDir?: (rel: string) => boolean): TreeListing; /** `walkTree` as steps (crew#852) — the walk's yields. */ export declare function walkTreeSteps(root: string, skipDir?: (rel: string) => boolean): Steps; /** * The lstat FINGERPRINT of a tree (DES-L6 PR-L6-1 (c), crew#547): sha256 over every entry `walkEntries` * classifies — pruned subtrees included, so a node planted under one moves it — as * `rel · kind · size · mtimeMs · ctimeMs · mode · linkText`. It is a CHANGE DETECTOR, never an * integrity claim: `verifyCurrent` re-hashes the bytes on the first read and again whenever this * moved; an equal fingerprint lets it answer its last VERIFIED result. `ctime` cannot be set by * user tools (`touch -r` sets mtime only) and a published generation is locked read-only, so an * edit needs a `chmod +w` that moves ctime and mode; a root-forged ctime is the stated gap. * Answers `null` when an entry vanished between the readdir and its lstat — the caller treats that * as "moved" and takes the full path. */ export declare function fingerprintTree(root: string, skipDir?: (rel: string) => boolean): string | null; /** `fingerprintTree` as steps (crew#852) — the walk's yields, then one per entry lstat'ed. */ export declare function fingerprintTreeSteps(root: string, skipDir?: (rel: string) => boolean): Steps; /** Every proper ancestor directory a set of POSIX-relative file paths implies (`a/b/c.md` → `a`, `a/b`), sorted. */ export declare function impliedDirs(rels: Iterable): string[]; export declare function sha256Hex(data: Buffer | string): string; /** The leaf changed identity between the lstat and the open (a link swapped in, a file replaced) — refused, never read or copied (v3.5 §3). */ export declare class EntrySwappedError extends Error { readonly path: string; constructor(path: string); } /** * Open the REGULAR FILE at `path` for reading without following a symlink at the leaf (`O_NOFOLLOW` * where available), and prove the opened descriptor IS the entry lstat saw (dev/ino equal, a regular * file) — the Windows close of the lstat-then-open gap, applied everywhere (v3.5 §3). Throws * `SymlinkComponentError` for a link at the leaf, `EntrySwappedError` when the identity moved, and * the filesystem's own error otherwise (ENOENT propagates). Answers the descriptor and its stats. */ export declare function openRegularNoFollow(path: string): { fd: number; stat: Stats; }; /** The bytes of the regular file at `path`, read through an `O_NOFOLLOW` open with the identity check (`openRegularNoFollow`). */ export declare function readFileNoFollow(path: string): Buffer; /** crew#1021: background probes read bytes asynchronously, with the same no-follow identity check. */ export declare function readFileNoFollowAsync(path: string): Promise; /** crew#1021: bounded parallel reads, folded in sync hash order, keep large probes responsive. */ export declare function hashFileSetAsync(files: ReadonlyArray): Promise; /** * Copy one regular file `src` → `dest`: the source is read through `openRegularNoFollow` (never by * path — a source swapped for a link between enumeration and copy is refused), the destination is * created `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` with the source's mode bits (an executable * baseline script lands executable), so a pre-planted destination entry — a link included — fails * the open instead of being written through. */ export declare function copyFileNoFollow(src: string, dest: string): void; /** * One hash over a file set: sorted relative paths + content digests (`rel \0 sha256(content) \n` * per file). Two trees with the same relative layout and bytes hash equal wherever they live — * which is what lets a baseline snapshot and the effective copy be compared by hash alone. */ export declare function hashFileSet(files: ReadonlyArray): string; /** * One hash over a file set, its symlink entries AND its directory entries: the files as * `hashFileSet` spells them, then every link as `rel \0 -> \0 \n` (sorted), then every * directory as `rel \0 dir \n` (sorted; codex round 9 — an extra EMPTY directory planted in a * generation used to leave the hash unchanged). With no links and no directories this IS * `hashFileSet`, so a bundle's identity hashes as before; a link added, removed or re-pointed, or a * directory added or removed, changes the hash — which is what lets `current` verification refuse * an injected link or directory (codex rounds 5, 9). Only `rel`s, digests and link TEXT enter the * hash, never what a link reaches: two trees with the same layout, bytes and link texts hash equal * wherever they live. Publish hashes the directories its file set IMPLIES (`impliedDirs`); a * verification hashes the directories it WALKED — equal iff the generation carries no directory * beyond those. */ export declare function hashTree(files: ReadonlyArray, links: ReadonlyArray>, dirs?: ReadonlyArray): string; /** `hashTree` as steps (crew#852) — one `yield` per file read and digested. */ export declare function hashTreeSteps(files: ReadonlyArray, links: ReadonlyArray>, dirs?: ReadonlyArray): Steps; /** * Copy every record to `destRoot/`, creating parents. EVERY destination is preflighted * first — the shape (`assertSafeRelSegments`: a `..`, an absolute or drive-prefixed piece, a * separator inside a segment, a NUL is refused, whatever produced the record) and the walk * (`assertNoSymlinkComponents`: a pre-planted link under the destination would redirect the * copy) — and only then does the first byte move, so a refused record leaves NOTHING written * (codex round 4: the per-record check used to run only when the record was reached, after the * ones ahead of it had landed). Each file goes through `copyFileNoFollow` (v3.5 §3): the source is * read through an `O_NOFOLLOW` open with the identity check — a source swapped for a symlink between * enumeration and copy is refused — and the destination is created `O_EXCL | O_NOFOLLOW` with the * source's mode bits, so an executable baseline script lands executable. */ export declare function copyFiles(files: ReadonlyArray, destRoot: string): void; /** `copyFiles` as steps (crew#852) — the whole preflight first (nothing written before every record passed), then one `yield` per file copied. */ export declare function copyFilesSteps(files: ReadonlyArray, destRoot: string): Steps; export interface AtomicWriteOptions { /** Mode bits for a NEW file; an existing regular file's mode is preserved regardless. */ mode?: number; } /** * Atomic write (tmp + rename), creating parents — the settings-store discipline, hardened: * * - the target is lstat'ed: a symlink there is refused (the rename would replace the link, * but the caller's containment already forbids links, and a write "over" one is never what * the file manager meant); * - the temp file is opened `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW`: POSIX fails that open on ANY existing * entry — a symlink included, regardless of where it points — so a pre-planted temp symlink * cannot redirect the bytes; the name carries 64 random bits so it cannot be pre-planted by * guessing either; * - the target's mode bits survive (an in-place edit of an executable support script keeps * it executable); a new file takes `opts.mode` or the platform default. */ export declare function writeFileAtomic(path: string, content: string | Buffer, opts?: AtomicWriteOptions): void; /** * Remove the records, then prune directories left empty between each file and `upTo` * (exclusive). Never removes a non-empty directory — a nested skill living under a disabled * parent's directory stays exactly where it is. */ export declare function removeFiles(files: ReadonlyArray, upTo: string): void; /** Remove `dir` and its ancestors while they are empty, stopping at `upTo` (which is kept). */ export declare function pruneEmptyDirs(dir: string, upTo: string): void; /** * Strip the write bits from every directory and file under `root` — the shared per-baseline * `.venv` is read-only by contract (design v3 §4): a worker's `uv run` from a snapshot must not * install into, or remove from, an env every other snapshot links; a published snapshot is locked * the same way (immutable by contract, verified by hash). Files first, then directories (deepest * first), so the walk never has to re-open a directory it already closed. A permission failure * PROPAGATES (codex round 2 on #480: "silently swallows permission failures while claiming to * lock") — a tree this cannot lock is not locked, and the caller decides what that means (the * store treats an unlockable env as a failed provisioning, blocking the publish). Only an entry * that vanished mid-walk (ENOENT) is tolerated. A no-op on platforms without POSIX modes. */ export declare function makeTreeReadOnly(root: string): void; /** `makeTreeReadOnly` as steps (crew#852) — one `yield` per entry chmod'ed. */ export declare function makeTreeReadOnlySteps(root: string): Steps; /** * `rmSync(root, { recursive: true, force: true })` that also removes a tree `makeTreeReadOnly` * locked — OR a partial env whose lock FAILED, which can leave an UNLISTABLE directory (a `uv sync` * that produced a `0o000`/`0o111` dir the lock choked on; codex round 3). Each directory has its * owner `rwx` restored TOP-DOWN, before it is enumerated, so an unlistable dir becomes traversable * and removable (unlinking an entry needs a writable, listable parent, whatever the entry's own * mode). A missing root is a no-op. */ export declare function removeTreeForce(root: string): void; /** * `removeTreeForce` off the event loop (crew#852): the top-down permission restore runs as paced * steps (one per directory), and the removal itself goes through `fs.promises.rm`, whose * recursive walk is asynchronous entry by entry. Same outcome, same tolerance for a missing root. */ export declare function removeTreeForcePaced(root: string, pacer: Pacer): Promise; /** Unlink one path without following it (a link is removed, its target untouched). */ export declare function unlinkNoFollow(path: string): void; //# sourceMappingURL=tree.d.ts.map