/** * Safe file access — the one primitive every faf writer, and every read of * project context, goes through. It is also the only module in faf that calls * a filesystem write, rename, delete, mkdir or chmod API (tests/write-guard * enforces that for all of src/). * * Rule 1 — stay inside the project. Before faf touches a path it resolves it on * disk (realpath: every link followed). If that leads outside the project * folder, faf refuses. A dangling link is refused — faf never creates a file at * the end of a link. Anything whose real path is inside a `.git` folder is * refused, always — with two narrow exceptions: faf's own git hook * (`allowGitHooks`: one file directly inside the repo's hooks folder), and * the repo's own git config file (`allowGitConfig`: the file `config` * directly in the folder git names for it), where `faf diff * --uninstall-driver` removes the section faf's install wrote. * * Rule 2 — a link leads to the same kind of file. faf follows a link only to a * file with the same name (CLAUDE.md → docs/CLAUDE.md), or from one AI context * file to another (CLAUDE.md → AGENTS.md; the set is FAF_CONTEXT_FILES). The * link itself survives: faf writes the file it points at. `CLAUDE.md → * README.md` or `project.html → package.json` is refused — faf would be * writing a file it was never asked to write. A read of project context * through a link must land on a .faf or .fafm file instead, so `project.faf → * .env` is refused even though .env is in the project — or, for a read of an * AI context file, on another AI context file (`faf recover` reads CLAUDE.md → * AGENTS.md), the rule the writers use. * * Rule 3 — never leave a half-written file. A write goes to a temp file in the * same folder, is flushed to disk (fsync), then renamed over the original in * one step, keeping the original's permissions. If any step fails the temp * file is removed and the original is exactly as it was: "not written; * original kept". A plain writeFileSync truncates first, so a full disk, a * quota or a killed process used to leave the user's file cut short. With * `expect` (the bytes the caller read), the file is read again just before the * rename and the write is refused if it changed in the meantime. Its mode is * checked too: a file made read-only, or given other permissions, while faf * was writing is not replaced. * * Rule 4 — text faf edits is UTF-8. readUtf8 decodes strictly: a UTF-16 file or * any bytes that are not UTF-8 are refused, never turned into U+FFFD and * written back. * * Rule 5 — a whole file faf renders replaces a file already there only when * faf can prove it wrote it: project.html, the cards, `faf server-card --out`, * a `faf taf --output` snapshot and a `faf decompile --output` file must be * byte for byte what faf last wrote (their render hash, render-hash.ts); a * `.fafb` must carry the FAFB header (see {@link safeReplaceOwned}). Anything * else is refused unless the caller passes `force` (the CLI's `--force`). * * Rule 6 — detection reads stay inside the project. The files faf reads to fill * slots and render context (README.md, package.json, pyproject.toml, * Cargo.toml, go.mod, …) go through {@link repoFile}: resolved on disk, every * link followed. A file whose real path, or the folder it sits in, leaves the * project folder, runs through `.git`, or is a dangling link is absent to * detection: it does not exist, and reading it gives nothing. A link that * stays inside the project is followed (README.md → docs/README.md). */ import { type Dirent, type Stats } from 'fs'; /** Why a path was refused. */ export type SafePathReason = 'outside' | 'dangling' | 'not-a-file' | 'not-faf' | 'other-file' | 'git' | 'not-utf8' | 'changed' | 'not-owned' | 'not-yaml' | 'unplaceable'; /** * A path faf will not read or write, or a file faf will not change. Nothing * was written; the file on disk is exactly as it was. * - `outside`, `dangling`, `not-a-file`, `not-faf`, `other-file`, `git`: the * path is refused (see {@link resolveInside}). * - `not-utf8`: the file is not UTF-8 (see {@link readUtf8}). * - `changed`: the file changed on disk after faf read it — its bytes, or its * mode (see the `expect` option of {@link safeWriteFile}). * - `not-owned`: a whole file faf renders is already there and faf cannot * prove it wrote every byte of it — it has no faf mark, or it was edited * since faf wrote it, or it is from before faf recorded a render hash (see * {@link safeReplaceOwned} and render-hash.ts). * - `not-yaml`: a .faf faf reads or edits is not valid YAML: " is not * valid YAML (, line N) — faf left it unchanged"; or it parses to * a scalar or a list, not a mapping; or faf's scoring kernel cannot read * it (`faf score`, `faf compile`, `faf refresh`). * - `unplaceable`: faf could not place its managed block where its next run * finds it again, so it wrote nothing (see inject.ts). * * `onWrite` is true when faf refused at the write itself — it may have read * the file before (a `.faf` read through a link, say) — and false when it * refused before reading or writing anything. */ export declare class SafePathError extends Error { readonly reason: SafePathReason; /** The path as the caller named it (absolute). */ readonly path: string; /** True when the refusal came at the write (faf may have read the file first). */ readonly onWrite: boolean; constructor(reason: SafePathReason, path: string, message: string, opts?: { onWrite?: boolean; cause?: unknown; }); } export interface ResolveInsideOptions { /** Resolving for a read of project context: a link must end at a `.faf` or * `.fafm` file — or, when the name is an AI context file (CLAUDE.md), at * another AI context file (CLAUDE.md → AGENTS.md), the rule the writers * use. A plain file is read under the name the caller gave. */ read?: boolean; /** * Resolving faf's own git hook (`faf hooks`) — one of the two exceptions * to the `.git` rule. `dir` must be the repo's hooks folder as git names it * (`git rev-parse --git-path hooks`, a folder named `hooks`), and `name` a * hook file sitting directly in it (`pre-commit`). Only that file may be * inside `.git`; nothing below or beside it. Links are still checked: a * link must stay inside the hooks folder's real folder and end at a file of * the same name. */ allowGitHooks?: boolean; /** * Resolving the repo's own git config file (`faf diff --uninstall-driver`) * — the other exception to the `.git` rule. `dir` must be the folder git * names for it (the folder of `git rev-parse --git-path config`), and `name` * the file `config` directly in it. Only that file may be inside `.git`; * nothing below or beside it. A link must stay inside that folder and end * at a file named `config`. */ allowGitConfig?: boolean; } export interface SafeWriteOptions { /** The project folder the write must stay inside. Default: the file's own folder. */ root?: string; /** * The bytes the caller read from the file. Just before the rename the file * is read again; when it no longer equals `expect`, the temp file is removed * and a SafePathError (`changed`) is thrown: " changed on disk while * faf was writing — not written; original kept". A string is compared as its * UTF-8 bytes. `null` means the caller found no file there: if one has * appeared, the write is refused the same way (a missing file stays * missing). Omit it to write without the byte check (the mode check below * still runs). */ expect?: string | Uint8Array | null; /** Write faf's own git hook: see {@link ResolveInsideOptions.allowGitHooks}. `root` is the hooks folder. */ allowGitHooks?: boolean; /** Write the repo's own git config file: see {@link ResolveInsideOptions.allowGitConfig}. `root` is its folder. */ allowGitConfig?: boolean; /** Permission bits for the written file. Default: the original's (a new * file gets the default create mode). */ mode?: number; } /** * The files faf's block injector writes — one managed block, every other byte * of the file kept. Each injector writer (writeClaudeMd, writeAgentsMd, * writeGeminiMd, writeCursorrules, writeCopilotInstructions, writeMemoryMd, * writeLlmsTxt, writeClaudeMemory) takes its file name from this table, so * the set is exactly the files those writers target. A link from one of these * names to another (CLAUDE.md → AGENTS.md) may be followed. */ export declare const FAF_CONTEXT_FILES: Readonly<{ readonly claude: "CLAUDE.md"; readonly agents: "AGENTS.md"; readonly gemini: "GEMINI.md"; readonly cursorrules: ".cursorrules"; readonly copilot: "copilot-instructions.md"; readonly memory: "MEMORY.md"; readonly llms: "llms.txt"; }>; /** * Resolve `name` inside the project folder `dir` and return the real path to * read or write — or throw a SafePathError. * * - the folder the file sits in must resolve to `dir` or below it * - a path whose real form runs through `.git` → refused, always (the two * exceptions: `allowGitHooks`, a hook file directly in the hooks folder, * and `allowGitConfig`, the file `config` directly in its git folder) * - a file that does not exist yet → its path inside the project * - a regular file → its path (spelled as on disk) * - a link → the file it points at, when that exists, is a regular file, is * inside the project and out of `.git`, and has the link's own name (or * both names are AI context files: CLAUDE.md → AGENTS.md). With `read`, the * file must be a .faf/.fafm file instead (`project.faf → config/team.faf`), * or — for an AI context file — another AI context file. * - anything else (a link out, a dangling link, a link to a file with * another name, a folder, a device) → refused * * `name` may be relative to `dir` or absolute. `dir` and the folder `name` sits * in must exist (their ENOENT is thrown as is). */ export declare function resolveInside(dir: string, name: string, opts?: ResolveInsideOptions): string; /** * Read a text file faf may write back — strictly as UTF-8. A UTF-8 BOM is kept * (U+FEFF at the start of the text, as a plain read gives it). A UTF-16 BOM * (FF FE or FE FF) or any byte sequence that is not UTF-8 is refused with a * SafePathError (`not-utf8`): " is not UTF-8 — faf left it unchanged". A * lenient read would turn those bytes into U+FFFD, and the next write would * lose them. Other read errors (ENOENT, EACCES, …) are thrown as they are. * `path` is read as given: resolve it with {@link resolveInside} first. */ export declare function readUtf8(path: string): string; /** The bytes at `path`, or null when nothing is there (only ENOENT). */ export declare function readBytesIfPresent(path: string): Buffer | null; /** * Where detection may read `rel` in the project folder `dir` — its real path — * or null when detection treats it as absent (Rule 6). `rel` names a file or a * folder, relative to `dir` (as `join(dir, rel)`). Every link on the way is * followed, the folders included; the result is null when: * - nothing is there, or a link on the way dangles or loops * - the real path leaves `dir` (README.md → ~/.aws/credentials, or a folder * on the way that is a link out) * - the real path runs through `.git` (README.md → .git/config) * A link that stays inside `dir` is followed. `dir` is the folder detection * was handed: the project, or a folder below it that detection reads on its * own (a subfolder's manifest), so a link may not leave that folder either. */ export declare function repoFile(dir: string, rel: string): string | null; /** * Run `scan` with `root` as the project folder for every detection read under * it: a read in a subfolder (web/package.json) may then follow a link to * anywhere inside `root` (../shared/web-package.json), not only inside that * subfolder. A link out of `root` is still absent. Subfolder scans use this. */ export declare function withRepoRoot(root: string, scan: () => T): T; /** True when `rel` (a file or a folder) is in the project folder `dir` for * detection: see {@link repoFile}. */ export declare function repoExists(dir: string, rel: string): boolean; /** The text of the file `rel` in the project folder `dir` (UTF-8, read the way * detection reads it), or null when detection treats it as absent (see * {@link repoFile}), it is not a regular file, or it cannot be read. */ export declare function readRepoFile(dir: string, rel: string): string | null; /** What `rel` is (its real path's stats) in the project folder `dir`, or null * when detection treats it as absent (see {@link repoFile}). */ export declare function statRepoFile(dir: string, rel: string): Stats | null; /** * The entries of the folder `rel` (default: `dir` itself) in the project * folder `dir`, or null when detection treats the folder as absent (see * {@link repoFile}) or it cannot be read. A link among them that leaves `dir`, * dangles, or leads into `.git` is left out: detection does not see it. Each * entry says what it is itself (a link is a link, as readdirSync gives it). */ export declare function readRepoDir(dir: string, rel?: string): Dirent[] | null; /** * A write that failed partway — a full disk, a quota, a read-only file, a * killed rename — with the file on disk exactly as it was: ": not * written; original kept ()" ("not written" alone when there was no file). * `cause` is the error underneath. The CLI prints it as one line. */ export declare class NotWrittenError extends Error { /** The file that was not written. */ readonly path: string; constructor(path: string, message: string, opts?: { cause?: unknown; }); } /** * Write a file inside a project, safely: resolved with {@link resolveInside} * (a link that leaves the project, dangles, or leads to a file with another * name, and anything in `.git`, is refused), then replaced atomically (temp * file in the same folder, fsync, rename; the original's permissions — and, * where the OS allows, its owner — kept). On any failure the original is * untouched and the Error says "not written; original kept". With `expect`, * a file that changed after the caller read it is not replaced either * (SafePathError `changed`); nor is one whose mode changed, or that became * read-only, while faf was writing. Returns the real path written — the * link's target when `path` is an in-project link. */ export declare function safeWriteFile(path: string, content: string | Uint8Array, opts?: SafeWriteOptions): string; /** Options for {@link safeReplaceOwned}. */ export interface ReplaceOwnedOptions { /** The project folder the write must stay inside. Default: the file's own folder. */ root?: string; /** True when the bytes already at the path carry faf's own mark (faf wrote them). */ owns: (existing: Buffer) => boolean; /** faf's mark in words, for the refusal: "the `_meta[\"one.faf/context\"]` block". */ mark: string; /** Replace a file without the mark anyway — the explicit overwrite (`--force`). */ force?: boolean; /** The bytes the caller read there earlier (`null`: no file then). When * given, the file must still hold them, else SafePathError `changed`. */ expect?: string | Uint8Array | null; } /** * Write a whole file faf renders (project.html, a Server Card, an A2A card, a * `.fafb`), replacing a file already at `path` only when faf can prove it wrote * it: its bytes carry faf's own mark (`owns`). A file without the mark is * refused — SafePathError `not-owned`: " has no , so faf did not * write it — faf left it unchanged." — and stays byte for byte, unless `force` * asks to replace it. The link rules and the atomic write are * {@link safeWriteFile}'s, and the write is refused if the file changed on * disk after faf read it. Returns the real path written. */ export declare function safeReplaceOwned(path: string, content: string | Uint8Array, opts: ReplaceOwnedOptions): string; /** * Create the folder `dir` inside the project folder `root`, with any missing * folders between them, and return its real path. `root` itself is the * caller's own folder: it is created as named when missing. Below it, faf * never creates a folder through a link: an existing folder on the way that * is a link leading out of `root` (or a dangling link) is refused, as is any * part inside `.git` and anything on the way that is not a folder — so * `.github → ~/elsewhere` cannot make faf create `~/elsewhere/workflows`. */ export declare function makeDirInside(root: string, dir?: string): string; /** * Remove a file faf wrote — only when it still holds exactly `expect` (the * bytes faf wrote or read there) and is a regular file inside `root` (default: * its own folder), out of `.git`. A link is never removed (faf did not make * it), and a file whose bytes changed is left as it is (SafePathError * `changed`). Returns false when nothing was there. */ export declare function safeUnlink(path: string, opts: { root?: string; expect: string | Uint8Array; }): boolean; /** The marker file makeTempDir writes into every temp folder it makes, and * its exact content: `faf clear` removes only a folder that carries it. */ export declare const TEMP_MARKER = ".faf-temp"; /** Make a new temp folder for faf's own use — `/XXXXXX`, a * fresh name no one else can have made (mkdtemp) — write faf's marker file * into it ({@link TEMP_MARKER}), and return its real path. Anything faf puts * there (a clone, say) goes in a subfolder, beside the marker. */ export declare function makeTempDir(prefix: string): string; /** Remove a temp folder {@link makeTempDir} made in this process, with what is * in it. Any other folder is refused (an Error; nothing removed). */ export declare function removeTempDir(dir: string): void; /** * Remove faf's own temp folders left behind by earlier runs (`faf clear`): * only folders faf made — entries of the OS temp folder named exactly as * mkdtemp names them (`prefix` plus six letters or digits), that are real * folders (a link is left alone), belong to this user and carry the marker * file {@link makeTempDir} writes into each one. A folder of yours that only * starts with the prefix (`faf-git-my-notes`) stays. Returns how many were * removed. A folder that cannot be read or removed is skipped. */ export declare function removeStaleTempDirs(prefix: string): number;