/** * gitvault — snapshot + hardened git execution (protocol rev 41 §6.6; task 5.5). * * Snapshot policy: clean tree → `HEAD`; dirty → REFUSED BY DEFAULT * (`SNAPSHOT_DIRTY_TREE`, before any object is created) unless * `allowDirty: true`, in which case a synthetic commit of tracked + * untracked-not-ignored paths is captured (parented on HEAD; parentless on * an unborn HEAD) and the result discloses exactly what got swept in * (`modified_captured` / `untracked_captured`). "Dirty" means any modified * or staged tracked path, or any untracked-not-ignored path — a project * whose build step writes gitignored output is never dirty on that account. * Unmerged index → `SNAPSHOT_CONFLICTED_INDEX` (checked first — allowDirty * does not touch it); detached HEAD is representable; linked worktrees, bare * layouts, sparse checkouts, shallow clones, submodules, SHA-256 * repositories, alternates/grafts, and active filters on any captured path * are REFUSED BY NAME before any object is created. * * Ignore authority is FROZEN and read AS DATA: repository `.gitignore`, * `.git/info/exclude`, and the user's global excludes file. The global path * is discovered deterministically (round-6 M11) by parsing * `$XDG_CONFIG_HOME/git/config` then `~/.gitconfig` with a frozen config * subset (last value wins, `~/`/`$HOME` expansion only; `include`/`includeIf` * or an unreadable config/excludes file → refuse by name). git never sees the * user's config: the discovered file is handed to `git ls-files` as an * `--exclude-from` pattern source, so git's matcher runs over OUR discovery. * * Object creation NEVER invokes filters for any path (tracked or untracked): * bytes are hashed with `git hash-object --no-filters`; every included path is * first checked with `git check-attr filter`, and any configured filter (LFS * included) refuses the capture by name — no clean/smudge/process command is * ever started (the acceptance test asserts a sentinel the filter would write * never appears). * * The capture also records the CAPTURED SET (`snapshot.captured`) and a digest * over it (`snapshot.captured_digest`). The deploy lane re-derives that digest * after artifacts are collected and before a plan commits, and refuses * `SNAPSHOT_MOVED_DURING_DEPLOY` on any difference. The set is tracked plus * untracked-but-not-ignored — so a build rewriting gitignored output changes * nothing, deliberately. * * Every git invocation is argv-only (no shell), with `GIT_*` cleared, * `GIT_CONFIG_NOSYSTEM`, `GIT_CONFIG_GLOBAL=/dev/null` (no user config), an * empty `core.hooksPath`, `--no-replace-objects`, `core.fsmonitor=false`, and * no alternates. Repository-config `include`/`includeIf` is refused by name * (git applies it before any `-c`; there is no override switch). */ import type { GitvaultHeadTarget } from "../namespaces/gitvault.types.js"; /** `MAX_GIT_OBJECT_BYTES` — any single object above this is `GIT_OBJECT_TOO_LARGE`. */ export declare const GITVAULT_MAX_GIT_OBJECT_BYTES: number; /** `refs/run402/deploys/latest` — the single protocol-owned deploy ref (round-3 H9). */ export declare const GITVAULT_DEPLOY_REF = "refs/run402/deploys/latest"; /** Probed git version range: `[2.32, ∞)` — `GIT_CONFIG_GLOBAL` landed in 2.32. */ export declare const GITVAULT_MIN_GIT_VERSION: readonly [2, 32]; export interface HardenedGitOptions { /** Bytes written to stdin (closed immediately when omitted). */ input?: Uint8Array | string; /** Extra environment members — only `GIT_INDEX_FILE` and the author/committer identity are ever passed here. */ env?: Record; /** Accept these non-zero exit statuses instead of throwing. */ okStatuses?: number[]; /** Max stdout bytes (packs are large). Default 1 GiB. */ maxBuffer?: number; } export interface HardenedGitResult { status: number; stdout: Buffer; stderr: string; text(): string; lines(): string[]; /** NUL-separated output → entries (trailing NUL dropped). */ nul(): string[]; } /** The environment every gitvault git process runs under — nothing inherited except PATH. */ export declare function hardenedGitEnv(extra?: Record): Record; /** * The `RUN402_*` environment this process was configured with — for the ONE * gitvault git invocation that touches the network (`cloneGitvaultRemote`): * that clone spawns the `git-remote-kygit` / `git-remote-run402` helper, * which must authenticate as THIS session's wallet and config dir. Under the * bare {@link hardenedGitEnv} the helper saw no `RUN402_CONFIG_DIR` / * `RUN402_WALLET` / `RUN402_API_BASE` and fell back to the machine's default * config — a different wallet, not a member of the vault's org — so a * `resume` on any machine that selects its identity by env (a laptop with * several agent config dirs, a CI job, a harness with `RUN402_WALLET`) died * `GITVAULT_ACCESS_DENIED` "while resolving the project's gitvault" right * after a successful claim (kygit-handoff live rerun, 2026-09-03). Only * `RUN402_*` keys pass; git's own hardening is untouched. */ export declare function run402PassthroughEnv(source?: NodeJS.ProcessEnv): Record; /** The argv prefix that neutralizes hooks, fsmonitor, replace refs, and filter autodetection for every invocation. */ export declare const HARDENED_GIT_ARGV_PREFIX: () => string[]; /** * Run git with explicit args (no shell) in `cwd` under the hardened env. * Throws `GIT_COMMAND_FAILED` on a non-ok exit. * * Node's `execFile` reports a MISSING `cwd` with the exact same `ENOENT` as a * missing `git` binary, so a caller that forgot to create its working * directory (e.g. `recover`'s `out_dir` before this fix) saw the misleading * `GIT_UNAVAILABLE: git could not be executed` even though git itself was * perfectly installed. Disambiguate BEFORE spawning: a missing `cwd` is its * own distinct, honest refusal, never folded into "git is unavailable." */ export declare function hardenedGit(cwd: string, args: string[], options?: HardenedGitOptions): Promise; /** * The repository a `git-remote-*` helper was invoked for. * * `repo_dir` is what every hardened git call takes as `cwd`. Because * {@link hardenedGitEnv} builds its environment from scratch — `GIT_DIR` is * never inherited — git's discovery starts at that directory, and starting it * AT the git directory makes discovery land on that repository and no other. */ export interface GitInvocationRepo { /** The absolute git directory git named for this invocation. */ git_dir: string; /** Hand this to `hardenedGit` as `cwd`; discovery from here resolves to `git_dir`. */ repo_dir: string; } /** * Resolve — and PROVE — the repository git invoked this helper for. * * git always sets `GIT_DIR` when it runs a remote helper against a * repository: `.git` (relative, cwd already moved to the top level) for * fetch/push, the linked worktree's git dir inside a worktree, `.` in a bare * repository, and the ABSOLUTE path of the not-yet-populated target during * `git clone`. It is unset only for a repository-free invocation such as * `git ls-remote ` run outside any checkout, which needs no repository * at all. * * So `process.cwd()` is NOT the repository: during a clone it is the directory * the user ran clone FROM, which is routinely inside some unrelated repo. A * helper that discovers its repository from cwd therefore writes the vault's * decrypted objects into a repository the user never named — the leak this * function exists to make impossible. It fails closed: no `GIT_DIR`, a * `GIT_DIR` that is not a repository, or a `GIT_DIR` whose own * `--absolute-git-dir` disagrees with the path we were handed, all refuse. * The caller must write nothing after a refusal. */ export declare function resolveGitInvocationRepo(env?: { GIT_DIR?: string | undefined; }, cwd?: string): Promise; /** `git version` parsed as [major, minor]; refuses below {@link GITVAULT_MIN_GIT_VERSION}. */ export declare function probeGitVersion(cwd?: string): Promise<[number, number]>; export interface GitConfigDiscoveryEnv { HOME?: string; XDG_CONFIG_HOME?: string; } /** The frozen config subset: `[section "sub"]` headers + `key = value` lines; quoting per git-config; comments `#`/`;`. */ export interface ParsedGitConfigEntry { section: string; subsection: string | null; key: string; value: string; file: string; } export type GlobalExcludesRefusal = { code: "GITVAULT_CONFIG_INCLUDE_REFUSED"; file: string; key: string; } | { code: "GITVAULT_CONFIG_UNREADABLE"; file: string; } | { code: "GITVAULT_EXCLUDES_PATH_UNSUPPORTED"; file: string; value: string; } | { code: "GITVAULT_EXCLUDES_UNREADABLE"; file: string; }; export interface GlobalExcludesDiscovery { /** The resolved excludes path — `null` when no file exists (an absent DEFAULT path is fine; an absent EXPLICIT `core.excludesFile` is also fine — git ignores it too). */ path: string | null; /** Which config (if any) set `core.excludesFile`. */ source: "core.excludesFile" | "default"; /** The config files that were read (in read order). */ configs_read: string[]; /** The pattern bytes, read as data, when `path` exists. */ patterns: string | null; } /** Parse a git config file as data (frozen subset). Throws by name on `include`/`includeIf`. */ export declare function parseGitConfigAsData(text: string, file: string): ParsedGitConfigEntry[]; /** * Deterministic discovery of the user's global excludes file (M11): * `$XDG_CONFIG_HOME/git/config` (default `~/.config/git/config`) THEN * `~/.gitconfig`, last value wins; default `$XDG_CONFIG_HOME/git/ignore` * falling back to `~/.config/git/ignore`. Every refusal names its file. */ export declare function discoverGlobalExcludes(env?: GitConfigDiscoveryEnv): GlobalExcludesDiscovery; export type GitvaultRepositoryRefusalCode = "GITVAULT_BARE_REPOSITORY" | "GITVAULT_LINKED_WORKTREE" | "GITVAULT_SPARSE_CHECKOUT" | "GITVAULT_SHALLOW_CLONE" | "GITVAULT_SUBMODULES_UNSUPPORTED" | "GIT_OBJECT_FORMAT_UNSUPPORTED" | "GITVAULT_ALTERNATES_UNSUPPORTED" | "GITVAULT_GRAFTS_UNSUPPORTED" | "GITVAULT_CONFIG_INCLUDE_REFUSED" | "GITVAULT_NOT_A_REPOSITORY"; export interface GitvaultRepositoryInspection { top_level: string; git_dir: string; git_common_dir: string; object_format: "sha1"; head: GitvaultHeadTarget & { resolved_oid: string | null; }; unmerged_paths: string[]; } /** Inspect `dir`; every unsupported layout is refused with its named code. */ export declare function inspectRepository(dir: string): Promise; export interface GitvaultSnapshotOptions { /** The work tree (any directory inside it). */ dir: string; /** Discovery environment; defaults to the process's HOME / XDG_CONFIG_HOME. */ env?: GitConfigDiscoveryEnv; /** When set, also move the LOCAL `refs/run402/deploys/latest` to the snapshot (keeps a synthetic commit reachable across `git gc`). Default true. */ update_local_deploy_ref?: boolean; /** Commit message for a synthetic commit. */ message?: string; /** Clock (synthetic commit timestamps). */ now?: () => Date; /** * Capture a dirty tree anyway. Default `false` — a dirty tree (any * modified/staged tracked path, or any untracked-not-ignored path) is * refused with `SNAPSHOT_DIRTY_TREE` before any object is created, in * BOTH capture lanes (manual `repos snapshot` and the deploy-lane * capture). Has no effect on a clean tree, and never overrides * `SNAPSHOT_CONFLICTED_INDEX` (an unmerged index is checked first and * refuses regardless). See {@link GitvaultSnapshot.modified_captured} / * {@link GitvaultSnapshot.untracked_captured} for the disclosure this * produces once set. */ allowDirty?: boolean; } /** How many dirty-tree paths `SNAPSHOT_DIRTY_TREE`'s `details.modified`/`details.untracked` list before capping. */ export declare const SNAPSHOT_DIRTY_TREE_LIST_CAP = 500; export interface GitvaultSnapshotRefusalPath { path: string; filter: string; } export interface GitvaultSnapshot { /** `head` = the tree was clean and the snapshot IS `HEAD`; `synthetic` = a commit was created for a dirty tree. */ kind: "head" | "synthetic"; /** The snapshot commit oid — the `gitvault_commit`. */ oid: string; /** The tree the snapshot resolves to. */ tree_oid: string; /** What `HEAD` was at capture — symref (possibly unborn) or detached. */ head: GitvaultHeadTarget; /** `HEAD`'s commit when it had one. */ head_oid: string | null; /** Paths included in a synthetic commit (tracked + untracked-not-ignored); empty for `head`. */ paths: string[]; /** * The CAPTURED SET, always populated for both kinds: every tracked or * untracked-but-not-ignored path present on disk at capture, with the mode * and the raw-bytes oid the snapshot corresponds to. This is the set the * correspondence check re-derives — NOT the whole working directory, and * deliberately NOT gitignored build output (see {@link capturedSetDigest}). */ captured: GitvaultCapturedFile[]; /** `sha256` over {@link captured} — the value re-derived before a plan commits. */ captured_digest: string; /** The global excludes file honored (as data), when one existed. */ global_excludes_path: string | null; top_level: string; /** * Disclosure for an `allowDirty: true` capture: tracked paths that were * modified/staged relative to `HEAD` (or, on an unborn `HEAD`, every * populated index path) and got swept into the capture. Empty for a clean * tree (`kind: "head"`) — there was nothing to sweep in. */ modified_captured: string[]; /** * Disclosure for an `allowDirty: true` capture: untracked-not-ignored * paths that got swept into the capture. Empty for a clean tree * (`kind: "head"`). */ untracked_captured: string[]; } /** One member of the captured set: path, mode, and the oid of its RAW bytes (`--no-filters`). */ export interface GitvaultCapturedFile { path: string; mode: "100644" | "100755" | "120000"; oid: string; } /** * `sha256` over the captured set, in git's own index-info line form * (` \t\n`, path-sorted) so the serialization is the one the * tree is built from rather than a second, drifting encoding. * * WHAT THIS COVERS, EXACTLY: the captured source set — tracked plus * untracked-but-not-ignored. It does NOT cover gitignored paths, so a build * that rewrites `dist/` between capture and commit changes nothing here, by * design: a project with a build step would otherwise be undeployable. The * guarantee this digest buys is "the captured source did not change while the * artifacts were produced", NOT "the artifacts are a function of that source". */ export declare function capturedSetDigest(files: GitvaultCapturedFile[]): string; /** * Re-derive the captured set from the work tree, without writing any object. * * `global_excludes_path` is the file the capture honored — passed through * rather than rediscovered, so the comparison is about the TREE and cannot be * perturbed by the user's git config changing mid-deploy. */ export declare function deriveCapturedSet(options: { top_level: string; global_excludes_path?: string | null; }): Promise; /** What changed between two captured sets. Empty on every list means the set is byte-identical. */ export interface GitvaultCapturedSetDrift { added: string[]; removed: string[]; modified: string[]; } /** Name the paths that moved — added, removed, or changed content/mode. */ export declare function diffCapturedSets(before: GitvaultCapturedFile[], after: GitvaultCapturedFile[]): GitvaultCapturedSetDrift; /** True when nothing in the captured set moved. */ export declare function capturedSetUnchanged(drift: GitvaultCapturedSetDrift): boolean; /** * Capture the work tree per the §6.6 policy. Branches, index, and work tree * are left byte-identical; a synthetic commit lives only in the object * database (plus the local deploy ref when enabled). */ export declare function captureSnapshot(options: GitvaultSnapshotOptions): Promise; /** `git check-attr filter` over paths; returns every path whose `filter` attribute is set (LFS included). */ export declare function detectActiveFilters(top: string, paths: string[]): Promise; /** * Sensitive untracked-path denylist (design D10, verbatim). Applies to the * UNTRACKED set only — a tracked file is the user's own committed choice and * is never filtered by this list. `--include-sensitive ` re-admits a * named path by matching the SAME glob grammar against the candidate. */ export declare const GITVAULT_HANDOFF_SENSITIVE_DENYLIST: readonly string[]; /** * A slash-free glob matches the path's BASENAME at any depth (gitignore's * "no slash" rule). A glob ending `/**` (e.g. `.aws/**`) matches the named * directory component at ANY depth in the tree, plus everything under it — * a deliberately broader-than-gitignore reading for a credential-directory * denylist, where over-excluding is the safe failure mode. Any other * slash-bearing glob matches the full relative path exactly. */ export declare function globMatchesGitPath(glob: string, relPath: string): boolean; /** True iff `relPath` (an untracked-not-ignored path) matches the sensitive denylist and is not re-admitted by `includeSensitive`. */ export declare function isHandoffSensitivePath(relPath: string, includeSensitive?: readonly string[]): boolean; /** What {@link GitvaultHandoffCaptureOptions.message} learns once capture figures are known — everything a Handoff Note's `capture` block needs, before the outer commit exists. */ export interface GitvaultHandoffCaptureStats { base_head_oid: string; /** `HEAD`'s symref name (`refs/heads/`, stripped) — `null` on a detached HEAD. */ branch: string | null; modified_captured: string[]; untracked_captured: string[]; sensitive_excluded: string[]; ignored_not_transferred_count: number; } export interface GitvaultHandoffCaptureOptions { /** The work tree (any directory inside it). */ dir: string; env?: GitConfigDiscoveryEnv; /** * The outer commit's message (the Handoff Note). A plain string is used * verbatim (the caller has already composed and secret-scanned it). A * FUNCTION is called with the real capture figures right before the * outer `commit-tree` — the one point at which a Handoff Note's * `capture` block can be filled with true numbers, since the note (and * the commit that carries it) cannot exist before those numbers are * known. Throwing inside it (the client-side secret scan) aborts the * capture before the outer commit is written; the two throwaway parent * commits (index/untracked) are already unreachable garbage at that * point, exactly as if the capture had never run. */ message: string | ((stats: GitvaultHandoffCaptureStats) => string); now?: () => Date; /** Re-admit named untracked paths the sensitive denylist would otherwise exclude. */ includeSensitive?: string[]; } /** * A stash-shaped checkpoint (design D1): one synthetic commit, ALWAYS * created (a clean tree still produces it — there is one shape, one code * path). `oid`'s tree is the worktree state of TRACKED paths only; its * three parents are `base_head_oid` (parent 1), `index_commit_oid` (parent * 2, tree = the real index), and `untracked_commit_oid` (parent 3, * parentless, tree = the admitted untracked set — the EMPTY tree when * nothing untracked survives the denylist). Restore is `git clone` at * `base_head_oid` then `git stash apply --index `. */ export interface GitvaultHandoffSnapshot { oid: string; tree_oid: string; base_head_oid: string; index_commit_oid: string; untracked_commit_oid: string; head: GitvaultHeadTarget; top_level: string; /** Tracked paths whose on-disk content differs from `base_head_oid`'s tree (staged, unstaged, or both) and are present in `tree_oid`. */ modified_captured: string[]; /** Untracked-not-ignored paths admitted into `untracked_commit_oid`'s tree (denylist already applied). */ untracked_captured: string[]; /** Untracked-not-ignored paths excluded by the sensitive denylist (before any `includeSensitive` re-admission). */ sensitive_excluded: string[]; /** Ignored (gitignore/exclude) files present on disk — never transferred, counted only. */ ignored_not_transferred_count: number; captured: GitvaultCapturedFile[]; captured_digest: string; global_excludes_path: string | null; } /** * Capture a stash-shaped handoff checkpoint. Reuses every hardened-execution * guarantee `captureSnapshot` already provides (filter-free hashing, frozen * ignore authority, refusal-by-name for conflicted/linked/submodule/sparse/ * shallow/LFS layouts) via {@link inspectRepository} + {@link * detectActiveFilters}; the shape below is the ONLY thing new. */ export declare function captureHandoffSnapshot(options: GitvaultHandoffCaptureOptions): Promise; /** * Check out `oid`'s tree into `targetDir` through a temporary index * (`read-tree` + `checkout-index`) — filter-free, hook-free, and isolated * from the mutable work tree. Returns the absolute target directory. */ export declare function materializeSnapshot(repoDir: string, oid: string, targetDir?: string): Promise; /** `snapshot_oid_hmac` = HMAC(K_digest("snapshot_oid"), JCS({oid, format:"sha1"})) — the keyed commitment the platform compares without ever seeing the oid. */ export declare function snapshotCommitment(kRepo: Uint8Array, repoId: string, epoch: string, oid: string): string; /** The client-side `gitvault_commit` line — printed ALWAYS; commit ids never reach the platform. */ export declare function gitvaultCommitLine(snapshot: Pick): string; /** Object sizes for a set of oids (`cat-file --batch-check`), for the per-object cap on tracked history. */ export declare function findOversizeObjects(repoDir: string, tips: string[], cap?: number): Promise>; /** * True iff `ancestor` is reachable from `descendant` (`merge-base --is-ancestor`). * A commit this object store does not hold at all (git exits 128, "Not a * valid commit name") answers FALSE, never throws: on a multi-writer vault a * ref can name a commit only another writer's clone has (a handoff * checkpoint minted elsewhere), and the callers use this answer to decide * what to RETAIN — "not provably reachable" keeps the tip as a root, the * conservative outcome, whereas a throw refused every push and mint from a * checkout that had not seen the other writer's objects. */ export declare function isAncestor(repoDir: string, ancestor: string, descendant: string): Promise; /** Does the object exist locally? */ export declare function hasObject(repoDir: string, oid: string): Promise; /** * Which of `oids` exist locally — ONE `cat-file --batch-check` process for * the whole set (gitvault-delta-fetch task 3.1), identical per-oid * semantics to {@link hasObject}. `--batch-check` answers ` * ` for a present object and ` missing` otherwise, one line per * input line, so presence is exactly "second column is not `missing`". */ export declare function hasObjects(repoDir: string, oids: string[]): Promise>; //# sourceMappingURL=gitvault-snapshot.d.ts.map