From 73b8fedb3d473d01f41074d2d266e4ed709ebb41 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:14:40 +0200 Subject: [PATCH 01/26] records: plan the setup redesign (four intents, their specs, and the issues behind them) Assisted-by: Claude:claude-opus-5-5 --- .../brief/06-delivery/03-out-of-scope.md | 1 + ...before-abcd-starts-anything-the-product.md | 56 ++++++++++ ...-person-sets-abcd-up-in-a-repository-in.md | 100 ++++++++++++++++++ ...very-person-who-commits-to-a-repository.md | 66 ++++++++++++ ...-when-abcd-needs-another-tool-such-as-a.md | 64 +++++++++++ ...3-when-the-folder-abcd-installs-into-is.md | 58 ++++++++++ ...-person-sets-abcd-up-in-a-repository-in.md | 21 ++++ ...very-person-who-commits-to-a-repository.md | 21 ++++ ...-when-abcd-needs-another-tool-such-as-a.md | 21 ++++ ...5-when-the-folder-abcd-installs-into-is.md | 21 ++++ ...776-tool-installs-default-to-a-verified.md | 6 ++ ...-ahoy-install-run-from-a-plain-terminal.md | 15 +++ ...ahoy-install-hides-the-git-identity-fix.md | 15 +++ ...in-a-source-checkout-of-abcd-go-run-cmd.md | 15 +++ ...1059-ahoy-install-offers-a-tool-install.md | 15 +++ ...93-the-committed-identity-pin-names-one.md | 15 +++ ...hoy-reports-an-empty-install-mode-for-a.md | 15 +++ ...0-before-a-new-release-abcd-must-verify.md | 14 +++ 18 files changed, 539 insertions(+) create mode 100644 .abcd/development/intents/drafts/itd-2610240913343804-before-abcd-starts-anything-the-product.md create mode 100644 .abcd/development/intents/planned/itd-2610240823055889-a-person-sets-abcd-up-in-a-repository-in.md create mode 100644 .abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md create mode 100644 .abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md create mode 100644 .abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md create mode 100644 .abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md create mode 100644 .abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md create mode 100644 .abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md create mode 100644 .abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md create mode 100644 .abcd/work/decisions/dcn-2610240845548776-tool-installs-default-to-a-verified.md create mode 100644 .abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md create mode 100644 .abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md create mode 100644 .abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md create mode 100644 .abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md create mode 100644 .abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md create mode 100644 .abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md create mode 100644 .abcd/work/issues/open/iss-2610240959030370-before-a-new-release-abcd-must-verify.md diff --git a/.abcd/development/brief/06-delivery/03-out-of-scope.md b/.abcd/development/brief/06-delivery/03-out-of-scope.md index e2aa2a905b..51d99f312f 100644 --- a/.abcd/development/brief/06-delivery/03-out-of-scope.md +++ b/.abcd/development/brief/06-delivery/03-out-of-scope.md @@ -149,6 +149,7 @@ That is what keeps "not hand-counted" true after the day it was written. - `itd-2610161710316538` — Drains and builds a person's session drives report how isolated they were - `itd-2610170634394993` — Headless agents in parallel coordinate through the shared remote, not conversation - `itd-2610170638184486` — Every session keeps a checklist on disk, so no run stalls and no stall spreads +- `itd-2610240913343804` — No surprises: abcd says up front what it will do on its own and what it will ask **Later-phase items with no intent id.** These four were written into the brief diff --git a/.abcd/development/intents/drafts/itd-2610240913343804-before-abcd-starts-anything-the-product.md b/.abcd/development/intents/drafts/itd-2610240913343804-before-abcd-starts-anything-the-product.md new file mode 100644 index 0000000000..5ee59c3193 --- /dev/null +++ b/.abcd/development/intents/drafts/itd-2610240913343804-before-abcd-starts-anything-the-product.md @@ -0,0 +1,56 @@ +--- +id: itd-2610240913343804 +slug: before-abcd-starts-anything-the-product +spec_id: null +kind: null +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610031214560142] +severity: minor +origin: researcher-authored +production_mode: hand-written +--- + +# No surprises: abcd says up front what it will do on its own and what it will ask + +## Press Release + +> Before abcd starts anything, the product thinker and the technical facilitator can see which steps will run on their own and which will stop and wait for them, and why. Each flow (setup, planning, a build, a drain, a release) says up front, in plain words, what it will do automatically, what it will ask, and who will be asked; then it does exactly that. A step that turns out to need a person is asked as itself, at the moment it is needed, never discovered after the answers are in; and an agent working through abcd never stops to ask permission for a step the flow already said it would take. + +## Why This Matters + +_Drafted by the agent from the 2026-10-24 session; not yet confirmed by the product thinker._ + +On 2026-10-24 the product thinker said there are "too many surprises at the moment for the PT and TF", and that "it must be clear to them what will be automated". The same day, in one session: + +- Setup asked every status-line question, then refused the status line afterwards for a prerequisite nobody had mentioned (iss-2610240823574047). +- A no to a generic "Change the settings listed above?" silently skipped the identity question the person wanted (iss-2610240824005655). +- Setup ran a tool install through Homebrew that could never succeed on the account, then passed on Homebrew's `sudo` advice (iss-2610240824571059). +- The agent stopped to ask "should I start the two reviews?" for a step the planning flow already requires, and the product thinker asked why. +- Two other sessions wrote records into the same working tree, unannounced, which put two generated indexes out of date for everyone. + +Each was a step the person did not know was coming, or a question about a step they had already agreed to. A note in one agent's memory fixes none of it for the next person: the flow itself has to say what it will do. + +The board already shows what waits on the product thinker and what comes next (itd-2610031214560142, which this builds on); this intent asks every flow to say the same thing before it starts, and to keep to it. + +## Mechanism + +> _Prompted (the claim-recording gradient): why the authors expect this to work, as a falsifiable "we expect X because Y" — not the outcome restated. Replace this line with the claim, or with the exact token `None stated.` alone on its line to record the claim as considered and declined._ + +## Scope Conditions + +> _Required (the claim-recording gradient): the population, platform, scale, or assumptions this claim holds under, one per top-level bullet — `abcd intent plan` stamps each with a persistent identity. Replace this line with those bullets, or with the exact token `None stated.` alone on its line._ + +## Acceptance Criteria + +> _Required (the itd-1 discipline): add at least one Given-When-Then bullet describing the verifiable bar for "shipped" before this draft can be planned._ + +## Open Questions + +- Where does a flow's statement of what it will automate and what it will ask live: printed at the start of each flow, in the flow's command page, on the board, or all three? (raised 2026-10-24) +- Is "an agent never asks permission for a step its flow already announced" a principle for every abcd-managed repository, carried by the rules loader, rather than part of this intent? (raised 2026-10-24) +- Does this cover peer sessions too: should a session announce to the person, not only to its peers, the records it is about to write into a shared working tree? (raised 2026-10-24) + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ diff --git a/.abcd/development/intents/planned/itd-2610240823055889-a-person-sets-abcd-up-in-a-repository-in.md b/.abcd/development/intents/planned/itd-2610240823055889-a-person-sets-abcd-up-in-a-repository-in.md new file mode 100644 index 0000000000..bd7955b9a0 --- /dev/null +++ b/.abcd/development/intents/planned/itd-2610240823055889-a-person-sets-abcd-up-in-a-repository-in.md @@ -0,0 +1,100 @@ +--- +id: itd-2610240823055889 +slug: a-person-sets-abcd-up-in-a-repository-in +spec_id: spc-2610241007434505 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610030810370060] +severity: major +origin: researcher-authored +production_mode: hand-written +impact: breaking +--- + +# Setting abcd up is short, plain, and finishes + +## Press Release + +> A person sets abcd up in a repository in one short sitting, and it finishes. Setup asks only the questions whose answers it cannot work out for itself, one screen at a time in a polished terminal interface, each with a sentence or two rather than a page; it does what each answer allows at once, shows plainly what it changed, and ends either with abcd fully working (its command on the PATH, the status line wired, the commit identity right) or with the one next step that is still needed and why. Nothing is left half-done behind a generic yes or no, and running it again from a source checkout works the same way as from a release. + +## Why This Matters + +_Drafted by the agent from the 2026-10-24 setup session; not yet confirmed by the product thinker._ + +On 2026-10-24 the product thinker set abcd up in abcd's own checkout and found it "complicated, too much text, and doesn't seem to be working". Getting to a clean install took five runs of `ahoy install` (one refused outright, one with every question declined, three at a terminal), a hand-typed `git config`, and the README's install line pasted by hand. What the session showed: + +- **The status line only worked after a step nobody was told about.** The status-line offer was asked and answered through all its element switches, then refused afterwards because no PATH entry was on record (iss-2610240823574047). The only way on was the README's install line: one very long command, pasted into a terminal by hand. +- **After setup, abcd does not recognise the command it just installed.** With `~/.local/bin/abcd` on PATH and recorded in `~/.abcd.noindex/path-entry`, the detection pass still reports an empty `install_mode`, beside the standing note that the plugin folder was not found (iss-2610240916382802). +- **The identity fix was hidden behind a generic yes or no.** A no to "Change the settings listed above?" silently skipped the identity question the person wanted answered (iss-2610240824005655). +- **The committed identity pin names one person**, so setup and the pre-commit hook tell any other contributor to commit under the maintainer's name (iss-2610240830397793). +- **A run from the source checkout is always refused as stale**, because `go run` never stamps a build, although the repository's own rules make `go run` the only sanctioned way to run abcd there (iss-2610240824047504). +- **Tool installs assume admin rights.** Homebrew on this machine belongs to another account, so `brew install gitleaks` failed on every run, and the failure note passed on Homebrew's `sudo` advice (iss-2610240824571059). A by-hand Go build of the tool into `~/.local/bin` worked, which shows a route without admin rights exists; it is evidence, not the route chosen. +- **Too much text, too many questions.** Each run asked up to thirteen questions, several of them only "go on to the offer listed above?", and a clean run's report still ran to a screen of detail and notes. + +## Mechanism + +- We expect setup to finish in one run because it checks everything before asking anything, and asks a question only once the steps it depends on can run. + +## Scope Conditions + +- macOS and Linux. +- An account with or without admin rights. +- An interactive terminal; anywhere else, setup is answered by options or an answers file. +- A released abcd and a source checkout of abcd. + +## Acceptance Criteria + +_Each criterion confirmed by the product thinker, one at a time, on 2026-10-24._ + +- Given no git, no repository or no agent harness, when setup runs, then it stops before any question with one message naming what is missing. +- Given setup can run, when it starts at a terminal, then it checks everything first, shows one plan screen listing every change and every default with where to change it, and asks once: proceed, customise or cancel. +- Given a step only a person can approve (the commit identity, a download, the assistant's settings, the shell settings), when setup reaches it, then it is its own question naming the value and where it is written, whatever the answer to proceed. +- Given abcd is not on the PATH, when setup runs, then it offers the abcd download before the status line, and on a yes wires the status line in the same run; it never asks about the status line when it cannot wire it. +- Given an account that has never used abcd and the suggested answers, when setup runs at a terminal, then it asks at most four questions (the abcd download, the shell settings, the plan screen, the commit identity), plus one question for each missing tool it offers to install. +- Given any setup question, when it is shown, then it is one line, and answering `?` shows the full explanation and asks again. +- Given setup ends, when its report is printed, then it has three parts: done (one line per change, with where), needs you (a numbered checklist, each with the exact step and why), and next (one step). +- Given a repository that is fully set up, when setup runs again, then it prints one line and changes nothing. + +## Open Questions + +- resolved: Should setup work on an account without admin rights, installing every tool it offers for that account alone? (raised 2026-10-24; see Decisions) +- resolved: Should setup work for more than one person committing to the same repository, so that no contributor is ever asked to take on another's identity? (raised 2026-10-24; see Decisions) +- resolved: Should setup put abcd's own command on the PATH itself, so that the status line never depends on the README's install line? (raised 2026-10-24; see Decisions) +- resolved: When a tool setup needs is already installed some other way (by Homebrew, a package manager, or by hand), what does setup do? (raised 2026-10-24; see Decisions) +- resolved: Which basics does setup check before anything else, and how does it report a missing one? (raised 2026-10-24; see Decisions) +- resolved: Do the research proposals under Research stand: one plan and one proceed, two to four questions on a first run, one line per question with `?` for more, a one-line clean re-run? Two of them sit against brief invariant 1 ("every prompt shows current state, consequences of each option, and how to change later. No silent defaults."), so accepting them amends that invariant. (raised 2026-10-24 by the design and record reviews) (answered 2026-10-24; see Decisions) +- resolved: The basics decisions disagree with each other and with the research: `curl` and a checksum tool are needed only by the README's install line, `gh` is not needed for a verified install, and without git, a repository or a harness there is nothing to set up, so those stop setup rather than wait for the end. Which basics block, and which go on the end-of-run list? (raised 2026-10-24 by the design review) (answered 2026-10-24; see Decisions) +- resolved: Is this one intent, or a setup-flow intent plus separate records for the commit identity, third-party tool installs and shell settings, with the stale `go run` refusal and the status-line order fixed as issues on their own? (raised 2026-10-24 by both reviews) Split by the product thinker on 2026-10-24; see Decisions. + +## Research + +State of the art (2026-10-24; sources: clig.dev, rustup, uv, Homebrew's installer and documentation, the GitHub CLI, mise, aqua, flutter doctor, create-next-app, Nielsen Norman Group on progressive disclosure, and Sampath, Merrick and Macvean, "Accessibility of Command Line Interfaces", CHI 2021). Proposals for the planning interview, not yet confirmed: + +- Detect everything first, show one short plan grouped by where each change lands, and ask once: proceed, customise, or cancel (rustup, Homebrew's installer, Terraform, create-next-app). +- A question only a person can answer (the identity, installing a tool, the harness's settings) is asked as itself, naming the value and where it is written, never behind a generic approval. +- Steps run in dependency order, and a step that cannot run is never asked about. +- A first run asks two to four questions; everything with a reasonable default is decided and listed in the plan with where to change it. +- Each question is one line; answering `?` shows the full explanation. +- The report ends in three short parts: done, needs you (a numbered checklist), next. +- The prerequisite pass reads like a doctor: ok, needs you, or missing, each with one fix line, and blockers kept apart from notes. +- Tools install from a per-tool table checked in-process: GitHub's asset digest, the release's checksums, and the signed build record where the project publishes one (gh does; gitleaks is checksum-checked only); `gh` is not needed for a verified install. +- abcd keeps its own question package rather than adding a terminal UI framework, and makes it calmer: no animation, `NO_COLOR`, the numbered mode always available. +- A clean re-run prints one line. + +## Decisions + +- 2026-10-24, the product thinker: before asking anything, setup checks everything abcd needs to work, and the list grows as abcd does; today that is git (present and recent enough), the agent harnesses it wires into and whether each reads `AGENTS.md`, `~/.local/bin` existing and on the PATH, the download tools it relies on (`curl`, a checksum tool), `gh` where signed build records are checked, and, in a source checkout of abcd, Go at the version `go.mod` declares. +- 2026-10-24, the product thinker: a missing basic does not stop setup. At the end, setup offers to install what is missing, or, where it cannot or the person declines, gives a checklist in plain language of what to do by hand. +- 2026-10-24, the product thinker: setup puts abcd's own command on the PATH itself, in the same run, after one question that names the download; the status line never depends on the README's install line (iss-2610240823574047). +- 2026-10-24, the product thinker: this plan is split. It keeps the setup flow (what is checked first, what is asked and in which order, and the report). Three parts are their own drafts: every contributor commits as themselves (itd-2610240918547728), abcd installs the tools it needs for the person alone (itd-2610240918594138), and setup offers to put abcd's folder on the PATH (itd-2610240919030303). The stale refusal of a source-checkout run and the status-line order are fixed as issues on their own (iss-2610240824047504, iss-2610240823574047). +- 2026-10-24, the product thinker: a missing git, a folder that is not a repository, or no agent harness stops setup at once, before any question, with a plain message saying what is missing; every other missing basic goes on the end-of-run list. The earlier decision that a missing basic does not stop setup holds for those others only. +- 2026-10-24, the product thinker: setup's questions are short (one line, with `?` showing the full explanation), and defaults are decided for the person and listed on the plan screen; what each answer does is shown in full only on `?`. This changes brief invariant 1 ("every prompt shows current state, consequences of each option, and how to change later. No silent defaults.") for setup, and the change is owed an ADR that amends the invariant before the work ships. + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ + +## Grounds + +- pursued: we expect a first-time setup to end working in one run; it is shown wrong if a fresh account still needs a hand-run step or a second run. diff --git a/.abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md b/.abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md new file mode 100644 index 0000000000..5d0b89b19d --- /dev/null +++ b/.abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md @@ -0,0 +1,66 @@ +--- +id: itd-2610240918547728 +slug: every-person-who-commits-to-a-repository +spec_id: spc-2610241007568207 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610240823055889] +severity: minor +origin: researcher-authored +production_mode: hand-written +impact: additive +--- + +# Every contributor commits as themselves + +## Press Release + +> Every person who commits to a repository abcd manages commits as themselves. Setup and the commit check know a person by their own identity, never by someone else's: a second contributor is never told to commit under the maintainer's name, a commit made under a bot's or an AI tool's name is still refused, and a mistyped email of one's own is still caught. + +## Why This Matters + +_Split out of itd-2610240823055889 by the product thinker on 2026-10-24; drafted by the agent, not yet confirmed._ + +On 2026-10-24 the product thinker asked whether a pinned identity would confuse anyone else working on abcd. It does: the repository commits one person's identity, so setup reports any other contributor's identity as a required problem and offers to make them commit as the maintainer, and the commit check refuses their commits with a fix line naming the maintainer (iss-2610240830397793). The pin was meant to catch commits under a bot's name or a mistyped email; it should keep doing that without ever naming another person. + +## Mechanism + +- We expect no check to ever propose someone else because each person's record names only themselves, while a wrong email of one's own is still caught. + + +## Scope Conditions + +- Any number of people committing to one repository. +- One identity per person per machine. +- macOS and Linux. +- Attended and unattended runs. + + +## Acceptance Criteria + +_Each criterion confirmed by the product thinker, one at a time, on 2026-10-24._ + +- Given a person who has not recorded who they are on this machine, when setup runs at a terminal, then it proposes their usual git identity and records it on their machine on a yes. +- Given a contributor who is not the maintainer, when they set up and commit, then nothing names the maintainer, and their commits are checked against their own record. +- Given a commit under a different email from the one the person recorded, or under a bot's or an AI tool's name, when it is committed, then it is refused, naming the person's own record and how to change it. +- Given a repository that stores an identity today, when setup runs after the update, then it moves that identity to its owner's machine, removes it from the repository, keeps the repository's other settings, and says so. +- Given an unattended run on a fresh machine with no identity recorded, when it would commit, then it uses the human identity whatever launched it supplied, or is refused, never committing as the tool. + + +## Open Questions + +- resolved: Decision 2026-10-24 on more than one contributor reverses part of shipped itd-131, which checks commits "against the identity the repo pins"; the reviews also found that a pin kept on each machine leaves an autonomous routine in a fresh cloud container with no human identity to establish, and that the same committed file carries the repository's `production_mode`. Confirm the reversal, or choose instead to drop the pin and check only that the author is a human and matches the committer. (raised 2026-10-24 by the design and record reviews; flagged for the product thinker, never auto-classified) (answered 2026-10-24; see Decisions) + +## Decisions + +- 2026-10-24, the product thinker: setup works for more than one person committing to a repository. The commit identity is each person's own, kept on their own machine, and nothing in the repository names who may commit; no contributor is ever told to commit as someone else (iss-2610240830397793). +- 2026-10-24, the product thinker, confirming the reversal of part of shipped itd-131: each person records their own identity once, on their own machine, and every commit is checked against that record, not against an identity the repository names; the alternative of dropping the pin was considered and declined, because the record catches a wrong email of one's own. Still owed by this plan: how an autonomous routine on a fresh machine gets its human identity before its first commit, and keeping the repository's `production_mode` committed when the identity leaves the repository. + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ + +## Grounds + +- pursued: we expect a second contributor and an account without admin rights to set abcd up with no surprises; it is shown wrong if either still hits a step they cannot do. diff --git a/.abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md new file mode 100644 index 0000000000..c7a3c630c7 --- /dev/null +++ b/.abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md @@ -0,0 +1,64 @@ +--- +id: itd-2610240918594138 +slug: when-abcd-needs-another-tool-such-as-a +spec_id: spc-2610241008092356 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610240823055889] +severity: minor +origin: researcher-authored +production_mode: hand-written +impact: additive +--- + +# abcd installs the tools it needs for the person alone, checked + +## Press Release + +> When abcd needs another tool, such as a secret scanner, it installs it for the person alone, with no admin rights, into their own folder, and checks the download is the one the tool's makers published before it runs anything. A copy the person already has is used rather than duplicated, and setup says plainly how far each download could be checked. + +## Why This Matters + +_Split out of itd-2610240823055889 by the product thinker on 2026-10-24; drafted by the agent, not yet confirmed._ + +On 2026-10-24 setup offered to install a secret scanner through Homebrew on an account that cannot write to Homebrew, failed on every run, and passed on Homebrew's advice to use admin rights (iss-2610240824571059). Research the same day found that a supported Homebrew for one non-admin account cannot be created on a Mac by a tool, so the product thinker chose a checked download into the person's own folder as the default for every tool (dcn-2610240845548776). + +## Mechanism + +- We expect what lands in the person's folder to be what abcd shipped, with no admin rights needed, because every download is named and checked in abcd's own release. + + +## Scope Conditions + +- macOS and Linux, on Apple and Intel processors (kept because abcd already ships an Intel Mac build and each tool needs only one more download entry; the product thinker asked for Intel only if it costs no extra effort). +- An account with or without admin rights. +- Tools whose makers publish releases with checksums. + + +## Acceptance Criteria + +_Each criterion confirmed by the product thinker, one at a time, on 2026-10-24; the third with the product thinker's change that an existing copy is used only when it is compatible._ + +- Given a tool abcd needs is missing, when setup installs it, then it downloads the version fixed in abcd's release into the person's own folder, checks it before writing anything, needs no admin rights, and never shows a step that needs admin rights. +- Given a download fails its check, when setup installs it, then nothing is written and setup says why; and given a tool that publishes no signed build record, when setup installs it, then setup says it was checked by checksum only. +- Given the tool is already installed, when setup runs, then it uses that copy and installs nothing only when the copy is compatible with what abcd needs; otherwise it offers abcd's own copy and says which of the two will run; it never changes the other copy. +- Given abcd installed a tool, when abcd runs it, then abcd runs that copy whatever else comes first on the PATH; and when abcd is removed, then the copies it installed are removed and nothing else. + +## Open Questions + +- resolved: Decision 2026-10-24 on tool installs meets brief invariant 7 ("provisioning fetches only a manifest-named, pinned, checksum-verified artifact. No version-discovery request exists anywhere in abcd") only if abcd fixes each tool's version and checksum in its own release; and a tool that publishes no signed build record, such as gitleaks, is checksum-checked only. Is the decision read that way? (raised 2026-10-24 by the design review) (answered 2026-10-24; see Decisions) + +## Decisions + +- 2026-10-24, the product thinker: setup works on an account without admin rights. Every tool it installs, on macOS and Linux alike, is a verified download of the tool's official release into `~/.local/bin` (checked against the published checksums and the signed build record where one exists), the route abcd already uses for itself; Homebrew is not the default (dcn-2610240845548776). +- 2026-10-24, the product thinker: when a tool setup needs is already installed some other way (by Homebrew, a package manager, or by hand), setup uses it when it runs and is recent enough, and installs no second copy; when it is too old, setup offers abcd's own copy in `~/.local/bin` and says which of the two will run, since the first on the PATH wins; setup never removes or changes the other copy. +- 2026-10-24, the product thinker: each tool's version and checksum are fixed in abcd's own release, as brief invariant 7 requires, so a tool updates only when abcd does; a tool that publishes no signed build record, such as gitleaks, is checksum-checked only, and setup says so. + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ + +## Grounds + +- pursued: we expect a second contributor and an account without admin rights to set abcd up with no surprises; it is shown wrong if either still hits a step they cannot do. diff --git a/.abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md b/.abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md new file mode 100644 index 0000000000..374b4a3b26 --- /dev/null +++ b/.abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md @@ -0,0 +1,58 @@ +--- +id: itd-2610240919030303 +slug: when-the-folder-abcd-installs-into-is +spec_id: spc-2610241008191355 +kind: standalone +suggested_kind: null +reclassification_history: [] +builds_on: [itd-2610240823055889] +severity: minor +origin: researcher-authored +production_mode: hand-written +impact: additive +--- + +# Setup offers to put abcd's folder on the PATH + +## Press Release + +> When the folder abcd installs into is not on the person's PATH, setup asks once whether to add it to their shell settings, changes them only on a yes, never adds the same line twice, and undoes the change when abcd is removed; declined, the line to add is on the checklist at the end. + +## Why This Matters + +_Split out of itd-2610240823055889 by the product thinker on 2026-10-24; drafted by the agent, not yet confirmed._ + +On 2026-10-24 the product thinker had to put abcd on the PATH by pasting the README's long install line before setup could finish. The research found that tools disagree on whether setup should edit shell settings; the product thinker chose that setup asks. + +## Mechanism + +- We expect the edit to be safe to make and to undo because setup edits only a file it can read whole, adds one marked line, and can find and remove that line later. + + +## Scope Conditions + +- The zsh and bash shells. +- macOS and Linux. + + +## Acceptance Criteria + +_Each criterion confirmed by the product thinker, one at a time, on 2026-10-24._ + +- Given abcd's folder is not on the PATH, when setup runs at a terminal, then it asks once whether to add it, naming the file and the line; on a yes it keeps a copy of the file, adds one line, and says to open a new terminal. +- Given the person's settings already add the folder, or the settings file is a link kept elsewhere (shared dotfiles), when setup runs, then it does not edit the file: it does nothing in the first case, and puts the line on the checklist in the second. +- Given the person answers no, when setup ends, then the line is on the checklist; and given abcd is removed, when the removal runs, then the line abcd added is removed and nothing else in the file changes. + + +## Open Questions + +- resolved: Decision 2026-10-24 on shell settings reverses a written rule (the brief's setup page: "abcd prints a one-line export fix and never edits a shell profile"), and the status line does not need it, since it runs the recorded absolute path. Confirm the reversal, with its costs (which file per shell, linked dotfiles, undoing it on uninstall, a new terminal before it takes effect), or keep the export line on the checklist. (raised 2026-10-24 by the design review) (answered 2026-10-24; see Decisions) + +## Decisions + +- 2026-10-24, the product thinker: when `~/.local/bin` is not on the PATH, setup asks whether to edit the person's shell settings to add it, and edits them only on a yes; declined, the line to add goes on the end-of-run checklist. +- 2026-10-24, the product thinker, confirming the reversal: setup asks, then edits the person's shell settings to add abcd's folder to the PATH, reversing the written rule that abcd never edits a shell profile; the costs named by the design review (which file for each shell, linked dotfiles, undoing the edit when abcd is removed, a new terminal before it takes effect) are this plan's to meet. + +## Audit Notes + +_Empty. Populated by intent-auditor when intent moves to shipped/._ diff --git a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md new file mode 100644 index 0000000000..cd5642ab5c --- /dev/null +++ b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md @@ -0,0 +1,21 @@ +--- +id: spc-2610241007434505 +slug: a-person-sets-abcd-up-in-a-repository-in +intent: itd-2610240823055889 +origin: researcher-authored +production_mode: dictated-and-formatted +--- +# a-person-sets-abcd-up-in-a-repository-in + +## Summary + +_Draft: describe what spc-2610241007434505 delivers for itd-2610240823055889 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ + +## Footprint + +- packages: +- tests: + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. `, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ diff --git a/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md b/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md new file mode 100644 index 0000000000..0b99c0b95c --- /dev/null +++ b/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md @@ -0,0 +1,21 @@ +--- +id: spc-2610241007568207 +slug: every-person-who-commits-to-a-repository +intent: itd-2610240918547728 +origin: researcher-authored +production_mode: dictated-and-formatted +--- +# every-person-who-commits-to-a-repository + +## Summary + +_Draft: describe what spc-2610241007568207 delivers for itd-2610240918547728 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ + +## Footprint + +- packages: +- tests: + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. <title>`, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ diff --git a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md new file mode 100644 index 0000000000..e57019e4ce --- /dev/null +++ b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md @@ -0,0 +1,21 @@ +--- +id: spc-2610241008092356 +slug: when-abcd-needs-another-tool-such-as-a +intent: itd-2610240918594138 +origin: researcher-authored +production_mode: dictated-and-formatted +--- +# when-abcd-needs-another-tool-such-as-a + +## Summary + +_Draft: describe what spc-2610241008092356 delivers for itd-2610240918594138 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ + +## Footprint + +- packages: +- tests: + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. <title>`, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ diff --git a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md new file mode 100644 index 0000000000..243d3d5ab3 --- /dev/null +++ b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md @@ -0,0 +1,21 @@ +--- +id: spc-2610241008191355 +slug: when-the-folder-abcd-installs-into-is +intent: itd-2610240919030303 +origin: researcher-authored +production_mode: dictated-and-formatted +--- +# when-the-folder-abcd-installs-into-is + +## Summary + +_Draft: describe what spc-2610241008191355 delivers for itd-2610240919030303 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ + +## Footprint + +- packages: +- tests: + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. <title>`, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ diff --git a/.abcd/work/decisions/dcn-2610240845548776-tool-installs-default-to-a-verified.md b/.abcd/work/decisions/dcn-2610240845548776-tool-installs-default-to-a-verified.md new file mode 100644 index 0000000000..2ea72416e1 --- /dev/null +++ b/.abcd/work/decisions/dcn-2610240845548776-tool-installs-default-to-a-verified.md @@ -0,0 +1,6 @@ +--- +id: dcn-2610240845548776 +slug: tool-installs-default-to-a-verified +date: 2026-10-24 +--- +2026-10-24 — Tool installs default to a verified download, on macOS and Linux alike: abcd fetches the tool's official release, checks it against the release's published checksums (and its signed build record where the project publishes one), and puts it in ~/.local/bin, needing no admin rights; this is the route abcd already uses to install itself. Homebrew is not the default, because a supported Homebrew for one non-admin account cannot be created by abcd on a Mac (Homebrew's installer refuses a prefix longer than /opt/homebrew, so no home-folder prefix fits) and a shared Homebrew is often not writable by the person. Decided by the product thinker on 2026-10-24 after the Homebrew-without-admin research (iss-2610240824571059). diff --git a/.abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md b/.abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md new file mode 100644 index 0000000000..26d4f8eefa --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240823574047" +slug: "ahoy-install-run-from-a-plain-terminal" +severity: "major" +category: "bug" +source: "user-observation" +found_during: "ahoy install from a terminal, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/statusline_guard.go" +remedy: "check for a trusted PATH entry before the status-line offer is asked, and when none can be written offer the README one-liner (or resolve the plugin root from the plugin cache) inside the same run instead of refusing after the answers" +--- + +ahoy install run from a plain Terminal cannot wire the status line or a PATH entry: with no CLAUDE_PLUGIN_ROOT the plugin root is missing (plugin.root_missing), stepPathEntry returns early, ~/.abcd.noindex/path-entry stays absent, and the status-line offer is answered yes through all its element switches only to be refused afterwards (no PATH entry on record). The person learns of the prerequisite after answering every question. diff --git a/.abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md b/.abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md new file mode 100644 index 0000000000..30d0316869 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240824005655" +slug: "ahoy-install-hides-the-git-identity-fix" +severity: "minor" +category: "ux" +source: "user-observation" +found_during: "ahoy install from a terminal, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/apply.go" +remedy: "ask the identity question on its own at a terminal, quoting the current identity and the pin, independent of the config-change approval" +--- + +ahoy install hides the git identity fix behind the generic settings approval: the identity mismatch is listed only as one line under 'Change the settings listed above?', a no there means the identity question is never asked, and nothing says that declining the settings approval also declines the identity pin the person wanted to keep. diff --git a/.abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md b/.abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md new file mode 100644 index 0000000000..f2ab7dfdc0 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240824047504" +slug: "in-a-source-checkout-of-abcd-go-run-cmd" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "ahoy install from a terminal, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy" +remedy: "treat a go run build whose module root is the checkout being installed as current (vintage: source tip), and keep the stale refusal for real binaries" +--- + +in a source checkout of abcd, go run ./cmd/abcd ahoy install is always refused as a stale binary, because go run never stamps VCS build info, although AGENTS.md makes go run the only sanctioned way to run abcd there; every run needs --allow-stale-binary. diff --git a/.abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md b/.abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md new file mode 100644 index 0000000000..570102e594 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240824571059" +slug: "ahoy-install-offers-a-tool-install" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "ahoy install from a terminal, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/tools/registry.go" +remedy: "make a verified download the default tool install on macOS and Linux (dcn-2610240845548776): fetch the tool's official release, check it against the published checksums and the signed build record where one exists, and put it in ~/.local/bin; drop Homebrew as the default step, and never relay a sudo line" +--- + +ahoy install offers a tool install through Homebrew on an account that cannot write to Homebrew: on this machine /opt/homebrew is owned by the admin account, so brew install gitleaks fails on every run, and the failure note relays Homebrew's own sudo chown advice, although abcd never escalates privileges. The registry has one install step per platform (Homebrew), so an account without admin rights has no route that works. diff --git a/.abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md b/.abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md new file mode 100644 index 0000000000..af2a2b922b --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240830397793" +slug: "the-committed-identity-pin-names-one" +severity: "major" +category: "bug" +source: "user-observation" +found_during: "ahoy install review, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/config/identity.json" +remedy: "move the identity pin out of the repository to each person's own machine (under ~/.abcd.noindex), so the committed hook and setup check a contributor against their own pin and never print or propose another person's identity; keep refusing machine identities in every repository" +--- + +the committed identity pin names one person, so it misleads every other contributor: .abcd/config/identity.json pins REPPL's identity, ahoy install reports any other contributor's identity as a required git_identity.mismatch gap and proposes they commit as the pinned person, and the committed .githooks/pre-commit refuses their commits with 'fix: git config user.name "REPPL" && git config user.email ...', telling them to commit under the maintainer's name. That contradicts the attribution rule that every commit is honestly authored by the human who made it, in a repository that takes outside contributions. diff --git a/.abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md b/.abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md new file mode 100644 index 0000000000..81882d6e8a --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610240916382802" +slug: "ahoy-reports-an-empty-install-mode-for-a" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "design review of itd-2610240823055889, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/detect.go" +remedy: "classify the PATH entry from ~/.abcd.noindex/path-entry when no plugin root resolves, and report the install mode from the record rather than leaving it empty" +--- + +ahoy reports an empty install_mode for a working, recorded PATH entry whenever no plugin root resolves: detectInstallMode returns "" when !pluginOK, so after a successful README install (~/.local/bin/abcd on PATH and named by ~/.abcd.noindex/path-entry) a plain-terminal or session run still says nothing is installed. diff --git a/.abcd/work/issues/open/iss-2610240959030370-before-a-new-release-abcd-must-verify.md b/.abcd/work/issues/open/iss-2610240959030370-before-a-new-release-abcd-must-verify.md new file mode 100644 index 0000000000..294eca61c1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610240959030370-before-a-new-release-abcd-must-verify.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2610240959030370" +slug: "before-a-new-release-abcd-must-verify" +severity: "minor" +category: "observation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +remedy: "The release cut tries each dependency's and each managed-repository pin's newest release against abcd's gates, moves the pin when they pass, and otherwise holds the last passing version and records why." +--- + +before a new release, abcd must verify that all dependencies and especially those it pins to an abcd-managed repository are moved to the latest, verified version -- verified meaning that they are guaranteed to work with abcd. It should stay with a previous version if the newest one breaks abcd. From 7439b84ba30e8062027ba6f51dab5f5ab2fc10a9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:56:03 +0200 Subject: [PATCH 02/26] feat: setup offers to put abcd's folder on the PATH in the shell settings When the folder abcd installs into is not on the PATH, `abcd ahoy install` asks a person at a terminal, once, whether to add one marked line to the startup file their login shell reads, naming the file and the line. On a yes it keeps a copy of the file, adds the line, reads the file back and says to open a new terminal; `abcd ahoy uninstall` removes that line and nothing else. Settings that already add the folder are left alone, a file that is a link kept elsewhere is never edited, and --yes or a run off a terminal never asks: the line stays on the notes for the person to add. internal/core/shellpath holds the plan, the edit and the removal (zsh and bash, macOS and Linux). adr-2610241022330872 supersedes the rule that abcd never edits a shell profile; the brief, the ahoy command page and the install guide now describe the offer. The spec body is filled; the spec stays open. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 22 +- ...2-setup-edits-the-shell-settings-to-put.md | 52 ++ .abcd/development/decisions/adrs/README.md | 1 + ...5-when-the-folder-abcd-installs-into-is.md | 24 +- commands/ahoy.md | 10 +- docs/how-to/install.md | 8 + internal/core/ahoy/ahoy.go | 8 + internal/core/ahoy/apply.go | 25 +- internal/core/ahoy/bin_install_test.go | 10 +- internal/core/ahoy/detect.go | 14 +- internal/core/ahoy/install_summary.go | 14 +- internal/core/ahoy/main_test.go | 9 + internal/core/ahoy/setup_question.go | 6 +- .../core/ahoy/setup_questions_limits_test.go | 21 +- internal/core/ahoy/shell_path.go | 159 ++++ internal/core/ahoy/shell_path_test.go | 266 +++++++ internal/core/ahoy/store.go | 12 +- internal/core/shellpath/shellpath.go | 745 ++++++++++++++++++ internal/core/shellpath/shellpath_test.go | 333 ++++++++ .../surface/cli/ahoy_prompt_stdin_test.go | 10 +- internal/surface/cli/cli.go | 1 + internal/surface/cli/main_test.go | 5 + 22 files changed, 1721 insertions(+), 34 deletions(-) create mode 100644 .abcd/development/decisions/adrs/2610241022330872-setup-edits-the-shell-settings-to-put.md create mode 100644 internal/core/ahoy/shell_path.go create mode 100644 internal/core/ahoy/shell_path_test.go create mode 100644 internal/core/shellpath/shellpath.go create mode 100644 internal/core/shellpath/shellpath_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d5574cb84a..12f042bd0e 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -616,8 +616,9 @@ home-scoped `path-entry` record names it, read exactly as the hook shims read it; any other dangling link asserts no provenance (iss-2609100506263330). Three states are named rather than lumped together: an owned entry whose target has gone is dangling; an -install directory absent from `PATH` is required but not resolvable, for which -abcd prints a one-line export fix and never edits a shell profile; and any +install directory absent from `PATH` is required but not resolvable, carrying +the one-line export fix, beside which a person at a terminal is offered the +line added to their shell settings (below); and any `abcd` that comes *before* abcd's own entry is shadowed, because an entry that is correct and never reached is not an install (iss-171). A link whose target has gone is the exception to "never reached" in wording, not in the gap: it runs @@ -636,6 +637,23 @@ embedded Go build metadata identifies it as an abcd build and of which version, read without running it, so the person deciding whether to clear it need not inspect it by hand (iss-2609120447482255). +**Setup asks before it puts abcd's folder on the `PATH`.** When the folder the +entry is in is not on `PATH`, install asks a person at a terminal, once, whether +to add one line to the startup file their login shell reads, naming the file and +the line (`path.shell_settings_offered`, adr-2610241022330872, +itd-2610240919030303). zsh gets `${ZDOTDIR:-$HOME}/.zshrc`; bash gets the login +file on macOS (the first of `.bash_profile`, `.bash_login` and `.profile`, else +a new `.bash_profile`) and `~/.bashrc` on Linux. On a yes abcd keeps a copy of +the file in `~/.abcd.noindex/backups/`, adds +`export PATH="$HOME/.local/bin:$PATH"` at the end with abcd's marker comment +after it, reads the file back, records the line in `~/.abcd.noindex/shell-path.json`, and +says to open a new terminal. It edits only a regular file it can read whole, +owned by the person and writable by no one else. Settings that already add the +folder are left alone, and a file that is a link kept elsewhere (shared +dotfiles) is never offered: its line goes on the checklist, as it does on a no, +under the approve-everything flag, and off a terminal. Uninstall removes the +line abcd added and nothing else in the file. + **The name-guard scaffolding is reported at the granularity the technical facilitator can act on.** Each absent artefact is a gap abcd will create; every other state is a diagnostic, because abcd writes what is missing and never replaces what the diff --git a/.abcd/development/decisions/adrs/2610241022330872-setup-edits-the-shell-settings-to-put.md b/.abcd/development/decisions/adrs/2610241022330872-setup-edits-the-shell-settings-to-put.md new file mode 100644 index 0000000000..a7476c5bd0 --- /dev/null +++ b/.abcd/development/decisions/adrs/2610241022330872-setup-edits-the-shell-settings-to-put.md @@ -0,0 +1,52 @@ +--- +id: adr-2610241022330872 +slug: setup-edits-the-shell-settings-to-put +status: accepted +date: 2026-10-24 +supersedes: null +superseded_by: null +related_intents: [itd-2610240919030303, itd-2610240823055889] +related_rfcs: [] +related_adrs: [adr-2610050711287503] +--- + +# ADR-2610241022330872: Setup edits the shell settings to put abcd's folder on the PATH, on a yes + +## Context + +`abcd ahoy install` puts its PATH entry in `~/.local/bin` by default (iss-171). On many machines that folder is not on the PATH, and then `abcd` cannot be run by name. Until now the rule was script-first: setup printed the one-line `export` fix and never edited a shell profile, written in the brief's setup page and in the `/abcd:ahoy` command page. + +On 2026-10-24 the product thinker had to paste the README's long install line by hand before setup could finish. The research behind itd-2610240919030303 found that installers disagree: some edit the shell settings themselves, some only print the line. The product thinker chose that setup asks, and then confirmed that this reverses the written rule. The design review named the costs the change has to meet: which file each shell reads, settings files that are links into a shared dotfiles folder, undoing the edit when abcd is removed, and the new terminal the edit needs before it takes effect. + +The status line does not need the PATH: it runs the recorded absolute path of the entry (adr-2610050711287503). The edit is for the person typing `abcd`. + +## Decision + +When the folder abcd installs into is not on the PATH, `abcd ahoy install` asks a person at a terminal, once, whether to add one line to the startup file their login shell reads. The question names the file and the line. abcd edits the file only on a yes. Declined, or when abcd will not edit the file, the line is listed for the person to add. + +The edit meets this bar (`internal/core/shellpath`): + +- Only zsh and bash, on macOS and Linux. zsh gets `${ZDOTDIR:-$HOME}/.zshrc`, which every interactive zsh reads. bash on macOS gets the login file bash reads (the first of `.bash_profile`, `.bash_login` and `.profile`, else a new `.bash_profile`). bash on Linux gets `~/.bashrc`. A `ZDOTDIR` outside the home folder, or any other shell, gets the checklist line. +- Only a file abcd can read whole: a regular file, not a link, owned by the person, writable by no one else, and at most 256 KiB. A file that is a link is kept elsewhere, so abcd never edits it. +- Never the same folder twice. Settings that already add the folder are left alone: abcd's own marked line, or a line of the person's that sets the PATH to include the folder, in any file that shell reads at startup. +- One line, added at the end and marked: `export PATH="$HOME/.local/bin:$PATH" # added by abcd ahoy install`. The folder comes first, ahead of the PATH it had, so the abcd setup installed answers before an older copy in a system folder. +- A copy of the file goes into `~/.abcd.noindex/backups/` first, which keeps the newest ten copies of each file. No copy, no write. The file is replaced atomically with its mode kept, read back, and put back from the copy when it does not hold exactly what was written. +- The file and the line are recorded in `~/.abcd.noindex/shell-path.json`. `abcd ahoy uninstall` removes that line and nothing else, puts back the newline abcd added before it, and deletes a file abcd created for the line alone. A line the person has since edited is no longer abcd's, and uninstall leaves it in place. +- `--yes` never gives the yes, and the question is never asked off a terminal. + +This supersedes the rule that abcd never edits a shell profile. adr-2610050711287503 is unchanged: the harness's user settings are still touched only for the status line. + +## Alternatives Considered + +- Ask, then edit under the bar above (chosen): a person who says yes is done in one step, and the edit can be found and undone. +- Keep printing the line and never edit (the old rule): no risk to the person's files, but every person whose PATH lacks the folder has to find the right file and paste the line. That is the step the product thinker hit. +- Edit without asking, as some installers do: fewer questions, but it changes a file the person owns without a consent that names the change, which the setup flow's person-only questions rule out. +- Edit through a link into a dotfiles folder: reaches the person's real file, but it changes a file shared with other machines, and uninstall on one machine would take the line out on every machine. + +## Consequences + +- A person at a terminal can put abcd on the PATH by answering one question, and still has to open a new terminal before `abcd` runs by name there; setup says so. +- abcd now writes to a file outside its own folders and the repository. That write has its own record, copies and read-back, and uninstall removes it. +- The already-added check reads text. It can take a line for one that adds the folder when it does not, which costs the person the offer but never adds a second line. A line that adds the folder only through a file it sources is not seen. +- Shells other than zsh and bash, and settings kept as links, still need the person to add the line. The checklist names it. +- The brief's setup page and the `/abcd:ahoy` command page now describe the offer instead of the never-edit rule. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 084dce4990..55cfeaa161 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -216,4 +216,5 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2610161621415708](2610161621415708-abcd-s-command-guard-steers-agents-an-os.md) | Unattended runs abcd launches execute inside an OS sandbox | proposed | 2026-10-16 | | [adr-2610161733400551](2610161733400551-a-reviewer-in-a-model-matrix-run-never.md) | A reviewer or judge in a model-matrix run is told nothing about which model wrote the copy it judges | proposed | 2026-10-16 | | [adr-2610181003541997](2610181003541997-the-away-mode-s-stop-check-reads-the.md) | The away mode's stop check reads the forge once, as its documented meaning | proposed | 2026-10-18 | +| [adr-2610241022330872](2610241022330872-setup-edits-the-shell-settings-to-put.md) | Setup edits the shell settings to put abcd's folder on the PATH, on a yes | accepted | 2026-10-24 | <!-- /index --> diff --git a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md index 243d3d5ab3..218c003ee6 100644 --- a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md +++ b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md @@ -9,12 +9,30 @@ production_mode: dictated-and-formatted ## Summary -_Draft: describe what spc-2610241008191355 delivers for itd-2610240919030303 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-2610241008191355 delivers itd-2610240919030303: when the folder abcd installs into is not on the PATH, `abcd ahoy install` asks a person at a terminal, once, whether to add one marked line to the startup file their login shell reads. It edits the file only on a yes, never adds the line twice, and `abcd ahoy uninstall` removes it again. Declined, or when abcd will not edit the file, the line is on the checklist. adr-2610241022330872 records the reversal of the rule that abcd never edits a shell profile. + +### Scope + +zsh and bash, on macOS and Linux (the intent's two scope conditions). Any other shell, and a `ZDOTDIR` outside the home folder, get the checklist line. The end-of-run checklist itself belongs to the setup flow (itd-2610240823055889). This spec gives that flow the core API and, until the flow lands, carries the line on the install's notes. + +### Approach + +- `internal/core/shellpath` holds every decision and the edit, and prints nothing. `PlanFor(env, dir)` reads only, and returns the state (`on_path`, `already_added`, `offer`, `checklist`), the file, the marked line, the unmarked export line and the reason. `Apply(env, plan)` makes the edit on a yes, `Remove(env, dir)` undoes it, and `ChecklistLine(home, dir)` gives the line for the checklist. +- File per shell: zsh `${ZDOTDIR:-$HOME}/.zshrc` (every interactive zsh reads it; `.zprofile` is read by login shells only). bash on macOS: the first of `.bash_profile`, `.bash_login` and `.profile`, else a new `.bash_profile`, because a macOS terminal opens a login shell and a new `.bash_profile` would hide an existing `.profile`. bash on Linux: `~/.bashrc`. +- The line is `export PATH="$HOME/.local/bin:$PATH" # added by abcd ahoy install`. The folder comes first so the installed abcd answers before an older copy in a system folder. +- Safety bar: a regular file that is not a link, owned by the person, writable by no one else, at most 256 KiB (`fsutil.ReadGuarded`); re-judged and re-read at Apply, and refused if it changed since the question. A copy goes into `~/.abcd.noindex/backups/shell-<file>.<stamp>` first (newest ten kept; no copy, no write). Then an atomic replace that keeps the mode, a read-back, and the file put back from the copy on a mismatch. The record goes in `~/.abcd.noindex/shell-path.json`. +- ahoy: `detectBinDirOnPath` keeps `path.bin_dir_not_on_path` as the required, non-resolvable diagnosis and adds the optional offer `path.shell_settings_offered` (category `shell-path`, never asked as a category approval). `stepShellPath` runs right after `stepPathEntry`, so a first install is offered too. It asks through `Prompter.Confirm` only at a terminal and never under `--yes`. `noteReachability` then says to open a new terminal, that the settings already add the folder, or gives the line with why abcd did not offer it. `Uninstall` carries `shell_path` on its receipt. + +### Acceptance criteria + +1. Not on the PATH, setup at a terminal: the offer is asked once, naming the file and the line. On a yes a copy is kept (when the file existed), one line is added, and the notes say to open a new terminal. Tests: `TestShellPathOfferAddsTheLineOnYes`, `TestOfferAddsOneLineAndRemoveTakesOnlyIt`. +2. Settings already add the folder: nothing is asked or edited (`TestShellPathAlreadyAddedDoesNothing`, `TestAlreadyAddedByThePerson`). The settings file is a link kept elsewhere: nothing is asked or edited, and the line is on the checklist with the reason (`TestShellPathLinkedFileIsNotEdited`, `TestLinkedFileGoesOnTheChecklist`). +3. A no: the line is on the checklist (`TestShellPathDeclinedPutsTheLineOnTheChecklist`; `--yes` and off a terminal in `TestShellPathNeverAskedWithoutAPerson`). Removal takes out abcd's line and nothing else, byte for byte, including the newline it added and a file it created (`TestRemoveRestoresAFileWithNoFinalNewline`, `TestCreatedFileIsRemovedWithTheLine`, `TestRemoveWithoutRecordFindsTheMarkedLine`, and the uninstall half of `TestShellPathOfferAddsTheLineOnYes`). ## Footprint -- packages: -- tests: +- packages: internal/core/shellpath, internal/core/ahoy, internal/surface/cli +- tests: internal/core/shellpath/shellpath_test.go, internal/core/ahoy/shell_path_test.go, internal/core/ahoy/bin_install_test.go ## Steps diff --git a/commands/ahoy.md b/commands/ahoy.md index c2618c0c36..49e68c56d5 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -464,8 +464,14 @@ abcd-owned entry already on `PATH`, which is adopted exactly where it stands. system-wide one — and fails loudly when it is not writable. abcd never escalates privileges, so never suggest re-running any of this under `sudo`; report the failure and let the user pick a directory they own. If the report carries a -`path.bin_dir_not_on_path` gap, relay its one-line `export` fix verbatim and -leave the user's shell profile alone. +`path.bin_dir_not_on_path` gap, relay its one-line `export` fix verbatim. Do not +edit the user's shell settings yourself: `abcd ahoy install` run at a terminal +asks the person whether to add that line to the file their shell reads, names +the file and the line, and adds it only on a yes (the +`path.shell_settings_offered` gap). It keeps a copy of the file first, never +edits a file that is a link kept elsewhere, and `abcd ahoy uninstall` removes +the line again. Off a terminal, under `--yes`, or on a no, the line stays the +person's to add. The entry install writes is the abcd-owned copy of the verified release binary, taken from the persistent plugin data directory a session's hooks provision. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index e136d795b7..44b2434adc 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -481,6 +481,14 @@ Add this line to your shell profile: export PATH="$HOME/.local/bin:$PATH" ``` +Or let setup add it: abcd's setup, run at a terminal, asks whether to add that +line to the file your shell reads (`~/.zshrc` for zsh; for bash, +`~/.bash_profile` on macOS and `~/.bashrc` on Linux), names the file, and adds +the line only if you say yes. It keeps a copy of the file in +`~/.abcd.noindex/backups/` first, never edits a file that is a link to one kept +elsewhere, and abcd's uninstall takes the line out again. Open a new terminal +afterwards. + The one-liners above take no options — they always install to `~/.local/bin` and print no `PATH` warning — and they record that install as this machine's `abcd` in `~/.abcd.noindex/path-entry`, replacing whatever the record named before, so diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index f9e6a7e694..409e13de17 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -325,12 +325,20 @@ type StatusLineReceipt struct { Note string `json:"note"` } +// ShellPathReceipt is what uninstall did with the PATH line abcd added to the +// person's shell settings. +type ShellPathReceipt struct { + Removed bool `json:"removed"` + Note string `json:"note"` +} + // UninstallReceipt is the outcome of Uninstall. type UninstallReceipt struct { Marker MarkerReceipt `json:"marker"` Readme ReadmeReceipt `json:"readme"` Symlink SymlinkReceipt `json:"symlink"` StatusLine StatusLineReceipt `json:"status_line"` + ShellPath ShellPathReceipt `json:"shell_path"` } // DoctorReport is the outcome of Doctor: the detection envelope plus read-only diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index c3f5659262..9170026b58 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/fsutil" "slices" "sort" @@ -279,6 +280,9 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // After stepSymlink, never before: the record names the entry that step // leaves on PATH, and the hooks read it before they will run that entry. ac.stepPathEntry() + // After stepPathEntry: the shell-settings offer judges the entry this run + // left on disk, and is the first of the consent questions. + ac.stepShellPath() // After the PATH-entry steps: abcd.binary names the verified copy they left. ac.stepOutboundBinary() // After stepOutboundBinary: the shim finds abcd through the abcd.binary it @@ -479,6 +483,9 @@ type applyCtx struct { docsTargetForced bool // a --docs-target override overwrote a valid value, or this run chose the first one markerRetract []string // marker files a narrowed docs-target override de-selected configMalformed bool // config.json could not be parsed; the refusal note was given once + + shellPlan *shellpath.Plan // what stepShellPath found for the entry's folder; nil when it did not look + shellAdded bool // stepShellPath added the PATH line this run } // note is the receipt seam; it lives in receipt.go with the path scrub it @@ -2036,7 +2043,9 @@ func (a *applyCtx) noteReachability() { return // nothing was installed here; a refusal already said why } if dir := filepath.Dir(a.binTarget); !dirOnPath(dir) { - a.refuse(pathReachMessage(dir)) + if n := a.shellPathNote(dir); n != "" { + a.refuse(n) + } } if e, ok := shadowingEntry(a.det.pluginRoot, a.binTarget); ok { a.refuse(shadowMessage(e, a.binTarget)) @@ -2211,6 +2220,13 @@ func Uninstall(cwd, binDir string) (UninstallReceipt, error) { target = filepath.Join(abs, binName) } } + // Shell settings: the PATH line abcd added, found by its record or, with + // none, by its exact text for the entry's folder (shellpath.Remove). + shellDir := "" + if target != "" { + shellDir = filepath.Dir(target) + } + receipt.ShellPath = uninstallShellPath(shellDir) if target == "" { receipt.Symlink.Note = "no user-scope install location; left untouched" return receipt, nil @@ -2356,14 +2372,14 @@ const credentialAtRestGapID = "history.credential_at_rest" // the retirement of a tool's own conventions file (see // stepConventionsFiles) and the README question (see stepReadme). In the // order they are reported. -var optionalGapIDs = []string{OptionalPinGapID, ReadmeOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var optionalGapIDs = []string{OptionalPinGapID, ReadmeOfferGapID, ShellPathOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} // terminalOnlyGapIDs are the optional offers put only to a person at a // terminal, and their categories: off one, neither the category nor the offer // is asked, so a piped answer stream keeps the order it had before they // existed. The README question has no category to ask (resolveApproval leaves // it out everywhere: the question is the consent). -var terminalOnlyGapIDs = []string{ReadmeOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var terminalOnlyGapIDs = []string{ReadmeOfferGapID, ShellPathOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} var terminalOnlyCategories = []GapCategory{DrainRule, ConventionsFile} @@ -2521,6 +2537,9 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr // The README question is its own consent (open question 5): its category // is never asked, approved or declined. delete(present, Readme) + // The shell-settings offer is its own consent too, asked only at a + // terminal (stepShellPath). + delete(present, ShellPath) approved := map[GapCategory]bool{} switch { case opts.ApprovedCategories != nil: diff --git a/internal/core/ahoy/bin_install_test.go b/internal/core/ahoy/bin_install_test.go index b4000ba08e..fb1dc8b6b9 100644 --- a/internal/core/ahoy/bin_install_test.go +++ b/internal/core/ahoy/bin_install_test.go @@ -365,10 +365,10 @@ func TestStrandedOwnershipScope(t *testing.T) { } } -// TestDetectBinDirNotOnPathIsALoudGap pins the script-first remedy: an install -// directory that is not on PATH is its own loud gap carrying the one-line export -// fix, and abcd never offers to patch a shell profile (the gap is not resolvable -// by an apply step). +// TestDetectBinDirNotOnPathIsALoudGap pins the diagnosis: an install directory +// that is not on PATH is its own loud gap carrying the one-line export fix. The +// gap itself is not resolvable by an apply step; the edit of the shell settings +// is a separate optional offer asked at a terminal (shell_path_test.go). func TestDetectBinDirNotOnPathIsALoudGap(t *testing.T) { home, pluginRoot := setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) linkOwned(t, filepath.Join(home, ".local", "bin", "abcd"), pluginRoot) @@ -385,7 +385,7 @@ func TestDetectBinDirNotOnPathIsALoudGap(t *testing.T) { t.Errorf("the PATH gap must be required (loud): %+v", g) } if g.Resolvable { - t.Errorf("the PATH gap must NOT be resolvable — abcd prints the fix, it never patches a shell profile: %+v", g) + t.Errorf("the PATH gap must NOT be resolvable — it is the diagnosis; the shell-settings edit is its own offer: %+v", g) } if want := `export PATH="$HOME/.local/bin:$PATH"`; !strings.Contains(g.FixHint, want) { t.Errorf("fix hint = %q, want it to carry %q", g.FixHint, want) diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index aa04f3ac5f..5e4243b0fa 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -916,9 +916,11 @@ func detectShadowedEntry(pluginRoot, target string) []Gap { // detectBinDirOnPath reports an install directory that is not on PATH. It is a // gap in its own right: the entry exists and abcd still cannot be run by name, -// which otherwise reads as a broken install. The remedy is printed, never -// applied — abcd states the one-line export and leaves the user's shell profile -// alone (script-first), so the gap is required but NOT resolvable. +// which otherwise reads as a broken install. The gap is required but NOT +// resolvable: it is the diagnosis, carrying the one-line export. The remedy +// abcd can apply is a separate, optional offer (detectShellPath): the line +// added to the person's shell settings, asked at a terminal and made only on a +// yes (itd-2610240919030303). func detectBinDirOnPath(dir string, installed bool) []Gap { if dir == "" || dirOnPath(dir) { return nil @@ -930,13 +932,13 @@ func detectBinDirOnPath(dir string, installed bool) []Gap { if !installed { return nil } - return []Gap{{ + return append([]Gap{{ ID: "path.bin_dir_not_on_path", Category: ConfigChange, Scope: "machine", Title: displayPath(dir) + " is not on PATH", Detail: "abcd is installed at " + displayPath(filepath.Join(dir, binName)) + ". " + pathReachMessage(dir), - FixHint: "Add it to your shell profile: " + exportPathLine(dir), + FixHint: "Run `abcd ahoy install` at a terminal, which offers to add this line to your shell settings, or add it yourself: " + exportPathLine(dir), Required: true, Resolvable: false, - }} + }}, detectShellPath(dir)...) } // detectInstallMode reports the current PATH-target install mode: "dev (tip diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index 1239ba6a5a..c65d0d78e3 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -48,6 +48,8 @@ const ( writeStatusLineBackup writeKind = "status-line-backup" writeRouting writeKind = "routing" writeDrainRule writeKind = "drain-rule" + writeShellPath writeKind = "shell-path" + writeShellPathBackup writeKind = "shell-path-backup" writeToolFileRetired writeKind = "conventions-file-retired" writeRules writeKind = "rules" writeIdentityPin writeKind = "identity-pin" @@ -64,7 +66,7 @@ var allWriteKinds = []writeKind{ writeSettings, writeSetupRecord, writeGitignore, writeLocalTier, writeRecordTiers, writeDevelopmentReadme, writeDecisionsFolder, writeNameGuard, writePrivateNames, writeDocsCheck, writeAttributionHook, writeOutboundHook, writeOutboundForge, writeOutboundBinary, writeOutboundShim, writeOutboundChain, writeRules, writeConventionsBlock, writeConventionsBlockRemoved, writeReadmeParts, writeReadmeCreated, writeReadmePartsRemoved, writeToolFileRetired, writeGitIdentity, writeIdentityPin, writeArtefactKind, writeCommandEntry, writeSessionStore, - writeStatusLine, writeStatusLineBackup, writeRouting, writeDrainRule, + writeStatusLine, writeStatusLineBackup, writeRouting, writeDrainRule, writeShellPath, writeShellPathBackup, } // writeKindHelp is the plain-language explanation of each kind of write. @@ -229,6 +231,16 @@ var writeKindHelp = map[writeKind]SummaryItem{ Why: "abcd drain refuses to run in a repository until it records that rule; this one is abcd's strict default.", Action: "Commit the record. Edit its drain_ fields to change the rule; abcd drain names any loosening.", }, + writeShellPath: { + What: "Added one line to your shell settings that puts the folder abcd is installed in on your PATH.", + Why: "Without it a new terminal cannot run abcd by name.", + Action: "Open a new terminal. abcd ahoy uninstall takes the line out again.", + }, + writeShellPathBackup: { + What: "Kept a copy of your shell settings file, as it was, before abcd added its PATH line.", + Why: "If your shell settings look wrong afterwards, the copy is the file exactly as it stood.", + Action: "Nothing. To undo the change by hand, copy the file listed back over your shell settings file.", + }, } // unexplainedWriteHelp is what a write reaches the person as when it carries no diff --git a/internal/core/ahoy/main_test.go b/internal/core/ahoy/main_test.go index 3914e021d8..31b3c6ce5b 100644 --- a/internal/core/ahoy/main_test.go +++ b/internal/core/ahoy/main_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/core/vintage" ) @@ -25,5 +26,13 @@ func TestMain(m *testing.M) { // build metadata; the copy's claimed release is stood in, and the reader's // own tests call it directly (cold_cache_adopt_provenance_test.go). copyRelease = func([]byte) (string, error) { return stubCopyRelease, nil } + // No test plans against, or edits, the machine's own shell settings: the + // shell-settings offer sees no shell it edits unless a test names one + // (shell_path_test.go). + shellEnv = func() shellpath.Env { + e := shellpath.CurrentEnv() + e.Shell, e.ZDotDir = "", "" + return e + } os.Exit(m.Run()) } diff --git a/internal/core/ahoy/setup_question.go b/internal/core/ahoy/setup_question.go index 5d3a81f98b..da4cc14b0e 100644 --- a/internal/core/ahoy/setup_question.go +++ b/internal/core/ahoy/setup_question.go @@ -134,6 +134,7 @@ var setupConfirmIDs = []struct { {"adopt", func(s string) bool { return s == "Adopt this unmanaged repo into abcd?" }}, {OracleRoutingMachineGapID, func(s string) bool { return s == machineRoutingQuestion() }}, {OracleRoutingRepoGapID, func(s string) bool { return s == repoRoutingQuestion() }}, + {ShellPathOfferGapID, isShellPathQuestion}, {StatusLineOfferGapID, isStatusLineOfferQuestion}, {DrainRuleOfferGapID, func(s string) bool { return s == drainRuleQuestion() }}, {"history.lineage", func(s string) bool { @@ -234,10 +235,11 @@ func splitConfirm(text string) ([]question.Block, string) { // SetupMachineWide reports whether the setup question id changes the machine // rather than the repository: the status line, which every repository on the -// machine shows, and the machine's routing table. Its answer is recorded in +// machine shows, the machine's routing table, and the PATH line in the +// person's shell settings. Its answer is recorded in // the home's interviews, the rest in the repository's local tier. func SetupMachineWide(id string) bool { - return strings.HasPrefix(id, elementPromptPrefix) || id == OracleRoutingMachineGapID + return strings.HasPrefix(id, elementPromptPrefix) || id == OracleRoutingMachineGapID || id == ShellPathOfferGapID } // SetupFixedValues returns the values an answers file may give the setup diff --git a/internal/core/ahoy/setup_questions_limits_test.go b/internal/core/ahoy/setup_questions_limits_test.go index 520a4e3f76..cbffb9f37c 100644 --- a/internal/core/ahoy/setup_questions_limits_test.go +++ b/internal/core/ahoy/setup_questions_limits_test.go @@ -1,11 +1,13 @@ package ahoy import ( + "path/filepath" "sort" "strings" "testing" "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/core/shellpath" ) // heldQuestion is the value question h as setup builds it (valueQuestion, the @@ -88,7 +90,8 @@ func TestEverySetupQuestionPassesTheLimits(t *testing.T) { } qs = append(qs, confirmQuestion(t, len(qs)+1, OracleRoutingMachineGapID, machineRoutingQuestion(), oracleRoutingMachineQuestionTail), - confirmQuestion(t, len(qs)+2, OracleRoutingRepoGapID, repoRoutingQuestion(), oracleRoutingRepoQuestionTail)) + confirmQuestion(t, len(qs)+2, OracleRoutingRepoGapID, repoRoutingQuestion(), oracleRoutingRepoQuestionTail), + shellPathLimitQuestion(t, len(qs)+3)) var asks []question.Ask for _, q := range qs { @@ -184,3 +187,19 @@ func readmeLimitAsks(t *testing.T) []question.Ask { } return out } + +// shellPathLimitQuestion is the shell-settings offer in its tallest form: a +// new file abcd would create, in a folder below the home. +func shellPathLimitQuestion(t *testing.T, n int) question.Question { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) // paths shown in the tilde form, as a person sees them + dir := filepath.Join(home, ".local", "bin") + plan := shellpath.PlanFor(shellpath.Env{Home: home, Shell: "/bin/bash", GOOS: "darwin"}, dir) + if plan.State != shellpath.Offer { + t.Fatalf("no offer to hold to the limits: %+v", plan) + } + text := shellPathQuestion(dir, plan) + _, tail := splitConfirm(text) + return confirmQuestion(t, n, ShellPathOfferGapID, text, tail) +} diff --git a/internal/core/ahoy/shell_path.go b/internal/core/ahoy/shell_path.go new file mode 100644 index 0000000000..e0b37fe0bb --- /dev/null +++ b/internal/core/ahoy/shell_path.go @@ -0,0 +1,159 @@ +package ahoy + +// The shell-settings offer (itd-2610240919030303, adr-2610241022330872): when +// the folder abcd installs into is not on the PATH, setup asks a person at a +// terminal whether to add the one line that puts it there to the startup file +// their login shell reads, naming the file and the line, and adds it only on a +// yes. internal/core/shellpath decides the file, holds the safety bar and +// makes the edit; this file asks. +// +// - It is asked only of a person at a terminal: --yes never gives the yes, +// and off a terminal (a pipe, a routine, CI) it is never put, so a piped +// answer stream keeps the order it had before the offer existed. Its +// category is never asked as an approval: the offer is its own consent. +// - Settings that already add the folder are left as they are, and a file +// abcd will not edit (a link kept elsewhere, another account's) is never +// offered; in both cases, and on a no, the line is listed for the person. +// - `abcd ahoy uninstall` takes the line out again (uninstallShellPath). + +import ( + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/shellpath" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// ShellPath covers adding the PATH line to the person's shell settings. +const ShellPath GapCategory = "shell-path" + +// ShellPathOfferGapID is the offer of the PATH line. +const ShellPathOfferGapID = "path.shell_settings_offered" + +// shellEnv is the environment the plan reads. A variable so the package's +// tests never plan against the machine's own shell settings (TestMain). +var shellEnv = shellpath.CurrentEnv + +// shellPathAskPrefix opens the question the offer ends on. +const shellPathAskPrefix = "Add the line to " + +// detectShellPath raises the offer beside path.bin_dir_not_on_path while the +// plan says the line may be added. +func detectShellPath(dir string) []Gap { + plan := shellpath.PlanFor(shellEnv(), dir) + if plan.State != shellpath.Offer { + return nil + } + return []Gap{{ + ID: ShellPathOfferGapID, Category: ShellPath, Scope: "machine", + Title: "abcd can add " + displayPath(dir) + " to the PATH in " + displayPath(plan.File), + Detail: "abcd can add one line to " + displayPath(plan.File) + " that puts " + displayPath(dir) + " on the PATH: " + plan.Line, + FixHint: "ahoy install asks at a terminal and adds the line only on a yes; --yes never adds it. " + + "Or add it yourself: " + plan.Export, + Required: false, Resolvable: true, + }} +} + +// installedBinDir is the folder of the entry this run left, when that folder +// is not on the PATH. +func (a *applyCtx) installedBinDir() (string, bool) { + if a.binTarget == "" { + return "", false + } + if present, err := fsutil.ExistsNoFollow(a.binTarget); err != nil || !present { + return "", false + } + dir := filepath.Dir(a.binTarget) + if dirOnPath(dir) { + return "", false + } + return dir, true +} + +// stepShellPath makes the offer. It runs after stepPathEntry, so it judges the +// entry this run actually left, the first install's included, and it never +// runs under --yes or off a terminal. What it found is kept for +// noteReachability, which says what the person still has to do. +func (a *applyCtx) stepShellPath() { + dir, ok := a.installedBinDir() + if !ok { + return + } + plan := shellpath.PlanFor(shellEnv(), dir) + a.shellPlan = &plan + if plan.State != shellpath.Offer || a.autoYes || !atTerminal(a.prompter) { + return + } + if !a.prompter.Confirm(shellPathQuestion(dir, plan)) { + return + } + applied, err := shellpath.Apply(shellEnv(), plan) + if err != nil { + a.refuse("the PATH line was not added to " + displayPath(plan.File) + ": " + displayText(err.Error()) + ".") + return + } + a.shellAdded = true + if applied.Backup != "" { + a.note(writeShellPathBackup, applied.Backup) + } + a.note(writeShellPath, applied.File) + a.inform("Added `" + applied.Line + "` to " + displayPath(applied.File) + "; open a new terminal for `abcd` to run by name there.") + for _, n := range []string{applied.PruneNote, applied.RecordNote} { + if n != "" { + a.inform(n + ".") + } + } +} + +// shellPathQuestion is the reason, the file, the line and the question: core +// never prints, so what the person consents to travels as the confirm's text. +func shellPathQuestion(dir string, plan shellpath.Plan) string { + file := displayPath(plan.File) + where := "the end of " + file + if plan.Create { + where = "a new " + file + } + return displayPath(dir) + " is not on your PATH. abcd can add this line to " + where + + ", keeping a copy first; abcd ahoy uninstall removes it:\n" + + " " + plan.Line + "\n" + + shellPathAskPrefix + file + "?" +} + +// isShellPathQuestion reports whether text is the shell-settings offer. +func isShellPathQuestion(text string) bool { + i := strings.LastIndex(text, "\n") + return i >= 0 && strings.HasPrefix(text[i+1:], shellPathAskPrefix) && strings.HasSuffix(text, "?") && + strings.Contains(text, shellpath.Marker) +} + +// shellPathNote is what the run says about a folder still off the PATH once +// the steps are done: nothing when this run added the line (the step said +// so), that a new terminal picks it up when the settings already add it, and +// otherwise the line to add, with why abcd did not offer to add it itself. +func (a *applyCtx) shellPathNote(dir string) string { + if a.shellAdded { + return "" + } + plan := a.shellPlan + if plan == nil { + p := shellpath.PlanFor(shellEnv(), dir) + plan = &p + } + switch plan.State { + case shellpath.AlreadyAdded: + return displayPath(dir) + " is not on this terminal's PATH, but " + displayPath(plan.File) + + " already adds it; open a new terminal for `abcd` to run by name." + case shellpath.Checklist: + return pathReachMessage(dir) + " abcd did not offer to add it: " + displayText(plan.Reason) + "." + } + return pathReachMessage(dir) +} + +// uninstallShellPath takes out the PATH line abcd added, and nothing else. +func uninstallShellPath(dir string) ShellPathReceipt { + r, err := shellpath.Remove(shellEnv(), dir) + if err != nil { + return ShellPathReceipt{Note: "the PATH line was not removed: " + displayText(err.Error())} + } + return ShellPathReceipt{Removed: r.Removed, Note: displayText(r.Note)} +} diff --git a/internal/core/ahoy/shell_path_test.go b/internal/core/ahoy/shell_path_test.go new file mode 100644 index 0000000000..0b04bbf7e3 --- /dev/null +++ b/internal/core/ahoy/shell_path_test.go @@ -0,0 +1,266 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/shellpath" +) + +// The shell-settings offer (itd-2610240919030303): when the folder abcd +// installs into is not on the PATH, a person at a terminal is asked once +// whether to add it to their shell settings, naming the file and the line. + +// zshUser points the shell-settings seam at a zsh login shell in the test's +// home, reading the PATH the test set. +func zshUser(t *testing.T) { + t.Helper() + orig := shellEnv + shellEnv = func() shellpath.Env { + e := shellpath.CurrentEnv() + e.Shell, e.ZDotDir, e.GOOS = "/bin/zsh", "", "darwin" + return e + } + t.Cleanup(func() { shellEnv = orig }) +} + +// shellPathPrompter approves every category, answers the shell-settings +// offer as told, and declines the rest, at a terminal when terminal is set. +func shellPathPrompter(yes, terminal bool) Prompter { + sp := &scriptedPrompter{confirm: func(q string) bool { + switch { + case askedCategory(q) != "": + return true + case isShellPathQuestion(q): + return yes + } + return false + }} + if terminal { + return terminalScripted{sp} + } + return sp +} + +func askedOf(p Prompter) []string { + switch v := p.(type) { + case terminalScripted: + return v.asked + case *scriptedPrompter: + return v.asked + } + return nil +} + +func shellPathRun(t *testing.T, yes, terminal bool, opts InstallOptions) (home string, res InstallResult, asked []string) { + t.Helper() + home, _ = setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) + zshUser(t) + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + p := shellPathPrompter(yes, terminal) + res, err := Install(repo, opts, p) + if err != nil { + t.Fatal(err) + } + return home, res, askedOf(p) +} + +func shellPathAsked(asked []string) []string { + var out []string + for _, q := range asked { + if isShellPathQuestion(q) { + out = append(out, q) + } + } + return out +} + +const shellLine = `export PATH="$HOME/.local/bin:$PATH" # added by abcd ahoy install` + +func interactiveOpts() InstallOptions { + opts := installOpts() + opts.Yes = false + return opts +} + +// TestShellPathOfferAddsTheLineOnYes is criterion 1: asked once, naming the +// file and the line; a yes keeps a copy, adds one line, and says to open a new +// terminal. +func TestShellPathOfferAddsTheLineOnYes(t *testing.T) { + home, res, asked := shellPathRun(t, true, true, interactiveOpts()) + qs := shellPathAsked(asked) + if len(qs) != 1 { + t.Fatalf("the offer was asked %d times, want once; asked %q", len(qs), asked) + } + for _, want := range []string{"~/.zshrc", shellLine} { + if !strings.Contains(qs[0], want) { + t.Errorf("the offer does not name %q:\n%s", want, qs[0]) + } + } + raw, err := os.ReadFile(filepath.Join(home, ".zshrc")) + if err != nil || string(raw) != shellLine+"\n" { + t.Fatalf("~/.zshrc = %q, %v; want the one line", raw, err) + } + joined := notesJoined(res.Notes) + if !strings.Contains(joined, "open a new terminal") { + t.Errorf("the run does not say to open a new terminal; notes %v", res.Notes) + } + if strings.Contains(joined, "Add it to your shell settings") { + t.Errorf("the run still asks the person to add the line it added; notes %v", res.Notes) + } + if strings.Contains(joined, home) { + t.Errorf("a note leaks the absolute home: %v", res.Notes) + } + + // A second run finds the line and asks nothing. + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + p := shellPathPrompter(true, true) + if _, err := Install(repo, interactiveOpts(), p); err != nil { + t.Fatal(err) + } + if again := shellPathAsked(askedOf(p)); len(again) != 0 { + t.Errorf("a second run asked again: %q", again) + } + if raw, _ := os.ReadFile(filepath.Join(home, ".zshrc")); string(raw) != shellLine+"\n" { + t.Errorf("a second run changed ~/.zshrc: %q", raw) + } + + // Criterion 3, second half: removal takes out the line abcd added. + rcpt, err := Uninstall(repo, "") + if err != nil { + t.Fatal(err) + } + if !rcpt.ShellPath.Removed { + t.Errorf("uninstall did not remove the line: %+v", rcpt.ShellPath) + } + if _, err := os.Lstat(filepath.Join(home, ".zshrc")); !os.IsNotExist(err) { + t.Errorf("the ~/.zshrc abcd created for its line alone is still there: %v", err) + } +} + +// TestShellPathDeclinedPutsTheLineOnTheChecklist is criterion 3, first half. +func TestShellPathDeclinedPutsTheLineOnTheChecklist(t *testing.T) { + home, res, asked := shellPathRun(t, false, true, interactiveOpts()) + if len(shellPathAsked(asked)) != 1 { + t.Fatalf("the offer was not asked once; asked %q", asked) + } + if _, err := os.Lstat(filepath.Join(home, ".zshrc")); !os.IsNotExist(err) { + t.Errorf("a no wrote ~/.zshrc: %v", err) + } + if joined := notesJoined(res.Notes); !strings.Contains(joined, `export PATH="$HOME/.local/bin:$PATH"`) { + t.Errorf("the line is not listed for the person; notes %v", res.Notes) + } +} + +// TestShellPathNeverAskedWithoutAPerson: --yes and a prompter not at a +// terminal never put the offer and never edit the file. +func TestShellPathNeverAskedWithoutAPerson(t *testing.T) { + for name, run := range map[string]func(t *testing.T) (string, InstallResult, []string){ + "--yes": func(t *testing.T) (string, InstallResult, []string) { + return shellPathRun(t, true, true, installOpts()) + }, + "no terminal": func(t *testing.T) (string, InstallResult, []string) { + return shellPathRun(t, true, false, interactiveOpts()) + }, + } { + t.Run(name, func(t *testing.T) { + home, res, asked := run(t) + if qs := shellPathAsked(asked); len(qs) != 0 { + t.Errorf("the offer was put: %q", qs) + } + if _, err := os.Lstat(filepath.Join(home, ".zshrc")); !os.IsNotExist(err) { + t.Errorf("~/.zshrc was written: %v", err) + } + if !strings.Contains(notesJoined(res.Notes), `export PATH="$HOME/.local/bin:$PATH"`) { + t.Errorf("the line is not listed; notes %v", res.Notes) + } + }) + } +} + +// TestShellPathLinkedFileIsNotEdited is criterion 2, second case: a startup +// file kept elsewhere is never edited; the line goes on the checklist. +func TestShellPathLinkedFileIsNotEdited(t *testing.T) { + home, _ := setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) + zshUser(t) + kept := filepath.Join(t.TempDir(), "zshrc") + if err := os.WriteFile(kept, []byte("alias g=git\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(kept, filepath.Join(home, ".zshrc")); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + p := shellPathPrompter(true, true) + res, err := Install(repo, interactiveOpts(), p) + if err != nil { + t.Fatal(err) + } + if qs := shellPathAsked(askedOf(p)); len(qs) != 0 { + t.Errorf("a linked file was offered: %q", qs) + } + if raw, _ := os.ReadFile(kept); string(raw) != "alias g=git\n" { + t.Errorf("the linked file changed: %q", raw) + } + joined := notesJoined(res.Notes) + if !strings.Contains(joined, "link") || !strings.Contains(joined, `export PATH="$HOME/.local/bin:$PATH"`) { + t.Errorf("the checklist does not say why and what to add; notes %v", res.Notes) + } +} + +// TestShellPathAlreadyAddedDoesNothing is criterion 2, first case. +func TestShellPathAlreadyAddedDoesNothing(t *testing.T) { + home, _ := setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) + zshUser(t) + body := "export PATH=\"$HOME/.local/bin:$PATH\"\n" + if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + p := shellPathPrompter(true, true) + if _, err := Install(repo, interactiveOpts(), p); err != nil { + t.Fatal(err) + } + if qs := shellPathAsked(askedOf(p)); len(qs) != 0 { + t.Errorf("settings that already add the folder were offered a second line: %q", qs) + } + if raw, _ := os.ReadFile(filepath.Join(home, ".zshrc")); string(raw) != body { + t.Errorf("~/.zshrc changed: %q", raw) + } +} + +// TestShellPathOfferGapIsRaised: detection names the offer beside the PATH +// gap, which still carries the line. +func TestShellPathOfferGapIsRaised(t *testing.T) { + home, pluginRoot := setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) + zshUser(t) + linkOwned(t, filepath.Join(home, ".local", "bin", "abcd"), pluginRoot) + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + g := gapByID(det.Gaps, ShellPathOfferGapID) + if g == nil { + t.Fatalf("no %s gap: %+v", ShellPathOfferGapID, det.Gaps) + } + if g.Required || !g.Resolvable || g.Category != ShellPath { + t.Errorf("the offer gap must be optional and resolvable in its own category: %+v", g) + } + if !strings.Contains(g.Detail, "~/.zshrc") { + t.Errorf("the offer does not name the file: %+v", g) + } +} diff --git a/internal/core/ahoy/store.go b/internal/core/ahoy/store.go index 3215d37277..8a78c5d49c 100644 --- a/internal/core/ahoy/store.go +++ b/internal/core/ahoy/store.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core" + "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/shellquote" ) @@ -463,7 +464,7 @@ func shadowMessage(e pathEntry, target string) string { // PATH, shared by the detection gap and the install-time note. func pathReachMessage(dir string) string { return displayPath(dir) + " is not on PATH, so `abcd` cannot be run by name. " + - "Add it to your shell profile: " + exportPathLine(dir) + "Add this line to your shell settings: " + exportPathLine(dir) } // dirWritable reports whether a file can actually be created in dir. It probes @@ -481,13 +482,10 @@ func dirWritable(dir string) bool { } // exportPathLine renders the one-line PATH fix for dir, in the form a user can -// paste. abcd prints it and never patches a shell profile (script-first). +// paste: the line the shell-settings offer adds, without its marker +// (shellpath.ExportLine), so the printed fix and the added line never differ. func exportPathLine(dir string) string { - d := displayPath(dir) - if strings.HasPrefix(d, "~/") { - d = "$HOME/" + strings.TrimPrefix(d, "~/") - } - return `export PATH="` + d + `:$PATH"` + return shellpath.ExportLine(userHome(), dir) } // --------------------------------------------------------------------------- diff --git a/internal/core/shellpath/shellpath.go b/internal/core/shellpath/shellpath.go new file mode 100644 index 0000000000..9c10afec28 --- /dev/null +++ b/internal/core/shellpath/shellpath.go @@ -0,0 +1,745 @@ +// Package shellpath is the one place abcd edits a person's shell settings: the +// line that puts the folder abcd installs into on the PATH +// (itd-2610240919030303, adr-2610241022330872). It never prints and never +// asks; `abcd ahoy install` asks the question and the setup flow lists the +// line when the answer is no. What it does on a yes is held to one bar: +// +// - it edits only the zsh or bash startup file the person's login shell +// reads in a new terminal, and only one it can read whole: a regular file, +// not a link, owned by the person, writable by no one else, and within +// MaxFileBytes; +// - it never adds the folder twice: a startup file that already adds it (or +// a PATH that already reaches it) is left alone; +// - it keeps a copy of the file in abcd's backups folder first (no copy, no +// write), replaces the file atomically, reads it back, and puts it back +// from the copy when it does not say what was written; +// - it adds exactly one line, ending in Marker, and records it in abcd's +// home, so Remove takes out that line and nothing else. +// +// Which file each shell reads (Plan documents the choice): +// +// - zsh: ${ZDOTDIR:-$HOME}/.zshrc, which every interactive zsh reads, the +// login shell a macOS terminal opens and the non-login one most Linux +// terminals open alike; .zprofile is read by login shells only. +// - bash on macOS: the login file bash reads, the first of .bash_profile, +// .bash_login and .profile that exists, else a new .bash_profile; a +// macOS terminal opens a login shell, which never reads .bashrc, and +// creating .bash_profile beside an existing .profile would stop bash +// reading .profile. +// - bash on Linux: ~/.bashrc, which the non-login shell a terminal opens +// reads. +package shellpath + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// Marker ends the one line abcd adds, so the line can be found and removed +// again and a person reading the file can tell who put it there. +const Marker = "# added by abcd ahoy install" + +// MaxFileBytes is the largest startup file abcd edits. A file it cannot read +// whole is a file it cannot vouch for leaving otherwise unchanged. +const MaxFileBytes = 256 << 10 + +// recordLeaf is the record of the line abcd added, in abcd's home. +const recordLeaf = "shell-path.json" + +// backupKeep is how many copies of each startup file the backups folder +// keeps, the bound the harness-settings copies are held to. +const backupKeep = 10 + +// Env is the part of the environment a plan reads. CurrentEnv fills it from +// the process; a test builds its own. +type Env struct { + Home string // the person's home directory, absolute + Shell string // $SHELL, the login shell + ZDotDir string // $ZDOTDIR, where zsh looks for its startup files + Path string // $PATH + GOOS string // runtime.GOOS +} + +// CurrentEnv reads Env from the process environment. +func CurrentEnv() Env { + home, _ := os.UserHomeDir() + return Env{ + Home: home, + Shell: os.Getenv("SHELL"), + ZDotDir: os.Getenv("ZDOTDIR"), + Path: os.Getenv("PATH"), + GOOS: runtime.GOOS, + } +} + +// State is what a plan found. +type State string + +const ( + // OnPath: the PATH already reaches the folder; there is nothing to do. + OnPath State = "on_path" + // AlreadyAdded: a startup file already adds the folder (abcd's own line + // or the person's); nothing is edited, and a new terminal picks it up. + AlreadyAdded State = "already_added" + // Offer: abcd may add its line to File, on the person's yes. + Offer State = "offer" + // Checklist: abcd will not edit the file (Reason says why); the line is + // the person's to add. + Checklist State = "checklist" +) + +// Plan is what Plan found: the file and line to show the person before +// asking, or why there is nothing to ask. +type Plan struct { + State State `json:"state"` + Shell string `json:"shell,omitempty"` // "zsh" or "bash"; "" for another shell + File string `json:"file,omitempty"` // the file to edit, or the one that already adds the folder + Line string `json:"line"` // the marked line abcd adds + Export string `json:"export"` // the same line without the marker, for the checklist + Reason string `json:"reason,omitempty"` + Create bool `json:"create,omitempty"` // File does not exist yet; Apply creates it + + dir string + current []byte // File's bytes when planned; nil when absent +} + +// Applied is what Apply did. +type Applied struct { + File string `json:"file"` + Line string `json:"line"` + Backup string `json:"backup,omitempty"` // the copy of the file as it was; "" when Apply created it + // RecordNote is set when the line was added but its record could not be + // kept: Remove still finds the line by its marker. + RecordNote string `json:"record_note,omitempty"` + // PruneNote is set when older copies could not be pruned. + PruneNote string `json:"prune_note,omitempty"` +} + +// Removed is what Remove did. +type Removed struct { + Removed bool `json:"removed"` + File string `json:"file,omitempty"` + Backup string `json:"backup,omitempty"` + Note string `json:"note"` +} + +// ExportLine is the line that puts dir on the PATH, in the form a person +// pastes: $HOME stands for the home directory so the line reads the same on +// every account. The folder comes first, ahead of the PATH it had, so the +// abcd abcd installs is the one that answers rather than an older copy in a +// system folder (the shadowing ahoy reports as symlink.shadowed). +func ExportLine(home, dir string) string { + return `export PATH="` + shellDir(home, dir) + `:$PATH"` +} + +// MarkedLine is ExportLine with Marker, the one line abcd adds. +func MarkedLine(home, dir string) string { + return ExportLine(home, dir) + " " + Marker +} + +// ChecklistLine is the line the checklist names when the person declines, or +// abcd will not edit the file: the export line, for the person to add. +func ChecklistLine(home, dir string) string { return ExportLine(home, dir) } + +// shellDir renders dir for a double-quoted shell string. +func shellDir(home, dir string) string { + if home != "" { + if rel, err := filepath.Rel(home, dir); err == nil && rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return "$HOME/" + filepath.ToSlash(rel) + } + } + return dir +} + +// unsafeInQuotes are the bytes a double-quoted shell string expands or ends +// on; a folder holding one is never written into a startup file. +const unsafeInQuotes = "\"$`\\\n" + +// PlanFor decides, reading only, whether abcd may add dir to the PATH in the +// startup file the person's login shell reads, and which file and line that +// is. It never writes. +func PlanFor(env Env, dir string) Plan { + p := Plan{dir: dir, Line: MarkedLine(env.Home, dir), Export: ExportLine(env.Home, dir)} + if onPath(env.Path, dir) { + p.State = OnPath + return p + } + if env.Home == "" || !filepath.IsAbs(env.Home) { + return checklist(p, "no home directory is resolved") + } + if strings.ContainsAny(dir, unsafeInQuotes) { + return checklist(p, "the folder's name holds a character a shell would expand") + } + p.Shell = filepath.Base(env.Shell) + switch p.Shell { + case "zsh", "bash": + default: + shown := p.Shell + if shown == "" || shown == "." { + shown = "not named" + } + p.Shell = "" + return checklist(p, "abcd edits zsh and bash settings only, and your login shell is "+shown) + } + target, read, why := files(env, p.Shell) + if why != "" { + return checklist(p, why) + } + p.File = target + // Already added: abcd's own line, or a line of the person's that sets the + // PATH to include the folder, in any file this shell reads. + for _, f := range read { + if f == "" { + continue + } + if raw, ok := readFollowing(f); ok && addsDir(raw, env.Home, dir) { + p.State = AlreadyAdded + p.File = f + return p + } + } + cur, why := editable(target) + if why != "" { + return checklist(p, why) + } + p.current = cur + p.Create = cur == nil + p.State = Offer + return p +} + +func checklist(p Plan, why string) Plan { + p.State = Checklist + p.Reason = why + return p +} + +// files names the file abcd edits for shell, and every file that shell reads +// at startup, which the already-added check reads. +func files(env Env, shell string) (target string, read []string, why string) { + h := env.Home + if shell == "zsh" { + zdot := h + if env.ZDotDir != "" { + z := filepath.Clean(env.ZDotDir) + if !filepath.IsAbs(z) || !within(h, z) { + return "", nil, "ZDOTDIR names a folder outside your home folder, which abcd does not edit" + } + zdot = z + } + read = []string{filepath.Join(h, ".zshenv")} + for _, leaf := range []string{".zshenv", ".zprofile", ".zshrc", ".zlogin"} { + if f := filepath.Join(zdot, leaf); f != read[0] { + read = append(read, f) + } + } + return filepath.Join(zdot, ".zshrc"), read, "" + } + login := []string{filepath.Join(h, ".bash_profile"), filepath.Join(h, ".bash_login"), filepath.Join(h, ".profile")} + read = append(append([]string{}, login...), filepath.Join(h, ".bashrc")) + if env.GOOS != "darwin" { + return filepath.Join(h, ".bashrc"), read, "" + } + for _, f := range login { + if _, err := os.Lstat(f); err == nil { + return f, read, "" + } + } + return login[0], read, "" +} + +// within reports whether p is home or a path below it. +func within(home, p string) bool { + rel, err := filepath.Rel(home, p) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// onPath reports whether dir is one of the entries of pathList, comparing +// each through its symlinks. +func onPath(pathList, dir string) bool { + want := resolve(dir) + for _, d := range filepath.SplitList(pathList) { + if d != "" && resolve(d) == want { + return true + } + } + return false +} + +func resolve(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return filepath.Clean(p) +} + +// readFollowing reads a startup file for the already-added check, following a +// link (reading changes nothing) but never a non-regular file and never past +// MaxFileBytes. +func readFollowing(path string) ([]byte, bool) { + fi, err := os.Stat(path) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > MaxFileBytes { + return nil, false + } + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return nil, false + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1)) + if err != nil || len(raw) > MaxFileBytes { + return nil, false + } + return raw, true +} + +// addsDir reports whether raw already puts dir on the PATH: abcd's own marked +// line, or an uncommented line that names the PATH and the folder in any of +// the spellings a person writes it in. The check is textual and conservative: +// it can mistake a line for one that adds the folder, which costs the offer, +// never a second line. +func addsDir(raw []byte, home, dir string) bool { + forms := []string{dir} + if home != "" && within(home, dir) && dir != home { + rel, _ := filepath.Rel(home, dir) + rel = filepath.ToSlash(rel) + forms = append(forms, "$HOME/"+rel, "${HOME}/"+rel, "~/"+rel) + } + for _, ln := range strings.Split(string(raw), "\n") { + t := strings.TrimSpace(ln) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + if strings.HasSuffix(t, Marker) { + return true + } + if !strings.Contains(strings.ToLower(t), "path") { + continue + } + for _, f := range forms { + if containsWord(t, f) { + return true + } + } + } + return false +} + +// containsWord reports whether s holds f followed by the end of a PATH entry. +func containsWord(s, f string) bool { + for i := 0; ; { + j := strings.Index(s[i:], f) + if j < 0 { + return false + } + end := i + j + len(f) + if end == len(s) || strings.ContainsRune(":\"' )\t;}/", rune(s[end])) { + if end == len(s) || s[end] != '/' || end+1 == len(s) || strings.ContainsRune(":\"' )\t;}", rune(s[end+1])) { + return true + } + } + i = i + j + 1 + } +} + +// editable reads the file abcd would edit and says why it will not, or "" +// when it may: absent (it is created), or a regular file, not a link, owned +// by the person, writable by no one else, within MaxFileBytes. A file that +// is a link is kept elsewhere (shared dotfiles), and an edit through the +// link would change that other copy. +func editable(path string) (current []byte, why string) { + shown := fsutil.RedactHome(path) + fi, err := os.Lstat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + parent, perr := os.Lstat(filepath.Dir(path)) + if perr != nil || !parent.IsDir() { + return nil, "the folder " + fsutil.RedactHome(filepath.Dir(path)) + " that holds it is not there" + } + return nil, "" + case err != nil: + return nil, shown + " could not be read (" + err.Error() + ")" + case fi.Mode()&os.ModeSymlink != 0: + return nil, shown + " is a link to a file kept elsewhere, which abcd does not edit" + case !fi.Mode().IsRegular(): + return nil, shown + " is not a regular file" + case fi.Size() > MaxFileBytes: + return nil, shown + " is larger than abcd reads whole" + } + if err := fsutil.CallersAlone(path, fi); err != nil { + return nil, shown + " is owned by another account or writable by others" + } + raw, err := fsutil.ReadGuarded(path, MaxFileBytes) + if err != nil { + return nil, shown + " could not be read whole" + } + if raw == nil { + raw = []byte{} + } + return raw, "" +} + +// record is what abcd keeps of the line it added, so Remove takes out exactly +// that line. +type record struct { + SchemaVersion int `json:"schema_version"` + File string `json:"file"` + Line string `json:"line"` + // Separated: the file did not end in a newline, so abcd added one before + // its line, and Remove takes that newline out with it. + Separated bool `json:"separated,omitempty"` + // Created: the file did not exist, and Remove deletes it when nothing but + // abcd's line was ever in it. + Created bool `json:"created,omitempty"` +} + +// Errors Apply and Remove return; the text completes "…: <text>". +var ( + ErrNotOffered = errors.New("the plan offers no edit") + ErrChanged = errors.New("the file changed since the question was asked, so nothing was written") +) + +// Apply adds the plan's line to its file, on the person's yes. The file is +// judged and read again first, and refused when it is no longer the file the +// person was shown. +func Apply(env Env, p Plan) (Applied, error) { + if p.State != Offer || p.File == "" { + return Applied{}, ErrNotOffered + } + shown := fsutil.RedactHome(p.File) + cur, why := editable(p.File) + if why != "" { + return Applied{}, errors.New(why + ", so nothing was written") + } + if (cur == nil) != p.Create || (cur != nil && !bytes.Equal(cur, p.current)) { + return Applied{}, ErrChanged + } + rec := record{SchemaVersion: 1, File: p.File, Line: p.Line, Created: p.Create} + next := append([]byte{}, cur...) + if len(next) > 0 && next[len(next)-1] != '\n' { + next = append(next, '\n') + rec.Separated = true + } + next = append(next, []byte(p.Line+"\n")...) + + out := Applied{File: p.File, Line: p.Line} + if p.Create { + if err := createFile(p.File, next); err != nil { + return Applied{}, errors.New(shown + " could not be created (" + fsutil.RedactHome(err.Error()) + "); nothing was written") + } + } else { + backup, pruned, err := keepCopy(env.Home, p.File, cur) + if err != nil { + return Applied{}, errors.New("a copy of " + shown + " could not be kept in " + abcdhome.Display("backups") + + "/ first (" + fsutil.RedactHome(err.Error()) + "), so nothing was written") + } + out.Backup = backup + if pruned != nil { + out.PruneNote = "older copies of " + shown + " in " + abcdhome.Display("backups") + "/ could not be pruned (" + pruned.Error() + ")" + } + if err := replace(p.File, cur, next, backup); err != nil { + return Applied{}, err + } + } + if err := writeRecord(env.Home, rec); err != nil { + out.RecordNote = "the record of the line could not be kept in " + abcdhome.Display(recordLeaf) + + " (" + fsutil.RedactHome(err.Error()) + "); `abcd ahoy uninstall` still finds the line by its marker" + } + return out, nil +} + +// createFile creates path holding data, never over a file that appeared +// since it was judged absent. +func createFile(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0o644) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(path) + return err + } + if err := fsutil.Flush(f); err != nil { + f.Close() + os.Remove(path) + return err + } + if err := f.Close(); err != nil { + os.Remove(path) + return err + } + return readBack(path, data) +} + +// writeFile replaces a startup file atomically, keeping its mode. A variable +// so a test can make the write land something other than what was asked, +// which is what the read-back exists to catch. +var writeFile = fsutil.WriteFileAtomicPreserveMode + +// replace writes next over path, whose bytes now are cur and whose copy is at +// backup, reads it back, and puts cur back when it does not read as written. +func replace(path string, cur, next []byte, backup string) error { + shown := fsutil.RedactHome(path) + copyAt := fsutil.RedactHome(backup) + if err := writeFile(path, next); err != nil { + return errors.New(shown + " could not be written (" + fsutil.RedactHome(err.Error()) + "); it is as it was, and a copy is at " + copyAt) + } + if err := readBack(path, next); err == nil { + return nil + } + if rerr := fsutil.WriteFileAtomicPreserveMode(path, cur); rerr != nil { + return errors.New(shown + " did not read back as written, and putting it back failed too (" + + fsutil.RedactHome(rerr.Error()) + "): copy " + copyAt + " over it by hand") + } + return errors.New(shown + " did not read back as written, so it was put back from the copy at " + copyAt) +} + +// readBack confirms path holds exactly want. +func readBack(path string, want []byte) error { + got, err := fsutil.ReadGuarded(path, MaxFileBytes+int64(len(want))) + if err != nil { + return err + } + if !bytes.Equal(got, want) { + return errors.New("the file does not hold what was written") + } + return nil +} + +// Remove takes out the line abcd added and nothing else, for `abcd ahoy +// uninstall`. The file comes from the record abcd kept; with none, every +// startup file the person's shell reads is searched for abcd's exact line for +// dir. A line the person has since edited is no longer abcd's and is left. +func Remove(env Env, dir string) (Removed, error) { + rec, rerr := readRecord(env.Home) + var candidates []record + switch { + case rerr != nil: + return Removed{Note: "the record in " + abcdhome.Display(recordLeaf) + " could not be read (" + rerr.Error() + "); no shell settings file was changed"}, nil + case rec != nil: + candidates = []record{*rec} + default: + if dir == "" || env.Home == "" { + return Removed{Note: "abcd added no line to a shell settings file"}, nil + } + line := MarkedLine(env.Home, dir) + for _, shell := range []string{"zsh", "bash"} { + _, read, _ := files(env, shell) + for _, f := range read { + if raw, ok := readFollowing(f); ok && lineIndex(raw, line) >= 0 { + candidates = append(candidates, record{File: f, Line: line}) + } + } + } + if len(candidates) == 0 { + return Removed{Note: "abcd added no line to a shell settings file"}, nil + } + } + c := candidates[0] + shown := fsutil.RedactHome(c.File) + cur, why := editable(c.File) + switch { + case why != "": + return Removed{File: c.File, Note: why + ", so abcd's line was left in it; remove `" + c.Line + "` by hand"}, nil + case cur == nil: + _ = removeRecord(env.Home) + return Removed{File: c.File, Note: shown + " is not there; nothing to remove"}, nil + } + i := lineIndex(cur, c.Line) + if i < 0 { + _ = removeRecord(env.Home) + return Removed{File: c.File, Note: shown + " no longer holds abcd's line; nothing in it was changed"}, nil + } + end := i + len(c.Line) + if end < len(cur) && cur[end] == '\n' { + end++ + } + next := append(append([]byte{}, cur[:i]...), cur[end:]...) + if c.Separated && end == len(cur) && len(next) > 0 && next[len(next)-1] == '\n' { + next = next[:len(next)-1] + } + if c.Created && len(next) == 0 { + if err := os.Remove(c.File); err != nil { + return Removed{}, errors.New(shown + " could not be removed (" + fsutil.RedactHome(err.Error()) + ")") + } + _ = removeRecord(env.Home) + return Removed{Removed: true, File: c.File, Note: "removed " + shown + ", which abcd created for its line alone"}, nil + } + backup, _, err := keepCopy(env.Home, c.File, cur) + if err != nil { + return Removed{}, errors.New("a copy of " + shown + " could not be kept in " + abcdhome.Display("backups") + + "/ first (" + fsutil.RedactHome(err.Error()) + "), so abcd's line was left in it") + } + if err := replace(c.File, cur, next, backup); err != nil { + return Removed{}, err + } + _ = removeRecord(env.Home) + return Removed{Removed: true, File: c.File, Backup: backup, + Note: "removed abcd's PATH line from " + shown + "; the file as it was is kept at " + fsutil.RedactHome(backup)}, nil +} + +// lineIndex is the offset of the last whole line of raw that is exactly line, +// or -1. +func lineIndex(raw []byte, line string) int { + at := -1 + off := 0 + for _, ln := range bytes.SplitAfter(raw, []byte("\n")) { + if string(bytes.TrimSuffix(ln, []byte("\n"))) == line { + at = off + } + off += len(ln) + } + return at +} + +// --------------------------------------------------------------------------- +// abcd's home: the record and the copies +// --------------------------------------------------------------------------- + +func writeRecord(home string, rec record) error { + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + dir, err := fsutil.EnsureHomeScope(home, abcdhome.Rel(), abcdhome.DirMode) + if err != nil { + return err + } + defer dir.Close() + return fsutil.WriteFileAtomicInRoot(dir, recordLeaf, append(data, '\n'), abcdhome.FileMode) +} + +// readRecord reads the record: nil with no error when there is none. +func readRecord(home string) (*record, error) { + if home == "" { + return nil, nil + } + dir, err := fsutil.OpenHomeScope(home, abcdhome.Rel()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer dir.Close() + raw, err := fsutil.ReadGuardedInRoot(dir, recordLeaf, 64<<10) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var rec record + if err := json.Unmarshal(raw, &rec); err != nil { + return nil, errors.New("it is not valid JSON") + } + if rec.File == "" || !filepath.IsAbs(rec.File) || !strings.HasSuffix(rec.Line, Marker) || strings.Contains(rec.Line, "\n") { + return nil, errors.New("it does not name an absolute file and a line ending in abcd's marker") + } + return &rec, nil +} + +func removeRecord(home string) error { + dir, err := fsutil.OpenHomeScope(home, abcdhome.Rel()) + if err != nil { + return err + } + defer dir.Close() + return dir.Remove(recordLeaf) +} + +// keepCopy keeps raw, the file's bytes now, in abcd's backups folder as +// shell-<leaf>.<UTC stamp>, created exclusively and private to the account, +// and prunes that file's copies to the newest backupKeep. +func keepCopy(home, path string, raw []byte) (backup string, pruned, err error) { + if home == "" { + return "", nil, errors.New("no home directory is resolved") + } + dir, err := fsutil.EnsureHomeScope(home, abcdhome.Rel("backups"), abcdhome.DirMode) + if err != nil { + return "", nil, err + } + defer dir.Close() + prefix := backupPrefix(path) + stamp := prefix + time.Now().UTC().Format("20060102T150405Z") + for n := 1; n <= 100; n++ { + leaf := stamp + if n > 1 { + leaf += "-" + strconv.Itoa(n) + } + err := fsutil.CreateExclusiveIn(dir, leaf, raw, abcdhome.FileMode) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", nil, err + } + return abcdhome.Path(home, "backups", leaf), prune(dir, prefix), nil + } + return "", nil, errors.New("a hundred copies already carry this second's stamp") +} + +// backupPrefix names a startup file's copies: shell-zshrc. for ~/.zshrc. +func backupPrefix(path string) string { + return "shell-" + strings.TrimPrefix(filepath.Base(path), ".") + "." +} + +// prune removes all but the newest backupKeep copies carrying prefix. Only a +// regular file named exactly like a copy counts; anything else is left. +func prune(dir *os.Root, prefix string) error { + re := regexp.MustCompile(`^` + regexp.QuoteMeta(prefix) + `(\d{8}T\d{6}Z)(?:-(\d+))?$`) + ents, err := fs.ReadDir(dir.FS(), ".") + if err != nil { + return err + } + type kept struct { + leaf, stamp string + n int + } + var copies []kept + for _, e := range ents { + m := re.FindStringSubmatch(e.Name()) + if m == nil || !e.Type().IsRegular() { + continue + } + n := 1 + if m[2] != "" { + if n, err = strconv.Atoi(m[2]); err != nil { + continue + } + } + copies = append(copies, kept{leaf: e.Name(), stamp: m[1], n: n}) + } + if len(copies) <= backupKeep { + return nil + } + sort.Slice(copies, func(i, j int) bool { + if copies[i].stamp != copies[j].stamp { + return copies[i].stamp < copies[j].stamp + } + return copies[i].n < copies[j].n + }) + var first error + for _, c := range copies[:len(copies)-backupKeep] { + if err := dir.Remove(c.leaf); err != nil && first == nil { + first = err + } + } + return first +} diff --git a/internal/core/shellpath/shellpath_test.go b/internal/core/shellpath/shellpath_test.go new file mode 100644 index 0000000000..1c89c644b8 --- /dev/null +++ b/internal/core/shellpath/shellpath_test.go @@ -0,0 +1,333 @@ +package shellpath + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/abcdhome" +) + +// env is a person with a temporary home, the given login shell, and a PATH +// that does not reach ~/.local/bin. +func env(t *testing.T, shell, goos string) (Env, string) { + t.Helper() + home := t.TempDir() + dir := filepath.Join(home, ".local", "bin") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return Env{Home: home, Shell: shell, Path: "/usr/bin:/bin", GOOS: goos}, dir +} + +func write(t *testing.T, path, body string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +const wantLine = `export PATH="$HOME/.local/bin:$PATH" # added by abcd ahoy install` + +// TestOfferAddsOneLineAndRemoveTakesOnlyIt is the whole round trip the intent +// promises: the plan names the file and the line, a yes keeps a copy and adds +// that one line, a second plan finds it and offers nothing, and removal puts +// the file back byte for byte, a file with no final newline included. +func TestOfferAddsOneLineAndRemoveTakesOnlyIt(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + orig := "alias ll='ls -l'\nexport EDITOR=vi" // no final newline + write(t, rc, orig, 0o644) + + p := PlanFor(e, dir) + if p.State != Offer || p.File != rc || p.Line != wantLine || p.Create { + t.Fatalf("plan = %+v, want an offer to add %q to %s", p, wantLine, rc) + } + if p.Export != `export PATH="$HOME/.local/bin:$PATH"` || ChecklistLine(e.Home, dir) != p.Export { + t.Errorf("checklist line = %q", p.Export) + } + got, err := Apply(e, p) + if err != nil { + t.Fatal(err) + } + if want := orig + "\n" + wantLine + "\n"; read(t, rc) != want { + t.Errorf("after apply the file is %q, want %q", read(t, rc), want) + } + if got.Backup == "" || read(t, got.Backup) != orig { + t.Errorf("the copy kept is %q, want the file as it was", got.Backup) + } + if fi, err := os.Stat(rc); err != nil || fi.Mode().Perm() != 0o644 { + t.Errorf("the file's mode changed: %v %v", fi.Mode(), err) + } + if _, err := os.Stat(abcdhome.Path(e.Home, recordLeaf)); err != nil { + t.Errorf("no record of the line: %v", err) + } + + if again := PlanFor(e, dir); again.State != AlreadyAdded || again.File != rc { + t.Fatalf("second plan = %+v, want already added (never a second line)", again) + } + + // The person adds a line after abcd's; removal takes abcd's alone. + write(t, rc, read(t, rc)+"alias g=git\n", 0o644) + r, err := Remove(e, dir) + if err != nil || !r.Removed { + t.Fatalf("remove = %+v, %v", r, err) + } + if want := orig + "\nalias g=git\n"; read(t, rc) != want { + t.Errorf("after removal the file is %q, want %q", read(t, rc), want) + } + if _, err := os.Stat(abcdhome.Path(e.Home, recordLeaf)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the record outlived the line: %v", err) + } +} + +// TestRemoveRestoresAFileWithNoFinalNewline holds the byte-for-byte half: the +// newline abcd added before its line goes with it. +func TestRemoveRestoresAFileWithNoFinalNewline(t *testing.T) { + e, dir := env(t, "/bin/zsh", "linux") + rc := filepath.Join(e.Home, ".zshrc") + orig := "setopt autocd" + write(t, rc, orig, 0o600) + if _, err := Apply(e, PlanFor(e, dir)); err != nil { + t.Fatal(err) + } + if _, err := Remove(e, dir); err != nil { + t.Fatal(err) + } + if read(t, rc) != orig { + t.Errorf("after removal the file is %q, want %q", read(t, rc), orig) + } + if fi, _ := os.Stat(rc); fi.Mode().Perm() != 0o600 { + t.Errorf("the mode is %v, want 0600 kept", fi.Mode().Perm()) + } +} + +// TestCreatedFileIsRemovedWithTheLine: a file abcd created for its line alone +// goes when the line does. +func TestCreatedFileIsRemovedWithTheLine(t *testing.T) { + e, dir := env(t, "/usr/bin/zsh", "linux") + p := PlanFor(e, dir) + if p.State != Offer || !p.Create { + t.Fatalf("plan = %+v, want an offer to create .zshrc", p) + } + if _, err := Apply(e, p); err != nil { + t.Fatal(err) + } + if read(t, p.File) != wantLine+"\n" { + t.Errorf("created file holds %q", read(t, p.File)) + } + if _, err := Remove(e, dir); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(p.File); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the file abcd created is still there: %v", err) + } +} + +// TestLinkedFileGoesOnTheChecklist: a startup file that is a link (shared +// dotfiles) is never edited. +func TestLinkedFileGoesOnTheChecklist(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + kept := filepath.Join(t.TempDir(), "dotfiles-zshrc") + write(t, kept, "export EDITOR=vi\n", 0o644) + if err := os.Symlink(kept, filepath.Join(e.Home, ".zshrc")); err != nil { + t.Fatal(err) + } + p := PlanFor(e, dir) + if p.State != Checklist || !strings.Contains(p.Reason, "link") { + t.Fatalf("plan = %+v, want the checklist for a linked file", p) + } + if _, err := Apply(e, p); err == nil { + t.Error("apply edited a plan that offers nothing") + } + if read(t, kept) != "export EDITOR=vi\n" { + t.Error("the linked file changed") + } +} + +// TestAlreadyAddedByThePerson: a line of the person's own that puts the folder +// on the PATH, in any file the shell reads, means nothing is offered. +func TestAlreadyAddedByThePerson(t *testing.T) { + for _, line := range []string{ + `export PATH="$HOME/.local/bin:$PATH"`, + `export PATH=~/.local/bin:$PATH`, + `path=(${HOME}/.local/bin $path)`, + } { + e, dir := env(t, "/bin/zsh", "darwin") + write(t, filepath.Join(e.Home, ".zprofile"), line+"\n", 0o644) + if p := PlanFor(e, dir); p.State != AlreadyAdded { + t.Errorf("%q: plan = %+v, want already added", line, p) + } + if _, err := os.Lstat(filepath.Join(e.Home, ".zshrc")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%q: .zshrc was touched", line) + } + } + // A commented line, or one naming a deeper folder, adds nothing. + for _, line := range []string{`# export PATH="$HOME/.local/bin:$PATH"`, `export PATH="$HOME/.local/bin/sub:$PATH"`} { + e, dir := env(t, "/bin/zsh", "darwin") + write(t, filepath.Join(e.Home, ".zshrc"), line+"\n", 0o644) + if p := PlanFor(e, dir); p.State != Offer { + t.Errorf("%q: plan = %+v, want an offer", line, p) + } + } +} + +// TestOnPathOffersNothing. +func TestOnPathOffersNothing(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + e.Path = "/usr/bin:" + dir + if p := PlanFor(e, dir); p.State != OnPath { + t.Errorf("plan = %+v, want on path", p) + } +} + +// TestBashFilePerSystem holds the file choice for bash. +func TestBashFilePerSystem(t *testing.T) { + e, dir := env(t, "/bin/bash", "linux") + if p := PlanFor(e, dir); p.File != filepath.Join(e.Home, ".bashrc") { + t.Errorf("linux bash file = %s, want ~/.bashrc", p.File) + } + e, dir = env(t, "/bin/bash", "darwin") + if p := PlanFor(e, dir); p.File != filepath.Join(e.Home, ".bash_profile") || !p.Create { + t.Errorf("macOS bash with no login file: %+v, want a new ~/.bash_profile", p) + } + write(t, filepath.Join(e.Home, ".profile"), "umask 022\n", 0o644) + if p := PlanFor(e, dir); p.File != filepath.Join(e.Home, ".profile") || p.Create { + t.Errorf("macOS bash with ~/.profile: %+v, want ~/.profile, never a .bash_profile that hides it", p) + } +} + +// TestUnsupportedAndUnsafe: another shell, a ZDOTDIR outside home, and a file +// another account could write all go on the checklist. +func TestUnsupportedAndUnsafe(t *testing.T) { + e, dir := env(t, "/opt/homebrew/bin/fish", "darwin") + if p := PlanFor(e, dir); p.State != Checklist || p.Shell != "" || !strings.Contains(p.Reason, "fish") { + t.Errorf("fish: %+v", p) + } + e, dir = env(t, "/bin/zsh", "darwin") + e.ZDotDir = t.TempDir() + if p := PlanFor(e, dir); p.State != Checklist || !strings.Contains(p.Reason, "ZDOTDIR") { + t.Errorf("ZDOTDIR outside home: %+v", p) + } + e, dir = env(t, "/bin/zsh", "darwin") + write(t, filepath.Join(e.Home, ".zshrc"), "x=1\n", 0o666) + if p := PlanFor(e, dir); p.State != Checklist || !strings.Contains(p.Reason, "writable by others") { + t.Errorf("other-writable file: %+v", p) + } + e, dir = env(t, "/bin/zsh", "darwin") + write(t, filepath.Join(e.Home, ".zshrc"), strings.Repeat("#\n", MaxFileBytes), 0o644) + if p := PlanFor(e, dir); p.State != Checklist { + t.Errorf("oversize file: %+v", p) + } +} + +// TestZDotDirInsideHome is honoured. +func TestZDotDirInsideHome(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + e.ZDotDir = filepath.Join(e.Home, ".config", "zsh") + if err := os.MkdirAll(e.ZDotDir, 0o755); err != nil { + t.Fatal(err) + } + if p := PlanFor(e, dir); p.State != Offer || p.File != filepath.Join(e.ZDotDir, ".zshrc") { + t.Errorf("plan = %+v, want $ZDOTDIR/.zshrc", p) + } +} + +// TestApplyRefusesAFileChangedSinceTheQuestion. +func TestApplyRefusesAFileChangedSinceTheQuestion(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + write(t, rc, "a=1\n", 0o644) + p := PlanFor(e, dir) + write(t, rc, "a=2\n", 0o644) + if _, err := Apply(e, p); !errors.Is(err, ErrChanged) { + t.Errorf("apply over a changed file: %v, want ErrChanged", err) + } + if read(t, rc) != "a=2\n" { + t.Error("the changed file was written") + } +} + +// TestReadBackMismatchPutsTheFileBack: a write that lands something other than +// what was asked is undone from the copy. +func TestReadBackMismatchPutsTheFileBack(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + write(t, rc, "a=1\n", 0o644) + orig := writeFile + writeFile = func(path string, _ []byte) error { return os.WriteFile(path, []byte("garbled"), 0o644) } + t.Cleanup(func() { writeFile = orig }) + _, err := Apply(e, PlanFor(e, dir)) + if err == nil || !strings.Contains(err.Error(), "put back") { + t.Fatalf("apply = %v, want the file put back", err) + } + if read(t, rc) != "a=1\n" { + t.Errorf("the file is %q, want it put back", read(t, rc)) + } +} + +// TestRemoveWithoutRecordFindsTheMarkedLine, and leaves a line the person +// edited alone. +func TestRemoveWithoutRecordFindsTheMarkedLine(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + write(t, rc, "a=1\n"+wantLine+"\nb=2\n", 0o644) + if r, err := Remove(e, dir); err != nil || !r.Removed { + t.Fatalf("remove = %+v, %v", r, err) + } + if read(t, rc) != "a=1\nb=2\n" { + t.Errorf("file = %q", read(t, rc)) + } + + edited := strings.Replace(wantLine, "$PATH", "$PATH:/x", 1) + write(t, rc, "a=1\n"+edited+"\n", 0o644) + if r, err := Remove(e, dir); err != nil || r.Removed { + t.Errorf("remove of an edited line = %+v, %v; want it left", r, err) + } + if read(t, rc) != "a=1\n"+edited+"\n" { + t.Error("an edited line was removed") + } +} + +// TestCopiesArePrunedToTen. +func TestCopiesArePrunedToTen(t *testing.T) { + e, _ := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + for i := 0; i < backupKeep+3; i++ { + if _, pruned, err := keepCopy(e.Home, rc, []byte("x")); err != nil || pruned != nil { + t.Fatal(err, pruned) + } + } + other := abcdhome.Path(e.Home, "backups", "settings.json.20200101T000000Z") + write(t, other, "{}", 0o600) + if _, _, err := keepCopy(e.Home, rc, []byte("x")); err != nil { + t.Fatal(err) + } + ents, _ := os.ReadDir(abcdhome.Path(e.Home, "backups")) + n := 0 + for _, d := range ents { + if strings.HasPrefix(d.Name(), "shell-zshrc.") { + n++ + } + } + if n != backupKeep { + t.Errorf("%d copies kept, want %d", n, backupKeep) + } + if _, err := os.Stat(other); err != nil { + t.Errorf("another file's copy was pruned: %v", err) + } +} diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index ef85b4271a..06636c5f36 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -215,6 +215,10 @@ func gitRepoWithIdentity(t *testing.T, repo, name, email string) { func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") + // A zsh login shell, so the shell-settings offer is raised whatever shell + // the machine running the test uses (itd-2610240919030303). + t.Setenv("SHELL", "/bin/zsh") + t.Setenv("ZDOTDIR", "") base := []string{"ahoy", "install", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", @@ -260,8 +264,10 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { // table is accepted only on an answer, so --yes names them as well; and so // is the drain eligibility record (ruling BX2), which decides what an // unattended agent may change; and so is the README question - // (spc-2610031704338417), which only the owner answers. - want := "git_identity.unpinned readme.offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" + // (spc-2610031704338417), which only the owner answers; and so is the + // shell-settings offer (itd-2610240919030303), which edits a file of the + // person's only on their own yes. + want := "git_identity.unpinned readme.offered path.shell_settings_offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" if strings.Join(res.OptionalSkipped, " ") != want { t.Fatalf("optional_skipped = %v, want [%s]\n%s", res.OptionalSkipped, want, jsonOut) } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 59600139ae..6d9235a05e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4297,6 +4297,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " readme: %s\n", termsafe.Sanitize(receipt.Readme.Note)) fmt.Fprintf(w, " symlink: %s\n", symlinkNote(receipt)) fmt.Fprintf(w, " status line: %s\n", receipt.StatusLine.Note) + fmt.Fprintf(w, " shell settings: %s\n", termsafe.Sanitize(receipt.ShellPath.Note)) }) }, } diff --git a/internal/surface/cli/main_test.go b/internal/surface/cli/main_test.go index 8e836cd6a6..7ab1ca34f7 100644 --- a/internal/surface/cli/main_test.go +++ b/internal/surface/cli/main_test.go @@ -66,6 +66,11 @@ func TestMain(m *testing.M) { os.Exit(Run(os.Args[1:], os.Stdout, os.Stderr)) } pageRunnerExtraEnv = []string{cliTestAsBinaryEnv + "=1"} + // The machine's login shell never shapes a setup run here: with no shell + // named, the shell-settings offer (itd-2610240919030303) is not raised, + // and a test that wants it names one with t.Setenv. + os.Setenv("SHELL", "") + os.Unsetenv("ZDOTDIR") ahoy.SetCurrentVintageForTest(func() vintage.Current { return vintage.Current{Revision: "testvintage", Known: true} }) From a0f82585ed6c06d477f8da97a27a300a526c65fe Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:04:56 +0200 Subject: [PATCH 03/26] fix: report the install mode of a recorded copy with no plugin root A plain-terminal run after the README's install line resolves no plugin root, and detection left install_mode empty for a working copy that ~/.abcd.noindex/path-entry records. The copy the record names, whose bytes still match it, is now reported as pinned. Resolves: iss-2610240916382802 Assisted-by: Claude:claude-opus-5-5 --- ...hoy-reports-an-empty-install-mode-for-a.md | 4 ++ internal/core/ahoy/detect.go | 14 +++++- .../core/ahoy/install_mode_record_test.go | 44 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) rename .abcd/work/issues/{open => resolved}/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md (82%) create mode 100644 internal/core/ahoy/install_mode_record_test.go diff --git a/.abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md b/.abcd/work/issues/resolved/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md similarity index 82% rename from .abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md rename to .abcd/work/issues/resolved/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md index 81882d6e8a..7a4ee02568 100644 --- a/.abcd/work/issues/open/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md +++ b/.abcd/work/issues/resolved/iss-2610240916382802-ahoy-reports-an-empty-install-mode-for-a.md @@ -10,6 +10,10 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/detect.go" remedy: "classify the PATH entry from ~/.abcd.noindex/path-entry when no plugin root resolves, and report the install mode from the record rather than leaving it empty" +resolution: "the install mode is read from the copy ~/.abcd.noindex/path-entry records when no plugin root resolves" +impact: fix +resolved_by: + spec: "spc-2610241007434505" --- ahoy reports an empty install_mode for a working, recorded PATH entry whenever no plugin root resolves: detectInstallMode returns "" when !pluginOK, so after a successful README install (~/.local/bin/abcd on PATH and named by ~/.abcd.noindex/path-entry) a plain-terminal or session run still says nothing is installed. diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index aa04f3ac5f..efc6ee3a7e 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -945,9 +945,21 @@ func detectBinDirOnPath(dir string, installed bool) []Gap { // unresolved (nothing to attribute a mode to). A mode that another `abcd` earlier // on PATH shadows is reported as shadowed rather than as a healthy install: the // entry is correct and it is still not what runs. +// +// With no plugin root, the copy ~/.abcd.noindex/path-entry records is still an +// install when its bytes match the record: a plain-terminal run after the +// README's install line resolves no plugin root, and the copy it left is +// abcd's all the same (iss-2610240916382802). func detectInstallMode(pluginRoot string, pluginOK bool) string { if !pluginOK { - return "" + rec, ok := readPathEntry() + if !ok || !isOwnedCopyFile(rec.path) { + return "" + } + if _, shadowed := shadowingEntry("", rec.path); shadowed { + return "pinned (shadowed on PATH)" + } + return "pinned" } target := effectiveBinTarget(pluginRoot) if target == "" { diff --git a/internal/core/ahoy/install_mode_record_test.go b/internal/core/ahoy/install_mode_record_test.go new file mode 100644 index 0000000000..0ffc643e32 --- /dev/null +++ b/internal/core/ahoy/install_mode_record_test.go @@ -0,0 +1,44 @@ +package ahoy + +import ( + "os" + "path/filepath" + "testing" +) + +// TestInstallModeReadsTheRecordWithoutAPluginRoot is iss-2610240916382802: a +// working copy at ~/.local/bin/abcd that ~/.abcd.noindex/path-entry records is +// an install whether or not a plugin root resolves. A plain-terminal run after +// the README's install line has no plugin root, and the detection pass must +// still say the copy is installed rather than leave install_mode empty. +func TestInstallModeReadsTheRecordWithoutAPluginRoot(t *testing.T) { + home, _ := setupUserScope(t) + t.Setenv("ABCD_PLUGIN_ROOT", "") + target := filepath.Join(home, ".local", "bin", "abcd") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + body := []byte("#!/bin/sh\necho abcd release copy\n") + if err := os.WriteFile(target, body, 0o755); err != nil { + t.Fatal(err) + } + sha, ok := fileSHA256Hex(target) + if !ok { + t.Fatal("hashing the copy failed") + } + if err := writePathEntry(target, sha, ""); err != nil { + t.Fatal(err) + } + + if got := detectInstallMode("", false); got != "pinned" { + t.Errorf("install mode with no plugin root = %q, want %q for the copy the record names", got, "pinned") + } + + // A record whose copy no longer matches its hash vouches for nothing. + if err := os.WriteFile(target, []byte("#!/bin/sh\necho changed\n"), 0o755); err != nil { + t.Fatal(err) + } + if got := detectInstallMode("", false); got != "" { + t.Errorf("install mode for a copy whose bytes changed = %q, want empty", got) + } +} From dd452463c6752a482ff1ac7890084e3f7f1f973a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:04:57 +0200 Subject: [PATCH 04/26] fix: treat a go run build of the checkout being installed as current In a source checkout of abcd, go run is the sanctioned way to run abcd, and go run never stamps a revision, so ahoy install refused every such run as an unknown vintage. A go run build (a development build of the module's cmd/abcd, unstamped, run from a go-build work directory) of the module the checkout's go.mod declares is built from that tree as it stands, so the refusal lets it through; every other unstamped binary is still refused. Resolves: iss-2610240824047504 Assisted-by: Claude:claude-opus-5-5 --- ...in-a-source-checkout-of-abcd-go-run-cmd.md | 4 ++ internal/core/ahoy/gorun_vintage.go | 61 +++++++++++++++++++ internal/core/ahoy/gorun_vintage_test.go | 58 ++++++++++++++++++ internal/core/ahoy/vintage.go | 7 +++ internal/core/vintage/gorun.go | 49 +++++++++++++++ internal/core/vintage/gorun_test.go | 35 +++++++++++ 6 files changed, 214 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md (81%) create mode 100644 internal/core/ahoy/gorun_vintage.go create mode 100644 internal/core/ahoy/gorun_vintage_test.go create mode 100644 internal/core/vintage/gorun.go create mode 100644 internal/core/vintage/gorun_test.go diff --git a/.abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md b/.abcd/work/issues/resolved/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md similarity index 81% rename from .abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md rename to .abcd/work/issues/resolved/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md index f2ab7dfdc0..e6063cc2a6 100644 --- a/.abcd/work/issues/open/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md +++ b/.abcd/work/issues/resolved/iss-2610240824047504-in-a-source-checkout-of-abcd-go-run-cmd.md @@ -10,6 +10,10 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy" remedy: "treat a go run build whose module root is the checkout being installed as current (vintage: source tip), and keep the stale refusal for real binaries" +resolution: "a go run build of the checkout being installed is treated as current by the stale-binary refusal" +impact: fix +resolved_by: + spec: "spc-2610241007434505" --- in a source checkout of abcd, go run ./cmd/abcd ahoy install is always refused as a stale binary, because go run never stamps VCS build info, although AGENTS.md makes go run the only sanctioned way to run abcd there; every run needs --allow-stale-binary. diff --git a/internal/core/ahoy/gorun_vintage.go b/internal/core/ahoy/gorun_vintage.go new file mode 100644 index 0000000000..0b3726c2c1 --- /dev/null +++ b/internal/core/ahoy/gorun_vintage.go @@ -0,0 +1,61 @@ +package ahoy + +import ( + "bufio" + "bytes" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/vintage" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// goRunModule is the seam for reading whether the running binary is a go run +// build, and of which module (vintage.GoRunModule); a test swaps it. +var goRunModule = vintage.GoRunModule + +// swapGoRunModule replaces the seam and returns a restore func. +func swapGoRunModule(f func() (string, bool)) (restore func()) { + prev := goRunModule + goRunModule = f + return func() { goRunModule = prev } +} + +// maxGoModBytes bounds the read of a checkout's go.mod. +const maxGoModBytes = 1 << 20 + +// goRunOfCheckout reports whether the running binary is a go run build of the +// module the checkout holding cwd declares: in a source checkout of abcd that +// is the one sanctioned way to run abcd, and go run builds the tree as it +// stands, so the build is that checkout's own and never older than it +// (iss-2610240824047504). It reads git's top level and the go.mod there, and +// touches no network. +func goRunOfCheckout(cwd string) bool { + mod, ok := goRunModule() + if !ok || mod == "" { + return false + } + return checkoutModule(cwd) == mod +} + +// checkoutModule is the module path the go.mod at the top of the checkout +// holding cwd declares, or "" when there is none. +func checkoutModule(cwd string) string { + top, err := gitutil.Run(cwd, "rev-parse", "--show-toplevel") + if err != nil || top == "" { + return "" + } + data, err := fsutil.ReadGuarded(filepath.Join(top, "go.mod"), maxGoModBytes) + if err != nil { + return "" + } + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if rest, ok := strings.CutPrefix(line, "module "); ok { + return strings.Trim(strings.TrimSpace(rest), `"`) + } + } + return "" +} diff --git a/internal/core/ahoy/gorun_vintage_test.go b/internal/core/ahoy/gorun_vintage_test.go new file mode 100644 index 0000000000..b125504535 --- /dev/null +++ b/internal/core/ahoy/gorun_vintage_test.go @@ -0,0 +1,58 @@ +package ahoy + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/intentdriven/abcd/internal/core/vintage" +) + +// sourceCheckout is a git checkout whose go.mod declares module. +func sourceCheckout(t *testing.T, module string) string { + t.Helper() + dir := t.TempDir() + if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+module+"\n\ngo 1.26\n"), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +// TestGoRunOfThisCheckoutIsCurrent is iss-2610240824047504: in a source +// checkout of abcd, `go run ./cmd/abcd ahoy install` is the one sanctioned way +// to run abcd, and go run never stamps a revision, so the install used to +// refuse it as an unknown vintage on every run. A go run build of the module +// the checkout being installed declares is built from that tree as it stands, +// so it is current; any other unstamped binary still refuses. +func TestGoRunOfThisCheckoutIsCurrent(t *testing.T) { + const mod = "github.com/intentdriven/abcd" + unknown := vintage.Current{Known: false} + restore := swapGoRunModule(func() (string, bool) { return mod, true }) + defer restore() + + repo := sourceCheckout(t, mod) + if reason := staleBinaryRefusal(unknown, repo); reason != "" { + t.Errorf("a go run build of the checkout being installed was refused: %s", reason) + } + sub := filepath.Join(repo, "internal") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if reason := staleBinaryRefusal(unknown, sub); reason != "" { + t.Errorf("a go run build from a folder inside the checkout was refused: %s", reason) + } + + other := sourceCheckout(t, "example.com/someone/else") + if reason := staleBinaryRefusal(unknown, other); reason == "" { + t.Error("a go run build of abcd installing into another module's checkout was accepted; it is not that tree's build") + } + + swapGoRunModule(func() (string, bool) { return "", false }) + if reason := staleBinaryRefusal(unknown, repo); reason == "" { + t.Error("an unstamped binary that is not a go run build was accepted") + } +} diff --git a/internal/core/ahoy/vintage.go b/internal/core/ahoy/vintage.go index f9c6083ab5..67c4f9fd52 100644 --- a/internal/core/ahoy/vintage.go +++ b/internal/core/ahoy/vintage.go @@ -150,7 +150,14 @@ func dogfoodStalenessFrom(cur vintage.Current, cwd string) DogfoodReport { // or modified/dirty build), and a dogfood binary behind its own source tip. // Everything else — a determinable vintage that is not a dogfood-stale binary — // proceeds. The check is disk-only; it never touches the network. +// +// A go run build of the checkout being installed is current by construction +// and proceeds: go run never stamps a revision, so its vintage reads unknown, +// yet it is built from this tree as it stands (iss-2610240824047504). func staleBinaryRefusal(cur vintage.Current, cwd string) string { + if !cur.Known && goRunOfCheckout(cwd) { + return "" + } if !cur.Known { return "the running abcd binary's vintage cannot be determined (an unstamped or modified/dirty build), so it may be applying stale install logic. Rebuild it with `make build`, or re-run with --allow-stale-binary to proceed anyway." } diff --git a/internal/core/vintage/gorun.go b/internal/core/vintage/gorun.go new file mode 100644 index 0000000000..881aa4965e --- /dev/null +++ b/internal/core/vintage/gorun.go @@ -0,0 +1,49 @@ +package vintage + +import ( + "os" + "path/filepath" + "runtime/debug" + "strings" +) + +// GoRunModule reports the module path of the running binary when it is a +// `go run` build of that module's abcd command: go compiles it from the +// working tree into a temporary go-build directory and runs it once, so it is +// built from the tree as it stands and is never older than its source. Such a +// build carries no vcs.revision (go run never stamps one), which is why it +// reads Unknown to Compare; GoRunModule is what lets a caller tell it apart +// from a real unstamped binary (iss-2610240824047504). ok is false for any +// other build: a binary on disk, a go test binary, an installed copy. +func GoRunModule() (module string, ok bool) { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "", false + } + exe, err := os.Executable() + if err != nil { + return "", false + } + return goRunModuleOf(bi.Path, bi.Main.Path, bi.Main.Version, bi.Settings, exe) +} + +// goRunModuleOf is GoRunModule's pure core: the command's package path, the +// main module's path and version, the build settings, and the executable's +// path. A go run build is a development build ("(devel)") of the module's +// cmd/abcd, with no vcs.revision, run from a go-build work directory. +func goRunModuleOf(pkgPath, mainPath, mainVersion string, settings []debug.BuildSetting, exe string) (string, bool) { + if mainPath == "" || mainVersion != "(devel)" || pkgPath != mainPath+"/cmd/abcd" { + return "", false + } + for _, s := range settings { + if s.Key == "vcs.revision" && s.Value != "" { + return "", false + } + } + for _, part := range strings.Split(filepath.ToSlash(filepath.Dir(exe)), "/") { + if strings.HasPrefix(part, "go-build") { + return mainPath, true + } + } + return "", false +} diff --git a/internal/core/vintage/gorun_test.go b/internal/core/vintage/gorun_test.go new file mode 100644 index 0000000000..0565d7e686 --- /dev/null +++ b/internal/core/vintage/gorun_test.go @@ -0,0 +1,35 @@ +package vintage + +import ( + "runtime/debug" + "testing" +) + +// TestGoRunModuleOf: only a development build of the module's cmd/abcd, with +// no revision stamped, run from a go-build work directory, is a go run build. +func TestGoRunModuleOf(t *testing.T) { + const mod = "github.com/intentdriven/abcd" + goRunExe := "/var/folders/xy/T/go-build1234/b001/exe/abcd" + stamped := []debug.BuildSetting{{Key: "vcs.revision", Value: "0123456789abcdef0123456789abcdef01234567"}} + for _, tc := range []struct { + name string + pkg, main, version, ex string + settings []debug.BuildSetting + want bool + }{ + {"go run of the checkout", mod + "/cmd/abcd", mod, "(devel)", goRunExe, nil, true}, + {"a built binary on disk", mod + "/cmd/abcd", mod, "(devel)", "/Users/x/.local/bin/abcd", nil, false}, + {"a stamped build", mod + "/cmd/abcd", mod, "(devel)", goRunExe, stamped, false}, + {"a released module version", mod + "/cmd/abcd", mod, "v0.14.0", goRunExe, nil, false}, + {"a go test binary", mod + "/internal/core/ahoy.test", mod, "(devel)", goRunExe, nil, false}, + {"another module's command", "example.com/other/cmd/abcd", "example.com/other", "(devel)", goRunExe, nil, true}, + } { + got, ok := goRunModuleOf(tc.pkg, tc.main, tc.version, tc.settings, tc.ex) + if ok != tc.want { + t.Errorf("%s: ok = %v, want %v", tc.name, ok, tc.want) + } + if ok && got != tc.main { + t.Errorf("%s: module = %q, want %q", tc.name, got, tc.main) + } + } +} From 27f895dc4ff257ff39b75822c729a5a08d019192 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:05:54 +0200 Subject: [PATCH 05/26] feat: setup checks first, shows one plan, and asks a person only their own ahoy install now stops before any question when git, a repository or an agent harness is missing; shows one plan screen (approve.plan) listing every change and every default with where to change it, and asks once: proceed, customise or cancel; asks the abcd download, the shell settings (a seam for itd-2610240919030303), the status line, the commit identity and each tool as questions of their own; draws every question on one line with ? for the whole of it; and ends on a three-part report, or one line when nothing is left to do. The download is fetched through update.FetchVerified, checked against the release's checksums and signed build provenance, before the status line is offered, and the status line is never asked about when it cannot be wired. An answers file's approve.<category> ids are refused, naming approve.plan. Resolves: iss-2610240824005655 Resolves: iss-2610240823574047 Assisted-by: Claude:claude-opus-5-5 --- ...-ahoy-install-run-from-a-plain-terminal.md | 4 + ...ahoy-install-hides-the-git-identity-fix.md | 4 + internal/core/ahoy/abcd_download.go | 169 +++++++++ internal/core/ahoy/ahoy.go | 10 + internal/core/ahoy/apply.go | 68 +++- internal/core/ahoy/idempotency_test.go | 6 +- internal/core/ahoy/identity_establish.go | 5 +- .../core/ahoy/identity_own_question_test.go | 51 +++ internal/core/ahoy/main_test.go | 4 + internal/core/ahoy/person_only.go | 99 +++++ internal/core/ahoy/setup_basics.go | 80 ++++ internal/core/ahoy/setup_basics_test.go | 74 ++++ internal/core/ahoy/setup_brief.go | 76 ++++ internal/core/ahoy/setup_plan.go | 348 ++++++++++++++++++ internal/core/ahoy/setup_plan_test.go | 217 +++++++++++ internal/core/ahoy/setup_question.go | 3 + internal/core/ahoy/setup_question_test.go | 2 +- internal/core/ahoy/setup_report.go | 184 +++++++++ internal/core/ahoy/setup_report_test.go | 71 ++++ internal/core/ahoy/statusline_apply.go | 4 +- internal/core/ahoy/statusline_guard.go | 23 +- internal/core/ahoy/statusline_guard_test.go | 4 +- internal/core/update/fetch.go | 68 ++++ internal/core/update/fetch_test.go | 42 +++ .../surface/cli/ahoy_identity_gate_test.go | 2 +- .../surface/cli/ahoy_only_waiting_test.go | 2 +- internal/surface/cli/ahoy_prompt_help_test.go | 12 +- .../surface/cli/ahoy_prompt_stdin_test.go | 10 +- internal/surface/cli/ahoy_provenance_test.go | 4 +- internal/surface/cli/ahoy_readme_ask_test.go | 7 +- internal/surface/cli/ahoy_readme_test.go | 6 +- internal/surface/cli/ahoy_statusline_test.go | 8 +- internal/surface/cli/ask/explain_test.go | 51 +++ internal/surface/cli/ask/loop.go | 27 ++ internal/surface/cli/ask/numbered.go | 2 + internal/surface/cli/ask/picker.go | 15 + internal/surface/cli/cli.go | 99 ++++- internal/surface/cli/main_test.go | 6 + internal/surface/cli/setup_flow_test.go | 80 ++++ internal/surface/cli/setup_interview.go | 80 +++- internal/surface/cli/setup_interview_test.go | 30 +- internal/surface/cli/update.go | 14 + 42 files changed, 2006 insertions(+), 65 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md (84%) rename .abcd/work/issues/{open => resolved}/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md (81%) create mode 100644 internal/core/ahoy/abcd_download.go create mode 100644 internal/core/ahoy/identity_own_question_test.go create mode 100644 internal/core/ahoy/person_only.go create mode 100644 internal/core/ahoy/setup_basics.go create mode 100644 internal/core/ahoy/setup_basics_test.go create mode 100644 internal/core/ahoy/setup_brief.go create mode 100644 internal/core/ahoy/setup_plan.go create mode 100644 internal/core/ahoy/setup_plan_test.go create mode 100644 internal/core/ahoy/setup_report.go create mode 100644 internal/core/ahoy/setup_report_test.go create mode 100644 internal/core/update/fetch.go create mode 100644 internal/core/update/fetch_test.go create mode 100644 internal/surface/cli/ask/explain_test.go create mode 100644 internal/surface/cli/setup_flow_test.go diff --git a/.abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md b/.abcd/work/issues/resolved/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md similarity index 84% rename from .abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md rename to .abcd/work/issues/resolved/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md index 26d4f8eefa..bbdf80acd3 100644 --- a/.abcd/work/issues/open/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md +++ b/.abcd/work/issues/resolved/iss-2610240823574047-ahoy-install-run-from-a-plain-terminal.md @@ -10,6 +10,10 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/statusline_guard.go" remedy: "check for a trusted PATH entry before the status-line offer is asked, and when none can be written offer the README one-liner (or resolve the plugin root from the plugin cache) inside the same run instead of refusing after the answers" +resolution: "setup offers the abcd download before the status line, and never asks about the status line when it cannot be wired" +impact: fix +resolved_by: + spec: "spc-2610241007434505" --- ahoy install run from a plain Terminal cannot wire the status line or a PATH entry: with no CLAUDE_PLUGIN_ROOT the plugin root is missing (plugin.root_missing), stepPathEntry returns early, ~/.abcd.noindex/path-entry stays absent, and the status-line offer is answered yes through all its element switches only to be refused afterwards (no PATH entry on record). The person learns of the prerequisite after answering every question. diff --git a/.abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md b/.abcd/work/issues/resolved/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md similarity index 81% rename from .abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md rename to .abcd/work/issues/resolved/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md index 30d0316869..a95be2a327 100644 --- a/.abcd/work/issues/open/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md +++ b/.abcd/work/issues/resolved/iss-2610240824005655-ahoy-install-hides-the-git-identity-fix.md @@ -10,6 +10,10 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/ahoy/apply.go" remedy: "ask the identity question on its own at a terminal, quoting the current identity and the pin, independent of the config-change approval" +resolution: "the commit identity is its own question at a terminal, never listed under or declined by the settings approval" +impact: fix +resolved_by: + spec: "spc-2610241007434505" --- ahoy install hides the git identity fix behind the generic settings approval: the identity mismatch is listed only as one line under 'Change the settings listed above?', a no there means the identity question is never asked, and nothing says that declining the settings approval also declines the identity pin the person wanted to keep. diff --git a/internal/core/ahoy/abcd_download.go b/internal/core/ahoy/abcd_download.go new file mode 100644 index 0000000000..7722d30092 --- /dev/null +++ b/internal/core/ahoy/abcd_download.go @@ -0,0 +1,169 @@ +package ahoy + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/core" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// The abcd download (itd-2610240823055889, criterion 4, and the product +// thinker's decision of 2026-10-24): when abcd is not on the PATH, setup puts +// it there itself in the same run, after one question naming the download, +// so the status line, which needs a PATH entry to run, never depends on the +// README's install line (iss-2610240823574047). The download is the release +// binary for this platform, checked the way the README's install line and +// abcd update check it: against the release's checksums.txt, then against +// the build provenance the release workflow signed (update.FetchVerified, +// which the front door supplies, since update imports this package). It is +// fetched only on a yes to that question, the fetch its documented meaning. + +// DownloadQuestionID is the download question's id, the id an answers file +// answers it by. +const DownloadQuestionID = "abcd.download" + +// DownloadedRelease is a release binary fetched and verified: its bytes, the +// sha256 checksums.txt names for them, and the signer of its build +// provenance. +type DownloadedRelease struct { + Bytes []byte + SHA256 string + Signer string +} + +// DownloadRelease fetches the release tag's binary for this platform and +// verifies it before returning it; any failure is an error and nothing is +// returned. +type DownloadRelease func(tag string) (DownloadedRelease, error) + +// downloadLead opens the download question's explanation. +const downloadLead = "abcd is not on your PATH, so the status line and the commit hooks have nothing to run." + +// downloadAskPrefix opens the download question's one line. +const downloadAskPrefix = "Download abcd " + +// downloadQuestion is the download question: the explanation, then its one +// line naming the release and where it is written. +func downloadQuestion(tag, target string) string { + return downloadLead + " Setup downloads the abcd release " + tag + " for this computer from " + core.ReleaseOrigin + + ", checks it against the release's checksums.txt and against the build record the release workflow signed, " + + "writes it to " + displayPath(target) + " and records it in " + abcdhome.Display("path-entry") + + "; declining writes nothing, and the README's install line does the same by hand.\n" + + downloadAskPrefix + tag + " to " + displayPath(target) + "?" +} + +// isDownloadQuestion reports whether text is the download question. +func isDownloadQuestion(text string) bool { + _, ask := splitConfirm(text) + return strings.HasPrefix(text, downloadLead) && strings.HasPrefix(ask, downloadAskPrefix) +} + +// releaseTagRe is the shape of a release tag. +var releaseTagRe = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) + +// abcdModule is the module path of abcd's own source checkout. +const abcdModule = "github.com/intentdriven/abcd" + +// downloadTag is the release setup downloads: the one this abcd was stamped +// as, or, in a source checkout of abcd, where a development build carries no +// release, the newest release tag the checkout holds, read from disk. "" when +// neither names one, and the download is not offered. A seam for tests. +var downloadTag = func(cwd string) string { + if releaseTagRe.MatchString(core.Version) { + return core.Version + } + if checkoutModule(cwd) != abcdModule { + return "" + } + out, err := gitutil.Run(cwd, "tag", "--list", "v*", "--sort=-v:refname") + if err != nil { + return "" + } + for _, t := range strings.Split(out, "\n") { + if t = strings.TrimSpace(t); releaseTagRe.MatchString(t) { + return t + } + } + return "" +} + +// downloadOffer is the release and the target the download question offers, +// or ok false where it is not offered: under --dev (the shim is the entry), +// with no downloader wired, where the PATH target already holds something, or +// where the plugin's verified cache can supply the copy itself. +func downloadOffer(cwd string, det DetectionResult, opts InstallOptions, target string) (tag string, ok bool) { + if opts.Dev || opts.DownloadAbcd == nil || target == "" { + return "", false + } + if classifyBinTarget(target, det.pluginRoot) != binTargetAbsent { + return "", false + } + if det.pluginRoot != "" && cachePresent(pluginDataDir(det.pluginRoot).dir, cwd) { + return "", false + } + tag = downloadTag(cwd) + return tag, tag != "" +} + +// stepDownload asks the download question and, on a yes, writes the verified +// release to the PATH target and records it, before the status line is +// offered, so a yes wires the status line in the same run. Every way of +// writing nothing is said. +func (a *applyCtx) stepDownload() { + tag, ok := downloadOffer(a.cwd, a.det, a.opts, a.binTarget) + if !ok { + return + } + const did = "did not put abcd on your PATH" + if a.autoYes { + a.refuse(did + ": --yes never downloads a program; run abcd ahoy install at a terminal to be asked") + return + } + if !a.prompter.Confirm(downloadQuestion(tag, a.binTarget)) { + a.refuse(did + ": the download of abcd " + tag + " was declined; the README's install line puts it there by hand") + return + } + rel, err := a.opts.DownloadAbcd(tag) + if err != nil { + a.refuse(did + ": the download of abcd " + tag + " failed or did not verify, so nothing was written: " + errText(err)) + return + } + sum := sha256.Sum256(rel.Bytes) + got := hex.EncodeToString(sum[:]) + if rel.SHA256 != "" && got != strings.ToLower(rel.SHA256) { + a.refuse(did + ": the downloaded abcd " + tag + " does not hash to the checksum it was verified against, so nothing was written") + return + } + if err := os.MkdirAll(filepath.Dir(a.binTarget), 0o755); err != nil { + a.refuse(did + ": " + displayPath(filepath.Dir(a.binTarget)) + " could not be created: " + errText(err)) + return + } + // Re-checked at the write: an entry that appeared while the question was + // open is never replaced. + if classifyBinTarget(a.binTarget, a.det.pluginRoot) != binTargetAbsent { + a.refuse(did + ": " + displayPath(a.binTarget) + " appeared while the question was open, and it is left as it is") + return + } + if err := fsutil.WriteFileAtomic(a.binTarget, rel.Bytes, 0o755); err != nil { + a.refuse(did + ": " + displayPath(a.binTarget) + " could not be written: " + errText(err)) + return + } + a.verifiedCopy = a.binTarget + a.downloaded = true + a.note(writeCommandEntry, a.binTarget) + a.recordEntry(a.binTarget, got) +} + +// swapDownloadTag replaces the release tag seam and returns a restore func. +func swapDownloadTag(f func(cwd string) string) (restore func()) { + prev := downloadTag + downloadTag = f + return func() { downloadTag = prev } +} diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index f9e6a7e694..c02f805153 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -220,6 +220,11 @@ type InstallOptions struct { // cannot import it, because update imports ahoy. Nil refuses every // promotion: there is no unverified path. VerifyProvenance ProvenanceCheck + // DownloadAbcd fetches and verifies a release binary for the download + // question setup asks where abcd is not on the PATH (abcd_download.go): + // the front door supplies abcd update's checked fetch, since update + // imports this package. Nil offers no download. + DownloadAbcd DownloadRelease // OnlyWaiting is the setup the yes to the after-update offer runs // (spc-2610170635097509, step 7): only the questions and changed // defaults Waiting returns for this repository are put to the prompter, @@ -274,6 +279,11 @@ type InstallResult struct { // (iss-164). Headline string `json:"headline"` Summary []SummaryItem `json:"summary"` + // Report is the three-part report setup ends on (setup_report.go): done, + // needs you and next, or one line for a repository already set up. + Report SetupReport `json:"report"` + // needs is the needs-you checklist the run found in its last detection. + needs []ReportStep // writeKinds runs parallel to Writes: what each write is, which the summary // explains. Unexported, so the wire shape is unchanged. writeKinds []writeKind diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index c3f5659262..646ddbb135 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -32,6 +32,7 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) } res.readmeAwaitsConfig = slices.Contains(res.OptionalSkipped, ReadmeOfferGapID) && !readmeStorable(cwd) res.explain() + res.composeReport() return res, nil } @@ -46,6 +47,12 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er p = RefusingPrompter{} } + // No git, no repository or no agent harness: there is nothing to set up, + // so setup stops before its first question with one message naming what + // is missing (setup_basics.go). + if !gitInstalled() { + return InstallResult{Status: "refused", Notes: []string{missingGitStop()}}, nil + } det, err := Detect(abs) if err != nil { return InstallResult{}, err @@ -61,11 +68,14 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er if err == nil && res.Warnings == nil { res.Warnings = installWarnings(det.Gaps, hostReach) } + if err == nil && res.needs == nil && res.Status != "refused" && res.Status != "aborted" { + res.needs = remainingSteps(abs, det.Gaps) + } }() - // Unmanaged folder: nothing to act on. - if det.FolderKind == UnmanagedFolder { - return InstallResult{Status: "aborted"}, nil + // Not a repository, or no harness: nothing to act on, said in one message. + if reason := setupBasicsStop(det); reason != "" { + return InstallResult{Status: "refused", Notes: []string{reason}}, nil } // A committed `.abcd` that exists but is NOT a real directory — a symlink @@ -132,6 +142,24 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er return InstallResult{}, fmt.Errorf("git gave no root commit for this repository, so abcd cannot tell whether it already manages it; run the install again once git answers, or pass --adopt to adopt it anyway: %w", gitUnanswered) } + // The plan screen (setup_plan.go): for a repository abcd does not manage + // yet it is asked before the adoption, which proceeding or customising + // approves, so the plan is the one question that adopts. + planAsked := false + var planChoice PlanChoice + if det.FolderKind == UnmanagedRepo && opts.Adopt == nil { + var np Prompter + np, planChoice, planAsked = askSetupPlan(det, opts, personOnlyAsks(abs, det, opts, plannedBinTarget(opts, det)), p) + if planAsked { + if planChoice == PlanCancel { + return InstallResult{Status: "aborted"}, nil + } + p = np + yes := true + opts.Adopt = &yes + } + } + // Adoption gate for an unmanaged repo. adopted := false if det.FolderKind == UnmanagedRepo { @@ -228,10 +256,25 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er }, nil } + // A repository abcd already manages is shown the plan once the run knows + // there is something to do. + if !planAsked { + var np Prompter + np, planChoice, planAsked = askSetupPlan(det, opts, personOnlyAsks(abs, det, opts, binTargetPath), p) + if planAsked { + if planChoice == PlanCancel { + return InstallResult{Status: "aborted"}, nil + } + p = np + } + } + // A value flag that would change a saved setting is held to the settings // approval like any other settings change, so it puts the config-change // question even where no config-change gap would (iss-2610071538032843). - approved, declined := resolveApproval(det.Gaps, opts, overrideChanges(abs, opts.ValueOverrides), p) + // The status line is never asked about where it cannot be wired + // (iss-2610240823574047). + approved, declined := resolveApproval(approvalGaps(abs, det, opts, binTargetPath), opts, overrideChanges(abs, opts.ValueOverrides), p) ac := &applyCtx{ cwd: abs, @@ -250,6 +293,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er provenance: opts.VerifyProvenance, readmeFlag: opts.ReadmeParts, waiting: waiting, + opts: opts, } // Ordered apply steps. @@ -275,6 +319,9 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // After stepMarker, before stepSymlink: the README pair's place in // setup's fixed order (decision 7). ac.stepReadme() + // Before stepSymlink: where abcd is not on the PATH and no verified copy + // is to hand, the download question puts it there (abcd_download.go). + ac.stepDownload() ac.stepSymlink() // After stepSymlink, never before: the record names the entry that step // leaves on PATH, and the hooks read it before they will run that entry. @@ -284,6 +331,9 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // After stepOutboundBinary: the shim finds abcd through the abcd.binary it // wrote, and the two notes read in that order. ac.stepOutboundShim() + // After the PATH entry and before the status line: the shell settings, a + // question of its own (person_only.go). + ac.stepShellSettings() // After stepPathEntry: the harness command names the entry the two steps // above actually left on PATH. ac.stepStatusLine() @@ -342,6 +392,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er writeKinds: ac.writeKinds, unverifiedCheck: ac.unverifiedCheck, unverifiedWhy: ac.unverifiedWhy, + needs: remainingSteps(abs, final.Gaps), }, nil } @@ -479,6 +530,9 @@ type applyCtx struct { docsTargetForced bool // a --docs-target override overwrote a valid value, or this run chose the first one markerRetract []string // marker files a narrowed docs-target override de-selected configMalformed bool // config.json could not be parsed; the refusal note was given once + + opts InstallOptions // the run's options, as the plan left them + downloaded bool // stepDownload wrote the verified release to the PATH target } // note is the receipt seam; it lives in receipt.go with the path scrub it @@ -912,7 +966,7 @@ func WalkConfigValueQuestions(cwd string, opts InstallOptions, approves func(id } // install's gates before its first question, in its order: each ends the // run before a value is asked. - if det.FolderKind == UnmanagedFolder || abcdDirHazard(abs) != "" { + if !gitInstalled() || setupBasicsStop(det) != "" || abcdDirHazard(abs) != "" { return nil } if !opts.AllowStaleBinary && staleBinaryRefusal(currentVintage(), abs) != "" { @@ -1477,7 +1531,7 @@ func (a *applyCtx) stepSymlink() { // Nothing is written, but an owned copy is still the entry this run // reports as abcd's, so it is verified first, read-only // (iss-2610120955431790). - if !a.devMode { + if !a.devMode && !a.downloaded { a.adoptOwnedCopy(a.binTarget) } return @@ -2509,7 +2563,7 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr present := map[GapCategory]bool{} lines := map[GapCategory][]string{} for _, g := range gaps { - if g.Resolvable { + if g.Resolvable && !personOnlyGap(g.ID) { present[g.Category] = true lines[g.Category] = append(lines[g.Category], g.Title) } diff --git a/internal/core/ahoy/idempotency_test.go b/internal/core/ahoy/idempotency_test.go index fe5c956802..6255b2dab8 100644 --- a/internal/core/ahoy/idempotency_test.go +++ b/internal/core/ahoy/idempotency_test.go @@ -169,8 +169,10 @@ func TestUnmanagedFolderInstallAborts(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "aborted" { - t.Errorf("status = %q, want aborted", res.Status) + // Not a repository: setup stops with the one message naming it + // (setup_basics.go, itd-2610240823055889 criterion 1). + if res.Status != "refused" || len(res.Notes) != 1 { + t.Errorf("status = %q, notes %q: want refused with one note", res.Status, res.Notes) } } diff --git a/internal/core/ahoy/identity_establish.go b/internal/core/ahoy/identity_establish.go index c3d928d392..3492fc24da 100644 --- a/internal/core/ahoy/identity_establish.go +++ b/internal/core/ahoy/identity_establish.go @@ -62,7 +62,10 @@ var establishGapIDs = []string{MismatchGapID, UnsetGapID, CommitterGapID, ToolId // The write is a plain `git config --local`, as the person running it; the step // never escalates privileges and never touches global or system config. func (a *applyCtx) stepGitIdentity() { - if !a.anyGap(establishGapIDs) || !a.approved[ConfigChange] { + // The identity is its own question, never behind the settings approval: + // a no to the settings must not decline the identity the person wanted + // answered (iss-2610240824005655). + if !a.anyGap(establishGapIDs) { return } const did = "did not change who commits to this repository" diff --git a/internal/core/ahoy/identity_own_question_test.go b/internal/core/ahoy/identity_own_question_test.go new file mode 100644 index 0000000000..215b906531 --- /dev/null +++ b/internal/core/ahoy/identity_own_question_test.go @@ -0,0 +1,51 @@ +package ahoy + +import ( + "strings" + "testing" +) + +// TestIdentityQuestionStandsWithoutTheSettingsApproval is iss-2610240824005655: +// the commit identity is a question only the person can answer, so it is asked +// on its own, naming the identity and where it is written, whatever the answer +// to the settings change was. A no to the settings used to mean the identity +// question was never asked. +func TestIdentityQuestionStandsWithoutTheSettingsApproval(t *testing.T) { + clearIdentityEnv(t) + dir := idGitRepo(t, "Test User", "test@example.com") + idWritePin(t, dir, pinAlex) + p := &identityPrompter{terminal: true, answer: true} + a := establishCtx(dir, p, MismatchGapID) + a.approved = map[GapCategory]bool{} // the settings change declined + + a.stepGitIdentity() + + if len(p.asked) != 1 { + t.Fatalf("asked %d questions, want the one identity question: %q", len(p.asked), p.asked) + } + q := p.asked[0] + for _, want := range []string{"Alex Reppel <alex@example.com>", ".git/config"} { + if !strings.Contains(q, want) { + t.Errorf("the identity question does not name %q: %q", want, q) + } + } + if got := localConfig(t, dir, "user.name"); got != "Alex Reppel" { + t.Errorf("user.name = %q after a yes, want the pinned name", got) + } +} + +// TestSettingsApprovalDoesNotListTheIdentity: the identity is its own question, +// so the settings change neither lists it nor is asked for it alone. +func TestSettingsApprovalDoesNotListTheIdentity(t *testing.T) { + gaps := []Gap{{ID: MismatchGapID, Category: ConfigChange, Title: "git identity differs from the pin", Required: true, Resolvable: true}} + p := &identityPrompter{terminal: true, answer: true} + resolveApproval(gaps, InstallOptions{}, nil, p) + for _, q := range p.asked { + if strings.Contains(q, "git identity differs") { + t.Errorf("the settings approval lists the identity, which is its own question: %q", q) + } + } + if len(p.asked) != 0 { + t.Errorf("an approval was asked for the identity alone: %q", p.asked) + } +} diff --git a/internal/core/ahoy/main_test.go b/internal/core/ahoy/main_test.go index 3914e021d8..2d0e9ba91e 100644 --- a/internal/core/ahoy/main_test.go +++ b/internal/core/ahoy/main_test.go @@ -21,6 +21,10 @@ func TestMain(m *testing.M) { // No test runs the machine's own agent tool to read its version; the // version test puts the real reading back over a fake command. NoHostVersionForTest() + // The fixture homes hold no harness folder, many tests narrow PATH past + // git, and the machine running the tests may have no assistant on PATH; + // the basics test swaps each check per case (setup_basics_test.go). + AssumeSetupBasicsForTest() // The fixtures standing for a release binary are shell scripts with no Go // build metadata; the copy's claimed release is stood in, and the reader's // own tests call it directly (cold_cache_adopt_provenance_test.go). diff --git a/internal/core/ahoy/person_only.go b/internal/core/ahoy/person_only.go new file mode 100644 index 0000000000..4d95d8cc0e --- /dev/null +++ b/internal/core/ahoy/person_only.go @@ -0,0 +1,99 @@ +package ahoy + +import ( + "path/filepath" + "slices" +) + +// The steps only a person can approve (itd-2610240823055889, criterion 3): +// the commit identity, a download, the assistant's settings and the shell +// settings. Each is asked as its own question naming the value and where it +// is written, whatever the answer to the plan or to an approval of a kind of +// change, so none is ever listed under, or declined by, a generic approval. + +// personOnlyGap reports whether the gap id is closed by a question of its own +// rather than by the approval of its category: the commit identity's gaps, +// which stepGitIdentity asks about at a terminal. +func personOnlyGap(id string) bool { + return slices.Contains(establishGapIDs, id) +} + +// shellSettingsAsks is the line the plan lists for the shell settings +// question, or nil where it is not asked. The shell-profile PATH line +// (itd-2610240919030303) fills it, with stepShellSettings; until then setup +// asks nothing about the shell. +var shellSettingsAsks = func(cwd string, det DetectionResult, target string) []string { return nil } + +// stepShellSettings is the shell settings question's place in the run: after +// the PATH entry is written and before the status line is offered, asked as +// its own question naming the line and the profile it is written to. The +// shell-profile PATH line (itd-2610240919030303) fills it; until then the +// install says the directory is not on PATH and leaves the profile alone. +var stepShellSettingsHook = func(a *applyCtx) {} + +func (a *applyCtx) stepShellSettings() { stepShellSettingsHook(a) } + +// plannedBinTarget is the PATH entry the run will act on, as the plan lists +// it before the run resolves it: the --bin-dir entry, or the entry an owned +// install already holds, else the default. It probes nothing. +func plannedBinTarget(opts InstallOptions, det DetectionResult) string { + if opts.BinDir != "" { + return filepath.Join(opts.BinDir, binName) + } + return effectiveBinTarget(det.pluginRoot) +} + +// statusLineReachable reports whether the status line can be wired in this +// run: abcd's PATH entry is on record and passes the checks the wiring makes, +// or the download question can put it there first. +func statusLineReachable(cwd string, det DetectionResult, downloadOffered bool) bool { + if downloadOffered { + return true + } + // The plugin's verified cache supplies the PATH copy earlier in this run. + if det.pluginRoot != "" && cachePresent(pluginDataDir(det.pluginRoot).dir, cwd) { + return true + } + entry, _ := (&applyCtx{det: det}).statusLineEntry() + return entry != "" +} + +// approvalGaps is the gaps the approvals are asked over: the status-line +// offer is left out where the status line cannot be wired, so it is never +// asked about only to be refused after the answers (iss-2610240823574047). +func approvalGaps(cwd string, det DetectionResult, opts InstallOptions, target string) []Gap { + _, dl := downloadOffer(cwd, det, opts, target) + if statusLineReachable(cwd, det, dl) { + return det.Gaps + } + return slices.DeleteFunc(slices.Clone(det.Gaps), func(g Gap) bool { return g.ID == StatusLineOfferGapID }) +} + +// personOnlyAsks is the plan's list of the questions that follow whatever the +// answer to it, in the order the run asks them: the abcd download, the shell +// settings, the status line, the commit identity, and one question for each +// missing tool. +func personOnlyAsks(cwd string, det DetectionResult, opts InstallOptions, target string) []string { + var out []string + tag, dl := downloadOffer(cwd, det, opts, target) + if dl { + out = append(out, "the abcd download: abcd "+tag+" to "+displayPath(target)) + } + out = append(out, shellSettingsAsks(cwd, det, target)...) + present := gapIDSet(det.Gaps) + if present[StatusLineOfferGapID] && statusLineReachable(cwd, det, dl) { + out = append(out, "the status line: one entry in your assistant's settings, "+displayPath(harnessSettingsPath())) + } + for _, id := range establishGapIDs { + if present[id] { + out = append(out, "the commit identity: who commits here, in this repository's .git/config") + break + } + } + for _, g := range det.Gaps { + if g.Category == Dependency && g.Tool != nil && !onPath(cwd, g.Tool.Tool) { + out = append(out, "installing "+g.Tool.Tool+", a tool abcd uses") + } + } + return out +} diff --git a/internal/core/ahoy/setup_basics.go b/internal/core/ahoy/setup_basics.go new file mode 100644 index 0000000000..d012b16118 --- /dev/null +++ b/internal/core/ahoy/setup_basics.go @@ -0,0 +1,80 @@ +package ahoy + +import ( + "os" + "os/exec" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// The basics setup cannot run without (itd-2610240823055889, criterion 1, and +// the product thinker's decision of 2026-10-24): git, a repository and an +// agent harness. Without any one of them there is nothing to set up, so setup +// stops before its first question with one message naming what is missing. +// Every other missing basic goes on the end-of-run list instead. + +// setupStopLead opens every basics message. +const setupStopLead = "setup stopped before asking anything: " + +// gitInstalled reports whether a git command is on PATH; a test swaps it. +var gitInstalled = shippedGitInstalled + +// shippedGitInstalled is gitInstalled as it ships. +func shippedGitInstalled() bool { + _, err := exec.LookPath("git") + return err == nil +} + +// harnessInstalled reports whether an agent harness abcd wires into is set up +// for this account: its configuration folder exists, its command is on PATH, +// or abcd is running inside it. A test swaps it. +var harnessInstalled = func() bool { + if os.Getenv("CLAUDE_PLUGIN_ROOT") != "" || os.Getenv("CLAUDECODE") != "" { + return true + } + if dir := harnessHome(); dir != "" && fsutil.IsRealDir(dir) { + return true + } + _, err := exec.LookPath("claude") + return err == nil +} + +// swapSetupBasics replaces the git and harness checks (nil keeps one) and +// returns a restore func. +func swapSetupBasics(git, harness func() bool) (restore func()) { + prevGit, prevHarness := gitInstalled, harnessInstalled + if git != nil { + gitInstalled = git + } + if harness != nil { + harnessInstalled = harness + } + return func() { gitInstalled, harnessInstalled = prevGit, prevHarness } +} + +// AssumeSetupBasicsForTest makes the git and harness checks pass and returns +// a restore func: the tests run installs under a fixture home with no harness +// folder, many with PATH narrowed to an install directory, over fixture +// repositories that need no git command. Production never calls it. +func AssumeSetupBasicsForTest() (restore func()) { + return swapSetupBasics(func() bool { return true }, func() bool { return true }) +} + +// missingGitStop is the message for a machine with no git. +func missingGitStop() string { + return setupStopLead + "git is not installed (no git command is on PATH). Install git, then run abcd ahoy install again." +} + +// setupBasicsStop is the one message naming the first basic missing for the +// folder det describes, or "" when setup can run. +func setupBasicsStop(det DetectionResult) string { + switch { + case det.FolderKind == UnmanagedFolder: + return setupStopLead + "this folder is not the top folder of a git repository. " + + "Run abcd ahoy install from the top of the repository, or run git init here first." + case !harnessInstalled(): + return setupStopLead + "no agent harness was found for this account (no " + displayPath(harnessHome()) + + " folder and no claude command on PATH). abcd works inside your AI coding assistant; install it, then run abcd ahoy install again." + } + return "" +} diff --git a/internal/core/ahoy/setup_basics_test.go b/internal/core/ahoy/setup_basics_test.go new file mode 100644 index 0000000000..4567526d54 --- /dev/null +++ b/internal/core/ahoy/setup_basics_test.go @@ -0,0 +1,74 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// countingPrompter records every question it is asked and declines each. +type countingPrompter struct{ asked []string } + +func (p *countingPrompter) Confirm(q string) bool { p.asked = append(p.asked, q); return false } +func (p *countingPrompter) Prompt(key string, _ []string, def string) string { + p.asked = append(p.asked, key) + return def +} +func (p *countingPrompter) AtTerminal() bool { return true } + +// TestSetupStopsBeforeAnyQuestionWithoutTheBasics is itd-2610240823055889 +// criterion 1: with no git, no repository or no agent harness there is +// nothing to set up, so setup stops before any question with one message +// naming what is missing, and writes nothing. +func TestSetupStopsBeforeAnyQuestionWithoutTheBasics(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T) (repo string, restore func()) + names string + }{ + {"no git", func(t *testing.T) (string, func()) { + return gitRepo(t), swapSetupBasics(func() bool { return false }, nil) + }, "git is not installed"}, + {"no repository", func(t *testing.T) (string, func()) { + return t.TempDir(), func() {} + }, "not the top folder of a git repository"}, + {"no harness", func(t *testing.T) (string, func()) { + return gitRepo(t), swapSetupBasics(nil, func() bool { return false }) + }, "no agent harness"}, + } { + t.Run(tc.name, func(t *testing.T) { + setupHermetic(t) + repo, restore := tc.setup(t) + defer restore() + p := &countingPrompter{} + res, err := Install(repo, InstallOptions{}, p) + if err != nil { + t.Fatal(err) + } + if len(p.asked) != 0 { + t.Errorf("asked %q before stopping", p.asked) + } + if res.Status != "refused" || len(res.Notes) != 1 || !strings.Contains(res.Notes[0], tc.names) { + t.Fatalf("status %q, notes %q: want refused with one note naming %q", res.Status, res.Notes, tc.names) + } + if len(res.Writes) != 0 { + t.Errorf("wrote %v", res.Writes) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd")); err == nil { + t.Error(".abcd/ was created") + } + }) + } +} + +// TestGitInstalledReadsPath: the real check is the git command on PATH. +func TestGitInstalledReadsPath(t *testing.T) { + restore := swapSetupBasics(nil, nil) + defer restore() + gitInstalled = shippedGitInstalled + t.Setenv("PATH", t.TempDir()) + if gitInstalled() { + t.Error("git reported installed with no git on PATH") + } +} diff --git a/internal/core/ahoy/setup_brief.go b/internal/core/ahoy/setup_brief.go new file mode 100644 index 0000000000..b068ccf77d --- /dev/null +++ b/internal/core/ahoy/setup_brief.go @@ -0,0 +1,76 @@ +package ahoy + +import ( + "strings" + + "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/core/statusline" +) + +// The one-line form of every setup question (itd-2610240823055889, criterion +// 6, and the amendment of brief invariant 1 for setup): a question is shown +// as its one line and its answers' labels, and answering ? shows the whole +// question (the material, what each answer does, what holds now and where to +// change it later) and asks again. The plan screen keeps its plan, which is +// the screen; only what each answer does waits for ?. + +// ExplainHint closes the one line of every setup question. +const ExplainHint = "(? explains)" + +// briefValueAsks is the one line of each value question whose full form asks +// the generic setupAsk. +var briefValueAsks = map[string]string{ + "visibility": "Commit abcd's records with your code (private), or keep them out of git (public)?", + "docs_target": "Add abcd's block to AGENTS.md (agents_md), or leave AGENTS.md alone (skip)?", + "oracle_backend": "Which AI reviewer does abcd use?", + "scan_deep": "Record that this repository wants the trufflehog deep secret scan?", + artefactKindKey: "What does this repository release?", + emDashPromptKey: "Does an em dash in a list item block the docs check, or only warn?", +} + +// BriefQuestion is q in its one-line form: the question's one line, ending on +// ExplainHint, and the answers' labels, with the material, the meanings and +// the Now and change-later lines left for ?. The plan screen keeps its +// material, which is the plan itself. +func BriefQuestion(q question.Question) question.Question { + b := q + if q.ID != PlanQuestionID { + b.Material = nil + } + b.Ask = briefAsk(q) + " " + ExplainHint + b.Options = make([]question.Option, len(q.Options)) + for i, o := range q.Options { + o.Meaning = "" + b.Options[i] = o + } + if q.List != nil { + l := *q.List + l.Choices = make([]question.Option, len(q.List.Choices)) + for i, o := range q.List.Choices { + o.Meaning = "" + l.Choices[i] = o + } + b.List = &l + } + b.Later.Meaning = "" + b.Now, b.ChangeLater = "", "" + return b +} + +// briefAsk is the one line q is asked by in its one-line form. +func briefAsk(q question.Question) string { + if q.Ask == setupAsk { + if a, ok := briefValueAsks[q.ID]; ok { + return a + } + if el, ok := strings.CutPrefix(q.ID, elementPromptPrefix); ok { + if shows, known := statusLineElementAbout[statusline.ElementKey(el)]; known { + return "Show " + shows + " on the status line?" + } + } + return "Choose " + q.ID + "?" + } + // An approval of a kind of change points at its list, which the plan + // already showed and ? shows again. + return strings.Replace(q.Ask, "listed above", "on the plan", 1) +} diff --git a/internal/core/ahoy/setup_plan.go b/internal/core/ahoy/setup_plan.go new file mode 100644 index 0000000000..83d057fd67 --- /dev/null +++ b/internal/core/ahoy/setup_plan.go @@ -0,0 +1,348 @@ +package ahoy + +import ( + "fmt" + "slices" + "strings" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/core/banlist" + "github.com/intentdriven/abcd/internal/core/drainrule" + "github.com/intentdriven/abcd/internal/core/layered" + "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/core/statusline" +) + +// The plan screen (itd-2610240823055889, criteria 2 and 5): setup checks +// everything first, then shows one screen listing every change it would make +// and every default it would take, each default with where to change it, and +// asks once: proceed, customise or cancel. Proceed approves every kind of +// change and takes the defaults listed; customise asks each kind of change and +// each value in turn, as setup asked before the plan existed; cancel writes +// nothing. The steps only a person can approve are listed under "asks you" +// and asked as their own questions whatever the answer (person_only.go). + +// PlanQuestionID is the plan screen's id: the id an answers file answers it +// by. It replaces the approve.<category> ids of the approvals the plan asks +// for at once; an answers file naming one of those is refused, naming this. +const PlanQuestionID = approvePrefix + "plan" + +// PlanChoice is the answer to the plan screen. +type PlanChoice string + +// The plan screen's answers. +const ( + PlanProceed PlanChoice = "proceed" + PlanCustomise PlanChoice = "customise" + PlanCancel PlanChoice = "cancel" +) + +// planChoices are the plan's answers in the order it offers them. +var planChoices = []PlanChoice{PlanProceed, PlanCustomise, PlanCancel} + +// SetupPlan is what setup would do in one repository, as the plan screen +// lists it: the changes grouped by where they land, the defaults taken, and +// the questions that follow whatever the answer. +type SetupPlan struct { + Changes []PlanGroup `json:"changes"` + Defaults []PlanDefault `json:"defaults"` + AsksYou []string `json:"asks_you"` +} + +// PlanGroup is the changes that land in one place. +type PlanGroup struct { + Where string `json:"where"` + Items []string `json:"items"` +} + +// PlanDefault is one default the plan takes: what it decides, the value, and +// where to change it. +type PlanDefault struct { + Key string `json:"key"` + What string `json:"what"` + Value string `json:"value"` + ChangeWith string `json:"change_with"` +} + +// PlanPrompter is a prompter that puts the plan screen. asked is false where +// it does not (a piped answer stream, whose answers line up with the +// approvals one by one), and the run then asks as it did before the plan. +type PlanPrompter interface { + Prompter + Plan(p SetupPlan) (choice PlanChoice, asked bool) +} + +// planWhere names each scope's group on the screen. +var planWhere = map[string]string{ + "repo": "In this repository", + "machine": "On this machine", + "user": "In your home folder", +} + +// planDefaultFor is each default the plan can take, keyed by the gap whose +// presence makes setup ask it. +var planDefaultFor = []struct { + gap string + def PlanDefault +}{ + {"config.visibility_missing", PlanDefault{Key: "visibility", What: "abcd's records", Value: "private: committed with your code", + ChangeWith: "abcd ahoy install --visibility public"}}, + {"config.docs_target_missing", PlanDefault{Key: "docs_target", What: "the abcd block in AGENTS.md", Value: docsTargetDefault + ": no block", + ChangeWith: "abcd ahoy install --docs-target agents_md"}}, + {"config.scan_deep_missing", PlanDefault{Key: "scan_deep", What: "the deep secret scan", Value: "off", + ChangeWith: "abcd ahoy install --scan-deep true"}}, + {"banlist.public_family_missing", PlanDefault{Key: emDashPromptKey, What: "an em dash in a list item", Value: "a warning", + ChangeWith: banlist.PublicConfigRelPath}}, + {ArtefactMissingGapID, PlanDefault{Key: artefactKindKey, What: "what this repository releases", Value: artefactKindDefault, + ChangeWith: ".abcd/config/artefact.json"}}, + {OracleRoutingMachineGapID, PlanDefault{Key: OracleRoutingMachineGapID, What: "model routing for this machine", Value: "abcd's suggested table", + ChangeWith: abcdhome.Display(layered.OracleRouting.MachineRel)}}, + {OracleRoutingRepoGapID, PlanDefault{Key: OracleRoutingRepoGapID, What: "model routing for this repository", Value: "abcd's suggested table", + ChangeWith: layered.OracleRouting.RepoRel}}, + {DrainRuleOfferGapID, PlanDefault{Key: DrainRuleOfferGapID, What: "which issues abcd drain fixes alone", Value: "abcd's strict baseline", + ChangeWith: drainrule.ADRsRelDir + "/"}}, + {ReadmeOfferGapID, PlanDefault{Key: ReadmePromptKey, What: "README parts abcd keeps current", Value: "the licence line and the links", + ChangeWith: "abcd ahoy install --readme-parts <list>|none"}}, + {StatusLineOfferGapID, PlanDefault{Key: elementPromptPrefix + "*", What: "the status line's parts", Value: "all on", + ChangeWith: statusline.SettingsDisplay}}, + {ConventionsRetireGapID, PlanDefault{Key: conventionsRetirePromptPrefix + "*", What: "a tool's file that only repeats AGENTS.md", Value: "kept", + ChangeWith: "remove it yourself, or choose customise"}}, +} + +// buildSetupPlan composes the plan for the repository det describes, given +// what the run will ask of the person on its own (asksYou). +func buildSetupPlan(det DetectionResult, asksYou []string) SetupPlan { + present := gapIDSet(det.Gaps) + plan := SetupPlan{Changes: []PlanGroup{}, Defaults: []PlanDefault{}, AsksYou: asksYou} + if plan.AsksYou == nil { + plan.AsksYou = []string{} + } + if det.FolderKind == UnmanagedRepo { + plan.Changes = append(plan.Changes, PlanGroup{Where: planWhere["repo"], Items: []string{"adopt this repository into abcd"}}) + } + offers := map[string]bool{} + for _, d := range planDefaultFor { + offers[d.gap] = true + } + for _, scope := range []string{"repo", "machine", "user"} { + var items []string + for _, g := range det.Gaps { + if g.Scope != scope || !g.Resolvable || personOnlyGap(g.ID) || offers[g.ID] || g.Category == Readme { + continue + } + if t := strings.Join(strings.Fields(g.Title), " "); t != "" && !slices.Contains(items, t) { + items = append(items, t) + } + } + if len(items) == 0 { + continue + } + if i := slices.IndexFunc(plan.Changes, func(pg PlanGroup) bool { return pg.Where == planWhere[scope] }); i >= 0 { + plan.Changes[i].Items = append(plan.Changes[i].Items, items...) + continue + } + plan.Changes = append(plan.Changes, PlanGroup{Where: planWhere[scope], Items: items}) + } + for _, d := range planDefaultFor { + if present[d.gap] { + plan.Defaults = append(plan.Defaults, d.def) + } + } + return plan +} + +// planAsk is the plan screen's one line. +const planAsk = "Proceed with this plan?" + +// SetupPlanQuestion is the plan screen as the shared question type: the plan +// as its material (the changes grouped by where they land, the defaults each +// with where to change it, and the questions that follow), the one line, and +// the three answers, deciding later cancelling. +func SetupPlanQuestion(n int, p SetupPlan) question.Question { + var material []question.Block + for _, g := range p.Changes { + material = append(material, question.Block{Kind: question.KindParagraph, Text: g.Where + ", setup:"}, + question.Block{Kind: question.KindList, Items: append([]string(nil), g.Items...)}) + } + if len(p.Defaults) > 0 { + items := make([]string, len(p.Defaults)) + for i, d := range p.Defaults { + items[i] = d.What + ": " + d.Value + " (change: " + d.ChangeWith + ")" + } + material = append(material, question.Block{Kind: question.KindParagraph, Text: "Defaults it takes:"}, + question.Block{Kind: question.KindList, Items: items}) + } + if len(p.AsksYou) > 0 { + material = append(material, question.Block{Kind: question.KindParagraph, Text: "Then it asks you, one question each:"}, + question.Block{Kind: question.KindList, Items: append([]string(nil), p.AsksYou...)}) + } + if len(material) == 0 { + material = []question.Block{{Kind: question.KindParagraph, Text: "Setup has nothing to change here but the questions it asks you."}} + } + return question.Question{ + ID: PlanQuestionID, + Chip: setupChip(n), + Material: material, + Ask: planAsk, + Options: []question.Option{ + {Value: string(PlanProceed), Label: "Proceed", Meaning: "Makes every change and takes every default on the plan; the questions it lists are still asked, one each."}, + {Value: string(PlanCustomise), Label: "Customise", Meaning: "Asks about each kind of change and each value in turn before anything is written."}, + {Value: string(PlanCancel), Label: "Cancel", Meaning: "Writes nothing and ends setup."}, + }, + Later: question.Option{Value: SetupLaterValue, Label: question.Default.LaterLabels[0], + Meaning: "Cancels for now and writes nothing, so the next setup run shows the plan again."}, + Now: question.Default.NotApplicable, + ChangeLater: "each default's own line on the plan says where to change it", + } +} + +// PlanChoiceOf is the plan answer a recorded value stands for: deciding later +// cancels, and anything the plan does not offer is no answer. +func PlanChoiceOf(value string) (PlanChoice, bool) { + v := strings.ToLower(strings.TrimSpace(value)) + if v == SetupLaterValue { + return PlanCancel, true + } + for _, c := range planChoices { + if v == string(c) || (c == PlanCustomise && v == "customize") { + return c, true + } + } + return "", false +} + +// LegacyApprovalRefusal is the refusal of an answers file that answers an +// approval of one kind of change by its old id: the plan asks for them all at +// once now, under PlanQuestionID. "" for any other id. +func LegacyApprovalRefusal(id string) string { + if !strings.HasPrefix(id, approvePrefix) || id == PlanQuestionID { + return "" + } + return fmt.Sprintf("the answers file answers %s, an approval setup no longer asks: one plan screen approves every kind of change, "+ + `so answer it with {"id": %q, "value": "proceed|customise|cancel"} instead`, id, PlanQuestionID) +} + +// DefaultRecorder is a prompter that records the answer the plan took for a +// question it did not put, as the question was and as if it had been asked +// and answered so: a run's record then says what every answer was, the +// defaults the plan took included, and a replay from that record answers +// each question the same way. +type DefaultRecorder interface { + RecordDefaultConfirm(text string, yes bool) + RecordDefaultPrompt(key string, choices []string, def, value string) + RecordDefaultHelped(h PromptHelp, choices []string, def, value string) +} + +// planDefaults is the prompter of a run whose plan was proceeded with (or, +// off a terminal, customised): every approval of a kind of change is answered +// yes, and, where takeDefaults is +// set (proceed at a terminal), every value and offer the plan lists takes its +// default without being asked. The steps only a person can approve, and any +// question the plan does not list, reach inner as before. +type planDefaults struct { + inner Prompter + takeDefaults bool +} + +// Confirm answers an approval yes, an offer the plan lists by its default, +// and puts everything else to inner. +func (p *planDefaults) Confirm(text string) bool { + id := setupConfirmID(text) + switch { + case strings.HasPrefix(id, approvePrefix), id == "adopt": + return true + case p.takeDefaults && (id == OracleRoutingMachineGapID || id == OracleRoutingRepoGapID || id == DrainRuleOfferGapID): + if r, ok := p.inner.(DefaultRecorder); ok { + r.RecordDefaultConfirm(text, true) + } + return true + } + return p.inner.Confirm(text) +} + +// Prompt takes the plan's default for a value it lists, else puts it to inner. +func (p *planDefaults) Prompt(key string, choices []string, def string) string { + if v, ok := p.planValue(key, def); ok { + if r, ok := p.inner.(DefaultRecorder); ok { + r.RecordDefaultPrompt(key, choices, def, v) + } + return v + } + return p.inner.Prompt(key, choices, def) +} + +// PromptHelped is Prompt for a question with help built for this run. +func (p *planDefaults) PromptHelped(h PromptHelp, choices []string, def string) string { + if v, ok := p.planValue(h.Key, def); ok { + if r, ok := p.inner.(DefaultRecorder); ok { + r.RecordDefaultHelped(h, choices, def, v) + } + return v + } + return promptHelped(p.inner, h, choices, def) +} + +// AtTerminal is inner's. +func (p *planDefaults) AtTerminal() bool { return atTerminal(p.inner) } + +// planValue is the default the plan takes for the value question key, whose +// own default is def. +func (p *planDefaults) planValue(key, def string) (string, bool) { + if !p.takeDefaults { + return "", false + } + switch { + case key == "visibility": + return "private", true + case key == "docs_target": + return docsTargetDefault, true + case key == "scan_deep": + return scanDeepDefault, true + case key == emDashPromptKey: + return "warning", true + case key == artefactKindKey: + return artefactKindDefault, true + case key == ReadmePromptKey: + return "yes", true + case strings.HasPrefix(key, readmePartKeyPrefix): + return def, true + case strings.HasPrefix(key, elementPromptPrefix): + return "on", true + case strings.HasPrefix(key, conventionsRetirePromptPrefix): + return retireKeep, true + } + return "", false +} + +// askSetupPlan puts the plan for det to p when p puts one and the run asks +// its questions (no --yes, no categories answered in advance, no +// --only-waiting). It returns the prompter the rest of the run asks through, +// the answer, and whether the plan was put at all. +func askSetupPlan(det DetectionResult, opts InstallOptions, asksYou []string, p Prompter) (Prompter, PlanChoice, bool) { + if opts.Yes || opts.ApprovedCategories != nil || opts.OnlyWaiting { + return p, "", false + } + pp, ok := p.(PlanPrompter) + if !ok { + return p, "", false + } + choice, asked := pp.Plan(buildSetupPlan(det, asksYou)) + if !asked { + return p, "", false + } + switch { + case choice == PlanProceed: + // At a terminal the plan's defaults are taken; from an answers file + // every value still comes from the file. + return &planDefaults{inner: p, takeDefaults: atTerminal(p)}, choice, true + case choice == PlanCustomise && atTerminal(p): + // Each kind of change and each value is asked in turn, as setup + // asked before the plan existed. + return p, choice, true + case choice == PlanCustomise: + // Off a terminal there is nothing to customise but the file's own + // answers: every kind of change is approved, each value the file's. + return &planDefaults{inner: p}, choice, true + } + return p, PlanCancel, true +} diff --git a/internal/core/ahoy/setup_plan_test.go b/internal/core/ahoy/setup_plan_test.go new file mode 100644 index 0000000000..4022cea0b6 --- /dev/null +++ b/internal/core/ahoy/setup_plan_test.go @@ -0,0 +1,217 @@ +package ahoy + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +// planPrompter is a person at a terminal who answers the plan screen with +// choice and every other question through confirm (no, when nil), recording +// each question in the order it was put. A value question takes its default. +type planPrompter struct { + choice PlanChoice + confirm func(q string) bool + plans []SetupPlan + asked []string +} + +func (p *planPrompter) Plan(sp SetupPlan) (PlanChoice, bool) { + p.plans = append(p.plans, sp) + p.asked = append(p.asked, PlanQuestionID) + return p.choice, true +} +func (p *planPrompter) Confirm(q string) bool { + p.asked = append(p.asked, q) + return p.confirm != nil && p.confirm(q) +} +func (p *planPrompter) Prompt(key string, _ []string, def string) string { + p.asked = append(p.asked, key) + return def +} +func (p *planPrompter) AtTerminal() bool { return true } + +// gitOnlyDir is a directory holding only a link to the machine's git, for a +// PATH narrowed to what the run under test may find. +func gitOnlyDir(t *testing.T) string { + t.Helper() + git, err := exec.LookPath("git") + if err != nil { + t.Skip("git is not on PATH") + } + dir := t.TempDir() + if err := os.Symlink(git, filepath.Join(dir, "git")); err != nil { + t.Fatal(err) + } + return dir +} + +// planOpts are the options of a person's run at a terminal: nothing answered +// in advance, the accepting provenance stand-in. +func planOpts() InstallOptions { + return InstallOptions{VerifyProvenance: acceptProvenance} +} + +// TestPlanProceedAsksOnceAndTakesTheDefaults is criteria 2 and 5: a repository +// abcd has never seen, at a terminal, with the suggested answer: one plan +// screen (which adopts), no approval of a kind of change, no value question, +// and every default the plan lists is what lands. +func TestPlanProceedAsksOnceAndTakesTheDefaults(t *testing.T) { + setupHermetic(t) + repo := gitRepo(t) + p := &planPrompter{choice: PlanProceed} + + res, err := Install(repo, planOpts(), p) + if err != nil { + t.Fatal(err) + } + if len(p.asked) != 1 || p.asked[0] != PlanQuestionID { + t.Fatalf("asked %q, want the plan screen alone", p.asked) + } + if res.Status == "aborted" || res.Status == "refused" { + t.Fatalf("status %q, notes %q", res.Status, res.Notes) + } + plan := p.plans[0] + if len(plan.Changes) == 0 || plan.Changes[0].Items[0] != "adopt this repository into abcd" { + t.Errorf("the plan does not lead with the adoption: %+v", plan.Changes) + } + var keys []string + for _, d := range plan.Defaults { + keys = append(keys, d.Key) + if d.ChangeWith == "" || d.Value == "" { + t.Errorf("default %q does not say its value and where to change it: %+v", d.Key, d) + } + } + for _, want := range []string{"visibility", "docs_target"} { + if !slices.Contains(keys, want) { + t.Errorf("the plan lists no default for %s: %v", want, keys) + } + } + cfg, err := readConfig(repo) + if err != nil { + t.Fatal(err) + } + if repoSec, _ := cfg["repo"].(map[string]any); repoSec["visibility"] != "private" { + t.Errorf("visibility = %v, want the plan's default private", repoSec["visibility"]) + } +} + +// TestPlanCancelWritesNothing: cancel ends setup before its first write. +func TestPlanCancelWritesNothing(t *testing.T) { + setupHermetic(t) + repo := gitRepo(t) + p := &planPrompter{choice: PlanCancel} + res, err := Install(repo, planOpts(), p) + if err != nil { + t.Fatal(err) + } + if res.Status != "aborted" || len(res.Writes) != 0 { + t.Fatalf("status %q writes %v, want aborted with nothing written", res.Status, res.Writes) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd")); err == nil { + t.Error("cancel created .abcd/") + } +} + +// TestPlanCustomiseAsksEachKindOfChange: customise asks each kind of change +// and each value as setup asked before the plan, and the plan adopts. +func TestPlanCustomiseAsksEachKindOfChange(t *testing.T) { + setupHermetic(t) + repo := gitRepo(t) + p := &planPrompter{choice: PlanCustomise, confirm: func(string) bool { return true }} + if _, err := Install(repo, planOpts(), p); err != nil { + t.Fatal(err) + } + if slices.Contains(p.asked, "Adopt this unmanaged repo into abcd?") { + t.Error("customise asked the adoption again after the plan") + } + approvals := 0 + for _, q := range p.asked { + if askedCategory(q) != "" { + approvals++ + } + } + if approvals == 0 { + t.Errorf("customise asked no kind of change: %q", p.asked) + } + if !slices.Contains(p.asked, "visibility") { + t.Errorf("customise did not ask the visibility: %q", p.asked) + } +} + +// TestDownloadComesBeforeTheStatusLine is criterion 4: with abcd not on the +// PATH, setup offers the download before the status line, and a yes wires the +// status line in the same run; declined, the status line is never asked. +func TestDownloadComesBeforeTheStatusLine(t *testing.T) { + release := []byte("#!/bin/sh\necho abcd v9.9.9\n") + sum := sha256.Sum256(release) + fetch := func(tag string) (DownloadedRelease, error) { + if tag != "v9.9.9" { + return DownloadedRelease{}, errors.New("unexpected tag " + tag) + } + return DownloadedRelease{Bytes: release, SHA256: hex.EncodeToString(sum[:])}, nil + } + defer swapDownloadTag(func(string) string { return "v9.9.9" })() + + for _, tc := range []struct { + name string + yes bool + wantLine bool + }{{"yes", true, true}, {"no", false, false}} { + t.Run(tc.name, func(t *testing.T) { + home, _ := setupUserScope(t, gitOnlyDir(t)) + t.Setenv("ABCD_PLUGIN_ROOT", "") + harnessFixture(t, harnessSettingsWith("")) + repo := gitRepo(t) + opts := planOpts() + opts.DownloadAbcd = fetch + p := &planPrompter{choice: PlanProceed, confirm: func(q string) bool { + if isDownloadQuestion(q) { + return tc.yes + } + return isStatusLineOfferQuestion(q) + }} + res, err := Install(repo, opts, p) + if err != nil { + t.Fatal(err) + } + dl := slices.IndexFunc(p.asked, isDownloadQuestion) + sl := slices.IndexFunc(p.asked, isStatusLineOfferQuestion) + if dl < 0 { + t.Fatalf("the download was not offered: %q (notes %q)", p.asked, res.Notes) + } + _, ask := splitConfirm(p.asked[dl]) + if !strings.Contains(ask, "v9.9.9") || !strings.Contains(ask, "~/.local/bin/abcd") { + t.Errorf("the download's one line does not name the release and where it goes: %q", ask) + } + if !strings.Contains(strings.Join(p.plans[0].AsksYou, "\n"), "abcd download") { + t.Errorf("the plan does not list the download among its questions: %q", p.plans[0].AsksYou) + } + target := filepath.Join(home, ".local", "bin", "abcd") + if tc.yes { + if got, _ := os.ReadFile(target); string(got) != string(release) { + t.Fatalf("the verified release was not written to %s (notes %q)", target, res.Notes) + } + if sl < 0 || sl < dl { + t.Fatalf("the status line was not offered after the download: %q (notes %q)", p.asked, res.Notes) + } + if !slices.Contains(res.writeKinds, writeStatusLine) { + t.Errorf("the status line was not wired in the same run (notes %q)", res.Notes) + } + return + } + if sl >= 0 { + t.Errorf("the status line was asked about with abcd not on the PATH: %q", p.asked) + } + if _, err := os.Stat(target); err == nil { + t.Error("a declined download wrote the binary") + } + }) + } +} diff --git a/internal/core/ahoy/setup_question.go b/internal/core/ahoy/setup_question.go index 5d3a81f98b..b93ae8010f 100644 --- a/internal/core/ahoy/setup_question.go +++ b/internal/core/ahoy/setup_question.go @@ -135,6 +135,7 @@ var setupConfirmIDs = []struct { {OracleRoutingMachineGapID, func(s string) bool { return s == machineRoutingQuestion() }}, {OracleRoutingRepoGapID, func(s string) bool { return s == repoRoutingQuestion() }}, {StatusLineOfferGapID, isStatusLineOfferQuestion}, + {DownloadQuestionID, isDownloadQuestion}, {DrainRuleOfferGapID, func(s string) bool { return s == drainRuleQuestion() }}, {"history.lineage", func(s string) bool { return strings.HasPrefix(s, "Re-founded from ") && strings.HasSuffix(s, "? Link lineage?") @@ -250,6 +251,8 @@ func SetupMachineWide(id string) bool { func SetupFixedValues(id string) (values []string, ok bool) { var q question.Question switch { + case id == PlanQuestionID: + q = SetupPlanQuestion(0, SetupPlan{}) case id == "adopt", strings.HasPrefix(id, approvePrefix), confirmIDKnown(id): q = SetupConfirmQuestion(0, "") case strings.HasPrefix(id, elementPromptPrefix): diff --git a/internal/core/ahoy/setup_question_test.go b/internal/core/ahoy/setup_question_test.go index a856c581d9..89ccc2caa0 100644 --- a/internal/core/ahoy/setup_question_test.go +++ b/internal/core/ahoy/setup_question_test.go @@ -66,7 +66,7 @@ func TestSetupConfirmQuestionNamesEveryApproval(t *testing.T) { {categoryApprovalText(OracleRouting, []string{"no model-tier routing committed"}), "approve.oracle-routing", categoryWords(OracleRouting).ask, true}, {machineRoutingQuestion(), OracleRoutingMachineGapID, oracleRoutingMachineQuestionTail, true}, {repoRoutingQuestion(), OracleRoutingRepoGapID, oracleRoutingRepoQuestionTail, true}, - {statusLineOfferQuestion(harnessSettings{path: "/h/.claude/settings.json"}, "/h/.local/bin/abcd"), StatusLineOfferGapID, "Install abcd's status line?", true}, + {statusLineOfferQuestion(harnessSettings{path: "/h/.claude/settings.json"}, "/h/.local/bin/abcd"), StatusLineOfferGapID, "Install abcd's status line in /h/.claude/settings.json, running /h/.local/bin/abcd statusline?", true}, {drainRuleQuestion(), DrainRuleOfferGapID, drainRuleQuestionTail, true}, {"Re-founded from abc1234? Link lineage?", "history.lineage", "Re-founded from abc1234? Link lineage?", false}, {"Commit to this repository as Jan B. Doe, from the pin? (sets user.name and user.email in this repository's .git/config only)", diff --git a/internal/core/ahoy/setup_report.go b/internal/core/ahoy/setup_report.go new file mode 100644 index 0000000000..bdee30d357 --- /dev/null +++ b/internal/core/ahoy/setup_report.go @@ -0,0 +1,184 @@ +package ahoy + +import ( + "slices" + "strings" +) + +// The report setup ends on (itd-2610240823055889, criteria 7 and 8): three +// short parts. Done is one line per change, with where it landed; needs you +// is a numbered checklist, each item the exact step and why; next is one +// step. A repository already fully set up is reported in one line. The exact +// record (writes, notes, remaining gap ids) stays on the result beside it. + +// SetupReport is the three parts of the report. +type SetupReport struct { + Done []string `json:"done"` + NeedsYou []ReportStep `json:"needs_you"` + Next string `json:"next"` + // OneLine is the whole report of a repository already fully set up, and + // "" otherwise: a front door prints it alone. + OneLine string `json:"one_line,omitempty"` +} + +// ReportStep is one item of the needs-you checklist: the exact step and why. +type ReportStep struct { + Step string `json:"step"` + Why string `json:"why"` +} + +// UpToDateLine is the one line a repository already fully set up is reported +// in. +const UpToDateLine = "abcd is already set up here; nothing changed." + +// doneLabel is each kind of write in the words of one done line, its path +// following. +var doneLabel = map[writeKind]string{ + writeSettings: "saved this repository's abcd settings", + writeSetupRecord: "recorded abcd's set-up details", + writeGitignore: "kept abcd's private files out of git", + writeLocalTier: "created the private working folder", + writeRecordTiers: "created a shared record folder", + writeDecisionsFolder: "started the decisions folder", + writeDevelopmentReadme: "started the lasting record's README", + writeNameGuard: "added the banned-names check", + writePrivateNames: "created this machine's banned-names list", + writeDocsCheck: "set up the documentation check", + writeAttributionHook: "added the AI-assistance commit prompt", + writeOutboundHook: "added the outbound commit-message check", + writeOutboundForge: "switched on the outbound post check", + writeOutboundBinary: "named the verified abcd for the commit check", + writeOutboundShim: "switched on the commit-message check", + writeOutboundChain: "saved abcd's commit-message check", + writeSessionStore: "registered this repository on this machine", + writeConventionsBlock: "added abcd's block", + writeConventionsBlockRemoved: "removed abcd's block", + writeCommandEntry: "put abcd on your PATH", + writeStatusLine: "set up abcd's status line", + writeStatusLineBackup: "kept a copy of your assistant's settings", + writeRouting: "saved the model routing table", + writeDrainRule: "recorded the drain rule", + writeToolFileRetired: "removed a file that only repeated AGENTS.md", + writeRules: "created the working-rules switches", + writeIdentityPin: "recorded who commits here", + writeGitIdentity: "set who commits here", + writeArtefactKind: "recorded what this repository releases", + writeReadmeParts: "wrote the README parts you chose", + writeReadmeCreated: "created README.md with the parts you chose", + writeReadmePartsRemoved: "removed README parts no longer chosen", +} + +// composeReport builds the report from the exact record and the steps the +// run found left for the person (r.needs), so it says nothing the record does +// not. +func (r *InstallResult) composeReport() { + rep := SetupReport{Done: []string{}, NeedsYou: []ReportStep{}} + for i, w := range r.Writes { + label := "wrote" + if i < len(r.writeKinds) { + if l, ok := doneLabel[r.writeKinds[i]]; ok { + label = l + } + } + line := label + ": " + w + if !slices.Contains(rep.Done, line) { + rep.Done = append(rep.Done, line) + } + } + for _, c := range r.Changes { + rep.Done = append(rep.Done, "changed: "+c) + } + add := func(step, why string) { + step, why = strings.TrimSpace(step), strings.TrimSpace(why) + if step == "" { + return + } + for _, s := range rep.NeedsYou { + if s.Step == step { + return + } + } + rep.NeedsYou = append(rep.NeedsYou, ReportStep{Step: step, Why: why}) + } + for _, s := range r.needs { + add(s.Step, s.Why) + } + for _, id := range r.Remaining { + if id == UnverifiedCopyGapID { + it := unverifiedCopyHelp(r.unverifiedCheck, r.unverifiedWhy) + add(it.Action, it.What) + } + } + for _, c := range r.DeclinedCategories { + if it, ok := declinedCategoryHelp[GapCategory(c)]; ok && !strings.HasPrefix(it.Action, "Nothing") { + add(it.Action, it.Why) + } + } + for _, id := range r.OptionalSkipped { + it, ok := optionalSkippedHelp[id] + if !ok { + continue + } + if id == ReadmeOfferGapID && r.readmeAwaitsConfig { + it = readmeAwaitsConfigHelp + } + add(it.Action, it.What) + } + if len(r.needs) == 0 && slices.ContainsFunc(r.Remaining, func(id string) bool { return id != UnverifiedCopyGapID }) { + add(remainingHelp.Action, remainingHelp.Why) + } + switch { + case r.Status == "refused": + rep.Next = "Do what the message above names, then run abcd ahoy install again." + case r.Status == "aborted": + rep.Next = "Run abcd ahoy install again when you want abcd set up here." + case len(rep.NeedsYou) > 0: + rep.Next = "Do step 1 above, then run abcd ahoy install again." + default: + rep.Next = "Open this repository in your AI coding assistant; run abcd here to see the status board." + } + if r.Status == "already_up_to_date" && len(rep.NeedsYou) == 0 && len(r.Warnings) == 0 { + rep.OneLine = UpToDateLine + } + r.Report = rep +} + +// remainingSteps is the needs-you checklist for the gaps a run left: each +// required gap's own fix, a step only the person can take (a folder not on +// PATH, an entry abcd will not replace) included, and each required tool +// still missing, as the step that installs it by hand. Ordered as the gaps +// are. +func remainingSteps(cwd string, gaps []Gap) []ReportStep { + var out []ReportStep + for _, g := range gaps { + switch { + case g.Category == Dependency && g.Tool != nil: + // An optional tool is offered at a terminal, one question each, + // and is not a step the person owes. + if !g.Required || onPath(cwd, g.Tool.Tool) { + continue + } + out = append(out, ReportStep{Step: "Install " + g.Tool.Tool + " yourself: " + g.Tool.StepText(), + Why: g.Tool.Tool + " is optional; " + strings.TrimSuffix(g.Title, ".") + "."}) + case g.Required && g.FixHint != "" && !optionalGapID(g.ID): + out = append(out, ReportStep{Step: g.FixHint, Why: strings.TrimSuffix(reportFirstSentence(g.Detail, g.Title), ".") + "."}) + } + } + return out +} + +// optionalGapID reports whether id is one of the offers the report lists +// from OptionalSkipped instead. +func optionalGapID(id string) bool { return slices.Contains(optionalGapIDs, id) } + +// reportFirstSentence is the first sentence of s, or fallback when s is empty. +func reportFirstSentence(s, fallback string) string { + s = strings.TrimSpace(s) + if s == "" { + return fallback + } + if i := strings.Index(s, ". "); i >= 0 { + return s[:i+1] + } + return s +} diff --git a/internal/core/ahoy/setup_report_test.go b/internal/core/ahoy/setup_report_test.go new file mode 100644 index 0000000000..40c1ef116e --- /dev/null +++ b/internal/core/ahoy/setup_report_test.go @@ -0,0 +1,71 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestReportHasThreeParts is criterion 7: the report ends in three parts: +// done, one line per change with where it landed; needs you, a checklist of +// exact steps each with why; and one next step. +func TestReportHasThreeParts(t *testing.T) { + setupHermetic(t) + clearIdentityEnv(t) + repo := idGitRepo(t, "Alex Reppel", "alex@example.com") + res, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed}) + if err != nil { + t.Fatal(err) + } + rep := res.Report + if len(rep.Done) == 0 { + t.Fatalf("a run that wrote %d files reports nothing done", len(res.Writes)) + } + for _, line := range rep.Done { + label, where, ok := strings.Cut(line, ": ") + if !ok || label == "" || where == "" { + t.Errorf("done line %q is not a change with where it landed", line) + } + } + for i, s := range rep.NeedsYou { + if s.Step == "" || s.Why == "" { + t.Errorf("needs-you item %d lacks its step or why: %+v", i+1, s) + } + } + if rep.Next == "" || strings.Contains(rep.Next, "\n") { + t.Errorf("next = %q, want one step", rep.Next) + } + if rep.OneLine != "" { + t.Errorf("a run that changed things reported one line: %q", rep.OneLine) + } +} + +// TestFullySetUpRerunIsOneLine is criterion 8: run again on a repository that +// is fully set up, setup asks nothing, changes nothing and reports one line. +func TestFullySetUpRerunIsOneLine(t *testing.T) { + setupHermetic(t) + // The PATH entry's folder is on PATH, as a set-up account's is. + t.Setenv("PATH", filepath.Dir(os.Getenv("ABCD_BIN_TARGET"))+string(os.PathListSeparator)+os.Getenv("PATH")) + repo := idGitRepo(t, "Alex Reppel", "alex@example.com") + // A first commit gives the repository the root commit abcd registers it by. + idMustGit(t, repo, "commit", "--allow-empty", "-q", "-m", "first") + clearIdentityEnv(t) + if _, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed}); err != nil { + t.Fatal(err) + } + again := &planPrompter{choice: PlanProceed} + res, err := Install(repo, planOpts(), again) + if err != nil { + t.Fatal(err) + } + if res.Status != "already_up_to_date" || len(res.Writes) != 0 { + t.Fatalf("rerun status %q, writes %v, notes %q, optional %v", res.Status, res.Writes, res.Notes, res.OptionalSkipped) + } + if len(again.asked) != 0 { + t.Errorf("the rerun asked %q", again.asked) + } + if res.Report.OneLine != UpToDateLine { + t.Errorf("one line = %q, needs you %+v", res.Report.OneLine, res.Report.NeedsYou) + } +} diff --git a/internal/core/ahoy/statusline_apply.go b/internal/core/ahoy/statusline_apply.go index 66dde89ed0..86dce5747c 100644 --- a/internal/core/ahoy/statusline_apply.go +++ b/internal/core/ahoy/statusline_apply.go @@ -95,8 +95,8 @@ func (a *applyCtx) offerStatusLine() { } entry, why := a.statusLineEntry() if entry == "" { - a.refuse("refused to wire the status line: " + why + "; nothing was written. " + - "Put abcd on your PATH with `abcd ahoy install` first, then run it again to wire the status line.") + a.refuse("refused to wire the status line: " + why + "; nothing was written, and the status line was not offered. " + + "Answer yes to the abcd download when setup offers it, or put abcd on your PATH with the README's install line, then run `abcd ahoy install` again to wire the status line.") return } if !a.prompter.Confirm(statusLineOfferQuestion(hs, entry)) { diff --git a/internal/core/ahoy/statusline_guard.go b/internal/core/ahoy/statusline_guard.go index cc9488b337..6660eb3531 100644 --- a/internal/core/ahoy/statusline_guard.go +++ b/internal/core/ahoy/statusline_guard.go @@ -41,9 +41,16 @@ var ( "led by a badge saying whether abcd is here and whose answer the work is waiting on; " + "in every other repository the line you have now runs untouched. " + "Switch parts of it off any time in " + statusline.SettingsDisplay + "." - statusLineOfferAsk = "Install abcd's status line?" + // statusLineOfferAsk opens the offer's one line, which names the entry + // and the file it is written to (itd-2610240823055889, criterion 3). + statusLineOfferAsk = "Install abcd's status line" ) +// statusLineOfferLine is the offer's one line: the file and the command. +func statusLineOfferLine(settings, entry string) string { + return statusLineOfferAsk + " in " + settings + ", running " + entry + " " + statusVerb + "?" +} + // statusLineOfferQuestion is the reason, the one change and the question, as // the confirm's text: core never prints, so what ac-8 and risk 4 require the // person to see travels as the question the prompter renders. The change is a @@ -60,12 +67,14 @@ func statusLineOfferQuestion(hs harnessSettings, entry string) string { " after: " + displayPath(entry) + " " + statusVerb + "\n" + "abcd records the command you have now and `abcd ahoy uninstall` puts it back; " + "a copy of the file is kept in " + abcdhome.Display("backups") + "/ before it changes, and declining writes nothing.\n" + - statusLineOfferAsk + statusLineOfferLine(displayPath(hs.path), displayPath(entry)) } // isStatusLineOfferQuestion reports whether text is the status-line offer. func isStatusLineOfferQuestion(text string) bool { - return strings.HasPrefix(text, statusLineOfferLead) && strings.HasSuffix(text, "\n"+statusLineOfferAsk) + i := strings.LastIndex(text, "\n") + return strings.HasPrefix(text, statusLineOfferLead) && i >= 0 && + strings.HasPrefix(text[i+1:], statusLineOfferAsk+" in ") && strings.HasSuffix(text, "?") } // statusLineEntry is the absolute path the harness command will run, or why @@ -85,7 +94,13 @@ func (a *applyCtx) statusLineEntry() (entry, why string) { return "", "the PATH entry on record, " + displayPath(rec.path) + ", is not there" } switch classifyBinTarget(rec.path, a.det.pluginRoot) { - case binTargetOwnedSymlink, binTargetOwnedCopy, binTargetDevShim: + case binTargetOwnedSymlink, binTargetOwnedCopy: + case binTargetDevShim: + // The dev shim rebuilds abcd from the source tip on every call, and + // the status line runs on every refresh, so the line runs only a + // release copy; in a source checkout, the download question puts one + // there (itd-2610240823055889). + return "", "the PATH entry on record, " + displayPath(rec.path) + ", is the dev shim, which rebuilds abcd on every call; the status line runs only a release copy" default: return "", "the PATH entry on record, " + displayPath(rec.path) + ", is not abcd's own" } diff --git a/internal/core/ahoy/statusline_guard_test.go b/internal/core/ahoy/statusline_guard_test.go index e62a05341a..c4c44aed5f 100644 --- a/internal/core/ahoy/statusline_guard_test.go +++ b/internal/core/ahoy/statusline_guard_test.go @@ -22,7 +22,7 @@ import ( // asked, or "" when it was never asked. func offerQuestionAsked(p *scriptedPrompter) string { for _, q := range p.asked { - if strings.Contains(q, "Install abcd's status line?") { + if strings.Contains(q, "Install abcd's status line in ") { return q } } @@ -110,7 +110,7 @@ func TestStatusLineOfferShowsTheOneChange(t *testing.T) { t.Errorf("setupConfirmID = %q, want %q", id, StatusLineOfferGapID) } sq := SetupConfirmQuestion(1, q) - if sq.Ask != "Install abcd's status line?" { + if !strings.HasPrefix(sq.Ask, "Install abcd's status line in ") { t.Errorf("ask = %q", sq.Ask) } if fs := question.Check(question.Ask{Questions: []question.Question{sq}}); len(fs) > 0 { diff --git a/internal/core/update/fetch.go b/internal/core/update/fetch.go new file mode 100644 index 0000000000..87b428f8b5 --- /dev/null +++ b/internal/core/update/fetch.go @@ -0,0 +1,68 @@ +package update + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" +) + +// FetchedRelease is a release binary downloaded and verified, before anything +// is written: its bytes, the sha256 checksums.txt names for them, and the +// signer of the build provenance that names them. +type FetchedRelease struct { + Bytes []byte + SHA256 string + Signer string +} + +// FetchVerified downloads tag's binary for this platform and checks it the +// way Apply checks a swap and the README's install line checks a first +// install: against the release's own checksums.txt, then against the build +// provenance the release workflow signed. It writes nothing; the caller +// writes the bytes. It is the fetch `ahoy install` makes on a yes to the +// question naming the download (itd-2610240823055889), where there is no +// file at the target for Apply to prove abcd's. +func (u *Updater) FetchVerified(tag string, progress io.Writer) (FetchedRelease, error) { + sums, found, err := u.fetchChecksums(tag) + if err != nil { + return FetchedRelease{}, fmt.Errorf("fetching %s checksums: %w", tag, err) + } + if !found { + return FetchedRelease{}, fmt.Errorf("release %s has no checksums.txt at %s", tag, u.origin) + } + wantHex, ok := sums[u.assetName] + if !ok { + return FetchedRelease{}, fmt.Errorf("release %s publishes no %s", tag, u.assetName) + } + want, err := hex.DecodeString(wantHex) + if err != nil { + return FetchedRelease{}, fmt.Errorf("release %s carries a malformed digest for %s", tag, u.assetName) + } + body, size, err := u.get(u.assetURL(tag, u.assetName), maxAssetBytes) + if err != nil { + return FetchedRelease{}, fmt.Errorf("downloading %s %s: %w", tag, u.assetName, err) + } + defer body.Close() + reader := io.Reader(body) + if progress != nil { + reader = &progressReader{r: body, total: size, out: progress, label: u.assetName + " " + tag} + } + b, err := io.ReadAll(reader) + if err != nil { + return FetchedRelease{}, fmt.Errorf("downloading %s %s: %w", tag, u.assetName, err) + } + if progress != nil { + fmt.Fprintln(progress) + } + if got := sha256.Sum256(b); !bytes.Equal(got[:], want) { + return FetchedRelease{}, fmt.Errorf("verifying %s against the release manifest: the download's sha256 is %x, the manifest names %x", u.assetName, got, want) + } + signer, err := u.verifyProvenance(tag, want) + if err != nil { + return FetchedRelease{}, fmt.Errorf("the release's %s matches its checksums.txt, but its build provenance could not be verified: %w; %s", + u.assetName, err, u.remedyInstallByHand(tag, "~/.local/bin/abcd")) + } + return FetchedRelease{Bytes: b, SHA256: wantHex, Signer: signer}, nil +} diff --git a/internal/core/update/fetch_test.go b/internal/core/update/fetch_test.go new file mode 100644 index 0000000000..1647f0b0c4 --- /dev/null +++ b/internal/core/update/fetch_test.go @@ -0,0 +1,42 @@ +package update + +import ( + "errors" + "strings" + "testing" +) + +// TestFetchVerifiedChecksBothRecords: the download setup offers is the +// release's own bytes, checked against its checksums.txt and its build +// provenance; a mismatch or an unverified provenance returns nothing. +func TestFetchVerifiedChecksBothRecords(t *testing.T) { + o := newTestOrigin(t) + bin := []byte("release-binary-bytes") + o.addRelease("v0.7.0", testAssetName, bin) + u := testUpdater(t, o) + + got, err := u.FetchVerified("v0.7.0", nil) + if err != nil { + t.Fatal(err) + } + if string(got.Bytes) != string(bin) || len(got.SHA256) != 64 { + t.Fatalf("fetched %q / %q", got.Bytes, got.SHA256) + } + + o.addRelease("v0.7.1", testAssetName, []byte("what-the-manifest-says")) + o.assets["v0.7.1"][testAssetName] = []byte("other-bytes") + if _, err := u.FetchVerified("v0.7.1", nil); err == nil || !strings.Contains(err.Error(), "manifest") { + t.Errorf("a checksum mismatch returned %v", err) + } + + u.provenance = refusingProvenance{} + if _, err := u.FetchVerified("v0.7.0", nil); err == nil || !strings.Contains(err.Error(), "provenance") { + t.Errorf("an unverified provenance returned %v", err) + } +} + +type refusingProvenance struct{} + +func (refusingProvenance) verify(string, []byte) (string, error) { + return "", errors.New("no attestation names these bytes") +} diff --git a/internal/surface/cli/ahoy_identity_gate_test.go b/internal/surface/cli/ahoy_identity_gate_test.go index 8ba6bc02a9..ac507e7ebd 100644 --- a/internal/surface/cli/ahoy_identity_gate_test.go +++ b/internal/surface/cli/ahoy_identity_gate_test.go @@ -57,7 +57,7 @@ func TestAhoyInstallPipedAnswersNeverRewriteTheIdentity(t *testing.T) { if err != nil { t.Fatal(err) } - stdout, stderr, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 16), "ahoy", "install", "--adopt", + stdout, stderr, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 16), "ahoy", "install", "--details", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if err != nil { diff --git a/internal/surface/cli/ahoy_only_waiting_test.go b/internal/surface/cli/ahoy_only_waiting_test.go index c497f8e302..56b2e1348f 100644 --- a/internal/surface/cli/ahoy_only_waiting_test.go +++ b/internal/surface/cli/ahoy_only_waiting_test.go @@ -14,7 +14,7 @@ func TestAhoyInstallOnlyWaitingWithYesLeavesTheValueQuestions(t *testing.T) { repo, _, _ := afterUpdateRepo(t) t.Setenv("ABCD_BIN_TARGET", filepath.Join(t.TempDir(), "bin", "abcd")) t.Setenv("PATH", "/usr/bin:/bin") - stdout, stderr, code := runSessionStart("", "ahoy", "install", "--only-waiting", "--yes", "--adopt", "--allow-stale-binary") + stdout, stderr, code := runSessionStart("", "ahoy", "install", "--details", "--only-waiting", "--yes", "--adopt", "--allow-stale-binary") if code != 0 && code != 2 { t.Fatalf("exit %d, stdout %q, stderr %q", code, stdout, stderr) } diff --git a/internal/surface/cli/ahoy_prompt_help_test.go b/internal/surface/cli/ahoy_prompt_help_test.go index 4a5d77e202..14e18c6e7e 100644 --- a/internal/surface/cli/ahoy_prompt_help_test.go +++ b/internal/surface/cli/ahoy_prompt_help_test.go @@ -25,7 +25,7 @@ func TestAhoyInstallRendersCoreHelpAboveEachValueQuestion(t *testing.T) { // questions no flag answered: visibility and the docs target. The oracle is // not asked while host-delegated is the only reviewer abcd ships // (iss-2610031236155833): it is recorded, and the report says so. - out, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--yes", "--adopt", "--json") + out, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--details", "--yes", "--adopt", "--json") if err != nil { t.Fatalf("install exited non-zero: %v\n%s\n%s", err, out, errOut) } @@ -138,7 +138,7 @@ func TestAhoyInstallShowsTheTrackedCaveatOnlyWhereItApplies(t *testing.T) { {"untracked", untracked.Root(), without, withCaveat}, } { t.Chdir(c.root) - out, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--yes", "--adopt", "--json") + out, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--details", "--yes", "--adopt", "--json") if err != nil { t.Fatalf("%s: install exited non-zero: %v\n%s\n%s", c.name, err, out, errOut) } @@ -165,7 +165,7 @@ func TestAhoyInstallTextLeadsWithThePlainSummary(t *testing.T) { hermeticEnv(t) repo := gittest.NewRepo(t).Root() t.Chdir(repo) - args := []string{"ahoy", "install", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", + args := []string{"ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false"} out, errOut, err := runCLIPipedStdinSplit(t, "", append(args, "--json")...) if err != nil { @@ -224,7 +224,7 @@ func TestAhoyInstallYesKeepsValuePromptsOffStdout(t *testing.T) { hermeticEnv(t) repo := gittest.NewRepo(t).Root() t.Chdir(repo) - out, errOut, err := runCLIPipedStdinSplit(t, "", "ahoy", "install", "--yes", "--adopt") + out, errOut, err := runCLIPipedStdinSplit(t, "", "ahoy", "install", "--details", "--yes", "--adopt") if err != nil { t.Fatalf("install exited non-zero: %v\n%s\n%s", err, out, errOut) } @@ -245,7 +245,7 @@ func TestAhoyInstallYesSaysUpFrontThatValuesAreStillAsked(t *testing.T) { hermeticEnv(t) repo := gittest.NewRepo(t).Root() t.Chdir(repo) - _, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--yes", "--adopt", "--json") + _, errOut, err := runCLIPipedStdinSplit(t, "private\n\n", "ahoy", "install", "--details", "--yes", "--adopt", "--json") if err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, errOut) } @@ -283,7 +283,7 @@ func TestAhoyInstallYesSaysUpFrontThatValuesAreStillAsked(t *testing.T) { repo2 := gittest.NewRepo(t).Root() t.Chdir(repo2) - _, errOut, err = runCLIPipedStdinSplit(t, "", "ahoy", "install", "--yes", "--adopt", "--json", + _, errOut, err = runCLIPipedStdinSplit(t, "", "ahoy", "install", "--details", "--yes", "--adopt", "--json", "--visibility", "private", "--docs-target", "skip", "--oracle-backend", "host-delegated", "--scan-deep", "false") if err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, errOut) diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index ef85b4271a..05e85af793 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -65,7 +65,7 @@ func TestAhoyInstallAcceptsPipedAnswersFromNonTTYStdin(t *testing.T) { // One `y` per question: the adoption gate, then one per gap category. This // is what `yes | abcd ahoy install` — the documented form — supplies. answers := strings.Repeat("y\n", 12) - out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", + out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", "--details", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false", "--json") if err != nil { @@ -174,7 +174,7 @@ func categoryQuestionsAsked(transcript string) []string { func TestAhoyInstallEmptyStdinStillDeclines(t *testing.T) { hermeticRepo(t) - out, err := runCLIPipedStdin(t, "", "ahoy", "install", "--json") + out, err := runCLIPipedStdin(t, "", "ahoy", "install", "--details", "--json") if err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, out) } @@ -216,7 +216,7 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") - base := []string{"ahoy", "install", "--yes", "--adopt", + base := []string{"ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false"} @@ -301,7 +301,7 @@ func TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") - if out, err := runCLIErr(t, "ahoy", "install", "--yes", "--adopt", + if out, err := runCLIErr(t, "ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false"); err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, out) @@ -311,7 +311,7 @@ func TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin(t *testing.T) { // install`, an answer for every question asked. More than one category is // still open after a --yes run (the dependency scanners as well as the pin), // which is why the documented remedy is `yes` and not a single `y`. - _, errOut, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 8), "ahoy", "install") + _, errOut, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 8), "ahoy", "install", "--details") if err != nil { t.Fatalf("piped re-install exited non-zero: %v\n%s", err, errOut) } diff --git a/internal/surface/cli/ahoy_provenance_test.go b/internal/surface/cli/ahoy_provenance_test.go index 28d01bb821..16f470800e 100644 --- a/internal/surface/cli/ahoy_provenance_test.go +++ b/internal/surface/cli/ahoy_provenance_test.go @@ -100,7 +100,7 @@ func TestAhoyInstallRefusesACacheTheReleaseWorkflowDidNotBuild(t *testing.T) { recordedProvenanceWithRef(t, tc.body, ref) // reads its fixture before the chdir t.Chdir(repo) - out, _ := runCLIErr(t, "ahoy", "install", "--yes", "--adopt", + out, _ := runCLIErr(t, "ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if _, err := os.Lstat(filepath.Join(binDir, "abcd")); !os.IsNotExist(err) { @@ -123,7 +123,7 @@ func TestAhoyInstallRefusesACacheTheReleaseWorkflowDidNotBuild(t *testing.T) { func TestAhoyInstallPromotesACacheWhoseProvenanceVerifies(t *testing.T) { binDir, repo := provenanceInstallEnv(t) t.Chdir(repo) - out := runCLI(t, "ahoy", "install", "--yes", "--adopt", + out := runCLI(t, "ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if fi, err := os.Lstat(filepath.Join(binDir, "abcd")); err != nil || !fi.Mode().IsRegular() { diff --git a/internal/surface/cli/ahoy_readme_ask_test.go b/internal/surface/cli/ahoy_readme_ask_test.go index 7c955dfdd3..379b420a4d 100644 --- a/internal/surface/cli/ahoy_readme_ask_test.go +++ b/internal/surface/cli/ahoy_readme_ask_test.go @@ -87,7 +87,12 @@ func TestDrawnPrompterAsksAReadmePartWithItsExample(t *testing.T) { swap := drawnPut t.Cleanup(func() { drawnPut = swap }) var put question.Ask - drawnPut = func(_ ask.Terminal, a question.Ask) ([]ask.Answer, error) { + drawnPut = func(tm ask.Terminal, a question.Ask) ([]ask.Answer, error) { + // The one-line form comes first; ? asks for the whole question, + // which is the one drawn next (itd-2610240823055889). + if tm.Explain { + return nil, ask.ErrExplain + } put = a return []ask.Answer{{Value: "on"}}, nil } diff --git a/internal/surface/cli/ahoy_readme_test.go b/internal/surface/cli/ahoy_readme_test.go index c6e713a20c..ada68e2502 100644 --- a/internal/surface/cli/ahoy_readme_test.go +++ b/internal/surface/cli/ahoy_readme_test.go @@ -18,7 +18,7 @@ func TestAhoyInstallReadmePartsFlag(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, "LICENSE"), []byte("MIT License\n"), 0o644); err != nil { t.Fatal(err) } - base := []string{"ahoy", "install", "--yes", "--adopt", + base := []string{"ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false"} @@ -63,7 +63,7 @@ func TestAhoyInstallReadmePartsFlag(t *testing.T) { // (spc-2610031704338417 step 4), at a terminal or off one. func TestAhoyInstallReadmeOfferLabelIsTrue(t *testing.T) { hermeticRepo(t) - out, err := runCLIPipedStdin(t, strings.Repeat("y\n", 40), "ahoy", "install", "--adopt", + out, err := runCLIPipedStdin(t, strings.Repeat("y\n", 40), "ahoy", "install", "--details", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if err != nil { @@ -88,7 +88,7 @@ func TestAhoyInstallReadmeOfferLabelIsTrue(t *testing.T) { // (iss-2610120505551170). func TestAhoyInstallReadmeHintWithoutAConfig(t *testing.T) { repo := hermeticRepo(t) - out, err := runCLIPipedStdin(t, strings.Repeat("n\n", 40), "ahoy", "install", "--adopt", + out, err := runCLIPipedStdin(t, strings.Repeat("n\n", 40), "ahoy", "install", "--details", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if err != nil { diff --git a/internal/surface/cli/ahoy_statusline_test.go b/internal/surface/cli/ahoy_statusline_test.go index 0ed5e01ce3..8d0a1452b6 100644 --- a/internal/surface/cli/ahoy_statusline_test.go +++ b/internal/surface/cli/ahoy_statusline_test.go @@ -46,7 +46,7 @@ func TestAhoyInstallYesReportsTheSkippedStatusLineOffer(t *testing.T) { settings := harnessSettingsFixture(t, `{"padding": 1}`) before, _ := os.ReadFile(settings) - out := runCLI(t, "ahoy", "install", "--yes", "--adopt", + out := runCLI(t, "ahoy", "install", "--details", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false", "--json") var res struct { @@ -63,7 +63,7 @@ func TestAhoyInstallYesReportsTheSkippedStatusLineOffer(t *testing.T) { t.Error("--yes rewrote the harness settings") } - text := runCLI(t, "ahoy", "install", "--yes") + text := runCLI(t, "ahoy", "install", "--details", "--yes") s := string(text) if !strings.Contains(s, "optional, not covered by --yes: ") || !strings.Contains(s, "statusline.offered") { t.Errorf("text render does not name the skipped offer:\n%s", s) @@ -80,7 +80,7 @@ func TestAhoyInstallPipedAnswersWireTheStatusLine(t *testing.T) { hermeticRepo(t) settings := harnessSettingsFixture(t, `{"statusLine": {"type": "command", "command": "bash /tmp/prev.sh"}}`) answers := strings.Repeat("y\n", 24) - out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", + out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", "--details", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false", "--json") if err != nil { @@ -133,7 +133,7 @@ func TestAhoyInstallSanitizesRefusalNotes(t *testing.T) { t.Fatal(err) } answers := strings.Repeat("y\n", 24) - out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", + out, errOut, err := runCLIPipedStdinSplit(t, answers, "ahoy", "install", "--details", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false") if err != nil { diff --git a/internal/surface/cli/ask/explain_test.go b/internal/surface/cli/ask/explain_test.go new file mode 100644 index 0000000000..b0cb9a94de --- /dev/null +++ b/internal/surface/cli/ask/explain_test.go @@ -0,0 +1,51 @@ +package ask + +import ( + "bytes" + "errors" + "testing" + + "github.com/intentdriven/abcd/internal/term" +) + +// TestQuestionMarkAsksForTheExplanation is itd-2610240823055889 criterion 6: +// a setup question is shown on one line, and answering ? asks for the full +// explanation, which the front door then shows before asking again. In both +// modes ? alone is the request; in the numbered reader it is a whole line, in +// the arrow-key list the first key typed. +func TestQuestionMarkAsksForTheExplanation(t *testing.T) { + t.Run("numbered", func(t *testing.T) { + var out bytes.Buffer + tm := Terminal{In: pipeIn(t, "?\n"), Out: &out, Getenv: env("TERM", "xterm"), Mode: term.Mono, + Roots: roots(t, numberedSetting, ""), Explain: true} + if _, err := tm.Put(fixture(t, "key-home")); !errors.Is(err, ErrExplain) { + t.Fatalf("a ? line returned %v, want ErrExplain", err) + } + }) + t.Run("numbered, not offered", func(t *testing.T) { + var out bytes.Buffer + tm := Terminal{In: pipeIn(t, "?\n2\n"), Out: &out, Getenv: env("TERM", "xterm"), Mode: term.Mono, + Roots: roots(t, numberedSetting, "")} + got, err := tm.Put(fixture(t, "key-home")) + if err != nil || len(got) != 1 || got[0].Value != "file" { + t.Fatalf("without Explain a ? narrows as before and the next line answers: %+v, %v", got, err) + } + }) + t.Run("arrows", func(t *testing.T) { + p := NewPicker(fixture(t, "key-home")) + p.AllowExplain() + if got := p.Apply(Key{Kind: KeyRune, Rune: '?'}); got != Explain { + t.Fatalf("? as the first key = %v, want Explain", got) + } + p = NewPicker(fixture(t, "key-home")) + p.AllowExplain() + p.Apply(Key{Kind: KeyRune, Rune: 'f'}) + if got := p.Apply(Key{Kind: KeyRune, Rune: '?'}); got != Pending { + t.Fatalf("? after other typing = %v, want Pending (it narrows)", got) + } + p = NewPicker(fixture(t, "key-home")) + if got := p.Apply(Key{Kind: KeyRune, Rune: '?'}); got != Pending { + t.Fatalf("? on a picker that offers no explanation = %v, want Pending", got) + } + }) +} diff --git a/internal/surface/cli/ask/loop.go b/internal/surface/cli/ask/loop.go index 63f7bd85fe..d0fad02dff 100644 --- a/internal/surface/cli/ask/loop.go +++ b/internal/surface/cli/ask/loop.go @@ -21,6 +21,11 @@ import ( // keeps the answers already given and exits 130. var ErrInterrupted = errors.New("interrupted") +// ErrExplain is ? answered at a question put with Terminal.Explain: nothing +// is recorded, and the front door shows the question's full explanation and +// asks it again (itd-2610240823055889, criterion 6). +var ErrExplain = errors.New("the full explanation was asked for") + // ExitInterrupted is the exit code a front door ends with on ErrInterrupted: // 128 plus SIGINT, as a shell reports an interrupted command. const ExitInterrupted = 130 @@ -46,6 +51,10 @@ type Terminal struct { Mode term.ColorMode // ASCII draws the marks without a UTF-8 locale. ASCII bool + // Explain makes ? answer the question with ErrExplain: a request for its + // full explanation, never an answer. In the numbered reader it is a line + // holding ? alone; in the arrow-key list, ? as the first key typed. + Explain bool // Roots are where Put reads the interview.list setting from // (layered.InterviewList): the machine's ~/.abcd.noindex/config.json under // Roots.Home, a repository's under Roots.Repo refused. @@ -184,6 +193,9 @@ type loop struct { // then a newline), and the session's own handler for a signal from outside. func (t Terminal) arrows(a question.Ask) ([]Answer, error) { l := &loop{t: t, p: NewPicker(a), headTab: -1} + if t.Explain { + l.p.AllowExplain() + } s, err := term.StartRaw(t.In, term.Hooks{Resized: l.redrawAll, Continued: l.redrawFresh}) if err != nil { return nil, &rawRefused{err: err} @@ -221,6 +233,14 @@ func (t Terminal) arrows(a question.Ask) ([]Answer, error) { _ = s.Restore() _, _ = io.WriteString(t.Out, "\n") return nil, ErrInterrupted + case Explain: + l.finished = true + err := l.collapseExplain() + l.mu.Unlock() + if err != nil { + return nil, err + } + return nil, ErrExplain case Suspended: if err := s.Suspend(); err != nil { l.mu.Unlock() @@ -318,6 +338,13 @@ func (l *loop) collapse() error { return l.paint(l.p.Collapsed(f), l.headLines+l.liveLines, rows, 0) } +// collapseExplain clears the question on screen for the explanation the +// front door draws next. The caller holds mu. +func (l *loop) collapseExplain() error { + _, rows := term.Size(l.t.In, l.t.Getenv) + return l.paint(nil, l.headLines+l.liveLines, rows, 0) +} + // paint moves up over the onScreen lines last drawn (no further than the // window's top), writes lines over them, each erased first, erases what is // left of the old ones, and leaves the cursor beneath the new lines. Raw mode diff --git a/internal/surface/cli/ask/numbered.go b/internal/surface/cli/ask/numbered.go index afac00a47e..462bba4173 100644 --- a/internal/surface/cli/ask/numbered.go +++ b/internal/surface/cli/ask/numbered.go @@ -120,6 +120,8 @@ func (t Terminal) numbered(a question.Ask) ([]Answer, error) { case text == "p" && paged(): page-- show("") + case text == "?" && t.Explain: + return nil, ErrExplain case text == "": filter, page = "", 0 show("") diff --git a/internal/surface/cli/ask/picker.go b/internal/surface/cli/ask/picker.go index 9860142ef4..38c5a0b1cb 100644 --- a/internal/surface/cli/ask/picker.go +++ b/internal/surface/cli/ask/picker.go @@ -21,6 +21,10 @@ const ( Interrupted // Suspended: Ctrl-Z. The loop stops the process and redraws on return. Suspended + // Explain: ? typed as the first key, on a list that offers the full + // explanation (AllowExplain). Nothing is recorded; the front door shows + // the explanation and asks again. + Explain ) func (o Outcome) String() string { @@ -33,6 +37,8 @@ func (o Outcome) String() string { return "Interrupted" case Suspended: return "Suspended" + case Explain: + return "Explain" } return fmt.Sprintf("Outcome(%d)", int(o)) } @@ -79,8 +85,14 @@ type Picker struct { parts []*part tab int window int + // explain is AllowExplain: ? as the first key asks for the explanation. + explain bool } +// AllowExplain makes ? typed as the first key of a part ask for the full +// explanation (Explain), rather than narrow the list by it. +func (p *Picker) AllowExplain() { p.explain = true } + // part is one question's list state. type part struct { all []question.Option // the answers, Later excluded @@ -194,6 +206,9 @@ func (p *Picker) Apply(k Key) Outcome { case KeyEnd: pt.cur = len(pt.shown) case KeyRune: + if p.explain && k.Rune == '?' && pt.typed == "" { + return Explain + } pt.typed += string(k.Rune) pt.refilter(!isNumber(pt.typed)) case KeyBackspace: diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 59600139ae..00cf99ab3c 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4094,6 +4094,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { answersPath string answeredIn string readmeParts string + details bool ) installCmd := &cobra.Command{ Use: "install", @@ -4118,6 +4119,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { opts.Outbound = outbound opts.OnlyWaiting = onlyWaiting(cmd) opts.VerifyProvenance = ahoyReleaseProvenance + opts.DownloadAbcd = ahoyDownloadRelease named, err := installToolNames(installTools) if err != nil { return err @@ -4165,6 +4167,12 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { return err } if rerr := render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + // A repository already fully set up is one line + // (itd-2610240823055889, criterion 8). + if res.Report.OneLine != "" && !details { + fmt.Fprintln(w, res.Report.OneLine) + return + } // The warnings come first, before the headline: each names a // thing that keeps abcd's rules from an agent tool here, which // only the person can end (itd-2610030814013772). @@ -4175,18 +4183,29 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintln(w) } fmt.Fprintf(w, "abcd ahoy install — %s\n", res.Status) - // Core's plain summary first (iss-164): what changed for the - // person, why it matters and what to do. The exact record of - // paths and identifiers follows as detail. if res.Headline != "" { fmt.Fprintf(w, "\n%s\n", res.Headline) } + // A refusal's message is the one thing to read. + if res.Status == "refused" { + fmt.Fprintln(w) + for _, n := range res.Notes { + fmt.Fprintf(w, " note: %s\n", termsafe.Sanitize(n)) + } + } + renderSetupReport(w, res.Report) + if !details { + if len(res.Notes) > 0 && res.Status != "refused" { + fmt.Fprint(w, "\nabcd ahoy install --details shows the full record, with every note.\n") + } + return + } + // The exact record of paths and identifiers, with core's plain + // summary of each (iss-164), under --details. for _, it := range res.Summary { fmt.Fprintf(w, "\n - %s\n %s\n %s\n", it.What, it.Why, it.Action) } - if len(res.Summary) > 0 { - fmt.Fprint(w, "\ndetail:\n") - } + fmt.Fprint(w, "\ndetail:\n") for _, c := range res.Changes { fmt.Fprintf(w, " changed: %s\n", c) } @@ -4259,6 +4278,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { // word as the flag's argument placeholder, so a quoted answer would render // this boolean as "--yes y" in the help and the generated reference. installCmd.Flags().BoolVar(&yes, "yes", false, "approve every resolvable change category without prompting; excludes the optional git-identity pin, the status line and the model-tier routing tables, which need an answered prompt (run without --yes, or answer every prompt with: yes | abcd ahoy install); it never changes the repository's git identity, which is proposed only to a person at a terminal") + installCmd.Flags().BoolVar(&details, "details", false, "print the full record after the report: core's summary of every write and every note, each path written, the declined kinds of change and the gaps left") installCmd.Flags().BoolVar(&adopt, "adopt", false, "adopt an unmanaged repo without prompting") installCmd.Flags().BoolVar(&refuseAdopt, "refuse-adopt", false, "decline to adopt an unmanaged repo") installCmd.Flags().BoolVar(&dev, "dev", false, "track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary") @@ -4344,6 +4364,27 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { return ahoyCmd } +// renderSetupReport prints setup's three-part report (itd-2610240823055889, +// criterion 7): done, one line per change with where; needs you, a numbered +// checklist, each step with why; and the one next step. +func renderSetupReport(w io.Writer, rep ahoy.SetupReport) { + if len(rep.Done) > 0 { + fmt.Fprint(w, "\nDone:\n") + for _, d := range rep.Done { + fmt.Fprintf(w, " %s\n", termsafe.Sanitize(d)) + } + } + if len(rep.NeedsYou) > 0 { + fmt.Fprint(w, "\nNeeds you:\n") + for i, s := range rep.NeedsYou { + fmt.Fprintf(w, " %d. %s\n Why: %s\n", i+1, termsafe.Sanitize(s.Step), termsafe.Sanitize(s.Why)) + } + } + if rep.Next != "" { + fmt.Fprintf(w, "\nNext: %s\n", termsafe.Sanitize(rep.Next)) + } +} + // runAhoyDryRun is `ahoy --dry-run`: the detection result as its canonical JSON // envelope, always, whether or not --json is passed (spc-16 T1). func runAhoyDryRun(cmd *cobra.Command, cwd string) error { @@ -4666,10 +4707,9 @@ func askToolAtTerminal(p ahoy.Prompter, yes bool, w io.Writer, otherwise func(to return tools.Answer{Why: "--yes never installs a tool; " + otherwise(e) + ", or run without --yes at a terminal"} } if dp, ok := p.(*drawnPrompter); ok { - for _, line := range e.Lines() { - fmt.Fprintln(w, termsafe.Sanitize(line)) - } - if !dp.confirmTool(e.Tool, "Install "+e.Tool+" now by running "+e.StepText()+"?") { + // The question is its one line; the explanation is its material, + // which ? shows (itd-2610240823055889, criterion 6). + if !dp.confirmTool(e.Tool, strings.Join(e.Lines(), "\n")+"\nInstall "+e.Tool+" now by running "+e.StepText()+"?") { return tools.Answer{Why: "answered no at the terminal"} } fmt.Fprintf(w, "running %s; a package manager can take a few minutes\n", e.StepText()) @@ -4765,6 +4805,45 @@ func (p *stdinPrompter) echo(answer string) { // only repeats AGENTS.md) are never put to a pipe. func (p *stdinPrompter) AtTerminal() bool { return p.tty } +// Plan puts the plan screen to a person typing at a terminal, which makes +// the stdin door an ahoy.PlanPrompter there: the plan, then its one line, +// and ? shows what each answer does before asking again. Off a terminal the +// plan is not put (asked false): a piped stream answers the approvals one +// by one, in their fixed order, as before. +func (p *stdinPrompter) Plan(plan ahoy.SetupPlan) (ahoy.PlanChoice, bool) { + if !p.tty { + return "", false + } + q := ahoy.SetupPlanQuestion(0, plan) + for _, b := range q.Material { + if b.Kind == question.KindList { + for _, it := range b.Items { + fmt.Fprintf(p.w, " - %s\n", termsafe.Sanitize(it)) + } + continue + } + fmt.Fprintf(p.w, "\n%s\n", termsafe.Sanitize(b.Text)) + } + for { + fmt.Fprintf(p.w, "\n%s [proceed/customise/cancel] %s: ", q.Ask, ahoy.ExplainHint) + line, err := p.r.ReadString('\n') + line = strings.TrimSpace(line) + if line == "?" { + for _, o := range q.Options { + fmt.Fprintf(p.w, " %s — %s\n", strings.ToLower(o.Label), o.Meaning) + } + continue + } + if c, ok := ahoy.PlanChoiceOf(line); ok { + return c, true + } + if line == "" && err == nil { + return ahoy.PlanProceed, true + } + return ahoy.PlanCancel, true + } +} + func (p *stdinPrompter) Confirm(question string) bool { fmt.Fprintf(p.w, "%s [y/N] ", question) line, _ := p.r.ReadString('\n') diff --git a/internal/surface/cli/main_test.go b/internal/surface/cli/main_test.go index 8e836cd6a6..ca7bd51872 100644 --- a/internal/surface/cli/main_test.go +++ b/internal/surface/cli/main_test.go @@ -44,6 +44,9 @@ func TestMain(m *testing.M) { // check back over recorded bundles (ahoy_provenance_test.go). The shipping // value is kept so the wiring itself stays asserted. shippedAhoyReleaseProvenance = ahoyReleaseProvenance + // No install here downloads abcd: the download question is offered only + // with a downloader wired, and the download's own tests stand one in. + ahoyDownloadRelease = nil ahoyReleaseProvenance = func(string, []byte) (string, error) { return "https://github.com/intentdriven/abcd/.github/workflows/release.yml@refs/heads/main", nil } @@ -72,6 +75,9 @@ func TestMain(m *testing.M) { // No install run here starts the machine's own agent tool to read its // version; the one test of that warning swaps in a stub reading. ahoy.NoHostVersionForTest() + // Every install here runs under a fixture home with no harness folder, + // some with PATH narrowed past git. + ahoy.AssumeSetupBasicsForTest() // No hook run here names a session unless its test asks: naming reads // the machine's process table and writes a register into the home, and a // test that did not ask for either must see stdout exactly as before. diff --git a/internal/surface/cli/setup_flow_test.go b/internal/surface/cli/setup_flow_test.go new file mode 100644 index 0000000000..5ff45d08c6 --- /dev/null +++ b/internal/surface/cli/setup_flow_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/interview" + "github.com/intentdriven/abcd/internal/core/question" + "github.com/intentdriven/abcd/internal/surface/cli/ask" +) + +// TestOldApprovalIdIsRefusedNamingThePlan: the plan screen answers every kind +// of change at once, so an answers file naming an approval of one kind by its +// old id is refused before anything is written, naming approve.plan. +func TestOldApprovalIdIsRefusedNamingThePlan(t *testing.T) { + run := newSetupRun(t) + before := listTree(t, run.repo) + out, err := pipedSetup(t, answersFileWith(t, interview.FileAnswer{ID: "approve.config-change", Value: "yes"})) + var ee *exitError + if !errors.As(err, &ee) || ee.Code != 2 { + t.Fatalf("an old approval id: %v, want exit 2\n%s", err, out) + } + for _, want := range []string{"approve.config-change", ahoy.PlanQuestionID, "nothing was written"} { + if !strings.Contains(ee.Msg, want) { + t.Errorf("the refusal %q does not name %q", ee.Msg, want) + } + } + if after := listTree(t, run.repo); after != before { + t.Fatal("the refused run wrote into the repository") + } +} + +// TestQuestionMarkDrawsTheWholeQuestion is criterion 6 at the terminal: each +// question is drawn on its one line, and ? draws the whole question, its +// answers' meanings included, and asks it again. +func TestQuestionMarkDrawsTheWholeQuestion(t *testing.T) { + newSetupRun(t) + t.Setenv("ABCD_ACCESSIBLE", "1") + in, keys := pipeWith(t, "") + stdout, stderr := tempStream(t, "stdout"), tempStream(t, "stderr") + swapStream, swapPut := isTerminalStream, drawnPut + t.Cleanup(func() { isTerminalStream, drawnPut = swapStream, swapPut }) + isTerminalStream = func(*os.File) bool { return true } + explained := map[string]bool{} + drawnPut = func(tm ask.Terminal, a question.Ask) ([]ask.Answer, error) { + q := a.Questions[0] + line := number(q, choose(q)) + if tm.Explain && !explained[q.ID] { + explained[q.ID] = true + line = "?" + } + if _, err := fmt.Fprintln(keys, line); err != nil { + return nil, err + } + return tm.Put(a) + } + cmd := NewRootCommand() + cmd.SetIn(in) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs([]string{"ahoy", "install"}) + err := cmd.Execute() + got, _ := os.ReadFile(stderr.Name()) + if err != nil { + t.Fatalf("install: %v\n%s", err, got) + } + out := string(got) + brief := strings.Index(out, "Proceed with this plan? "+ahoy.ExplainHint) + whole := strings.Index(out, "Makes every change and takes every default on the plan") + if brief < 0 || whole < 0 || whole < brief { + t.Fatalf("the plan was not drawn on its one line and then whole after ?:\n%s", out) + } + if strings.Count(out, "Makes every change and takes every default on the plan") != 1 { + t.Errorf("the meanings were drawn before ? was answered:\n%s", out) + } +} diff --git a/internal/surface/cli/setup_interview.go b/internal/surface/cli/setup_interview.go index d388905b9f..222f603a6e 100644 --- a/internal/surface/cli/setup_interview.go +++ b/internal/surface/cli/setup_interview.go @@ -148,7 +148,14 @@ func (p *drawnPrompter) AtTerminal() bool { return true } // it with exit 1. The words name no verb: ahoy remote apply and site setup // ask through this door too. func (p *drawnPrompter) put(q question.Question) ask.Answer { - got, err := drawnPut(p.term, question.Ask{Questions: []question.Question{q}}) + // Setup asks each question on its one line first; ? shows the whole + // question and asks it again (itd-2610240823055889, criterion 6). + brief := p.term + brief.Explain = true + got, err := drawnPut(brief, question.Ask{Questions: []question.Question{ahoy.BriefQuestion(q)}}) + if errors.Is(err, ask.ErrExplain) { + got, err = drawnPut(p.term, question.Ask{Questions: []question.Question{q}}) + } switch { case errors.Is(err, ask.ErrInterrupted): panic(&setupStop{code: ask.ExitInterrupted, msg: "interrupted at " + q.Chip + @@ -195,6 +202,48 @@ func (p *drawnPrompter) Confirm(text string) bool { return a.Value == "yes" } +// Plan draws the plan screen, which makes the drawn door an +// ahoy.PlanPrompter: the plan, its one line and its three answers, recorded +// under ahoy.PlanQuestionID; deciding later cancels. +func (p *drawnPrompter) Plan(plan ahoy.SetupPlan) (ahoy.PlanChoice, bool) { + p.n++ + q := ahoy.SetupPlanQuestion(p.n, plan) + a := p.put(q) + p.record(q, a.Value, "", interview.Terminal) + c, ok := ahoy.PlanChoiceOf(a.Value) + if !ok || a.Later { + c = ahoy.PlanCancel + } + return c, true +} + +// RecordDefaultConfirm records the plan's yes to an offer it did not put, +// which makes the drawn door an ahoy.DefaultRecorder: the question numbered +// and recorded as if asked, so a replay of the record answers it the same. +func (p *drawnPrompter) RecordDefaultConfirm(text string, yes bool) { + q := p.confirm(text) + v := "no" + if yes { + v = "yes" + } + p.record(q, v, "", interview.Terminal) +} + +// RecordDefaultPrompt records the plan's default for a value question it did +// not put. +func (p *drawnPrompter) RecordDefaultPrompt(key string, choices []string, def, value string) { + q := p.value(key, choices, def) + p.record(q, value, "", interview.Terminal) +} + +// RecordDefaultHelped records the plan's default for a helped value question +// it did not put. +func (p *drawnPrompter) RecordDefaultHelped(h ahoy.PromptHelp, choices []string, def, value string) { + p.n++ + q := ahoy.SetupHelpedQuestion(p.n, h, choices, def) + p.record(q, value, "", interview.Terminal) +} + // confirmTool draws the tool-install question. It is not part of the record: // an answers file can never answer it (a program is installed only on an // answer given at a terminal or relayed by --install-tool), so recording it @@ -277,6 +326,9 @@ func writtenBefore(id string) string { func settleSetupAnswers(cwd string, opts ahoy.InstallOptions, file interview.Answers) error { for _, e := range file.Answers { id := strings.TrimSpace(e.ID) + if why := ahoy.LegacyApprovalRefusal(id); why != "" { + return &exitError{Code: 2, Msg: "abcd ahoy install: " + termsafe.Sanitize(why) + "; nothing was written, and no answers record was written"} + } values, fixed := ahoy.SetupFixedValues(id) if fixed && !slices.Contains(values, strings.TrimSpace(e.Value)) { return &exitError{Code: 2, Msg: fmt.Sprintf("abcd ahoy install: the answers file answers %s with %q, which it does not offer (%s); "+ @@ -285,7 +337,16 @@ func settleSetupAnswers(cwd string, opts ahoy.InstallOptions, file interview.Ans } var missing string var missingChoices []string + // The plan approves the adoption and every kind of change at once. + planned := false + if e, ok := file.Find(ahoy.PlanQuestionID); ok { + c, _ := ahoy.PlanChoiceOf(e.Value) + planned = c == ahoy.PlanProceed || c == ahoy.PlanCustomise + } err := ahoy.WalkConfigValueQuestions(cwd, opts, func(id string) bool { + if id == "adopt" || strings.HasPrefix(id, "approve.") { + return planned + } e, ok := file.Find(id) return ok && strings.TrimSpace(e.Value) == "yes" }, func(key string, choices []string, def string) string { @@ -350,13 +411,28 @@ func (p *answersPrompter) Confirm(text string) bool { case q.ID == "adopt": flag = "pass --adopt or --refuse-adopt" case strings.HasPrefix(q.ID, "approve."): - flag = "pass --yes to approve every kind of change" + flag = "answer " + ahoy.PlanQuestionID + " in the answers file, or pass --yes to approve every kind of change" } e, o := p.answer(q, flag) p.record(q, o.Value, e.Note, p.place(e)) return o.Value == "yes" } +// Plan answers the plan screen from the file, which makes the answers door an +// ahoy.PlanPrompter: approve.plan is the one approval a file gives, and a +// file that does not answer it stops the run before anything is written. +func (p *answersPrompter) Plan(plan ahoy.SetupPlan) (ahoy.PlanChoice, bool) { + p.n++ + q := ahoy.SetupPlanQuestion(p.n, plan) + e, o := p.answer(q, "no flag answers it (--yes approves every kind of change without the plan)") + p.record(q, o.Value, e.Note, p.place(e)) + c, ok := ahoy.PlanChoiceOf(o.Value) + if !ok { + c = ahoy.PlanCancel + } + return c, true +} + // readSetupAnswers reads --answers for the setup interview: strict JSON, one // shape for every interview. func readSetupAnswers(path string) (interview.Answers, error) { diff --git a/internal/surface/cli/setup_interview_test.go b/internal/surface/cli/setup_interview_test.go index d03f7210f4..bde331509d 100644 --- a/internal/surface/cli/setup_interview_test.go +++ b/internal/surface/cli/setup_interview_test.go @@ -188,12 +188,15 @@ func newSetupRun(t *testing.T) setupRun { return setupRun{home: home, repo: repo} } -// choose is the answer a test gives a setup question: approve every kind of -// change but installing tools, take the first value, install the status +// choose is the answer a test gives a setup question: proceed with the plan, +// which takes its defaults; and, for a question the plan does not answer, +// approve every kind of change but installing tools, take the first value, install the status // line with every element on, accept the machine's routing and not the // repository's, and decide anything else later. func choose(q question.Question) string { switch { + case q.ID == ahoy.PlanQuestionID: + return string(ahoy.PlanProceed) case q.ID == "approve.dependency", q.ID == ahoy.OracleRoutingRepoGapID: return "no" case strings.HasPrefix(q.ID, "approve."), q.ID == "adopt", q.ID == ahoy.StatusLineOfferGapID, q.ID == ahoy.OracleRoutingMachineGapID: @@ -361,11 +364,11 @@ func TestInterviewOffATerminalIsPlainAndMatchesTheTerminalRecord(t *testing.T) { if dRepo == nil || dMachine == nil { t.Fatalf("the drawn run left the records repo=%q machine=%q\n%s", dRepo, dMachine, drawnOut) } - // The routing confirmation is drawn too: the proposal in counts, one - // paragraph, then the question on its own line. + // The plan screen is drawn first: the plan, then its one line + // (itd-2610240823055889); the routing table is one of its defaults. plain := regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(drawnOut, "") - if !regexp.MustCompile(`(?s)› Setup Q\d+\n\n abcd proposes a model tier.*\d+ agents: .*\n\n Accept abcd's proposed routing for this machine\?\n`).MatchString(plain) { - t.Fatalf("the routing confirmation was not drawn in counts:\n%s", drawnOut) + if !regexp.MustCompile(`(?s)› Setup Q1\n\n.*Defaults it takes:.*model routing for this machine.*Proceed with this plan\? \(\? explains\)\n`).MatchString(plain) { + t.Fatalf("the plan screen was not drawn with its defaults and its one line:\n%s", drawnOut) } piped := newSetupRun(t) @@ -376,7 +379,7 @@ func TestInterviewOffATerminalIsPlainAndMatchesTheTerminalRecord(t *testing.T) { if m := machineStream.FindString(pipedOut); m != "" { t.Fatalf("the piped run wrote %q to a machine stream:\n%s", m, pipedOut) } - if !strings.Contains(pipedOut, "Setup Q1") || !strings.Contains(pipedOut, "Adopt this unmanaged repo into abcd?") { + if !strings.Contains(pipedOut, "Setup Q1") || !strings.Contains(pipedOut, "Proceed with this plan?") { t.Fatalf("the piped run did not write its questions as plain text:\n%s", pipedOut) } pRepo, pMachine := piped.records(t) @@ -390,7 +393,7 @@ func TestInterviewOffATerminalIsPlainAndMatchesTheTerminalRecord(t *testing.T) { t.Fatalf("the configurations differ:\n--- drawn\n%s\n--- piped\n%s", drawn.config(t), piped.config(t)) } var rec interview.Record - if err := json.Unmarshal(dRepo, &rec); err != nil || rec.Answers[0].ID != "adopt" || rec.Answers[0].AnsweredIn != interview.Terminal { + if err := json.Unmarshal(dRepo, &rec); err != nil || rec.Answers[0].ID != ahoy.PlanQuestionID || rec.Answers[0].AnsweredIn != interview.Terminal { t.Fatalf("drawn record %+v, %v", rec, err) } } @@ -453,7 +456,7 @@ func TestUnansweredSetupQuestionRefusesNamingIt(t *testing.T) { if !errors.As(err, &ee) || ee.Code != 2 { t.Fatalf("an unanswered question: %v, want exit 2\n%s", err, out) } - for _, part := range []string{"Setup Q1 (adopt) has no answer", "--adopt or --refuse-adopt", `{"id": "adopt"`} { + for _, part := range []string{"Setup Q1 (approve.plan) has no answer", "--yes", `{"id": "approve.plan"`} { if !strings.Contains(ee.Msg, part) { t.Errorf("the refusal %q lacks %q", ee.Msg, part) } @@ -699,7 +702,12 @@ func TestSetupInterruptKeepsTheAnswersGiven(t *testing.T) { isTerminalStream = func(*os.File) bool { return true } drawnPut = func(tm ask.Terminal, a question.Ask) ([]ask.Answer, error) { q := a.Questions[0] - if _, err := fmt.Fprintln(keys, number(q, choose(q))); err != nil { + answer := choose(q) + if q.ID == ahoy.PlanQuestionID { + // Customise, so the artefact kind is asked rather than defaulted. + answer = string(ahoy.PlanCustomise) + } + if _, err := fmt.Fprintln(keys, number(q, answer)); err != nil { return nil, err } return put(tm, a) @@ -719,7 +727,7 @@ func TestSetupInterruptKeepsTheAnswersGiven(t *testing.T) { } repo, machine := run.records(t) var r, m interview.Record - if err := json.Unmarshal(repo, &r); err != nil || len(r.Answers) == 0 || r.Answers[0].ID != "adopt" { + if err := json.Unmarshal(repo, &r); err != nil || len(r.Answers) == 0 || r.Answers[0].ID != ahoy.PlanQuestionID { t.Fatalf("the repository's answers were not kept: %v\n%s", err, repo) } if err := json.Unmarshal(machine, &m); err != nil { diff --git a/internal/surface/cli/update.go b/internal/surface/cli/update.go index e8bee3f813..85144c9563 100644 --- a/internal/surface/cli/update.go +++ b/internal/surface/cli/update.go @@ -32,6 +32,20 @@ var newUpdater = func() updater { return update.NewGitHubUpdater() } // in for the network. var ahoyReleaseProvenance ahoy.ProvenanceCheck = update.VerifyReleaseProvenance +// ahoyDownloadRelease is the checked fetch `ahoy install` makes on a yes to +// the question naming the abcd download (itd-2610240823055889): the release +// client is built only then, so a run that is not asked, or answers no, +// constructs nothing network-capable, and the bytes are held to the same +// checksums and build provenance abcd update holds a swap to. A variable so +// tests stand in for the network. +var ahoyDownloadRelease ahoy.DownloadRelease = func(tag string) (ahoy.DownloadedRelease, error) { + r, err := update.NewGitHubUpdater().FetchVerified(tag, os.Stderr) + if err != nil { + return ahoy.DownloadedRelease{}, err + } + return ahoy.DownloadedRelease{Bytes: r.Bytes, SHA256: r.SHA256, Signer: r.Signer}, nil +} + // updater is what the verb asks of the release client: resolve a tag, then // apply it. An interface so a test drives the swap path with no network, the // way version.go's newReleaseFetcher hands back a vintage.ReleaseFetcher. From dd3a0ed1052b346bc49e78d1221f1f420196aed9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:05:56 +0200 Subject: [PATCH 06/26] docs: describe the setup flow and amend invariant 1 for setup adr-2610241101463288 records that setup asks short questions with ? for the whole question and lists its defaults on one plan screen, and invariant 1 and the universal pattern for prompts now say so; invariant 7 names the download setup makes on a yes. commands/ahoy.md and the brief's ahoy page describe the flow, the answers file's approve.plan and the download; the spec spc-2610241007434505 is filled; the generated surface appendix and CLI reference carry --details. Assisted-by: Claude:claude-opus-5-5 --- .../brief/02-constraints/03-invariants.md | 12 +- .../development/brief/04-surfaces/01-ahoy.md | 62 +++++++- .../05-internals/04-universal-patterns.md | 6 + ...etup-asks-short-questions-and-lists-its.md | 90 ++++++++++++ .abcd/development/decisions/adrs/README.md | 1 + .abcd/development/release/surface.json | 7 + ...-person-sets-abcd-up-in-a-repository-in.md | 99 ++++++++++++- commands/ahoy.md | 135 ++++++++++++++---- docs/reference/cli/commands.md | 1 + 9 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 .abcd/development/decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md diff --git a/.abcd/development/brief/02-constraints/03-invariants.md b/.abcd/development/brief/02-constraints/03-invariants.md index 5e13616426..3f3746bb0f 100644 --- a/.abcd/development/brief/02-constraints/03-invariants.md +++ b/.abcd/development/brief/02-constraints/03-invariants.md @@ -6,7 +6,7 @@ The following are non-negotiable invariants — any architectural choice that violates them is wrong even if it works. -1. **Transparent prompts** — every `AskUserQuestion` shows current state, consequences of each option, and how to change later. No silent defaults. See [`05-internals/04-universal-patterns.md § 1`](../05-internals/04-universal-patterns.md#1-transparent-prompts). +1. **Transparent prompts** — every `AskUserQuestion` shows current state, consequences of each option, and how to change later. No silent defaults. Setup (`abcd ahoy install`) meets the rule in two steps ([adr-2610241101463288](../../decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md)): one plan screen lists every change and every default before anything is written, each default with where to change it, and each setup question is one line whose answer `?` shows the whole question, current state, consequences and how to change later, before asking again. See [`05-internals/04-universal-patterns.md § 1`](../05-internals/04-universal-patterns.md#1-transparent-prompts). 2. **Adapter over native default** — every capability abcd once delegated to a bundled tool has a native default; the external tool is an opt-in adapter over the same seam ([adr-22](../../decisions/adrs/0022-bundled-deps-as-pluggable-adapters.md)). A missing or misbehaving adapter degrades to the native path; abcd never blocks on an absent tool. See [`05-internals/04-universal-patterns.md`](../05-internals/04-universal-patterns.md). @@ -136,6 +136,16 @@ The following are non-negotiable invariants — any architectural choice that vi `TestPresentReadsDiskOnly` ([adr-2610181003541997](../../decisions/adrs/2610181003541997-the-away-mode-s-stop-check-reads-the.md), which refines adr-38 for this verb's meaning alone). + Setup (`abcd ahoy install`) downloads abcd only on a yes to the question + that names the download, the release and the file it is written to, which + is that fetch's documented meaning: the release's binary for this computer, + checked against the release's `checksums.txt` and the build provenance its + release workflow signed before anything is written, exactly as `abcd + update` checks a swap + ([adr-2610241101463288](../../decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md)). + The release is the one the running abcd was stamped as, or, in a source + checkout of abcd, the newest release tag the checkout holds, read from + disk; no version-discovery request is made. Inbound, abcd listens on a network only through the dashboard, only while someone has started it on purpose, and only for the people let in: it listens on this computer's own Tailscale addresses alone, never every diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d5574cb84a..b81e0ed97f 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -735,11 +735,68 @@ reason, and the shim degrades. For the pre-tool-use guard that degradation is an unguarded line and exit 1, never the exit 0 the host reads as approval. Session start carries no `PATH` rung at all and fails closed. +## The setup flow: basics, one plan, the person's own questions, three-part report + +Setup is short, plain, and finishes (itd-2610240823055889, +[adr-2610241101463288](../../decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md), +which amends brief invariant 1 for setup). It checks everything before it asks +anything, in this order: + +1. **The basics.** No `git` command, a folder that is not the top of a git + repository, or an account with no agent harness stops the run before its + first question, with one message naming what is missing, writing nothing + and exiting 2 (`setup_basics.go`). Every other missing basic, a folder not + on `PATH`, a required tool, is a step on the report's checklist. +2. **The plan screen** (`setup_plan.go`). At a terminal, and through an answers + file (`approve.plan`), setup shows one plan: every change grouped by where + it lands, every default it takes with where to change it (visibility, + docs target, deep scanning, the house-style rule, the artefact kind, the + model routing tables, the drain rule, the README parts, the status line's + parts, all on), and the person-only questions that follow. It asks once: + proceed, customise or cancel. Proceed answers every approval of a kind of + change and takes every listed default without asking; customise asks the + approvals and the values in turn, as before the plan; cancel writes + nothing. For a repository abcd does not manage, the plan is asked before the + adoption and proceeding adopts it. A piped answer stream, the + approve-everything flag, an explicit category set and the after-update + run are not shown the plan. +3. **The person-only questions** (`person_only.go`): the abcd download, the + shell settings, the status line, the commit identity and each missing + tool, each its own question naming the value and where it is written, + whatever the answer to the plan, in that order. The identity is never + listed under, or declined by, the settings approval + (iss-2610240824005655). +4. **The abcd download** (`abcd_download.go`). Where nothing is at the `PATH` + target and the plugin's verified cache cannot supply a copy, setup asks to + download the release this abcd was stamped as (in a source checkout of + abcd, the newest release tag the checkout holds, read from disk), before + the status line; on a yes it fetches the release binary through `abcd + update`'s checked fetch (`update.FetchVerified`: `checksums.txt`, then the + signed build provenance), writes it to the target and records it in + `~/.abcd.noindex/path-entry`, so the status line is wired in the same run. + The status-line offer is left out of the approvals, and never asked, when + it cannot be wired (iss-2610240823574047), and its entry is a release copy, + never the track-latest dev shim. +5. **One line each** (`setup_brief.go`). Every question is drawn on its one + line with its answers' labels; `?` draws the whole question and asks again + (`ask.ErrExplain`). The answers record keeps the whole question. +6. **The report** (`setup_report.go`): done, one line per change with where; + needs you, a numbered checklist of exact steps, each with why; next, one + step. A repository already fully set up is one line, and a details flag + adds the full record. + +Run from a source checkout of abcd through `go run ./cmd/abcd`, setup is a +build of that checkout as it stands, so the stale-binary refusal treats it as +current (`gorun_vintage.go`, iss-2610240824047504). With no plugin root, the +copy `~/.abcd.noindex/path-entry` records still reports its install mode +(iss-2610240916382802). + ## Gaps, and how the apply pass asks about them Each detected discrepancy becomes a **gap** with a stable id, a category, a -scope, a title, detail and a fix hint. The category is what the apply pass asks -about, one question per category present, never one per item. The question +scope, a title, detail and a fix hint. Under customise, and off a terminal, +the category is what the apply pass asks about, one question per category +present, never one per item. The question shows what approving it would do (iss-2610071528375981): one line per change, the gap's title, which names the file or the setting, with a value flag that would change a saved setting listed under the settings approval; then the @@ -1361,6 +1418,7 @@ Sub-verbs: none. | `--answers` | string | | `--attribution` | bool | | `--bin-dir` | string | +| `--details` | bool | | `--dev` | bool | | `--docs-target` | string | | `--install-tool` | stringSlice | diff --git a/.abcd/development/brief/05-internals/04-universal-patterns.md b/.abcd/development/brief/05-internals/04-universal-patterns.md index 4c2c516ce4..a86640975c 100644 --- a/.abcd/development/brief/05-internals/04-universal-patterns.md +++ b/.abcd/development/brief/05-internals/04-universal-patterns.md @@ -12,6 +12,12 @@ Every `AskUserQuestion` (or equivalent harness call) shows: No silent defaults. No surprises. +Setup meets the three in two steps +([adr-2610241101463288](../../decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md)): +its plan screen lists every change and every default before anything is +written, each default with where to change it, and each of its questions is +one line whose answer `?` shows all three before asking again. + Every question also has one layout (itd-2610030810350727, itd-201; spc-2610030944505997), and abcd's question check in `abcd guard hook` refuses an abcd question that breaks it, naming the part, the value, and the limit. The diff --git a/.abcd/development/decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md b/.abcd/development/decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md new file mode 100644 index 0000000000..c6d730121c --- /dev/null +++ b/.abcd/development/decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md @@ -0,0 +1,90 @@ +--- +id: adr-2610241101463288 +slug: setup-asks-short-questions-and-lists-its +status: accepted +date: 2026-10-24 +supersedes: null +superseded_by: null +related_intents: [itd-2610240823055889] +related_rfcs: [] +related_adrs: [adr-38] +--- + +# ADR-2610241101463288: Setup asks short questions and lists its defaults on one plan + +## Context + +Brief invariant 1 says every prompt shows the current state, the +consequence of each option and how to change the answer later, and that +there are no silent defaults. Applied to `abcd ahoy install`, it produced the +setup the product thinker ran on 2026-10-24: up to thirteen questions a run, +each a page of explanation, several of them only "go on to the offer listed +above?", and a report that ran to a screen of detail. Setup took five runs to +finish (itd-2610240823055889, Why This Matters). + +The research for that intent (clig.dev, rustup, Homebrew's installer, +create-next-app, Nielsen Norman Group on progressive disclosure, and the +CHI 2021 study of command-line accessibility) found the same shape in the +installers that finish in one run: check everything first, show one plan +grouped by where each change lands, ask once, and keep every question to one +line with the full explanation one keystroke away. The product thinker +decided on 2026-10-24 that setup's questions are short (one line, with `?` +showing the full explanation) and that defaults are decided for the person +and listed on the plan screen, and recorded that the change is owed an ADR +amending the invariant before the work ships. + +## Decision + +We will hold setup to an amended invariant 1: + +- Setup checks everything before its first question and shows one plan + screen listing every change it would make and every default it would + take, each default with where to change it. It asks once: proceed, + customise or cancel. +- A default on the plan screen is not a silent default: it is listed, with + its value and where to change it, before anything is written. +- Every setup question is one line. Answering `?` shows the whole question + as invariant 1 describes it, the current state, what each answer does and + how to change it later, and asks again. The record of every answer keeps + the whole question. +- A step only the person can approve (the commit identity, a download, the + assistant's settings, the shell settings) is its own question, naming the + value and where it is written, whatever the answer to the plan. +- Every other abcd question keeps invariant 1 as it stands. + +The download the plan lists is fetched only on a yes to the question that +names it: that question is the fetch's documented meaning, as adr-38 asks of +every fetch. + +## Alternatives Considered + +- **Keep invariant 1 whole for setup.** Rejected: the 2026-10-24 session + showed it produced a setup that did not finish, and the explanation that + was meant to inform drowned the questions that mattered. +- **Short questions with no `?`, the explanation in the docs only.** + Rejected: the person answering would have to leave the question to learn + what an answer does, which is the silent default the invariant forbids in + another form. +- **One plan screen and short questions, with `?` for the full question.** + Chosen: the plan shows every change and default before anything is + written, so nothing is silent, and the full explanation stays one + keystroke away at every question. +- **Ask nothing and take every default, as `--yes` does.** Rejected: the + commit identity, a download and the assistant's settings are a person's to + approve, and `--yes` already refuses them for that reason. + +## Consequences + +- A first run at a terminal asks the plan screen and the person-only + questions only: at most four, the abcd download, the shell settings, the + plan and the commit identity, plus one for each missing tool it offers to + install, and one for the status line where it can be wired. +- The approve.<category> ids of the answers file give way to one + `approve.plan` answer; a file naming an old id is refused, naming the new + one, so the change is breaking. +- The asking-rules check in `abcd guard hook` is unchanged: it judges the + questions an agent puts through the host's question tool, and the plugin + page still relays setup's questions with their whole material. +- Every setup question needs a one-line form that names its value and where + it is written; core composes it (`ahoy.BriefQuestion`), so no front door + invents its own words. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 084dce4990..2574808367 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -216,4 +216,5 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2610161621415708](2610161621415708-abcd-s-command-guard-steers-agents-an-os.md) | Unattended runs abcd launches execute inside an OS sandbox | proposed | 2026-10-16 | | [adr-2610161733400551](2610161733400551-a-reviewer-in-a-model-matrix-run-never.md) | A reviewer or judge in a model-matrix run is told nothing about which model wrote the copy it judges | proposed | 2026-10-16 | | [adr-2610181003541997](2610181003541997-the-away-mode-s-stop-check-reads-the.md) | The away mode's stop check reads the forge once, as its documented meaning | proposed | 2026-10-18 | +| [adr-2610241101463288](2610241101463288-setup-asks-short-questions-and-lists-its.md) | Setup asks short questions and lists its defaults on one plan | accepted | 2026-10-24 | <!-- /index --> diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index eab0157258..2cfcc28692 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -259,6 +259,13 @@ "required": false, "hidden": false }, + { + "name": "details", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, { "name": "dev", "shorthand": "", diff --git a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md index cd5642ab5c..1d86d62766 100644 --- a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md +++ b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md @@ -9,12 +9,105 @@ production_mode: dictated-and-formatted ## Summary -_Draft: describe what spc-2610241007434505 delivers for itd-2610240823055889 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-2610241007434505 delivers the setup flow of itd-2610240823055889: `abcd +ahoy install` checks the basics before anything else, shows one plan screen +and asks once, asks each step only a person can approve as its own one-line +question, puts abcd on the `PATH` itself before the status line, and ends on a +three-part report, or on one line where nothing is left to do. Three parts of +the intent are their own records, built on sibling branches and merged with +this one: per-machine commit identity (itd-2610240918547728), verified tool +installs for the person alone (itd-2610240918594138), and the shell-profile +`PATH` line (itd-2610240919030303). This spec leaves each of them a seam +(below) rather than building it. Brief invariant 1 is amended for setup by +adr-2610241101463288. + +## Scope + +- **The basics** (`internal/core/ahoy/setup_basics.go`): no `git`, a folder + that is not the top of a repository, or no agent harness ends the run before + its first question with one message, status `refused`, exit 2. +- **The plan screen** (`setup_plan.go`): `SetupPlan` (the changes grouped by + where they land, the defaults with where to change each, the person-only + questions), `SetupPlanQuestion` (id `approve.plan`), `PlanPrompter`, and the + `planDefaults` prompter that answers every approval yes and, at a terminal + under proceed, takes the listed defaults, recording each through + `DefaultRecorder` so a replay of the record answers the same. +- **The person-only questions** (`person_only.go`): the identity gaps leave the + approvals (`personOnlyGap`), the plan lists the questions that follow + (`personOnlyAsks`), and the status-line offer leaves the approvals where it + cannot be wired (`approvalGaps`). +- **The abcd download** (`abcd_download.go`, `internal/core/update/fetch.go`): + `stepDownload` before `stepSymlink`; `update.FetchVerified` checks the + release's `checksums.txt` and its signed build provenance before returning + bytes; the CLI wires it as `InstallOptions.DownloadAbcd`. +- **One line each** (`setup_brief.go`, `internal/surface/cli/ask`): + `BriefQuestion` and `ExplainHint`; `ask.Terminal.Explain`, `ask.ErrExplain` + and the picker's `Explain` outcome; the drawn door draws the brief form and, + on `?`, the whole question. +- **The report** (`setup_report.go`): `SetupReport` (done, needs you, next, + one line) on `InstallResult.Report`; the CLI prints it, and `--details` + adds the full record. +- **The source checkout**: `vintage.GoRunModule` and `goRunOfCheckout` treat a + `go run` build of the checkout being installed as current + (iss-2610240824047504); the download's release there is the newest `v*` tag + the checkout holds; the status line never runs the `--dev` shim. +- **Install mode without a plugin root** (`detect.go`): the copy + `~/.abcd.noindex/path-entry` records reports `pinned` + (iss-2610240916382802). +- **The docs**: commands/ahoy.md (the setup flow, the answers file's + `approve.plan`, the download), the brief's 01-ahoy.md, invariants 1 and 7, + the universal pattern for prompts, and adr-2610241101463288. + +## Approach + +The redesign is a layer over the existing apply pass rather than a rewrite of +it: the plan is asked through an optional prompter interface, so every door +that does not put a plan (a piped stream, a test's fake prompter, `--yes`) +runs the pass exactly as before, and the plan's answer becomes a prompter +(`planDefaults`) that answers the approvals and the defaults the plan listed +and passes every other question through. That keeps the steps' own questions, +and their order, as they were, and keeps the edits to `apply.go` to the plan's +two places (before the adoption of an unmanaged repository, after the +idempotency check of a managed one), the download step, the shell seam and the +approvals' gap list. + +## Criteria + +1. **No git, no repository or no harness stops before any question.** Met: + `setupBasicsStop` and `missingGitStop`, held by + `TestSetupStopsBeforeAnyQuestionWithoutTheBasics`. +2. **One plan screen, asked once.** Met at a terminal and through an answers + file: `askSetupPlan`, held by `TestPlanProceedAsksOnceAndTakesTheDefaults`, + `TestPlanCancelWritesNothing`, `TestPlanCustomiseAsksEachKindOfChange` and + the drawn door's `TestInterviewOffATerminalIsPlainAndMatchesTheTerminalRecord`. +3. **A person-only step is its own question naming value and where.** Met for + the identity (`TestIdentityQuestionStandsWithoutTheSettingsApproval`, + `TestSettingsApprovalDoesNotListTheIdentity`), the download and the status + line (`TestDownloadComesBeforeTheStatusLine`), and each tool (its question + unchanged, now with the explanation as its material). The shell settings + question is the seam `stepShellSettings`/`shellSettingsAsks`, which + itd-2610240919030303 fills. +4. **The download before the status line.** Met: + `TestDownloadComesBeforeTheStatusLine` (a yes wires the status line in the + same run; a no never asks about it), `TestFetchVerifiedChecksBothRecords`. +5. **At most four questions on a first run.** Met for the plan, the download + and the identity; the shell question is the seam above. The status-line + offer is a person-only question too (criterion 3), so a fresh account whose + assistant settings exist is asked it as a fifth where it can be wired; the + product thinker is asked whether criterion 5 counts it. +6. **One line, `?` for the whole question.** Met at the drawn door (arrows and + numbered) and the stdin door's plan: `TestQuestionMarkAsksForTheExplanation`, + `TestQuestionMarkDrawsTheWholeQuestion`. The answers door writes each + question whole, since it is a record, not a person. +7. **A three-part report.** Met: `TestReportHasThreeParts`; the CLI prints + it. +8. **A fully set-up repository prints one line.** Met: + `TestFullySetUpRerunIsOneLine`. ## Footprint -- packages: -- tests: +- packages: internal/core/ahoy, internal/core/update, internal/core/vintage, internal/surface/cli, internal/surface/cli/ask, commands/ahoy.md, .abcd/development/brief/04-surfaces/01-ahoy.md, .abcd/development/brief/02-constraints/03-invariants.md, .abcd/development/brief/05-internals/04-universal-patterns.md, .abcd/development/decisions/adrs, docs/reference/cli/commands.md +- tests: TestSetupStopsBeforeAnyQuestionWithoutTheBasics, TestGitInstalledReadsPath, TestPlanProceedAsksOnceAndTakesTheDefaults, TestPlanCancelWritesNothing, TestPlanCustomiseAsksEachKindOfChange, TestDownloadComesBeforeTheStatusLine, TestFetchVerifiedChecksBothRecords, TestIdentityQuestionStandsWithoutTheSettingsApproval, TestSettingsApprovalDoesNotListTheIdentity, TestQuestionMarkAsksForTheExplanation, TestQuestionMarkDrawsTheWholeQuestion, TestOldApprovalIdIsRefusedNamingThePlan, TestReportHasThreeParts, TestFullySetUpRerunIsOneLine, TestGoRunOfThisCheckoutIsCurrent, TestGoRunModuleOf, TestInstallModeReadsTheRecordWithoutAPluginRoot ## Steps diff --git a/commands/ahoy.md b/commands/ahoy.md index c2618c0c36..91ee7e8363 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -425,6 +425,69 @@ are committed. "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --json ``` +#### The setup flow + +Setup checks everything before it asks anything, then asks as few questions as +it can, each on one line +([adr-2610241101463288](../.abcd/development/decisions/adrs/2610241101463288-setup-asks-short-questions-and-lists-its.md)). + +1. **The basics.** With no `git` command, in a folder that is not the top of a + git repository, or on an account with no agent harness, there is nothing to + set up: the install stops before its first question, writes nothing, and + exits 2 with one message naming what is missing. Relay that message as it + stands. Every other missing basic goes on the report's checklist at the end. +2. **The plan screen.** At a terminal, and through an answers file, setup shows + one plan: every change it would make, grouped by where it lands (this + repository, this machine), every default it would take with where to change + it (the visibility, the docs target, deep scanning, the house-style rule, + the artefact kind, the model routing tables, the drain rule, the README + parts, and the status line's parts, all on), and the questions it asks + whatever the answer. It asks once: `proceed`, `customise` or `cancel`. + Proceed makes every change and takes every default listed; customise asks + each kind of change and each value in turn before anything is written; + cancel, or deciding later, writes nothing. For a repository abcd does not + manage yet, proceed and customise also adopt it, so the plan is the one + question that adopts. +3. **The questions only a person can answer.** The abcd download, the shell + settings, the status line (one entry in the assistant's settings), the + commit identity and each missing tool are each their own question, naming + the value and where it is written, whatever the answer to the plan, in that + order. None is ever asked behind the plan or behind an approval of a kind + of change. +4. **The abcd download.** Where abcd is not on the `PATH` and the plugin's + verified cache cannot supply a copy, setup offers to download abcd before it + offers the status line: the release the running abcd was stamped as, or, in + a source checkout of abcd, the newest release tag the checkout holds. On a + yes it fetches that release's binary for this computer, checks it against + the release's `checksums.txt` and the build provenance its release workflow + signed (the check `abcd update` makes), writes it to `~/.local/bin/abcd` (or + the `--bin-dir` folder) and records it in `~/.abcd.noindex/path-entry`, so + the status line is wired in the same run. Setup never asks about the status + line when it cannot wire it. `--yes` never downloads; a no writes nothing, + and the README's install line does the same by hand. +5. **One line each.** Every question is one line naming what it decides; `?` + (typed as the answer, or as the first key in the arrow-key list) shows the + whole question (what each answer does, what holds now and where to change + it later) and asks again. The numbered list (`ABCD_ACCESSIBLE`, `TERM=dumb` + or `interview.list` set to `numbered`) and `NO_COLOR` work as before. +6. **The report.** The run ends in three parts: **Done**, one line per change + with where it landed; **Needs you**, a numbered checklist, each item the + exact step and why; and **Next**, one step. `--details` adds the full + record (every path written and every note). A repository already fully set + up is reported in one line, `abcd is already set up here; nothing + changed.`, and nothing is written. + +A first run at a terminal with the suggested answers therefore asks the plan +and the person-only questions alone: the abcd download, the shell settings, +the plan screen and the commit identity, plus the status line where it can be +wired and one question for each missing tool it offers to install. + +In a source checkout of abcd, `go run ./cmd/abcd ahoy install` is treated as +current: `go run` builds the checkout as it stands, so its unstamped vintage is +not refused as stale there. Any other unstamped binary is still refused. On +the `PATH` there means a release copy, as anywhere else: the `--dev` shim +rebuilds abcd on every call and is never the status line's entry. + **This writes.** It applies the actionable gaps the detection pass found — the marker block (into `AGENTS.md` only, and only where `--docs-target agents_md` names it; the default, `skip`, names none), the `.abcd/` scaffolding, the owned @@ -475,9 +538,10 @@ release the cache attestation names; when the check fails or cannot reach GitHub's attestations API or the Sigstore TUF repository, install writes no entry and its note names what failed and how to check the release by hand with `gh attestation verify`. Relay that note verbatim. -When no verified copy is there, install writes no entry at all rather than a -symlink into the plugin root, which stops working at the next plugin update, and -its note names the command to run first: the install one-liner in the README, +When no verified copy is there, install writes no symlink into the plugin root, +which stops working at the next plugin update: where nothing is at the `PATH` +target it asks the download question above instead, and on a no (or under +`--yes`) its note names the other way, the install one-liner in the README, which downloads the release binary, verifies it against that release's own checksums and records it, after which a re-run of `ahoy install` adopts it. Relay that note verbatim. Every install run checks the abcd-owned copy @@ -525,9 +589,10 @@ deferred with decide later, is left as it is. With `--yes` it answers no question at all: the artefact kind and the house-style question are left unwritten, each still waiting, and a note names the run that asks them. -**One line answers one question.** The install asks one approval per gap -category present — often several — and every line after the last one you supply -reads end-of-input and DECLINES. `yes` is the reliable form because it never +**One line answers one question.** Off a terminal, a piped answer stream is not +shown the plan: the install asks one approval per gap category present — often +several — and every line after the last one you supply reads end-of-input and +DECLINES. `yes` is the reliable form because it never runs out; a single `printf 'y\n'` answers the first question only and silently declines the rest. The questions come in a fixed order (dependency, safe-autocreate, config-change, status-line, oracle-routing, drain-rule, conventions-file, user-state, plugin-owned), so a @@ -577,31 +642,42 @@ EVERY question, so only reach for it once they have agreed to all of them. **At a terminal the questions are drawn; through this page they are relayed in an answers file.** When stdin, stdout and stderr are all terminals, the -install draws each question itself (the material, the question, each answer -with its meaning, and decide later last), answered by the arrow keys or by -its number, and Ctrl-C ends the run with exit 130, keeping the answers given -before it. Every answer is recorded, with the question as it was asked and +install draws each question itself on its one line with its answers' labels +(the plan screen with its plan), answered by the arrow keys or by its number; +`?` draws the whole question (the material, each answer with its meaning, and +decide later last) and asks it again, and Ctrl-C ends the run with exit 130, +keeping the answers given before it. Every answer is recorded, with the question as it was asked and where it was answered, in `.abcd/.work.local/interviews/setup-<stamp>.json`, and the answers that change the machine (the status line, the machine's routing table) in `~/.abcd.noindex/interviews/`; a run that ends aborted or refused changed nothing and records nothing. Through this page, put each question to the user with the host's question tool, quoting the question exactly as the install writes it, then pass their answers in an answers file -and say where they were given: +and say where they were given. The plan screen is the first: relay its plan +(the changes, each default with where to change it, and the questions that +follow) as its material, and its three answers, `proceed`, `customise` and +`cancel`, as options. Then relay each person-only question the run reaches +(the download, the shell settings, the status line, the commit identity, a +tool) as its own question, never folded into the plan's answer: ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --answers <file> --answered-in "Claude Code" --json ``` The file is one JSON object: `{"schema_version": 1, "interview": "setup", -"answers": [{"id": "adopt", "value": "yes"}, {"id": "visibility", "value": +"answers": [{"id": "approve.plan", "value": "proceed"}, {"id": "visibility", "value": "private", "note": "<optional>"}]}`. Each answer names its question by id -(a value question's key, `adopt`, `approve.<category>` for a kind of change, -or the offer's gap id) and gives one of the values the question offers, or -`later` to decide later; an unknown or repeated key is refused. With +(a value question's key, `approve.plan` for the plan screen, `abcd.download` +for the download, or the offer's gap id) and gives one of the values the +question offers, or `later` to decide later; an unknown or repeated key is +refused. The plan answers every kind of change at once, so an +`approve.<category>` id an earlier release asked for is refused with exit 2, +naming `approve.plan`, before anything is written; from a file, `proceed` and +`customise` both approve every kind of change, and each value still comes +from the file. With `--answers`, each question is written as plain text on stderr. A value -the question does not offer, or a missing answer to the adoption, an -approval or a setting value the run would ask (the visibility, the docs +the question does not offer, or a missing answer to the plan screen or a +setting value the run would ask (the visibility, the docs target, deep scanning), stops the run with exit 2 before anything is written, naming the question's id, the flag that answers it, if any, and the line to add; nothing is recorded. Put that question to the user, add their @@ -614,10 +690,9 @@ replaces the piped answer stream for that run. **A value flag needs the settings approval.** `--visibility`, `--docs-target`, `--oracle-backend` and `--scan-deep` are held to the config-change approval -like any other settings change: a flag that would change a saved setting puts -the config-change question even when nothing else would, and only a yes, given -at the prompt, as `approve.config-change` in the answers file, or by `--yes`, -saves it. `later`, a no or end of input drops the flag: nothing it would change +like any other settings change: a flag that would change a saved setting is +listed on the plan, and only `proceed`, a yes to the config-change question +under `customise`, `approve.plan` in the answers file, or `--yes` saves it. `later`, a no or end of input drops the flag: nothing it would change is saved or moved, and the run names each dropped flag in a note. **Stdin must end, or the prompt waits.** With stdin at end-of-input every @@ -644,7 +719,9 @@ a commit would carry diverges from the identity pin, or is a machine identity (`git_identity.mismatch`, `git_identity.unset`, `git_identity.committer`, `git_identity.tool`), the install proposes the human identity (the pin, else the global git identity) and asks whether to commit to this repository as it, -but only at a terminal. Off a terminal it asks nothing and writes nothing, and a +naming the identity and `.git/config`, but only at a terminal. It is its own +question: the plan and the settings approval never list it, and no answer to +either declines it. Off a terminal it asks nothing and writes nothing, and a piped `yes` does not answer it; neither does `--yes`. Relay the result's note to the user and tell them to run `abcd ahoy install` in their own terminal, or to set `user.name` and `user.email` themselves; never set them on the user's @@ -784,12 +861,14 @@ is an `artefact.invalid` gap instead: the file is the user's, so the install reports it and never overwrites it. **The status-line offer.** When the harness's user-level settings file exists -(`$CLAUDE_CONFIG_DIR/settings.json`, or `~/.claude/settings.json`) and its -`statusLine` is absent or is a command that is not abcd's, the install asks -whether to install abcd's status line — one paragraph of reason, then one -question, then one on/off prompt per element after the badge (repository, -branch, model, context, five-hour and seven-day usage, intent and issue -counts; default on). The question shows the one change: the file, and the +(`$CLAUDE_CONFIG_DIR/settings.json`, or `~/.claude/settings.json`), its +`statusLine` is absent or is a command that is not abcd's, and abcd's `PATH` +entry is on record (or the download question can put it there first), the +install asks whether to install abcd's status line in that file, running that +entry — one paragraph of reason, then one question, then, under `customise`, +one on/off prompt per element after the badge (repository, branch, model, +context, five-hour and seven-day usage, intent and issue counts; default on, +which `proceed` takes without asking). The question shows the one change: the file, and the `statusLine` entry's value now and after. Present the reason and the change to the user and relay their answer; never answer it for them. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index c4fa0c9ef5..df7db4d03f 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -122,6 +122,7 @@ Apply the install gaps the detection finds: Writes .abcd/, the name-guard hooks --answers string answer the setup questions from this answers file (JSON: schema_version, interview "setup", answers by question id), wherever the install runs; a question the file and no flag answers refuses with exit 2, naming its id, the flag and the file key --attribution opt this repo into the committed prepare-commit-msg prompt asking every commit to declare whether a tool assisted it; the choice is recorded, so a later install without the flag keeps the hook --bin-dir string directory for the PATH entry (default ~/.local/bin, or an existing abcd install adopted in place); fails when it is not writable — abcd never escalates privileges + --details print the full record after the report: core's summary of every write and every note, each path written, the declined kinds of change and the gaps left --dev track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary --docs-target string whether AGENTS.md carries the managed block, which names abcd: agents_md | skip (default skip); claude_md and both are refused, with the one setting to change --install-tool strings answer yes to installing this missing tool (repeatable): the answer a host's question tool relays; without it a tool is installed only on an answer typed at a terminal, never on the approve-everything flag, a piped answer or CI From 95b1ca4be9af413990b7f229e4faf2bd974e3d37 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:10:44 +0200 Subject: [PATCH 07/26] feat: each person's commit identity lives on their own machine The commit check no longer reads an identity the repository commits. Each person records their own identity under ~/.abcd.noindex (identity.json, or identity/<root-sha>.json for one repository), read under the home-scoped declaration guards; the Go check, `abcd ahoy --identity` and the committed pre-commit gate hold the author and committer to that record, refuse a machine identity in either role with or without one, and name only the person's own record and how to change it. Setup proposes a person's usual git identity at a terminal, as its own question, and records it on a yes. A repository that still stores the itd-131 pin has it moved to its owner's machine on a yes at a terminal; its production_mode stays committed in .abcd/config/provenance.json, which provenance.DeclaredMode now reads. The pin and the declared-mode files are read through the guarded reader. Records adr-2610241022541063; fills spc-2610241007568207 (left open). Resolves: iss-2610240830397793 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 75 +++- ...-each-person-s-commit-identity-lives-on.md | 128 +++++++ .abcd/development/decisions/adrs/README.md | 1 + ...very-person-who-commits-to-a-repository.md | 119 +++++- ...93-the-committed-identity-pin-names-one.md | 5 + .githooks/pre-commit | 249 +++++++++--- .github/CONTRIBUTING.md | 6 +- commands/ahoy.md | 68 +++- commands/capture.md | 5 +- commands/intent.md | 5 +- docs/reference/cli/commands.md | 4 +- docs/reference/terminology.md | 2 +- internal/core/ahoy/ahoy.go | 11 +- internal/core/ahoy/apply.go | 84 ++-- internal/core/ahoy/apply_test.go | 36 +- internal/core/ahoy/artefact.go | 2 +- internal/core/ahoy/category_approval.go | 4 +- internal/core/ahoy/category_approval_test.go | 45 +-- internal/core/ahoy/detect.go | 130 +++++-- internal/core/ahoy/identity_establish.go | 118 +++++- internal/core/ahoy/identity_establish_test.go | 20 +- internal/core/ahoy/identity_gap_test.go | 190 +++++++-- internal/core/ahoy/install_summary.go | 37 +- internal/core/ahoy/install_summary_test.go | 2 +- .../core/ahoy/install_summary_truth_test.go | 51 ++- internal/core/ahoy/setup_question.go | 4 + internal/core/ahoy/swallowed_writes_test.go | 19 +- internal/core/ahoy/symlink_escape_test.go | 6 +- internal/core/ahoy/waiting.go | 6 + internal/core/identity/committer_test.go | 9 +- internal/core/identity/establish.go | 55 ++- internal/core/identity/hook_test.go | 329 +++++++++++----- internal/core/identity/identity.go | 283 +++++--------- internal/core/identity/identity_test.go | 361 +++++++++++------- internal/core/identity/migrate.go | 240 ++++++++++++ internal/core/identity/migrate_test.go | 189 +++++++++ internal/core/identity/record.go | 230 +++++++++++ internal/core/positioning/block.go | 5 +- internal/core/positioning/config.go | 5 +- internal/core/provenance/declared.go | 130 +++++++ internal/core/provenance/declared_test.go | 103 +++++ .../surface/cli/ahoy_identity_gate_test.go | 21 +- .../surface/cli/ahoy_prompt_stdin_test.go | 51 ++- internal/surface/cli/capture_surface_test.go | 17 + internal/surface/cli/cli.go | 64 ++-- internal/surface/cli/consolidate_test.go | 16 +- 46 files changed, 2669 insertions(+), 871 deletions(-) create mode 100644 .abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md rename .abcd/work/issues/{open => resolved}/iss-2610240830397793-the-committed-identity-pin-names-one.md (67%) create mode 100644 internal/core/identity/migrate.go create mode 100644 internal/core/identity/migrate_test.go create mode 100644 internal/core/identity/record.go create mode 100644 internal/core/provenance/declared.go create mode 100644 internal/core/provenance/declared_test.go diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d5574cb84a..a6561e3e0d 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -90,8 +90,8 @@ flags. - **The remote apply** is **the one abcd verb that mutates state outside this machine.** See below. - **The identity check** exits non-zero when the author or the committer a - commit would carry does not match the repo's identity pin, and names a machine - identity in either role whether or not it fails. Read-only, CLI-only, for an + commit would carry does not match the identity the person recorded on their + own machine, or is a machine identity in either role, recorded or not. Read-only, CLI-only, for an operator or CI. - **The provider board** explains the optional OpenAI-compatible provider adapter (itd-2609081951381895): what an aggregator is, that abcd @@ -595,7 +595,9 @@ would find available); the repo skeleton; the repo's identity, both its root-commit SHA against the registry and the git author and committer a commit would carry, each resolved as git resolves it (the environment override, then the role's own `author.*` or `committer.*` key, then `user.*`), against the -committed identity pin and the list of machine identities; the registry's own wiring; the ignore +person's own identity record on their machine and the list of machine +identities, and whether the repository still stores the identity pin it once +committed; the registry's own wiring; the ignore block against the visibility policy; marker-block drift against the current template; the `PATH` entry; the hook manifest; the recorded setup version; and the two-layer name-guard scaffolding. @@ -748,7 +750,7 @@ the settings listed above?"), never the category's internal name. Yes says how many listed changes it writes, or, for a category whose changes are each asked again (a tool, an offer, a conventions file), which questions it goes on to; the settings approval's says it writes the changes that need no answer (the -identity pin, the `PATH` entry) and asks for each value not yet chosen, saving +`PATH` entry) and asks for each value not yet chosen, saving none if one is left unanswered. Every change is listed on every route, never cut to fit, so every door records the same question; a list taller than the host's rows is shown with the question check's note rather than refused. The question check refuses an answer @@ -758,7 +760,8 @@ approvals had before they listed anything. | `category` | Examples | Apply behaviour | |---|---|---| | `safe-autocreate` | the repo skeleton, history-store directories, the name-guard artefacts | applied once the category is approved, no per-item prompt; create-if-absent, never overwriting | -| `config-change` | visibility, oracle adapter, the `PATH` entry, the git-identity pin, the repository's own git identity, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | +| `config-change` | visibility, oracle adapter, the `PATH` entry, the repository's own git identity, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | +| `machine-identity` | the person's own commit identity record on their machine; moving the identity pin a repository still stores to its owner's machine (itd-2610240918547728) | never a category approval: each is its own question, asked only at a terminal, written only on that yes; never under the approve-everything flag or a piped answer, and reported as optional work it skipped | | `plugin-owned` | the marker block (itd-3); hook-manifest verification | silent overwrite on marker drift; a non-resolvable diagnostic for a malformed or missing manifest, for a conventions file whose block would land inside a fence or HTML comment nothing closes (`marker.unplaceable`), for one that exists but cannot be read whole, a folder, a pipe, a file past the size cap or one without read permission (`marker.unreadable`), and for one whose block is missing or out of date in a folder that does not let abcd create a file there, which the write needs for its lock and temporary file (`marker.unwritable`) | | `dependency` | a tool a capability uses and cannot find: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json` | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the exact install step, what the install does) and its install step runs only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | @@ -840,7 +843,7 @@ form: it never runs out. **Answers arrive from stdin whether or not stdin is a terminal** (iss-167). The prompts are the same prompts; only the reader differs. At a terminal a human types them; off one, a caller pipes them, which is how a host -agent drives the git-identity pin, the one approval no flag covers. Off a +agent drives the approvals no flag covers. Off a terminal each answer is echoed to the diagnostic stream, so a piped run leaves a transcript rather than a column of questions with no visible reply. @@ -1031,25 +1034,46 @@ seeds the warning and the result says what was heard. The question is asked only when the seed is written: a repository that already carries a docs-lint config keeps its own severity and is not asked. -Blanket approval does **not** adopt an unmanaged repo or pin an unset git -identity: those still need their own answer. The identity-pin exclusion is +Blanket approval does **not** adopt an unmanaged repo or record who a person +commits as: those still need their own answer. The identity-record exclusion is stated rather than assumed — the flag's own help names it, the install envelope carries it as skipped-and-optional, and the completion output prints it with the way to apply it (iss-166). +**Each person commits as themselves** (itd-2610240918547728, which reverses +the committed pin of itd-131; adr-2610241022541063). Nothing in a repository +names who may commit to it. Each person's identity is their own record, on +their own machine: `~/.abcd.noindex/identity/<root-sha>.json` for one +repository, keyed on its root commit, else `~/.abcd.noindex/identity.json` for +every other repository on the machine, read under the guards the rules loader +applies (a regular file this account owns that no other account can write, +size-capped, refused behind a symlinked `~/.abcd.noindex`); a record that is +there and fails a guard fails the check closed. The committed shell guard in +abcd's own `.githooks/pre-commit` reads the same two files in the same order, +with no binary. With no record (`git_identity.unrecorded`, advisory) the +install proposes the person's usual git identity at a terminal, as its own +question, and records it only on a yes. A repository that still stores the +pin (`git_identity.repo_pin`) has it moved on its owner's machine (the person +whose git identity is the stored one), on a yes at a terminal: recorded as +that person's record for the repository, its `production_mode` kept in +`.abcd/config/provenance.json`, and `identity.json` removed from the working +tree. On anyone else's machine it is not copied, not removed, and not named. + **Who commits is proposed, never assumed** (itd-131). Detection raises -`git_identity.mismatch` or `git_identity.unset` for the author, +`git_identity.mismatch` for an author that is not the person's own record and +`git_identity.unset` for an author with none (required once a record applies), `git_identity.committer` for a committer that diverges on its own (required -where the repo pins an identity, advisory where it does not), and +where a record applies, advisory where none does), and `git_identity.tool` wherever the author or the committer is a machine identity (the harness's own default, a `[bot]` account, a vendor's address, a configured -automation's name such as `semantic-release-bot`), pinned or not, because the +automation's name such as `semantic-release-bot`), recorded or not, because the human is the author of record either way. The machine identities are one list, `internal/core/identity/tool-identities.txt`, which the CI attribution gate reads too, role asymmetry included: a `noreply@` mailbox is a machine as the author, and the forge's own committer stamp passes. Once the config-change category is approved, the install proposes the human identity -(the pin, else the global git identity, read from disk and never looked up over +(the person's own record, else their global git identity, read from disk, never +from the repository, and never looked up over the network, per adr-38, and never a machine's) and sets `user.name` and `user.email` in the repository's own `.git/config` only when the person confirms at a terminal. Otherwise it writes nothing and its notes say why: under the @@ -1059,7 +1083,9 @@ give; when an environment override or an `author.*` or `committer.*` key outranks `user.*`, so the write would change nothing; and when there is no human identity to propose. An autonomous routine is therefore detected here and established by whatever launches it, before its first commit -(iss-2608210932052003). The pin is never recorded from a machine identity. +(iss-2608210932052003); on a fresh machine with no record, the commit check lets +the human identity the launcher supplied through and refuses a machine's. A +record is never written from a machine identity. ### What the apply pass writes @@ -1219,8 +1245,8 @@ byte-identical to a fresh install save for the setup date. bare invocation never adopts. **Given** a folder that is not a git repository, it reports `unmanaged-folder` and that there is nothing to act on. - **Given** a fresh repo with no `.abcd/` directory, **when** the install runs to - completion, **then** the repo carve-out is written, the identity pin is - recorded where the git-identity gate is adopted, the visibility-driven ignore + completion, **then** the repo carve-out is written, nothing in the repository + names who commits to it, the visibility-driven ignore entries are present, the registry entry exists, the marker block from the canonical template is installed in the files a chosen docs target names and in none at the default, and the hook-manifest check runs verify-only @@ -1267,11 +1293,20 @@ byte-identical to a fresh install save for the setup date. the doctor runs, **then** detection notices the stale registered path and the install refreshes it: the root SHA is unchanged, so the entry is updated rather than duplicated. -- **Given** a pinned repo whose repo-local `user.name` and `user.email` differ - from the pin, **when** the install runs at a terminal, **then** it proposes - the pinned identity and writes repo-local config only after the person - confirms; declined, git config is unchanged. -- **Given** a committer that differs from the author or the pin, or an author or +- **Given** a person whose repo-local `user.name` and `user.email` differ from + their own record, **when** the install runs at a terminal, **then** it + proposes the recorded identity and writes repo-local config only after the + person confirms; declined, git config is unchanged. +- **Given** a person with no record on this machine, **when** the install runs + at a terminal, **then** it proposes their usual git identity as its own + question and records it on their machine on a yes; off a terminal it records + nothing and says so. +- **Given** a contributor in a repository that still stores another person's + identity, **when** they set up and commit, **then** nothing names that + person and their commits are checked against their own record; on the + stored identity's owner's machine the install moves it to their record, removes it from the repository, keeps its `production_mode`, + and says so. +- **Given** a committer that differs from the author or the record, or an author or committer that is a machine identity, **when** detection runs, **then** the divergence is reported as its own gap, and with no terminal the install asks nothing, writes nothing and says so. diff --git a/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md b/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md new file mode 100644 index 0000000000..f881bc2022 --- /dev/null +++ b/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md @@ -0,0 +1,128 @@ +--- +id: adr-2610241022541063 +slug: each-person-s-commit-identity-lives-on +status: accepted +date: 2026-10-24 +supersedes: null +superseded_by: null +related_intents: [itd-2610240918547728, itd-131, itd-178] +related_rfcs: [] +related_adrs: [adr-38] +--- + +# ADR-2610241022541063: Each person's commit identity lives on their own machine, not in the repository + +## Context + +[itd-131](../../intents/shipped/itd-131-managed-repo-identity-gate.md) made +the commit identity gate check git's effective author and committer "against +the identity the repo pins" in a committed `.abcd/config/identity.json`, and +made adopting that pin a step of `abcd ahoy install`. The pin names one +person. In a repository that takes outside contributions that is wrong for +everyone else: setup reports any other contributor's identity as a required +`git_identity.mismatch` gap and offers to make them commit as the pinned +person, and abcd's own committed `.githooks/pre-commit` refuses their commits +with a fix line telling them to set the maintainer's name and email +([iss-2610240830397793](../../../work/issues/resolved/iss-2610240830397793-the-committed-identity-pin-names-one.md)). +That contradicts the attribution rule that every commit is honestly authored +by the human who made it. + +The pin did two useful things, and both had to survive: it caught a commit +made under a bot's or an AI tool's identity, and it caught a mistyped email of +one's own. The same committed file also carried the repository's default +`production_mode` (itd-178), which is the repository's setting, not a +person's. And an unattended routine on a fresh machine (a cloud container) +has no person at a terminal to establish an identity for. + +The product thinker accepted the reversal on 2026-10-24, in the Decisions of +[itd-2610240918547728](../../intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md), +after considering and declining the alternative of dropping the pin and +checking only that the author is a human. + +## Decision + +We will keep each person's commit identity on their own machine, and nothing +in a repository will name who may commit to it. + +- The record is `{"name": "…", "email": "…"}` under the user-level home, read + most specific first: `~/.abcd.noindex/identity/<root-sha>.json` for one + repository, keyed on the full object name of its root commit (as the + worktree and transcript stores are keyed), else + `~/.abcd.noindex/identity.json`, the machine-wide default. Per repository + with a machine-wide default, so a person with a work and a personal identity + keeps each where it belongs while the common case is one file. +- It is read under the guards every home-scoped declaration takes + (`fsutil.ReadHomeDeclaration`, the rules loader's): a regular file this + account owns that no other account can write, capped in size, refused behind + a symlinked `~/.abcd.noindex`. A record that is there and fails a guard, or + does not parse, fails the check closed; it never reads as no record. +- The commit check (`identity.Check`, `abcd ahoy --identity`, and the + self-contained shell gate in abcd's own `.githooks/pre-commit`, which reads + the same two files in the same order from `$HOME`, with no binary) holds the + author and committer to that record, and refuses a machine identity, from + the one list `internal/core/identity/tool-identities.txt`, in either role + whether or not a record exists. With no record, a human identity commits: + an unattended run on a fresh machine commits as the human identity its + launcher supplied, or is refused as a machine. +- Setup proposes a person's usual git identity and records it only on their + yes, as its own question, only at a terminal; off a terminal, or under the + approve-everything flag, it records nothing and says so. +- A repository that still stores the pin is migrated on its owner's machine + (the person whose git identity is the stored one), on a yes at a terminal: + the pin becomes that person's record for the repository, its + `production_mode` moves to `.abcd/config/provenance.json`, and + `identity.json` leaves the working tree. On anyone else's machine it is not + copied, not removed and not named. +- The repository's default production mode stays committed, in + `.abcd/config/provenance.json`; while a repository has not migrated, the + legacy pin's `production_mode` member is still read when that file is + absent. + +This supersedes, from itd-131, the decision that the gate checks commits +against an identity the repository pins; the scope item that adopting the gate +writes `identity.json` behind the config-change approval; and the first link +of the disk-only proposal chain, "pinned → global", which becomes "the +person's own record → global". Its other decisions stand: detection here and +establishment by whatever launches a routine (iss-2608210932052003), a +disk-only proposal chain with no network lookup (adr-38), and +propose-and-confirm at a terminal before repo-local `user.*` is written. + +## Alternatives Considered + +- **Keep the committed pin** (itd-131 as shipped). Rejected: it names one + person to every contributor and tells them to commit as that person. +- **Drop the pin and check only that the author is a human and matches the + committer.** Considered and declined by the product thinker: it keeps the + machine refusal but loses the check that catches a mistyped email of one's + own. +- **A machine-wide record only.** Rejected: a person who commits to work + repositories under a work identity and to their own under a personal one + would be refused in one set or the other. +- **A per-repository record only.** Rejected as the only form: every + repository would need its own question, while most people commit under one + identity everywhere. +- **Chosen: per repository, with a machine-wide default**, kept on the + person's own machine. + +## Consequences + +- No contributor is ever shown or offered another person's identity, and a + repository that takes outside contributions needs no per-person setup. +- The commit check is now a person's own commitment: until they record an + identity, only a machine identity is refused. A person who never runs setup + at a terminal is checked for machines alone. +- An unattended run on a fresh machine needs no record: its launcher supplies + the human identity, and a machine identity is still refused. A git identity + git invents from the account and host name, when none is configured, is not + distinguishable from a supplied one inside the hook; the Go check reports it + as `git_identity.unset`. +- The shell gate in abcd's own pre-commit hook now reads the person's home + directory, and is at least as strict as the Go reader (it refuses a + group-writable folder the Go reader admits when the group is the owner's + own). +- `.abcd/config/provenance.json` is a new committed file, and + `provenance.DeclaredMode` is the one reader of the repository's default + production mode; `identity.DeclaredProductionMode` is gone. +- abcd's own repository still stores the maintainer's pin until the + maintainer's next setup moves it; nothing reads it to check commits in the + meantime. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 084dce4990..e87497c371 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -216,4 +216,5 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2610161621415708](2610161621415708-abcd-s-command-guard-steers-agents-an-os.md) | Unattended runs abcd launches execute inside an OS sandbox | proposed | 2026-10-16 | | [adr-2610161733400551](2610161733400551-a-reviewer-in-a-model-matrix-run-never.md) | A reviewer or judge in a model-matrix run is told nothing about which model wrote the copy it judges | proposed | 2026-10-16 | | [adr-2610181003541997](2610181003541997-the-away-mode-s-stop-check-reads-the.md) | The away mode's stop check reads the forge once, as its documented meaning | proposed | 2026-10-18 | +| [adr-2610241022541063](2610241022541063-each-person-s-commit-identity-lives-on.md) | Each person's commit identity lives on their own machine, not in the repository | accepted | 2026-10-24 | <!-- /index --> diff --git a/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md b/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md index 0b99c0b95c..6c0bdfed9d 100644 --- a/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md +++ b/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md @@ -9,12 +9,125 @@ production_mode: dictated-and-formatted ## Summary -_Draft: describe what spc-2610241007568207 delivers for itd-2610240918547728 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-2610241007568207 moves each person's commit identity out of the +repository and onto their own machine, so a second contributor is never told +to commit as the maintainer (iss-2610240830397793), while a commit under a +machine's identity, or under a mistyped email of one's own, is still refused. +It reverses the committed pin of itd-131; the decision is +[adr-2610241022541063](../../decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md). + +### Scope + +This lane is the identity half of the setup redesign: the per-machine record, +the commit check that reads it (Go and the committed shell gate), the +migration of a committed pin, the repository's production mode kept +committed, and the minimum wiring into `abcd ahoy install` so the gap and its +fix work today. The setup-flow lane builds on the core API below; it is not +in this lane. + +### Approach + +- **The record** (`internal/core/identity/record.go`). `{"name","email"}` under + the user-level home, read most specific first: + `~/.abcd.noindex/identity/<root-sha>.json` (one repository, keyed on its + root commit) then `~/.abcd.noindex/identity.json` (the machine-wide default). + Read through `fsutil.ReadHomeDeclaration` (owned by this account, not + writable by another, 4096-byte cap, refused behind a symlinked + `~/.abcd.noindex`); a record that is there and fails a guard or does not + parse is `ErrRecordRefused`, never "no record". Written atomically through + `fsutil.EnsureHomeScope` at `abcdhome.DirMode`, the file at + `abcdhome.FileMode`; a machine identity, an incomplete one or one the shell's + sed could not read back is refused. +- **The check** (`identity.Check`, `abcd ahoy --identity`). Holds the author + and committer to the record that applies; refuses a machine identity + (`tool-identities.txt`) in either role whether or not a record exists; with + no record a human identity passes (`StatusNoRecord`). Nothing the repository + commits is read. +- **The shell gate** (`.githooks/pre-commit`). Self-contained: reads the same + two files from `$HOME` in the same order, with shell equivalents of every + guard (symlinked folder or file, not regular, not owned, group- or + other-writable file or folder, over 4096 bytes, unparseable), each a BLOCK; + refuses a machine identity from the one list, found beside the hook's own + checkout or in the working tree (a copied hook with no list warns in one + line and checks the record alone); names the person's own record and how to + change it in every refusal. +- **The migration** (`internal/core/identity/migrate.go`). `PlanMigration` + reads `.abcd/config/identity.json`; the owner is the person whose git + identity (repo-resolved or global) is the stored one. `ApplyMigration` writes + the owner's record for the repository (never over a different one), moves + `production_mode` to `.abcd/config/provenance.json`, and removes the pin; it + refuses a non-owner and a pin holding a member it cannot carry. +- **The production mode** (`internal/core/provenance/declared.go`). + `provenance.DeclaredMode` reads `.abcd/config/provenance.json`, else the + legacy pin's `production_mode`, else `hand-written`; the CLI's + `resolveProductionMode` calls it. +- **Ahoy** (`internal/core/ahoy/detect.go`, `identity_establish.go`). + `git_identity.unrecorded` and `git_identity.repo_pin` are `machine-identity` + gaps, never behind a category approval, asked only at a terminal as their + own questions (`stepIdentityMigrate`, `stepIdentityRecord`), listed in + `optional_skipped` otherwise. A contributor's `repo_pin` gap is not + resolvable and names no one. The itd-131 propose-and-confirm step proposes + the person's own record, else their global identity. + +### How each criterion is met + +1. **No record, at a terminal.** `git_identity.unrecorded` is raised; + `stepIdentityRecord` proposes `identity.ProposeRecord` (global identity, else + the repo-resolved one, never a machine's) as its own question and writes + the machine-wide record on a yes. Tests: + `TestStepIdentityRecord_RecordsOnYesAtATerminal`, + `TestStepIdentityRecord_WritesNothingOffATerminalUnderYesOrOnNo`, + `TestInstallRecordsTheIdentityAtATerminal`, + `TestAhoyInstallPipedAnswersNeverRecordTheIdentity`. +2. **A contributor who is not the maintainer.** Neither the check nor the + shell gate reads the committed pin; the contributor's `repo_pin` gap says + only that the stored identity is not theirs. Tests: + `TestCheck_IgnoresACommittedPin`, + `TestDetectGitIdentity_ContributorIsNeverShownTheMaintainer`, the hook case + "a committed pin of the maintainer does not refuse a contributor". +3. **A different email, a bot's or an AI tool's name.** Refused by the shell + gate and `ahoy --identity`, naming the person's own record and how to + change it. Tests: the hook cases "mismatch blocks, naming the record…", + "an AI tool's identity blocks…", "a bot's identity blocks…", + `TestAhoyIdentityFlagHoldsTheCommitIdentityToThePin`, + `TestDetectGitIdentity_Mismatch`. +4. **A repository that stores an identity today.** On the owner's machine, at + a terminal, `stepIdentityMigrate` moves it to their record, keeps + `production_mode` in `.abcd/config/provenance.json`, removes the pin and + notes each; on another person's machine nothing is copied or removed. + Tests: `TestMigration_OwnerMovesThePin`, + `TestMigration_NonOwnerIsNotGivenThePin`, + `TestMigration_UnknownMemberBlocks`, `TestMigration_KeepsADifferentRecord`, + `TestStepIdentityMigrate_MovesThePinOnYes`, + `TestDeclaredModeFallsBackToTheLegacyPin`. +5. **Unattended, fresh machine, no record.** A human identity the launcher + supplied commits (`StatusNoRecord` does not block; the hook's "no record + passes a human"); a machine identity is refused in either role + (`TestCheck_FlagsAToolIdentity`, `TestBlocks`, the hook's machine cases). + Off a terminal setup records nothing and says so. + +### Known limits + +- A repository with no git identity configured anywhere: git invents one + from the account and host name and exports it into the hook, where it is + indistinguishable from a supplied one; the Go check reports it as + `git_identity.unset` (advisory until a record applies). +- The shell gate refuses a group-writable folder the Go reader admits when the + group is the owner's own: stricter, in the fail-closed direction. ## Footprint -- packages: -- tests: +- packages: internal/core/identity, internal/core/provenance, + internal/core/ahoy, internal/surface/cli +- hooks: .githooks/pre-commit (the self-contained identity gate) +- docs: commands/ahoy.md, commands/capture.md, commands/intent.md, + .abcd/development/brief/04-surfaces/01-ahoy.md, .github/CONTRIBUTING.md, + docs/reference/terminology.md, docs/reference/cli/commands.md (generated) +- records: adr-2610241022541063, iss-2610240830397793 +- tests: internal/core/identity (record, migrate, hook, committer, env), + internal/core/provenance (declared), internal/core/ahoy (identity gap, + establish, apply, summary, swallowed writes), internal/surface/cli (ahoy + identity gate, piped install, consolidate, capture production mode) ## Steps diff --git a/.abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md b/.abcd/work/issues/resolved/iss-2610240830397793-the-committed-identity-pin-names-one.md similarity index 67% rename from .abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md rename to .abcd/work/issues/resolved/iss-2610240830397793-the-committed-identity-pin-names-one.md index af2a2b922b..6e016d133a 100644 --- a/.abcd/work/issues/open/iss-2610240830397793-the-committed-identity-pin-names-one.md +++ b/.abcd/work/issues/resolved/iss-2610240830397793-the-committed-identity-pin-names-one.md @@ -10,6 +10,11 @@ origin: researcher-authored production_mode: hand-written found_at: ".abcd/config/identity.json" remedy: "move the identity pin out of the repository to each person's own machine (under ~/.abcd.noindex), so the committed hook and setup check a contributor against their own pin and never print or propose another person's identity; keep refusing machine identities in every repository" +resolution: "fixed by moving each person's commit identity to their own machine (~/.abcd.noindex/identity.json, or identity/<root-sha>.json for one repository): the commit check, abcd ahoy --identity and the committed pre-commit gate hold commits to that record and never read the committed pin, so no contributor is shown or offered the maintainer's identity; machine identities are still refused in every repository, and setup moves a committed pin to its owner's machine (adr-2610241022541063)" +impact: additive +resolved_by: + intent: "itd-2610240918547728" + spec: "spc-2610241007568207" --- the committed identity pin names one person, so it misleads every other contributor: .abcd/config/identity.json pins REPPL's identity, ahoy install reports any other contributor's identity as a required git_identity.mismatch gap and proposes they commit as the pinned person, and the committed .githooks/pre-commit refuses their commits with 'fix: git config user.name "REPPL" && git config user.email ...', telling them to commit under the maintainer's name. That contradicts the attribution rule that every commit is honestly authored by the human who made it, in a repository that takes outside contributions. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 2f6041345a..417ebbb57b 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -75,7 +75,7 @@ case $- in *x*) set +x ;; esac # .githooks/commit-msg, where a no-op `continue` once cut the judged message to nothing). # `go`, `cd`, `pwd` and `trap` are on it for the sources refresh below, which builds # this checkout's abcd (parity with .githooks/commit-msg). -unset -f git grep awk mktemp tr cat mkdir rm chmod printf sed head command read echo exit test [ \ +unset -f git grep awk mktemp tr cat mkdir rm chmod printf sed head find wc command read echo exit test [ \ declare continue break return local export set true go cd pwd trap 2>/dev/null || true # A field separator the caller cannot choose: an inherited IFS changes how every # unquoted expansion below splits — including the sweep loop's, so it is pinned @@ -149,7 +149,7 @@ fi export PATH # Every external tool this hook runs after the pin. A missing one must BLOCK loudly: # left to `set -e` it would surface as a mute exit 127 that reads like a broken repo. -for tool in git grep awk mktemp tr cat mkdir rm chmod printf sed head; do +for tool in git grep awk mktemp tr cat mkdir rm chmod printf sed head find wc; do if ! command -v "$tool" >/dev/null 2>&1; then echo "pre-commit: BLOCKED — $tool is not on the guard's pinned PATH ($PATH)." >&2 echo " the guard pins PATH so a repo-scoped override cannot substitute a fake tool," >&2 @@ -172,64 +172,219 @@ if [ -z "$toplevel" ]; then fi cd "$toplevel" -# --- iss-62 identity gate --------------------------------------------------- -# Refuse a commit whose author or committer identity diverges from the committed pin -# (.abcd/config/identity.json). Self-contained (no binary dependency) so the -# guard holds even before abcd is built or on PATH, and can't be broken by a -# stale abcd. Runs FIRST — the name-guard below early-exits when no banlist is -# present, which must not skip this check. No-op ONLY when the repo has no pin. +# --- identity gate (itd-2610240918547728; iss-62 and itd-131 before it) ---- +# Refuse a commit made under a machine's identity, in either role, wherever this +# hook runs; and refuse one whose author or committer is not the identity the +# PERSON recorded on their own machine, when they have recorded one. Nothing in +# the repository names who may commit to it: each person's record lives under +# their own home, so no contributor is ever told to commit as someone else. # -# FAIL CLOSED: a pin that is present but that this shell cannot read (empty -# value, or non-lowercase / non-canonical JSON keys the sed does not match) -# BLOCKS the commit rather than falling through. The Go check -# (`abcd ahoy --identity`) enforces such pins directly; this shell guard is -# deliberately stricter — it never permits an unverified identity. Keep the two -# in the same fail-closed direction (see identity_test.go / the shell hook test). -identity_pin=".abcd/config/identity.json" -if [ -f "$identity_pin" ]; then - want_name=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$identity_pin" | head -n1) - want_email=$(sed -n 's/.*"email"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$identity_pin" | head -n1) +# The record is read from two places, the more specific first, exactly as the Go +# check (internal/core/identity, `abcd ahoy --identity`) reads it: +# ~/.abcd.noindex/identity/<root-sha>.json this person, in THIS repository +# (keyed on its root commit) +# ~/.abcd.noindex/identity.json this person, everywhere else +# With neither, a human identity — whatever launched the run supplied — commits, +# and a machine identity is still refused. +# +# Self-contained (no binary dependency) so the guard holds even before abcd is +# built or on PATH, and can't be broken by a stale abcd. Runs FIRST — the +# name-guard below early-exits when no banlist is present, which must not skip +# this check. +# +# FAIL CLOSED: a record that is there and that this shell cannot trust or read — +# behind a symlinked folder, a symlink itself, not a regular file, not owned by +# this account, writable by another, over 4096 bytes, or with a name/email the +# sed cannot read — BLOCKS the commit rather than reading as no record. This +# shell is deliberately at least as strict as the Go check. Keep the two in the +# same fail-closed direction (identity_test.go and hook_test.go). +# +# Each field is read the way git resolves the one it stamps, first source that +# sets it wins: the GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL environment, then the role's +# own author.name/author.email (iss-2609261454332615), then user.*. The +# environment comes first because git ranks it above every config key, and an +# exported machine identity is the harness and CI shape (iss-2609261614268567). +# git also exports the author it resolved, from --author and -c author.* +# included, into this hook's environment, so reading it first holds those too. +# The committer is read in the same order: GIT_COMMITTER_*, committer.*, user.*. +# git does not export the committer into this hook, so the environment holds only +# what the caller set; a -c committer.* reaches the git config reads below +# through the GIT_CONFIG_PARAMETERS git passes down. +have_name=${GIT_AUTHOR_NAME:-} +[ -n "$have_name" ] || have_name=$(git config author.name || git config user.name || true) +have_email=${GIT_AUTHOR_EMAIL:-} +[ -n "$have_email" ] || have_email=$(git config author.email || git config user.email || true) +have_cname=${GIT_COMMITTER_NAME:-} +[ -n "$have_cname" ] || have_cname=$(git config committer.name || git config user.name || true) +have_cemail=${GIT_COMMITTER_EMAIL:-} +[ -n "$have_cemail" ] || have_cemail=$(git config committer.email || git config user.email || true) +if [ -z "$have_name" ] || [ -z "$have_email" ]; then + echo "pre-commit: BLOCKED — git has no author identity here (user.name/user.email)." >&2 + echo " fix: set git config user.name and user.email to your own; an unattended run's" >&2 + echo " launcher supplies the human identity (GIT_AUTHOR_*/GIT_COMMITTER_*) before it commits." >&2 + exit 1 +fi + +# The machine identities, from the ONE list the CI attribution gate and the Go +# check read (internal/core/identity/tool-identities.txt). It is found beside +# this hook's own checkout first (the hook is committed at .githooks/, one level +# below it), then in the working tree being committed; in abcd's own checkout it +# is always there. A list that is there but cannot be read, or lacks a key, +# BLOCKS: an empty pattern would match nothing. A copy of this hook with no list +# anywhere near it (a hook copied out of the checkout) says so in one line and +# checks the record alone; the CI attribution gate still refuses the identity. +tool_list="internal/core/identity/tool-identities.txt" +if [ -e "$hook_dir/../$tool_list" ]; then + tool_list="$hook_dir/../$tool_list" +fi +tool_pattern() { + if [ ! -f "$tool_list" ] || [ ! -r "$tool_list" ]; then + echo "pre-commit: BLOCKED — cannot read the machine-identity list $tool_list (fail closed)." >&2 + exit 1 + fi + _pat=$(sed -n "s/^$1=//p" "$tool_list" | head -n1) + if [ -z "$_pat" ]; then + echo "pre-commit: BLOCKED — the machine-identity list $tool_list has no $1 pattern (fail closed)." >&2 + exit 1 + fi + printf '%s' "$_pat" +} +# is_machine ROLE NAME EMAIL — the IsToolIdentity reading, role asymmetry included: +# a noreply@ mailbox is a machine's as the AUTHOR only, because the forge stamps +# `GitHub <noreply@github.com>` as the committer of every web-UI merge on a +# human's click. +is_machine() { + # Here-strings, not a pipe: under pipefail, a printf killed by grep -q's early + # exit would turn a match into a miss. + if grep -Eiq -e "$re_ai_name" -e "$re_machine_name" -e "$re_name_word" <<<"$2"; then + return 0 + fi + if grep -Eiq -e "$re_ai_mail" -e "$re_machine_mail" -e "$re_local_word" <<<"$3"; then + return 0 + fi + if [ "$1" = author ] && grep -Eiq -e "$re_author_mail" <<<"$3"; then + return 0 + fi + return 1 +} +if [ ! -e "$tool_list" ] && [ ! -L "$tool_list" ]; then + echo "pre-commit: warning — no machine-identity list ($tool_list) beside this hook or in this working tree, so only your own identity record is checked here; CI's attribution gate still refuses a machine identity." >&2 +else + re_ai_name=$(tool_pattern ai_name) + re_ai_mail=$(tool_pattern ai_mail) + re_machine_name=$(tool_pattern machine_name) + re_machine_mail=$(tool_pattern machine_mail) + re_name_word=$(tool_pattern machine_name_word) + re_local_word=$(tool_pattern machine_local_word) + re_author_mail=$(tool_pattern author_only_mail) + for _role in author committer; do + if [ "$_role" = author ]; then _n=$have_name _e=$have_email; else _n=$have_cname _e=$have_cemail; fi + if is_machine "$_role" "$_n" "$_e"; then + echo "pre-commit: BLOCKED — the $_role \"$_n <$_e>\" is a machine identity; the person responsible is the author of record." >&2 + echo " fix: commit as yourself — set user.name/user.email to your own, and drop any GIT_AUTHOR_*/GIT_COMMITTER_*," >&2 + echo " author.*/committer.*, --author or -c that names the tool. An unattended run's launcher supplies the" >&2 + echo " human identity before it commits." >&2 + exit 1 + fi + done +fi + +# The person's own record. HOME must be an absolute path to be read at all; an +# unset or relative HOME is no record (the machine refusal above still holds). +abcd_home="" +case "${HOME:-}" in /*) abcd_home="$HOME/.abcd.noindex" ;; esac +record="" +record_shown="" +# record_usable FILE SHOWN — 1 when FILE is absent; 0 when it is there and passes +# every guard; and BLOCKS when it is there and fails one. A symlinked +# ~/.abcd.noindex holding no record reads as absent, as the rules loader reads it. +record_refuse() { + echo "pre-commit: BLOCKED — your identity record $1 $2, so it cannot be trusted (fail closed)." >&2 + echo " fix: make it a regular file you own that no other account can write (chmod 600), with no" >&2 + echo " symlinked folder above it — or remove it and run abcd ahoy install at a terminal to be asked again." >&2 + exit 1 +} +record_usable() { + _f=$1 + _shown=$2 + if [ ! -e "$_f" ] && [ ! -L "$_f" ]; then + return 1 + fi + _parent=${_f%/*} + for _d in "$abcd_home" "$_parent"; do + if [ -L "$_d" ]; then + record_refuse "$_shown" "sits behind a symlinked folder" + fi + if [ ! -O "$_d" ]; then + record_refuse "$_shown" "sits in a folder another account owns" + fi + done + if [ -L "$_f" ]; then + record_refuse "$_shown" "is a symlink" + fi + if [ ! -f "$_f" ]; then + record_refuse "$_shown" "is not a regular file" + fi + if [ ! -O "$_f" ]; then + record_refuse "$_shown" "is owned by another account" + fi + _open=$(find "$_f" "$abcd_home" "$_parent" -prune \( -perm -0020 -o -perm -0002 \) -print 2>/dev/null) || + record_refuse "$_shown" "could not have its permissions read" + if [ -n "$_open" ]; then + record_refuse "$_shown" "(or its folder) can be written by another account" + fi + if [ ! -r "$_f" ]; then + record_refuse "$_shown" "cannot be read" + fi + _size=$(wc -c <"$_f" | tr -d '[:space:]') + case "$_size" in '' | *[!0-9]*) record_refuse "$_shown" "could not be measured" ;; esac + if [ "$_size" -gt 4096 ]; then + record_refuse "$_shown" "is larger than 4096 bytes" + fi + return 0 +} +if [ -n "$abcd_home" ]; then + root_sha=$(git rev-list -n 1 --max-parents=0 HEAD 2>/dev/null || true) + case "$root_sha" in + '' | *[!0-9a-f]*) ;; + *) + if record_usable "$abcd_home/identity/$root_sha.json" "~/.abcd.noindex/identity/$root_sha.json"; then + record="$abcd_home/identity/$root_sha.json" + record_shown="~/.abcd.noindex/identity/$root_sha.json" + fi + ;; + esac + if [ -z "$record" ] && record_usable "$abcd_home/identity.json" "~/.abcd.noindex/identity.json"; then + record="$abcd_home/identity.json" + record_shown="~/.abcd.noindex/identity.json" + fi +fi +if [ -n "$record" ]; then + want_name=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$record" | head -n1) + want_email=$(sed -n 's/.*"email"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$record" | head -n1) if [ -z "$want_name" ] || [ -z "$want_email" ]; then - echo "pre-commit: BLOCKED — $identity_pin is present but its name/email could not be read (fail closed)." >&2 - echo " check the pin's JSON (lowercase \"name\"/\"email\", both non-empty), or run: abcd ahoy --identity" >&2 + echo "pre-commit: BLOCKED — your identity record $record_shown is present but its name/email could not be read (fail closed)." >&2 + echo " fix: check its JSON (lowercase \"name\"/\"email\", both non-empty), or remove it and run" >&2 + echo " abcd ahoy install at a terminal to be asked again." >&2 exit 1 fi - # Each field is read the way git resolves the one it stamps, first source that - # sets it wins: the GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL environment, then the - # role's own author.name/author.email (iss-2609261454332615), then user.*. - # The environment comes first because git ranks it above every config key, and - # an exported machine identity is the harness and CI shape - # (iss-2609261614268567). git also exports the author it resolved, from - # --author and -c author.* included, into this hook's environment, so reading - # it first holds those too. An empty variable is not a source; git refuses an - # empty ident itself. - have_name=${GIT_AUTHOR_NAME:-} - [ -n "$have_name" ] || have_name=$(git config author.name || git config user.name || true) - have_email=${GIT_AUTHOR_EMAIL:-} - [ -n "$have_email" ] || have_email=$(git config author.email || git config user.email || true) + change_hint="if your record is wrong, edit $record_shown, or remove it and run abcd ahoy install at a terminal to be asked again" if [ "$have_name" != "$want_name" ] || [ "$have_email" != "$want_email" ]; then - echo "pre-commit: BLOCKED — git identity \"$have_name <$have_email>\" does not match the pinned \"$want_name <$want_email>\" ($identity_pin)." >&2 + echo "pre-commit: BLOCKED — git identity \"$have_name <$have_email>\" is not the one you recorded, \"$want_name <$want_email>\" ($record_shown)." >&2 echo " fix: git config user.name \"$want_name\" && git config user.email \"$want_email\"," >&2 - echo " and drop any GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL, author.name/author.email, --author or -c author.* that outranks them" >&2 + echo " and drop any GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL, author.name/author.email, --author or -c author.* that outranks them;" >&2 + echo " $change_hint." >&2 exit 1 fi - # The committer is held to the pin in the same order (itd-131): - # GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL, then committer.name/committer.email, - # then user.*. git does not export the committer into this hook, so the - # environment holds only what the caller set; a -c committer.* reaches the - # git config reads below through the GIT_CONFIG_PARAMETERS git passes down. - have_cname=${GIT_COMMITTER_NAME:-} - [ -n "$have_cname" ] || have_cname=$(git config committer.name || git config user.name || true) - have_cemail=${GIT_COMMITTER_EMAIL:-} - [ -n "$have_cemail" ] || have_cemail=$(git config committer.email || git config user.email || true) if [ "$have_cname" != "$want_name" ] || [ "$have_cemail" != "$want_email" ]; then - echo "pre-commit: BLOCKED — git committer \"$have_cname <$have_cemail>\" does not match the pinned \"$want_name <$want_email>\" ($identity_pin)." >&2 + echo "pre-commit: BLOCKED — git committer \"$have_cname <$have_cemail>\" is not the one you recorded, \"$want_name <$want_email>\" ($record_shown)." >&2 echo " fix: unset GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL and drop any -c committer.*;" >&2 - echo " git config --unset committer.name; git config --unset committer.email (or set them to the pinned identity)" >&2 + echo " git config --unset committer.name; git config --unset committer.email;" >&2 + echo " $change_hint." >&2 exit 1 fi fi -# --- end iss-62 identity gate ----------------------------------------------- +# --- end identity gate ---------------------------------------------------------- # --- itd-74 private name guard ---------------------------------------------- diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f177cb9e21..32e6d4662d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -160,7 +160,11 @@ disclosure, and never an authorship assertion for a tool. The rules: web-UI merge passes too, which is why the sixth signal is author-only. Set `user.name` and `user.email` to a human before you commit; the assistant belongs in the trailer, never in the identity fields the contributor graph - reads. An automated dependency bump is therefore never merged as the bot + reads. Locally, the committed pre-commit hook refuses the same machine + identities before the commit is made, and holds each commit to the identity + you recorded on your own machine, if you have recorded one (`abcd ahoy + install` offers it at a terminal, in `~/.abcd.noindex/identity.json`); nothing + in the repository names who may commit to it. An automated dependency bump is therefore never merged as the bot authored it: one that only modifies a declared ecosystem's manifest and lock files, at the directory the bot's configuration declares, is re-authored as the repository owner by the `dependency-reauthor` workflow, and any other is diff --git a/commands/ahoy.md b/commands/ahoy.md index c2618c0c36..a782d05403 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -608,8 +608,8 @@ line to add; nothing is recorded. Put that question to the user, add their answer, and run again. The questions the run itself decides to ask (the status line's elements, the offers, the artefact kind) are checked when they are asked: a stop there leaves the steps before it done, says so, and -the next run asks only what is still open. The git identity, the -drain rule and installing a tool are never answered from a file. `--answers` +the next run asks only what is still open. The git identity, the identity +record, the drain rule and installing a tool are never answered from a file. `--answers` replaces the piped answer stream for that run. **A value flag needs the settings approval.** `--visibility`, `--docs-target`, @@ -627,29 +627,68 @@ prompt WAIT for the answer that never comes, rather than declining. For a run that must not block and must not prompt, close stdin or pre-answer everything: `abcd ahoy install --yes --refuse-adopt < /dev/null`. -`--yes` approves every resolvable category but never adopts the optional -git-identity pin, because the pin records whatever git identity is currently -configured, never wires the status line (below), because that rewrites a +`--yes` approves every resolvable category but never records who the person +commits as (below), because that would record whatever git identity is +currently configured, never wires the status line (below), because that rewrites a harness-wide setting, never accepts a model-tier routing table (below), because a table decides which model every delegated step asks for, and never adds the drain eligibility record (below), because the record decides what an unattended agent may change in the repository. When the result carries `optional_skipped`, report it and -offer the `yes |` form above as the way to apply it, except `drain_rule.offered` -and `conventions.retire_offered`, which only a person at a terminal is asked, +offer the `yes |` form above as the way to apply it, except +`git_identity.unrecorded`, `git_identity.repo_pin`, `drain_rule.offered` and +`conventions.retire_offered`, which only a person at a terminal is asked, and `readme.offered`, which this page relays as the README question pair below and answers with `--readme-parts`. +**Each person's commit identity is their own, kept on their own machine.** +Nothing in a repository names who may commit to it. The person's record lives +under their home directory: `~/.abcd.noindex/identity/<root-sha>.json` for one +repository (keyed on its root commit, so a work identity can stay with work +repositories), else `~/.abcd.noindex/identity.json` for every other repository +on the machine. Both hold `{"name": "…", "email": "…"}`, must be a file the +person owns that no other account can write, and are refused behind a +symlinked `~/.abcd.noindex`. The commit check holds the author and committer +to that record, and refuses a machine identity (an AI tool's or a bot's) in +either role whether or not there is one; with no record, a human identity +commits. A wrong record is changed by editing the file, or by removing it and +running `abcd ahoy install` at a terminal to be asked again. + +**The identity record question is a person's alone.** When no record applies +(`git_identity.unrecorded`), the install proposes the person's usual git +identity (the global `user.name`/`user.email`, else the one git resolves in +this repository, never a machine's) and records it in +`~/.abcd.noindex/identity.json` only on a yes. It is its own question, asked +only at a terminal and behind no category approval. Off a terminal, or under +`--yes`, it records nothing and the result lists `git_identity.unrecorded` in +`optional_skipped`; relay that and tell the user to run `abcd ahoy install` in +their own terminal. Never write the record on the user's behalf. + +**A repository that still stores an identity is migrated.** A repository set up +before this change committed one person's identity in +`.abcd/config/identity.json` (`git_identity.repo_pin`). abcd no longer reads it +to check commits. On its owner's machine (the person whose git identity is the +stored one) the install asks, at a terminal, whether to move it: a yes records +it as that person's record for this repository, keeps any `production_mode` it +declared in `.abcd/config/provenance.json`, removes `identity.json` from the +working tree, and says each of those; the owner commits the removal. On anyone +else's machine the gap says only that the stored identity is not theirs and is +not read, names no one, and nothing is copied or removed. The stored file is read through +the guarded reader: one that is a symlink, not a regular file, or over 64 KiB +is reported as `git_identity.uncheckable` and not read. + **The git identity question is a person's alone.** When the author or committer -a commit would carry diverges from the identity pin, or is a machine identity -(`git_identity.mismatch`, `git_identity.unset`, `git_identity.committer`, -`git_identity.tool`), the install proposes the human identity (the pin, else -the global git identity) and asks whether to commit to this repository as it, -but only at a terminal. Off a terminal it asks nothing and writes nothing, and a +a commit would carry diverges from the person's own record, or is a machine +identity (`git_identity.mismatch`, `git_identity.unset`, +`git_identity.committer`, `git_identity.tool`), the install proposes the human +identity (the person's own record, else their global git identity, never one a +repository names) and asks whether to commit to this repository as it, but only +at a terminal. Off a terminal it asks nothing and writes nothing, and a piped `yes` does not answer it; neither does `--yes`. Relay the result's note to the user and tell them to run `abcd ahoy install` in their own terminal, or to set `user.name` and `user.email` themselves; never set them on the user's behalf. For an autonomous routine the human identity is set by whatever launches -it, before its first commit. +it, before its first commit: on a fresh machine with no record, the commit check +lets through the human identity the launcher supplied and refuses a machine's. **The tool question.** When a `dependency` gap is present and its category is approved, each missing tool is its own question, and a piped answer never @@ -1346,7 +1385,8 @@ renders one domain's rules instead. ## Scoping note: `--identity` is CLI-only `abcd ahoy --identity` exits non-zero when the author or the committer a commit -would carry diverges from the committed pin. It exists to be wired into a pre-commit hook or CI, where +would carry diverges from the identity the person recorded on their machine, or +is a machine identity. It exists to be wired into a pre-commit hook or CI, where its exit code is the whole point, so it stays a bare-CLI entrypoint rather than a plugin mode; report it only if a user asks how the identity gate fails closed. diff --git a/commands/capture.md b/commands/capture.md index b415878165..b36eeda91e 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -177,8 +177,9 @@ with `--intent` leaves its `origin` alone. `production_mode` is the closed choice `--production-mode` carries: `hand-written`, `dictated-and-formatted`, or `scribe-transcribed`. Any other value is refused and nothing is written. On `capture` and `capture promote` an -absent flag takes the repo's declared default from `.abcd/config/identity.json`, -falling back to `hand-written`. On `capture resolve` and `capture wontfix` the +absent flag takes the repo's declared default from `.abcd/config/provenance.json` +(or, in a repository not yet migrated, the `production_mode` of its +`.abcd/config/identity.json`), falling back to `hand-written`. On `capture resolve` and `capture wontfix` the flag **restamps** the record — a resolution note is new text with its own mode — and an absent flag leaves the record's existing stamp alone. A restamp of a record that predates disclosure (one carrying no `origin`), or of one whose diff --git a/commands/intent.md b/commands/intent.md index 2f1dd9539a..a218a27d8c 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -153,8 +153,9 @@ rewritten — linking an existing draft with `capture promote --intent` writes t `production_mode` is the closed choice `--production-mode` carries: `hand-written`, `dictated-and-formatted`, or `scribe-transcribed`. Any other value is refused and nothing is written. An absent flag takes the repo's -declared default from `.abcd/config/identity.json`, falling back to -`hand-written`. `abcd intent plan` takes the same flag, which stamps the **spec** +declared default from `.abcd/config/provenance.json` (or, in a repository not +yet migrated, the `production_mode` of its `.abcd/config/identity.json`), +falling back to `hand-written`. `abcd intent plan` takes the same flag, which stamps the **spec** it mints; the intent's own stamp was written when its draft was created and is never rewritten. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index c4fa0c9ef5..3b9ef37782 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -53,7 +53,7 @@ Detect abcd's install state and list its gaps, or report one mode a flag names: ``` --dry-run print the detection result as its JSON envelope, whether or not --json is passed - --identity check git's commit author and committer against .abcd/config/identity.json, exiting non-zero when either diverges (for a pre-commit hook or CI) + --identity check git's commit author and committer against the identity you recorded on this machine, exiting non-zero when either diverges or is a machine identity (for a pre-commit hook or CI) --providers explain the optional OpenAI-compatible provider adapter, list the providers configured on this machine and where a key can live --remote report this repository's GitHub secret-scanning settings and what the remote apply sub-verb would change ``` @@ -132,7 +132,7 @@ Apply the install gaps the detection finds: Writes .abcd/, the name-guard hooks --refuse-adopt decline to adopt an unmanaged repo --scan-deep string enable deep scan: true | false --visibility string repo visibility: private | public - --yes approve every resolvable change category without prompting; excludes the optional git-identity pin, the status line and the model-tier routing tables, which need an answered prompt (run without --yes, or answer every prompt with: yes | abcd ahoy install); it never changes the repository's git identity, which is proposed only to a person at a terminal + --yes approve every resolvable change category without prompting; excludes recording who you commit as on this machine (asked only at a terminal), the status line and the model-tier routing tables, which need an answered prompt (run without --yes, or answer every prompt with: yes | abcd ahoy install); it never changes the repository's git identity, which is proposed only to a person at a terminal ``` #### `abcd ahoy remote` diff --git a/docs/reference/terminology.md b/docs/reference/terminology.md index dfcfb8e6f7..6b0a57eea8 100644 --- a/docs/reference/terminology.md +++ b/docs/reference/terminology.md @@ -41,7 +41,7 @@ deep-linked. | Term | Established meaning | abcd's position | |------|---------------------|-----------------| | **Agent harness** | The infrastructure around the model — the agent loop, tools, context management, and execution logic — that turns a model into a working agent.[^harness] | **USES** — "the agent harness" is abcd's own generic term for the host it configures and drives. abcd is a host-agnostic configuration layer over any harness, and a written adoption rubric (itd-51, draft) gates which autonomous harnesses abcd will drive. | -| **Agent identity** | Cryptographic workload identity issued across heterogeneous environments (SPIFFE); no ratified standard yet uses the exact term "agent identity".[^id] | **WATCHING** — a managed repo holds every commit to the user's chosen git identity (itd-131, shipped): `abcd ahoy` checks git's effective author and committer against the identity the repo pins in `.abcd/config/identity.json`, and on a divergence proposes the pinned (else global) identity and writes repo-local config only on a person's confirm. A permission/authorisation matrix is recorded as out of scope for agent coordination and routed to draft permission-template work (itd-33, itd-18). No workload-identity standard is adopted. | +| **Agent identity** | Cryptographic workload identity issued across heterogeneous environments (SPIFFE); no ratified standard yet uses the exact term "agent identity".[^id] | **WATCHING** — a managed repo holds every commit to the committing person's own git identity (itd-131, shipped; itd-2610240918547728): `abcd ahoy` checks git's effective author and committer against the identity each person records on their own machine, refuses a machine identity in either role, and on a divergence proposes the person's own (else global) identity and writes repo-local config only on that person's confirm; nothing in the repository names who may commit. A permission/authorisation matrix is recorded as out of scope for agent coordination and routed to draft permission-template work (itd-33, itd-18). No workload-identity standard is adopted. | | **Agent loop** | The iterative cycle in which a model interleaves reasoning with tool calls, feeding observations back until it produces an answer (the ReAct pattern).[^react] | **ADAPTS** — abcd's unit is the *autonomous run* (adr-27): iterate over ready work, gate each step on a receipt, apply a safety guard. The loop itself is supplied by a pluggable adapter; abcd defines the contract, never owns the loop. | | **Agent Payments Protocol (AP2)** | Open protocol for agent-led payments: cryptographically signed intent, cart, and payment mandates carrying a user's authority to transact (v0.2; standardisation continuing at the FIDO Alliance).[^ap2] | **WATCHING** — the record is silent on payments, and that silence is captured as iss-138 rather than dressed up as a rejection. Adjacent commerce protocols are noted in the footnote. | | **Agent skills** | A lightweight open format extending agent capabilities: a folder with a `SKILL.md` file plus optional scripts and assets; the specification is unversioned, draft-stage, and without a standards-body steward.[^skills] | **REJECTS** — the record rules that abcd ships zero skills: its surface namespace is commands only, and anything that mutates state is a command by definition (brief, internals: skills). The format stays under watch (iss-139) for the case a findings-only skill ever ships. | diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index f9e6a7e694..8279a1baa0 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -256,11 +256,12 @@ type InstallResult struct { // silent failure, so the reason travels with the result. Notes []string `json:"notes,omitempty"` // OptionalSkipped names the optional gaps a --yes run deliberately did not - // apply: the advisory git-identity pin, the status-line offer and the two - // model-tier routing offers (which accept a table only on an answer). --yes - // approves every resolvable CATEGORY, but it never writes the pin, because - // the pin captures whatever git identity happens to be configured and an - // unattended run would canonicalise a sandbox or agent identity; and it never + // apply: the person's own identity record (and moving a pin the + // repository still stores), the status-line offer and the two model-tier + // routing offers (which accept a table only on an answer). --yes approves + // every resolvable CATEGORY, but it never records who a person commits as, + // because that captures whatever git identity happens to be configured and + // an unattended run would record a sandbox or agent identity; and it never // wires the status line, because that rewrites a harness-wide user setting // and takes element choices only a prompt can carry. The exclusion is // reported rather than assumed: a run that says "already up to date" while diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index c3f5659262..8317cbd9fa 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -17,7 +17,6 @@ import ( "time" "github.com/intentdriven/abcd/internal/core/history" - "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/tools" ) @@ -169,9 +168,10 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er } // Idempotency: zero required+resolvable gaps => exact no-op. Three exceptions - // fall through: the advisory git-identity pin and the status-line offer, the - // optional gaps install closes against a confirmed answer (never under - // --yes), as their fix hints advertise; and an explicit value override that + // fall through: the person's own identity record (and the move of a pin + // the repository still stores) and the status-line offer, the optional + // gaps install closes against a confirmed answer (never under --yes), as + // their fix hints advertise; and an explicit value override that // differs from the persisted config, which forces an apply-as-update on an // otherwise-clean repo (iss-107). // An explicit --dev (or a plain install over an existing dev shim) forces an @@ -297,9 +297,13 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er ac.stepConventionsFiles() ac.stepRules() ac.stepVersionStamp() - // Before the pin: an identity mended here is the one the pin then records. + // The person's own identity record first (itd-2610240918547728): a pin the + // repository still stores moves to its owner's machine, else a person with + // no record is offered one; each is its own question, asked only at a + // terminal. Then the repository's git identity is mended against it. + ac.stepIdentityMigrate() + ac.stepIdentityRecord() ac.stepGitIdentity() - ac.stepIdentityPin() // After every step that asks its questions first: the kind is the last // answer a piped install gives (itd-2609150819432059). ac.stepArtefact() @@ -546,45 +550,6 @@ func droppedOverrides(overrides map[string]string) string { return strings.Join(pairs, ", ") } -// stepIdentityPin adopts the iss-62 identity gate for an un-pinned repo: it -// writes .abcd/config/identity.json from the current git author identity (the -// proposal), gated on ConfigChange approval (the confirmation). A mismatch is -// never auto-resolved — abcd must not silently change the pin or the user's git -// identity — so it stays a guided manual fix. -// -// It does NOT auto-adopt under --yes: pinning captures whatever git identity is -// currently set, so a blanket approval could pin a wrong/sandbox identity as -// canonical (the very value the gate exists to reject). Under --yes the -// un-pinned gap simply remains, to be adopted against a confirmed answer — typed -// at a terminal, or piped to a non-interactive run (iss-167). The exclusion is -// reported in InstallResult.OptionalSkipped, never left silent (iss-166). -func (a *applyCtx) stepIdentityPin() { - if a.autoYes || !a.approved[ConfigChange] || !a.has(OptionalPinGapID) { - return - } - // Each way of recording nothing is said, not dropped (iss-2609260057127611). - eff, err := identity.EffectiveIdentity(a.cwd) - if err != nil { - a.refuse("did not record who commits to this repository in " + identity.PinRelPath + ": git's user.name and user.email could not be read: " + errText(err)) - return - } - if eff.Name == "" || eff.Email == "" { - a.refuse("did not record who commits to this repository in " + identity.PinRelPath + ": git has no user.name and user.email set here; set them and run abcd ahoy install again") - return - } - // The pin names the repo's human; a machine identity is the one value it - // must never canonicalise (itd-131). - if identity.IsToolIdentity(identity.RoleAuthor, eff.Name, eff.Email) { - a.refuse("did not record who commits to this repository in " + identity.PinRelPath + ": the current git identity " + eff.Name + " <" + eff.Email + "> is a machine's, not a person's; set your own user.name and user.email and run abcd ahoy install again") - return - } - if err := identity.WritePin(a.cwd, identity.Pin{Name: eff.Name, Email: eff.Email}); err != nil { - a.refuse("could not record who commits to this repository in " + identity.PinRelPath + ": " + errText(err)) - return - } - a.note(writeIdentityPin, identity.PinRelPath) -} - func (a *applyCtx) has(id string) bool { return a.gapPresent[id] } // stepDependencies is the explain-then-install mode at ahoy (itd-63). For each @@ -2334,10 +2299,6 @@ func auditGaps(cwd string, det DetectionResult) []Gap { // approval + gap helpers // --------------------------------------------------------------------------- -// OptionalPinGapID is the one optional gap --yes does not cover. Named here so -// the front doors can point at it without re-deriving the string. -const OptionalPinGapID = "git_identity.unpinned" - // malformedConfigGapID is the one gap that means "abcd will not touch this // repo's config until a human repairs it". It is required and non-resolvable, so // it never appears in an actionable count, and both the detector that raises it @@ -2350,26 +2311,27 @@ const malformedConfigGapID = "config.malformed" const credentialAtRestGapID = "history.credential_at_rest" // optionalGapIDs are the advisory gaps install closes only against an answered -// prompt, never under --yes: the identity pin (see stepIdentityPin), the +// prompt, never under --yes: the identity pin's move and the person's own +// identity record (see stepIdentityMigrate, stepIdentityRecord), the // status-line offer (see stepStatusLine), the two model-tier routing offers // (see stepOracleRouting), the drain eligibility record (see stepDrainRule), // the retirement of a tool's own conventions file (see // stepConventionsFiles) and the README question (see stepReadme). In the // order they are reported. -var optionalGapIDs = []string{OptionalPinGapID, ReadmeOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var optionalGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} // terminalOnlyGapIDs are the optional offers put only to a person at a // terminal, and their categories: off one, neither the category nor the offer // is asked, so a piped answer stream keeps the order it had before they // existed. The README question has no category to ask (resolveApproval leaves // it out everywhere: the question is the consent). -var terminalOnlyGapIDs = []string{ReadmeOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var terminalOnlyGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} var terminalOnlyCategories = []GapCategory{DrainRule, ConventionsFile} // optionalSkipped lists the optional gaps a run left un-applied without asking. -// --yes approves every resolvable category but never adopts the identity pin -// or wires the status line, so the skip is deliberate — and therefore has to +// --yes approves every resolvable category but never records who a person +// commits as or wires the status line, so the skip is deliberate — and therefore has to // be reported rather than left ambient (iss-166). Outside --yes each is offered // as a confirmation, except the terminal-only offers off a terminal (see // stepDrainRule and stepConventionsFiles) and the README question in a @@ -2410,7 +2372,14 @@ func optionalAsked(cwd, id string, p Prompter) bool { // under --yes), so their presence must not be short-circuited by the // "already_up_to_date" early return. func optionalPending(gaps []Gap) []string { - present := gapIDSet(gaps) + // Only a gap install can close is pending: the identity pin a repository + // stores for someone else is reported, never offered (detectLegacyPin). + present := map[string]bool{} + for _, g := range gaps { + if g.Resolvable { + present[g.ID] = true + } + } var out []string for _, id := range optionalGapIDs { if present[id] { @@ -2519,8 +2488,11 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr lines[ConfigChange] = append(lines[ConfigChange], flagChanges...) } // The README question is its own consent (open question 5): its category - // is never asked, approved or declined. + // is never asked, approved or declined. So are the person's own identity + // questions (itd-2610240918547728): each is asked as its own question at + // a terminal, never behind a group approval. delete(present, Readme) + delete(present, MachineIdentity) approved := map[GapCategory]bool{} switch { case opts.ApprovedCategories != nil: diff --git a/internal/core/ahoy/apply_test.go b/internal/core/ahoy/apply_test.go index 977811730e..ffb771bae5 100644 --- a/internal/core/ahoy/apply_test.go +++ b/internal/core/ahoy/apply_test.go @@ -193,12 +193,13 @@ func TestStepConfigValuesNoOverrideLeavesValidValue(t *testing.T) { } } -// TestInstallAdoptsIdentityPinInteractively guards that the advisory identity -// pin can be adopted through a later interactive `ahoy install`, as the gap's -// fix hint advertises — the "already_up_to_date" early return must not short -// -circuit it (iss-62). -func TestInstallAdoptsIdentityPinInteractively(t *testing.T) { - setupHermetic(t) +// TestInstallRecordsTheIdentityAtATerminal guards that the advisory identity +// record can be made through a later `ahoy install` at a terminal, as the +// gap's fix hint advertises — the "already_up_to_date" early return must not +// short-circuit it — and that --yes never makes it (itd-2610240918547728, +// criterion 1). +func TestInstallRecordsTheIdentityAtATerminal(t *testing.T) { + home, _ := setupHermetic(t) t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) repo := t.TempDir() @@ -206,36 +207,35 @@ func TestInstallAdoptsIdentityPinInteractively(t *testing.T) { idMustGit(t, repo, "config", "user.name", "Alex Reppel") idMustGit(t, repo, "config", "user.email", "alex@example.com") - // First install (--yes) resolves the actionable gaps but deliberately leaves - // the advisory identity pin un-adopted. if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) } - if _, ok, _ := identity.LoadPin(repo); ok { - t.Fatal("precondition: --yes must not pin the current identity") + if _, _, ok, _ := identity.LoadRecord(home, ""); ok { + t.Fatal("precondition: --yes must not record the current identity") } det, err := Detect(repo) if err != nil { t.Fatal(err) } - if !hasGap(det.Gaps, "git_identity.unpinned") { - t.Fatalf("expected advisory git_identity.unpinned gap after --yes install: %+v", det.Gaps) + if !hasGap(det.Gaps, UnrecordedGapID) { + t.Fatalf("expected advisory %s gap after --yes install: %+v", UnrecordedGapID, det.Gaps) } if len(actionable(det.Gaps)) != 0 { t.Fatalf("precondition: repo should be otherwise clean, got %+v", actionable(det.Gaps)) } - // Interactive re-install with a confirming prompter must fall through the - // early return and adopt the pin rather than reporting already_up_to_date. - if _, err := Install(repo, InstallOptions{}, stubPrompter{confirm: true}); err != nil { + if _, err := Install(repo, InstallOptions{}, &recordingPrompter{confirm: true, terminal: true}); err != nil { t.Fatal(err) } - got, ok, err := identity.LoadPin(repo) + got, _, ok, err := identity.LoadRecord(home, "") if err != nil || !ok { - t.Fatalf("interactive install did not adopt the identity pin: ok=%v err=%v", ok, err) + t.Fatalf("an install at a terminal did not record the identity: ok=%v err=%v", ok, err) } if got.Name != "Alex Reppel" || got.Email != "alex@example.com" { - t.Fatalf("wrong pin written: %+v", got) + t.Fatalf("wrong record written: %+v", got) + } + if _, err := os.Stat(filepath.Join(repo, ".abcd", "config", "identity.json")); !os.IsNotExist(err) { + t.Fatalf("an identity was written into the repository: %v", err) } } diff --git a/internal/core/ahoy/artefact.go b/internal/core/ahoy/artefact.go index c95fda774f..5d95fc02a3 100644 --- a/internal/core/ahoy/artefact.go +++ b/internal/core/ahoy/artefact.go @@ -94,7 +94,7 @@ func (a *applyCtx) stepArtefact() { return } defer root.Close() - // Contained under an os.Root opened at the repository, as the identity pin + // Contained under an os.Root opened at the repository, as the declared production mode // is: a committed .abcd ancestor symlink is refused rather than followed. if err := fsutil.WriteFileAtomicInRoot(root, launch.ArtefactRelPath, data, 0o644); err != nil { a.refuse("could not write " + launch.ArtefactRelPath + ": " + errText(err)) diff --git a/internal/core/ahoy/category_approval.go b/internal/core/ahoy/category_approval.go index b7627adc9f..d8fa9bae0c 100644 --- a/internal/core/ahoy/category_approval.go +++ b/internal/core/ahoy/category_approval.go @@ -43,8 +43,8 @@ var categoryApprovalWords = map[GapCategory]approvalWords{ Dependency: {ask: "Offer to install the tools listed above?", yes: nextLabel, next: "Asks before installing each tool listed above, one question per tool."}, SafeAutocreate: {ask: "Create the files and folders listed above?", yes: writesLabel, writes: true}, - // The settings approval writes some listed changes on its own (the - // identity pin, the PATH entry) and asks for each value not yet chosen + // The settings approval writes some listed changes on its own (the PATH + // entry) and asks for each value not yet chosen // before saving any (stepConfigValues), so its yes says both. ConfigChange: {ask: "Change the settings listed above?", yes: writesLabel, next: "Writes the listed changes that need no answer and asks for each value not yet chosen; if one is left unanswered, no value is saved."}, diff --git a/internal/core/ahoy/category_approval_test.go b/internal/core/ahoy/category_approval_test.go index 8a39d47b9d..2ae7ac5401 100644 --- a/internal/core/ahoy/category_approval_test.go +++ b/internal/core/ahoy/category_approval_test.go @@ -2,11 +2,9 @@ package ahoy import ( "fmt" - "os" "strings" "testing" - "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/core/question" ) @@ -183,43 +181,14 @@ func TestTheSettingsApprovalSaysValuesAreAskedFirst(t *testing.T) { } // TestTheSettingsYesSaysItWritesWhatNeedsNoAnswer: the settings approval's -// yes writes some listed changes with no further question (the identity pin, -// the PATH entry), so its meaning must say it writes, not that nothing is -// written without an answer. Driven through an install whose only listed -// setting is the pin: the pin is written and no question about it follows -// the approval. +// yes writes some listed changes with no further question (the PATH entry), +// so its meaning must say it writes, not that nothing is written without an +// answer. (It was driven through the identity pin, which left the settings +// approval when each person's identity moved to their own machine.) func TestTheSettingsYesSaysItWritesWhatNeedsNoAnswer(t *testing.T) { - setupHermetic(t) - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) - repo := t.TempDir() - idMustGit(t, repo, "init") - idMustGit(t, repo, "config", "user.name", "Alex Reppel") - idMustGit(t, repo, "config", "user.email", "alex@example.com") - if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { - t.Fatal(err) - } - p := &recordingPrompter{confirm: true} - if _, err := Install(repo, InstallOptions{}, p); err != nil { - t.Fatal(err) - } - if _, ok, _ := identity.LoadPin(repo); !ok { - t.Fatal("precondition: approving the settings did not write the pin") - } - var settings string - for _, q := range p.asked { - switch c := askedCategory(q); { - case c == ConfigChange: - settings = q - case c == "" && setupConfirmID(q) == "git_identity.establish": - t.Fatalf("the pin was asked about after the approval: %q", q) - } - } - if settings == "" { - t.Fatalf("the settings approval was not asked: %q", p.asked) - } - yes := SetupConfirmQuestion(1, settings).Options[0].Meaning + q := categoryApprovalText(ConfigChange, []string{"the PATH entry"}) + yes := SetupConfirmQuestion(1, q).Options[0].Meaning if !strings.HasPrefix(yes, "Writes") || strings.Contains(yes, "not written") { - t.Fatalf("the settings yes means %q, but it wrote the pin with no further question", yes) + t.Fatalf("the settings yes means %q, but it writes what needs no answer", yes) } } diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index aa04f3ac5f..2830d7b1f4 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -14,6 +14,7 @@ import ( "github.com/intentdriven/abcd/internal/adapter/gitleaks" "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/core/launch" + "github.com/intentdriven/abcd/internal/core/provenance" "github.com/intentdriven/abcd/internal/core/tools" ) @@ -362,6 +363,7 @@ const ( UnsetGapID = "git_identity.unset" CommitterGapID = "git_identity.committer" ToolIdentityGapID = "git_identity.tool" + UncheckableGapID = "git_identity.uncheckable" ) // routineRunnerRecord is where establishing the human identity for an @@ -371,58 +373,71 @@ const routineRunnerRecord = "iss-2608210932052003" // establishHint is the fix every resolvable identity gap shares: install // proposes the human identity and asks, and nothing writes it unasked. -const establishHint = "run abcd ahoy install at a terminal: it proposes the pinned identity (else your global git identity) for this repository's user.name/user.email and writes it only if you confirm" +const establishHint = "run abcd ahoy install at a terminal: it proposes the identity you recorded on this machine (else your global git identity) for this repository's user.name/user.email and writes it only if you confirm" + +// changeRecordHint says how a person changes their own record. It names the +// record's file, which is theirs, and no one else's identity. +func changeRecordHint(ref identity.RecordRef) string { + return "if the record is wrong, edit " + ref.Display + ", or remove it and run abcd ahoy install at a terminal to be asked again" +} // detectGitIdentity compares the git author and committer identities a commit -// would use against the committed .abcd/config/identity.json pin (iss-62, -// itd-131). An author mismatch or unset identity is a required, resolvable gap; -// a committer that diverges is one too in a pinned repo and advisory in an -// un-pinned one; a machine identity in either role is required wherever it is -// found, because the human is the author of record whether or not the repo has -// pinned who that is. An un-pinned repo also gets an advisory gap (adopt the -// gate); a match yields nothing. +// would use against the person's OWN record, kept on their machine +// (itd-2610240918547728; iss-62 and itd-131 before it). Nothing a repository +// commits names who may commit to it, so no gap here ever names anyone but the +// person running it. An author mismatch or unset identity is a required, +// resolvable gap; a committer that diverges is one too when a record applies +// and advisory otherwise; a machine identity in either role is required +// wherever it is found, because the human is the author of record whether or +// not they have recorded who that is. No record yet is an advisory gap +// (record one); a repository that still stores the identity pin itd-131 +// committed gets the migration gap (detectLegacyPin). func detectGitIdentity(cwd string) []Gap { + gaps := detectLegacyPin(cwd) res, err := identity.Check(cwd) if err != nil { - // A present-but-unreadable pin is an adopted-but-broken gate (required); - // an error with no pin is a git/environment issue (advisory). - _, statErr := os.Stat(filepath.Join(cwd, identity.PinRelPath)) - pinPresent := statErr == nil - return []Gap{{ - ID: "git_identity.uncheckable", Category: ConfigChange, Scope: "repo", + // A record that is there and cannot be trusted or read is a gate that + // fails closed (required); any other error is a git/environment issue + // (advisory). + return append(gaps, Gap{ + ID: UncheckableGapID, Category: ConfigChange, Scope: "repo", Title: "git identity could not be checked", Detail: err.Error(), - FixHint: "fix the pin JSON in " + identity.PinRelPath + " (both name and email), or ensure git is available", - Required: pinPresent, + FixHint: "fix or remove your own identity record (" + identity.MachineRecordRef().Display + ", or the one for this repository under " + abcdhome.Display("identity/") + "), or ensure git is available", + Required: errors.Is(err, identity.ErrRecordRefused), Resolvable: false, - }} + }) } - var gaps []Gap switch res.Status { case identity.StatusMismatch: gaps = append(gaps, Gap{ ID: MismatchGapID, Category: ConfigChange, Scope: "repo", - Title: "git commit identity does not match the pin", + Title: "git commit identity is not the one you recorded", Detail: res.Reason, - FixHint: establishHint + "; or set user.name/user.email by hand (or update the pin if the identity changed)", + FixHint: establishHint + "; or set user.name/user.email by hand; " + changeRecordHint(res.RecordAt), Required: true, Resolvable: true, }) case identity.StatusUnset: gaps = append(gaps, Gap{ ID: UnsetGapID, Category: ConfigChange, Scope: "repo", - Title: "git author identity is not configured", - Detail: res.Reason, - FixHint: establishHint + "; or set git user.name/user.email to the pinned identity in " + identity.PinRelPath, - Required: true, + Title: "git author identity is not configured", + Detail: res.Reason, + FixHint: establishHint + "; or set git user.name/user.email to your own identity", + // Required once the person has recorded who they are. Before that, + // git itself refuses a commit with no identity, and a repository + // set up where none is configured yet (CI, a fresh container) is + // not left with required work it cannot do (criterion 5: the + // launcher supplies the identity). + Required: res.Recorded, Resolvable: true, }) - case identity.StatusNoPin: + case identity.StatusNoRecord: gaps = append(gaps, Gap{ - ID: OptionalPinGapID, Category: ConfigChange, Scope: "repo", - Title: "no git identity pin", - Detail: res.Reason, - FixHint: "ahoy install can pin the current git identity to " + identity.PinRelPath, + ID: UnrecordedGapID, Category: MachineIdentity, Scope: "machine", + Title: "you have not recorded who you commit as on this machine", + Detail: res.Reason + "; until you do, a commit is refused only when it is made under a machine's identity", + FixHint: "run abcd ahoy install at a terminal: it proposes your usual git identity and records it in " + identity.MachineRecordRef().Display + " only if you confirm", Required: false, Resolvable: true, }) @@ -433,7 +448,7 @@ func detectGitIdentity(cwd string) []Gap { Title: "git committer identity diverges", Detail: res.CommitterReason, FixHint: "unset any GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL override and committer.name/committer.email key; or " + establishHint, - Required: res.Status != identity.StatusNoPin, + Required: res.Recorded, Resolvable: true, }) } @@ -441,7 +456,7 @@ func detectGitIdentity(cwd string) []Gap { gaps = append(gaps, Gap{ ID: ToolIdentityGapID, Category: ConfigChange, Scope: "repo", Title: "commits would be made under a machine identity", - Detail: tool + " is a machine identity; the human is the author of record, and the attribution gate refuses a machine in these fields", + Detail: tool + " is a machine identity; the human is the author of record, and the commit check refuses a machine in these fields", FixHint: establishHint + ". An autonomous routine has no one to ask: whatever launches it sets the human identity before the first commit (" + routineRunnerRecord + "), and abcd does not write it unasked", Required: true, Resolvable: true, @@ -450,6 +465,59 @@ func detectGitIdentity(cwd string) []Gap { return gaps } +// detectLegacyPin reports a repository that still stores the identity pin +// itd-131 committed (.abcd/config/identity.json). On its owner's machine the +// gap offers the move; on anyone else's it says only that the stored identity +// is not theirs and is no longer read, and names no one. +func detectLegacyPin(cwd string) []Gap { + plan, found, err := identity.PlanMigration(cwd, userHome()) + if err != nil { + // A pin that is there and cannot be read (malformed, or refused by the + // guarded reader: a symlink, not a regular file, over the size cap) is + // the present-but-unreadable path the gate has always reported. + return []Gap{{ + ID: UncheckableGapID, Category: ConfigChange, Scope: "repo", + Title: "git identity could not be checked", + Detail: err.Error(), + FixHint: "repair or remove " + identity.LegacyPinRelPath + " (a regular file, not a symlink); abcd no longer reads it to check commits, only to move it to its owner's machine", + Required: false, + Resolvable: false, + }} + } + if !found { + return nil + } + if !plan.Owner { + return []Gap{{ + ID: LegacyPinGapID, Category: MachineIdentity, Scope: "repo", + Title: "this repository stores a commit identity that is not yours", + Detail: identity.LegacyPinRelPath + " stores the identity of whoever set the repository up. abcd no longer reads it: " + + "your commits are checked against your own record on your machine, and it is not copied there", + FixHint: "nothing to do; the person it names moves it out of the repository when they next run abcd ahoy install", + Required: false, + Resolvable: false, + }} + } + g := Gap{ + ID: LegacyPinGapID, Category: MachineIdentity, Scope: "repo", + Title: "this repository stores your commit identity", + Detail: identity.LegacyPinRelPath + " stores " + plan.Pin.Name + " <" + plan.Pin.Email + ">, which tells every other contributor to commit as you; " + + "abcd now keeps each person's identity on their own machine", + FixHint: "run abcd ahoy install at a terminal: it moves the identity to " + plan.Target.Display + " and removes " + identity.LegacyPinRelPath + " from the repository, only if you confirm", + Required: false, + Resolvable: true, + } + if plan.Pin.ProductionMode != "" { + g.FixHint += "; its production_mode is kept, in " + provenance.DeclaredModeRelPath + } + if plan.Blocker != "" { + g.Detail += "; " + plan.Blocker + g.FixHint = "settle what blocks the move, then run abcd ahoy install at a terminal" + g.Resolvable = false + } + return []Gap{g} +} + // toolRoles words which roles hold a machine identity, or "" when none does. func toolRoles(res identity.Result) string { who := func(e identity.Effective) string { return e.Name + " <" + e.Email + ">" } diff --git a/internal/core/ahoy/identity_establish.go b/internal/core/ahoy/identity_establish.go index c3d928d392..898b8ad964 100644 --- a/internal/core/ahoy/identity_establish.go +++ b/internal/core/ahoy/identity_establish.go @@ -4,6 +4,27 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/identity" + "github.com/intentdriven/abcd/internal/core/provenance" +) + +// MachineIdentity (itd-2610240918547728) covers the person's own commit +// identity record on their machine, and moving the identity pin a repository +// still stores to its owner's machine. Its gaps are advisory, never written +// under --yes, and never behind a category approval: each is asked as its own +// question, only at a terminal, because it records who a person is. +const MachineIdentity GapCategory = "machine-identity" + +// legacyPinQuestionPrefix opens the migration question, so the question's id +// (setupConfirmIDs) is matched on the words that ask it. +const legacyPinQuestionPrefix = "This repository stores your commit identity, " + +// The two gaps the person's own identity record raises. +const ( + // UnrecordedGapID: no record on this machine applies to the repository. + UnrecordedGapID = "git_identity.unrecorded" + // LegacyPinGapID: the repository still stores the identity pin itd-131 + // committed (.abcd/config/identity.json). + LegacyPinGapID = "git_identity.repo_pin" ) // TerminalPrompter is a Prompter that can say whether a person is answering at @@ -40,12 +61,101 @@ func atTerminal(p Prompter) bool { return ok && tp.AtTerminal() } +// stepIdentityMigrate moves the identity pin a repository still stores to its +// owner's machine and out of the repository (criterion 4 of +// itd-2610240918547728), keeping its production_mode in the repository's own +// committed file. It asks first, as its own question, and only at a +// terminal; under --yes or off a terminal it writes nothing, and +// optionalSkipped reports that. On anyone else's machine the gap is not +// resolvable and this step never runs: the pin is neither copied nor removed. +func (a *applyCtx) stepIdentityMigrate() { + if !a.has(LegacyPinGapID) || a.autoYes || !atTerminal(a.prompter) { + return + } + home := userHome() + plan, found, err := identity.PlanMigration(a.cwd, home) + if err != nil { + a.refuse("did not move the identity this repository stores: " + errText(err)) + return + } + if !found || !plan.Owner || plan.Blocker != "" { + return + } + who := plan.Pin.Name + " <" + plan.Pin.Email + ">" + q := legacyPinQuestionPrefix + who + ", in " + identity.LegacyPinRelPath + + ", which tells every other contributor to commit as you. Move it to your own machine (" + plan.Target.Display + + ") and remove it from the repository?" + if plan.Pin.ProductionMode != "" { + q += " (its production_mode stays committed, in " + provenance.DeclaredModeRelPath + ")" + } + if !a.prompter.Confirm(q) { + a.refuse("left " + identity.LegacyPinRelPath + " as it is: the move was declined. abcd no longer reads it to check commits; run abcd ahoy install at a terminal to be asked again") + return + } + res, err := identity.ApplyMigration(a.cwd, home, plan) + if res.ProductionModeMoved { + a.note(writeProductionMode, provenance.DeclaredModeRelPath) + } + if res.Recorded { + a.note(writeIdentityRecord, res.RecordAt.Display) + } + if err != nil { + a.refuse("did not finish moving the identity this repository stores: " + errText(err)) + return + } + if res.KeptDifferent { + a.refuse("kept the identity you had already recorded in " + res.RecordAt.Display + "; the one " + identity.LegacyPinRelPath + " stored was not copied over it") + } + if res.Removed { + a.note(writeIdentityPinRemoved, identity.LegacyPinRelPath) + } +} + +// stepIdentityRecord offers a person who has not recorded who they commit as +// on this machine their usual git identity, and records it in the +// machine-wide record on a yes (criterion 1 of itd-2610240918547728). It is +// its own question, asked only at a terminal. Under --yes or off a terminal +// it records nothing, and optionalSkipped reports that: a blanket approval or +// an unattended run never decides who a person is. +func (a *applyCtx) stepIdentityRecord() { + if !a.has(UnrecordedGapID) || a.autoYes || !atTerminal(a.prompter) { + return + } + const did = "did not record who you commit as on this machine" + // The move above may have just recorded one. + if res, err := identity.Check(a.cwd); err == nil && res.Recorded { + return + } + prop, ok, err := identity.ProposeRecord(a.cwd) + if err != nil { + a.refuse(did + ": your git identity could not be read: " + errText(err)) + return + } + if !ok { + a.refuse(did + ": git holds no human identity to propose; set git config --global user.name and user.email to your own and run abcd ahoy install again") + return + } + ref := identity.MachineRecordRef() + who := prop.Identity.Name + " <" + prop.Identity.Email + ">" + if !a.prompter.Confirm("Record " + who + ", " + prop.From + ", as who you commit as on this machine? (writes " + ref.Display + + "; a commit made under any other identity is then refused until you change it)") { + a.refuse(did + ": " + who + " was declined; run abcd ahoy install at a terminal to be asked again") + return + } + if err := identity.WriteRecord(userHome(), ref, identity.Record{Name: prop.Identity.Name, Email: prop.Identity.Email}); err != nil { + a.refuse(did + ": " + errText(err)) + return + } + a.note(writeIdentityRecord, ref.Display) +} + // establishGapIDs are the gaps a repo-local user.name/user.email can mend. var establishGapIDs = []string{MismatchGapID, UnsetGapID, CommitterGapID, ToolIdentityGapID} // stepGitIdentity is the itd-131 propose-and-confirm step. When the effective -// author or committer diverges, it proposes the human identity — the pin, else -// the global git identity, read from disk only — and writes repo-local +// author or committer diverges, it proposes the human identity — the person's +// own record, else their global git identity, read from disk only and never +// from the repository — and writes repo-local // user.name/user.email only when the person confirms that proposal at a // terminal. // @@ -71,7 +181,7 @@ func (a *applyCtx) stepGitIdentity() { return } if !atTerminal(a.prompter) { - a.refuse(did + ": there is no terminal to confirm at, so nothing was written (fail-closed); run abcd ahoy install at a terminal to be offered the pinned identity, or set user.name/user.email by hand. An autonomous routine's runner sets the human identity before its first commit (" + routineRunnerRecord + ")") + a.refuse(did + ": there is no terminal to confirm at, so nothing was written (fail-closed); run abcd ahoy install at a terminal to be offered your own identity, or set user.name/user.email by hand. An autonomous routine's runner sets the human identity before its first commit (" + routineRunnerRecord + ")") return } prop, ok, err := identity.Propose(a.cwd) @@ -80,7 +190,7 @@ func (a *applyCtx) stepGitIdentity() { return } if !ok { - a.refuse(did + ": there is no human identity to propose — neither a pin in " + identity.PinRelPath + " nor a global git user.name/user.email that is not a machine's; set them and run abcd ahoy install again") + a.refuse(did + ": there is no human identity to propose — neither a record of your own in " + identity.MachineRecordRef().Display + " nor a global git user.name/user.email that is not a machine's; set them and run abcd ahoy install again") return } over, err := identity.Outranking(a.cwd, prop.Identity) diff --git a/internal/core/ahoy/identity_establish_test.go b/internal/core/ahoy/identity_establish_test.go index c1d303f35e..0ba40884be 100644 --- a/internal/core/ahoy/identity_establish_test.go +++ b/internal/core/ahoy/identity_establish_test.go @@ -136,8 +136,8 @@ func TestDetectGitIdentity_ForgeCommitterIsNotATool(t *testing.T) { } // TestStepGitIdentity_ProposesPinAndWritesOnlyOnConfirm is criterion 1: a -// repo-local override that differs from the pin is met with the pinned -// identity as the proposal, and repo-local config is written only after the +// repo-local override that differs from the person's own record is met with +// that record as the proposal, and repo-local config is written only after the // person confirms — at the moment of asking, nothing has changed yet. func TestStepGitIdentity_ProposesPinAndWritesOnlyOnConfirm(t *testing.T) { clearIdentityEnv(t) @@ -261,17 +261,17 @@ func TestStepGitIdentity_EnvOverrideIsNotRewritten(t *testing.T) { } } -// TestStepIdentityPin_RefusesAToolIdentity: adopting the gate must never -// canonicalise the harness's own identity as the repo's human. -func TestStepIdentityPin_RefusesAToolIdentity(t *testing.T) { +// TestStepIdentityRecord_RefusesAToolIdentity: a record must never name the +// harness's own identity as the person. +func TestStepIdentityRecord_RefusesAToolIdentity(t *testing.T) { clearIdentityEnv(t) dir := idGitRepo(t, "Claude", "noreply@anthropic.com") - a := establishCtx(dir, &identityPrompter{terminal: true, answer: true}, OptionalPinGapID) - a.stepIdentityPin() - if _, ok, _ := identity.LoadPin(dir); ok { - t.Fatal("a tool identity was pinned as the repo's human") + a := establishCtx(dir, &identityPrompter{terminal: true, answer: true}, UnrecordedGapID) + a.stepIdentityRecord() + if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + t.Fatal("a tool identity was recorded as the person") } if len(a.notes) == 0 { - t.Fatal("the refusal to pin a tool identity was not said") + t.Fatal("the refusal to record a tool identity was not said") } } diff --git a/internal/core/ahoy/identity_gap_test.go b/internal/core/ahoy/identity_gap_test.go index d8955b756b..263f93d78c 100644 --- a/internal/core/ahoy/identity_gap_test.go +++ b/internal/core/ahoy/identity_gap_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/intentdriven/abcd/internal/core/identity" @@ -34,7 +35,24 @@ func idMustGit(t *testing.T, dir string, args ...string) { } } -func idWritePin(t *testing.T, root, body string) { +// idWritePin records body as the person's own machine-wide identity record, +// under the test's HOME (gittest.Env pins it to a per-test temp dir). Nothing +// is written into the repository: it names no one (itd-2610240918547728). +func idWritePin(t *testing.T, _ string, body string) { + t.Helper() + gittest.Env(t) + p := filepath.Join(os.Getenv("HOME"), filepath.FromSlash(identity.MachineRecordRef().Rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// idCommitPin writes body as the identity pin a repository committed before +// itd-2610240918547728. +func idCommitPin(t *testing.T, root, body string) { t.Helper() if err := os.MkdirAll(filepath.Join(root, ".abcd", "config"), 0o755); err != nil { t.Fatal(err) @@ -51,6 +69,10 @@ func TestDetectGitIdentity_Mismatch(t *testing.T) { if len(gaps) != 1 || gaps[0].ID != "git_identity.mismatch" || !gaps[0].Required { t.Fatalf("want one required git_identity.mismatch gap, got %+v", gaps) } + // Criterion 3: the refusal names the person's own record and how to change it. + if !strings.Contains(gaps[0].FixHint, identity.MachineRecordRef().Display) { + t.Fatalf("the mismatch gap does not say how to change the record: %+v", gaps[0]) + } } func TestDetectGitIdentity_Match(t *testing.T) { @@ -61,57 +83,151 @@ func TestDetectGitIdentity_Match(t *testing.T) { } } -func TestDetectGitIdentity_Unpinned(t *testing.T) { +func TestDetectGitIdentity_Unrecorded(t *testing.T) { dir := idGitRepo(t, "Alex Reppel", "alex@example.com") gaps := detectGitIdentity(dir) - if len(gaps) != 1 || gaps[0].ID != "git_identity.unpinned" || gaps[0].Required { - t.Fatalf("want one advisory git_identity.unpinned gap, got %+v", gaps) + if len(gaps) != 1 || gaps[0].ID != UnrecordedGapID || gaps[0].Required || !gaps[0].Resolvable || gaps[0].Category != MachineIdentity { + t.Fatalf("want one advisory, resolvable %s gap, got %+v", UnrecordedGapID, gaps) } } -func TestStepIdentityPin_WritesFromCurrentIdentity(t *testing.T) { +// Criterion 2: a contributor in a repository that still commits the +// maintainer's identity is shown nothing that names the maintainer, is not +// offered the move, and is checked against their own record. +func TestDetectGitIdentity_ContributorIsNeverShownTheMaintainer(t *testing.T) { + dir := idGitRepo(t, "Second Contributor", "second@example.com") + idCommitPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + gaps := detectGitIdentity(dir) + for _, g := range gaps { + if strings.Contains(g.Title+g.Detail+g.FixHint, "Maintainer") || strings.Contains(g.Title+g.Detail+g.FixHint, "maintainer@") { + t.Fatalf("a gap names the maintainer to a contributor: %+v", g) + } + if g.Required { + t.Fatalf("a contributor's own human identity raised required work: %+v", g) + } + } + pin := gapByID(gaps, LegacyPinGapID) + if pin == nil || pin.Resolvable { + t.Fatalf("want a non-resolvable %s note on a contributor's machine, got %+v", LegacyPinGapID, gaps) + } + if gapByID(gaps, UnrecordedGapID) == nil { + t.Fatalf("the contributor is not offered a record of their own: %+v", gaps) + } +} + +func TestDetectGitIdentity_OwnerIsOfferedTheMove(t *testing.T) { + dir := idGitRepo(t, "A Maintainer", "maintainer@example.com") + idCommitPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + pin := gapByID(detectGitIdentity(dir), LegacyPinGapID) + if pin == nil || !pin.Resolvable || pin.Required { + t.Fatalf("want an advisory, resolvable %s gap on the owner's machine, got %+v", LegacyPinGapID, pin) + } +} + +// Criterion 1: at a terminal, a person with no record is offered their usual +// git identity as its own question, and it is recorded on their machine only +// on a yes. +func TestStepIdentityRecord_RecordsOnYesAtATerminal(t *testing.T) { dir := idGitRepo(t, "Alex Reppel", "alex@example.com") - a := &applyCtx{ - cwd: dir, - approved: map[GapCategory]bool{ConfigChange: true}, - gapPresent: map[string]bool{"git_identity.unpinned": true}, + p := &identityPrompter{terminal: true, answer: true} + a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{}, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: p} + a.stepIdentityRecord() + if len(p.asked) != 1 || !strings.Contains(p.asked[0], "Alex Reppel <alex@example.com>") { + t.Fatalf("want one question proposing the usual identity, got %q", p.asked) } - a.stepIdentityPin() - got, ok, err := identity.LoadPin(dir) - if err != nil || !ok { - t.Fatalf("pin not written: ok=%v err=%v", ok, err) + got, _, ok, err := identity.LoadRecord(os.Getenv("HOME"), "") + if err != nil || !ok || got.Name != "Alex Reppel" || got.Email != "alex@example.com" { + t.Fatalf("record not written: %+v ok=%v err=%v", got, ok, err) } - if got.Name != "Alex Reppel" || got.Email != "alex@example.com" { - t.Fatalf("wrong pin written: %+v", got) + if len(a.writes) != 1 || a.writes[0] != identity.MachineRecordRef().Display { + t.Fatalf("expected one noted write of the record, got %v", a.writes) } - if len(a.writes) != 1 || a.writes[0] != identity.PinRelPath { - t.Fatalf("expected one noted write of the pin, got %v", a.writes) +} + +func TestStepIdentityRecord_WritesNothingOffATerminalUnderYesOrOnNo(t *testing.T) { + for name, a := range map[string]func(dir string, p *identityPrompter) *applyCtx{ + "off a terminal": func(dir string, p *identityPrompter) *applyCtx { + p.terminal = false + return &applyCtx{cwd: dir, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: p} + }, + "under --yes": func(dir string, p *identityPrompter) *applyCtx { + return &applyCtx{cwd: dir, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: p, autoYes: true} + }, + "declined": func(dir string, p *identityPrompter) *applyCtx { + p.answer = false + return &applyCtx{cwd: dir, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: p} + }, + } { + t.Run(name, func(t *testing.T) { + dir := idGitRepo(t, "Test User", "test@example.com") + p := &identityPrompter{terminal: true, answer: true} + ctx := a(dir, p) + ctx.stepIdentityRecord() + if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + t.Fatal("a record was written without a yes at a terminal") + } + if name != "declined" && len(p.asked) != 0 { + t.Fatalf("asked %q", p.asked) + } + }) } } -func TestStepIdentityPin_SkipsUnderYes(t *testing.T) { - dir := idGitRepo(t, "Test User", "test@example.com") - a := &applyCtx{ - cwd: dir, - approved: map[GapCategory]bool{ConfigChange: true}, - gapPresent: map[string]bool{"git_identity.unpinned": true}, - autoYes: true, +// Criterion 4 at the install: on the owner's machine, at a terminal, a yes +// moves the pin to their machine and out of the repository, and says so. +func TestStepIdentityMigrate_MovesThePinOnYes(t *testing.T) { + dir := idGitRepo(t, "A Maintainer", "maintainer@example.com") + idCommitPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com","production_mode":"scribe-transcribed"}`) + p := &identityPrompter{terminal: true, answer: true} + a := &applyCtx{cwd: dir, gapPresent: map[string]bool{LegacyPinGapID: true}, prompter: p} + a.stepIdentityMigrate() + if len(p.asked) != 1 { + t.Fatalf("want the move asked once, got %q", p.asked) + } + if _, err := os.Stat(filepath.Join(dir, identity.LegacyPinRelPath)); !os.IsNotExist(err) { + t.Fatalf("the pin is still in the repository: %v", err) + } + if res, err := identity.Check(dir); err != nil || res.Status != identity.StatusOK { + t.Fatalf("after the move the owner's record does not decide: %v %v", res.Status, err) + } + if len(a.writes) != 3 { + t.Fatalf("want the record, the production mode and the removal said, got %v", a.writes) } - a.stepIdentityPin() - if _, ok, _ := identity.LoadPin(dir); ok { - t.Fatal("--yes must not silently pin the current (possibly sandbox) identity") +} + +func TestStepIdentityMigrate_OffATerminalMovesNothing(t *testing.T) { + dir := idGitRepo(t, "A Maintainer", "maintainer@example.com") + idCommitPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + p := &identityPrompter{terminal: false, answer: true} + a := &applyCtx{cwd: dir, gapPresent: map[string]bool{LegacyPinGapID: true}, prompter: p} + a.stepIdentityMigrate() + if len(p.asked) != 0 { + t.Fatalf("asked off a terminal: %q", p.asked) + } + if _, err := os.Stat(filepath.Join(dir, identity.LegacyPinRelPath)); err != nil { + t.Fatalf("the pin was moved off a terminal: %v", err) } } -func TestStepIdentityPin_NeverAutoResolvesMismatch(t *testing.T) { - dir := idGitRepo(t, "Test User", "test@example.com") - a := &applyCtx{ - cwd: dir, - approved: map[GapCategory]bool{ConfigChange: true}, - gapPresent: map[string]bool{"git_identity.mismatch": true}, - } - a.stepIdentityPin() - if _, ok, _ := identity.LoadPin(dir); ok { - t.Fatal("a mismatch must never auto-write a pin") +// A committed pin the guarded reader refuses (here a symlink to a pin inside +// the working tree) is reported on the present-but-unreadable path, +// git_identity.uncheckable, and nothing is read through it. +func TestDetectGitIdentity_SymlinkedPinIsUncheckable(t *testing.T) { + dir := idGitRepo(t, "A Maintainer", "maintainer@example.com") + if err := os.WriteFile(filepath.Join(dir, "elsewhere.json"), []byte(`{"name":"A Maintainer","email":"maintainer@example.com"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "..", "elsewhere.json"), filepath.Join(dir, ".abcd", "config", "identity.json")); err != nil { + t.Fatal(err) + } + gaps := detectGitIdentity(dir) + if g := gapByID(gaps, UncheckableGapID); g == nil || g.Resolvable { + t.Fatalf("want a non-resolvable %s gap for a symlinked pin, got %+v", UncheckableGapID, gaps) + } + if gapByID(gaps, LegacyPinGapID) != nil { + t.Fatalf("a symlinked pin was read and offered for the move: %+v", gaps) } } diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index 1239ba6a5a..11800795be 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -50,7 +50,9 @@ const ( writeDrainRule writeKind = "drain-rule" writeToolFileRetired writeKind = "conventions-file-retired" writeRules writeKind = "rules" - writeIdentityPin writeKind = "identity-pin" + writeIdentityRecord writeKind = "identity-record" + writeIdentityPinRemoved writeKind = "identity-pin-removed" + writeProductionMode writeKind = "production-mode" writeGitIdentity writeKind = "git-identity" writeArtefactKind writeKind = "artefact-kind" writeReadmeParts writeKind = "readme-parts" @@ -63,7 +65,7 @@ const ( var allWriteKinds = []writeKind{ writeSettings, writeSetupRecord, writeGitignore, writeLocalTier, writeRecordTiers, writeDevelopmentReadme, writeDecisionsFolder, writeNameGuard, writePrivateNames, writeDocsCheck, writeAttributionHook, writeOutboundHook, writeOutboundForge, writeOutboundBinary, writeOutboundShim, writeOutboundChain, writeRules, writeConventionsBlock, - writeConventionsBlockRemoved, writeReadmeParts, writeReadmeCreated, writeReadmePartsRemoved, writeToolFileRetired, writeGitIdentity, writeIdentityPin, writeArtefactKind, writeCommandEntry, writeSessionStore, + writeConventionsBlockRemoved, writeReadmeParts, writeReadmeCreated, writeReadmePartsRemoved, writeToolFileRetired, writeGitIdentity, writeIdentityPinRemoved, writeProductionMode, writeArtefactKind, writeCommandEntry, writeSessionStore, writeIdentityRecord, writeStatusLine, writeStatusLineBackup, writeRouting, writeDrainRule, } @@ -189,10 +191,20 @@ var writeKindHelp = map[writeKind]SummaryItem{ Why: "Commits here were about to be made under a different identity, such as a leftover test account or an agent's, and the human is the author of record.", Action: "Nothing. Your global git settings are unchanged; to undo it, run git config --local --unset user.name and git config --local --unset user.email.", }, - writeIdentityPin: { - What: "Recorded the git name and email that commit to this repository.", - Why: "abcd can then warn when a commit is about to be made under a different identity, such as an agent's.", - Action: "Nothing, unless the recorded name or email is wrong; then correct it in .abcd/config/identity.json, because running abcd ahoy install again leaves a recorded name and email as they are.", + writeIdentityRecord: { + What: "Recorded on this machine the git name and email you commit as, in abcd's folder in your home directory.", + Why: "abcd then refuses a commit made under a different identity, such as an agent's or a mistyped email, and it names only you: nothing in the repository says who may commit to it.", + Action: "Nothing, unless the recorded name or email is wrong; then edit the file named above, or remove it and run abcd ahoy install again at a terminal to be asked.", + }, + writeIdentityPinRemoved: { + What: "Removed .abcd/config/identity.json, the commit identity this repository stored, after recording it on your machine.", + Why: "A committed identity told every other contributor to commit as you; each person's identity now lives on their own machine.", + Action: "Commit the removal; your own record stays on your machine, and the repository no longer names who may commit to it.", + }, + writeProductionMode: { + What: "Kept the repository's production mode, which .abcd/config/identity.json declared, in .abcd/config/provenance.json.", + Why: "It is the repository's own setting, how the text of the records it mints was produced, so it stays committed when the identity leaves.", + Action: "Commit .abcd/config/provenance.json with the removal; records minted here keep the production mode it declares.", }, writeArtefactKind: { What: "Recorded what this repository releases, a plugin, a binary or an application, in .abcd/config/artefact.json.", @@ -293,10 +305,15 @@ var declinedCategoryHelp = map[GapCategory]SummaryItem{ // optionalSkippedHelp explains each optional step an unattended run left alone. var optionalSkippedHelp = map[string]SummaryItem{ - OptionalPinGapID: { - What: "Recording who commits to this repository was left for you to confirm.", - Why: "It would record whatever git name and email happen to be set, which in an unattended run may be an agent's.", - Action: "Check that git config user.name and git config user.email give your own name and email, then run abcd ahoy install without --yes and answer y to the config-change question; abcd records those two values.", + UnrecordedGapID: { + What: "Recording who you commit as on this machine was left for you to confirm.", + Why: "It records a person's own identity, which only that person answers, at a terminal; an unattended run may be running as an agent.", + Action: "Run abcd ahoy install at a terminal, without --yes, and answer the identity question; abcd proposes your usual git identity and records it only on your yes.", + }, + LegacyPinGapID: { + What: "Moving the commit identity this repository stores to your machine was left for you to confirm.", + Why: "It writes to your home directory and removes a committed file, so only your own answer at a terminal does it.", + Action: "Run abcd ahoy install at a terminal, without --yes, and answer the question about .abcd/config/identity.json.", }, StatusLineOfferGapID: { What: "abcd's status line was not set up.", diff --git a/internal/core/ahoy/install_summary_test.go b/internal/core/ahoy/install_summary_test.go index 56bcaa13ac..e8d0380d00 100644 --- a/internal/core/ahoy/install_summary_test.go +++ b/internal/core/ahoy/install_summary_test.go @@ -131,7 +131,7 @@ func TestEveryWriteKindIsExplained(t *testing.T) { } assertPlainItem(t, it) } - for _, id := range []string{OptionalPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID} { + for _, id := range []string{UnrecordedGapID, LegacyPinGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID} { it, ok := optionalSkippedHelp[id] if !ok { t.Errorf("optional step %q has no explanation", id) diff --git a/internal/core/ahoy/install_summary_truth_test.go b/internal/core/ahoy/install_summary_truth_test.go index 9b9b796048..978cfd2b6a 100644 --- a/internal/core/ahoy/install_summary_truth_test.go +++ b/internal/core/ahoy/install_summary_truth_test.go @@ -1,53 +1,48 @@ package ahoy import ( + "os" "strings" "testing" "github.com/intentdriven/abcd/internal/core/identity" ) -// TestIdentityPinActionNamesWhatCorrectsAWrongPin is iss-2609260057117878's -// first sentence: the summary told a person whose recorded name or email is -// wrong to run abcd ahoy install again, but the pin step writes only while no -// pin exists, so a re-run leaves a wrong pin exactly as it is. The behaviour is -// proved here first, and then the item is held to naming the file that does -// correct it. -func TestIdentityPinActionNamesWhatCorrectsAWrongPin(t *testing.T) { +// TestIdentityRecordActionSaysHowToCorrectIt is iss-2609260057117878's +// first sentence, carried to the person's own record: a re-run never rewrites +// a record that exists, so the summary item must say how a wrong one is +// corrected (edit it, or remove it and be asked again). +func TestIdentityRecordActionSaysHowToCorrectIt(t *testing.T) { dir := idGitRepo(t, "Right Person", "right@example.com") idWritePin(t, dir, `{"name":"Wrong Person","email":"wrong@example.com"}`) gapPresent := map[string]bool{} for _, g := range detectGitIdentity(dir) { gapPresent[g.ID] = true } - a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{ConfigChange: true}, gapPresent: gapPresent} - a.stepIdentityPin() - pin, ok, err := identity.LoadPin(dir) - if err != nil || !ok || pin.Name != "Wrong Person" { - t.Fatalf("precondition: a re-run leaves a recorded pin as it is; got %+v ok=%v err=%v", pin, ok, err) + p := &identityPrompter{terminal: true, answer: true} + a := &applyCtx{cwd: dir, gapPresent: gapPresent, prompter: p} + a.stepIdentityRecord() + rec, _, ok, err := identity.LoadRecord(os.Getenv("HOME"), "") + if err != nil || !ok || rec.Name != "Wrong Person" || len(p.asked) != 0 { + t.Fatalf("precondition: a re-run leaves a record as it is; got %+v ok=%v err=%v asked=%q", rec, ok, err, p.asked) } - - action := writeKindHelp[writeIdentityPin].Action - if !strings.Contains(action, identity.PinRelPath) { - t.Errorf("the identity-pin item must name the file that corrects a wrong pin (%s), since a re-run does not: %q", identity.PinRelPath, action) + action := writeKindHelp[writeIdentityRecord].Action + for _, want := range []string{"edit", "remove"} { + if !strings.Contains(action, want) { + t.Errorf("the identity-record item does not say %q: %q", want, action) + } } } -// TestSkippedPinActionNamesWhatThePersonChecks is the second sentence: the -// item told the person to answer y "if the name and email shown are yours", -// but the install shows no name or email; the pin rides on the question about -// the category it belongs to. The item must say where the values come from and -// name the question that is actually asked. -func TestSkippedPinActionNamesWhatThePersonChecks(t *testing.T) { - action := optionalSkippedHelp[OptionalPinGapID].Action - for _, want := range []string{"user.name", "user.email", string(ConfigChange)} { +// TestSkippedRecordActionNamesTheQuestion is the second sentence: the item +// must name the question that is actually asked, and where. +func TestSkippedRecordActionNamesTheQuestion(t *testing.T) { + action := optionalSkippedHelp[UnrecordedGapID].Action + for _, want := range []string{"terminal", "identity question", "without --yes"} { if !strings.Contains(action, want) { - t.Errorf("the skipped-pin item does not name %q: %q", want, action) + t.Errorf("the skipped-record item does not name %q: %q", want, action) } } - if strings.Contains(action, "shown") { - t.Errorf("the skipped-pin item promises values the install never shows: %q", action) - } } // TestVisibilityMeaningNamesEveryFencedPath is the third: each visibility's diff --git a/internal/core/ahoy/setup_question.go b/internal/core/ahoy/setup_question.go index 5d3a81f98b..62a28166a2 100644 --- a/internal/core/ahoy/setup_question.go +++ b/internal/core/ahoy/setup_question.go @@ -140,6 +140,10 @@ var setupConfirmIDs = []struct { return strings.HasPrefix(s, "Re-founded from ") && strings.HasSuffix(s, "? Link lineage?") }}, {"git_identity.establish", func(s string) bool { return strings.HasPrefix(s, "Commit to this repository as ") }}, + {UnrecordedGapID, func(s string) bool { + return strings.HasPrefix(s, "Record ") && strings.Contains(s, " as who you commit as on this machine? ") + }}, + {LegacyPinGapID, func(s string) bool { return strings.HasPrefix(s, legacyPinQuestionPrefix) }}, {"remote.apply", func(s string) bool { return strings.HasPrefix(s, "Change GitHub settings on ") }}, } diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go index db0733e310..71c061bde4 100644 --- a/internal/core/ahoy/swallowed_writes_test.go +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -27,12 +27,11 @@ func notesCarryAll(notes []string, want ...string) bool { return false } -// TestIdentityPinFailureIsNoted is iss-2609260057127611's first write: the pin -// step dropped a failed identity.WritePin, and an unset git identity, without a -// word, so a run that recorded no identity said nothing about it. -func TestIdentityPinFailureIsNoted(t *testing.T) { +// TestIdentityRecordFailureIsNoted is iss-2609260057127611's first write, +// carried to the person's own record: a run that records no identity says so. +func TestIdentityRecordFailureIsNoted(t *testing.T) { for name, gitName := range map[string]string{ - "write refused": `Quote "Person"`, // WritePin refuses a double-quote + "write refused": `Quote "Person"`, // WriteRecord refuses a double-quote "identity unset": "", } { t.Run(name, func(t *testing.T) { @@ -41,12 +40,12 @@ func TestIdentityPinFailureIsNoted(t *testing.T) { email = "" } dir := idGitRepo(t, gitName, email) - a := &applyCtx{cwd: dir, approved: map[GapCategory]bool{ConfigChange: true}, gapPresent: map[string]bool{OptionalPinGapID: true}} - a.stepIdentityPin() - if _, ok, _ := identity.LoadPin(dir); ok { - t.Fatal("precondition: no pin is written") + a := &applyCtx{cwd: dir, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: &identityPrompter{terminal: true, answer: true}} + a.stepIdentityRecord() + if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + t.Fatal("precondition: no record is written") } - if !notesCarryAll(a.notes, identity.PinRelPath) { + if !notesCarryAll(a.notes, "did not record who you commit as") { t.Errorf("no note says the identity was not recorded; notes: %v", a.notes) } }) diff --git a/internal/core/ahoy/symlink_escape_test.go b/internal/core/ahoy/symlink_escape_test.go index 1e17209da3..5714e73a9d 100644 --- a/internal/core/ahoy/symlink_escape_test.go +++ b/internal/core/ahoy/symlink_escape_test.go @@ -4,8 +4,6 @@ import ( "os" "path/filepath" "testing" - - "github.com/intentdriven/abcd/internal/core/identity" ) // TestInstallRefusesAbcdDirSymlinkEscape is the GHSA-xrf8-4432-gw2f regression: @@ -72,8 +70,8 @@ func TestInstallRefusesAbcdDirSymlinkEscape(t *testing.T) { t.Errorf("Install status = %q, want %q (a non-real .abcd must gate writes)", res.Status, "refused") } - // And no identity pin may have been written through the symlink either. - if _, ok, _ := identity.LoadPin(repo); ok { + // And no identity may have been written through the symlink either. + if _, err := os.Lstat(filepath.Join(repo, ".abcd", "config", "identity.json")); err == nil { t.Errorf("an identity pin was written despite the .abcd symlink hazard") } } diff --git a/internal/core/ahoy/waiting.go b/internal/core/ahoy/waiting.go index f60f4be647..3f3527c1fb 100644 --- a/internal/core/ahoy/waiting.go +++ b/internal/core/ahoy/waiting.go @@ -169,6 +169,12 @@ func armedQuestions(cwd string, det DetectionResult) []WaitingItem { if _, ok := gaps["banlist.public_family_missing"]; ok && publicPathIsWritable(cwd) { add(emDashPromptKey, "the house-style question: an em dash in a list item") } + // The person's own identity questions, in the order setup asks them; a + // pin stored for someone else is reported, never asked. + if g, ok := gaps[LegacyPinGapID]; ok && g.Resolvable { + add(LegacyPinGapID, g.Title) + } + byGap(UnrecordedGapID, UnrecordedGapID) if slices.ContainsFunc(establishGapIDs, func(id string) bool { _, ok := gaps[id]; return ok }) { add("git_identity.establish", "who commits to this repository") } diff --git a/internal/core/identity/committer_test.go b/internal/core/identity/committer_test.go index a928297797..66f4ed343d 100644 --- a/internal/core/identity/committer_test.go +++ b/internal/core/identity/committer_test.go @@ -109,8 +109,8 @@ func TestCheck_AuthorNotCommitterUnpinned(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != StatusNoPin { - t.Fatalf("want StatusNoPin, got %v", res.Status) + if res.Status != StatusNoRecord { + t.Fatalf("want StatusNoRecord, got %v", res.Status) } if !res.CommitterDiverges { t.Fatalf("author≠committer was not detected: %+v", res) @@ -173,6 +173,11 @@ func TestCheck_FlagsAToolIdentity(t *testing.T) { if res.AuthorIsTool || !res.CommitterIsTool { t.Fatalf("want only the committer flagged as a tool identity, got author=%v committer=%v", res.AuthorIsTool, res.CommitterIsTool) } + // Criterion 5: with no record on a fresh machine, a commit is never made + // as the tool. + if !res.Blocks() { + t.Fatal("a machine identity in the committer role must block, record or not") + } } // TestIsToolIdentity pins the predicate to the attribution gate's reading, diff --git a/internal/core/identity/establish.go b/internal/core/identity/establish.go index 91512cb8ec..45e5049359 100644 --- a/internal/core/identity/establish.go +++ b/internal/core/identity/establish.go @@ -17,20 +17,57 @@ type Proposal struct { From string } -// Propose returns the identity to offer: the committed pin, else the global git -// identity (user.name/user.email in the caller's global config). The chain is -// disk-only by decision (itd-131, adr-38): no network lookup in an implicit -// path. A machine identity is never proposed. ok is false when neither source -// holds a complete human identity. +// Propose returns the identity to offer when the effective one diverges: the +// person's own record on this machine, else their global git identity +// (user.name/user.email in the caller's global config). The chain is disk-only +// by decision (itd-131, adr-38): no network lookup in an implicit path. A +// machine identity is never proposed, and nothing a repository commits is ever +// read as the proposal (itd-2610240918547728): a contributor is never offered +// anyone's identity but their own. ok is false when neither source holds a +// complete human identity. func Propose(root string) (Proposal, bool, error) { - pin, pinned, err := LoadPin(root) + return ProposeWithHome(root, UserHome()) +} + +// ProposeWithHome is Propose for the person whose home directory is home. +func ProposeWithHome(root, home string) (Proposal, bool, error) { + rootSHA, err := gitutil.RootCommit(root) + if err != nil { + return Proposal{}, false, err + } + rec, ref, recorded, err := LoadRecord(home, rootSHA) if err != nil { return Proposal{}, false, err } - if pinned { - p := Proposal{Identity: Effective{Name: pin.Name, Email: pin.Email}, From: "the identity pinned in " + PinRelPath} - return p, !IsToolIdentity(RoleAuthor, pin.Name, pin.Email), nil + if recorded { + p := Proposal{Identity: Effective{Name: rec.Name, Email: rec.Email}, From: "the identity you recorded in " + ref.Display} + return p, !IsToolIdentity(RoleAuthor, rec.Name, rec.Email), nil + } + return proposeGlobal(root) +} + +// ProposeRecord returns the identity to offer a person who has not recorded +// who they commit as on this machine: their usual git identity, which is the +// global user.name/user.email, else the identity git resolves in this +// repository when that is a complete human one. A machine identity is never +// proposed. ok is false when no human identity can be read from disk. +func ProposeRecord(root string) (Proposal, bool, error) { + p, ok, err := proposeGlobal(root) + if err != nil || ok { + return p, ok, err } + eff, err := EffectiveIdentity(root) + if err != nil { + return Proposal{}, false, err + } + if eff.Name == "" || eff.Email == "" || IsToolIdentity(RoleAuthor, eff.Name, eff.Email) { + return Proposal{}, false, nil + } + return Proposal{Identity: eff, From: "the git identity this repository commits under"}, true, nil +} + +// proposeGlobal is the global git identity, when it is a complete human one. +func proposeGlobal(root string) (Proposal, bool, error) { name, err := gitConfigScope(root, "--global", "user.name") if err != nil { return Proposal{}, false, err diff --git a/internal/core/identity/hook_test.go b/internal/core/identity/hook_test.go index 3ec33c0aa4..4c43afb755 100644 --- a/internal/core/identity/hook_test.go +++ b/internal/core/identity/hook_test.go @@ -42,103 +42,264 @@ func hookGit(t *testing.T, dir string, env []string, args ...string) error { return err } -// TestPreCommitHook_IdentityGate exercises the committed shell hook end to end, -// pinning down the fail-closed contract: a pin the shell cannot read must BLOCK, -// never fall through (the F1 regression this test guards). +// hookRepo builds a repository with user.* set to name/email, the committed +// pre-commit hook installed, the machine-identity list it reads in the working +// tree (the copied hook has no checkout of its own beside it), and one file +// staged. +func hookRepo(t *testing.T, hook string, env []string, name, email string) string { + t.Helper() + dir := t.TempDir() + hookGit(t, dir, env, "init") + if name != "" { + hookGit(t, dir, env, "config", "user.name", name) + } + if email != "" { + hookGit(t, dir, env, "config", "user.email", email) + } + list, err := os.ReadFile(toolIdentitiesFile) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "internal", "core", "identity"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "internal", "core", "identity", toolIdentitiesFile), list, 0o644); err != nil { + t.Fatal(err) + } + src, err := os.ReadFile(hook) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".git", "hooks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git", "hooks", "pre-commit"), src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + hookGit(t, dir, env, "add", "-A") + return dir +} + +// commitThroughHook runs `git commit` with env and reports whether the hook +// refused it, and what it said. +func commitThroughHook(t *testing.T, dir string, env []string, args ...string) (bool, string) { + t.Helper() + cmd := exec.Command("git", append(append([]string{"-C", dir}, args...), "commit", "-m", "t")...) + cmd.Env = env + out, err := cmd.CombinedOutput() + return err != nil, string(out) +} + +// TestPreCommitHook_IdentityGate exercises the committed shell hook end to end +// against the person's own record on their machine (itd-2610240918547728), +// pinning down the fail-closed contract: a record the shell cannot read or +// trust must BLOCK, never fall through. func TestPreCommitHook_IdentityGate(t *testing.T) { hook := locateHook(t) - // Isolate from the machine's git global/system config and ~/.abcd.noindex corpus so - // the hook only sees the temp repo (gittest.Env pins HOME/XDG to a temp dir - // and neutralises global/system config). - env := hookEnv(t) - cases := []struct { name string - pin string // "" => no identity.json + record string // "" => no record on this machine + pin string // a committed .abcd/config/identity.json, now ignored gitName string gitEmail string wantBlock bool + wantSay string }{ - {"no pin passes", "", "Whoever", "who@ever.com", false}, - {"match passes", `{"name":"Alex","email":"a@b.com"}`, "Alex", "a@b.com", false}, - {"mismatch blocks", `{"name":"Alex","email":"a@b.com"}`, "Test User", "test@example.com", true}, - {"pretty-printed match passes", "{\n \"name\": \"Alex\",\n \"email\": \"a@b.com\"\n}\n", "Alex", "a@b.com", false}, - {"non-canonical key blocks (fail closed)", `{"NAME":"Alex","email":"a@b.com"}`, "Alex", "a@b.com", true}, - {"empty value blocks (fail closed)", `{"name":"","email":""}`, "Alex", "a@b.com", true}, - {"malformed blocks (fail closed)", `{garbage`, "Alex", "a@b.com", true}, + {name: "no record passes a human", gitName: "Whoever", gitEmail: "who@ever.com"}, + {name: "match passes", record: `{"name":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "a@b.com"}, + {name: "mismatch blocks, naming the record and how to change it", record: `{"name":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "typo@b.com", wantBlock: true, wantSay: "~/.abcd.noindex/identity.json, or remove it"}, + {name: "pretty-printed match passes", record: "{\n \"name\": \"Alex\",\n \"email\": \"a@b.com\"\n}\n", gitName: "Alex", gitEmail: "a@b.com"}, + {name: "non-canonical key blocks (fail closed)", record: `{"NAME":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "a@b.com", wantBlock: true}, + {name: "empty value blocks (fail closed)", record: `{"name":"","email":""}`, gitName: "Alex", gitEmail: "a@b.com", wantBlock: true}, + {name: "malformed blocks (fail closed)", record: `{garbage`, gitName: "Alex", gitEmail: "a@b.com", wantBlock: true}, + // Criterion 2: a committed pin naming someone else decides nothing. + {name: "a committed pin of the maintainer does not refuse a contributor", pin: `{"name":"A Maintainer","email":"m@example.com"}`, gitName: "Second Contributor", gitEmail: "second@example.com"}, + // Criterion 5: no record, and a machine identity, is never committed. + {name: "an AI tool's identity blocks with no record", gitName: "Claude", gitEmail: "noreply@anthropic.com", wantBlock: true, wantSay: "machine identity"}, + {name: "a bot's identity blocks with no record", gitName: "dependabot[bot]", gitEmail: "49699333+dependabot[bot]@users.noreply.github.com", wantBlock: true}, + {name: "a machine identity blocks even when recorded", record: `{"name":"Claude","email":"noreply@anthropic.com"}`, gitName: "Claude", gitEmail: "noreply@anthropic.com", wantBlock: true}, + {name: "a person's forge privacy address passes", gitName: "Alex", gitEmail: "1234+alex@users.noreply.github.com"}, + // Not covered: a repository with no identity configured anywhere. + // git then invents one from the account name and the host name and + // exports it into the hook's environment, where it is + // indistinguishable from one a launcher supplied; the Go check + // reports it as git_identity.unset. } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - hookGit(t, dir, env, "init") - hookGit(t, dir, env, "config", "user.name", tc.gitName) - hookGit(t, dir, env, "config", "user.email", tc.gitEmail) + env := hookEnv(t) + dir := hookRepo(t, hook, env, tc.gitName, tc.gitEmail) + if tc.record != "" { + writePin(t, dir, tc.record) + } if tc.pin != "" { - if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".abcd", "config", "identity.json"), []byte(tc.pin), 0o644); err != nil { - t.Fatal(err) - } - } - hooksDir := filepath.Join(dir, ".git", "hooks") - if err := os.MkdirAll(hooksDir, 0o755); err != nil { + writeRepoPin(t, dir, tc.pin) + hookGit(t, dir, env, "add", "-A") + } + blocked, out := commitThroughHook(t, dir, env) + if blocked != tc.wantBlock { + t.Fatalf("blocked=%v, want %v\n%s", blocked, tc.wantBlock, out) + } + if tc.wantSay != "" && !strings.Contains(out, tc.wantSay) { + t.Fatalf("the refusal does not say %q:\n%s", tc.wantSay, out) + } + if strings.Contains(out, "Maintainer") { + t.Fatalf("the hook named the maintainer:\n%s", out) + } + }) + } +} + +// TestPreCommitHook_RefusesAnUntrustedRecord: the hook reads the record under +// the rules loader's guards and blocks on every one it fails; a symlinked +// ~/.abcd.noindex with no record in it reads as absent. +func TestPreCommitHook_RefusesAnUntrustedRecord(t *testing.T) { + hook := locateHook(t) + const rec = `{"name":"Alex","email":"a@b.com"}` + cases := map[string]struct { + setup func(t *testing.T, home string) + wantBlock bool + }{ + "group-writable record": {setup: func(t *testing.T, home string) { + p := writeHomeFile(t, MachineRecordRef().Rel, rec) + if err := os.Chmod(p, 0o660); err != nil { t.Fatal(err) } - src, err := os.ReadFile(hook) - if err != nil { + }, wantBlock: true}, + "world-writable folder": {setup: func(t *testing.T, home string) { + p := writeHomeFile(t, MachineRecordRef().Rel, rec) + if err := os.Chmod(filepath.Dir(p), 0o777); err != nil { + t.Fatal(err) + } + }, wantBlock: true}, + "record is a symlink": {setup: func(t *testing.T, home string) { + p := writeHomeFile(t, MachineRecordRef().Rel, rec) + if err := os.Rename(p, p+".real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(p+".real", p); err != nil { + t.Fatal(err) + } + }, wantBlock: true}, + "record behind a symlinked home": {setup: func(t *testing.T, home string) { + real := filepath.Join(t.TempDir(), "elsewhere") + if err := os.MkdirAll(real, 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), src, 0o755); err != nil { + if err := os.WriteFile(filepath.Join(real, "identity.json"), []byte(rec), 0o600); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o644); err != nil { + if err := os.Symlink(real, filepath.Join(home, ".abcd.noindex")); err != nil { + t.Fatal(err) + } + }, wantBlock: true}, + "symlinked home with no record reads as absent": {setup: func(t *testing.T, home string) { + real := filepath.Join(t.TempDir(), "elsewhere") + if err := os.MkdirAll(real, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, filepath.Join(home, ".abcd.noindex")); err != nil { + t.Fatal(err) + } + }}, + "oversized record": {setup: func(t *testing.T, home string) { + writeHomeFile(t, MachineRecordRef().Rel, `{"name":"Alex","email":"a@b.com","pad":"`+strings.Repeat("x", 5000)+`"}`) + }, wantBlock: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + env := hookEnv(t) + dir := hookRepo(t, hook, env, "Alex", "a@b.com") + tc.setup(t, os.Getenv("HOME")) + if blocked, out := commitThroughHook(t, dir, env); blocked != tc.wantBlock { + t.Fatalf("blocked=%v, want %v\n%s", blocked, tc.wantBlock, out) + } + }) + } +} + +// TestPreCommitHook_RepoRecordOutranksTheMachineRecord: the hook reads the +// record for this repository, keyed on its root commit, ahead of the +// machine-wide default, exactly as the Go check does. +func TestPreCommitHook_RepoRecordOutranksTheMachineRecord(t *testing.T) { + hook := locateHook(t) + for _, tc := range []struct { + name string + repoRec string + wantBlock bool + }{ + {name: "the repository's record matches", repoRec: `{"name":"Alex at Work","email":"alex@work.example"}`}, + {name: "the repository's record differs", repoRec: `{"name":"Alex at Work","email":"other@work.example"}`, wantBlock: true}, + } { + t.Run(tc.name, func(t *testing.T) { + env := hookEnv(t) + dir := hookRepo(t, hook, env, "Alex at Work", "alex@work.example") + hookGit(t, dir, env, "commit", "--no-verify", "-m", "root") + sha := gittest.RootCommit(t, dir) + writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + ref, err := RepoRecordRef(sha) + if err != nil { + t.Fatal(err) + } + writeHomeFile(t, ref.Rel, tc.repoRec) + if err := os.WriteFile(filepath.Join(dir, "g.txt"), []byte("y"), 0o644); err != nil { t.Fatal(err) } hookGit(t, dir, env, "add", "-A") - err = hookGit(t, dir, env, "commit", "-m", "t") - blocked := err != nil - if blocked != tc.wantBlock { - t.Fatalf("blocked=%v, want %v", blocked, tc.wantBlock) + if blocked, out := commitThroughHook(t, dir, env); blocked != tc.wantBlock { + t.Fatalf("blocked=%v, want %v\n%s", blocked, tc.wantBlock, out) } }) } } +// TestPreCommitHook_ACopyWithNoListStillChecksTheRecord: a hook copied out of +// the checkout, with no machine-identity list anywhere near it, says so in one +// line and still holds the person to their own record; a list that is there +// but lacks a key blocks. +func TestPreCommitHook_ACopyWithNoListStillChecksTheRecord(t *testing.T) { + hook := locateHook(t) + env := hookEnv(t) + dir := hookRepo(t, hook, env, "Alex", "typo@b.com") + list := filepath.Join(dir, "internal", "core", "identity", toolIdentitiesFile) + if err := os.Remove(list); err != nil { + t.Fatal(err) + } + hookGit(t, dir, env, "add", "-A") + writePin(t, dir, `{"name":"Alex","email":"a@b.com"}`) + blocked, out := commitThroughHook(t, dir, env) + if !blocked || !strings.Contains(out, "no machine-identity list") { + t.Fatalf("want a one-line warning and the record still checked; blocked=%v\n%s", blocked, out) + } + env = hookEnv(t) + dir = hookRepo(t, hook, env, "Alex", "a@b.com") + if err := os.WriteFile(filepath.Join(dir, "internal", "core", "identity", toolIdentitiesFile), []byte("ai_name=x\n"), 0o644); err != nil { + t.Fatal(err) + } + hookGit(t, dir, env, "add", "-A") + if blocked, out := commitThroughHook(t, dir, env); !blocked || !strings.Contains(out, "has no ai_mail pattern") { + t.Fatalf("a list missing a key must block; blocked=%v\n%s", blocked, out) + } +} + // TestPreCommitHook_AuthorRoleConfigBlocks is iss-2609261454332615 at the shell // guard: a repo-local author.name/author.email outranks user.name/user.email // for the author git stamps, so a matching user.* must not wave through a commit -// that author.* attributes to someone else. The committer is held to the pin the -// same way (itd-131), so committer.* is refused on the same terms. +// that author.* attributes to someone else. The committer is held to the +// record the same way (itd-131), so committer.* is refused on the same terms. func TestPreCommitHook_AuthorRoleConfigBlocks(t *testing.T) { hook := locateHook(t) - env := hookEnv(t) for _, key := range []string{"author.name", "author.email", "committer.name", "committer.email"} { t.Run(key, func(t *testing.T) { - dir := t.TempDir() - hookGit(t, dir, env, "init") - hookGit(t, dir, env, "config", "user.name", "Alex") - hookGit(t, dir, env, "config", "user.email", "a@b.com") + env := hookEnv(t) + dir := hookRepo(t, hook, env, "Alex", "a@b.com") + writePin(t, dir, `{"name":"Alex","email":"a@b.com"}`) hookGit(t, dir, env, "config", key, "Test User") - if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".abcd", "config", "identity.json"), []byte(`{"name":"Alex","email":"a@b.com"}`), 0o644); err != nil { - t.Fatal(err) - } - src, err := os.ReadFile(hook) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".git", "hooks", "pre-commit"), src, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - hookGit(t, dir, env, "add", "-A") - if err := hookGit(t, dir, env, "commit", "-m", "t"); err == nil { - t.Fatalf("the hook let %s = Test User make a commit under a pin of Alex", key) + if blocked, _ := commitThroughHook(t, dir, env); !blocked { + t.Fatalf("the hook let %s = Test User make a commit under a record of Alex", key) } }) } @@ -148,8 +309,12 @@ func TestPreCommitHook_AuthorRoleConfigBlocks(t *testing.T) { // the hook reads them ahead of every config key, as git does, so a value // inherited from whatever runs the tests would decide the case instead of the // repository the case builds. +// +// Each call starts from a HOME of its own, so a record one case writes under +// ~/.abcd.noindex is never read by the next. func hookEnv(t *testing.T) []string { t.Helper() + t.Setenv("HOME", t.TempDir()) var env []string for _, kv := range gittest.Env(t) { key, _, _ := strings.Cut(kv, "=") @@ -162,40 +327,13 @@ func hookEnv(t *testing.T) []string { return env } -// pinnedHookRepo builds a repository pinned to Alex <a@b.com>, with user.* set -// to name/email and the committed pre-commit hook installed, and one file staged. -func pinnedHookRepo(t *testing.T, hook string, env []string, name, email string) string { - t.Helper() - dir := t.TempDir() - hookGit(t, dir, env, "init") - hookGit(t, dir, env, "config", "user.name", name) - hookGit(t, dir, env, "config", "user.email", email) - if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".abcd", "config", "identity.json"), []byte(`{"name":"Alex","email":"a@b.com"}`), 0o644); err != nil { - t.Fatal(err) - } - src, err := os.ReadFile(hook) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".git", "hooks", "pre-commit"), src, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - hookGit(t, dir, env, "add", "-A") - return dir -} - // TestPreCommitHook_IdentityEnvOutranksConfig is iss-2609261614268567: git // stamps GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL and GIT_COMMITTER_NAME/ // GIT_COMMITTER_EMAIL ahead of every config key, so the guard must read them // first too. A machine identity exported by a harness or CI runner must not -// commit past a pin that the configured user.* satisfies, and an environment -// that sets the pinned identity over a different user.* must not be refused. +// commit past a record that the configured user.* satisfies, and an +// environment that sets the recorded identity over a different user.* must not +// be refused. // git exports the author it resolved from --author and from -c author.* into // the hook's environment, so those shapes are held by the same reading. func TestPreCommitHook_IdentityEnvOutranksConfig(t *testing.T) { @@ -216,12 +354,13 @@ func TestPreCommitHook_IdentityEnvOutranksConfig(t *testing.T) { {name: "--author blocks", user: [2]string{"Alex", "a@b.com"}, flags: []string{"--author", "Claude <noreply@anthropic.com>"}, wantBlock: true}, {name: "-c author.name blocks", user: [2]string{"Alex", "a@b.com"}, args: []string{"-c", "author.name=Claude"}, wantBlock: true}, {name: "-c committer.email blocks", user: [2]string{"Alex", "a@b.com"}, args: []string{"-c", "committer.email=noreply@anthropic.com"}, wantBlock: true}, - {name: "pinned identity in the environment passes over a different user.*", user: [2]string{"Other", "o@example.com"}, env: pinnedEnv, wantBlock: false}, + {name: "recorded identity in the environment passes over a different user.*", user: [2]string{"Other", "o@example.com"}, env: pinnedEnv, wantBlock: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env := hookEnv(t) - dir := pinnedHookRepo(t, hook, env, tc.user[0], tc.user[1]) + dir := hookRepo(t, hook, env, tc.user[0], tc.user[1]) + writePin(t, dir, `{"name":"Alex","email":"a@b.com"}`) args := append(append(append([]string{}, tc.args...), "commit", "-m", "t"), tc.flags...) cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) cmd.Env = append(append([]string{}, env...), tc.env...) diff --git a/internal/core/identity/identity.go b/internal/core/identity/identity.go index 7ecaa7dc15..6fedef944b 100644 --- a/internal/core/identity/identity.go +++ b/internal/core/identity/identity.go @@ -1,49 +1,26 @@ // Package identity checks that the git author and committer identities a commit -// would use in a managed repo match the identity pinned in -// .abcd/config/identity.json, and recognises a machine identity in either role. +// would use match the person's OWN identity record, kept on their own machine +// under the user-level home (record.go), and recognises a machine identity in +// either role. Nothing in a repository names who may commit to it +// (itd-2610240918547728, adr-2610241022541063); a repository that still +// stores the identity pin itd-131 introduced is migrated (migrate.go). // -// It is the single source of truth for the iss-62 managed-repo identity gate: -// `ahoy doctor` surfaces a divergence as a detection gap, and the installed -// pre-commit hook calls Check to fail closed before a mis-attributed commit can -// land — so a stray repo-local override (e.g. a sandbox "Test User") is caught -// up front rather than discovered later. +// It is the single source of truth for the managed-repo identity gate: +// `ahoy doctor` surfaces a divergence as a detection gap, and `abcd ahoy +// --identity` exits non-zero on one, so a stray repo-local override (e.g. a +// sandbox "Test User") or a harness's machine identity is caught before the +// commit rather than discovered later. package identity import ( - "bytes" - "encoding/json" "fmt" "os" "os/exec" - "path/filepath" "strings" - "github.com/intentdriven/abcd/internal/core/provenance" - "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" ) -// PinRelPath is the committed identity pin, relative to the repo root. -const PinRelPath = ".abcd/config/identity.json" - -// Pin is the expected commit identity, committed so every checkout enforces the -// same value regardless of local git config. -type Pin struct { - Name string `json:"name"` - Email string `json:"email"` - // ProductionMode is the repo's DEFAULT production mode: how the text of a - // record this repository mints was produced, unless the minting verb says - // otherwise (itd-178). It rides the pin rather than a second config file - // because this is already the repo's attribution seam and already has a - // reader; a second file would be a second reader of the same question. - // - // Optional and omitted when empty, so a pin written before the member existed - // round-trips byte-identically. An absent member means provenance.DefaultMode. - // The self-contained pre-commit identity guard seds `name` and `email` out of - // this file by name, so an added member is invisible to it. - ProductionMode string `json:"production_mode,omitempty"` -} - // Effective is the identity git would actually stamp on a commit in the repo, // resolved as git resolves the author: the GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL // environment overrides first, then git config's local > global > system @@ -53,17 +30,19 @@ type Effective struct { Email string } -// Status is the outcome of comparing the effective identity to the pin. +// Status is the outcome of comparing the effective identity to the person's +// own record. type Status int const ( - // StatusOK: a pin exists and the effective identity matches it. + // StatusOK: a record applies and the effective identity matches it. StatusOK Status = iota - // StatusNoPin: no identity.json — the repo has not opted into the gate. - StatusNoPin - // StatusMismatch: a pin exists and the effective identity differs. + // StatusNoRecord: no record on this machine applies to the repo. Nothing + // to compare against; a machine identity is still refused (Blocks). + StatusNoRecord + // StatusMismatch: a record applies and the effective identity differs. StatusMismatch - // StatusUnset: a pin exists but git has no author identity configured. + // StatusUnset: git has no author identity configured. StatusUnset ) @@ -71,8 +50,8 @@ func (s Status) String() string { switch s { case StatusOK: return "ok" - case StatusNoPin: - return "no-pin" + case StatusNoRecord: + return "no-record" case StatusMismatch: return "mismatch" case StatusUnset: @@ -84,158 +63,57 @@ func (s Status) String() string { // Result carries the comparison outcome and the identities for reporting. // -// Status and Effective describe the AUTHOR, with the meaning they have always -// had. The committer is reported beside them rather than folded into Status, so -// every existing reader of the author statuses reads exactly what it did. +// Status and Effective describe the AUTHOR. The committer is reported beside +// them rather than folded into Status. type Result struct { - Status Status - Pin Pin + Status Status + // Record is the person's own record, when one applies (Recorded). + Record Record + Recorded bool + // RecordAt names the record that applies, or would: the repository's own + // record when one exists, else the machine-wide default. + RecordAt RecordRef Effective Effective Reason string // Committer is the committer identity git would stamp (effectiveCommitter). Committer Effective // CommitterDiverges reports a committer that differs from the author and is - // not the pinned identity either: a GIT_COMMITTER_* override, a committer.* - // config key, or an author override the committer does not share. A - // committer that is the same wrong identity as the author is not reported - // again — the author status already says so, and one fix mends both. + // not the recorded identity either: a GIT_COMMITTER_* override, a + // committer.* config key, or an author override the committer does not + // share. A committer that is the same wrong identity as the author is not + // reported again — the author status already says so, and one fix mends + // both. CommitterDiverges bool // CommitterReason says what diverges, for the person reading the gap. CommitterReason string // AuthorIsTool and CommitterIsTool report a machine identity in that role - // (IsToolIdentity), pinned or not: the routine case, made visible. + // (IsToolIdentity), recorded or not. AuthorIsTool bool CommitterIsTool bool } -// Blocks reports whether a pre-commit hook should refuse the commit. A mismatch -// or an unset author identity blocks, and so does a committer that diverges from -// the pin; a match, or an un-pinned (opted-out) repo, does not — an absent pin -// must never break commits in a repo that has not adopted the gate. +// Blocks reports whether a pre-commit hook should refuse the commit, on the +// shell guard's terms: a mismatch with the person's own record, an unset +// author, or a machine identity in either role blocks wherever it is found; +// a committer that diverges blocks when a record applies. With no record, a +// human identity — whatever launched the run supplied — commits. func (r Result) Blocks() bool { if r.Status == StatusMismatch || r.Status == StatusUnset { return true } - return r.Status != StatusNoPin && r.CommitterDiverges -} - -// LoadPin reads .abcd/config/identity.json. It returns (pin, true, nil) when the -// pin is present and well formed, (Pin{}, false, nil) when the file is absent, -// and an error when it is malformed or missing a field — validating this -// external input rather than trusting it. -func LoadPin(root string) (Pin, bool, error) { - path := filepath.Join(root, PinRelPath) - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return Pin{}, false, nil - } - return Pin{}, false, fmt.Errorf("reading %s: %w", PinRelPath, err) - } - var p Pin - if err := json.Unmarshal(data, &p); err != nil { - return Pin{}, false, fmt.Errorf("malformed %s: %w", PinRelPath, err) - } - p.Name = strings.TrimSpace(p.Name) - p.Email = strings.TrimSpace(p.Email) - if p.Name == "" || p.Email == "" { - return Pin{}, false, fmt.Errorf("%s must set both name and email", PinRelPath) - } - // The optional member is validated at the boundary exactly as a malformed pin - // is: a value outside the closed set would otherwise be stamped, unread, onto - // every record the repo mints. - p.ProductionMode = strings.TrimSpace(p.ProductionMode) - if p.ProductionMode != "" { - if _, err := provenance.ParseMode(p.ProductionMode); err != nil { - return Pin{}, false, fmt.Errorf("malformed %s: %w", PinRelPath, err) - } - } - return p, true, nil -} - -// DeclaredProductionMode is the repo's default production mode: the pin's -// optional member, or provenance.DefaultMode when the repo has no pin or the pin -// declares none. It is the ONE resolution of that question, so no surface -// re-derives "absent means hand-written" for itself. -func DeclaredProductionMode(root string) (provenance.Mode, error) { - pin, pinned, err := LoadPin(root) - if err != nil { - return "", err - } - if !pinned { - return provenance.DefaultMode, nil - } - return provenance.ModeOrDefault(pin.ProductionMode) -} - -// WritePin writes the pin to .abcd/config/identity.json (creating the config -// directory), pretty-printed with a trailing newline. It is how a repo adopts -// the identity gate. Both fields are required. -func WritePin(root string, p Pin) error { - p.Name = strings.TrimSpace(p.Name) - p.Email = strings.TrimSpace(p.Email) - if p.Name == "" || p.Email == "" { - return fmt.Errorf("identity pin requires both name and email") - } - // The optional member is refused here on the same terms LoadPin refuses it, - // so the writer can never store a pin its own reader rejects. - p.ProductionMode = strings.TrimSpace(p.ProductionMode) - if p.ProductionMode != "" { - if _, err := provenance.ParseMode(p.ProductionMode); err != nil { - return fmt.Errorf("identity pin: %w", err) - } - } - // The self-contained pre-commit identity guard reads the pin with a naive - // sed that captures the raw bytes between the JSON quotes and compares them - // literally to `git config`, so the stored value must round-trip through that - // sed. Two things ensure it (iss-63): the pin is marshalled WITHOUT HTML - // escaping (below), so &, <, > — legal in a git user.name like - // "Marks & Spencer" — are stored literally rather than escaped; and the - // characters JSON must escape regardless (a double-quote, a backslash, or a - // control character), which the sed can never read back, are refused here so - // a pin can never hold one and fail-close a correct identity. This keeps the - // hook zero-dependency rather than delegating the gate to a possibly-stale - // binary. - if unpinnable(p.Name) { - return fmt.Errorf("identity pin name must not contain a double-quote, backslash, or control character (it breaks the self-contained pre-commit identity guard); adjust git config user.name") - } - if unpinnable(p.Email) { - return fmt.Errorf("identity pin email must not contain a double-quote, backslash, or control character; adjust git config user.email") - } - // Marshal WITHOUT HTML escaping (so &, <, > survive literally) and route the - // bytes through the canonical atomic primitive (temp + fchmod + fsync + - // rename + parent-dir fsync): a plain in-place os.WriteFile truncates the pin - // before rewriting it — a crash mid-write leaves a corrupt or empty - // identity.json — and it follows a symlink at path. json.Encoder appends the - // trailing newline. - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - if err := enc.Encode(p); err != nil { - return err - } - // Contain the write under an os.Root opened at the repo: PinRelPath joins - // `.abcd/config/identity.json`, and a committed `.abcd` ancestor symlink would - // otherwise land the pin outside the working tree (GHSA-xrf8-4432-gw2f). The - // InRoot writer resolves every component through the root — creating the - // missing config/ parent included — so a symlinked ancestor is refused rather - // than followed. The leaf's own symlink is still replaced, not written through. - osRoot, err := os.OpenRoot(root) - if err != nil { - return err + if r.AuthorIsTool || r.CommitterIsTool { + return true } - defer osRoot.Close() - return fsutil.WriteFileAtomicInRoot(osRoot, PinRelPath, buf.Bytes(), 0o644) + return r.Recorded && r.CommitterDiverges } -// unpinnable reports whether s holds a character the identity pin cannot safely -// carry: a double-quote or backslash (which JSON must always escape) or a -// control character — none of which the self-contained pre-commit hook's sed can -// read back to compare against `git config` (iss-63). Characters like &, <, > -// are pinnable because WritePin marshals without HTML escaping. +// unpinnable reports whether s holds a character the identity record cannot +// safely carry: a double-quote or backslash (which JSON must always escape) or +// a control character — none of which the self-contained pre-commit hook's sed +// can read back to compare against `git config` (iss-63). Characters like &, +// <, > are fine because records are marshalled without HTML escaping. func unpinnable(s string) bool { for _, r := range s { if r == '"' || r == '\\' || r < 0x20 { @@ -357,13 +235,26 @@ func commitConfigEnv() []string { return append(gitutil.ScrubbedEnv(), gitutil.CommandLineConfig()...) } -// Check resolves the effective author and committer, loads the pin, and -// compares them. +// Check resolves the effective author and committer, loads the person's own +// record for the repository at root (from the home directory os.UserHomeDir +// names), and compares them. func Check(root string) (Result, error) { - pin, pinned, err := LoadPin(root) + return CheckWithHome(root, UserHome()) +} + +// CheckWithHome is Check for the person whose home directory is home. +func CheckWithHome(root, home string) (Result, error) { + rootSHA, err := gitutil.RootCommit(root) if err != nil { return Result{}, err } + rec, ref, recorded, err := LoadRecord(home, rootSHA) + if err != nil { + return Result{}, err + } + if !recorded { + ref = MachineRecordRef() + } eff, err := EffectiveIdentity(root) if err != nil { return Result{}, err @@ -372,46 +263,48 @@ func Check(root string) (Result, error) { if err != nil { return Result{}, err } - res := authorResult(pin, pinned, eff) + res := authorResult(rec, recorded, ref, eff) res.Committer = committer - res.CommitterDiverges, res.CommitterReason = committerDivergence(pin, pinned, eff, committer) + res.CommitterDiverges, res.CommitterReason = committerDivergence(rec, recorded, eff, committer) res.AuthorIsTool = eff != (Effective{}) && IsToolIdentity(RoleAuthor, eff.Name, eff.Email) res.CommitterIsTool = committer != (Effective{}) && IsToolIdentity(RoleCommitter, committer.Name, committer.Email) return res, nil } -// authorResult is the author half of Check, unchanged in meaning. -func authorResult(pin Pin, pinned bool, eff Effective) Result { - if !pinned { - return Result{Status: StatusNoPin, Effective: eff, Reason: "no " + PinRelPath + "; repo has not adopted the identity gate"} - } - if eff.Name == "" || eff.Email == "" { - return Result{Status: StatusUnset, Pin: pin, Effective: eff, Reason: "git author identity is not configured (user.name/user.email)"} - } - if eff.Name != pin.Name || eff.Email != pin.Email { - return Result{ - Status: StatusMismatch, Pin: pin, Effective: eff, - Reason: fmt.Sprintf("commit identity %q <%s> does not match the pin %q <%s>", eff.Name, eff.Email, pin.Name, pin.Email), - } +// authorResult is the author half of Check. +func authorResult(rec Record, recorded bool, ref RecordRef, eff Effective) Result { + res := Result{Record: rec, Recorded: recorded, RecordAt: ref, Effective: eff} + switch { + case eff.Name == "" || eff.Email == "": + res.Status = StatusUnset + res.Reason = "git author identity is not configured (user.name/user.email)" + case !recorded: + res.Status = StatusNoRecord + res.Reason = "you have not recorded who you commit as on this machine (" + ref.Display + ")" + case eff.Name != rec.Name || eff.Email != rec.Email: + res.Status = StatusMismatch + res.Reason = fmt.Sprintf("commit identity %q <%s> is not the one you recorded, %q <%s> (%s)", eff.Name, eff.Email, rec.Name, rec.Email, ref.Display) + default: + res.Status = StatusOK } - return Result{Status: StatusOK, Pin: pin, Effective: eff} + return res } // committerDivergence is the committer half: a committer that differs from the -// author, unless it is the pinned identity (then the author is the one that is -// wrong, and the author status says so). -func committerDivergence(pin Pin, pinned bool, author, committer Effective) (bool, string) { +// author, unless it is the recorded identity (then the author is the one that +// is wrong, and the author status says so). +func committerDivergence(rec Record, recorded bool, author, committer Effective) (bool, string) { if committer == author { return false, "" } - if pinned && committer.Name == pin.Name && committer.Email == pin.Email { + if recorded && committer.Name == rec.Name && committer.Email == rec.Email { return false, "" } if committer.Name == "" || committer.Email == "" { return true, fmt.Sprintf("git committer identity is not configured, while the author is %q <%s>", author.Name, author.Email) } - if pinned { - return true, fmt.Sprintf("committer %q <%s> does not match the pin %q <%s>", committer.Name, committer.Email, pin.Name, pin.Email) + if recorded { + return true, fmt.Sprintf("committer %q <%s> is not the identity you recorded, %q <%s>", committer.Name, committer.Email, rec.Name, rec.Email) } return true, fmt.Sprintf("committer %q <%s> differs from the author %q <%s>", committer.Name, committer.Email, author.Name, author.Email) } diff --git a/internal/core/identity/identity_test.go b/internal/core/identity/identity_test.go index 24bee5f27a..3e9ab0a4f4 100644 --- a/internal/core/identity/identity_test.go +++ b/internal/core/identity/identity_test.go @@ -1,13 +1,14 @@ package identity import ( + "errors" "os" "os/exec" "path/filepath" "strings" "testing" - "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/abcdhome" "github.com/intentdriven/abcd/internal/gittest" ) @@ -46,15 +47,29 @@ func runGitT(t *testing.T, dir string, args ...string) { } } -func writePin(t *testing.T, root, body string) { +// writePin records body as the person's own machine-wide identity record, +// under the test's own HOME (gittest.Env pins HOME to a per-test temp dir). +// The name is kept from when the identity was committed into the repository; +// root is unused, and nothing is written there: the repository names no one +// (itd-2610240918547728). +func writePin(t *testing.T, _ string, body string) { t.Helper() - dir := filepath.Join(root, ".abcd", "config") - if err := os.MkdirAll(dir, 0o755); err != nil { + writeHomeFile(t, MachineRecordRef().Rel, body) +} + +// writeHomeFile writes body at rel under the test HOME, the folders 0700 and +// the file 0600 as abcd writes them. +func writeHomeFile(t *testing.T, rel, body string) string { + t.Helper() + gittest.Env(t) + p := filepath.Join(os.Getenv("HOME"), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "identity.json"), []byte(body), 0o644); err != nil { + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatal(err) } + return p } func TestCheck_Match(t *testing.T) { @@ -81,21 +96,145 @@ func TestCheck_Mismatch(t *testing.T) { if res.Status != StatusMismatch { t.Fatalf("want StatusMismatch, got %v — %s", res.Status, res.Reason) } - if res.Effective.Email != "test@example.com" || res.Pin.Email != "alex@example.com" { + if res.Effective.Email != "test@example.com" || res.Record.Email != "alex@example.com" { t.Fatalf("result did not carry both identities: %+v", res) } } -func TestCheck_NoPin(t *testing.T) { +func TestCheck_NoRecord(t *testing.T) { isolate(t) dir := gitRepo(t, "Alex Reppel", "alex@example.com") - // no identity.json written + // no record written + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusNoRecord { + t.Fatalf("want StatusNoRecord, got %v — %s", res.Status, res.Reason) + } + if res.Blocks() { + t.Fatal("a human identity with no record must commit (criterion 5: whatever launched the run supplied it)") + } +} + +// TestCheck_IgnoresACommittedPin is criterion 2 at the gate: a repository that +// still commits another person's identity no longer decides who may commit. +// The contributor's own human identity, with no record of their own, is not +// refused and the result names no one but them. +func TestCheck_IgnoresACommittedPin(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Second Contributor", "second@example.com") + writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusNoRecord || res.Blocks() { + t.Fatalf("a committed pin decided the gate: %v blocks=%v — %s", res.Status, res.Blocks(), res.Reason) + } + if strings.Contains(res.Reason+res.CommitterReason, "Maintainer") { + t.Fatalf("the result names the maintainer: %+v", res) + } +} + +// TestCheck_RepoRecordOutranksTheMachineRecord: a person who commits to one +// repository under a work identity records it for that repository, keyed on +// its root commit, and it wins over their machine-wide default there. +func TestCheck_RepoRecordOutranksTheMachineRecord(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Alex at Work", "alex@work.example") + // isolate blanks GIT_AUTHOR_*, which git reads as an empty ident. + t.Setenv("GIT_AUTHOR_NAME", "Alex at Work") + t.Setenv("GIT_AUTHOR_EMAIL", "alex@work.example") + runGitT(t, dir, "commit", "--allow-empty", "-m", "root", "--no-verify") + sha := gittest.RootCommit(t, dir) + writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + ref, err := RepoRecordRef(sha) + if err != nil { + t.Fatal(err) + } + writeHomeFile(t, ref.Rel, `{"name":"Alex at Work","email":"alex@work.example"}`) res, err := Check(dir) if err != nil { t.Fatal(err) } - if res.Status != StatusNoPin { - t.Fatalf("want StatusNoPin, got %v — %s", res.Status, res.Reason) + if res.Status != StatusOK || res.RecordAt != ref { + t.Fatalf("want the repository's own record to decide: %v at %+v — %s", res.Status, res.RecordAt, res.Reason) + } +} + +// TestCheck_RefusedRecordFailsClosed: a record that is there and cannot be +// trusted (writable by another account, a symlink, unparseable) is an error, +// never a fall-through to "no record". +func TestCheck_RefusedRecordFailsClosed(t *testing.T) { + for name, mutate := range map[string]func(t *testing.T, p string){ + "group-writable": func(t *testing.T, p string) { + if err := os.Chmod(p, 0o620); err != nil { + t.Fatal(err) + } + }, + "symlink": func(t *testing.T, p string) { + target := p + ".real" + if err := os.Rename(p, target); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, p); err != nil { + t.Fatal(err) + } + }, + "malformed": func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte("{garbage"), 0o600); err != nil { + t.Fatal(err) + } + }, + "incomplete": func(t *testing.T, p string) { + if err := os.WriteFile(p, []byte(`{"name":"Alex"}`), 0o600); err != nil { + t.Fatal(err) + } + }, + } { + t.Run(name, func(t *testing.T) { + isolate(t) + t.Setenv("HOME", t.TempDir()) + dir := gitRepo(t, "Alex Reppel", "alex@example.com") + p := writeHomeFile(t, MachineRecordRef().Rel, `{"name":"Alex Reppel","email":"alex@example.com"}`) + mutate(t, p) + if _, err := Check(dir); err == nil || !errors.Is(err, ErrRecordRefused) { + t.Fatalf("want ErrRecordRefused, got %v", err) + } + }) + } +} + +// TestCheck_RecordBehindASymlinkedHomeIsRefused: the record is refused behind +// a symlinked user-level home, as the rules loader refuses rules.json there. +func TestCheck_RecordBehindASymlinkedHomeIsRefused(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Alex Reppel", "alex@example.com") + home := os.Getenv("HOME") + real := filepath.Join(t.TempDir(), "real-home") + if err := os.MkdirAll(real, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(real, "identity.json"), []byte(`{"name":"Alex Reppel","email":"alex@example.com"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, filepath.Join(home, filepath.FromSlash(abcdhome.Rel()))); err != nil { + t.Fatal(err) + } + if _, err := Check(dir); err == nil || !errors.Is(err, ErrRecordRefused) { + t.Fatalf("want a record behind a symlinked home refused, got %v", err) + } +} + +func writeRepoPin(t *testing.T, root, body string) { + t.Helper() + cfg := filepath.Join(root, ".abcd", "config") + if err := os.MkdirAll(cfg, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfg, "identity.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) } } @@ -135,130 +274,119 @@ func TestCheck_AuthorEnvOverride(t *testing.T) { } } -func TestLoadPin_Malformed(t *testing.T) { - isolate(t) - dir := t.TempDir() - writePin(t, dir, `{"name": "no closing quote`) - if _, _, err := LoadPin(dir); err == nil { - t.Fatal("want error on malformed identity.json, got nil") - } -} - -func TestLoadPin_MissingFields(t *testing.T) { - isolate(t) - dir := t.TempDir() - writePin(t, dir, `{"name":"Alex Reppel"}`) // no email - if _, _, err := LoadPin(dir); err == nil { - t.Fatal("want error when email is empty, got nil") - } +func recordHome(t *testing.T) string { + t.Helper() + gittest.Env(t) + return os.Getenv("HOME") } -func TestWritePin_RoundTrip(t *testing.T) { - dir := t.TempDir() - want := Pin{Name: "Alex Reppel", Email: "alex@example.com"} - if err := WritePin(dir, want); err != nil { +func TestWriteRecord_RoundTrip(t *testing.T) { + home := recordHome(t) + want := Record{Name: "Alex Reppel", Email: "alex@example.com"} + if err := WriteRecord(home, MachineRecordRef(), want); err != nil { t.Fatal(err) } - got, ok, err := LoadPin(dir) + got, ref, ok, err := LoadRecord(home, "") if err != nil || !ok { - t.Fatalf("LoadPin after WritePin: ok=%v err=%v", ok, err) + t.Fatalf("LoadRecord after WriteRecord: ok=%v err=%v", ok, err) + } + if got != want || ref != MachineRecordRef() { + t.Fatalf("round-trip mismatch: got %+v at %+v, want %+v", got, ref, want) + } + st, err := os.Stat(filepath.Join(home, filepath.FromSlash(ref.Rel))) + if err != nil { + t.Fatal(err) } - if got != want { - t.Fatalf("round-trip mismatch: got %+v want %+v", got, want) + if st.Mode().Perm() != 0o600 { + t.Fatalf("the record was written %v, want 0600", st.Mode().Perm()) } } -// TestWritePin_DoesNotFollowSymlink proves the atomic write does not clobber a -// symlink target: a plain os.WriteFile at a symlinked identity.json followed the -// link and wrote through to the victim; the canonical temp+rename replaces the -// symlink itself, leaving the target untouched. -func TestWritePin_DoesNotFollowSymlink(t *testing.T) { - dir := t.TempDir() - victim := filepath.Join(dir, "victim.txt") - const sentinel = "do-not-touch" - if err := os.WriteFile(victim, []byte(sentinel), 0o644); err != nil { +func TestWriteRecord_RepoScope(t *testing.T) { + home := recordHome(t) + ref, err := RepoRecordRef(strings.Repeat("ab", 20)) + if err != nil { t.Fatal(err) } - cfgDir := filepath.Join(dir, ".abcd", "config") - if err := os.MkdirAll(cfgDir, 0o755); err != nil { + want := Record{Name: "Alex at Work", Email: "alex@work.example"} + if err := WriteRecord(home, ref, want); err != nil { t.Fatal(err) } - if err := os.Symlink(victim, filepath.Join(cfgDir, "identity.json")); err != nil { - t.Skipf("cannot symlink: %v", err) + got, at, ok, err := LoadRecord(home, strings.Repeat("ab", 20)) + if err != nil || !ok || got != want || at != ref { + t.Fatalf("got %+v at %+v ok=%v err=%v", got, at, ok, err) } - if err := WritePin(dir, Pin{Name: "Alex", Email: "alex@example.com"}); err != nil { - t.Fatalf("WritePin: %v", err) - } - got, err := os.ReadFile(victim) - if err != nil { - t.Fatal(err) - } - if string(got) != sentinel { - t.Errorf("WritePin followed the symlink and clobbered the target: %q", got) + if _, err := RepoRecordRef("../../etc"); err == nil { + t.Fatal("a root name that is not an object name must be refused") } } -func TestWritePin_RequiresBothFields(t *testing.T) { - dir := t.TempDir() - if err := WritePin(dir, Pin{Name: "Alex"}); err == nil { +func TestWriteRecord_RequiresBothFields(t *testing.T) { + if err := WriteRecord(recordHome(t), MachineRecordRef(), Record{Name: "Alex"}); err == nil { t.Fatal("want error when email is missing") } } -// The self-contained pre-commit hook reads the pin with a naive sed, so the -// stored value must round-trip through it (iss-63). WritePin refuses the -// characters JSON must always escape (double-quote, backslash, control), which -// the sed cannot read back, and stores &, <, > literally (no HTML escaping) so -// a legal git identity like "Marks & Spencer" is not fail-closed. -func TestWritePin_RejectsUnpinnableChars(t *testing.T) { - dir := t.TempDir() - for _, p := range []Pin{ +// A record never names a machine: it is the person's own. +func TestWriteRecord_RefusesAMachineIdentity(t *testing.T) { + if err := WriteRecord(recordHome(t), MachineRecordRef(), Record{Name: "Claude", Email: "noreply@anthropic.com"}); err == nil { + t.Fatal("want a machine identity refused") + } +} + +// The self-contained pre-commit hook reads the record with a naive sed, so +// the stored value must round-trip through it (iss-63). WriteRecord refuses +// the characters JSON must always escape (double-quote, backslash, control), +// which the sed cannot read back, and stores &, <, > literally (no HTML +// escaping) so a legal git identity like "Marks & Spencer" is not +// fail-closed. +func TestWriteRecord_RejectsUnpinnableChars(t *testing.T) { + home := recordHome(t) + for _, r := range []Record{ {Name: `Alex "the dev"`, Email: "alex@example.com"}, // double-quote {Name: `back\slash`, Email: "alex@example.com"}, // backslash {Name: "line\nbreak", Email: "alex@example.com"}, // control char {Name: "Alex", Email: `a"x@example.com`}, // quote in email } { - if err := WritePin(dir, p); err == nil { - t.Fatalf("want error for unpinnable pin %+v", p) + if err := WriteRecord(home, MachineRecordRef(), r); err == nil { + t.Fatalf("want error for unpinnable record %+v", r) } } } -func TestWritePin_LegalIdentitiesRoundTrip(t *testing.T) { - for _, want := range []Pin{ +func TestWriteRecord_LegalIdentitiesRoundTrip(t *testing.T) { + for _, want := range []Record{ {Name: "Marks & Spencer", Email: "ci@m-and-s.example"}, // & is legal, must not HTML-escape {Name: "O'Brien", Email: "obrien@example.com"}, // apostrophe {Name: "José <team>", Email: "jose@example.com"}, // unicode + < > } { - dir := t.TempDir() - if err := WritePin(dir, want); err != nil { + home := recordHome(t) + if err := WriteRecord(home, MachineRecordRef(), want); err != nil { t.Fatalf("legal identity %+v must write: %v", want, err) } - got, ok, err := LoadPin(dir) + got, _, ok, err := LoadRecord(home, "") if err != nil || !ok || got != want { t.Fatalf("round-trip %+v: got %+v ok=%v err=%v", want, got, ok, err) } - // With HTML escaping off, the raw pin carries &, <, > literally; the bug - // (escaping on) would store the \uXXXX form instead, so the literal char - // would be ABSENT from the file — which the hook's sed cannot read back. - raw, err := os.ReadFile(filepath.Join(dir, PinRelPath)) + raw, err := os.ReadFile(filepath.Join(home, filepath.FromSlash(MachineRecordRef().Rel))) if err != nil { t.Fatal(err) } for _, r := range []rune{'&', '<', '>'} { if strings.ContainsRune(want.Name, r) && !strings.ContainsRune(string(raw), r) { - t.Fatalf("pin did not store %q literally (HTML-escaped?); the hook sed cannot read it back:\n%s", r, raw) + t.Fatalf("record did not store %q literally (HTML-escaped?); the hook sed cannot read it back:\n%s", r, raw) } } } } -// Blocks reports whether a pre-commit hook should refuse the commit: a mismatch -// or an unset identity blocks; OK and NoPin (opted-out) do not. +// Blocks reports whether a pre-commit hook should refuse the commit: a +// mismatch or an unset identity blocks; OK and NoRecord do not, unless a +// machine identity is in either role, which blocks whatever the record. func TestBlocks(t *testing.T) { cases := map[Status]bool{ StatusOK: false, - StatusNoPin: false, + StatusNoRecord: false, StatusMismatch: true, StatusUnset: true, } @@ -266,72 +394,9 @@ func TestBlocks(t *testing.T) { if got := (Result{Status: s}).Blocks(); got != want { t.Errorf("Status %v: Blocks()=%v, want %v", s, got, want) } - } -} - -// TestLoadPinReadsProductionModeDefault proves the itd-91 attribution seam is -// EXTENDED rather than duplicated: the repo's default production mode is an -// optional member of the pin the repo already commits, read by the one reader -// that already exists (spc-56). A second config file would be a second reader. -func TestLoadPinReadsProductionModeDefault(t *testing.T) { - isolate(t) - dir := t.TempDir() - writePin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com","production_mode":"dictated-and-formatted"}`) - pin, ok, err := LoadPin(dir) - if err != nil || !ok { - t.Fatalf("LoadPin: ok=%v err=%v", ok, err) - } - if pin.ProductionMode != "dictated-and-formatted" { - t.Fatalf("pin production_mode = %q, want dictated-and-formatted", pin.ProductionMode) - } - got, err := DeclaredProductionMode(dir) - if err != nil { - t.Fatalf("DeclaredProductionMode: %v", err) - } - if string(got) != "dictated-and-formatted" { - t.Fatalf("DeclaredProductionMode = %q, want dictated-and-formatted", got) - } -} - -// TestLoadPinRefusesUnknownProductionMode proves the member is validated at the -// boundary exactly as a malformed pin already is: a value outside the closed set -// is an error, never a silently-ignored member that would let every record the -// repo mints carry a mode nothing can read. -func TestLoadPinRefusesUnknownProductionMode(t *testing.T) { - isolate(t) - dir := t.TempDir() - writePin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com","production_mode":"typed"}`) - if _, _, err := LoadPin(dir); err == nil { - t.Fatal("want an error on an out-of-vocabulary production_mode, got nil") - } - if _, err := DeclaredProductionMode(dir); err == nil { - t.Fatal("DeclaredProductionMode: want an error on an out-of-vocabulary pin, got nil") - } -} - -// TestLoadPinAbsentMemberDefaultsHandWritten proves the member is optional in -// both directions: a pin written before it existed loads unchanged, and a repo -// with no pin at all still has a default. An absent member means hand-written. -func TestLoadPinAbsentMemberDefaultsHandWritten(t *testing.T) { - isolate(t) - dir := t.TempDir() - writePin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) - pin, ok, err := LoadPin(dir) - if err != nil || !ok { - t.Fatalf("LoadPin: ok=%v err=%v", ok, err) - } - if pin.ProductionMode != "" { - t.Fatalf("pin production_mode = %q, want the member absent", pin.ProductionMode) - } - got, err := DeclaredProductionMode(dir) - if err != nil || got != provenance.DefaultMode { - t.Fatalf("DeclaredProductionMode with an absent member = %q, %v; want %q", got, err, provenance.DefaultMode) - } - // A repo that has not adopted the identity gate at all still has a default. - unpinned := t.TempDir() - got, err = DeclaredProductionMode(unpinned) - if err != nil || got != provenance.DefaultMode { - t.Fatalf("DeclaredProductionMode with no pin = %q, %v; want %q", got, err, provenance.DefaultMode) + if !(Result{Status: s, AuthorIsTool: true}).Blocks() || !(Result{Status: s, CommitterIsTool: true}).Blocks() { + t.Errorf("Status %v: a machine identity must block", s) + } } } diff --git a/internal/core/identity/migrate.go b/internal/core/identity/migrate.go new file mode 100644 index 0000000000..6712963f15 --- /dev/null +++ b/internal/core/identity/migrate.go @@ -0,0 +1,240 @@ +package identity + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// LegacyPinRelPath is the identity pin itd-131 committed into a repository. It +// named one person as who commits there, so every other contributor was told +// to commit as them (iss-2610240830397793). Nothing gates on it any more; the +// migration below moves it to its owner's machine and out of the repository. +const LegacyPinRelPath = provenance.LegacyPinRelPath + +// legacyPinLimit caps the bytes read from a legacy pin. +const legacyPinLimit = 64 << 10 + +// LegacyPin is what a repository's committed identity pin holds. +type LegacyPin struct { + Name string + Email string + ProductionMode string + // Unknown lists any member the migration does not know how to carry. A + // pin holding one is never removed: removing it would drop a setting. + Unknown []string +} + +// LoadLegacyPin reads the committed pin at root through the guarded reader +// (fsutil.ReadGuardedInRoot): resolved inside an os.Root at the working tree, +// a symlinked pin refused rather than followed, only a regular file accepted, +// and capped at 64 KiB. found is false when the repository has none; a pin +// that is there and refused is an error. +func LoadLegacyPin(root string) (LegacyPin, bool, error) { + osRoot, err := os.OpenRoot(root) + if err != nil { + return LegacyPin{}, false, err + } + defer osRoot.Close() + data, err := fsutil.ReadGuardedInRoot(osRoot, LegacyPinRelPath, legacyPinLimit) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return LegacyPin{}, false, nil + } + return LegacyPin{}, false, fmt.Errorf("reading %s: %w", LegacyPinRelPath, err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return LegacyPin{}, false, fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) + } + var p LegacyPin + for key, val := range raw { + var s string + known := key == "name" || key == "email" || key == "production_mode" + if known && json.Unmarshal(val, &s) != nil { + return LegacyPin{}, false, fmt.Errorf("malformed %s: %s is not a string", LegacyPinRelPath, key) + } + switch key { + case "name": + p.Name = strings.TrimSpace(s) + case "email": + p.Email = strings.TrimSpace(s) + case "production_mode": + p.ProductionMode = strings.TrimSpace(s) + default: + p.Unknown = append(p.Unknown, key) + } + } + sort.Strings(p.Unknown) + if p.ProductionMode != "" { + if _, err := provenance.ParseMode(p.ProductionMode); err != nil { + return LegacyPin{}, false, fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) + } + } + return p, true, nil +} + +// MigrationPlan is what moving a repository's committed pin would do, for one +// person on one machine. It is computed without writing anything. +type MigrationPlan struct { + Pin LegacyPin + // Owner is true when this person's git identity — the one git resolves in + // the repository, or their global one — is the pinned identity. Only an + // owner's machine receives the pin; on anyone else's it is not copied, and + // nothing shown to them names it. + Owner bool + // Target is where an owner's record goes: the repository's own record + // (keyed on its root commit), else the machine-wide default for a + // repository with no commit yet. + Target RecordRef + // Existing is the record already at Target, if any. A different record is + // never overwritten: the person's own record wins over the repository's pin. + Existing Record + ExistingFound bool + // Blocker, when set, is why the pin cannot be removed without losing + // something: a member the migration cannot carry, a pin that names no + // complete human identity, or a production mode that conflicts with the one + // the repository already declares. + Blocker string +} + +// PlanMigration reads the repository's committed pin and says what moving it +// would do for the person whose home directory is home. found is false when the +// repository stores no pin. +func PlanMigration(root, home string) (MigrationPlan, bool, error) { + pin, found, err := LoadLegacyPin(root) + if err != nil || !found { + return MigrationPlan{}, found, err + } + plan := MigrationPlan{Pin: pin} + if len(pin.Unknown) > 0 { + plan.Blocker = LegacyPinRelPath + " holds " + strings.Join(pin.Unknown, ", ") + ", which abcd does not know where to keep, so it is left in place" + } + if pin.ProductionMode != "" { + declared, has, err := provenance.ReadDeclaredModeFile(root) + if err != nil { + return MigrationPlan{}, true, err + } + if has && string(declared) != pin.ProductionMode && plan.Blocker == "" { + plan.Blocker = LegacyPinRelPath + " declares production_mode " + pin.ProductionMode + " while " + provenance.DeclaredModeRelPath + " declares " + string(declared) + "; settle which one holds before the pin is removed" + } + } + human := pin.Name != "" && pin.Email != "" && !IsToolIdentity(RoleAuthor, pin.Name, pin.Email) && !unpinnable(pin.Name) && !unpinnable(pin.Email) + if human { + plan.Owner, err = ownsPin(root, pin) + if err != nil { + return MigrationPlan{}, true, err + } + } + rootSHA, err := gitutil.RootCommit(root) + if err != nil { + return MigrationPlan{}, true, err + } + plan.Target = MachineRecordRef() + if ref, err := RepoRecordRef(rootSHA); err == nil { + plan.Target = ref + } + if plan.Owner { + plan.Existing, plan.ExistingFound, err = ReadRecordAt(home, plan.Target) + if err != nil { + return MigrationPlan{}, true, err + } + } + return plan, true, nil +} + +// ownsPin reports whether the person's git identity is the pinned one: the +// identity git resolves in the repository (which the old gate held to the pin +// on its owner's machine), or their global user.name/user.email. +func ownsPin(root string, pin LegacyPin) (bool, error) { + eff, err := EffectiveIdentity(root) + if err != nil { + return false, err + } + if eff.Name == pin.Name && eff.Email == pin.Email { + return true, nil + } + name, err := gitConfigScope(root, "--global", "user.name") + if err != nil { + return false, err + } + email, err := gitConfigScope(root, "--global", "user.email") + if err != nil { + return false, err + } + return name == pin.Name && email == pin.Email, nil +} + +// MigrationResult says what ApplyMigration did. +type MigrationResult struct { + // Recorded is true when the pin was written as the owner's record at + // RecordAt; false when an identical record was already there (Kept) or a + // different one was (KeptDifferent). + Recorded bool + Kept bool + KeptDifferent bool + RecordAt RecordRef + // ProductionModeMoved is true when the pin's production_mode was written + // to provenance.DeclaredModeRelPath. + ProductionModeMoved bool + // Removed is true when the pin left the working tree. + Removed bool +} + +// ApplyMigration carries out plan for its owner, in this order: the owner's +// record (never overwriting a different one), the production mode into its +// own committed file, then the pin's removal from the working tree. It refuses +// a plan with a Blocker, and one whose person is not the owner: on anyone +// else's machine the pin is neither copied nor removed by abcd. +func ApplyMigration(root, home string, plan MigrationPlan) (MigrationResult, error) { + if plan.Blocker != "" { + return MigrationResult{}, errors.New(plan.Blocker) + } + if !plan.Owner { + return MigrationResult{}, errors.New("the identity this repository stores is not yours, so it is not moved to your machine") + } + res := MigrationResult{RecordAt: plan.Target} + want := Record{Name: plan.Pin.Name, Email: plan.Pin.Email} + switch { + case plan.ExistingFound && plan.Existing == want: + res.Kept = true + case plan.ExistingFound: + res.KeptDifferent = true + default: + if err := WriteRecord(home, plan.Target, want); err != nil { + return res, err + } + res.Recorded = true + } + if plan.Pin.ProductionMode != "" { + _, has, err := provenance.ReadDeclaredModeFile(root) + if err != nil { + return res, err + } + if !has { + if err := provenance.WriteDeclaredMode(root, provenance.Mode(plan.Pin.ProductionMode)); err != nil { + return res, err + } + res.ProductionModeMoved = true + } + } + osRoot, err := os.OpenRoot(root) + if err != nil { + return res, err + } + defer osRoot.Close() + if err := osRoot.Remove(filepath.FromSlash(LegacyPinRelPath)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return res, err + } + res.Removed = true + return res, nil +} diff --git a/internal/core/identity/migrate_test.go b/internal/core/identity/migrate_test.go new file mode 100644 index 0000000000..5b61a82e90 --- /dev/null +++ b/internal/core/identity/migrate_test.go @@ -0,0 +1,189 @@ +package identity + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/gittest" +) + +// committedRepo is gitRepo with one commit, so the repository has a root +// commit to key its own record on, and the identity it commits under also in +// the environment (isolate blanks GIT_AUTHOR_*, which git reads as empty). +func committedRepo(t *testing.T, name, email string) string { + t.Helper() + dir := gitRepo(t, name, email) + t.Setenv("GIT_AUTHOR_NAME", name) + t.Setenv("GIT_AUTHOR_EMAIL", email) + runGitT(t, dir, "commit", "--allow-empty", "-m", "root", "--no-verify") + return dir +} + +// Criterion 4, on the owner's machine: the pin moves to the owner's own record +// for this repository, its production mode moves to the repository's own +// committed file, and the pin leaves the working tree. +func TestMigration_OwnerMovesThePin(t *testing.T) { + isolate(t) + dir := committedRepo(t, "A Maintainer", "maintainer@example.com") + writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com","production_mode":"dictated-and-formatted"}`) + home := recordHome(t) + plan, found, err := PlanMigration(dir, home) + if err != nil || !found { + t.Fatalf("PlanMigration: found=%v err=%v", found, err) + } + if !plan.Owner || plan.Blocker != "" || plan.Target.Scope != ScopeRepository { + t.Fatalf("want an owner plan for the repository's own record, got %+v", plan) + } + res, err := ApplyMigration(dir, home, plan) + if err != nil { + t.Fatal(err) + } + if !res.Recorded || !res.ProductionModeMoved || !res.Removed { + t.Fatalf("migration did not do all three: %+v", res) + } + if _, err := os.Stat(filepath.Join(dir, LegacyPinRelPath)); !os.IsNotExist(err) { + t.Fatalf("the pin is still in the repository: %v", err) + } + m, err := provenance.DeclaredMode(dir) + if err != nil || m != provenance.ModeDictatedAndFormatted { + t.Fatalf("the production mode was not kept: %q %v", m, err) + } + got, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if got.Status != StatusOK || got.RecordAt != plan.Target { + t.Fatalf("after the move the owner's commits are not checked against their record: %v at %+v", got.Status, got.RecordAt) + } +} + +// Criterion 4, on anyone else's machine: the pin is not the person's, so it +// is not copied to their machine and abcd does not move it. +func TestMigration_NonOwnerIsNotGivenThePin(t *testing.T) { + isolate(t) + dir := committedRepo(t, "Second Contributor", "second@example.com") + writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + home := recordHome(t) + plan, found, err := PlanMigration(dir, home) + if err != nil || !found { + t.Fatalf("PlanMigration: found=%v err=%v", found, err) + } + if plan.Owner { + t.Fatalf("a contributor was taken for the pin's owner: %+v", plan) + } + if _, err := ApplyMigration(dir, home, plan); err == nil { + t.Fatal("ApplyMigration must refuse a person who is not the owner") + } + if _, _, found, err := LoadRecord(home, gittestRoot(t, dir)); found || err != nil { + t.Fatalf("the maintainer's identity reached a contributor's machine: found=%v err=%v", found, err) + } + if _, err := os.Stat(filepath.Join(dir, LegacyPinRelPath)); err != nil { + t.Fatalf("the pin was removed on a contributor's machine: %v", err) + } +} + +// A pin holding a member the migration cannot carry is left in place: removing +// it would drop a setting (criterion 4: keeps the repository's other settings). +func TestMigration_UnknownMemberBlocks(t *testing.T) { + isolate(t) + dir := committedRepo(t, "A Maintainer", "maintainer@example.com") + writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com","signing_key":"x"}`) + home := recordHome(t) + plan, _, err := PlanMigration(dir, home) + if err != nil { + t.Fatal(err) + } + if plan.Blocker == "" { + t.Fatalf("want a blocker naming signing_key, got %+v", plan) + } + if _, err := ApplyMigration(dir, home, plan); err == nil { + t.Fatal("ApplyMigration must refuse a blocked plan") + } + if _, err := os.Stat(filepath.Join(dir, LegacyPinRelPath)); err != nil { + t.Fatalf("a blocked pin was removed: %v", err) + } +} + +// The owner's own record wins: a different record already at the target is +// kept, and the pin still leaves the repository. +func TestMigration_KeepsADifferentRecord(t *testing.T) { + isolate(t) + dir := committedRepo(t, "A Maintainer", "maintainer@example.com") + writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) + home := recordHome(t) + ref, err := RepoRecordRef(gittestRoot(t, dir)) + if err != nil { + t.Fatal(err) + } + writeHomeFile(t, ref.Rel, `{"name":"A Maintainer","email":"maintainer@work.example"}`) + plan, _, err := PlanMigration(dir, home) + if err != nil { + t.Fatal(err) + } + res, err := ApplyMigration(dir, home, plan) + if err != nil { + t.Fatal(err) + } + if !res.KeptDifferent || res.Recorded || !res.Removed { + t.Fatalf("want the existing record kept and the pin removed: %+v", res) + } + got, _, _, _ := LoadRecord(home, gittestRoot(t, dir)) + if got.Email != "maintainer@work.example" { + t.Fatalf("the person's own record was overwritten: %+v", got) + } +} + +// ProposeRecord offers the person's usual git identity, the global one first, +// and never a machine identity (criterion 1). +func TestProposeRecord(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Repo Local", "local@example.com") + p, ok, err := ProposeRecord(dir) + if err != nil || !ok || p.Identity.Name != "Repo Local" { + t.Fatalf("with no global identity, want the repository's: %+v ok=%v err=%v", p, ok, err) + } + machine := gitRepo(t, "Claude", "noreply@anthropic.com") + if _, ok, err := ProposeRecord(machine); ok || err != nil { + t.Fatalf("a machine identity was proposed: ok=%v err=%v", ok, err) + } +} + +func gittestRoot(t *testing.T, dir string) string { + t.Helper() + return gittest.RootCommit(t, dir) +} + +// The committed pin is read through the guarded reader: a symlinked pin, even +// one pointing at a well-formed pin inside the working tree, or a FIFO, is +// refused rather than followed or opened. +func TestLoadLegacyPin_ReadsThroughTheGuardedReader(t *testing.T) { + isolate(t) + dir := gitRepo(t, "A Maintainer", "maintainer@example.com") + real := filepath.Join(dir, "elsewhere.json") + if err := os.WriteFile(real, []byte(`{"name":"A Maintainer","email":"maintainer@example.com"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + pin := filepath.Join(dir, filepath.FromSlash(LegacyPinRelPath)) + // A relative target inside the working tree, which an os.Root alone follows. + if err := os.Symlink(filepath.Join("..", "..", filepath.Base(real)), pin); err != nil { + t.Fatal(err) + } + if _, _, err := LoadLegacyPin(dir); err == nil { + t.Fatal("a symlinked pin was followed") + } + if err := os.Remove(pin); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pin, []byte(`{"name":"A Maintainer","email":"maintainer@example.com","pad":"`+strings.Repeat("x", 70<<10)+`"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := LoadLegacyPin(dir); err == nil { + t.Fatal("a pin over 64 KiB was read") + } +} diff --git a/internal/core/identity/record.go b/internal/core/identity/record.go new file mode 100644 index 0000000000..81f97d173e --- /dev/null +++ b/internal/core/identity/record.go @@ -0,0 +1,230 @@ +package identity + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "strings" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// A person's commit identity is THEIR OWN, kept on their own machine, and +// nothing in a repository names who may commit to it (itd-2610240918547728, +// adr-2610241022541063 reverses that part of itd-131). The record is read from +// two places under the user-level home, the more specific first: +// +// - identity/<root-sha>.json — the identity this person commits to ONE +// repository under, keyed on the full object name of that repository's +// root commit (as the worktree and transcript stores are), so every clone +// and worktree of it agrees; it is how a person with a work and a personal +// identity keeps each where it belongs; +// - identity.json — the identity this person commits under everywhere else +// on this machine: the machine-wide default. +// +// Both hold {"name": "...", "email": "..."} and nothing else. The committed +// .githooks/pre-commit guard reads the same two files with the same order +// and refuses on the same terms, so the two cannot disagree about who the +// person is. +const ( + machineRecordLeaf = "identity.json" + repoRecordDir = "identity" + // recordLimit caps the bytes read from a record: two short strings fit in + // a fraction of it, and the shell guard refuses a larger file too. + recordLimit = 4096 +) + +// Record is one person's own commit identity. +type Record struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// RecordScope says which of the two records a RecordRef names. +type RecordScope string + +const ( + // ScopeMachine is the machine-wide default record. + ScopeMachine RecordScope = "machine" + // ScopeRepository is the record for one repository, by its root commit. + ScopeRepository RecordScope = "repository" +) + +// RecordRef names one record file: its scope, its slash path relative to the +// person's home directory (the form fsutil's home-scope primitives take), and +// the tilde form a message shows. +type RecordRef struct { + Scope RecordScope `json:"scope"` + Rel string `json:"rel"` + Display string `json:"display"` +} + +// MachineRecordRef is the machine-wide default record. +func MachineRecordRef() RecordRef { + return RecordRef{Scope: ScopeMachine, Rel: abcdhome.Rel(machineRecordLeaf), Display: abcdhome.Display(machineRecordLeaf)} +} + +// RepoRecordRef is the record for the repository whose root commit is rootSHA. +// The name must be a full object name (40 or 64 lowercase hex digits): it +// becomes a file name, so nothing else is accepted. +func RepoRecordRef(rootSHA string) (RecordRef, error) { + if !validRootSHA(rootSHA) { + return RecordRef{}, fmt.Errorf("%q is not a full root-commit object name", rootSHA) + } + leaf := rootSHA + ".json" + return RecordRef{Scope: ScopeRepository, Rel: abcdhome.Rel(repoRecordDir, leaf), Display: abcdhome.Display(repoRecordDir, leaf)}, nil +} + +// RecordRefs is the lookup order for a repository: its own record, when the +// repository has a root commit, then the machine-wide default. +func RecordRefs(rootSHA string) []RecordRef { + var refs []RecordRef + if ref, err := RepoRecordRef(rootSHA); err == nil { + refs = append(refs, ref) + } + return append(refs, MachineRecordRef()) +} + +func validRootSHA(s string) bool { + if len(s) != 40 && len(s) != 64 { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + +// ErrRecordRefused wraps every record that is there and cannot be trusted or +// read: the gate fails closed on it, exactly as the shell guard does. +var ErrRecordRefused = errors.New("identity record refused") + +// LoadRecord returns the record that applies to the repository whose root +// commit is rootSHA ("" for a repository with no commit yet), for the person +// whose home directory is home: the repository's own record, else the +// machine-wide default. found is false when neither exists. +// +// Each file is read through fsutil.ReadHomeDeclaration, the guard every +// home-scoped declaration takes (the rules loader's own): a regular file this +// account owns that no other account can write, within the size cap, with no +// symlinked directory between home and it. A record that is there and fails a +// guard, or does not parse, or lacks a field, is an error wrapping +// ErrRecordRefused, never a fall-through to the next file: a record the gate +// cannot read must not read as no record. +func LoadRecord(home, rootSHA string) (rec Record, ref RecordRef, found bool, err error) { + if home == "" { + return Record{}, RecordRef{}, false, nil + } + for _, ref := range RecordRefs(rootSHA) { + rec, ok, err := readRecord(home, ref) + if err != nil { + return Record{}, ref, false, err + } + if ok { + return rec, ref, true, nil + } + } + return Record{}, RecordRef{}, false, nil +} + +// ReadRecordAt reads the one record ref names, with LoadRecord's guards. +func ReadRecordAt(home string, ref RecordRef) (Record, bool, error) { + return readRecord(home, ref) +} + +func readRecord(home string, ref RecordRef) (Record, bool, error) { + data, refusal, err := fsutil.ReadHomeDeclaration(home, ref.Rel, recordLimit) + switch refusal { + case fsutil.DeclarationOK: + case fsutil.DeclarationAbsent: + return Record{}, false, nil + default: + return Record{}, false, fmt.Errorf("%w: %s cannot be trusted or read (%s); it must be a regular file you own that no other account can write, with no symlinked folder above it", ErrRecordRefused, ref.Display, errText(err)) + } + var r Record + if err := json.Unmarshal(data, &r); err != nil { + return Record{}, false, fmt.Errorf("%w: %s is not valid JSON: %v", ErrRecordRefused, ref.Display, err) + } + r.Name = strings.TrimSpace(r.Name) + r.Email = strings.TrimSpace(r.Email) + if r.Name == "" || r.Email == "" { + return Record{}, false, fmt.Errorf("%w: %s must set both name and email", ErrRecordRefused, ref.Display) + } + return r, true, nil +} + +// WriteRecord writes r as the record ref names, for the person whose home +// directory is home, creating the user-level home (and the per-repository +// folder) at abcdhome.DirMode and the file at abcdhome.FileMode. It refuses a +// record the shell guard could not read back (a double-quote, backslash or +// control character), an incomplete one, and a machine identity: the record +// names a person, and a machine is the one thing it must never name. +// +// The write goes through the home-scope primitives, so a symlinked level is +// refused rather than written through, and it is atomic, so a crash never +// leaves a half-written record the gate would then refuse. +func WriteRecord(home string, ref RecordRef, r Record) error { + if home == "" { + return fmt.Errorf("the home directory could not be resolved, so %s has nowhere to go", ref.Display) + } + r.Name = strings.TrimSpace(r.Name) + r.Email = strings.TrimSpace(r.Email) + if r.Name == "" || r.Email == "" { + return fmt.Errorf("an identity record needs both a name and an email") + } + if unpinnable(r.Name) || unpinnable(r.Email) { + return fmt.Errorf("an identity record must not hold a double-quote, backslash or control character (the pre-commit guard could not read it back); adjust git config user.name/user.email") + } + if IsToolIdentity(RoleAuthor, r.Name, r.Email) { + return fmt.Errorf("%s <%s> is a machine identity, not a person's; an identity record names the person who commits", r.Name, r.Email) + } + if !fsutil.ValidRelPath(ref.Rel) { + return fmt.Errorf("%q is not a place in the home to write", ref.Rel) + } + body, err := marshalRecord(r) + if err != nil { + return err + } + dir, err := fsutil.EnsureHomeScope(home, path.Dir(ref.Rel), abcdhome.DirMode) + if err != nil { + return err + } + defer dir.Close() + return fsutil.WriteFileAtomicInRoot(dir, path.Base(ref.Rel), body, abcdhome.FileMode) +} + +// marshalRecord renders r pretty-printed with a trailing newline and WITHOUT +// HTML escaping, so &, <, > (legal in a git user.name) are stored literally and +// the shell guard's sed reads back exactly what git config holds (iss-63). +func marshalRecord(r Record) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(r); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// UserHome is the person's home directory, or "" when it cannot be resolved. +func UserHome() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +func errText(err error) string { + if err == nil { + return "unknown error" + } + return err.Error() +} diff --git a/internal/core/positioning/block.go b/internal/core/positioning/block.go index e59394db9e..b47e9c6609 100644 --- a/internal/core/positioning/block.go +++ b/internal/core/positioning/block.go @@ -8,8 +8,9 @@ // advisory by default and never rewrites a surface: a re-render is always a // proposed diff a human adopts (spc-19, itd-102). // -// This is repo POSITIONING, not commit-author identity: the git author gate -// pinned in .abcd/config/identity.json lives in internal/core/identity and +// This is repo POSITIONING, not commit-author identity: the git author gate, +// which checks each person's own record on their machine, lives in +// internal/core/identity and // shares nothing with this package but the English word. package positioning diff --git a/internal/core/positioning/config.go b/internal/core/positioning/config.go index 370acdfe2f..c52d5e205f 100644 --- a/internal/core/positioning/config.go +++ b/internal/core/positioning/config.go @@ -14,8 +14,9 @@ import ( // ConfigRelPath is the committed positioning configuration, relative to the repo // root. It sits beside the repo's other per-concern lint configuration // (.abcd/docs-lint.json, .abcd/record-lint.json, .abcd/rules.json) rather than -// inside .abcd/config/, where identity.json already means the git commit-author -// pin — two different things must not share a filename. +// inside .abcd/config/, where identity.json meant the git commit-author pin +// (now each person's own record, kept on their machine) — two different +// things must not share a filename. const ConfigRelPath = ".abcd/positioning.json" // maxConfigBytes caps the guarded config read. The configuration is a short diff --git a/internal/core/provenance/declared.go b/internal/core/provenance/declared.go new file mode 100644 index 0000000000..988f2888ed --- /dev/null +++ b/internal/core/provenance/declared.go @@ -0,0 +1,130 @@ +package provenance + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// DeclaredModeRelPath is the committed file a repository declares its DEFAULT +// production mode in: how the text of a record it mints was produced, unless +// the minting verb says otherwise (itd-178). It holds one member, +// {"production_mode": "<mode>"}. +// +// The default used to ride the committed identity pin +// (.abcd/config/identity.json). That pin left the repository when each +// person's commit identity moved to their own machine (itd-2610240918547728, +// adr-2610241022541063), and the production mode, which is the repository's +// and not a person's, moved here so it stays committed. +const DeclaredModeRelPath = ".abcd/config/provenance.json" + +// LegacyPinRelPath is the identity pin the production mode used to ride. It +// is read here ONLY for its production_mode member, and only while the new +// file is absent, so a repository that has not yet run the migration keeps the +// mode it declared. +const LegacyPinRelPath = ".abcd/config/identity.json" + +// declaredLimit caps the bytes read from either file. +const declaredLimit = 64 << 10 + +type declaredFile struct { + ProductionMode string `json:"production_mode,omitempty"` +} + +// DeclaredMode is the repo's default production mode: the member of +// DeclaredModeRelPath, else (that file absent) the production_mode member of +// the legacy identity pin, else DefaultMode. It is the ONE resolution of that +// question, so no surface re-derives "absent means hand-written" for itself. +// A present value outside the closed set, or a file that does not parse, is +// an error, never a silent default. +func DeclaredMode(root string) (Mode, error) { + m, found, err := ReadDeclaredModeFile(root) + if err != nil || found { + return m, err + } + data, ok, err := readRepoConfig(root, LegacyPinRelPath) + if err != nil { + return "", err + } + if !ok { + return DefaultMode, nil + } + var f declaredFile + if err := json.Unmarshal(data, &f); err != nil { + return "", fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) + } + m, err = ModeOrDefault(strings.TrimSpace(f.ProductionMode)) + if err != nil { + return "", fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) + } + return m, nil +} + +// ReadDeclaredModeFile reads DeclaredModeRelPath alone: found is false when +// the file is absent. A file that is present declares a mode; one whose member +// is missing or outside the closed set is an error. +func ReadDeclaredModeFile(root string) (Mode, bool, error) { + data, ok, err := readRepoConfig(root, DeclaredModeRelPath) + if err != nil || !ok { + return "", false, err + } + var f declaredFile + if err := json.Unmarshal(data, &f); err != nil { + return "", false, fmt.Errorf("malformed %s: %w", DeclaredModeRelPath, err) + } + m, err := ParseMode(strings.TrimSpace(f.ProductionMode)) + if err != nil { + return "", false, fmt.Errorf("malformed %s: %w", DeclaredModeRelPath, err) + } + return m, true, nil +} + +// WriteDeclaredMode writes m as the repo's declared default production mode, +// atomically and inside an os.Root at the repository, so a committed symlink +// under .abcd cannot aim the write outside the working tree. +func WriteDeclaredMode(root string, m Mode) error { + if _, err := ParseMode(string(m)); err != nil { + return err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + if err := enc.Encode(declaredFile{ProductionMode: string(m)}); err != nil { + return err + } + osRoot, err := os.OpenRoot(root) + if err != nil { + return err + } + defer osRoot.Close() + return fsutil.WriteFileAtomicInRoot(osRoot, DeclaredModeRelPath, buf.Bytes(), 0o644) +} + +// readRepoConfig reads rel through the guarded reader +// (fsutil.ReadGuardedInRoot): inside an os.Root at root, a symlinked file +// refused rather than followed, only a regular file accepted, capped at 64 KiB. +// ok is false when the file is absent. +func readRepoConfig(root, rel string) ([]byte, bool, error) { + osRoot, err := os.OpenRoot(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + defer osRoot.Close() + data, err := fsutil.ReadGuardedInRoot(osRoot, rel, declaredLimit) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, fmt.Errorf("reading %s: %w", rel, err) + } + return data, true, nil +} diff --git a/internal/core/provenance/declared_test.go b/internal/core/provenance/declared_test.go new file mode 100644 index 0000000000..60e05facb6 --- /dev/null +++ b/internal/core/provenance/declared_test.go @@ -0,0 +1,103 @@ +package provenance + +import ( + "os" + "path/filepath" + "testing" +) + +func writeRepoFile(t *testing.T, root, rel, body string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// The repo's declared production mode is committed in its own file, now the +// identity it used to ride lives on each person's machine. +func TestDeclaredModeReadsItsOwnFile(t *testing.T) { + dir := t.TempDir() + writeRepoFile(t, dir, DeclaredModeRelPath, `{"production_mode":"dictated-and-formatted"}`) + got, err := DeclaredMode(dir) + if err != nil || got != ModeDictatedAndFormatted { + t.Fatalf("DeclaredMode = %q, %v; want dictated-and-formatted", got, err) + } +} + +// A repository that has not yet migrated keeps the mode its legacy pin +// declared; the new file, once present, wins. +func TestDeclaredModeFallsBackToTheLegacyPin(t *testing.T) { + dir := t.TempDir() + writeRepoFile(t, dir, LegacyPinRelPath, `{"name":"A Maintainer","email":"m@example.com","production_mode":"scribe-transcribed"}`) + got, err := DeclaredMode(dir) + if err != nil || got != ModeScribeTranscribed { + t.Fatalf("legacy fallback = %q, %v; want scribe-transcribed", got, err) + } + writeRepoFile(t, dir, DeclaredModeRelPath, `{"production_mode":"hand-written"}`) + got, err = DeclaredMode(dir) + if err != nil || got != ModeHandWritten { + t.Fatalf("with both = %q, %v; want the new file's hand-written", got, err) + } +} + +// Absent everywhere means the default; an out-of-vocabulary value in either +// file is an error, never a silent default. +func TestDeclaredModeDefaultsAndRefuses(t *testing.T) { + if got, err := DeclaredMode(t.TempDir()); err != nil || got != DefaultMode { + t.Fatalf("no file = %q, %v; want %q", got, err, DefaultMode) + } + pinOnly := t.TempDir() + writeRepoFile(t, pinOnly, LegacyPinRelPath, `{"name":"A Maintainer","email":"m@example.com"}`) + if got, err := DeclaredMode(pinOnly); err != nil || got != DefaultMode { + t.Fatalf("a pin with no member = %q, %v; want %q", got, err, DefaultMode) + } + for _, rel := range []string{DeclaredModeRelPath, LegacyPinRelPath} { + dir := t.TempDir() + writeRepoFile(t, dir, rel, `{"production_mode":"typed"}`) + if _, err := DeclaredMode(dir); err == nil { + t.Fatalf("%s: want an error on an out-of-vocabulary mode", rel) + } + } + empty := t.TempDir() + writeRepoFile(t, empty, DeclaredModeRelPath, `{}`) + if _, err := DeclaredMode(empty); err == nil { + t.Fatal("a declared-mode file that declares nothing must be an error") + } +} + +func TestWriteDeclaredModeRoundTrips(t *testing.T) { + dir := t.TempDir() + if err := WriteDeclaredMode(dir, ModeScribeTranscribed); err != nil { + t.Fatal(err) + } + got, found, err := ReadDeclaredModeFile(dir) + if err != nil || !found || got != ModeScribeTranscribed { + t.Fatalf("round trip = %q found=%v err=%v", got, found, err) + } + if err := WriteDeclaredMode(dir, Mode("typed")); err == nil { + t.Fatal("an out-of-vocabulary mode must not be written") + } +} + +// Both files are read through the guarded reader: a symlinked declaration is +// refused rather than followed. +func TestDeclaredModeRefusesASymlink(t *testing.T) { + for _, rel := range []string{DeclaredModeRelPath, LegacyPinRelPath} { + dir := t.TempDir() + writeRepoFile(t, dir, "real.json", `{"production_mode":"scribe-transcribed"}`) + if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + // A relative target inside the working tree, which an os.Root alone follows. + if err := os.Symlink(filepath.Join("..", "..", "real.json"), filepath.Join(dir, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + if _, err := DeclaredMode(dir); err == nil { + t.Fatalf("%s: a symlinked declaration was followed", rel) + } + } +} diff --git a/internal/surface/cli/ahoy_identity_gate_test.go b/internal/surface/cli/ahoy_identity_gate_test.go index 8ba6bc02a9..3afa5a067c 100644 --- a/internal/surface/cli/ahoy_identity_gate_test.go +++ b/internal/surface/cli/ahoy_identity_gate_test.go @@ -8,12 +8,14 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/gittest" ) // identityGateRepo is a hermetic, real git repo whose user.* config is the -// given identity and whose committed pin is Alex Reppel, with every identity -// environment override cleared so a case sets only what it means to. +// given identity, for a person who recorded Alex Reppel as who they commit as +// on this machine, with every identity environment override cleared so a case +// sets only what it means to. func identityGateRepo(t *testing.T, name, email string) string { t.Helper() for _, k := range []string{"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"} { @@ -21,14 +23,21 @@ func identityGateRepo(t *testing.T, name, email string) string { } repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, name, email) - if err := os.MkdirAll(filepath.Join(repo, ".abcd", "config"), 0o755); err != nil { + writeOwnRecord(t, `{"name":"Alex Reppel","email":"alex@example.com"}`+"\n") + return repo +} + +// writeOwnRecord writes body as the person's machine-wide identity record +// under the test's HOME. +func writeOwnRecord(t *testing.T, body string) { + t.Helper() + p := filepath.Join(os.Getenv("HOME"), filepath.FromSlash(identity.MachineRecordRef().Rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repo, ".abcd", "config", "identity.json"), - []byte(`{"name":"Alex Reppel","email":"alex@example.com"}`+"\n"), 0o644); err != nil { + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatal(err) } - return repo } // TestAhoyIdentityRefusesADivergentCommitter: the gate's canonical entrypoint diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index ef85b4271a..683bb1e60a 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/gittest" ) @@ -208,10 +209,10 @@ func gitRepoWithIdentity(t *testing.T, repo, name, email string) { } // TestAhoyInstallYesDisclosesOptionalIdentityPin is the iss-166 ruling in the -// output: --yes does NOT cover the optional git-identity pin (it would capture +// output: --yes does NOT record who the person commits as (it would capture // whatever identity happens to be configured), so a --yes run that reports -// "already up to date" must say the optional pin was excluded and how to apply -// it. Silence there is what made the skip ambiguous. +// "already up to date" must say the record was excluded and how to make it. +// Silence there is what made the skip ambiguous. func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") @@ -224,8 +225,8 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { if out, err := runCLIErr(t, base...); err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, out) } - if _, err := os.Stat(filepath.Join(repo, ".abcd", "config", "identity.json")); err == nil { - t.Fatal("precondition: --yes must not pin the current git identity") + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), filepath.FromSlash(identity.MachineRecordRef().Rel))); err == nil { + t.Fatal("precondition: --yes must not record the current git identity") } // Second --yes run: "already up to date", and it must name the exclusion. @@ -237,8 +238,8 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { if !strings.Contains(text, "already_up_to_date") { t.Fatalf("precondition: second --yes run should report already_up_to_date:\n%s", text) } - if !strings.Contains(text, "git_identity.unpinned") { - t.Fatalf("--yes silently skipped the optional identity pin; output names no exclusion:\n%s", text) + if !strings.Contains(text, "git_identity.unrecorded") { + t.Fatalf("--yes silently skipped the optional identity record; output names no exclusion:\n%s", text) } if !strings.Contains(text, "abcd ahoy install") { t.Fatalf("the exclusion notice must say how to apply the optional pin:\n%s", text) @@ -261,7 +262,7 @@ func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { // is the drain eligibility record (ruling BX2), which decides what an // unattended agent may change; and so is the README question // (spc-2610031704338417), which only the owner answers. - want := "git_identity.unpinned readme.offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" + want := "git_identity.unrecorded readme.offered oracle_routing.machine_offered oracle_routing.repo_offered drain_rule.offered" if strings.Join(res.OptionalSkipped, " ") != want { t.Fatalf("optional_skipped = %v, want [%s]\n%s", res.OptionalSkipped, want, jsonOut) } @@ -294,10 +295,11 @@ func TestAhoyInstallYesHelpStatesTheExclusion(t *testing.T) { } } -// TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin closes the loop between -// the two issues: the way to apply the optional pin without a terminal is the -// piped answer iss-167 makes work. -func TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin(t *testing.T) { +// TestAhoyInstallPipedAnswersNeverRecordTheIdentity: who a person commits as +// is recorded only on their own answer at a terminal (itd-2610240918547728, +// criterion 1), so a piped `yes` records nothing, writes nothing into the +// repository, and the run names the record it left for the person. +func TestAhoyInstallPipedAnswersNeverRecordTheIdentity(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") @@ -306,28 +308,19 @@ func TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin(t *testing.T) { "--oracle-backend", "host-delegated", "--scan-deep", "false"); err != nil { t.Fatalf("install exited non-zero: %v\n%s", err, out) } - - // Drive it exactly as the completion notice says to: `yes | abcd ahoy - // install`, an answer for every question asked. More than one category is - // still open after a --yes run (the dependency scanners as well as the pin), - // which is why the documented remedy is `yes` and not a single `y`. - _, errOut, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 8), "ahoy", "install") + out, errOut, err := runCLIPipedStdinSplit(t, strings.Repeat("y\n", 8), "ahoy", "install") if err != nil { t.Fatalf("piped re-install exited non-zero: %v\n%s", err, errOut) } - // The pin lives behind the config-change approval, so that question must - // have been asked and answered y — not merely "some question was". - if !strings.Contains(string(errOut), " no git identity pin\n") || - !strings.Contains(string(errOut), "Change the settings listed above? [y/N] y") { - t.Fatalf("the config-change approval carrying the pin was not answered:\n%s", errOut) - } assertCategoryQuestionOrder(t, string(errOut)) - body, err := os.ReadFile(filepath.Join(repo, ".abcd", "config", "identity.json")) - if err != nil { - t.Fatalf("piped `y` did not adopt the optional identity pin: %v", err) + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), filepath.FromSlash(identity.MachineRecordRef().Rel))); err == nil { + t.Fatal("a piped yes recorded who the person commits as") + } + if _, err := os.Stat(filepath.Join(repo, ".abcd", "config", "identity.json")); err == nil { + t.Fatal("an identity was written into the repository") } - if !strings.Contains(string(body), "alex@example.com") { - t.Fatalf("pin written from the wrong identity:\n%s", body) + if all := string(out) + string(errOut); !strings.Contains(all, "git_identity.unrecorded") { + t.Fatalf("the run does not name the record it left for the person:\n%s", all) } } diff --git a/internal/surface/cli/capture_surface_test.go b/internal/surface/cli/capture_surface_test.go index dbf267f243..e0aafe75d5 100644 --- a/internal/surface/cli/capture_surface_test.go +++ b/internal/surface/cli/capture_surface_test.go @@ -940,6 +940,23 @@ func TestCaptureProductionModeDefaultsToThePin(t *testing.T) { } } +// TestCaptureProductionModeDefaultsToTheDeclaredFile: the repository's own +// committed declaration, which the mode moved to when the identity left the +// repository (itd-2610240918547728), is the default. +func TestCaptureProductionModeDefaultsToTheDeclaredFile(t *testing.T) { + repo := captureLedgerRepo(t) + if err := os.MkdirAll(filepath.Join(repo, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config", "provenance.json"), []byte(`{"production_mode":"dictated-and-formatted"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + rec := captureRecordFields(t, repo, "a finding taking the declared default") + if rec["production_mode"] != "dictated-and-formatted" { + t.Errorf("production_mode = %q, want the declared dictated-and-formatted", rec["production_mode"]) + } +} + // captureRecordFields files an issue through the CLI and returns the written // record's frontmatter as key/value pairs, read off the bytes that landed. func captureRecordFields(t *testing.T, repo, text string, extra ...string) map[string]string { diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 59600139ae..ba065836f0 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3319,8 +3319,9 @@ const ideateRoutingRule = " a big, unproven idea? `abcd ideate` runs the option // resolveProductionMode turns the --production-mode flag into the value a MINT // path stamps: the operator's declared choice, or the repo's own declared -// default from the identity pin (itd-91's seam), which is hand-written when the -// pin declares none. +// default (provenance.DeclaredMode: .abcd/config/provenance.json, else the +// legacy identity pin's member), which is hand-written when neither declares +// one. // // The value is validated HERE as well as in the core, because a surface refusal // costs nothing and names the closed set before any id is minted — and because @@ -3338,7 +3339,7 @@ func resolveProductionMode(repoRoot, flag string) (string, error) { } return string(m), nil } - m, err := identity.DeclaredProductionMode(repoRoot) + m, err := provenance.DeclaredMode(repoRoot) if err != nil { return "", &exitError{Code: 2, Msg: "abcd: " + err.Error() + " (nothing written)"} } @@ -4069,7 +4070,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { ahoyCmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the detection result as its JSON envelope, whether or not --json is passed") ahoyCmd.Flags().BoolVar(&identityMode, "identity", false, - "check git's commit author and committer against .abcd/config/identity.json, exiting non-zero when either diverges (for a pre-commit hook or CI)") + "check git's commit author and committer against the identity you recorded on this machine, exiting non-zero when either diverges or is a machine identity (for a pre-commit hook or CI)") ahoyCmd.Flags().BoolVar(&remoteMode, "remote", false, "report this repository's GitHub secret-scanning settings and what the remote apply sub-verb would change") ahoyCmd.Flags().BoolVar(&providersMode, "providers", false, @@ -4258,7 +4259,7 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { // No backquotes in a flag's usage string: cobra reads the first backquoted // word as the flag's argument placeholder, so a quoted answer would render // this boolean as "--yes y" in the help and the generated reference. - installCmd.Flags().BoolVar(&yes, "yes", false, "approve every resolvable change category without prompting; excludes the optional git-identity pin, the status line and the model-tier routing tables, which need an answered prompt (run without --yes, or answer every prompt with: yes | abcd ahoy install); it never changes the repository's git identity, which is proposed only to a person at a terminal") + installCmd.Flags().BoolVar(&yes, "yes", false, "approve every resolvable change category without prompting; excludes recording who you commit as on this machine (asked only at a terminal), the status line and the model-tier routing tables, which need an answered prompt (run without --yes, or answer every prompt with: yes | abcd ahoy install); it never changes the repository's git identity, which is proposed only to a person at a terminal") installCmd.Flags().BoolVar(&adopt, "adopt", false, "adopt an unmanaged repo without prompting") installCmd.Flags().BoolVar(&refuseAdopt, "refuse-adopt", false, "decline to adopt an unmanaged repo") installCmd.Flags().BoolVar(&dev, "dev", false, "track-latest dogfood mode: the PATH entry rebuilds from the source tip on every call instead of pinning the built binary") @@ -4356,42 +4357,49 @@ func runAhoyDryRun(cmd *cobra.Command, cwd string) error { return enc.Encode(res) } -// runAhoyIdentity is `ahoy --identity`, the iss-62 gate's canonical, testable -// entrypoint. It exits non-zero when the commit's author or committer diverges -// from the committed pin (itd-131), so a pre-commit hook (or CI) can fail -// closed. A match, or an un-pinned repo, exits zero; a machine identity is named -// either way, because the routine case must be visible where the gate runs. +// runAhoyIdentity is `ahoy --identity`, the identity gate's canonical, +// testable entrypoint. It exits non-zero when the commit's author or committer +// is not the identity the person recorded on their own machine, or is a +// machine identity in either role (itd-2610240918547728), so a pre-commit hook +// (or CI) can fail closed. A match, or a human identity with no record yet, +// exits zero. Nothing it prints names anyone but the person running it. func runAhoyIdentity(cmd *cobra.Command, cwd string) error { res, err := identity.Check(cwd) if err != nil { return err } if res.Blocks() { - var why []string + var why, fix []string if res.Status == identity.StatusMismatch || res.Status == identity.StatusUnset { why = append(why, res.Reason) } - fix := fmt.Sprintf("git config user.name %q && git config user.email %q", res.Pin.Name, res.Pin.Email) - if res.CommitterDiverges { + for _, t := range []struct { + is bool + who identity.Effective + as string + }{{res.AuthorIsTool, res.Effective, "author"}, {res.CommitterIsTool, res.Committer, "committer"}} { + if t.is { + why = append(why, "the "+t.as+" "+t.who.Name+" <"+t.who.Email+"> is a machine identity; the human is the author of record") + } + } + if res.Recorded { + fix = append(fix, fmt.Sprintf("git config user.name %q && git config user.email %q", res.Record.Name, res.Record.Email)) + } else { + fix = append(fix, "set user.name and user.email (or the GIT_AUTHOR_*/GIT_COMMITTER_* an unattended run's launcher supplies) to your own identity") + } + if res.CommitterDiverges && res.Recorded { why = append(why, res.CommitterReason) - fix += " (and unset any GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL override and committer.name/committer.email key)" + fix = append(fix, "unset any GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL override and committer.name/committer.email key") } - return fmt.Errorf("%s\n fix: %s", termsafe.Sanitize(strings.Join(why, "; ")), termsafe.Sanitize(fix)) + if res.Recorded { + fix = append(fix, "if your record is wrong, edit "+res.RecordAt.Display+", or remove it and run abcd ahoy install at a terminal to be asked again") + } + return fmt.Errorf("%s\n fix: %s", termsafe.Sanitize(strings.Join(why, "; ")), termsafe.Sanitize(strings.Join(fix, "; "))) } fmt.Fprintf(cmd.OutOrStdout(), "identity ok (%s)\n", res.Status) if res.CommitterDiverges { fmt.Fprintf(cmd.OutOrStdout(), " note: %s\n", termsafe.Sanitize(res.CommitterReason)) } - for _, t := range []struct { - is bool - who identity.Effective - as string - }{{res.AuthorIsTool, res.Effective, "author"}, {res.CommitterIsTool, res.Committer, "committer"}} { - if t.is { - fmt.Fprintf(cmd.OutOrStdout(), " note: the %s %s is a machine identity; the human is the author of record (abcd ahoy reports it as %s)\n", - t.as, termsafe.Sanitize(t.who.Name+" <"+t.who.Email+">"), ahoy.ToolIdentityGapID) - } - } return nil } @@ -4596,8 +4604,10 @@ func symlinkNote(r ahoy.UninstallReceipt) string { // so an id this switch does not know renders no reason rather than a wrong one. func optionalSkipReason(id string) string { switch id { - case ahoy.OptionalPinGapID: - return "the pin records the current git identity, so it is only written against an answered prompt" + case ahoy.UnrecordedGapID: + return "it records who you commit as, which only you answer, so it is only written against a prompt answered at a terminal" + case ahoy.LegacyPinGapID: + return "moving the stored identity writes to your home directory and removes a committed file, so it is only done against a prompt answered at a terminal" case ahoy.StatusLineOfferGapID: return "the status line rewrites a setting of the host harness and takes element choices, so it is only written against an answered prompt" case ahoy.OracleRoutingMachineGapID, ahoy.OracleRoutingRepoGapID: diff --git a/internal/surface/cli/consolidate_test.go b/internal/surface/cli/consolidate_test.go index 7d3dcc1f8d..acacea1900 100644 --- a/internal/surface/cli/consolidate_test.go +++ b/internal/surface/cli/consolidate_test.go @@ -196,19 +196,14 @@ func TestAhoyDryRunFlagPrintsTheDetectionEnvelope(t *testing.T) { } // TestAhoyIdentityFlagHoldsTheCommitIdentityToThePin is criterion 1's flag half -// for the identity check: a commit identity that diverges from the pin exits -// non-zero naming the fix, and a matching one passes. +// for the identity check: a commit identity that diverges from the person's own +// record exits non-zero naming the fix and the record, and a matching one +// passes. func TestAhoyIdentityFlagHoldsTheCommitIdentityToThePin(t *testing.T) { hermeticEnv(t) repo := gittest.NewRepo(t) t.Chdir(repo.Root()) - pin := filepath.Join(repo.Root(), filepath.FromSlash(identity.PinRelPath)) - if err := os.MkdirAll(filepath.Dir(pin), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(pin, []byte(`{"name":"Pinned Person","email":"pinned@example.com"}`), 0o644); err != nil { - t.Fatal(err) - } + writeOwnRecord(t, `{"name":"Pinned Person","email":"pinned@example.com"}`) t.Setenv("GIT_AUTHOR_NAME", "Someone Else") t.Setenv("GIT_AUTHOR_EMAIL", "else@example.com") @@ -219,6 +214,9 @@ func TestAhoyIdentityFlagHoldsTheCommitIdentityToThePin(t *testing.T) { if !strings.Contains(err.Error(), `git config user.name "Pinned Person"`) { t.Errorf("the refusal does not name the fix: %v", err) } + if !strings.Contains(err.Error(), identity.MachineRecordRef().Display) { + t.Errorf("the refusal does not name the record and how to change it: %v", err) + } // The author alone matching is not a match: this repo configures no // committer, so git would stamp a fabricated one, and the gate reads the From 65cb33df8d8071cced5d8f78838cc93ea2b60c63 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:10:54 +0200 Subject: [PATCH 08/26] fix: judge the shell settings file again just before it is replaced A link put at the path after the file was judged is refused rather than replaced by a regular file. Assisted-by: Claude:claude-opus-5-5 --- internal/core/shellpath/shellpath.go | 15 ++++++++++ internal/core/shellpath/shellpath_test.go | 35 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/internal/core/shellpath/shellpath.go b/internal/core/shellpath/shellpath.go index 9c10afec28..790ac91cab 100644 --- a/internal/core/shellpath/shellpath.go +++ b/internal/core/shellpath/shellpath.go @@ -452,6 +452,17 @@ func Apply(env Env, p Plan) (Applied, error) { if pruned != nil { out.PruneNote = "older copies of " + shown + " in " + abcdhome.Display("backups") + "/ could not be pruned (" + pruned.Error() + ")" } + judged, err := os.Lstat(p.File) + if err != nil || !judged.Mode().IsRegular() { + return Applied{}, ErrChanged + } + beforeReplace() + // The rename replaces the leaf without following it, so a link put at + // the path since it was judged would be lost rather than written + // through; the leaf is judged again here and refused when it moved. + if now, err := os.Lstat(p.File); err != nil || !now.Mode().IsRegular() || !os.SameFile(judged, now) { + return Applied{}, ErrChanged + } if err := replace(p.File, cur, next, backup); err != nil { return Applied{}, err } @@ -492,6 +503,10 @@ func createFile(path string, data []byte) error { // which is what the read-back exists to catch. var writeFile = fsutil.WriteFileAtomicPreserveMode +// beforeReplace runs between the last judgement of the leaf and the replace. +// A variable so a test can swap the leaf in that moment. +var beforeReplace = func() {} + // replace writes next over path, whose bytes now are cur and whose copy is at // backup, reads it back, and puts cur back when it does not read as written. func replace(path string, cur, next []byte, backup string) error { diff --git a/internal/core/shellpath/shellpath_test.go b/internal/core/shellpath/shellpath_test.go index 1c89c644b8..4143bd7c19 100644 --- a/internal/core/shellpath/shellpath_test.go +++ b/internal/core/shellpath/shellpath_test.go @@ -331,3 +331,38 @@ func TestCopiesArePrunedToTen(t *testing.T) { t.Errorf("another file's copy was pruned: %v", err) } } + +// TestApplyRefusesALinkSwappedInBeforeTheReplace holds the last moment before +// the write: a link put at the path after the file was judged is refused, and +// both the link and the file it names are left as they were. +func TestApplyRefusesALinkSwappedInBeforeTheReplace(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + rc := filepath.Join(e.Home, ".zshrc") + write(t, rc, "export EDITOR=vi\n", 0o644) + kept := filepath.Join(t.TempDir(), "dotfiles-zshrc") + write(t, kept, "export EDITOR=vi\n", 0o644) + + p := PlanFor(e, dir) + if p.State != Offer { + t.Fatalf("plan = %+v, want an offer", p) + } + beforeReplace = func() { + if err := os.Remove(rc); err != nil { + t.Fatal(err) + } + if err := os.Symlink(kept, rc); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { beforeReplace = func() {} }) + + if _, err := Apply(e, p); !errors.Is(err, ErrChanged) { + t.Fatalf("apply over a swapped-in link = %v, want ErrChanged", err) + } + if fi, err := os.Lstat(rc); err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Errorf("the link was replaced: %v %v", fi, err) + } + if got := read(t, kept); got != "export EDITOR=vi\n" { + t.Errorf("the linked file changed: %q", got) + } +} From cf6bd18d82e9d5a13fbf40ed62e835c85445c390 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:29:40 +0200 Subject: [PATCH 09/26] docs: record the findings and deferrals from building the setup redesign Assisted-by: Claude:claude-opus-5-5 --- .../2026-08-15-decomposition-calibration.md | 15 +++++++++++++++ ...8308487-deferred-out-of-the-setup-redesign.md | 6 ++++++ ...85-held-for-the-product-thinker-criteria-3.md | 6 ++++++ ...7195-abcd-reserve-refuses-in-a-fresh-store.md | 16 ++++++++++++++++ ...5056-ahoy-install-reports-clean-in-a-clone.md | 16 ++++++++++++++++ ...1-testhooksessionendretriesatransientrootf.md | 15 +++++++++++++++ ...8083013-the-shell-settings-edit-checks-the.md | 15 +++++++++++++++ ...3-with-no-git-identity-configured-anywhere.md | 15 +++++++++++++++ ...41212237390-there-is-no-command-to-write-a.md | 15 +++++++++++++++ ...339218-the-shell-identity-gate-in-githooks.md | 15 +++++++++++++++ ...47-testhostversionwarning-failed-once-on-a.md | 15 +++++++++++++++ ...056581-a-piped-answer-stream-yes-abcd-ahoy.md | 15 +++++++++++++++ 12 files changed, 164 insertions(+) create mode 100644 .abcd/work/decisions/dcn-2610241158308487-deferred-out-of-the-setup-redesign.md create mode 100644 .abcd/work/decisions/dcn-2610241228598485-held-for-the-product-thinker-criteria-3.md create mode 100644 .abcd/work/issues/open/iss-2610241111367195-abcd-reserve-refuses-in-a-fresh-store.md create mode 100644 .abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md create mode 100644 .abcd/work/issues/open/iss-2610241157493381-testhooksessionendretriesatransientrootf.md create mode 100644 .abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md create mode 100644 .abcd/work/issues/open/iss-2610241212094133-with-no-git-identity-configured-anywhere.md create mode 100644 .abcd/work/issues/open/iss-2610241212237390-there-is-no-command-to-write-a.md create mode 100644 .abcd/work/issues/open/iss-2610241212339218-the-shell-identity-gate-in-githooks.md create mode 100644 .abcd/work/issues/open/iss-2610241212419547-testhostversionwarning-failed-once-on-a.md create mode 100644 .abcd/work/issues/open/iss-2610241229056581-a-piped-answer-stream-yes-abcd-ahoy.md diff --git a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md index 83f8536a81..0f0594ac2a 100644 --- a/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md +++ b/.abcd/development/research/notes/2026-08-15-decomposition-calibration.md @@ -2560,3 +2560,18 @@ check and the away mode inside the checklist itself; carving them into a separate intent followed from the release cut waiting on them, and from planning the draft before its criteria are walked being the worse of the two routes. + +## 2026-10-24: setting abcd up is short, plain, and finishes (product thinker, routing confirmed) + +The proposal: "a thorough rethink of the CLI onboarding process (abcd install); it's complicated, too much text, and doesn't seem to be working. SOTA, rethink and then probably a fully functioning and polished TUI." + +| Part | Type | Home | +| --- | --- | --- | +| setup that is short, plain and finishes | capability | itd-2610240823055889 | +| state-of-the-art research on setup flows and terminal interfaces | research | a research report, summarised in the intent's Research section | +| the polished terminal interface | capability | builds on itd-2610030810370060 | +| today's failures (status-line order, identity hidden, source build refused) | defects | iss-2610240823574047, iss-2610240824005655, iss-2610240824047504 | + +Verdict at filing: SPLIT, confirmed by the product thinker. The initial routing did not survive the two pre-interview reviews: both found the setup intent still held four parts, and on the product thinker's second SPLIT the commit identity (itd-2610240918547728, reversing part of itd-131, confirmed), third-party tool installs (itd-2610240918594138) and the shell-settings edit (itd-2610240919030303, reversing the never-edit-a-shell-profile rule, confirmed) became their own intents. The research's recommendation against a terminal UI framework moved the "polished TUI" part from a new interface to calmer use of the existing question package. Typed links: the three split intents build on itd-2610240823055889. A sixth part, that every flow says up front what it automates and what it asks, was routed to its own draft (itd-2610240913343804) rather than kept in setup. + +Grade: the first routing was right at the level of intent versus issue, and wrong at the level of how many intents one setup rethink holds; both reviewers caught it independently. diff --git a/.abcd/work/decisions/dcn-2610241158308487-deferred-out-of-the-setup-redesign.md b/.abcd/work/decisions/dcn-2610241158308487-deferred-out-of-the-setup-redesign.md new file mode 100644 index 0000000000..fbe04d3ca2 --- /dev/null +++ b/.abcd/work/decisions/dcn-2610241158308487-deferred-out-of-the-setup-redesign.md @@ -0,0 +1,6 @@ +--- +id: dcn-2610241158308487 +slug: deferred-out-of-the-setup-redesign +date: 2026-10-24 +--- +2026-10-24 — Deferred out of the setup redesign: iss-2610241157493381 (the session-end retry test fails under machine load because its retry budget is wall-clock). It lies outside the redesign's footprint (internal/surface/cli/hook_subagent.go, history capture), fails on an unchanged branch, and is recorded so it is fixed on its own rather than stepped over; the redesign's checks report it as a known failure, not a pass. diff --git a/.abcd/work/decisions/dcn-2610241228598485-held-for-the-product-thinker-criteria-3.md b/.abcd/work/decisions/dcn-2610241228598485-held-for-the-product-thinker-criteria-3.md new file mode 100644 index 0000000000..8ac652d47b --- /dev/null +++ b/.abcd/work/decisions/dcn-2610241228598485-held-for-the-product-thinker-criteria-3.md @@ -0,0 +1,6 @@ +--- +id: dcn-2610241228598485 +slug: held-for-the-product-thinker-criteria-3 +date: 2026-10-24 +--- +2026-10-24 — Held for the product thinker: criteria 3 and 5 of itd-2610240823055889 conflict when the assistant's settings file exists. Criterion 3 makes the status line (the assistant's settings) its own person-approved question, and criterion 5 allows four questions (download, shell settings, plan, identity) plus one per tool, so a fresh account can be asked five. The build keeps the status-line question separate, the side on which nothing changes without a yes, and the spec close records criterion 5 as not met in that case until the product thinker rules: count the status line in the budget, fold it into the download question, or move it onto the plan screen. diff --git a/.abcd/work/issues/open/iss-2610241111367195-abcd-reserve-refuses-in-a-fresh-store.md b/.abcd/work/issues/open/iss-2610241111367195-abcd-reserve-refuses-in-a-fresh-store.md new file mode 100644 index 0000000000..3e1cd539df --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241111367195-abcd-reserve-refuses-in-a-fresh-store.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2610241111367195" +slug: "abcd-reserve-refuses-in-a-fresh-store" +severity: "minor" +category: "ux" +source: "agent-observation" +found_during: "setup redesign build, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/reserve.go" +remedy: "create the local tier (.abcd/.work.local/) on demand in reserve, as the transcript store creates itself, or have the documented worktree step create it; name the one command in the refusal rather than ahoy" +refines: [iss-2610222022157889] +--- + +abcd reserve refuses in a fresh store worktree: a worktree made with git worktree add under ~/.abcd.noindex/worktrees/<root-sha>/ has no .abcd/.work.local/, so reserve says 'nowhere to keep this copy's key; run abcd ahoy to set it up' and pushes nothing, although AGENTS.md tells every concurrent session to make exactly such a worktree and reserve before starting. diff --git a/.abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md b/.abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md new file mode 100644 index 0000000000..5afa1df794 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md @@ -0,0 +1,16 @@ +--- +schema_version: 1 +id: "iss-2610241134315056" +slug: "ahoy-install-reports-clean-in-a-clone" +severity: "major" +category: "bug" +source: "agent-observation" +found_during: "setup redesign build, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/detect.go" +remedy: "make an unarmed committed hook set a gap that setup offers to arm (one person-approved question naming core.hooksPath and the clone's .git/config), and never report clean while the hooks it installed do not run" +refines: [iss-2610020704152920, iss-2609120447480903] +--- + +ahoy install reports 'clean' in a clone whose committed hooks are not armed: in abcd's own checkout on 2026-10-24 the detection pass read banlist.hooks_path as 'unarmed' (core.hooksPath unset, so .githooks/pre-commit and pre-merge-commit never run) both before and after setup, yet no gap was listed and the install summary said 'abcd is set up in this repository'. diff --git a/.abcd/work/issues/open/iss-2610241157493381-testhooksessionendretriesatransientrootf.md b/.abcd/work/issues/open/iss-2610241157493381-testhooksessionendretriesatransientrootf.md new file mode 100644 index 0000000000..0e02673c01 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241157493381-testhooksessionendretriesatransientrootf.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241157493381" +slug: "testhooksessionendretriesatransientrootf" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "setup redesign build, shell-settings lane test run, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli/hook_subagent_stage_retry_test.go" +remedy: "give the retry a budget the test controls (an injectable clock or deadline), so the test proves one retry happens regardless of machine load" +--- + +TestHookSessionEndRetriesATransientRootFailure fails on an unchanged branch from origin/main (2026-10-24): the session-end hook's retry after a simulated transient git failure finds 'no time is left to ask again' and stages nothing, so the retry budget is spent before the retry on a loaded machine (several concurrent sessions and test runs); the test asserts timing-dependent behaviour against wall-clock time. diff --git a/.abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md b/.abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md new file mode 100644 index 0000000000..968be073ae --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241158083013" +slug: "the-shell-settings-edit-checks-the" +severity: "minor" +category: "tech-debt" +source: "agent-finding" +found_during: "setup redesign build, shell-settings lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "the shellpath package's Apply step (branch feat/setup-redesign-shellpath)" +remedy: "re-check the leaf with an lstat immediately before the rename and refuse if it changed, or open the file with O_NOFOLLOW and compare its identity before and after" +--- + +the shell-settings edit checks the profile file and then replaces it atomically, leaving a short window in which a symlink swapped in at the path would be replaced by a regular file rather than refused (the replace renames over the leaf, so it never writes through the link, but the person's link is lost). diff --git a/.abcd/work/issues/open/iss-2610241212094133-with-no-git-identity-configured-anywhere.md b/.abcd/work/issues/open/iss-2610241212094133-with-no-git-identity-configured-anywhere.md new file mode 100644 index 0000000000..f8c0d76e3f --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241212094133-with-no-git-identity-configured-anywhere.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241212094133" +slug: "with-no-git-identity-configured-anywhere" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "setup redesign build, identity lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "the pre-commit identity gate (branch feat/setup-redesign-identity)" +remedy: "detect git's invented identity in the hook (git var GIT_AUTHOR_IDENT against user.email being unset in every scope) and refuse it as unset, matching the Go check" +--- + +with no git identity configured anywhere, git invents one from the account and host name and exports it into the pre-commit hook, where it reads exactly like an identity a launcher supplied, so the per-machine identity gate lets an invented identity commit when no record exists; only the Go check reports it, as git_identity.unset. diff --git a/.abcd/work/issues/open/iss-2610241212237390-there-is-no-command-to-write-a.md b/.abcd/work/issues/open/iss-2610241212237390-there-is-no-command-to-write-a.md new file mode 100644 index 0000000000..07e79c2b65 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241212237390-there-is-no-command-to-write-a.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241212237390" +slug: "there-is-no-command-to-write-a" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "setup redesign build, identity lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "abcd ahoy identity recording (branch feat/setup-redesign-identity)" +remedy: "add an abcd ahoy identity record step (or flag) that writes the per-repository record after a confirm, reusing identity.WriteRecord" +--- + +there is no command to write a per-repository identity record under ~/.abcd.noindex/identity/<root-sha>.json; a person with a work and a personal identity can only create it by hand or through the legacy-pin migration. diff --git a/.abcd/work/issues/open/iss-2610241212339218-the-shell-identity-gate-in-githooks.md b/.abcd/work/issues/open/iss-2610241212339218-the-shell-identity-gate-in-githooks.md new file mode 100644 index 0000000000..e32f3c6c6d --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241212339218-the-shell-identity-gate-in-githooks.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241212339218" +slug: "the-shell-identity-gate-in-githooks" +severity: "minor" +category: "inconsistency" +source: "agent-finding" +found_during: "setup redesign build, identity lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "the pre-commit identity gate (branch feat/setup-redesign-identity)" +remedy: "align the shell gate's folder check with fsutil's guarded-reader rule, and test both against the same fixtures" +--- + +the shell identity gate in .githooks/pre-commit refuses any group-writable folder on the way to the identity record, which is stricter than the Go guarded reader the rest of abcd uses, so the two gates can disagree on the same record. diff --git a/.abcd/work/issues/open/iss-2610241212419547-testhostversionwarning-failed-once-on-a.md b/.abcd/work/issues/open/iss-2610241212419547-testhostversionwarning-failed-once-on-a.md new file mode 100644 index 0000000000..e20aab76f5 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241212419547-testhostversionwarning-failed-once-on-a.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241212419547" +slug: "testhostversionwarning-failed-once-on-a" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "setup redesign build, identity lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli" +remedy: "make the test's timing budget injectable, or assert behaviour rather than elapsed time" +--- + +TestHostVersionWarning failed once on a timing check during a loaded test run (load average 20-28) and passed when run alone: a wall-clock assertion that depends on machine load. diff --git a/.abcd/work/issues/open/iss-2610241229056581-a-piped-answer-stream-yes-abcd-ahoy.md b/.abcd/work/issues/open/iss-2610241229056581-a-piped-answer-stream-yes-abcd-ahoy.md new file mode 100644 index 0000000000..48f6a9045f --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241229056581-a-piped-answer-stream-yes-abcd-ahoy.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241229056581" +slug: "a-piped-answer-stream-yes-abcd-ahoy" +severity: "minor" +category: "inconsistency" +source: "agent-finding" +found_during: "setup redesign build, flow lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "ahoy install's piped answer path (branch feat/setup-redesign-flow)" +remedy: "route a piped stream through the same plan screen, answering approve.plan with its first line, and keep the person-only questions out of a piped stream as today" +--- + +a piped answer stream (yes | abcd ahoy install) is never shown the new plan screen; it still meets the old one-approval-per-category order, so the scripted path and the terminal path ask different questions. From 0a807d0547218ec865596dc4cd817c7c04aef1e2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:35:18 +0200 Subject: [PATCH 10/26] feat(tools): install tools from downloads pinned in abcd's release Setup no longer installs gitleaks or gh through Homebrew. Each tool has a release table compiled into abcd (internal/core/tools/release.go): the pinned version, and per platform (macOS and Linux, arm64 and amd64) the full GitHub release download URL, its sha256 copied from the makers' checksums file, and the program's path inside the archive. No version is ever looked up. An install downloads over https from GitHub's release hosts alone, bounded in size and time, refuses bytes whose sha256 is not the pin, verifies gh's signed build record in process against cli/cli's deployment.yml on trunk, and only then writes the program into the person's own bin folder ($XDG_BIN_HOME, $XDG_DATA_HOME/../bin, or ~/.local/bin) with no admin rights. gitleaks signs nothing, so it is checked by checksum only, and setup says so. The copy is verified by its version and recorded in ~/.abcd.noindex/tools.json; abcd runs that copy whatever comes first on PATH, and ahoy uninstall removes it while it is still the bytes abcd wrote. A copy installed some other way is used when its version lies in the range abcd works with; otherwise setup offers abcd's own copy, says which of the two will run, and never changes the other copy. No step shown or run needs admin rights, and nothing is installed in CI. Records adr-2610241057124151, which graduates dcn-2610240845548776, and fills spc-2610241008092356. Resolves: iss-2610240824571059 Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 52 +- ...1-abcd-installs-the-tools-it-needs-from.md | 107 ++ .abcd/development/decisions/adrs/README.md | 1 + ...-when-abcd-needs-another-tool-such-as-a.md | 138 ++- ...1059-ahoy-install-offers-a-tool-install.md | 5 + commands/ahoy.md | 66 +- internal/adapter/gitleaks/gitleaks.go | 10 +- internal/core/ahoy/ahoy.go | 6 + internal/core/ahoy/apply.go | 41 +- internal/core/ahoy/detect.go | 44 +- internal/core/ahoy/remote.go | 5 +- internal/core/ahoy/remote_gh_explain_test.go | 4 + internal/core/ahoy/remote_gh_offer_test.go | 71 +- internal/core/ahoy/tools_route_test.go | 148 ++- internal/core/github/github.go | 7 +- internal/core/landing/primitives.go | 5 +- internal/core/site/setup_test.go | 12 +- internal/core/tools/archive.go | 88 ++ internal/core/tools/attest.go | 242 +++++ internal/core/tools/explain.go | 80 +- internal/core/tools/fetch.go | 182 ++++ internal/core/tools/install.go | 543 ++++++++-- internal/core/tools/local.go | 287 ++++++ internal/core/tools/registry.go | 67 +- internal/core/tools/release.go | 235 +++++ internal/core/tools/snappy.go | 87 ++ .../attest/gh-v2.103.2-macOS-arm64.0.json.sn | Bin 0 -> 4832 bytes .../attest/gh-v2.103.2-macOS-arm64.1.json.sn | Bin 0 -> 13823 bytes .../gh-v2.103.2-macOS-arm64.attestations.json | 16 + .../tools/testdata/attest/trusted_root.json | 126 +++ internal/core/tools/tools_test.go | 931 ++++++++++++------ .../surface/cli/ahoy_prompt_stdin_test.go | 2 +- .../surface/cli/ahoy_remote_gh_offer_test.go | 13 +- .../surface/cli/ahoy_tool_confirm_test.go | 17 +- internal/surface/cli/cli.go | 19 +- internal/surface/cli/site_setup_test.go | 4 +- 36 files changed, 3100 insertions(+), 561 deletions(-) create mode 100644 .abcd/development/decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md rename .abcd/work/issues/{open => resolved}/iss-2610240824571059-ahoy-install-offers-a-tool-install.md (74%) create mode 100644 internal/core/tools/archive.go create mode 100644 internal/core/tools/attest.go create mode 100644 internal/core/tools/fetch.go create mode 100644 internal/core/tools/local.go create mode 100644 internal/core/tools/release.go create mode 100644 internal/core/tools/snappy.go create mode 100644 internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.0.json.sn create mode 100644 internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.1.json.sn create mode 100644 internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.attestations.json create mode 100644 internal/core/tools/testdata/attest/trusted_root.json diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d5574cb84a..4daa7725e0 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -135,10 +135,10 @@ authenticated identity: abcd never holds a token. That identity is the GitHub CLI's, so a missing `gh` is met with the explain-then-install mode (itd-63): after the first three gates and before the read, the verb explains `gh` from the tool registry and offers to install it, -running the registry's step only on a yes typed at a terminal. The pre-given +making the registry's checked download only on a yes typed at a terminal. The pre-given yes answers the settings change and never the install of a program, and a piped answer is not a person's answer, so both decline the offer; the verb then -refuses, its notes carrying the explanation and the command. A failed or +refuses, its notes carrying the explanation and the by-hand download. A failed or unverified install refuses the same way, before any request leaves the machine. The read never offers the install, because looking is never acting; it names the apply as the verb that does. @@ -760,7 +760,7 @@ approvals had before they listed anything. | `safe-autocreate` | the repo skeleton, history-store directories, the name-guard artefacts | applied once the category is approved, no per-item prompt; create-if-absent, never overwriting | | `config-change` | visibility, oracle adapter, the `PATH` entry, the git-identity pin, the repository's own git identity, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | | `plugin-owned` | the marker block (itd-3); hook-manifest verification | silent overwrite on marker drift; a non-resolvable diagnostic for a malformed or missing manifest, for a conventions file whose block would land inside a fence or HTML comment nothing closes (`marker.unplaceable`), for one that exists but cannot be read whole, a folder, a pipe, a file past the size cap or one without read permission (`marker.unreadable`), and for one whose block is missing or out of date in a folder that does not let abcd create a file there, which the write needs for its lock and temporary file (`marker.unwritable`) | -| `dependency` | a tool a capability uses and cannot find: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json` | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the exact install step, what the install does) and its install step runs only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a no is reported as what the capability continues on | +| `dependency` | a tool a capability uses with no copy abcd can run: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json`; missing, or installed some other way at a version outside the range abcd works with | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the download abcd's release pins and how far it is checked, any copy already installed and which copy will run, what the install does) and its download is made only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a compatible copy already installed is used and nothing installed; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | | `oracle-routing` | the offer of abcd's proposed model-tier routing table (itd-2609170822093401): the machine's `~/.abcd.noindex/oracle-routing.json`, then, as a separate question, the repository's `.abcd/config/oracle-routing.json` | the proposal said in counts (how many agents, how many at each tier, their fan-out bounds), naming no agent so the question fits, and each file written only on its own answered consent, the machine one owner-only; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; uninstall leaves both files | | `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on a consent answered at a terminal; never under the approve-everything flag and never off a terminal, where neither its category nor the offer is asked (so a piped answer stream keeps its order), and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | @@ -977,17 +977,27 @@ the answer is a flag naming the tool, which is how a host relays the answer its own question tool got; the approve-everything flag never installs a tool, and a CI runner never installs one and is not asked: the canonical CI detector (`internal/cienv`, `GITHUB_ACTIONS=true` or a truthy `CI`) refuses, and so does -`CI` set to any value, `false` and `0` included. What runs is the -registry's fixed argv for the platform, never a shell string and never a command -composed from input, and only when the package manager resolves on `PATH` -outside the repository (`internal/core/tools`). The step runs in a process group -of its own, bounded at 15 minutes (its verify at 30 seconds), and a timeout kills -that group through the handle abcd holds. The kill has one limit: a process the -step moves into another group or session (`setsid`, `setpgid`) is out of its -reach and can outlive the run. abcd stops reading output 10 seconds after the -step exits or is killed, so such a process holding the output open cannot hold -the run past its bound, and a step that exits cleanly while leaving one behind -is reported as failed, saying so. +`CI` set to any value, `false` and `0` included. What is fetched is fixed in +abcd's own release (`internal/core/tools/release.go`, [adr-2610241057124151](../../decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md)): +one GitHub release download per tool and platform, named by its full URL with +its sha256 pinned beside it, so no version is ever looked up (invariant 7) and a +tool updates only when abcd does. The download is bounded in size and time, +https only, and reaches GitHub's release hosts alone; its sha256 must equal the +pin, and for a tool whose makers sign their builds (gh) its signed build record +must verify in process against the pinned signing workflow, both before +anything is written. A tool that publishes no signed build record (gitleaks) is +checked by checksum only, and the question and the result say so. Only then is +the program read out of the archive by its exact name and written atomically +into the person's own bin folder (`$XDG_BIN_HOME`, else `$XDG_DATA_HOME/../bin`, +else `~/.local/bin`), never one inside the repository and never with admin +rights; no step abcd shows or runs needs them. Its verify must report the pinned +version, or the copy is removed again. The copy is recorded, path and digest, in +`~/.abcd.noindex/tools.json`: abcd runs that copy whatever comes first on +`PATH` while it is still the bytes abcd wrote, and uninstall removes it on the +same condition. A copy installed some other way is used when its version lies in +the tool's compatible range, and is never changed: when the bin folder already +holds a program of that name abcd did not install, it installs nothing and says +why. The non-interactive flags pre-answer the prompts: approve every resolvable category, decide the adoption question either way, set the marker target, the @@ -1235,12 +1245,14 @@ byte-identical to a fresh install save for the setup date. - **Given** a repo with the install run at an older setup version, **when** the install runs, **then** the version is updated, the marker block refreshed, and existing config keys preserved. -- **Given** a tool a capability uses is not on `PATH`, **when** the dependency - category is approved, **then** the person is shown the tool registry's - explanation and asked per tool; the registry's fixed install step runs only - on a yes typed at a terminal or relayed by a host naming the tool, the result - reports what ran and whether its verify passed, and a no reports what the - capability continues on (itd-63). +- **Given** a tool a capability uses has no copy abcd can run, **when** the + dependency category is approved, **then** the person is shown the tool + registry's explanation and asked per tool; the pinned, checked download is + made only on a yes typed at a terminal or relayed by a host naming the tool, + nothing is written unless it passes its check, the result reports what was + installed and how far it was checked (checksum only for a tool that signs + nothing), and a no reports what the capability continues on (itd-63, + itd-2610240918594138). - **Given** no oracle adapter is wired, **when** detection resolves the oracle, **then** it stays host-delegated: abcd needs no API keys or model config, because it emits prompts the host runs (adr-25), and an adapter can be diff --git a/.abcd/development/decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md b/.abcd/development/decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md new file mode 100644 index 0000000000..23f72d85fb --- /dev/null +++ b/.abcd/development/decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md @@ -0,0 +1,107 @@ +--- +id: adr-2610241057124151 +slug: abcd-installs-the-tools-it-needs-from +status: accepted +date: 2026-10-24 +supersedes: null +superseded_by: null +related_intents: [itd-2610240918594138] +related_rfcs: [] +related_adrs: [adr-38, adr-2609151706587280] +--- + +# ADR-2610241057124151: abcd installs the tools it needs from downloads pinned in its own release + +## Context + +Setup offered to install gitleaks and gh through Homebrew, one fixed argv per +platform (`brew install <formula>`). On an account that cannot write to the +machine's Homebrew, the step failed on every run and passed on Homebrew's own +advice to change ownership with admin rights (iss-2610240824571059), although +abcd never escalates privileges. A supported Homebrew for one non-admin account +cannot be made by a tool on a Mac: the installer refuses a prefix longer than +`/opt/homebrew`. The product thinker therefore decided that every tool abcd +installs, on macOS and Linux alike, is a checked download of the tool's +official release into the person's own folder, the route abcd already uses for +itself (dcn-2610240845548776, which this record graduates). + +Three constraints were already locked. Brief invariant 7: "provisioning fetches +only a manifest-named, pinned, checksum-verified artifact. No version-discovery +request exists anywhere in abcd" ([adr-38](0038-implicit-checks-are-disk-only.md)). +Brief invariant 12 and [adr-2609151706587280](2609151706587280-the-cache-reaches-path-only-through-a.md): +ownership of a file abcd placed is recorded provenance, never content-guessing. +And internal/core/update's rationale for its own binary: a checksums file +published beside an asset proves nothing alone, since whoever can replace one +can replace both. + +## Decision + +We will install a tool abcd needs only from a release table compiled into abcd +(`internal/core/tools/release.go`): per tool, the pinned version, and per +platform (macOS and Linux on arm64 and amd64) the asset's full GitHub release +download URL, its sha256 copied from the makers' published checksums file and +checked against the asset when it was pinned, and the program's path inside +the archive; for a tool whose makers sign SLSA build provenance, the exact +signer (repository, workflow file and ref); and the range of the tool's +versions abcd works with. A tool updates only when abcd does. + +The trust boundary is `tools.Installer.Install`. It downloads, over https and +from GitHub's release hosts alone, bounded in size and time, with the proxy and +CA overrides ignored as the updater ignores them; it refuses the bytes unless +their sha256 equals the pin; for a signed tool it verifies the build record in +process with sigstore-go against the pinned signer, under GitHub Actions' +issuer, from the signer's repository at its ref on a GitHub-hosted runner; it +reads the one archive member by its exact name. Only after every check passes +is anything written: atomically, at the account's own rights, into +`$XDG_BIN_HOME`, else `$XDG_DATA_HOME/../bin`, else `~/.local/bin`, and never +into the repository the verb ran from. The written copy must report the pinned +version, or it is removed. It is then recorded, path and digest, in +`~/.abcd.noindex/tools.json`. abcd runs a recorded copy by its path whatever +comes first on PATH, and uninstall removes it, each only while its bytes still +hash to the record. A copy installed some other way is used when its version +lies in the compatible range, never changed, and never overwritten: when the +bin folder already holds one of that name, nothing is installed. A tool that +publishes no signed build record (gitleaks) is checked by checksum only, and +every explanation and result says so. Nothing abcd shows or runs needs admin +rights, and nothing is installed in CI. + +The provenance check does not bind the record to the release tag's commit, as +abcd's own update does: that binding exists there because the digest is learnt +from the release page, so an older genuine build could be replayed under a +newer tag. Here the digest is fixed in abcd's source, and no replay changes the +bytes installed. + +## Alternatives Considered + +- **Keep Homebrew, add a fallback.** Rejected: Homebrew stays the default only + where it works, which is exactly the admin-owned prefix the person cannot + write, and its failure text relays the privilege escalation abcd refuses. +- **Resolve the latest release at install time and check the release's own + checksums file.** Rejected: it is a version-discovery request (invariant 7), + and a same-page checksums file proves nothing against whoever can edit the + release page. +- **Run `gh attestation verify` for the provenance.** Rejected: it needs gh + installed and signed in before gh itself can be installed, and abcd already + verifies Sigstore bundles in process for its own binary. +- **Pinned downloads with the in-process check (chosen).** It needs no package + manager and no admin rights, holds to invariant 7, and reuses the one + verifier abcd already ships. + +## Consequences + +- Each abcd release carries a tool's version and digests; moving them is a + change to `release.go` that re-copies the digests from the makers' + checksums files and checks them against the assets. The compatible range is + the seam a release-time check can widen or move (iss-2610240959030370, + still open). +- A copy installed some other way at an older version is no longer counted as + installed: setup names it, says why, and offers abcd's own copy beside it. +- GitHub sometimes hands out a build record only by a `bundle_url` into its + attestation store, snappy-compressed; abcd decodes that format itself rather + than taking a dependency, and every decoded byte is then verified by + Sigstore. +- The download transport is a second copy of the updater's policy, because + update imports ahoy, which imports the tools package. The two are held to + the same rules by their tests rather than by one function. +- A test binary's default installer has no network, so no test of another + package can fetch a real release by reaching a yes. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index 084dce4990..e5eea5f5c9 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -216,4 +216,5 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-2610161621415708](2610161621415708-abcd-s-command-guard-steers-agents-an-os.md) | Unattended runs abcd launches execute inside an OS sandbox | proposed | 2026-10-16 | | [adr-2610161733400551](2610161733400551-a-reviewer-in-a-model-matrix-run-never.md) | A reviewer or judge in a model-matrix run is told nothing about which model wrote the copy it judges | proposed | 2026-10-16 | | [adr-2610181003541997](2610181003541997-the-away-mode-s-stop-check-reads-the.md) | The away mode's stop check reads the forge once, as its documented meaning | proposed | 2026-10-18 | +| [adr-2610241057124151](2610241057124151-abcd-installs-the-tools-it-needs-from.md) | abcd installs the tools it needs from downloads pinned in its own release | accepted | 2026-10-24 | <!-- /index --> diff --git a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md index e57019e4ce..cd0d27c7d2 100644 --- a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md +++ b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md @@ -9,12 +9,144 @@ production_mode: dictated-and-formatted ## Summary -_Draft: describe what spc-2610241008092356 delivers for itd-2610240918594138 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-2610241008092356 replaces the tool registry's Homebrew step with a checked +download pinned in abcd's own release, so setup installs gitleaks and gh for +the person alone, into their own bin folder, with no admin rights, and says +how far each download was checked (itd-2610240918594138, dcn-2610240845548776, +adr-2610241057124151). It resolves iss-2610240824571059. + +## Scope + +- In: the release table (gitleaks v8.30.1, gh v2.103.2, macOS and Linux on + arm64 and amd64); the bounded https download from GitHub's release hosts; + the sha256 check against the pin; the in-process signed build record check + for gh; the archive read; the atomic write into `$XDG_BIN_HOME`, else + `$XDG_DATA_HOME/../bin`, else `~/.local/bin`; the installed-tools record in + `~/.abcd.noindex/tools.json`; abcd's callers running the recorded copy; + uninstall removing recorded copies; the plan that decides whether a copy + already installed is used; the minimum wiring into ahoy's dependency step, + the gh offer, and ahoy uninstall. +- Out: the setup-flow redesign that lays the plan out before asking (a + separate lane calls `Installer.Plan`); release-time checks that move a pin + to a newer verified version (iss-2610240959030370, left open); Windows; + tools whose makers publish no checksums. + +## Approach + +The table lives in `internal/core/tools/release.go`: per tool a `Release` +(repository, pinned version, an `Asset` per `<GOOS>/<GOARCH>` holding the full +`https://github.com/<repo>/releases/download/<tag>/<file>` URL, its sha256, the +archive kind and the program's member path), an optional `Signer` (repository, +workflow file, ref; gh is `cli/cli` `.github/workflows/deployment.yml` on +`refs/heads/trunk`, gitleaks has none), and a `Compatible` version range. The +digests were copied on 2026-10-24 from each release's published checksums file +(`gitleaks_8.30.1_checksums.txt`, `gh_2.103.2_checksums.txt`) and each matched +the downloaded asset. No version is ever looked up (invariant 7). + +`Installer.Plan(name)` decides, from the disk, PATH and the version a copy +already installed reports through the tool's verify argv, with no network: +`use-abcd-copy` (an intact recorded copy), `use-existing` (a copy installed +some other way whose version lies in `Compatible`), `install` (abcd's own copy +offered, saying which of the two will run in abcd and in the person's shell), +or `unavailable` (no download for the platform, or the bin folder holds a +program of that name abcd did not install). `Installer.Install` refuses in CI, +uses the plan, asks the front door's `Confirm` with the plan in the +explanation, then downloads (`fetch.go`: https only, GitHub release hosts only, +no proxy, CA overrides held out of the root pool, urlguard on dial and +redirect, 256 MiB and five-minute bounds), checks the sha256, checks the +signed build record (`attest.go`: GitHub's attestations API, inline bundle or +`bundle_url` into GitHub's attestation store decoded from snappy by +`snappy.go`, verified with sigstore-go against the TUF-fetched trust root, the +exact SAN, issuer, source repository, ref and GitHub-hosted runner), reads the +member (`archive.go`), and only then writes with `fsutil.WriteFileAtomic`, +verifies the pinned version, and records path and digest (`local.go`). A +failed verify or record removes the copy again. + +`tools.Resolve(name)` is where abcd runs a registered tool from: the recorded +copy while its bytes hash to the record, else PATH. The gitleaks adapter's +`NewDefault`, `internal/core/github` and `internal/core/landing` resolve gh and +gitleaks through it; ahoy's presence check (`onPath`) counts the recorded copy. +`tools.Uninstall(home)` removes recorded copies whose digest still matches and +drops the record; `ahoy.Uninstall` calls it and reports `tools` in its +receipt. + +### Core API for the setup-flow lane + +All in `internal/core/tools`, none of it prints: + +- `tools.Default(guard string) *tools.Installer` — the production installer. +- `(*Installer).Plan(name string) tools.Plan` — what setup would do, with + `Action` (`UseOwned`, `UseExisting`, `InstallOwn`, `Unavailable`), + `Pinned`, `Compatible`, `Download` (version, URL, sha256, member, dest, + `Checks`, `ChecksumOnly`), `Existing` and `Owned` (`Copy`: path, version, + compatible, why), `Runs` and `Why`. +- `(*Installer).Install(name string, c tools.Capability, confirm tools.Confirm) tools.Result` + — `Ready`, `Ran`, `Installed`, `Verified`, `Declined`, `Path`, `Version`, + `Checked`, `ChecksumOnly`, `Runs`, `Why`, `OnDecline`, and `Summary()`. +- `tools.Explain(name, c) tools.Explanation` — now carrying `Download` and, + when Install hands it to `Confirm`, `Plan`; `StepText()` and + `ManualText()` (the by-hand URL, sha256 and destination). +- `(tools.Release).Checks() string` — how far a download is checked. +- `tools.Resolve(name string) (string, error)`; `tools.Uninstall(home string) ([]tools.Removal, error)`. + +### How the compatible range prepares for iss-2610240959030370 + +The range is the one statement of which versions abcd works with, and +`TestEveryPinnedReleaseIsComplete` holds every pin inside it. A release-time +check that tries a newer release against abcd's gates would move the pin, its +digests and, when the gates show it, the range, in one change to +`release.go`; holding the last passing pin is leaving the table as it is. That +check is not built here. + +## Acceptance-criteria satisfaction + +1. Missing tool installed, pinned, into the person's folder, checked before + writing, no admin rights, no admin step shown: `Installer.Install` order + in `install.go`; `TestInstallDownloadsChecksWritesAndRecords`, + `TestExplainNamesTheCheckedDownload` and `noAdmin` (no `sudo`, `brew`, + `chown` in any explanation or result), `TestEveryPinnedReleaseIsComplete`, + `TestBinDirFollowsUvsOrder`; at ahoy `TestDependencyYesRunsTheStepAndReportsIt`. +2. A failed check writes nothing and says why; checksum-only is said: + `TestAFailedCheckWritesNothingAndSaysWhy` (tampered sha256, failed build + record, failed download), `TestAFailedVerifyRemovesTheCopy`, + `TestHowEachToolIsCheckedIsSaid`, `TestGhsRecordedBuildRecordVerifiesAgainstItsPinnedSigner` + and `TestABuildRecordFromAnotherSignerOrForOtherBytesFails` over the + recorded real gh v2.103.2 bundles. +3. A compatible copy is used and nothing installed; otherwise abcd's copy is + offered, saying which runs, and the other copy is never changed: + `TestACompatibleCopyIsUsedAndNothingInstalled`, + `TestAnIncompatibleCopyGetsAbcdsOwnAndSaysWhichRuns`, + `TestABinFolderCopyAbcdDidNotInstallIsNeverReplaced`; at ahoy + `TestAnIncompatibleGitleaksIsAGapOfItsOwn`. +4. abcd runs its copy whatever PATH says, and uninstall removes only its + copies: `TestResolvePrefersAbcdsCopyWhateverPathSays`, + `TestUninstallRemovesOnlyAbcdsIntactCopies`; at ahoy + `TestUninstallRemovesTheToolCopiesAbcdRecorded`. + +Scope conditions: the four platforms are pinned for both tools; nothing needs +admin rights, so an account with or without them takes the same route; both +tools publish checksums. + +Known limits: gh's offer at `ahoy remote apply` and `site setup` still counts +any gh on PATH as present (only a missing gh is offered abcd's copy), because +gh's compatible floor (2.102.0, for `gh attestation verify --source-ref`) +guards abcd's by-hand fallback, not `gh api`; the full criterion-3 judgement +runs for gitleaks at `ahoy install`. When the bin folder already holds a +foreign program of the tool's name, abcd installs nothing rather than choose +another folder. ## Footprint -- packages: -- tests: +- packages: internal/core/tools, internal/core/ahoy, internal/adapter/gitleaks, + internal/core/github, internal/core/landing, internal/surface/cli +- docs: commands/ahoy.md, .abcd/development/brief/04-surfaces/01-ahoy.md, + .abcd/development/decisions/adrs/2610241057124151-abcd-installs-the-tools-it-needs-from.md +- tests: internal/core/tools/tools_test.go (and testdata/attest), + internal/core/ahoy/tools_route_test.go, internal/core/ahoy/remote_gh_offer_test.go, + internal/core/ahoy/remote_gh_explain_test.go, internal/core/site/setup_test.go, + internal/surface/cli/ahoy_tool_confirm_test.go, + internal/surface/cli/ahoy_remote_gh_offer_test.go, + internal/surface/cli/site_setup_test.go ## Steps diff --git a/.abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md b/.abcd/work/issues/resolved/iss-2610240824571059-ahoy-install-offers-a-tool-install.md similarity index 74% rename from .abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md rename to .abcd/work/issues/resolved/iss-2610240824571059-ahoy-install-offers-a-tool-install.md index 570102e594..4b26ccd941 100644 --- a/.abcd/work/issues/open/iss-2610240824571059-ahoy-install-offers-a-tool-install.md +++ b/.abcd/work/issues/resolved/iss-2610240824571059-ahoy-install-offers-a-tool-install.md @@ -10,6 +10,11 @@ origin: researcher-authored production_mode: hand-written found_at: "internal/core/tools/registry.go" remedy: "make a verified download the default tool install on macOS and Linux (dcn-2610240845548776): fetch the tool's official release, check it against the published checksums and the signed build record where one exists, and put it in ~/.local/bin; drop Homebrew as the default step, and never relay a sudo line" +resolution: "setup now installs each tool as a download pinned in abcd's release, checked by sha256 and, for gh, its signed build record, into the person's own bin folder with no admin rights; Homebrew is no longer the step and no sudo line is ever relayed (adr-2610241057124151)" +impact: fix +resolved_by: + intent: "itd-2610240918594138" + spec: "spc-2610241008092356" --- ahoy install offers a tool install through Homebrew on an account that cannot write to Homebrew: on this machine /opt/homebrew is owned by the admin account, so brew install gitleaks fails on every run, and the failure note relays Homebrew's own sudo chown advice, although abcd never escalates privileges. The registry has one install step per platform (Homebrew), so an account without admin rights has no route that works. diff --git a/commands/ahoy.md b/commands/ahoy.md index c2618c0c36..b89e8ac8d3 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -91,10 +91,14 @@ Then summarise the JSON for the user: - `gaps` — how many are outstanding, and for each actionable one its `title`, `category`, and `fix_hint`; call out which are `required`. A `dependency` gap carries `tool`, the tool registry's explanation: relay `what`, the - `requirement` for `capability_name`, `without_it`, `does`, the exact `step` - and `effects` (what the install does on the machine and over the network, - Homebrew's own analytics included) rather than a bare command, so the - person can judge the install. + `requirement` for `capability_name`, `without_it`, `does`, the `download` + (the version abcd's release fixes, its URL, the bin folder it goes into and + `checks`, how far it is checked, which says "checksum only" for a tool + whose makers sign nothing) and `effects` rather than a bare command, so the + person can judge the install. A `deps.<tool>_incompatible` gap names a copy + already installed whose version abcd does not work with: relay `tool.plan`'s + `existing` (where it is and why it does not fit) and `runs` (which copy + abcd will run, and which the person's shell runs). - A report-only gap, one with `resolvable` false, is a note abcd only reports: `install` never closes it, and the person has to act. Relay each one's `detail` and `fix_hint` as they stand, rather than folding it into the @@ -652,12 +656,16 @@ behalf. For an autonomous routine the human identity is set by whatever launches it, before its first commit. **The tool question.** When a `dependency` gap is present and its category is -approved, each missing tool is its own question, and a piped answer never -answers it: installing runs a program on the machine. At a terminal the install -shows the explanation and asks `Install <tool> now by running <step>? [y/N]`. +approved, each tool abcd has no usable copy of is its own question, and a piped +answer never answers it: installing puts a program on the machine. A copy +already installed some other way is used, and nothing asked or installed, when +its version lies in the range abcd works with; abcd's own recorded copy is used +the same way. At a terminal the install shows the explanation and asks +`Install <tool> now: download <tool> <version> into <bin folder>, <checks>? [y/N]`. Through this page, ask the technical facilitator with the host's question tool instead: present the gap's `tool` explanation (what it is, whether this -capability needs it, what works without it, the exact step, what the install +capability needs it, what works without it, the download and how far it is +checked, any copy already installed and which copy will run, what the install does), and only on their yes run ```bash @@ -667,16 +675,23 @@ does), and only on their yes run `--install-tool` is the relayed yes, for the named tool only, and it also answers the `dependency` category's question, which is then not asked (so a piped answer stream has one question fewer); never pass it without the user's -answer. The result's `changes` reports what ran and whether -its verify passed; a no, a failed step or a missing package manager is a `notes` -line ending in what the capability continues on (`continuing on the native -secret scanner`). The step runs in its own process group, bounded at 15 -minutes, and a timeout kills that group; a process the step moves out of the -group (`setsid`) escapes the kill and can outlive the run, so abcd stops -waiting on its output 10 seconds after the step ends and reports a step that -left one behind as failed. `--yes` never installs a tool, and a run with `CI` set -to any value, or with `GITHUB_ACTIONS=true`, installs none and is not asked. A name that is not a tool `ahoy install` checks -for is refused, naming the ones it does. +answer. The install downloads the one build abcd's release names (no version is +ever looked up), checks its sha256 against the one abcd's release pins, and, +for a tool whose makers sign their builds (gh), checks its signed build record +against the pinned signing workflow, all before anything is written. Only then +is the program written into the person's own bin folder (`$XDG_BIN_HOME`, else +`$XDG_DATA_HOME/../bin`, else `~/.local/bin`) with no admin rights, verified by +its version, and recorded in `~/.abcd.noindex/tools.json`, so abcd runs that +copy whatever comes first on PATH and `abcd ahoy uninstall` removes it while it +is still the bytes abcd wrote. The result's `changes` reports what was +installed and how far it was checked; a no, a failed check or a failed download +is a `notes` line saying why, that nothing was written, and what the capability +continues on (`continuing on the native secret scanner`). abcd never changes a +copy it did not install: when the bin folder already holds one of that name, it +installs nothing and says so. No step it shows or runs needs admin rights. +`--yes` never installs a tool, and a run with `CI` set to any value, or with +`GITHUB_ACTIONS=true`, installs none and is not asked. A name that is not a tool +`ahoy install` checks for is refused, naming the ones it does. **The seeded documentation check.** A repository with no `.abcd/docs-lint.json` gets one. Its `roots` list `docs` when that folder @@ -1033,7 +1048,13 @@ abcd took the row — or removed, when none was recorded — and the receipt's `~/.abcd.noindex/backups/` first; a status line that is not abcd's is left alone, and `~/.abcd.noindex/statusline.json` itself stays, because it is the user's configuration. Run `uninstall` before removing abcd: a status line left -pointing at a removed abcd is blank in every repository. It never touches +pointing at a removed abcd is blank in every repository. It also removes each +tool copy abcd installed, by the record in `~/.abcd.noindex/tools.json`, while +it is still the bytes abcd wrote: report `tools`, one entry per recorded copy +with its `path` (tilde form) and `note` (removed, already gone, or changed since +and left untouched), and `tools_note` when the record could not be read. A copy +of the same tool installed any other way was never recorded and is never +touched. It never touches `hooks.json`. An entry that was installed with `--bin-dir` into a directory outside `PATH` cannot be found by a `PATH` scan — pass the same `--bin-dir <dir>` to `uninstall` to remove it. @@ -1102,10 +1123,11 @@ named. The call goes through the GitHub CLI (`gh`), so the write is made by the user's own authenticated identity and abcd never holds a token. If `gh` is absent, the verb explains it from the tool registry (what it is, that these verbs require -it, the exact install step and what that install does) and offers to install -it, running the step only on a yes typed at a terminal. `--yes` never answers +it, the checked download and what that install does) and offers to install +it, downloading it only on a yes typed at a terminal. `--yes` never answers that offer, and neither does a piped answer, so through this page the offer is -declined: the verb refuses, and its notes carry the explanation and the command. +declined: the verb refuses, and its notes carry the explanation and the +by-hand download (its URL, the sha256 to check, and where the program goes). Relay them; the install is the user's to run, by hand or by running the verb at a terminal. `ahoy --remote` never offers the install, since it writes nothing, and names this verb as the one that does. It is idempotent — a repository already in the desired diff --git a/internal/adapter/gitleaks/gitleaks.go b/internal/adapter/gitleaks/gitleaks.go index 364635cc1f..70bb18cce9 100644 --- a/internal/adapter/gitleaks/gitleaks.go +++ b/internal/adapter/gitleaks/gitleaks.go @@ -46,13 +46,13 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" "sort" "strings" "time" "github.com/intentdriven/abcd/internal/adapter/scanner" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -135,10 +135,12 @@ type Adapter struct { Runner Runner } -// NewDefault returns the production adapter: exec.LookPath for discovery and a -// real gitleaks shell-out for execution. +// NewDefault returns the production adapter: tools.Resolve for discovery (abcd's +// own recorded copy, whatever comes first on PATH, else the PATH lookup: +// itd-2610240918594138) and a real gitleaks shell-out for execution. What it +// finds is still held to admitBinary. func NewDefault() *Adapter { - return &Adapter{LookPath: exec.LookPath, Runner: execRunner{}} + return &Adapter{LookPath: tools.Resolve, Runner: execRunner{}} } // LoadConfig reads the per-repo opt-in config with the same trust-boundary diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index f9e6a7e694..a184e69b1a 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -331,6 +331,12 @@ type UninstallReceipt struct { Readme ReadmeReceipt `json:"readme"` Symlink SymlinkReceipt `json:"symlink"` StatusLine StatusLineReceipt `json:"status_line"` + // Tools is what became of each tool copy abcd installed and recorded in + // ~/.abcd.noindex/tools.json: removed while still the bytes abcd wrote, + // left untouched otherwise (itd-2610240918594138, criterion 4). + Tools []tools.Removal `json:"tools"` + // ToolsNote is set when the record could not be read or rewritten. + ToolsNote string `json:"tools_note,omitempty"` } // DoctorReport is the outcome of Doctor: the detection envelope plus read-only diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index c3f5659262..25d2bd68aa 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -588,12 +588,14 @@ func (a *applyCtx) stepIdentityPin() { func (a *applyCtx) has(id string) bool { return a.gapPresent[id] } // stepDependencies is the explain-then-install mode at ahoy (itd-63). For each -// dependency gap whose tool is still missing it hands the tool registry's -// explanation to the front door's confirmation, and runs the registry's step -// only on a yes. What ran, and whether it verified, is a change; a no, a failure -// or a caller that asked nothing is a note carrying the explanation and the -// capability's standing ("continuing on the native secret scanner"), never a -// silent skip. The category approval gates reaching this step at all; it never +// dependency gap it hands the tool registry's explanation to the front door's +// confirmation and, only on a yes, installs the pinned, checked download into +// the person's own bin folder (itd-2610240918594138). What was installed, and +// how far it was checked, is a change; a no, a failure or a caller that asked +// nothing is a note carrying the explanation and the capability's standing +// ("continuing on the native secret scanner"), never a silent skip. A copy that +// became usable since detection (installed meanwhile) is used, and nothing is +// asked. The category approval gates reaching this step at all; it never // stands in for the per-tool answer. func (a *applyCtx) stepDependencies() { if !a.approved[Dependency] { @@ -603,11 +605,11 @@ func (a *applyCtx) stepDependencies() { if g.Category != Dependency || g.Tool == nil { continue } - if onPath(a.cwd, g.Tool.Tool) { + res := newToolInstaller(a.cwd).Install(g.Tool.Tool, g.Tool.Capability, a.confirmTool) + if res.Ready && !res.Ran { continue } - res := newToolInstaller(a.cwd).Install(g.Tool.Tool, g.Tool.Capability, a.confirmTool) - if res.Ran && res.Installed && res.Verified { + if res.Ready { a.changes = append(a.changes, receiptPath(a.cwd, "dependency: "+res.Summary())) continue } @@ -2200,6 +2202,9 @@ func Uninstall(cwd, binDir string) (UninstallReceipt, error) { // independent of whether the entry below is still there to be removed. receipt.StatusLine = uninstallStatusLine() + // Tools: the copies abcd installed, by its record, and nothing else. + receipt.Tools, receipt.ToolsNote = uninstallTools() + // Symlink: remove only if it points at this plugin's binary. The entry is // found the same way detection finds it — an owned entry anywhere on PATH, // else the default location — so uninstall reaches the install that exists @@ -2284,6 +2289,24 @@ func Uninstall(cwd, binDir string) (UninstallReceipt, error) { return receipt, nil } +// uninstallTools removes the tool copies the installed-tools record names, +// each only while it is still the bytes abcd wrote (tools.Uninstall). A home +// abcd cannot judge removes nothing and says why. +func uninstallTools() ([]tools.Removal, string) { + home, refused := homeScope() + if refused != "" { + return []tools.Removal{}, "tool copies left untouched: " + refused + } + out, err := tools.Uninstall(home) + if out == nil { + out = []tools.Removal{} + } + if err != nil { + return out, "tool copies: " + errText(err) + } + return out, "" +} + // Doctor runs the detection pass plus a read-only cross-machine audit. Zero // writes. func Doctor(cwd string) (DoctorReport, error) { diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index aa04f3ac5f..fa476bc28a 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -3,7 +3,6 @@ package ahoy import ( "errors" "os" - "os/exec" "path/filepath" "github.com/intentdriven/abcd/internal/abcdhome" @@ -259,13 +258,20 @@ func detectPluginRoot(ok bool) []Gap { var DependencyTools = []string{"gitleaks"} // detectDependencies names each external tool a capability here would use and -// cannot find, with the tool registry's explanation rather than a bare command -// (itd-63). gitleaks is the one ahoy checks: optional over the native secret -// scanner, and REQUIRED in a repository that armed it in -// .abcd/config/gitleaks.json, whose release refuses without it. An +// has no copy of that abcd can run, with the tool registry's explanation +// rather than a bare command (itd-63). gitleaks is the one ahoy checks: +// optional over the native secret scanner, and REQUIRED in a repository that +// armed it in .abcd/config/gitleaks.json, whose release refuses without it. An // armed repository that names an existing binary by path has no gap: the // adapter judges that path itself, and a refusal there is not a missing tool. // +// Whether a copy is there is the installer's plan (itd-2610240918594138): abcd's +// own recorded copy, or one installed some other way whose version lies in the +// range abcd works with, is no gap; a copy outside that range is a gap of its +// own (deps.<tool>_incompatible) that names the copy and why, so the install +// offers abcd's copy beside it without changing it. The plan reads the disk and +// the copy's version output and asks the network nothing. +// // trufflehog is not offered: nothing in abcd runs it (iss-2609261447331434), // and a gap asking a person to install a program abcd never uses is not an // explanation anyone can act on. @@ -277,19 +283,35 @@ func detectDependencies(cwd string) []Gap { return nil } } - if onPath(cwd, "gitleaks") { + guard, err := filepath.Abs(cwd) + if err != nil { + guard = cwd + } + plan := newToolInstaller(guard).Plan("gitleaks") + if plan.Action == tools.UseOwned || plan.Action == tools.UseExisting { return nil } e := tools.Explain("gitleaks", capability) - return []Gap{{ + e.Plan = &plan + g := Gap{ ID: "deps.gitleaks_missing", Category: Dependency, Scope: "machine", Title: "gitleaks not on PATH", Detail: string(e.Requirement) + " for " + e.CapabilityName + "; without it: " + e.WithoutIt + ".", - FixHint: e.StepText() + " (abcd ahoy install explains it and runs it only on your yes)", + FixHint: e.StepText() + " (abcd ahoy install explains it and installs it only on your yes)", Required: e.Requirement == tools.Required, Resolvable: true, Tool: &e, - }} + } + if plan.Existing != nil { + g.ID = "deps.gitleaks_incompatible" + g.Title = "the gitleaks on PATH is not a version abcd works with" + g.Detail = plan.Existing.Path + ": " + plan.Existing.Why + "; " + g.Detail + } + if plan.Action == tools.Unavailable { + g.FixHint = plan.Why + g.Resolvable = false + } + return []Gap{g} } // onPath reports whether tool is installed where abcd would run it from: PATH @@ -297,8 +319,10 @@ func detectDependencies(cwd string) []Gap { // PATH resolves inside the checkout, lexically or after symlinks, is // repository content that the installer refuses to run, so it does not count // as installed either; the judgement is the installer's own (tools.WithinTree). +// abcd's own recorded copy counts wherever it is (tools.Resolve), since that is +// the copy abcd runs. func onPath(root, tool string) bool { - p, err := exec.LookPath(tool) + p, err := tools.Resolve(tool) if err != nil || !filepath.IsAbs(p) { return false } diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go index 34ff25050a..956152df07 100644 --- a/internal/core/ahoy/remote.go +++ b/internal/core/ahoy/remote.go @@ -281,7 +281,10 @@ func OfferGH(guard string, confirm tools.Confirm) (notes []string, ok bool) { return nil, true } res := newToolInstaller(guard).Install("gh", tools.GitHubSettings, confirm) - if res.Ran && res.Installed && res.Verified { + if res.Ready && !res.Ran { + return nil, true + } + if res.Ready { return []string{res.Summary() + "; the install does not sign gh in, so if GitHub refuses the request, run gh auth login"}, true } notes = []string{"the GitHub CLI (gh) is not on PATH: " + res.Summary()} diff --git a/internal/core/ahoy/remote_gh_explain_test.go b/internal/core/ahoy/remote_gh_explain_test.go index 0b6f6b347d..1ad5eadf81 100644 --- a/internal/core/ahoy/remote_gh_explain_test.go +++ b/internal/core/ahoy/remote_gh_explain_test.go @@ -12,6 +12,10 @@ import ( func emptyPath(t *testing.T) { t.Helper() t.Setenv("PATH", t.TempDir()) + // The bin folder a tool would be installed into is one of the test's own, + // so a program the machine running the test keeps in ~/.local/bin is + // never mistaken for the copy in the way. + t.Setenv("XDG_BIN_HOME", t.TempDir()) } // TestMissingGhIsExplained is itd-63 criterion 1 at the remote verbs: a missing diff --git a/internal/core/ahoy/remote_gh_offer_test.go b/internal/core/ahoy/remote_gh_offer_test.go index fca7fcf1f6..7ec8535d03 100644 --- a/internal/core/ahoy/remote_gh_offer_test.go +++ b/internal/core/ahoy/remote_gh_offer_test.go @@ -2,6 +2,8 @@ package ahoy import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "os/exec" @@ -13,16 +15,17 @@ import ( ) // ghOfferRig is a managed repository whose PATH holds git and nothing else, so -// gh is missing, and whose tool installer is a fake: a "brew install gh" it is -// told to run writes a stand-in gh onto that PATH (answering with body) and -// records the argv, so a test sees exactly what would have run and what the -// verb did once gh was there. +// gh is missing, and whose tool installer downloads from memory: the pinned +// "release" is a tar.gz holding a stand-in gh (answering with body) whose +// sha256 is pinned, the bin folder is that PATH directory, and every download +// and run is recorded, so a test sees exactly what would have been fetched and +// what the verb did once gh was there. type ghOfferRig struct { repo string path string // the only PATH directory calls [][]string ghLog string - fail bool // the fake brew exits non-zero + fail bool // the download fails } func newGHOfferRig(t *testing.T, body string) *ghOfferRig { @@ -38,34 +41,46 @@ func newGHOfferRig(t *testing.T, body string) *ghOfferRig { if err := os.Symlink(gitBin, filepath.Join(r.path, "git")); err != nil { t.Fatal(err) } - // brew is a real file the installer's admit can resolve; the fake Run never - // executes it. - if err := os.WriteFile(filepath.Join(r.path, "brew"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { - t.Fatal(err) - } bodyFile := filepath.Join(t.TempDir(), "get.json") if err := os.WriteFile(bodyFile, []byte(body), 0o644); err != nil { t.Fatal(err) } t.Setenv("PATH", r.path) + script := "#!/bin/sh\necho \"$*\" >> '" + r.ghLog + "'\n/bin/cat '" + bodyFile + "'\n" + archive := tarGzOf(t, "gh/bin/gh", []byte(script)) + sum := sha256.Sum256(archive) + home := t.TempDir() prev := newToolInstaller newToolInstaller = func(guard string) *tools.Installer { in := tools.Default(guard) - in.Getenv = func(string) string { return "" } - in.Run = func(_ context.Context, argv []string) ([]byte, error) { - r.calls = append(r.calls, argv) - if filepath.Base(argv[0]) != "brew" { - return []byte("gh version 2.0.0 (fake)\n"), nil + in.Home = home + in.Getenv = func(k string) string { + switch k { + case "PATH", "XDG_BIN_HOME": + return r.path } + return "" + } + in.Get = func(_ context.Context, u string, _ int64) ([]byte, error) { + r.calls = append(r.calls, []string{"GET", u}) if r.fail { - return []byte("Error: gh: download failed\n"), errors.New("exit status 1") + return nil, errors.New("download failed") } - script := "#!/bin/sh\necho \"$*\" >> '" + r.ghLog + "'\n/bin/cat '" + bodyFile + "'\n" - if err := os.WriteFile(filepath.Join(r.path, "gh"), []byte(script), 0o755); err != nil { - return nil, err - } - return []byte("==> Pouring gh\n"), nil + return archive, nil + } + in.Attest = func(context.Context, tools.Signer, []byte) (string, error) { return "signed", nil } + in.Release = func(string) (tools.Release, bool) { + return tools.Release{Repo: "cli/cli", Version: "2.103.2", + Assets: map[string]tools.Asset{in.GOOS + "/" + in.GOARCH: { + URL: "https://github.com/cli/cli/releases/download/v2.103.2/gh.tar.gz", + SHA256: hex.EncodeToString(sum[:]), Archive: tools.TarGz, Member: "gh/bin/gh"}}, + Signer: &tools.Signer{Repo: "cli/cli", Workflow: ".github/workflows/deployment.yml", Ref: "refs/heads/trunk"}, + Compatible: tools.VersionRange{Min: "2.102.0", Below: "3.0.0"}}, true + } + in.Run = func(_ context.Context, argv []string) ([]byte, error) { + r.calls = append(r.calls, argv) + return []byte("gh version 2.103.2 (fake)\n"), nil } return in } @@ -98,11 +113,11 @@ func TestRemoteApplyOffersGhAndRunsTheStepOnYes(t *testing.T) { if err != nil { t.Fatal(err) } - if len(seen) != 1 || seen[0].Tool != "gh" || seen[0].StepText() != "brew install gh" { + if len(seen) != 1 || seen[0].Tool != "gh" || !strings.HasPrefix(seen[0].StepText(), "download gh 2.103.2 into ") { t.Fatalf("the offer was not put to the question with gh's explanation: %+v", seen) } - if len(r.calls) == 0 || strings.Join(r.calls[0][1:], " ") != "install gh" { - t.Fatalf("the yes did not run the registry's step: %v", r.calls) + if len(r.calls) == 0 || r.calls[0][0] != "GET" { + t.Fatalf("the yes did not make the pinned download: %v", r.calls) } if res.Status == "refused" { t.Fatalf("the verb refused after gh was installed: %+v", res) @@ -111,7 +126,7 @@ func TestRemoteApplyOffersGhAndRunsTheStepOnYes(t *testing.T) { t.Fatal("the settings were not read through the installed gh") } notes := strings.Join(res.Notes, "\n") - for _, want := range []string{"gh: ran brew install gh — installed, and verified with gh --version", "gh auth login"} { + for _, want := range []string{"gh: installed 2.103.2 into", "signed build record", "verified with gh --version", "gh auth login"} { if !strings.Contains(notes, want) { t.Errorf("the notes lack %q:\n%s", want, notes) } @@ -141,7 +156,7 @@ func TestRemoteApplyGhDeclinedRunsNothingAndShowsTheStep(t *testing.T) { t.Fatalf("status = %q, want refused", res.Status) } notes := strings.Join(res.Notes, "\n") - for _, want := range []string{"gh not installed (", "install step (Homebrew): brew install gh", "the settings on GitHub stay unread and unchanged"} { + for _, want := range []string{"gh not installed (", "install: download gh 2.103.2", "the settings on GitHub stay unread and unchanged"} { if !strings.Contains(notes, want) { t.Errorf("the notes lack %q:\n%s", want, notes) } @@ -163,7 +178,7 @@ func TestRemoteApplyGhInstallFailureIsLoudAndStops(t *testing.T) { t.Fatalf("a failed install went on: status %q, gh ran %v, writes %v", res.Status, r.ghRan(t), res.Writes) } notes := strings.Join(res.Notes, "\n") - for _, want := range []string{"ran brew install gh — it failed", "download failed", "the settings on GitHub stay unread and unchanged"} { + for _, want := range []string{"gh: the download of 2.103.2 was not installed", "download failed", "nothing was written", "the settings on GitHub stay unread and unchanged"} { if !strings.Contains(notes, want) { t.Errorf("the notes lack %q:\n%s", want, notes) } @@ -183,7 +198,7 @@ func TestRemoteReadNeverOffersGh(t *testing.T) { t.Fatalf("the read ran an install: %v", r.calls) } notes := strings.Join(res.Notes, "\n") - for _, want := range []string{"brew install gh", "abcd ahoy remote apply offers to install it"} { + for _, want := range []string{"download gh 2.103.2", "abcd ahoy remote apply offers to install it"} { if !strings.Contains(notes, want) { t.Errorf("the read's refusal lacks %q:\n%s", want, notes) } diff --git a/internal/core/ahoy/tools_route_test.go b/internal/core/ahoy/tools_route_test.go index 941c87831b..1c0429ef0e 100644 --- a/internal/core/ahoy/tools_route_test.go +++ b/internal/core/ahoy/tools_route_test.go @@ -1,7 +1,12 @@ package ahoy import ( + "archive/tar" + "bytes" + "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "errors" "os" "path/filepath" @@ -74,19 +79,19 @@ func TestNoTrufflehogGap(t *testing.T) { } } -// fakeToolRun is a tools.Installer whose programs all "exist" outside the -// repository and whose runs are recorded rather than executed. +// fakeToolRun is a tools.Installer with no gitleaks on its PATH, a home of +// its own, and a "release" whose download is served in memory with its sha256 +// pinned: every download and every run is recorded rather than made. func fakeToolRun(t *testing.T, calls *[][]string) { t.Helper() bin := t.TempDir() - for _, n := range []string{"brew", "gitleaks"} { - if err := os.WriteFile(filepath.Join(bin, n), []byte("#!/bin/sh\n"), 0o755); err != nil { - t.Fatal(err) - } - } + home := t.TempDir() + archive := tarGzOf(t, "gitleaks", []byte("#!/bin/sh\n")) + sum := sha256.Sum256(archive) prev := newToolInstaller newToolInstaller = func(guard string) *tools.Installer { in := tools.Default(guard) + in.Home = home in.LookPath = func(n string) (string, error) { p := filepath.Join(bin, n) if _, err := os.Stat(p); err != nil { @@ -94,16 +99,47 @@ func fakeToolRun(t *testing.T, calls *[][]string) { } return p, nil } - in.Getenv = func(string) string { return "" } + in.Getenv = func(k string) string { + if k == "PATH" { + return bin + } + return "" + } + in.Get = func(_ context.Context, u string, _ int64) ([]byte, error) { + *calls = append(*calls, []string{"GET", u}) + return archive, nil + } + in.Attest = nil + in.Release = func(string) (tools.Release, bool) { + return tools.Release{Repo: "gitleaks/gitleaks", Version: "8.30.1", + Assets: map[string]tools.Asset{in.GOOS + "/" + in.GOARCH: { + URL: "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks.tar.gz", + SHA256: hex.EncodeToString(sum[:]), Archive: tools.TarGz, Member: "gitleaks"}}, + Compatible: tools.VersionRange{Min: "8.19.0", Below: "9.0.0"}}, true + } in.Run = func(_ context.Context, argv []string) ([]byte, error) { *calls = append(*calls, argv) - return []byte("8.0.0\n"), nil + return []byte("8.30.1\n"), nil } return in } t.Cleanup(func() { newToolInstaller = prev }) } +func tarGzOf(t *testing.T, name string, body []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + tw.Write(body) + tw.Close() + zw.Close() + return buf.Bytes() +} + func depCtx(t *testing.T, confirm tools.Confirm) *applyCtx { t.Helper() emptyPath(t) @@ -130,7 +166,7 @@ func TestDependencyNoKeepsTheNativeDefaultAndSaysSo(t *testing.T) { notes := strings.Join(a.notes, "\n") for _, want := range []string{ "what abcd uses it for:", - "install step", + "install: download gitleaks", "gitleaks not installed (answered no); continuing on the native secret scanner", } { if !strings.Contains(notes, want) { @@ -157,7 +193,9 @@ func TestDependencyWithoutAConfirmationNeverInstalls(t *testing.T) { } } -// TestDependencyYesRunsTheStepAndReportsIt is criterion 2's yes half at ahoy. +// TestDependencyYesRunsTheStepAndReportsIt is criterion 2's yes half at ahoy: +// the pinned download, then the verify of the copy written, reported as a +// change that names how far it was checked. func TestDependencyYesRunsTheStepAndReportsIt(t *testing.T) { var calls [][]string fakeToolRun(t, &calls) @@ -167,11 +205,12 @@ func TestDependencyYesRunsTheStepAndReportsIt(t *testing.T) { if shown.Tool != "gitleaks" { t.Fatalf("the confirmation was not shown the explanation: %+v", shown) } - if len(calls) != 2 || filepath.Base(calls[0][0]) != "brew" { - t.Fatalf("calls = %v, want the brew step and the verify", calls) + if len(calls) != 2 || calls[0][0] != "GET" || filepath.Base(calls[1][0]) != "gitleaks" { + t.Fatalf("calls = %v, want the download and the verify", calls) } changes := strings.Join(a.changes, "\n") - if !strings.Contains(changes, "ran brew install gitleaks") || !strings.Contains(changes, "verified") { + if !strings.Contains(changes, "installed 8.30.1") || !strings.Contains(changes, "verified") || + !strings.Contains(changes, "checksum only") || strings.Contains(changes, "sudo") { t.Fatalf("the install is not reported as a change: %v (notes %v)", a.changes, a.notes) } } @@ -233,3 +272,84 @@ func TestNamedToolApprovesTheDependencyCategory(t *testing.T) { t.Fatal("an explicit category subset dropped the named tool's approval") } } + +// TestAnIncompatibleGitleaksIsAGapOfItsOwn is itd-2610240918594138's third +// criterion at ahoy's detection: a gitleaks on PATH whose version abcd does +// not work with is named, with why, rather than counted as installed; a +// compatible one is no gap at all. +func TestAnIncompatibleGitleaksIsAGapOfItsOwn(t *testing.T) { + emptyPath(t) + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "gitleaks"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + version := "8.10.0" + prev := newToolInstaller + newToolInstaller = func(guard string) *tools.Installer { + in := tools.Default(guard) + in.Home = t.TempDir() + in.LookPath = func(n string) (string, error) { + p := filepath.Join(bin, n) + if _, err := os.Stat(p); err != nil { + return "", errors.New("absent") + } + return p, nil + } + in.Getenv = func(k string) string { + if k == "PATH" { + return bin + } + return "" + } + in.Run = func(context.Context, []string) ([]byte, error) { return []byte(version + "\n"), nil } + return in + } + t.Cleanup(func() { newToolInstaller = prev }) + + g := depGap(t, detectDependencies(t.TempDir()), "deps.gitleaks_incompatible") + if !strings.Contains(g.Detail, "8.10.0 is outside the versions abcd works with") || g.Tool == nil || g.Tool.Plan == nil { + t.Fatalf("gap = %+v", g) + } + version = "8.24.3" + if gaps := detectDependencies(t.TempDir()); len(gaps) != 0 { + t.Fatalf("a compatible gitleaks is a gap: %+v", gaps) + } +} + +// TestUninstallRemovesTheToolCopiesAbcdRecorded is the fourth criterion's +// second half at ahoy uninstall: the copy the record names goes, and a copy +// of the same tool installed some other way stays. +func TestUninstallRemovesTheToolCopiesAbcdRecorded(t *testing.T) { + home, _ := setupHermetic(t) + ours := filepath.Join(home, ".local", "bin", "gitleaks") + theirs := filepath.Join(t.TempDir(), "gitleaks") + for _, p := range []string{ours, theirs} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("program"), 0o755); err != nil { + t.Fatal(err) + } + } + sum := sha256.Sum256([]byte("program")) + if err := os.MkdirAll(filepath.Join(home, ".abcd.noindex"), 0o700); err != nil { + t.Fatal(err) + } + rec := `{"schema_version":1,"tools":{"gitleaks":{"path":"` + ours + `","sha256":"` + hex.EncodeToString(sum[:]) + `","version":"8.30.1"}}}` + if err := os.WriteFile(filepath.Join(home, ".abcd.noindex", "tools.json"), []byte(rec), 0o600); err != nil { + t.Fatal(err) + } + receipt, err := Uninstall(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(ours); err == nil { + t.Fatalf("abcd's copy survived: %+v", receipt.Tools) + } + if _, err := os.Stat(theirs); err != nil { + t.Fatal("a copy abcd did not install was removed") + } + if len(receipt.Tools) != 1 || !receipt.Tools[0].Removed || receipt.Tools[0].Path != "~/.local/bin/gitleaks" { + t.Fatalf("receipt = %+v (%s)", receipt.Tools, receipt.ToolsNote) + } +} diff --git a/internal/core/github/github.go b/internal/core/github/github.go index 507bb7a57d..95666b1195 100644 --- a/internal/core/github/github.go +++ b/internal/core/github/github.go @@ -178,7 +178,10 @@ func Run(cwd string, stdin []byte, args ...string) ([]byte, error) { // whichever ends first, so a caller with its own deadline is never held past // it, and the error then says gh did not answer before that deadline. func runContext(parent context.Context, cwd string, stdin []byte, args ...string) ([]byte, error) { - if _, err := exec.LookPath("gh"); err != nil { + // abcd's own recorded copy runs whatever comes first on PATH + // (itd-2610240918594138); otherwise PATH finds gh, as before. + bin, err := tools.Resolve("gh") + if err != nil { return nil, tools.Missing(errors.New("the GitHub CLI (gh) is not on PATH; abcd speaks to GitHub through it so that a "+ "remote write is made by your own authenticated identity, never by a token abcd holds"), "gh", tools.GitHubSettings) } @@ -187,7 +190,7 @@ func runContext(parent context.Context, cwd string, stdin []byte, args ...string } ctx, cancel := context.WithTimeout(parent, Timeout) defer cancel() - cmd := exec.CommandContext(ctx, "gh", args...) + cmd := exec.CommandContext(ctx, bin, args...) // Once gh is killed, a process it started that still holds its output // open is waited for at most this long, so the deadline holds. cmd.WaitDelay = time.Second diff --git a/internal/core/landing/primitives.go b/internal/core/landing/primitives.go index 8b6d114757..1ee00ec35b 100644 --- a/internal/core/landing/primitives.go +++ b/internal/core/landing/primitives.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gitutil" ) @@ -109,7 +110,9 @@ func GH(dir string, args ...string) (string, error) { // deadline (the away mode's stop check, which lists the forge under ten // seconds) holds. func GHContext(parent context.Context, dir string, args ...string) (string, error) { - bin, err := exec.LookPath("gh") + // abcd's own recorded copy runs whatever comes first on PATH + // (itd-2610240918594138); otherwise PATH finds gh, as before. + bin, err := tools.Resolve("gh") if err != nil { return "", ErrNoForgeClient } diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go index a6cda5579d..6811ad0514 100644 --- a/internal/core/site/setup_test.go +++ b/internal/core/site/setup_test.go @@ -990,9 +990,9 @@ func ghFreePath(t *testing.T) { // TestSetupOffersAMissingGh is the gh offer at site setup (the DQ3 ruling, // itd-63 criterion 2): with gh missing the forge stage puts the install to the // question with the registry's explanation. A no runs nothing and the note -// carries the command; a yes reaches the registry's step (here Homebrew is -// absent, so the step says so and nothing ran). Either way the forge stage is -// not reached and says what remains. +// carries the download; a yes reaches the pinned download (here a test +// binary's installer has no network, so the download fails and nothing is +// written). Either way the forge stage is not reached and says what remains. func TestSetupOffersAMissingGh(t *testing.T) { for _, tc := range []struct { name string @@ -1000,7 +1000,7 @@ func TestSetupOffersAMissingGh(t *testing.T) { want string }{ {"no", tools.Answer{Why: "answered no at the terminal"}, "gh not installed (answered no at the terminal)"}, - {"yes", tools.Answer{Yes: true}, "Homebrew (brew) is not on PATH, so the step cannot run"}, + {"yes", tools.Answer{Yes: true}, "a test binary never reaches the network"}, } { t.Run(tc.name, func(t *testing.T) { h := newHarness(t) @@ -1013,11 +1013,11 @@ func TestSetupOffersAMissingGh(t *testing.T) { return tc.ans } }) - if len(seen) != 1 || seen[0] != "gh: brew install gh" { + if len(seen) != 1 || !strings.HasPrefix(seen[0], "gh: download gh 2.103.2 into ") { t.Fatalf("gh was not offered with its step: %v", seen) } notes := strings.Join(res.Notes, "\n") - for _, want := range []string{tc.want, "brew install gh"} { + for _, want := range []string{tc.want, "2.103.2"} { if !strings.Contains(notes, want) { t.Errorf("the notes lack %q:\n%s", want, notes) } diff --git a/internal/core/tools/archive.go b/internal/core/tools/archive.go new file mode 100644 index 0000000000..a23c2c45c0 --- /dev/null +++ b/internal/core/tools/archive.go @@ -0,0 +1,88 @@ +package tools + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" +) + +// maxProgramBytes bounds the program extracted from an archive. gh is about +// 55 MiB unpacked. +const maxProgramBytes = 512 << 20 + +// extractMember returns the bytes of the one regular file named member inside +// an archive whose sha256 has already been checked. Only that exact name is +// read: no other entry is written anywhere, a link or a directory by that name +// is refused, and the read is bounded, so the archive chooses nothing but the +// program's bytes. +func extractMember(archive []byte, kind Archive, member string) ([]byte, error) { + switch kind { + case TarGz: + return extractTarGz(archive, member) + case Zip: + return extractZip(archive, member) + } + return nil, fmt.Errorf("abcd does not read %q archives", kind) +} + +func extractTarGz(archive []byte, member string) ([]byte, error) { + zr, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("the download is not a gzip archive: %w", err) + } + defer zr.Close() + tr := tar.NewReader(zr) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("the archive holds no %s", member) + } + if err != nil { + return nil, fmt.Errorf("the archive does not read: %w", err) + } + if h.Name != member && h.Name != "./"+member { + continue + } + if h.Typeflag != tar.TypeReg { + return nil, fmt.Errorf("%s in the archive is not a regular file", member) + } + return readBounded(tr, member) + } +} + +func extractZip(archive []byte, member string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("the download is not a zip archive: %w", err) + } + for _, f := range zr.File { + if f.Name != member { + continue + } + if !f.Mode().IsRegular() { + return nil, fmt.Errorf("%s in the archive is not a regular file", member) + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%s in the archive does not open: %w", member, err) + } + defer rc.Close() + return readBounded(rc, member) + } + return nil, fmt.Errorf("the archive holds no %s", member) +} + +func readBounded(r io.Reader, member string) ([]byte, error) { + b, err := io.ReadAll(io.LimitReader(r, maxProgramBytes+1)) + if err != nil { + return nil, fmt.Errorf("%s in the archive does not read: %w", member, err) + } + if len(b) > maxProgramBytes { + return nil, fmt.Errorf("%s in the archive is larger than the %d-byte bound", member, maxProgramBytes) + } + return b, nil +} diff --git a/internal/core/tools/attest.go b/internal/core/tools/attest.go new file mode 100644 index 0000000000..df0b5b01d1 --- /dev/null +++ b/internal/core/tools/attest.go @@ -0,0 +1,242 @@ +package tools + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/verify" + "github.com/theupdateframework/go-tuf/v2/metadata" +) + +// The provenance check is the one internal/core/update runs over abcd's own +// binary, generalised to a Signer (release.go): a Sigstore bundle whose +// certificate Fulcio issued to the signer's workflow, logged in Rekor, over +// SLSA build provenance naming the asset's sha256, verified in process with +// sigstore-go against the Sigstore trust root fetched through TUF. A +// checksums file beside the asset proves nothing alone, since whoever can +// replace one can replace both; the pinned sha256 already closes that here, +// and the provenance adds that the pinned bytes were built by the makers' +// own workflow on a GitHub-hosted runner. +// +// Unlike abcd's own check it does not bind the provenance to the release +// tag's commit: that binding exists there because the digest is learnt from +// the release page, and an older genuine build could be replayed under a newer +// tag. Here the digest is fixed in abcd's source, so no replay can change the +// bytes that are installed. + +const ( + githubActionsIssuer = "https://token.actions.githubusercontent.com" + slsaProvenanceV1 = "https://slsa.dev/provenance/v1" + githubHostedRunner = "github-hosted" + attestationsAPI = "https://api.github.com/repos/" + // bundleStoreHost is the one host a bundle_url may name. + bundleStoreHost = "tmaproduction.blob.core.windows.net" +) + +// Attester checks an asset's build provenance against a Signer, returning the +// verified signer identity. +type Attester func(ctx context.Context, s Signer, digest []byte) (string, error) + +// githubAttester is the production Attester's state: where it asks, and the +// trust root it verifies against. +type githubAttester struct { + f *fetcher + apiBase string // "https://api.github.com/repos/" + bundleOK func(*url.URL) bool + trusted func() (root.TrustedMaterial, error) +} + +// defaultAttester is the shipping provenance check: GitHub's attestations API +// and the Sigstore public-good trust root, over the pinned transport. +func defaultAttester() Attester { + g := &githubAttester{f: productionFetcher(apiHosts), apiBase: attestationsAPI, + bundleOK: func(u *url.URL) bool { return u.Scheme == "https" && u.Host == bundleStoreHost }} + g.trusted = g.sigstoreTrustedRoot + return g.verify +} + +func (g *githubAttester) verify(ctx context.Context, s Signer, digest []byte) (string, error) { + hexDigest := hex.EncodeToString(digest) + bundles, err := g.fetch(ctx, s.Repo, hexDigest) + if err != nil { + return "", err + } + tm, err := g.trusted() + if err != nil { + return "", fmt.Errorf("could not load the Sigstore trust root to check the signed build record against: %w", err) + } + var failures []string + for _, b := range bundles { + signer, err := verifyBundle(b, digest, tm, s) + if err == nil { + return signer, nil + } + failures = append(failures, err.Error()) + } + return "", fmt.Errorf("no signed build record for sha256 %s verifies as signed by %s: %s", + hexDigest, s.SAN(), strings.Join(failures, "; ")) +} + +// fetch reads the attestations GitHub holds for one digest in repo: each +// entry's inline bundle, or else the bundle its bundle_url names, which the +// store serves snappy-compressed. An entry that yields no bundle is skipped. +func (g *githubAttester) fetch(ctx context.Context, repo, hexDigest string) ([][]byte, error) { + raw, err := g.f.get(ctx, g.apiBase+repo+"/attestations/sha256:"+hexDigest+"?per_page=100", maxBundleBytes, map[string]string{ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + if err != nil { + var nf *errNotFound404 + if errors.As(err, &nf) { + return nil, fmt.Errorf("%s publishes no signed build record for sha256 %s", repo, hexDigest) + } + return nil, fmt.Errorf("could not read the signed build records for sha256 %s: %w", hexDigest, err) + } + var resp struct { + Attestations []struct { + Bundle json.RawMessage `json:"bundle"` + BundleURL string `json:"bundle_url"` + } `json:"attestations"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("the signed build records answer for sha256 %s is not JSON: %w", hexDigest, err) + } + var out [][]byte + var skipped []string + for _, a := range resp.Attestations { + if len(a.Bundle) > 0 && string(a.Bundle) != "null" { + out = append(out, a.Bundle) + continue + } + if a.BundleURL == "" { + continue + } + u, err := url.Parse(a.BundleURL) + if err != nil || !g.bundleOK(u) { + skipped = append(skipped, "a bundle_url outside GitHub's attestation store") + continue + } + sn, err := g.f.get(ctx, a.BundleURL, maxBundleBytes, nil) + if err != nil { + skipped = append(skipped, err.Error()) + continue + } + b, err := decodeSnappy(sn, maxBundleBytes) + if err != nil { + skipped = append(skipped, err.Error()) + continue + } + out = append(out, b) + } + if len(out) == 0 { + why := "" + if len(skipped) > 0 { + why = " (" + strings.Join(skipped, "; ") + ")" + } + return nil, fmt.Errorf("%s publishes no readable signed build record for sha256 %s%s", repo, hexDigest, why) + } + return out, nil +} + +// verifyBundle verifies one Sigstore bundle: the certificate chains to the +// trust root's Fulcio CA with an embedded SCT, the entry is in the trusted +// Rekor log and was integrated while the certificate was valid, the +// certificate names the Signer's workflow at its ref exactly under GitHub +// Actions' issuer, built from the Signer's repository at that ref on a +// GitHub-hosted runner, and the statement is SLSA provenance with digest +// among its subjects. +func verifyBundle(raw []byte, digest []byte, tm root.TrustedMaterial, s Signer) (string, error) { + var b bundle.Bundle + if err := b.UnmarshalJSON(raw); err != nil { + return "", fmt.Errorf("unreadable bundle: %w", err) + } + v, err := verify.NewVerifier(tm, + verify.WithSignedCertificateTimestamps(1), + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1)) + if err != nil { + return "", err + } + san, err := verify.NewSANMatcher(s.SAN(), "") + if err != nil { + return "", err + } + issuer, err := verify.NewIssuerMatcher(githubActionsIssuer, "") + if err != nil { + return "", err + } + id, err := verify.NewCertificateIdentity(san, issuer, certificate.Extensions{ + SourceRepositoryURI: "https://github.com/" + s.Repo, + SourceRepositoryRef: s.Ref, + RunnerEnvironment: githubHostedRunner, + }) + if err != nil { + return "", err + } + res, err := v.Verify(&b, verify.NewPolicy(verify.WithArtifactDigest("sha256", digest), verify.WithCertificateIdentity(id))) + if err != nil { + return "", err + } + if res.Statement == nil || res.Statement.GetPredicateType() != slsaProvenanceV1 { + got := "" + if res.Statement != nil { + got = res.Statement.GetPredicateType() + } + return "", fmt.Errorf("the record is %q, not SLSA build provenance", got) + } + if res.Signature == nil || res.Signature.Certificate == nil { + return "", errors.New("the verified record names no signing certificate") + } + return res.Signature.Certificate.SubjectAlternativeName, nil +} + +// sigstoreTrustedRoot fetches the Sigstore public-good trust root through +// TUF, anchored on the root.json sigstore-go embeds, over the pinned +// transport, from an empty temporary cache it never keeps (the default would +// write ~/.sigstore). +func (g *githubAttester) sigstoreTrustedRoot() (root.TrustedMaterial, error) { + dir, err := os.MkdirTemp("", "abcd-tuf-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + return root.FetchTrustedRootWithOptions(&tuf.Options{ + Root: tuf.DefaultRoot(), + RepositoryBaseURL: tuf.DefaultMirror, + CachePath: dir, + DisableLocalCache: true, + Fetcher: tufFetcher{f: g.f}, + }) +} + +// tufFetcher is go-tuf's fetcher over the pinned transport. +type tufFetcher struct{ f *fetcher } + +func (t tufFetcher) DownloadFile(urlPath string, maxLength int64, _ time.Duration) ([]byte, error) { + if maxLength <= 0 || maxLength > maxBundleBytes { + maxLength = maxBundleBytes + } + ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout) + defer cancel() + b, err := t.f.get(ctx, urlPath, maxLength, nil) + if err != nil { + var nf *errNotFound404 + if errors.As(err, &nf) { + // go-tuf walks root versions until a 404. + return nil, &metadata.ErrDownloadHTTP{StatusCode: 404, URL: urlPath} + } + return nil, err + } + return b, nil +} diff --git a/internal/core/tools/explain.go b/internal/core/tools/explain.go index 5045b7104c..cf038d1375 100644 --- a/internal/core/tools/explain.go +++ b/internal/core/tools/explain.go @@ -1,6 +1,7 @@ package tools import ( + "os" "runtime" "strings" ) @@ -23,10 +24,15 @@ type Explanation struct { WithoutIt string `json:"without_it,omitempty"` NativeDefault string `json:"native_default,omitempty"` OnDecline string `json:"on_decline,omitempty"` - StepManager string `json:"step_manager,omitempty"` - // Step is the exact argv Install would run on this platform; empty when - // the registry names none (an unknown tool, or a platform without a step). - Step []string `json:"step,omitempty"` + // Download is the checked download Install would make on this platform: + // the pinned version, its full URL and sha256, where the program goes and + // how far it is checked. nil when the registry names none (an unknown + // tool, or a platform abcd's release holds no download for). + Download *Download `json:"download,omitempty"` + // Plan is what Install found on this machine before it asked: the copy + // already installed and whether it is compatible, and which copy will + // run. Set only on the explanation Install hands its Confirm. + Plan *Plan `json:"plan,omitempty"` Effects string `json:"effects,omitempty"` Verify []string `json:"verify,omitempty"` // RegistryGap is set for a tool the registry does not know: the gap is @@ -35,14 +41,20 @@ type Explanation struct { } // Explain renders the explanation for name as capability uses it, on the -// running platform. +// running platform, into the running account's bin folder. func Explain(name string, capability Capability) Explanation { - return explainFor(name, capability, runtime.GOOS) + home, _ := os.UserHomeDir() + return explainFor(name, capability, runtime.GOOS, runtime.GOARCH, userBinDir(os.Getenv, home), home) } -// explainFor is Explain with the platform named, so a test can reach every -// platform's step from any host. -func explainFor(name string, capability Capability, goos string) Explanation { +// explanation is Explain for this installer's platform, environment and home. +func (in *Installer) explanation(name string, capability Capability) Explanation { + return explainFor(name, capability, in.GOOS, in.GOARCH, userBinDir(in.getenv, in.Home), in.Home) +} + +// explainFor is Explain with the platform, the bin folder and the home named, +// so a test can reach every platform's download from any host. +func explainFor(name string, capability Capability, goos, goarch, binDir, home string) Explanation { tool, ok := registry[name] if !ok { return unknown(name, capability) @@ -74,16 +86,20 @@ func explainFor(name string, capability Capability, goos string) Explanation { e.OnDecline = "nothing was installed" e.RegistryGap = gapCapture(name, capability) } - if step, ok := tool.Install[goos]; ok { - e.StepManager = step.Manager - e.Step = append([]string(nil), step.Argv...) + if asset, ok := tool.Release.assetFor(goos, goarch); ok && binDir != "" { + dest := binDir + string(os.PathSeparator) + name + e.Download = &Download{ + Version: tool.Release.Version, URL: asset.URL, SHA256: asset.SHA256, Member: asset.Member, + Dest: displayIn(home, dest), Checks: tool.Release.Checks(), ChecksumOnly: tool.Release.Signer == nil, dest: dest, + } } return e } // unknown is the generic explanation for a tool the registry does not know -// (criterion 3). It carries no step: abcd installs only what it can explain, -// and a step it does not hold is never composed from the tool's name. +// (criterion 3). It carries no download: abcd installs only what it can +// explain, and a download it does not hold is never composed from the tool's +// name. func unknown(name string, capability Capability) Explanation { return Explanation{ Tool: name, @@ -107,16 +123,28 @@ func gapCapture(name string, capability Capability) string { name + ` (` + string(capability) + `)" --category future-work-seed` } -// StepText is the install step as a person would type it, or the reason there -// is none. +// StepText is the install as a person reads it in the install question, or +// the reason there is none. It never names a step that needs admin rights. func (e Explanation) StepText() string { - if len(e.Step) == 0 { + if e.Download == nil { if !e.Known { return "none known to abcd (look the tool up before installing it)" } return "none known to abcd for this platform (see " + e.Homepage + ")" } - return strings.Join(e.Step, " ") + d := e.Download + return "download " + e.Tool + " " + d.Version + " into " + d.Dest + ", " + d.Checks +} + +// ManualText is the same install for a person to make by hand: the exact +// URL, the sha256 to check it against, and where the program goes. +func (e Explanation) ManualText() string { + if e.Download == nil { + return e.StepText() + } + d := e.Download + return "download " + d.URL + ", check its sha256 is " + d.SHA256 + ", and put " + d.Member + + " from it at " + d.Dest + " (no admin rights needed)" } // Lines is the plain-text explanation, one line per part, the first naming the @@ -125,7 +153,7 @@ func (e Explanation) Lines() []string { if !e.Known { return []string{ e.Tool + " — " + e.What, - " install step: " + e.StepText(), + " install: " + e.StepText(), " registry gap: " + e.RegistryGap, } } @@ -135,10 +163,18 @@ func (e Explanation) Lines() []string { " what abcd uses it for: " + e.Does, " without it: " + e.WithoutIt, } - if len(e.Step) > 0 { - lines = append(lines, " install step ("+e.StepManager+"): "+e.StepText()) + if e.Download != nil { + lines = append(lines, + " install: "+e.StepText(), + " from: "+e.Download.URL+" (no admin rights; nothing is written until the download passes its check)") } else { - lines = append(lines, " install step: "+e.StepText()) + lines = append(lines, " install: "+e.StepText()) + } + if e.Plan != nil && e.Plan.Existing != nil { + lines = append(lines, " already installed: "+e.Plan.Existing.Path+" — "+e.Plan.Existing.Why+"; abcd leaves that copy as it is") + } + if e.Plan != nil && e.Plan.Runs != "" { + lines = append(lines, " which copy runs: "+e.Plan.Runs) } lines = append(lines, " what the install does: "+e.Effects) if e.RegistryGap != "" { diff --git a/internal/core/tools/fetch.go b/internal/core/tools/fetch.go new file mode 100644 index 0000000000..7858a97f0a --- /dev/null +++ b/internal/core/tools/fetch.go @@ -0,0 +1,182 @@ +package tools + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "time" + + "github.com/intentdriven/abcd/internal/urlguard" +) + +// The download transport holds to the rules the updater's own fetch holds to +// (internal/core/update): https only, the environment never chooses where the +// client connects (no proxy, the CA overrides held out of the root pool), +// urlguard's address policy on every dial and every redirect, a redirect only +// to a host the request's purpose names, and a bound on time and on size. +// It is a second copy of that policy rather than a call into it because +// update imports ahoy, which imports this package; the two are held to the +// same words by their tests rather than by one function. + +const ( + // maxArchiveBytes bounds one tool download. gh's largest asset is about + // 20 MiB and gitleaks' about 8 MiB. + maxArchiveBytes = 256 << 20 + // maxBundleBytes bounds one attestation answer or bundle. + maxBundleBytes = 4 << 20 + // downloadTimeout bounds one request, connection to last byte. + downloadTimeout = 5 * time.Minute + maxRedirects = 8 +) + +// releaseHosts are the hosts a release download may reach: the release page +// itself and the asset hosts GitHub redirects a download to. +var releaseHosts = []string{"github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"} + +// apiHosts are the hosts a provenance check may reach: GitHub's attestations +// API, the blob store it hands a bundle out of when it does not inline it, and +// the Sigstore TUF repository the trust root is read from. Every byte read +// from them is verified by Sigstore before it is believed, so they choose +// nothing that runs. +var apiHosts = []string{"api.github.com", "tmaproduction.blob.core.windows.net", "tuf-repo-cdn.sigstore.dev"} + +// fetcher performs bounded GETs under the pinned transport. +type fetcher struct { + client *http.Client + allowHTTP bool // test seam: the shipping constructor never sets it + hosts map[string]bool +} + +// errNotFound404 is a 404 answer. +type errNotFound404 struct{ url string } + +func (e *errNotFound404) Error() string { return "not found: " + e.url } + +// newFetcher builds the transport. blocked is the address policy (nil allows +// every address, for a loopback test server); hosts are the only hosts a +// request or a redirect may name. +func newFetcher(blocked func(net.IP) bool, allowHTTP bool, hosts ...string) *fetcher { + f := &fetcher{allowHTTP: allowHTTP, hosts: map[string]bool{}} + for _, h := range hosts { + f.hosts[h] = true + } + dialer := &net.Dialer{Timeout: 30 * time.Second} + if blocked != nil { + dialer.Control = urlguard.DialControl(blocked) + } + f.client = &http.Client{ + Timeout: downloadTimeout, + Transport: &http.Transport{ + Proxy: nil, // by construction: the environment never chooses the route + DialContext: dialer.DialContext, + TLSHandshakeTimeout: 30 * time.Second, + TLSClientConfig: &tls.Config{RootCAs: overrideFreeRoots()}, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return errors.New("too many redirects") + } + if err := f.admitURL(req.URL); err != nil { + return fmt.Errorf("redirect refused: %w", err) + } + if blocked != nil { + if err := urlguard.CheckHostWith(req.URL.Hostname(), blocked); err != nil { + return fmt.Errorf("redirect refused: %w", err) + } + } + return nil + }, + } + return f +} + +// productionFetcher is the shipping transport for a purpose's hosts. +func productionFetcher(hosts []string) *fetcher { + return newFetcher(urlguard.BlockedIP, false, hosts...) +} + +func (f *fetcher) admitURL(u *url.URL) error { + if u.Scheme != "https" && !(f.allowHTTP && u.Scheme == "http") { + return fmt.Errorf("%s is not https", u.Redacted()) + } + if !f.hosts[u.Host] { + return fmt.Errorf("%s is not a host this download may reach", u.Host) + } + return nil +} + +// get reads rawURL whole, refusing an answer over limit bytes. +func (f *fetcher) get(ctx context.Context, rawURL string, limit int64, headers map[string]string) ([]byte, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + if err := f.admitURL(u); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "abcd-tools") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := f.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, &errNotFound404{url: u.Redacted()} + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", u.Redacted(), resp.Status) + } + if resp.ContentLength > limit { + return nil, fmt.Errorf("GET %s: %d bytes exceeds the %d-byte bound", u.Redacted(), resp.ContentLength, limit) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("GET %s: more than the %d-byte bound", u.Redacted(), limit) + } + return b, nil +} + +// caOverrides are the variables crypto/x509 reads when it loads the system +// root pool. +var caOverrides = []string{"SSL_CERT_FILE", "SSL_CERT_DIR"} + +// overrideFreeRoots loads the system root pool with the CA overrides out of +// the environment for the load alone, then puts back exactly what it took +// out: the same rule as the updater's, so an environment variable never +// chooses which certificate authorities a tool download trusts. A nil pool +// falls back to the platform default. +func overrideFreeRoots() *x509.CertPool { + taken := map[string]string{} + for _, name := range caOverrides { + if v, ok := os.LookupEnv(name); ok { + taken[name] = v + os.Unsetenv(name) + } + } + defer func() { + for name, v := range taken { + os.Setenv(name, v) + } + }() + if pool, err := x509.SystemCertPool(); err == nil { + return pool + } + return nil +} diff --git a/internal/core/tools/install.go b/internal/core/tools/install.go index ac4a2edcce..17a40046e8 100644 --- a/internal/core/tools/install.go +++ b/internal/core/tools/install.go @@ -3,6 +3,8 @@ package tools import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" @@ -10,6 +12,7 @@ import ( "path/filepath" "runtime" "strings" + "testing" "time" "github.com/intentdriven/abcd/internal/cienv" @@ -30,132 +33,439 @@ type Answer struct { // The front door supplies it; a nil Confirm is a no. type Confirm func(Explanation) Answer -// Result reports one Install: what it ran and whether it worked, or why it ran -// nothing and what the capability continues on (criterion 2). +// Result reports one Install: whether a compatible copy is ready for abcd to +// run, what was downloaded and how far it was checked, or why nothing was +// written and what the capability continues on. type Result struct { Tool string `json:"tool"` - // Ran reports that the install step was executed. + // Ready reports that a copy abcd can run is in place: one already + // installed and compatible, abcd's own recorded copy, or the one this + // install wrote and verified. + Ready bool `json:"ready"` + // Ran reports that a download was started. Ran bool `json:"ran"` - // Argv is the step as run (argv[0] resolved), when Ran. - Argv []string `json:"argv,omitempty"` - // Installed reports that the step exited zero. + // Installed reports that the checked program was written into the bin + // folder. Installed bool `json:"installed"` - // Verified reports that the verify command exited zero afterwards. + // Verified reports that the verify command ran the written copy and it + // reported the pinned version. Verified bool `json:"verified"` // Declined reports that nobody said yes (a no, no confirmation, CI). Declined bool `json:"declined"` - // Why is the reason nothing ran, or what failed. + // Path is the copy abcd runs, in the tilde form, when Ready. + Path string `json:"path,omitempty"` + // Version is the version of that copy. + Version string `json:"version,omitempty"` + // Checked is how far the download was checked, when one was written. + Checked string `json:"checked,omitempty"` + // ChecksumOnly reports that the download was checked by its pinned sha256 + // alone, because its makers publish no signed build record. + ChecksumOnly bool `json:"checksum_only,omitempty"` + // Runs says which copy abcd runs, and which one the person's shell runs. + Runs string `json:"runs,omitempty"` + // Why is the reason nothing was written, or what failed. Why string `json:"why,omitempty"` - // Output is the bounded tail of the step's output on a failure, or the - // verify command's first line on success. + // Output is the bounded tail of the verify command's output on a failure, + // or its first line on success. Output string `json:"output,omitempty"` // OnDecline is the capability's standing after a no or a failure. OnDecline string `json:"on_decline,omitempty"` - step string + pinned string verify string } // Summary is the one line a verb reports for the result. Every path that -// installed nothing ends with the capability's standing ("continuing on the -// native secret scanner"), so a no is loud rather than silent (spec scope 4). +// leaves no copy ready ends with the capability's standing ("continuing on the +// native secret scanner"), so a no is loud rather than silent. func (r Result) Summary() string { tail := "" if r.OnDecline != "" { tail = "; " + r.OnDecline } switch { + case r.Ready && !r.Ran: + return r.Tool + ": nothing installed; " + r.Runs case !r.Ran: return r.Tool + " not installed (" + r.Why + ")" + tail case !r.Installed: - s := r.Tool + ": ran " + r.step + " — it failed (" + r.Why + ")" - if r.Output != "" { - s += ": " + r.Output - } - return s + tail + return r.Tool + ": the download of " + r.pinned + " was not installed (" + r.Why + "); nothing was written" + tail case !r.Verified: - s := r.Tool + ": ran " + r.step + " — it succeeded, but the verify " + r.verify + " failed (" + r.Why + ")" + s := r.Tool + ": installed " + r.pinned + ", but the verify " + r.verify + " failed (" + r.Why + ")" if r.Output != "" { s += ": " + r.Output } return s + tail } - s := r.Tool + ": ran " + r.step + " — installed, and verified with " + r.verify + s := r.Tool + ": installed " + r.pinned + " into " + r.Path + ", " + r.Checked + ", and verified with " + r.verify if r.Output != "" { s += " (" + r.Output + ")" } + if r.Runs != "" { + s += "; " + r.Runs + } return s } -// Installer runs confirmed install steps. Every process-touching seam is a -// field so the whole decision path is exercised in tests with no package -// manager; Default is the production wiring. +// Installer installs the registry's tools. Every process-, network- and +// home-touching seam is a field so the whole decision path is exercised in +// tests with no network and no real home; Default is the production wiring. type Installer struct { // LookPath resolves a bare program name on the operator's PATH. LookPath func(string) (string, error) // Run executes an argv whose argv[0] is already resolved, without a shell. Run func(context.Context, []string) ([]byte, error) - // Getenv reads the environment (CI detection). + // Getenv reads the environment (CI detection, PATH, the XDG folders). Getenv func(string) string - // GOOS selects the platform's step. - GOOS string - // Guard is the directory a resolved program must lie outside: the - // repository the verb was run from. A PATH entry pointing into it is - // repository content, which is trusted to choose nothing that runs. + // GOOS and GOARCH select the platform's download. + GOOS, GOARCH string + // Guard is the directory a program abcd runs, and the bin folder it + // writes into, must lie outside: the repository the verb was run from. Guard string + // Home is the person's home directory: the record lives below it, and + // ~/.local/bin is the default bin folder. + Home string + // Get downloads one URL whole, bounded to limit bytes. + Get func(ctx context.Context, url string, limit int64) ([]byte, error) + // Attest checks a download's signed build record. + Attest Attester + // Release is the pinned release for a registered tool; the registry's by + // default, replaced in tests to point a download at a local server. + Release func(name string) (Release, bool) } // Default is the production installer for a verb run from guard (its -// repository root, or its working directory outside one). +// repository root, or its working directory outside one). Inside a test +// binary its network is the one SwapNetworkForTest installed, and by default +// none: a test that reaches a yes is refused the download rather than making +// one, so no test of another package can fetch a real release. func Default(guard string) *Installer { - return &Installer{LookPath: exec.LookPath, Run: runArgv, Getenv: os.Getenv, GOOS: runtime.GOOS, Guard: guard} + home, _ := os.UserHomeDir() + in := &Installer{ + LookPath: exec.LookPath, Run: runArgv, Getenv: os.Getenv, + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, Guard: guard, Home: home, + Release: registryRelease, + } + if testNetwork != nil || testing.Testing() { + in.Get, in.Attest = offlineGet, offlineAttest + if testNetwork != nil { + in.Get, in.Attest = testNetwork.get, testNetwork.attest + } + return in + } + dl := productionFetcher(releaseHosts) + in.Get = func(ctx context.Context, url string, limit int64) ([]byte, error) { + return dl.get(ctx, url, limit, nil) + } + in.Attest = defaultAttester() + return in +} + +// errOffline is what Default's download answers inside a test binary. +var errOffline = errors.New("a test binary never reaches the network") + +func offlineGet(context.Context, string, int64) ([]byte, error) { return nil, errOffline } +func offlineAttest(context.Context, Signer, []byte) (string, error) { return "", errOffline } + +type network struct { + get func(ctx context.Context, url string, limit int64) ([]byte, error) + attest Attester +} + +var testNetwork *network + +// SwapNetworkForTest makes Default download through get and check signed +// build records through attest, for a test of another package that drives an +// install to a yes. It returns the restore. +func SwapNetworkForTest(get func(ctx context.Context, url string, limit int64) ([]byte, error), attest Attester) (restore func()) { + prev := testNetwork + testNetwork = &network{get: get, attest: attest} + return func() { testNetwork = prev } +} + +func registryRelease(name string) (Release, bool) { + t, ok := registry[name] + return t.Release, ok } -// Install explains name as capability uses it, asks confirm, and on a yes runs -// the registry's step for this platform and then its verify command. It is the -// package-level spelling of Default(guard).Install. +// Install explains name as capability uses it, asks confirm, and on a yes +// installs the pinned release. It is the package-level spelling of +// Default(guard).Install. func Install(name string, capability Capability, confirm Confirm, guard string) Result { return Default(guard).Install(name, capability, confirm) } const ( - // installTimeout bounds a package manager run; a timed-out step's process - // group is killed through its own handle (runArgv names what that misses). + // installTimeout bounds the download and its checks. installTimeout = 15 * time.Minute // verifyTimeout bounds the verify command. verifyTimeout = 30 * time.Second - // maxOutput bounds what is kept of a step's output. + // maxOutput bounds what is kept of a program's output. maxOutput = 64 * 1024 // tailLines is how much of a failure's output the summary carries. tailLines = 6 ) -// Install is the trust boundary. In order, and each refusal runs nothing: +// PlanAction is what setup does about one tool. +type PlanAction string + +const ( + // UseOwned: abcd's own recorded copy is in place and intact. + UseOwned PlanAction = "use-abcd-copy" + // UseExisting: a copy installed some other way is compatible, so it is + // used and nothing is installed. + UseExisting PlanAction = "use-existing" + // InstallOwn: no compatible copy is in place, so abcd offers its own. + InstallOwn PlanAction = "install" + // Unavailable: abcd cannot install the tool here, and says why. + Unavailable PlanAction = "unavailable" +) + +// Copy is one copy of a tool on the machine. +type Copy struct { + // Path is where it is, in the tilde form. + Path string `json:"path"` + // Version is the version it reports, or "" when it reported none. + Version string `json:"version,omitempty"` + // Compatible reports that Version lies in the range abcd works with. + Compatible bool `json:"compatible"` + // Why says why it is not compatible. + Why string `json:"why,omitempty"` + abs string +} + +// Download is the checked download an install would make. +type Download struct { + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + // Member is the program's path inside the archive. + Member string `json:"member"` + // Dest is where the program is written, in the tilde form. + Dest string `json:"dest"` + // Checks is how far the download is checked. + Checks string `json:"checks"` + // ChecksumOnly reports a tool whose makers publish no signed build record. + ChecksumOnly bool `json:"checksum_only"` + dest string +} + +// Plan is what setup would do about one tool, decided from the disk, the PATH +// and the version the copy already installed reports, with no network: the +// core API a setup flow lays out before it asks anything. It prints nothing. +type Plan struct { + Tool string `json:"tool"` + Action PlanAction `json:"action"` + // Pinned is the version abcd's release fixes. + Pinned string `json:"pinned,omitempty"` + // Compatible is the range of versions abcd works with, as a person reads + // it. + Compatible string `json:"compatible,omitempty"` + // Download is the checked download, when one is offered. + Download *Download `json:"download,omitempty"` + // Existing is the copy PATH finds first, when it is not abcd's own. + Existing *Copy `json:"existing,omitempty"` + // Owned is abcd's own recorded copy, when one is in place. + Owned *Copy `json:"owned,omitempty"` + // Runs says which copy abcd runs, and which the person's shell runs. + Runs string `json:"runs,omitempty"` + // Why says why the tool is unavailable. + Why string `json:"why,omitempty"` +} + +// Plan decides what to do about name. It never downloads, never writes, and +// runs only the verify command of a copy PATH already finds, to read its +// version. +func (in *Installer) Plan(name string) Plan { + p := Plan{Tool: name} + tool, ok := registry[name] + rel, rok := in.release(name) + if !ok || !rok { + p.Action = Unavailable + p.Why = "abcd's tool registry has no entry for " + name + ", so it holds no download it trusts; " + + "it knows " + strings.Join(Names(), ", ") + return p + } + p.Pinned = rel.Version + p.Compatible = rel.Compatible.String() + if e, ok := owned(in.Home, name); ok { + p.Action = UseOwned + p.Owned = &Copy{Path: displayIn(in.Home, e.Path), Version: e.Version, Compatible: true, abs: e.Path} + p.Runs = "abcd runs its own copy at " + p.Owned.Path + " (" + e.Version + ")" + if shell := in.shellRuns(name, e.Path); shell != "" { + p.Runs += "; in your shell, " + name + " runs " + displayIn(in.Home, shell) + ", which comes first on your PATH" + } + return p + } + if prog, err := in.admit(name); err == nil { + c := in.judge(tool, rel, prog) + if lex, err := in.LookPath(name); err == nil { + c.Path = displayIn(in.Home, lex) // the spelling the person's PATH gives + } + p.Existing = &c + if c.Compatible { + p.Action = UseExisting + p.Runs = "abcd runs the copy already installed at " + c.Path + " (" + c.Version + "), which is compatible (" + + p.Compatible + "); nothing is installed" + return p + } + } + asset, ok := rel.assetFor(in.GOOS, in.GOARCH) + if !ok { + p.Action = Unavailable + p.Why = "abcd's release holds no download of " + name + " for " + in.GOOS + "/" + in.GOARCH + "; see " + tool.Homepage + return p + } + dir := userBinDir(in.getenv, in.Home) + if dir == "" { + p.Action = Unavailable + p.Why = "no home directory is resolved, so there is no bin folder of your own to install " + name + " into" + return p + } + dest := filepath.Join(dir, name) + p.Download = &Download{ + Version: rel.Version, URL: asset.URL, SHA256: asset.SHA256, Member: asset.Member, + Dest: displayIn(in.Home, dest), Checks: rel.Checks(), ChecksumOnly: rel.Signer == nil, dest: dest, + } + if exists(dest) { + // Not abcd's: Owned found no intact recorded copy above. + p.Action = Unavailable + p.Why = p.Download.Dest + " already holds a copy of " + name + " that abcd did not install, and abcd never changes another copy; " + + "move or remove it yourself to let abcd install its own, or set XDG_BIN_HOME to another folder of yours" + return p + } + p.Action = InstallOwn + p.Runs = "after the install abcd runs its own copy at " + p.Download.Dest + " whatever comes first on your PATH" + if p.Existing != nil { + if in.before(filepath.Dir(p.Existing.abs), dir) { + p.Runs += "; in your shell, " + name + " still runs " + p.Existing.Path + ", which comes first on your PATH" + } else { + p.Runs += "; in your shell, " + name + " runs abcd's copy, which comes before " + p.Existing.Path + " on your PATH" + } + } else if !in.onPath(dir) { + p.Runs += "; " + displayIn(in.Home, dir) + " is not on your PATH, so your shell will not find " + name + " by name until you add it" + } + return p +} + +// judge reads a copy's version through the tool's verify command and holds it +// to the compatible range. +func (in *Installer) judge(tool Tool, rel Release, prog string) Copy { + c := Copy{Path: displayIn(in.Home, prog), abs: prog} + ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout) + out, err := in.Run(ctx, append([]string{prog}, tool.Verify[1:]...)) + cancel() + if err != nil { + c.Why = "running " + strings.Join(tool.Verify, " ") + " failed (" + err.Error() + ")" + return c + } + c.Version = versionIn(firstLine(out)) + switch { + case c.Version == "": + c.Why = "it reports no version abcd can read" + case !rel.Compatible.Contains(c.Version): + c.Why = "version " + c.Version + " is outside the versions abcd works with (" + rel.Compatible.String() + ")" + default: + c.Compatible = true + } + return c +} + +func (in *Installer) release(name string) (Release, bool) { + if in.Release != nil { + return in.Release(name) + } + return registryRelease(name) +} + +func (in *Installer) getenv(k string) string { + if in.Getenv == nil { + return "" + } + return in.Getenv(k) +} + +// pathDirs is the PATH's entries, in order. +func (in *Installer) pathDirs() []string { + return filepath.SplitList(in.getenv("PATH")) +} + +// sameDir reports whether two directory spellings name one directory, after +// symlinks (a PATH entry under /var names the /private/var a lookup resolves +// to on macOS). +func sameDir(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) || resolvedOrSelf(a) == resolvedOrSelf(b) +} + +func (in *Installer) onPath(dir string) bool { + for _, d := range in.pathDirs() { + if sameDir(d, dir) { + return true + } + } + return false +} + +// before reports whether PATH lists a ahead of b (a listed and b not counts). +func (in *Installer) before(a, b string) bool { + for _, d := range in.pathDirs() { + switch { + case sameDir(d, a): + return true + case sameDir(d, b): + return false + } + } + return false +} + +// shellRuns is what the person's shell runs for name, as PATH spells it, or +// "" when that is ours (the copy at mine) or nothing. +func (in *Installer) shellRuns(name, mine string) string { + p, err := in.LookPath(name) + if err != nil || resolvedOrSelf(p) == resolvedOrSelf(mine) { + return "" + } + return p +} + +func resolvedOrSelf(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return p +} + +// Install is the trust boundary. In order, and each refusal writes nothing: // // 1. An unknown tool is refused: nothing is composed from a name. -// 2. A platform without a step is refused. -// 3. CI never installs, and is not asked. +// 2. CI never installs, and is not asked. +// 3. The plan is made: an intact copy of abcd's own, or a compatible copy +// installed some other way, is used and nothing is asked or installed; a +// platform with no download, or a bin folder whose program abcd did not +// install, is refused. // 4. The caller's Confirm must return yes; nil is a no. -// 5. The step's program must resolve on PATH to an absolute path outside the -// guarded tree, lexically and after symlinks. +// 5. The bin folder must lie outside the guarded tree. +// 6. The download is fetched whole, bounded, over https from GitHub alone, +// and its sha256 must equal the one abcd's release pins. +// 7. A tool whose makers sign their builds must carry a signed build record +// that verifies against the pinned signer. +// 8. The program is read out of the archive by its exact name. // -// The step then runs as an argv (no shell), with stdin closed, in its own -// process group, bounded in time (runArgv names the one limit of that bound's -// kill); the verify command follows under the same rules. +// Only then is the program written, atomically, at the account's own rights; +// the verify command must then report the pinned version, and the copy is +// recorded so abcd runs it by its path. A copy that fails the verify, or +// cannot be recorded, is removed again. func (in *Installer) Install(name string, capability Capability, confirm Confirm) Result { - e := explainFor(name, capability, in.GOOS) + tool, known := registry[name] + e := in.explanation(name, capability) r := Result{Tool: name, OnDecline: e.OnDecline} - if !e.Known { + if !known { r.Declined = true - r.Why = "abcd's tool registry has no entry for " + name + ", so it holds no install step it trusts; " + + r.Why = "abcd's tool registry has no entry for " + name + ", so it holds no download it trusts; " + "it knows " + strings.Join(Names(), ", ") return r } - if len(e.Step) == 0 { - r.Why = "abcd's registry holds no install step for " + in.GOOS + "; see " + e.Homepage - return r - } - r.step = strings.Join(e.Step, " ") - r.verify = strings.Join(e.Verify, " ") + r.verify = strings.Join(tool.Verify, " ") if reason, ci := cienv.Runner(in.Getenv); ci { r.Declined = true r.Why = "a CI runner (" + reason + "): abcd never installs a tool in CI" @@ -163,14 +473,31 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm } // Stricter than the canonical detector, on purpose: it counts CI=false and // CI=0 as no runner, which is right for a check that only skips a warning, - // but an install changes the machine and abcd cannot undo it, so any CI - // value refuses here. A person who exports CI=false is told why and can - // unset it; a runner that set it is never installed on. - if v := in.Getenv("CI"); v != "" { + // but an install changes the machine, so any CI value refuses here. + if v := in.getenv("CI"); v != "" { r.Declined = true r.Why = "CI is set: abcd never installs a tool in CI, whatever CI's value" return r } + plan := in.Plan(name) + e.Plan = &plan + switch plan.Action { + case UseOwned, UseExisting: + r.Ready = true + r.Runs = plan.Runs + r.OnDecline = "" + if plan.Owned != nil { + r.Path, r.Version = plan.Owned.Path, plan.Owned.Version + } else { + r.Path, r.Version = plan.Existing.Path, plan.Existing.Version + } + return r + case Unavailable: + r.Why = plan.Why + return r + } + dl := plan.Download + r.pinned = dl.Version if confirm == nil { r.Declined = true r.Why = "no one was asked" @@ -185,44 +512,104 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm } return r } - prog, err := in.admit(e.Step[0]) - if err != nil { - if errors.Is(err, errNotFound) { - r.Why = e.StepManager + " (" + e.Step[0] + ") is not on PATH, so the step cannot run; install " + - e.StepManager + " first, or install " + name + " by the route " + e.Homepage + " gives" - } else { - r.Why = err.Error() - } + if in.Guard == "" || !filepath.IsAbs(in.Guard) { + r.Why = "the bin folder cannot be judged without an absolute directory to hold it outside" + return r + } + if WithinTree(dl.dest, resolvedOrSelf(dl.dest), in.Guard) || WithinTree(filepath.Dir(dl.dest), resolvedOrSelf(filepath.Dir(dl.dest)), in.Guard) { + r.Why = "the bin folder " + filepath.Dir(dl.Dest) + " lies inside the repository this verb ran from, so a program written there " + + "would be repository content; set XDG_BIN_HOME to a folder of yours outside it" return r } - argv := append([]string{prog}, e.Step[1:]...) + rel, _ := in.release(name) + asset, _ := rel.assetFor(in.GOOS, in.GOARCH) r.Ran = true - r.Argv = argv ctx, cancel := context.WithTimeout(context.Background(), installTimeout) - out, err := in.Run(ctx, argv) - cancel() + defer cancel() + archive, err := in.Get(ctx, asset.URL, maxArchiveBytes) if err != nil { - r.Why = err.Error() - r.Output = tail(out) + r.Why = "the download from " + asset.URL + " failed: " + err.Error() return r } - r.Installed = true - vprog, err := in.admit(e.Verify[0]) + sum := sha256.Sum256(archive) + if got := hex.EncodeToString(sum[:]); got != asset.SHA256 { + r.Why = "the download's sha256 is " + got + ", but abcd's release fixes " + asset.SHA256 + + " for it, so it is not the build abcd names" + return r + } + if rel.Signer != nil { + if in.Attest == nil { + r.Why = "no check of its signed build record is configured for this run" + return r + } + if _, err := in.Attest(ctx, *rel.Signer, sum[:]); err != nil { + r.Why = "its signed build record does not check out: " + err.Error() + return r + } + } + prog, err := extractMember(archive, asset.Archive, asset.Member) if err != nil { r.Why = err.Error() return r } - ctx, cancel = context.WithTimeout(context.Background(), verifyTimeout) - vout, err := in.Run(ctx, append([]string{vprog}, e.Verify[1:]...)) - cancel() - if err != nil { + dir := filepath.Dir(dl.dest) + if err := os.MkdirAll(dir, 0o755); err != nil { + r.Why = "could not create your bin folder " + filepath.Dir(dl.Dest) + " (" + err.Error() + "); abcd installs only into a folder " + + "you own, so set XDG_BIN_HOME to one" + return r + } + if exists(dl.dest) { + r.Why = dl.Dest + " appeared while abcd was downloading, and abcd never changes a copy it did not install" + return r + } + if err := fsutil.WriteFileAtomic(dl.dest, prog, 0o755); err != nil { + r.Why = "could not write " + dl.Dest + " (" + err.Error() + "); abcd installs only into a folder you own, so set " + + "XDG_BIN_HOME to one" + return r + } + r.Installed = true + vctx, vcancel := context.WithTimeout(context.Background(), verifyTimeout) + vout, err := in.Run(vctx, append([]string{dl.dest}, tool.Verify[1:]...)) + vcancel() + got := versionIn(firstLine(vout)) + switch { + case err != nil: r.Why = err.Error() r.Output = tail(vout) + case got != dl.Version: + r.Why = "it reports version " + got + ", not the pinned " + dl.Version + r.Output = tail(vout) + } + if r.Why != "" { + r.Why += "; abcd removed the copy it wrote" + _ = os.Remove(dl.dest) + return r + } + progSum := sha256.Sum256(prog) + rec, err := readRecord(in.Home) + if err == nil { + rec.Tools[name] = Installed{Path: dl.dest, SHA256: hex.EncodeToString(progSum[:]), Version: dl.Version, + AssetSHA256: asset.SHA256, Checked: dl.Checks} + err = writeRecord(in.Home, rec) + } + if err != nil { + _ = os.Remove(dl.dest) + r.Why = "the copy works, but " + displayIn(in.Home, filepath.Join(in.Home, filepath.FromSlash(recordRel))) + + " could not record it (" + err.Error() + "), so abcd could neither run it by its path nor remove it on uninstall; abcd removed it again" return r } r.Verified = true + r.Ready = true r.OnDecline = "" + r.Path = dl.Dest + r.Version = dl.Version + r.Checked = dl.Checks + r.ChecksumOnly = dl.ChecksumOnly r.Output = firstLine(vout) + r.Runs = "abcd runs this copy whatever comes first on your PATH" + if shell := in.shellRuns(name, dl.dest); shell != "" { + r.Runs += "; in your shell, " + name + " runs " + displayIn(in.Home, shell) + ", which comes first on your PATH" + } return r } diff --git a/internal/core/tools/local.go b/internal/core/tools/local.go new file mode 100644 index 0000000000..e618d09120 --- /dev/null +++ b/internal/core/tools/local.go @@ -0,0 +1,287 @@ +package tools + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/intentdriven/abcd/internal/abcdhome" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// The installed-tools record is how abcd knows a copy is its own: the +// absolute path of each program it installed and the sha256 of the bytes it +// wrote there, in ~/.abcd.noindex/tools.json beside the path-entry record that +// vouches for abcd's own binary. Ownership is recorded provenance, never +// content-guessing (brief invariant 12's rule, applied to the tools): a +// program abcd runs by its recorded path only while its bytes still hash to +// the record, and removes only on that same condition, so a file someone else +// put at that path, or changed since, is never run as abcd's and never +// deleted. + +// recordRel is the record's place in the home, in the slash form the +// home-scoped primitives take. +var recordRel = abcdhome.Rel("tools.json") + +// maxRecordBytes bounds the record read. +const maxRecordBytes = 64 << 10 + +// Record is the installed-tools record. +type Record struct { + SchemaVersion int `json:"schema_version"` + Tools map[string]Installed `json:"tools"` +} + +// Installed is one copy abcd installed. +type Installed struct { + // Path is the program's absolute path. + Path string `json:"path"` + // SHA256 is the digest of the bytes abcd wrote there. + SHA256 string `json:"sha256"` + // Version is the pinned release it came from. + Version string `json:"version"` + // AssetSHA256 is the digest of the downloaded archive, which matched the + // pin. + AssetSHA256 string `json:"asset_sha256"` + // Checked is how far the download was checked, as the result said it. + Checked string `json:"checked"` +} + +// userBinDir is the folder abcd installs a tool's program into, in uv's order: +// $XDG_BIN_HOME, then $XDG_DATA_HOME/../bin, then ~/.local/bin, each only when +// absolute. It is the person's own folder: nothing here needs admin rights. +// Empty when home is not absolute. +func userBinDir(getenv func(string) string, home string) string { + if p := getenv("XDG_BIN_HOME"); filepath.IsAbs(p) { + return filepath.Clean(p) + } + if p := getenv("XDG_DATA_HOME"); filepath.IsAbs(p) { + return filepath.Join(p, "..", "bin") + } + if !filepath.IsAbs(home) { + return "" + } + return filepath.Join(home, ".local", "bin") +} + +// readRecord reads the record from home. An absent record is an empty one; a +// record that does not pass the home-declaration guards (owned by this +// account, writable by no one else, not behind a symlink) or does not parse +// is an error, and reads as no tool installed by abcd. +func readRecord(home string) (Record, error) { + rec := Record{SchemaVersion: 1, Tools: map[string]Installed{}} + if !filepath.IsAbs(home) { + return rec, errors.New("no absolute home directory to read " + abcdhome.Display("tools.json") + " from") + } + raw, refusal, err := fsutil.ReadHomeDeclaration(home, recordRel, maxRecordBytes) + if refusal == fsutil.DeclarationAbsent { + return rec, nil + } + if err != nil { + return rec, err + } + var got Record + if err := json.Unmarshal(raw, &got); err != nil { + return rec, errors.New(abcdhome.Display("tools.json") + " does not parse: " + err.Error()) + } + for name, e := range got.Tools { + if Known(name) && filepath.IsAbs(e.Path) && hexDigest(e.SHA256) { + rec.Tools[name] = e + } + } + return rec, nil +} + +// writeRecord writes rec into home, creating ~/.abcd.noindex at the account's +// own mode and writing through its descriptor; an empty record removes the +// file. +func writeRecord(home string, rec Record) error { + if err := fsutil.HomeScopeLink(home, recordRel); err != nil { + return err + } + dir, err := fsutil.EnsureHomeScope(home, path.Dir(recordRel), abcdhome.DirMode) + if err != nil { + return err + } + defer dir.Close() + if len(rec.Tools) == 0 { + if err := dir.Remove(path.Base(recordRel)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + rec.SchemaVersion = 1 + body, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + return fsutil.WriteFileAtomicInRoot(dir, path.Base(recordRel), append(body, '\n'), abcdhome.FileMode) +} + +func hexDigest(s string) bool { + if len(s) != 64 { + return false + } + _, err := hex.DecodeString(s) + return err == nil && strings.ToLower(s) == s +} + +// fileDigest hashes the regular file at p, never following a symlink at the +// leaf. +func fileDigest(p string) (string, bool) { + fi, err := os.Lstat(p) + if err != nil || !fi.Mode().IsRegular() { + return "", false + } + f, err := os.Open(p) + if err != nil { + return "", false + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, io.LimitReader(f, maxProgramBytes+1)); err != nil { + return "", false + } + return hex.EncodeToString(h.Sum(nil)), true +} + +// intactCache remembers a digest judgement for one (path, size, mtime), so a +// verb that runs gh a dozen times hashes it once. +var intactCache sync.Map + +type intactKey struct { + path string + size int64 + mtime int64 +} + +// intact reports whether e's program is still the bytes abcd wrote. +func intact(e Installed) bool { + fi, err := os.Lstat(e.Path) + if err != nil || !fi.Mode().IsRegular() { + return false + } + k := intactKey{e.Path, fi.Size(), fi.ModTime().UnixNano()} + if v, ok := intactCache.Load(k); ok { + return v.(string) == e.SHA256 + } + got, ok := fileDigest(e.Path) + if !ok { + return false + } + intactCache.Store(k, got) + return got == e.SHA256 +} + +// owned is abcd's own recorded copy of name in home, when the record names one +// and its bytes still hash to the record. +func owned(home, name string) (Installed, bool) { + rec, err := readRecord(home) + if err != nil { + return Installed{}, false + } + e, ok := rec.Tools[name] + if !ok || !intact(e) { + return Installed{}, false + } + return e, true +} + +// userHome is the person's home directory, a seam for tests. +var userHome = os.UserHomeDir + +// pathLookup is the PATH lookup Resolve falls back to, a seam for tests. +var pathLookup = exec.LookPath + +// Resolve is where abcd runs name from: abcd's own recorded copy when there is +// one and it is intact, whatever comes first on PATH (the intent's fourth +// criterion), and otherwise the PATH lookup. Every caller that runs a +// registered tool resolves it here, never by a bare exec.LookPath. +func Resolve(name string) (string, error) { + if home, err := userHome(); err == nil { + if e, ok := owned(home, name); ok { + return e.Path, nil + } + } + return pathLookup(name) +} + +// Removal reports what uninstall did with one recorded copy. +type Removal struct { + Tool string `json:"tool"` + Path string `json:"path"` + Removed bool `json:"removed"` + Note string `json:"note"` +} + +// Uninstall removes the copies the record in home names, and only those, and +// only while each is still the bytes abcd wrote: a file changed since, or +// replaced by another, is left where it is and said so. The record goes once +// nothing it names is still abcd's to remove. Nothing else is touched: a copy +// installed some other way was never recorded, so it is never reached. +func Uninstall(home string) ([]Removal, error) { + rec, err := readRecord(home) + if err != nil { + return nil, err + } + names := make([]string, 0, len(rec.Tools)) + for n := range rec.Tools { + names = append(names, n) + } + sort.Strings(names) + out := []Removal{} + keep := map[string]Installed{} + for _, n := range names { + e := rec.Tools[n] + r := Removal{Tool: n, Path: displayIn(home, e.Path)} + switch { + case !exists(e.Path): + r.Note = "already gone" + case !intact(e): + r.Note = "changed since abcd installed it, so it is not abcd's to remove; left untouched" + default: + if err := os.Remove(e.Path); err != nil { + r.Note = "remove failed: " + err.Error() + keep[n] = e + } else { + r.Removed = true + r.Note = "removed abcd's copy" + } + } + out = append(out, r) + } + if len(names) > 0 { + if err := writeRecord(home, Record{Tools: keep}); err != nil { + return out, err + } + } + return out, nil +} + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} + +// displayIn is p in the tilde form under home, so a message pasted into an +// issue never carries the username. +func displayIn(home, p string) string { + if home != "" && filepath.IsAbs(home) { + if p == home { + return "~" + } + if strings.HasPrefix(p, home+string(filepath.Separator)) { + return "~" + p[len(home):] + } + } + return p +} diff --git a/internal/core/tools/registry.go b/internal/core/tools/registry.go index da773c1ac7..b208768bbf 100644 --- a/internal/core/tools/registry.go +++ b/internal/core/tools/registry.go @@ -11,11 +11,14 @@ // asks on the terminal, the plugin page asks through the host's question tool, // and this code is the same under both (criterion 5). // -// The trust boundary is Install. What it runs is fixed data in this file, -// compiled into the binary: an argv per platform, never composed from a flag, a -// repository file or an environment value, never a shell string. It runs only -// after the caller's Confirm returns yes, never in CI, and never a program that -// resolves inside the repository it was asked from. See install.go. +// The trust boundary is Install. What it fetches is fixed data compiled into the +// binary (release.go): one release download per platform, named by its full +// URL with its sha256 pinned beside it, never composed from a flag, a +// repository file or an environment value, and never discovered (brief +// invariant 7: no version-discovery request exists anywhere in abcd). It runs +// only after the caller's Confirm returns yes, never in CI, writes nothing +// until the download has passed its check, and needs no admin rights. See +// install.go and adr-2610241057124151. package tools import "sort" @@ -72,46 +75,36 @@ type Use struct { OnDecline string } -// Step is one platform's install step: the package manager it uses, by name, -// and the exact argv run. Argv[0] is a bare program name resolved on PATH at -// run time; the rest are literal arguments. -type Step struct { - Manager string - Argv []string -} - // Tool is one registry entry. type Tool struct { Name string What string // what the tool is, plainly Homepage string // where to read what it is Uses map[Capability]Use - Install map[string]Step // keyed by runtime.GOOS + // Release is the one build of the tool abcd installs: its version, its + // download per platform with the sha256 pinned here, and how far its + // makers let a download be checked (release.go). + Release Release // Effects is what the install does on the machine and over the network, and // what it does not. Effects string - // Verify is the argv that proves the tool runs once installed. + // Verify is the argv that proves the tool runs once installed; argv[0] is + // the tool's name, replaced by the absolute path of the copy being judged. Verify []string } -// homebrew builds the Homebrew step for a formula. Homebrew is the one package -// manager with a single fixed command on both supported platforms (macOS, and -// Linux through Homebrew on Linux); a machine without it is told so, never -// offered a step composed for some other manager. -func homebrew(formula string) Step { - return Step{Manager: "Homebrew", Argv: []string{"brew", "install", formula}} -} - -// homebrewEffects is what a Homebrew install does beyond the machine, said in -// full because it is part of the sentence the install question is answered on: -// brew install may first update its package lists over the network, downloads -// the package, and sends Homebrew's own install analytics unless the person's -// Homebrew settings turn them off. abcd neither adds to nor subtracts from -// that: the step runs with the person's environment, as if they typed it. -func homebrewEffects(program string) string { - return "Homebrew uses the network: it may first update its package lists, then downloads the " + program + - " program (with anything it depends on) and puts it on your PATH, and it sends Homebrew's own install " + - "analytics unless your Homebrew settings turn them off (brew analytics off)." +// downloadEffects is what a checked download does beyond the machine, said in +// full because it is part of the sentence the install question is answered +// on: one download from the tool's own GitHub release (and, for a tool whose +// makers sign their builds, one read of GitHub's attestations API and of the +// Sigstore trust root), nothing sent but the requests themselves, no admin +// rights, and nothing written until the download has passed its check. +func downloadEffects(program string) string { + return "abcd downloads the one " + program + " build its own release names from " + program + + "'s GitHub release page, checks it against the sha256 abcd's release fixes before anything is written, " + + "and puts the program in your own bin folder (~/.local/bin unless XDG_BIN_HOME names another); it needs no admin " + + "rights, uses no package manager, and records the copy in ~/.abcd.noindex/tools.json so abcd runs exactly " + + "that copy and abcd ahoy uninstall removes it." } // registry is the curated set (the product thinker's decision 2). An entry is @@ -147,8 +140,8 @@ var registry = map[string]Tool{ "is installed or .abcd/config/gitleaks.json sets enabled to false", }, }, - Install: map[string]Step{"darwin": homebrew("gitleaks"), "linux": homebrew("gitleaks")}, - Effects: homebrewEffects("gitleaks") + " The install changes no repository and starts no background service; " + + Release: gitleaksRelease, + Effects: downloadEffects("gitleaks") + " The install changes no repository and starts no background service; " + "abcd runs gitleaks only in a repository that opts in.", Verify: []string{"gitleaks", "version"}, }, @@ -166,8 +159,8 @@ var registry = map[string]Tool{ OnDecline: "the settings on GitHub stay unread and unchanged", }, }, - Install: map[string]Step{"darwin": homebrew("gh"), "linux": homebrew("gh")}, - Effects: homebrewEffects("gh") + " The install does not sign you in: run gh auth login afterwards, " + + Release: ghRelease, + Effects: downloadEffects("gh") + " The install does not sign you in: run gh auth login afterwards, " + "and abcd never sees the token.", Verify: []string{"gh", "--version"}, }, diff --git a/internal/core/tools/release.go b/internal/core/tools/release.go new file mode 100644 index 0000000000..9ca0fe80ff --- /dev/null +++ b/internal/core/tools/release.go @@ -0,0 +1,235 @@ +package tools + +import ( + "fmt" + "regexp" + "strconv" +) + +// Release is the one build of a tool abcd installs, fixed in abcd's own +// release (brief invariant 7: "provisioning fetches only a manifest-named, +// pinned, checksum-verified artifact. No version-discovery request exists +// anywhere in abcd"). Every field is compiled in: the download URL is spelled +// out in full, the asset's sha256 is pinned beside it, and nothing about it is +// read from the network, a flag, a repository file or the environment. A tool +// therefore updates only when abcd does (dcn-2610240845548776, and the +// intent's third decision). +type Release struct { + // Repo is the tool's GitHub repository, owner/name. + Repo string + // Version is the pinned release, without its leading v. + Version string + // Assets is the download for each platform abcd supports, keyed + // "<GOOS>/<GOARCH>". A platform with no entry has no install. + Assets map[string]Asset + // Signer is who must have signed the asset's SLSA build provenance, for a + // tool whose makers publish one. nil is a tool that publishes no signed + // build record: its download is checked by checksum only, and every + // explanation and result says so. + Signer *Signer + // Compatible is the range of the tool's versions abcd works with. A copy + // already installed is used, and nothing installed, only when its version + // falls inside it (the intent's third criterion). The pinned Version must + // fall inside it too (TestEveryPinnedReleaseIsCompatible), which is the + // hook a release-time compatibility check moves (iss-2610240959030370). + // The version is read from the output of the tool's Verify argv. + Compatible VersionRange +} + +// Asset is one platform's download. +type Asset struct { + // URL is the release asset's full https://github.com/<repo>/releases/download/ + // address: no "latest", no redirect a release page could move. + URL string + // SHA256 is the asset's digest, copied from the release's published + // checksums file and checked against the downloaded bytes when it was + // pinned. It is the check every download must pass before anything is + // written. + SHA256 string + // Archive is the asset's packaging. + Archive Archive + // Member is the program's path inside the archive. + Member string +} + +// Archive is how an asset is packaged. +type Archive string + +const ( + // TarGz is a gzip-compressed tar archive. + TarGz Archive = "tar.gz" + // Zip is a zip archive. + Zip Archive = "zip" +) + +// Signer is a build-provenance policy: the certificate identity a Sigstore +// bundle over the asset must carry. It is pinned exactly (no prefix match): +// the repository, the workflow file inside it, and the ref that workflow ran +// from. The digest is pinned in abcd's own source, so the provenance is not +// bound to a commit: it proves the pinned bytes were built by the makers' +// workflow, which a release page edit cannot forge (provenance.go's rationale). +type Signer struct { + Repo string // owner/name + Workflow string // path of the signing workflow inside the repository + Ref string // the git ref the signing workflow ran from +} + +// SAN is the certificate's subject alternative name the policy requires. +func (s Signer) SAN() string { + return "https://github.com/" + s.Repo + "/" + s.Workflow + "@" + s.Ref +} + +// VersionRange is [Min, Below): Min inclusive, Below exclusive. Below empty +// means no upper bound. +type VersionRange struct { + Min string + Below string +} + +// String is the range as a person reads it. +func (r VersionRange) String() string { + if r.Below == "" { + return r.Min + " or later" + } + return r.Min + " or later, below " + r.Below +} + +// Contains reports whether v lies in the range. A version that does not parse +// lies in no range. +func (r VersionRange) Contains(v string) bool { + got, ok := parseVersion(v) + if !ok { + return false + } + if lo, ok := parseVersion(r.Min); !ok || compareVersion(got, lo) < 0 { + return false + } + if r.Below == "" { + return true + } + hi, ok := parseVersion(r.Below) + return ok && compareVersion(got, hi) < 0 +} + +var versionShape = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) + +// parseVersion reads the first major.minor.patch in s. +func parseVersion(s string) ([3]int, bool) { + m := versionShape.FindStringSubmatch(s) + if m == nil { + return [3]int{}, false + } + var v [3]int + for i := range v { + n, err := strconv.Atoi(m[i+1]) + if err != nil { + return [3]int{}, false + } + v[i] = n + } + return v, true +} + +func compareVersion(a, b [3]int) int { + for i := range a { + switch { + case a[i] < b[i]: + return -1 + case a[i] > b[i]: + return 1 + } + } + return 0 +} + +// versionIn is the first major.minor.patch in a tool's version output, or "". +func versionIn(out string) string { + v, ok := parseVersion(out) + if !ok { + return "" + } + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) +} + +// Checks says how far a download of this release is checked, in the words a +// person reads: always the sha256 abcd's release fixes, and the signed build +// record only where the makers publish one. +func (r Release) Checks() string { + if r.Signer == nil { + return "checked by checksum only: the sha256 abcd's release fixes for it (" + r.Repo + + " publishes no signed build record to check it against)" + } + return "checked against the sha256 abcd's release fixes for it, and against its signed build record (" + + r.Signer.SAN() + ")" +} + +// platform is the Assets key for a GOOS and GOARCH. +func platform(goos, goarch string) string { return goos + "/" + goarch } + +// releaseDownload is the GitHub release download URL for repo's tag and file: +// the one URL shape the table holds (TestEveryAssetURLIsAPinnedReleaseDownload). +func releaseDownload(repo, tag, file string) string { + return "https://github.com/" + repo + "/releases/download/" + tag + "/" + file +} + +// gitleaksRelease is gitleaks v8.30.1 (2026-03-21). The sha256 values were +// copied on 2026-10-24 from the release's gitleaks_8.30.1_checksums.txt and +// each matched the downloaded asset. gitleaks signs nothing (its +// .goreleaser.yml has no signing step and no attestation is published), so a +// download is checked by checksum only, and setup says so. Compatible starts +// at 8.19.0, the release that added the `dir` command the adapter runs +// (internal/adapter/gitleaks/runner.go), and stops below 9.0.0, the next +// major release, whose report format abcd has not read. +var gitleaksRelease = func() Release { + const repo, v = "gitleaks/gitleaks", "8.30.1" + asset := func(file, sum string) Asset { + return Asset{URL: releaseDownload(repo, "v"+v, file), SHA256: sum, Archive: TarGz, Member: "gitleaks"} + } + return Release{ + Repo: repo, + Version: v, + Assets: map[string]Asset{ + "darwin/arm64": asset("gitleaks_8.30.1_darwin_arm64.tar.gz", "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5"), + "darwin/amd64": asset("gitleaks_8.30.1_darwin_x64.tar.gz", "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709"), + "linux/arm64": asset("gitleaks_8.30.1_linux_arm64.tar.gz", "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080"), + "linux/amd64": asset("gitleaks_8.30.1_linux_x64.tar.gz", "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"), + }, + Compatible: VersionRange{Min: "8.19.0", Below: "9.0.0"}, + } +}() + +// ghRelease is gh v2.103.2 (2026-10-23). The sha256 values were copied on +// 2026-10-24 from the release's gh_2.103.2_checksums.txt and each matched the +// downloaded asset. cli/cli signs SLSA build provenance over every release +// asset from .github/workflows/deployment.yml on refs/heads/trunk (the +// certificate of every recorded bundle names it), so a download is checked +// against that too. Compatible starts at 2.102.0, the release that made `gh +// attestation verify --source-ref` compare the ref exactly rather than +// ignoring case: abcd's by-hand fallback for its own binary relies on that +// flag (internal/core/ahoy/owned_copy.go). It stops below 3.0.0. +var ghRelease = func() Release { + const repo, v = "cli/cli", "2.103.2" + asset := func(file, sum string, a Archive, dir string) Asset { + return Asset{URL: releaseDownload(repo, "v"+v, file), SHA256: sum, Archive: a, Member: dir + "/bin/gh"} + } + return Release{ + Repo: repo, + Version: v, + Assets: map[string]Asset{ + "darwin/arm64": asset("gh_2.103.2_macOS_arm64.zip", "5416000f5d17580a187339e0c4068a01ca5c87f7d4fe6ca64933696f967bc418", Zip, "gh_2.103.2_macOS_arm64"), + "darwin/amd64": asset("gh_2.103.2_macOS_amd64.zip", "2dc7f1fc4761f96732995f6e4f4c6b4d705f2635da4126620c8b30eaa67b9c98", Zip, "gh_2.103.2_macOS_amd64"), + "linux/arm64": asset("gh_2.103.2_linux_arm64.tar.gz", "4c3e99a50bd8b181a3fe9d69b25dd978bac768148d88f01507c6b4d1ef61a443", TarGz, "gh_2.103.2_linux_arm64"), + "linux/amd64": asset("gh_2.103.2_linux_amd64.tar.gz", "62ccf747115ddc61237efffdde66a3c8f70ba249433dc64f70801089b59c59c8", TarGz, "gh_2.103.2_linux_amd64"), + }, + Signer: &Signer{Repo: repo, Workflow: ".github/workflows/deployment.yml", Ref: "refs/heads/trunk"}, + Compatible: VersionRange{Min: "2.102.0", Below: "3.0.0"}, + } +}() + +// assetFor is the release's download for a platform. The scheme is the +// fetcher's to hold to https (fetch.go), and the table's URLs are held to +// GitHub release downloads by TestEveryPinnedReleaseIsComplete. +func (r Release) assetFor(goos, goarch string) (Asset, bool) { + a, ok := r.Assets[platform(goos, goarch)] + return a, ok && a.URL != "" +} diff --git a/internal/core/tools/snappy.go b/internal/core/tools/snappy.go new file mode 100644 index 0000000000..cebcde86d6 --- /dev/null +++ b/internal/core/tools/snappy.go @@ -0,0 +1,87 @@ +package tools + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// decodeSnappy decodes one snappy block (the format GitHub's attestation +// store serves a bundle in when its API does not inline it: a ".json.sn" +// blob). It is the block format only, no framing, and it refuses an output +// longer than limit before allocating it. Hand-written rather than a new +// dependency: the format is a length and a run of literal and back-reference +// elements, and every byte it produces is then verified by Sigstore, so a +// decoding fault can only make a check fail, never pass. +func decodeSnappy(src []byte, limit int) ([]byte, error) { + n, k := binary.Uvarint(src) + if k <= 0 { + return nil, errors.New("snappy: no length header") + } + if n > uint64(limit) { + return nil, fmt.Errorf("snappy: %d bytes exceeds the %d-byte bound", n, limit) + } + dst := make([]byte, 0, int(n)) + s := src[k:] + for len(s) > 0 { + tag := s[0] + switch tag & 3 { + case 0: // literal + length := int(tag >> 2) + s = s[1:] + if length >= 60 { + w := length - 59 + if len(s) < w { + return nil, errors.New("snappy: truncated literal length") + } + length = 0 + for i := 0; i < w; i++ { + length |= int(s[i]) << (8 * i) + } + s = s[w:] + } + length++ + if length <= 0 || length > len(s) || len(dst)+length > int(n) { + return nil, errors.New("snappy: literal overruns its input or its declared length") + } + dst = append(dst, s[:length]...) + s = s[length:] + default: // copy + var length, offset int + switch tag & 3 { + case 1: + if len(s) < 2 { + return nil, errors.New("snappy: truncated copy") + } + length = 4 + int(tag>>2)&7 + offset = int(tag>>5)<<8 | int(s[1]) + s = s[2:] + case 2: + if len(s) < 3 { + return nil, errors.New("snappy: truncated copy") + } + length = 1 + int(tag>>2) + offset = int(binary.LittleEndian.Uint16(s[1:3])) + s = s[3:] + case 3: + if len(s) < 5 { + return nil, errors.New("snappy: truncated copy") + } + length = 1 + int(tag>>2) + offset = int(binary.LittleEndian.Uint32(s[1:5])) + s = s[5:] + } + if offset <= 0 || offset > len(dst) || len(dst)+length > int(n) { + return nil, errors.New("snappy: copy reaches outside the output") + } + start := len(dst) - offset + for i := 0; i < length; i++ { // overlapping copies repeat, byte by byte + dst = append(dst, dst[start+i]) + } + } + } + if len(dst) != int(n) { + return nil, fmt.Errorf("snappy: decoded %d bytes, header declared %d", len(dst), n) + } + return dst, nil +} diff --git a/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.0.json.sn b/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.0.json.sn new file mode 100644 index 0000000000000000000000000000000000000000..f568ed10b1ec1cb5a7de9cd77c15fe07c2791838 GIT binary patch literal 4832 zcmZ8lO|0YAb$-gsG&Ata^TZto)8aLVA7(MlOlL?+dQURO!I0vQsHc1RNRgsQr-3O_ z;=iP)KT33M#6fGgMGz!_5d?*iI)D)vMbJ8h3uI9gHjn~Yc#%aGU35_cS$NSFTC~6{ zy67i+5u+FgBk*0`bI&>7&$;?1cGr6OBp(H_XKrVsljl#o*({5FZy8S~57(36K`>fB zSj6FCIh~Il3|EsN8$DPv532W)#dLCV|71Oy$K&`0`-pf;eD$)E=U+bYNAu;4KR6gw zp5Fa>?iIpzIa*+EqN=U)ven{;t&lHNfe%$nmKMV}mDyQK*#?`$8ZPc7`Lx$y7*e!C zM?IgqYMC$+Uywo*r9Kge!n2mtiU={djOn%eaknLOhB;XgqfxYVN9^mmC~aINus4Zk zH^QD>XWT(&>DtE385o(W6dNMc2BN8QM07SJ!R{hH@^t^K`v0C4=gH^e(#^q5sSpe2 z#^&2aBm`|E^Q-tIg1bMQu)S1zq}Jp^Nolpdbhvw^klH+>@`7UZr9Qp%dwR|@omxG- z?6=F7p7w0doz%pY-xuXbtLb&k4*P+2QM2V*O|#bWK+l_*I5y^OvQYbNX}Bii?pd#` z^;dm62|cR_J+%-+WNt7Qyxx8&@Vdx{BOV8;eDuwrqw9q5Q=Dg^@VX@nNSE-%wd1=| zAqfu)y+wHbkIkK7Fq$AZk}S@{bDH~ujBBrqQLu5A&GsrAj2|{tA~g052-HG%h~V)u z5u=rSp7xl=z3OCIll0+5&pC|@cTtiSqg3wi9!t8OlU7er^N0!BLaD`#wW~B4hifjo zlTJ2N`jOA|%WfXb0wrRdxSk9-=63*4k9Y5gp3WC;sBZYqq{XGtg<^Lu-B^;>;daf; z)1@QC6DvC9Q*lzO7EUj;qB3uIhUMLx%UoSgS$8y5;;PDH4>eQ;ASxx7qRxu_R%aV{ zs=H!`o8>wjH2KNeJ4+4Goe!sUv*5FA7)+*f$1w9|zDZqAf8m9bSMHxI<9xJOdikvX zDG&V7HxET`>5XpR9{W}Hlr_J6=RTnS;>#x>(PR`n`)po6W=tO4k$8_k-11Jxd-j89 z?NeU&TT?J(Wr87dtHtYNBbt0y2(vIsL&4RF1jZEMuuxk3U;IKHRz+RvNW3IXRb?Tl z%8aN=j*SH&Oy@!<NxY*yB)nP>lc%Tcga2+<B$?;+aCW9U%tmdG7Fw=tw!PC!D#pD{ z$WiB9jL*B;w7Zf*K{710CF|m%{!lG1?=2Ud)$-h58|z_D8P7eddM1PyAkUU2Qb7{3 zC8Ej@lX`?FIKC3nOP#^9jcoF!5YoQDPfbxmkEMN&i#;)uZX$s<{->noH$?BtR8VDO zHnjU?pvX&K$yV+u&l5)#stHOHOtH6A^bnUARv{Ii68>B&Hf?WUsDZ66R4H3I+uBAk z`N3y#i7Cm3srQ80a00I8Z00Ii6ew0p7W|TH4rdR;v(xrzWHt)97LJA;qoh5;FO-(u zQhL0c@n8vmiTJuzD53Mh2RBoZ=#21I<(LTQNbgc%-#Zpe=|V1;t#H3`RcxHzJ=m$D zv=v0LdatoHLsOVANh8P%N$~pyPpb7~=FGd}%lcWnqlej;nfi5qu;%^ChEP4H%ZE#o z)8ZE67op=n|0cFm*JKbDK_W1utxBq-+uQScDJ!#NYK~j={(4%Sh{@J#6#iMG9-m&K zxeHZpoDGOnD?Z<mfB2~K3luj{`dbt`^=z4ObtrOqF=SVhd*XUJzLYOYeh>@wMb;*) zT0GDG-tKQCw-hoy;cZgyPL)-sJk|Tw`DLT5O#<t3IB8AGW+d9n@nw*!7c-+5x2taN zvS!W;)6NE_c)_r1HegyrZ`C`II1O8I=X9Qo&-y`gViu<gYfJ0ph{-%{>TgG9jp>+9 zy;>s`FRD$oF+SDhW?3~yrILb#fBFh|7%UbeX|f(=)7j{?FPbxNn@zm{o-^8Z#z8wP z+@5;+;ON@-J-fCHAU(rveKy=SO+busZ8jTD^mUL(@RCj$y!KzaU)z*Xli3jOvwoh& zTHGl-&OdeGIo{rLIJ)q9&19%#e2}OsP^|1G>i&IHGJqxMu{K%6J{Ls<vDL)LNltUu zOt-E{7|qObO|)vn?&PG*QYZ1Nt|>&s)JtN!5#>~t+9Vzim<7(Ta&Sg9nZ_;`jY(YF zK)_cdYbKu3PrNE+hXb+-+7YA1MGR<9+`*^kKT{`k>X_IouKfAV(l=Dm;dy-BRY=uM za0ZibuIR&C$;Ppr8Zo1k*iuPyIU<Ui6ETQr>2p+cYBZ5EH`hUPzoe!ck<yJxuE*s& z2agw?U1vvfqtrxa<tqLC!`-)Pu9;=DlvA2JHCKVXVLR7$7?P`{t2jtiuaOc==K2ZB z!cq<^6RRYNRBEQ1(4?O@oV9fmi-Ub9qzpe-5~E$=-Z?n*C&F@$a4+U^f?P_6lUU?Z z)kHUON{iKO$dh6uv~)I_O=Fr^H75y6<gulkUUh7pbxfY6rp{5NlM>T`2TRB~$Q(}p zEa3P*_OXtEmo04SbpGza&Pq^&T*9HPx>NEs#NLwJ*`fptdWv33b($NfpR<KbZiuZi zj_t-yDb<`@#r{a1!<rJ6RQl*e2)QJG{qDj2W;C>Mm{vV?ZTI$-U5xzq?MJN`n$T2J ztc{x+sF`27iB#1Tr%H1uy%=ttB#4}Z=cpOPNRc6mTBD^}rD9ko3LuAmpa=W(630xd zTCdX__cmv0UPn~R`(XFC6Hjhzq09x@*0uv~1J&%;2AsTfks2Jk81|sDxiNF$+CfV_ z1w>0vk+#%y_EgDTg~l3ShLNp~SalcQQEK!V&=%#4CY=N&DBQ$NNTM=s;<HXE#~OO9 zDL5bU00s(bJ6oz4DImzG6SmoHFXCa2bL=UV2h&0MgNy5(@$e`Acy|zuL6Vdv`s#;I zDh+3g40BrQRkVS1`8OXW(w39~5R89{kx~A5z>yjv7fz0oiz%8$Q>S#8%B?#%JUABC zQOHM?t3MX6+-gRC&T!v*QrUfrLen)<$O+&@6SARZKPE~ia}%9$OrHg+;ki;>GxaLa zpHmZLGv!=U2t$BwSKL1X5N(<h4&6!gjr+SUy!xbaeLFPwp<cv<Y8zmrS^_17X1f_F zBhWx+s41kH2-+wrr<q8?+?U-dsnQ4CR2IQu$`nQisF*%V9ORl!3a8X-pOx~j5C>!N zPoGrY1G4~PfSyGjK$p7FFpQ#GnFbtcwz{P_2EqWi@@wz@2s)ty?;*;^BYnQ=KntKP zn?10g9}E@u+V#C3U48D!A<NPD_1%@*SX(E#;q4B#-Y)~!RBI&ZgCzzS88T>9joNbL znBH&SMST<tLKPrp7C|Ynu~j+=NUca5NnCVRPG}3r{qa5vheLp!1x}R`=a>Ml=%#xx zxe7)Qv;zRDQB(+IsFE_RfehfOjw+#ABB*9|KIJ79Q3~V+A&Q}XSSzuAhX|m`4-ui8 zTcB%@QLYbZ=%kR2y;k9};l?O%0*MC#f$zPHmpTiq;~0G)BTEq)=rBNYAhWcpg!`BG zud^ii&hE<b(y{4<#WsKb$>FIt;Ex{PI7o$q8$bW#E=W!}q@NS!<2zk9N%1ZL`Y_EV zZblVI&9@J6W$FLr?d!G?y>|B{nm}$80*NDqi-OW50Ix`0`_7ZfF&jBd(;RS@yTjSc zZ%sbAb`$0xN=J8Y>>h)TeW;($YU19BLS#?saHWKbJcq&;#HJBk3Dt3`u4ur`ps=O` zHv@HWv}^x<o9ke;R70$nY&kJmIu7jmEXey?pOY8ei7<D!^Pe5uh3euRQHUv5xxRap z7Syx~s<`NX2qjKS7qlgi4r=S@6y!sU5+Dr#gkp*mw&KG+WY~%Nv3L{JcjH%oc5o-) zFfS_2s?UDqH#ZK413Fv3aeXGE`o>;WI5$!8fyw}O4g}mOjpr`R4g8>{nNdVdA6ni= zmg>g+NjpeF=nt-Bq6O&o0g)7Tf<7kFt8ZLyRN;E*uo{$u5=Dt$fBOsHr=X-`S}+B4 zT0}2^7+2}wJ8*0RWLzgEqq|?rGLjguQ2Dj@??Og&@Dll(M<3m2?78f7kZSy|vIK$d zRD;<tH`YOw{?Bh;zo;DgEKNTAv%?fsKl;}jyOs|9GEf5xI>U1?zk;AeRP02qNh2pm zTcI;hV@L%-nStKYukMEqrxc)hXt@a-HZM+UH(u>tx!TE}xqIU!fY}uzsEQmzz*z!G zaFQq{+|e7qzwD!QDNM(~a|6#U7Hc*L1(mUC&pdgmk)WKmUMHfELU5y>zWs#{-+bn~ z*AJIIm(Jh1`Vu5CA(%&SeZX)4l9&!l%aGBY>mDRWzj^9cJiMw8B~SqX_?YPw4ulXO zjWI|;O#uvm2ud7j01DZA&Z}=-eel1P|NP`|lxM}fd*x;h1<XG_2FAWYfo%ztaH>x3 zz!vx_lnOSZ!!kn_lMRHqgRB4$2oEeO!<rftE2B9mUisx?Gu*%dA#7}bL9g)hqd&TN zhvFX5w?+dYJ9_)6cfGd3_-%69-8N^#m}Q1lW8KZOb(fPFtlQsd7|(95y4B7f4sSIq zOr89S#IVzeuyv2V{*~)kc=X+;AHh;n{91wL$)#&$Y2~GWiYkulW3W-#{h$Zrt?<J} z$88<GfArzM-NI~8N}I~@v9HL9$H|i~Ke`Wf+TYJG?CAAtUps7L*snC&dm5Ir2`)@i zT$t7lEdH7IpT2Gh+{xpouf5H5GGA$y`19JIq(^rT4>9Pb*O+S;_w#-+tWsRlo7>Ts z-uuS#;1C&2D!+Pg{pKwUt1q0~|LPBJy3gLe6HoqRIbBZiL(wx=Zy){rty|u5H6Ja$ z`p>s^;r_=U$!e*(XtrHmpU$=9VP5TCtjbk%dd{!M+9Xoe$-^_VI$h6OPIktf7Ha5v pEK|EEMy_!oZ5R6F%v=bPp3X<JfM=7nW;pz1ctPuHP5<m-@PEr)QON)R literal 0 HcmV?d00001 diff --git a/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.1.json.sn b/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.1.json.sn new file mode 100644 index 0000000000000000000000000000000000000000..e73ca3570772fdc38cd2b3d37a671bb005539714 GIT binary patch literal 13823 zcmch8%Z}?<mR%*<2At(~wQU%NYgTmuw+%#f21$vEP{TD4#fM16ISd|>haz1zz=uM7 zN(@OMQY;r>_y;`k#GZTLfj_|?;F-Unf50{95AdRH=FQAooq2CRU|a+gS3!~I?8n+` z@3Zv3>)$Vb`>xq$MeJSM?YlpJ7q@L)r17~p?C&r8?5k{h`RY{cPUpjM`&II`&+6@0 z7vZbPU3og}-~H;{WqT}MiZ8`~L*g@jjq7)R{@Zuy_IUpC=Retg@h5-jetC>L<$B(p zaC2gs#!Ou<m1eeEbf&V{nZA3h-j>pKy%PKT;gGo7`7YTR!+rB+cFSF8t`Ctpl!CVC zNJj^a6NMNH(btwaA*odF(+RtzjsF%ITq11Rtrnrm9Y@uAKEy#bWXAj&xni5_S)Ddw z9T|0=8Gg6QQ%cIPC#pG+a&aZ4CI#Pr5AWl>a<L$K)oES0`g$$Ph4W!u>cUA?XSdKE z*X#4jdb6K9&3JPkkH@`Sr=>N2DD=JC?Jl<dxahL$M!egYha@7cTi^Gtcez_kpM?8s zc`&T94-%y!lZVspVJO6BS6|ypB^0{dF0jaAI_^7DJ59Oo9N5EL?bL;*>?mKkzNV^+ zwX)kCm7S(5Skq!yyuAM#(F}DF>=f@sxZ6MT!eRBLIY@SO;PI33l;_p+pv1RpnWijW z9yMQ8?7Zp^*@|`PaJW24S(_QT7#4DwNWvwQ`u0hpr`Uabi=<kJSG;(dWbnheNsRd< zz0Rv>qnwgSeU1WIc-q(JL{qL&5DiaKUWWl2o+j*?1@e)om9Z>N#=>!w-NR$Ia5QZ# z<wCYn2D^B?z<<Xmn7)Mp72;t2mR{w3ykUndu*6j2*HD_jWd?uCmbbNat1euxyHHUU zhI$zmh%tv{()=1r5!REv!2d&P=t3y%I=cJl+RiypBg8$U+V-rP432CE;gov2K5W9l z4p>2ltR{xmQ<d~lqfNrn7sFu@D!apIdu<Wg_hg~LkgfJz8xY0+XrI2O+-x{66Qk-@ zAFV~%IZoQ}2DUszfnMQNNca3L9u^NDJ<C*<0{Pq6Y_s^j9hMHWkwE{S{J;CiE(dMB z>;9&G$Ud^old^auYVb|`iic!Eg(z^k^+)!;C9=|fde7cs`q49}vOJKr${NzpCi1Fl z<PpL4>c&?6DG98G40RQj=_K-$oOsSaf+(kwDbj#+=0~<ycaEznKaeZR$GP&TExXPF zPF-oK4McThvJAg(PqxsEMHj>?CZsDed%DiQ&L8s)xpThdvG)h@%ap}|ELC5o!$JV= z_C)VKy057rEy$1F*S*E|<*E6`{_{yPv5ryGU$P0g0JA&bg`5Pp{Kad(_<@-J*yi5f z%I41Z<6U1ko~r2Iu1%a*@rHKchLtg}@FRb7MO%>P=39*VfPoFYBwz3OJx1zfX4IEt zPcCEbrq@S-sEZ!<M0_kJ)Sc(R*gDxL0{+$$<zwH4+H%nuDeyHh>XImjk9?47>Y`hx zE9(-i`1@y-O7hWqLq!vaJF)82m=C~CtfhVd7QRIr>m?4H8TG0OVrN1;Z3g>y$PwWE z{JQ1yHc}5);+YchP=D2G20WMIjde*-OOw4b`~I_+O7S7DR>9UWwZ)D-Dyzd{yHKPB z73Rb^8hx)_!@k<Cta?wob>Y^t>7=}#@2A^!dRSc6+nK#sER;jgo=>%jUmbO!r5$t3 zeDOqu^XlHv?H5ba#dMm6M$iiP^CCaY&FTHAD=)L%B}<sEEs6y(RJCB<zC3qRFW9Tm z-BN#jX@h1W!)6|@&*iHdtp;;n-s<OTUX}JU_`{;3hlQ%ti*iBZlXBdgVmZ!*w~4mi z2Xk}SS<7~s5>e=$Dy?MZwB-H}$#=GwWMX-|w^#M-uCn_2&0G>k&0e$2I7njC?gV!} z43DuEO5vS0Y~pEnm-@6ymd3(=dz@Z}rQ8b(@9-$-wqv?3@o9eg^}FBw>fO0M?6m#) zSp1BBnf=?}zQcdbeYWjEttL}R7DPdwOYeU5_y5IDvY-8jFaJTcy?*iK@9lRTHRti! zLE*dO!HYe6V%Obj>i93={bMj?dRKUIAqQ-y2)D;n#XjTP;|UyaWOL(x@r3Mj+TIA? zKl=GUdnbG)d?kJ%2*v)q-5o*wGOuWU_Vm+Qe3(lQkF)t?hHHy`TEC6Ac|IPR;`C4d z`A`2fxa5ADjhncBG5N=*bKJB)d-v1d|9dT5n$GgFUa50-|M=F+eOAf?VLX05wVrep z$|8EqWyyM`{<81u-53ew;@+!NM$P)cUpq54zw65W%x2x(p~s7H*-i9z`Y2}k^2`7E z^Z)$!4llp_S6{8Zc$x?kQJ7680+x6@9L}rwlz;JG|Ids5oQZca-#eU4B(5ZG8&9q; z7i(54TS*w|a2uM#8i4p~eDHkSZrx(|<v;u<>?hM-{<lBQag%@fzy9)n{L#C3`dmNU zZ;Gk<WX={RxP<5#o70Y$)1y5t?)D-gPc+qCH{NJwZdk0IOV?ZS_OOz~>0Ma)kFrOX zOmZ%H6?6e5nP2X2muL42@yO?M-VeRL-aI@;{6eznseY8I^`#6;aa~8XT=e?wa{kcz ztA@SEqVZNc+h@goId30_@OU!bo;Nw&zwH@WE|SN?<G7fb%vAp_w`VWP+tlNe+9#3l z<gU(Q_-Zqycwp+o>5VRH0TBr!tWLr_9=3S+^IS<avV82)C)FVjL)vB1PFv0EL0P%2 zZjP5njc3>s9=@!sY%thx<fj+sr0ia_%OD@jPOVj~Uiypmc{*PHaL4u3VQC%jBzqNB z$0(AM*<-OPH|~A%yuT~X_d6Eb)0cF-{E^Ks`I~i!my_A*HQhW&@-|#Ai;#tL9<$9P zJUq>+*)lpD#>>;!Cc3(LC%^SZ`skcq=Iu;SL8aMa(oUOdlc#6<shQso)p$9X`GUC= z)Ad{8X0<hU*vmta3Ea1zhu62c{kB*fPlvnN;qG?#!%M-IhsVBn)vc4<u*-RW|FSu~ zURTMn08-x{ayE71G%@_~?&b5#-O;)9i)E<w$M`_W+jCKF0(&jaHqX~;6+Q3j`$y9p zobhshxQ3Z}I^92Ama|Ddvz>M7*)+@@=lLZL!_GA%cd&Ga-s~<K&z|7~>N$4@|MaXs zobNYI)0&dIny#Z}vWxjKC#RVw-L@Kr%i~O3JjO$O1Y@=zm*>0oSUfzvs(t0xe)V#C zcqv{(?RZ<esK)AgI4&tw^2W_V`J$J@9c>p)--Iurwo{hb^V5+$i?_t-SNH1jw0&9~ z=iBum^d4VNHjCB|1J5)g3|4RaeBwuSE_dOt-~BuIDc`15djQgmV6@#|w)NrGsvfu1 z;rMNHica+xfA$MeoKFN<n9K9IIGf2cS(yE1k5s>9{8akQo_w3;|LOn!k?@=SZ}xxj zAOFiQZa4c==}-Q6yv={_|MD;Xv{Z~mp)Dl$&R)I7>Z95ePP|FA$JaadfKH^-{kc7R zRqq=bcB~79bVM*lHSL<Wd!F~cpvUWh1uKo(v%7F+z1;V5yt})|jgz#|?$^Km&Hml* z0PAVIKkU(T*Tt~SltXp}+HS8_o*H$45{))?{$`ekf-I-qQ>hhCOY0a1ay7met>_TB z=E+1MjRI2G`%^KN=qTtfslm-~Ll0)zKA4SuipDkwtyAKfGh7ebgMt?0XQMn>@p`;p zIH1)jQ;80BLnT&+k~XC(S|N|tVdLX(J2CR=JS_8^g5uJEqcP&3zGT2ka@!zRqUx2c z=0#ZYB9zQ2xmt6q8FZ>XC6ZpbhCakgb2jcVF~+tLEu<k7c7eSbpt4>#?okSZUc}yJ zW=SjR<#}XiL)3&~=vDHK>IT{n296Y#peGV2s!Gs~DZoJBg$FuggcE|l*eYI)a7sL1 zz&+&1Af4^`#f#e&;|L*XbRpY-9$n2@Fgbniz0oF~&m%{u88c2(H?VjlDrLfJBVIm= z+v+@x%IHcBsyn_ob@rCM4ca|ca(x=Cf$?0*R+$xRI(3b%<Gj^lL7zI4D0b_`kqGlO z^FmysT$3t_QX@j|rH6sv>Vd)3ROO*rbVb(iYaB#vv@u6J5r0qwPba-2Vm(^x$G)Zc zb+qQ3@}O!N(IW2Gq|3D4BP*uHgTD4fUXt3<y^68KTU-N#I1wp#D=Q{OA8?PZ(}r<x zQ^$P6ERW}VJojt|H7B(%2Bo!5YI8^?Q4#3TIs!FL>Wn8^UyzNmjso$LVLhy*eqhii zZpa<0qaz56&jYsfcoeW&yT+RAb3v5DDiz~O=-twaNT~;r9(ArBy}FgmV?4(fxDnLW z_`^B3jL45-S|cZnbDeO@^u+uWaz11GEJ{4?g_YjLjpegF-NX_bLO*kHzcAA3oC)$X z*Rw8YxK4cT1uMl4gR?<7LrzCDG1w*W7NUndNmS<JdDzdXPq`kZnw-ZXmw9P*POWp| zXE~9W5vlsg_31HNMR;C^cx{(V*RjXegZ(5folmsUU!96GUPZ5d6&VRW&pig8I34!a z5$^O(NnIW|J>Rs4Z9vad)eEe*_oenoB%KF)&hb2qxK1(?<~sAw6{$^;3rtVzOixx( zl#KfkB0CkjA=X*o>h;y<%=LUxwc*Ibt4&`LJF16Qx1u>wm6=ykJE^8ax>6{wEZ=X= zg0E|xTepZ+_7;@LRgrP#DQ-4VZvKl!%Z#*$#a=bQDS^{CU82=7(XAz~IO2nyf(r4% zBg3z_>mUZ*MvjTls))CMiTa5(t;_sS@_80J;-RBF^H?R@e$6Y!5eJ6M65TpGQmdqm zb#m$~hyCE+JkAVj=GJJV%X$-VK6Q1rij8?rd{goR(fIa$t>MGJi9gqQ9pQPcgLn|A zVP1W#FKO6o6bLe@ndUa<l3`z=&vaMJHprDc@oU-h^Ejx)w|JHLOlzOR!7{iuH+iGe zWRp$s`~!!7vPm1edixsIxtr<r5qL;RH5ENE6GEQp?!JxUN*-XhE<o%%l~^R3T^9sr zpciKtM+IcFkhsRyiDVgxp7Ug{P|wrXl($AW=q1*qhn`X~120uR#*>TvmcS4AW6TfO zd(y5GL0n;f44-#G&JLt*b*!gEe2kw9f;#NzDpM0b3XwlS+;E@odBmIC3roulbovVa z&_q9oTx1pv*v7}_66CSJX@iv4ukI$Rh_M?uA`rZyPc+J6Au@3t@>740Cw}Aw)%ba2 z!lvFNmG~3{tsRT)Db@1AT`8}AsYIEW`G}8-sQm3m?pF~%lDKiMkiS7Z!TmP5gLvyw zk%g&l+1#r`tk?C#Xceh!!v{T%f~@oPuy^;04|^Ozt9w_s!dRQT%+mxe2|WY;Br84O zT7>J*z#9FGJCw1|z9Ak1#2&X-k<WeDx8`f7p*sA(K|IaI{iMKwRL^a$S+*bafveG@ zw^vGHu;u6zM{H-|z$)Oe%g6f(;*oCJh*yFb2IATsYucDUK@Uu)6`p?t9=k+0F)lsh z^H`4#|AN%;M(9U>A>TK^t5xV(HudMe<DY?_d=u%J0sH%rMq}iMjQFS+uoHOXZ@t<E zs0A+W?`<-lZ>WrTU&bQU-7@k?b3$ztU00ijZled=+U&U)RUYDr)%i2<{x#w($ao#Z zIxT<)M_wwItzIJ^vzj+&`4&&2*#kFX<_kUcXKj7ajP#oE=7;vD^{l2o@B{ds%2<!# zmNX(0MFP%Gw!Af+=IoLtn>tdD*pWBjMKrF=EBw74>u!cNC8^}Yh@XUKukM~wCPWr* z#1l&X)xXsj4Djo0js5vv)7ISRuUzU6jzq5RrY#T`Ip4QD6WNMxXvKsPKjb6u;1n5p z9ZxdYE1M#JP_H83f$T**bCJ&>>XpkRX8N9Wg56N_;4i;@^Y&Peh<dPx?>E42)r5`A zK2iCGYi*8rlu@tGTTQML&F?vQQ&7q1qvioxKY@#Bk9a@Y9C#4)ƒvMxqs+q@i zuIGmkd<6U6@QD(jzMkNJ<m(UnA0In1?^fT!pvvKY#MvZQ6Ql1u4*UDUh!10Yxv1~L zSl_`RD#WKc;wkK824%#sKOMBlh`}e^eO7>1SR9-b@p;DRe8kIRD4_ltx`z16xgPb% z7b}z43He*Qn*KE6od^4~8ud}PP#;Wr%MtHmuAtWQRV904y{zWI7g@rN1;+WA_;SHL zj{NOijH`lF2Zm`D@F1IJI_hd2+04^JANlQJJ(x3%_IX9zcOj2-x0(CAw%oD42YPk} z9?)1iPZ1N9cdKW(e#6LE-;sYuR^{Lk2h=ORX8eG7Wn(>2BMv;*v$s?eX%ghX!>IBl zendS$ed&*gr(g?yFb;5l2U^H;+r9AvizNXWJRz@~n)@;8DUqDPmqxx}g^4x?-w4P3 zx<0rN@quT-4}Ko&i(I0OC~p}q=dU+BFml^#&QsvSh6eeDRjm|MQROyl#82e2MZMr8 zZo#Mg3j0O8h9zB(^%J#a%va1r7rj$k0r&#r1MmYpfMY$>!{0Rv@pulX&UgU+0zCMk z{T&kxyl;$W;L~k=O$!FJxN<E&(!e^i8ui$#M?5(pe|^^j57=Zf_@f=`d<J}uz~`>O z1Nir2JQ(x2revkaBc4-(ol&R!KyPy&I7Gx-J+`^fhP;u3Sk!0S)1$<21n>_#)1xy9 zZg>E`)>ll@u3pKXvA@@_>0`oc@IT9jzeoF6?uf@9$0y?V?<77w9sPlhc*HQS8vP$t zVW5wA@csDIe<VIfys7yZpDWL^tZhx-fagVE?5?QCIt?`B8}PDWF&CmW>Ny8q_(c1j z|5eoUZ}YFtSS&_MtY?`apMl3fkb}fk>j-;~$9x6TtOL5MI0YXg{=7>3)(8LTVs2hD z73<XwLMU+Hxphtr;Q1cw2?X$McJ}rh{DMCP9z9Y`9Qip5jC_nQ;IY#qJ}LOYc)!D~ zKI|#{m#dzijrGrjy%_N82k{qt7I-2rfoD}*QzO_bV?3dE%sSx9+4gfrntld8GS+ww zK4Nd1-2i-B1K(gDt3nU;dgJSLc8v8bAN!AykH!J&bpyVI2oI$*_%HBy%qK4&^VeX& zPq&IF@W}T(rAq~R1`m5i|8W@YPwMK5dJ}_h&Va|k$QM&X1Rn#JX-*;!^cmV+msNZQ z`{PxHy;uPMwI2GFOuX^C8rZWz{auFKT7mD6__juV_nfmZ6wW?)`_*gu8~=10;K2s{ zUm$c_!&<rMFF5$?5ADxGzrZCHk3A<5$Nc~gf;<dxnC_1JqCvmb<f<>EvQxL5Rg^~P z7r4=)u|U0A)h>~Cf?ww+)U#D)^d8$NLx}wmkz5m<6%0r0h}RB$Q?*rP>_1=8%NwL7 zSB`oD{<IwNH><<JL61-N`5ON>fS;p(TrNxp@PoBqqxaKLe>^scG!DIj*#)%{#}oLr zX$|o3m4|pB0h@R}nbJVt#F(z};M934wb5_A1s?igu{{PszlI$-+bd{_b?{s6qSl;z zOn1gU*aJ?wVt7S&yTM#V^BdVn0%}AnMz?kJ6rrYq1D?04lB2ix(GMOd)<-u0%?(4n zv#PD3ud<cy6CZm(=#Bjp-2h*?rG^zZ(0_8^cZvLzxscmqtfxkvGtKM~Pl0DxCm{dW z&%XK6+0F#)uaYU(%z<l}z|haSRco_}vf*{!g}&@%I{Gl7J$gLrwtm;4@q+#9EBcF$ z8kWh&`GKmV_GIo>Z_!VHA9%S8e$XS{W=_ozzYEC|vN67W<ku6?fmcGNW7O?sMvGYj zM|_=c^^TYNGt*5G`Ai9zwo_RU0PF<JRbn6I9scKdUce3Uu&ouAOxV}+4IX%+r8*Tu z-Dx@I7nqr7;8&=N@8=(wZ_xd-CloJSoWD4~#`y(ChUcQ$-i{V<iUD=XdpV05pJPlA z$AQ~;l%ahXB>whY8(-@~JRWjjB=wT5>h3mn`pyYo98AwR5fhTD+$PsKwy5J2`E~+! z$=;9T1}GBHQc?lqwgmTqVnMu()FLT=A;okYPrZ?Pe!}QZ-0F`5#!{ndr5MLMP$lf6 z&F!-f-x<we^yN%3Zma2_SF{PIprX00W_c8Z0%^=4LUTw0XG#VrSR`KJ<XNN=t}Ojg zy)xKdRML`{Xtu9W>0>O&N;2$<myK}x+%=|q<@}>-Z1gfx(*dJGv^3$wR--Z;e9;cH z4z#DFL)hB^EokXaF`C0Eb$X?eGr{N%qr8PkJx4;=Rm2OSw$LQ1BFQD^ueC<_<BC(} z*^jQXjB?U|t4Y2L#l{qCzLF-q!VdyY=y5Wy$+TfP)(bVzq9=hVgh7Ons5T876YGMV zC0sKQ*qt}Fx5H^J`OJ0B^90R4UCLDynB#b}f;y>AC(dnTn@TxG8oqQJ^NuI(7<t-; z4}}SXPtzePwTX>!CT+})sJm-eW*EUPB<g{BtEoT?b4mwuLe-s245$teFNl%RmL>ZB ziS1hR7q`b4SjIa1afG;!^~Wp5$sM^)uNx?Ol0BbpB+wh!<7sbo7!#jNlo>SML-u}L z`(Zqb&o_}l3tL^tkc!B7mK@=C_!V&ob%Uzcw6sdtt;2Y<B&8`tWeTN?UO+`t+6LB# zeIP~w`G8OZMPo<>b1%-HR;L0Z+4K7x%nqBud?Vgscq*+IoPxh)oBRd2RngLc^5=8> zJ}P=>Lx<Z5w-}8v3B?3~k|%2L4<JUz*pLWDxoH<R7(uHQVuDuTfT0@R!#<I}I~jR} z*cpF5H;3kV+6bTJcdBYv+fy2-(gs?Ro_f3>hE+$UBZOWmBG0=qM#s@M@Svd{fuclj zFme|VI|JGkyoWpsXhSe|hkg3!^ZkBJywCAFM(rS{$PJA9F-8v}sH9My21lS6PuVIk z$S^T}haJ5!MljScIIznUvF?tP6r=%h?WM45*4R+MprB7b+wU9e{P}(d)*;t{DI)BM zd@_5C*5PNUyO4`^U|vBtDy$Xxt&cc`BLd*9x<ed5r2&OTI79`-h-=Ix5@-TcU!R@t zP#a3L_;kMGSu$y`4#U5WIUIR7GATLm2x_x(2lb6J(v%#>HOO)J*zhHyW>|NPBaskm z^M|km?i!jz$_R0ey(~XF->=Cgls=vBz`sG0LJxSMM%9?Z7;Xt;Od@Zgkb}y}FehZF za9p$lraJI9{H_A?JnM#`RE0laU(}}}Y6z~?#J`T;fn=YT@6D)M%4#a1cato%P*7Zz zS|1rvftV|y(gJn>pGGCt;E2E~><2^Qvc^~wnC~%QI}l^=aeQ>ESt2PvneS&P6rX+Z z1J2Ll&Em @tJL_^%=GF<#3G&m_QrN(5%2Q3-{w;}7Z_9I=hK5THH*CuoTpWf0U; zf@42Dr)36QC9?fVzl(V&%st@z*ZJPIIJf)}oW~KNinxLvCMr2<bSi?LBQ|&NACaMG zE2+AW5PKa7Xbwdi{2NX%f`t+eJOG$tdq7g`?Y2M3!u8sx^F7c@PoytUh_>U=S1ReJ zCya2T>yNG#oC42Mm4Zs+Yt>QD2c?aWJ3H`<YKS~w0csTu9K<N<m715xbL2njx(aeW zq~KFJ_!V*xwF-VHJ{6;xRD;Js^(wySbjgOcKh8sR#2JP*>JBzB@q>DZTn|x$Fe)DH zIUci4Z5(-0FGa24u&asqMLv}~)JmvQq0A!<j#q~YJPR=k4*CgBHyMwNq52v&1AJ7- zpNvlmEDv#hgkxYR84+>FtH`kMJ8&mJPD@q+&Ndzu*Q8`Q>@{K*qSOJV!|vY5<;FUY zO5h}THAV8NdNj#ew6;(O(hoKM)Ame7T8_1+LY$4f&O!ZV@SP7VUEo+Q><D5K$Jlw~ z(L8Jr$Ec6sEvQpawHd&PA;r-#eg*|KA2IHIGT*<o=LgR8sa!%m9C;G(CsK3NM#Pu8 z5YhUK$LR$|fT&d*F;Ic$g?OLgeP6^iP#=SD2L2SEN3BO(1W<ZnoB{5Ve|~&^;3DT# zs(uukz$0*k0qia{^c=uOFBB<SHmIRd!$(fPKz?x4A`^K8{^^BX2u=WuD4>A_CqaH8 z7r|$8d<L8pT=7%b^j+V7UFWw=-Ti1THQ*D?SR2Pz3VhOlhv5WjZKxR+6Vz;QOb2`! zvW3P0N3vjF<Q(cJ_$g!!*hd5Y*PuR(dw`@B{1>?-W}{>Q<7M!4if;^Xf4fm`{oHpH zuS2q;`4c{$6?0rWM}Gx=jWs<@!hT#g)RE&i4oWCV@!3&^VTo~oF(dj1<Res;P=^*b zDMXP4^(oSsoEkTN{v9Rq2NmhJYWt>!D*yexZvE<pT)tJgr{DCX^%jRW>B#wY1w0g- z8tX{+?`0z2x&D0*f*2e7kH1oZJL2yCy9&~3>><9h#%~n9AAdt&{G0;&?Qp&M?fO6U zZGl^?bOmfAKmvnax_812<RSrvW{||Zx8*m_yxFftop{Ui`G>yy+x>k{^*_e_70Ubz zq#pv*$nr+2Z&I4?s;F<i`@Rwu_Qf|kYA9mS3z_HxzEMWsu7$ccL7m!2SVL1^6G^^( z^I^2-2X%0eU+`^}I{JEVu=9{e{n(R_-xR2Ud-$P{c)&mP8FK(gK1TV=sGeS)KW%IO zuU3u!LH>;Q)1XuiMBI1W%il=3e#?=Yga&&@Jfl~xPosnebA|V@Z1C%QKfSl_ck<>B z73hCgO7ZtnqcP6GjqxoH*bnPLZyVuTEBfslByND7`?F$qo`)0G!k75AiY1OJZq#P< zU@5-+0d6t)7v%Lg2kNpFx!h{oMw~;R_w_*O2fkBP?Emb1I3K?G3e_lyjn8k#@hegg z_2R!6MBzLKax%4gG!uXKoHn7J-^;_gf0-SpDdgw9a-`RCH@Qgn(n;G~&K(4?WK|0| p{jC=L@)B9kcN5#vX0vm9-0NZc;yj9*(9$;9vXwWOll|^@{}0g6C9?nk literal 0 HcmV?d00001 diff --git a/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.attestations.json b/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.attestations.json new file mode 100644 index 0000000000..ea7298b671 --- /dev/null +++ b/internal/core/tools/testdata/attest/gh-v2.103.2-macOS-arm64.attestations.json @@ -0,0 +1,16 @@ +{ + "attestations": [ + { + "repository_id": 212613049, + "bundle_url": "BUNDLE_BASE/gh-v2.103.2-macOS-arm64.0.json.sn", + "initiator": "github", + "bundle": null + }, + { + "repository_id": 212613049, + "bundle_url": "BUNDLE_BASE/gh-v2.103.2-macOS-arm64.1.json.sn", + "initiator": "user", + "bundle": null + } + ] +} \ No newline at end of file diff --git a/internal/core/tools/testdata/attest/trusted_root.json b/internal/core/tools/testdata/attest/trusted_root.json new file mode 100644 index 0000000000..afbe26e889 --- /dev/null +++ b/internal/core/tools/testdata/attest/trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/internal/core/tools/tools_test.go b/internal/core/tools/tools_test.go index 2852417627..0813b911ba 100644 --- a/internal/core/tools/tools_test.go +++ b/internal/core/tools/tools_test.go @@ -1,397 +1,773 @@ package tools import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" + "net/http" + "net/http/httptest" + "net/url" "os" "path/filepath" "strings" "testing" "time" + + "github.com/sigstore/sigstore-go/pkg/root" ) -// TestExplainNamesEveryPartTheCriterionAsksFor is itd-63 criterion 1: a named -// gap states the tool's name, whether it is optional or required for THIS -// capability, what works without it, what the tool would do, and the exact -// install step, all from the registry entry. -func TestExplainNamesEveryPartTheCriterionAsksFor(t *testing.T) { - e := explainFor("gitleaks", TranscriptScan, "darwin") - if !e.Known { - t.Fatal("gitleaks is not in the registry") +// --- the release table ------------------------------------------------------- + +// TestEveryPinnedReleaseIsComplete keeps the table honest: every registered +// tool has a pinned version, a download for each platform the intent scopes +// (macOS and Linux on arm64 and amd64), each one a full GitHub release +// download URL of the tool's own repository at the pinned tag, with a sha256 +// and an archive member; and the pinned version lies inside the tool's +// compatible range, so abcd's own copy is always one it works with. +func TestEveryPinnedReleaseIsComplete(t *testing.T) { + for _, name := range Names() { + rel := registry[name].Release + if rel.Repo == "" || rel.Version == "" { + t.Fatalf("%s: no pinned release: %+v", name, rel) + } + if !rel.Compatible.Contains(rel.Version) { + t.Errorf("%s: pinned %s lies outside its own compatible range %s", name, rel.Version, rel.Compatible) + } + for _, p := range []string{"darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64"} { + a, ok := rel.Assets[p] + if !ok { + t.Errorf("%s: no download for %s", name, p) + continue + } + prefix := "https://github.com/" + rel.Repo + "/releases/download/v" + rel.Version + "/" + if !strings.HasPrefix(a.URL, prefix) || strings.Contains(a.URL, "latest") { + t.Errorf("%s/%s: %q is not a pinned release download under %s", name, p, a.URL, prefix) + } + if !hexDigest(a.SHA256) { + t.Errorf("%s/%s: sha256 %q is not a lowercase hex digest", name, p, a.SHA256) + } + if a.Member == "" || (a.Archive != TarGz && a.Archive != Zip) { + t.Errorf("%s/%s: incomplete asset %+v", name, p, a) + } + if !strings.HasSuffix(a.URL, "."+string(a.Archive)) { + t.Errorf("%s/%s: URL %q does not end in its archive kind %s", name, p, a.URL, a.Archive) + } + } + } +} + +// TestHowEachToolIsCheckedIsSaid is the second criterion's second half: a +// tool that publishes no signed build record (gitleaks) is said to be checked +// by checksum only; gh is checked against its pinned signer too. +func TestHowEachToolIsCheckedIsSaid(t *testing.T) { + gl := registry["gitleaks"].Release + if gl.Signer != nil || !strings.Contains(gl.Checks(), "checksum only") { + t.Fatalf("gitleaks: %q", gl.Checks()) + } + gh := registry["gh"].Release + if gh.Signer == nil || gh.Signer.SAN() != "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" { + t.Fatalf("gh signer = %+v", gh.Signer) } - if e.Requirement != Optional { - t.Errorf("requirement = %q, want optional for the plain transcript scan", e.Requirement) + if strings.Contains(gh.Checks(), "checksum only") || !strings.Contains(gh.Checks(), "signed build record") { + t.Fatalf("gh: %q", gh.Checks()) + } +} + +func TestVersionRange(t *testing.T) { + r := VersionRange{Min: "8.19.0", Below: "9.0.0"} + for v, want := range map[string]bool{ + "8.19.0": true, "8.30.1": true, "v8.30.1": true, "gitleaks version 8.24.3": true, + "8.18.9": false, "9.0.0": false, "10.1.0": false, "dev": false, "": false, + } { + if got := r.Contains(v); got != want { + t.Errorf("%q in %s = %v, want %v", v, r, got, want) + } } - if got := strings.Join(e.Step, " "); got != "brew install gitleaks" { - t.Errorf("step = %q, want the exact argv brew install gitleaks", got) + if versionIn("gh version 2.103.2 (2026-10-23)") != "2.103.2" { + t.Error("gh's version line does not parse") + } +} + +// --- explanations ------------------------------------------------------------ + +// TestExplainNamesTheCheckedDownload is the first criterion as the person +// reads it: the version abcd's release fixes, the exact URL, the person's own +// bin folder, how it is checked, and never a step that needs admin rights or a +// package manager. +func TestExplainNamesTheCheckedDownload(t *testing.T) { + e := explainFor("gitleaks", TranscriptScan, "darwin", "arm64", "/home/p/.local/bin", "/home/p") + if !e.Known || e.Requirement != Optional || e.Download == nil { + t.Fatalf("explanation = %+v", e) } text := strings.Join(e.Lines(), "\n") for _, want := range []string{ - "gitleaks", - "optional", - "without it:", - "native secret scanner", - "what abcd uses it for:", - "install step (Homebrew): brew install gitleaks", - "what the install does:", - "https://github.com/gitleaks/gitleaks", + "gitleaks", "optional", "without it:", "native secret scanner", "what abcd uses it for:", + "install: download gitleaks 8.30.1 into ~/.local/bin/gitleaks, checked by checksum only", + "from: https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_darwin_arm64.tar.gz (no admin rights", + "nothing is written until the download passes its check", "what the install does:", } { if !strings.Contains(text, want) { t.Errorf("explanation lacks %q:\n%s", want, text) } } + if !strings.Contains(e.ManualText(), "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5") { + t.Errorf("the by-hand text does not carry the sha256 to check: %q", e.ManualText()) + } + noAdmin(t, text+"\n"+e.StepText()+"\n"+e.ManualText()) } -// TestExplainIsPerCapability: the same tool is optional for one capability and -// required for another, and the explanation says which, with what a no means. -func TestExplainIsPerCapability(t *testing.T) { - e := explainFor("gitleaks", TranscriptScanArmed, "linux") - if e.Requirement != Required { - t.Fatalf("requirement = %q, want required where the repository armed gitleaks", e.Requirement) +// noAdmin fails on any text that shows a step needing admin rights or a +// package manager. +func noAdmin(t *testing.T, text string) { + t.Helper() + for _, bad := range []string{"sudo", "brew", "Homebrew", "chown", "administrator", "apt-get"} { + if strings.Contains(text, bad) { + t.Errorf("text shows %q:\n%s", bad, text) + } } - if !strings.Contains(e.WithoutIt, "enabled") { - t.Errorf("an armed repository's without-it must name the opt-out that returns it to the native scanner: %q", e.WithoutIt) +} + +func TestExplainIsPerCapability(t *testing.T) { + e := explainFor("gitleaks", TranscriptScanArmed, "linux", "amd64", "/h/.local/bin", "/h") + if e.Requirement != Required || !strings.Contains(e.WithoutIt, "enabled") { + t.Fatalf("armed: %+v", e) } - gh := explainFor("gh", GitHubSettings, "darwin") - if gh.Requirement != Required || !gh.Known { - t.Fatalf("gh for the remote settings: %+v", gh) + gh := explainFor("gh", GitHubSettings, "darwin", "amd64", "/h/.local/bin", "/h") + if gh.Requirement != Required || !gh.Known || !strings.Contains(gh.Effects, "gh auth login") { + t.Fatalf("gh: %+v", gh) } - if !strings.Contains(gh.Effects, "gh auth login") { - t.Errorf("gh's install effects must say it does not sign the person in: %q", gh.Effects) + if gh.Download == nil || !strings.HasSuffix(gh.Download.URL, "gh_2.103.2_macOS_amd64.zip") || gh.Download.ChecksumOnly { + t.Fatalf("gh download = %+v", gh.Download) } } -// TestUnknownToolGetsGenericTextAndARegistryGap is criterion 3: an unknown tool -// gets the generic explanation, no install step abcd would run, and the gap is -// named as abcd's own with the capture that records it. func TestUnknownToolGetsGenericTextAndARegistryGap(t *testing.T) { - e := explainFor("frobnicate", TranscriptScan, "darwin") - if e.Known { - t.Fatal("an unregistered tool reads as known") - } - if len(e.Step) != 0 { - t.Fatalf("an unregistered tool carries a step abcd would run: %v", e.Step) + e := explainFor("frobnicate", TranscriptScan, "darwin", "arm64", "/h/.local/bin", "/h") + if e.Known || e.Download != nil { + t.Fatalf("an unregistered tool carries a download: %+v", e) } - if e.RegistryGap == "" || !strings.Contains(e.RegistryGap, "abcd capture") || !strings.Contains(e.RegistryGap, "frobnicate") { - t.Fatalf("registry gap does not carry the capture naming the tool: %q", e.RegistryGap) + if !strings.Contains(e.RegistryGap, "abcd capture") || !strings.Contains(e.RegistryGap, "frobnicate") { + t.Fatalf("registry gap: %q", e.RegistryGap) } - text := strings.Join(e.Lines(), "\n") - for _, want := range []string{"frobnicate", "no entry", "install step: none"} { - if !strings.Contains(text, want) { - t.Errorf("generic explanation lacks %q:\n%s", want, text) - } + if text := strings.Join(e.Lines(), "\n"); !strings.Contains(text, "install: none") { + t.Fatalf("generic explanation:\n%s", text) } } -// TestUnknownPlatformHasNoStep: a platform the entry names no step for shows -// none rather than borrowing another platform's. -func TestUnknownPlatformHasNoStep(t *testing.T) { - e := explainFor("gitleaks", TranscriptScan, "plan9") - if len(e.Step) != 0 { - t.Fatalf("plan9 borrowed a step: %v", e.Step) - } - if !strings.Contains(strings.Join(e.Lines(), "\n"), "install step: none") { - t.Fatalf("no-step platform does not say so:\n%s", strings.Join(e.Lines(), "\n")) +func TestUnknownPlatformHasNoDownload(t *testing.T) { + e := explainFor("gitleaks", TranscriptScan, "plan9", "amd64", "/h/.local/bin", "/h") + if e.Download != nil || !strings.Contains(strings.Join(e.Lines(), "\n"), "install: none") { + t.Fatalf("plan9 borrowed a download: %+v", e.Download) } } -// TestEveryRegistryEntryIsComplete keeps the registry honest: every entry and -// every use carries each part the explanation renders, and every step is a -// fixed argv whose program is a bare name (resolved on PATH at run time), -// never a shell. func TestEveryRegistryEntryIsComplete(t *testing.T) { - if len(Names()) == 0 { - t.Fatal("empty registry") - } for _, name := range Names() { tool := registry[name] - if tool.Name != name || tool.What == "" || tool.Homepage == "" || tool.Effects == "" || len(tool.Verify) == 0 { + if tool.Name != name || tool.What == "" || tool.Homepage == "" || tool.Effects == "" || len(tool.Verify) == 0 || tool.Verify[0] != name { t.Errorf("%s: incomplete entry %+v", name, tool) } - if tool.Verify[0] != name { - t.Errorf("%s: verify runs %q, not the tool itself", name, tool.Verify[0]) - } - if len(tool.Uses) == 0 { - t.Errorf("%s: no capability uses it", name) - } for c, u := range tool.Uses { if u.Capability == "" || u.Does == "" || u.WithoutIt == "" || u.OnDecline == "" { t.Errorf("%s/%s: incomplete use %+v", name, c, u) } - if u.Requirement != Optional && u.Requirement != Required { - t.Errorf("%s/%s: requirement %q", name, c, u.Requirement) - } - } - for goos, s := range tool.Install { - if len(s.Argv) < 2 || s.Manager == "" { - t.Errorf("%s/%s: incomplete step %+v", name, goos, s) - } - for _, a := range s.Argv { - if strings.ContainsAny(a, " ;|&$`<>\\\"'") { - t.Errorf("%s/%s: argv element %q carries shell syntax", name, goos, a) - } - } - if s.Argv[0] == "sh" || s.Argv[0] == "bash" || s.Argv[0] == "sudo" || strings.Contains(s.Argv[0], "/") { - t.Errorf("%s/%s: program %q is a shell, sudo or a path", name, goos, s.Argv[0]) - } } + noAdmin(t, tool.Effects) } } -// TestHomebrewEffectsSayWhatTheInstallSends is iss-2609261604492132: the -// Effects text is the sentence the install question is answered on, so for a -// Homebrew step it names the network fetch (Homebrew may update its package -// lists first, then downloads the package) and Homebrew's own install -// analytics, which it sends unless the person's Homebrew settings turn them -// off. It never claims the install sends nothing. -func TestHomebrewEffectsSayWhatTheInstallSends(t *testing.T) { - for _, name := range Names() { - tool := registry[name] - brew := false - for _, s := range tool.Install { - brew = brew || s.Manager == "Homebrew" - } - if !brew { - continue - } - for _, want := range []string{"network", "update its package lists", "analytics", "brew analytics off"} { - if !strings.Contains(tool.Effects, want) { - t.Errorf("%s: effects do not say %q: %q", name, want, tool.Effects) - } - } - if strings.Contains(tool.Effects, "sends nothing") { - t.Errorf("%s: effects claim the install sends nothing: %q", name, tool.Effects) +func TestBinDirFollowsUvsOrder(t *testing.T) { + env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + for _, tc := range []struct { + env map[string]string + want string + }{ + {map[string]string{"XDG_BIN_HOME": "/x/bin", "XDG_DATA_HOME": "/d/share"}, "/x/bin"}, + {map[string]string{"XDG_DATA_HOME": "/d/share"}, "/d/bin"}, + {map[string]string{"XDG_BIN_HOME": "relative"}, "/h/.local/bin"}, + {nil, "/h/.local/bin"}, + } { + if got := userBinDir(env(tc.env), "/h"); got != tc.want { + t.Errorf("userBinDir(%v) = %q, want %q", tc.env, got, tc.want) } } } -// --- Install ----------------------------------------------------------------- +// --- install, against a local release server --------------------------------- -// fakeExec records every argv it is handed and answers from a table. +// fakeExec answers each verify by the program's base name. type fakeExec struct { calls [][]string - fail map[string]error // keyed by the program's base name out map[string]string + fail map[string]error } func (f *fakeExec) run(_ context.Context, argv []string) ([]byte, error) { f.calls = append(f.calls, append([]string(nil), argv...)) - base := filepath.Base(argv[0]) - return []byte(f.out[base]), f.fail[base] + return []byte(f.out[filepath.Base(argv[0])]), f.fail[filepath.Base(argv[0])] } -// binDir plants executables named for each program outside any repository, -// so the lookup finds them and admission passes. -func binDir(t *testing.T, names ...string) string { +// rig is one test machine: a home, a repository to guard, a PATH, and a +// release server holding one archive for the tool. +type rig struct { + t *testing.T + home string + repo string + pathDir string // a directory on PATH for copies installed "some other way" + srv *httptest.Server + hits int + archive []byte + fx *fakeExec + env map[string]string + attest Attester + signer *Signer +} + +func tarGz(t *testing.T, name string, body []byte) []byte { t.Helper() - dir := t.TempDir() - for _, n := range names { - if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + for _, f := range []struct { + n string + b []byte + }{{"README.md", []byte("readme")}, {name, body}} { + if err := tw.WriteHeader(&tar.Header{Name: f.n, Mode: 0o755, Size: int64(len(f.b)), Typeflag: tar.TypeReg}); err != nil { t.Fatal(err) } + tw.Write(f.b) } - return dir + tw.Close() + zw.Close() + return buf.Bytes() } -func testInstaller(t *testing.T, fx *fakeExec, bin string, env map[string]string) *Installer { +func zipOf(t *testing.T, name string, body []byte) []byte { t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + w.Write(body) + zw.Close() + return buf.Bytes() +} + +func newRig(t *testing.T) *rig { + t.Helper() + r := &rig{t: t, home: t.TempDir(), repo: t.TempDir(), pathDir: t.TempDir()} + r.archive = tarGz(t, "gitleaks", []byte("#!/bin/sh\necho 8.30.1\n")) + r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.hits++ + w.Write(r.archive) + })) + t.Cleanup(r.srv.Close) + r.fx = &fakeExec{out: map[string]string{"gitleaks": "8.30.1\n"}, fail: map[string]error{}} + r.env = map[string]string{"PATH": r.pathDir + string(os.PathListSeparator) + filepath.Join(r.home, ".local", "bin")} + return r +} + +func (r *rig) installer() *Installer { + sum := sha256.Sum256(r.archive) + u, _ := url.Parse(r.srv.URL) + f := newFetcher(nil, true, u.Host) return &Installer{ LookPath: func(name string) (string, error) { - p := filepath.Join(bin, name) - if _, err := os.Stat(p); err != nil { - return "", errors.New("not found") + for _, d := range filepath.SplitList(r.env["PATH"]) { + if p := filepath.Join(d, name); exists(p) { + return p, nil + } } - return p, nil + return "", errors.New("not found") + }, + Run: r.fx.run, + Getenv: func(k string) string { return r.env[k] }, + GOOS: "linux", GOARCH: "amd64", + Guard: r.repo, Home: r.home, + Get: func(ctx context.Context, u string, limit int64) ([]byte, error) { return f.get(ctx, u, limit, nil) }, + Attest: r.attest, + Release: func(name string) (Release, bool) { + rel, ok := registryRelease(name) + if !ok { + return rel, false + } + rel.Signer = r.signer + rel.Assets = map[string]Asset{"linux/amd64": {URL: r.srv.URL + "/gitleaks.tar.gz", SHA256: hex.EncodeToString(sum[:]), Archive: TarGz, Member: "gitleaks"}} + return rel, true }, - Run: fx.run, - Getenv: func(k string) string { return env[k] }, - GOOS: "darwin", - Guard: t.TempDir(), } } +func (r *rig) dest() string { return filepath.Join(r.home, ".local", "bin", "gitleaks") } + func yes(Explanation) Answer { return Answer{Yes: true, Why: "typed yes"} } func no(Explanation) Answer { return Answer{Why: "typed no"} } -// TestInstallRunsTheRegistryStepOnlyOnYesAndVerifies is criterion 2's yes half: -// the fixed argv runs, then the verify command, and the result says both. -func TestInstallRunsTheRegistryStepOnlyOnYesAndVerifies(t *testing.T) { - fx := &fakeExec{out: map[string]string{"gitleaks": "8.18.0\n"}} - bin := binDir(t, "brew", "gitleaks") - in := testInstaller(t, fx, bin, nil) +// TestInstallDownloadsChecksWritesAndRecords is the first criterion: a +// missing tool is downloaded at the pinned version into the person's own +// folder, checked, written, verified, and recorded; and gitleaks' result says +// it was checked by checksum only. +func TestInstallDownloadsChecksWritesAndRecords(t *testing.T) { + r := newRig(t) var asked Explanation - r := in.Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = e; return yes(e) }) - if asked.Tool != "gitleaks" || len(asked.Step) == 0 { - t.Fatalf("the confirmation was not handed the explanation: %+v", asked) + res := r.installer().Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = e; return yes(e) }) + if !res.Ready || !res.Ran || !res.Installed || !res.Verified { + t.Fatalf("result = %+v", res) } - if !r.Ran || !r.Installed || !r.Verified || r.Declined { - t.Fatalf("result = %+v, want ran, installed, verified", r) + if asked.Download == nil || asked.Plan == nil || asked.Plan.Action != InstallOwn { + t.Fatalf("the confirmation was not handed the plan: %+v", asked) } - if len(fx.calls) != 2 { - t.Fatalf("calls = %v, want the step then the verify", fx.calls) + got, err := os.ReadFile(r.dest()) + if err != nil || !strings.Contains(string(got), "8.30.1") { + t.Fatalf("program not written: %v %q", err, got) } - // What runs is the symlink-resolved path, so what was judged and what is - // executed are the same bytes. - bin, _ = filepath.EvalSymlinks(bin) - if got := fx.calls[0]; got[0] != filepath.Join(bin, "brew") || strings.Join(got[1:], " ") != "install gitleaks" { - t.Errorf("step argv = %v", got) + if fi, _ := os.Stat(r.dest()); fi.Mode().Perm()&0o111 == 0 { + t.Fatal("the program is not executable") } - if got := fx.calls[1]; got[0] != filepath.Join(bin, "gitleaks") || strings.Join(got[1:], " ") != "version" { - t.Errorf("verify argv = %v", got) + if !res.ChecksumOnly || !strings.Contains(res.Summary(), "checksum only") { + t.Fatalf("checksum-only not said: %q", res.Summary()) } - s := r.Summary() - if !strings.Contains(s, "ran brew install gitleaks") || !strings.Contains(s, "verified") { - t.Errorf("summary does not report what ran and whether it worked: %q", s) + if !strings.Contains(res.Summary(), "~/.local/bin/gitleaks") || strings.Contains(res.Summary(), r.home) { + t.Fatalf("summary does not name the copy in the tilde form: %q", res.Summary()) } + rec, err := readRecord(r.home) + if err != nil || rec.Tools["gitleaks"].Path != r.dest() || rec.Tools["gitleaks"].Version != "8.30.1" { + t.Fatalf("record = %+v, %v", rec, err) + } + if last := r.fx.calls[len(r.fx.calls)-1]; last[0] != r.dest() || last[1] != "version" { + t.Fatalf("verify ran %v, not the written copy", last) + } + noAdmin(t, strings.Join(asked.Lines(), "\n")+res.Summary()) } -// TestInstallOnNoRunsNothingAndSaysContinuing is criterion 2's no half and the -// spec's loud staging: nothing runs, and the result carries the -// "continuing on <native default>" line. -func TestInstallOnNoRunsNothingAndSaysContinuing(t *testing.T) { - fx := &fakeExec{} - in := testInstaller(t, fx, binDir(t, "brew"), nil) - r := in.Install("gitleaks", TranscriptScan, no) - if r.Ran || !r.Declined || len(fx.calls) != 0 { - t.Fatalf("a no ran something: %+v calls=%v", r, fx.calls) +// TestAFailedCheckWritesNothingAndSaysWhy is the second criterion's first +// half, for each check: a download whose sha256 is not the pinned one, a +// signed build record that does not verify, and a download that fails. +func TestAFailedCheckWritesNothingAndSaysWhy(t *testing.T) { + for _, tc := range []struct { + name string + rig func(*rig) *Installer + want string + }{ + {"tampered", func(r *rig) *Installer { + in := r.installer() + r.archive = tarGz(t, "gitleaks", []byte("evil")) + return in + }, "but abcd's release fixes"}, + {"unsigned", func(r *rig) *Installer { + r.signer = &Signer{Repo: "o/r", Workflow: "w.yml", Ref: "refs/heads/main"} + r.attest = func(context.Context, Signer, []byte) (string, error) { return "", errors.New("no record verifies") } + return r.installer() + }, "signed build record does not check out: no record verifies"}, + {"unreachable", func(r *rig) *Installer { + in := r.installer() + r.srv.Close() + return in + }, "the download from"}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newRig(t) + res := tc.rig(r).Install("gitleaks", TranscriptScan, yes) + if res.Ready || res.Installed || exists(r.dest()) { + t.Fatalf("a failed check wrote something: %+v", res) + } + if !strings.Contains(res.Summary(), tc.want) || !strings.Contains(res.Summary(), "nothing was written") || + !strings.Contains(res.Summary(), "continuing on the native secret scanner") { + t.Fatalf("summary = %q", res.Summary()) + } + if rec, _ := readRecord(r.home); len(rec.Tools) != 0 { + t.Fatalf("a failed check was recorded: %+v", rec) + } + noAdmin(t, res.Summary()) + }) } - want := "gitleaks not installed (typed no); continuing on the native secret scanner" - if r.Summary() != want { - t.Fatalf("summary = %q, want %q", r.Summary(), want) +} + +// TestASignedToolIsCheckedAgainstItsSigner: the provenance check is handed +// the pinned signer and the archive's digest, and the result says both checks. +func TestASignedToolIsCheckedAgainstItsSigner(t *testing.T) { + r := newRig(t) + r.signer = &Signer{Repo: "o/r", Workflow: ".github/workflows/w.yml", Ref: "refs/heads/main"} + var gotSigner Signer + var gotDigest []byte + r.attest = func(_ context.Context, s Signer, d []byte) (string, error) { + gotSigner, gotDigest = s, d + return s.SAN(), nil + } + res := r.installer().Install("gitleaks", TranscriptScan, yes) + sum := sha256.Sum256(r.archive) + if !res.Ready || gotSigner != *r.signer || !bytes.Equal(gotDigest, sum[:]) { + t.Fatalf("result %+v; attest saw %+v %x", res, gotSigner, gotDigest) + } + if res.ChecksumOnly || !strings.Contains(res.Summary(), "signed build record") { + t.Fatalf("summary = %q", res.Summary()) } } -// TestInstallNeverRunsWithoutAConfirmation: a nil confirmation is a no. -func TestInstallNeverRunsWithoutAConfirmation(t *testing.T) { - fx := &fakeExec{} - r := testInstaller(t, fx, binDir(t, "brew"), nil).Install("gitleaks", TranscriptScan, nil) - if r.Ran || len(fx.calls) != 0 || !r.Declined { - t.Fatalf("installed without a confirmation: %+v", r) +// TestACompatibleCopyIsUsedAndNothingInstalled is the third criterion's first +// half: a copy installed some other way whose version abcd works with is used; +// nothing is asked, downloaded or written. +func TestACompatibleCopyIsUsedAndNothingInstalled(t *testing.T) { + r := newRig(t) + other := filepath.Join(r.pathDir, "gitleaks") + os.WriteFile(other, []byte("other"), 0o755) + r.fx.out["gitleaks"] = "8.24.3\n" + asked := false + res := r.installer().Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = true; return yes(e) }) + if !res.Ready || res.Ran || asked || r.hits != 0 || exists(r.dest()) { + t.Fatalf("a compatible copy was not used: %+v asked=%v hits=%d", res, asked, r.hits) + } + if !strings.Contains(res.Summary(), "nothing installed") || !strings.Contains(res.Summary(), "8.24.3") { + t.Fatalf("summary = %q", res.Summary()) + } + if b, _ := os.ReadFile(other); string(b) != "other" { + t.Fatal("the other copy was changed") } } -// TestInstallNeverRunsInCI: CI never installs, whatever the confirmation says, -// and the confirmation is not even asked. Every environment the canonical CI -// detector names refuses (a runner that sets only GITHUB_ACTIONS included, -// iss-2609261604499090), and so does any non-empty CI, "false" and "0" -// included: the installer is deliberately stricter than the detector. -func TestInstallNeverRunsInCI(t *testing.T) { - for _, env := range []map[string]string{ - {"CI": "true"}, {"CI": "1"}, {"GITHUB_ACTIONS": "true"}, {"CI": "false"}, {"CI": "0"}, - } { - fx := &fakeExec{} - asked := false - in := testInstaller(t, fx, binDir(t, "brew"), env) - r := in.Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = true; return yes(e) }) - if r.Ran || len(fx.calls) != 0 || asked || !r.Declined { - t.Fatalf("%v: CI installed or asked: %+v asked=%v", env, r, asked) +// TestAnIncompatibleCopyGetsAbcdsOwnAndSaysWhichRuns is the third criterion's +// second half: an old copy is named with why it does not fit, abcd's own is +// offered, the question says which of the two will run, and the other copy is +// never changed. +func TestAnIncompatibleCopyGetsAbcdsOwnAndSaysWhichRuns(t *testing.T) { + r := newRig(t) + other := filepath.Join(r.pathDir, "gitleaks") + os.WriteFile(other, []byte("old"), 0o755) + r.fx.out["gitleaks"] = "8.18.0\n" + in := r.installer() + plan := in.Plan("gitleaks") + if plan.Action != InstallOwn || plan.Existing == nil || plan.Existing.Compatible { + t.Fatalf("plan = %+v", plan) + } + var asked Explanation + // The written copy answers the verify with the pinned version. + in.Run = func(ctx context.Context, argv []string) ([]byte, error) { + if argv[0] == r.dest() { + return []byte("8.30.1\n"), nil } - if !strings.Contains(r.Summary(), "CI") || !strings.Contains(r.Summary(), "continuing on") { - t.Fatalf("%v: CI refusal is not said: %q", env, r.Summary()) + return r.fx.run(ctx, argv) + } + res := in.Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = e; return yes(e) }) + if !res.Ready || !res.Installed { + t.Fatalf("result = %+v", res) + } + text := strings.Join(asked.Lines(), "\n") + for _, want := range []string{"already installed: " + other, "8.18.0 is outside the versions abcd works with", + "which copy runs:", "abcd runs its own copy at ~/.local/bin/gitleaks", "still runs " + other} { + if !strings.Contains(text, want) { + t.Errorf("question lacks %q:\n%s", want, text) } } + if b, _ := os.ReadFile(other); string(b) != "old" { + t.Fatal("the other copy was changed") + } + if !strings.Contains(res.Summary(), "in your shell, gitleaks runs "+other) { + t.Errorf("summary does not say which copy the shell runs: %q", res.Summary()) + } } -// TestInstallRefusesAnUnknownTool: nothing composed, nothing run, and the -// refusal names the tools the registry does know. -func TestInstallRefusesAnUnknownTool(t *testing.T) { - fx := &fakeExec{} - asked := false - r := testInstaller(t, fx, binDir(t, "brew"), nil).Install("frobnicate", TranscriptScan, func(e Explanation) Answer { asked = true; return yes(e) }) - if r.Ran || len(fx.calls) != 0 || asked { - t.Fatalf("an unknown tool ran or was asked about: %+v", r) +// TestABinFolderCopyAbcdDidNotInstallIsNeverReplaced: when the bin folder +// already holds a program of that name abcd did not install, abcd installs +// nothing and says why, rather than overwrite it. +func TestABinFolderCopyAbcdDidNotInstallIsNeverReplaced(t *testing.T) { + r := newRig(t) + os.MkdirAll(filepath.Dir(r.dest()), 0o755) + os.WriteFile(r.dest(), []byte("theirs"), 0o755) + r.fx.out["gitleaks"] = "7.0.0\n" + res := r.installer().Install("gitleaks", TranscriptScan, yes) + if res.Ready || res.Ran || r.hits != 0 { + t.Fatalf("result = %+v", res) + } + if b, _ := os.ReadFile(r.dest()); string(b) != "theirs" || !strings.Contains(res.Why, "abcd never changes another copy") { + t.Fatalf("copy changed or not said: %q", res.Why) } - for _, n := range Names() { - if !strings.Contains(r.Why, n) { - t.Errorf("refusal does not name known tool %q: %q", n, r.Why) - } +} + +// TestAFailedVerifyRemovesTheCopy: a written program that does not report the +// pinned version is removed again and never recorded. +func TestAFailedVerifyRemovesTheCopy(t *testing.T) { + r := newRig(t) + r.fx.out["gitleaks"] = "8.29.0\n" + res := r.installer().Install("gitleaks", TranscriptScan, yes) + if res.Ready || res.Verified || exists(r.dest()) { + t.Fatalf("result = %+v", res) + } + if !strings.Contains(res.Summary(), "not the pinned 8.30.1") || !strings.Contains(res.Summary(), "removed the copy") { + t.Fatalf("summary = %q", res.Summary()) } } -// TestInstallSaysWhenThePackageManagerIsMissing: a yes with no Homebrew runs -// nothing and says what is missing. -func TestInstallSaysWhenThePackageManagerIsMissing(t *testing.T) { - fx := &fakeExec{} - r := testInstaller(t, fx, binDir(t), nil).Install("gitleaks", TranscriptScan, yes) - if r.Ran || len(fx.calls) != 0 { - t.Fatalf("ran without a package manager: %+v", r) +// TestInstallRefusals: CI, a no, no confirmation, an unknown tool, and a bin +// folder inside the repository each write nothing and say so. +func TestInstallRefusals(t *testing.T) { + for _, env := range []map[string]string{{"CI": "true"}, {"GITHUB_ACTIONS": "true"}, {"CI": "false"}, {"CI": "0"}} { + r := newRig(t) + for k, v := range env { + r.env[k] = v + } + asked := false + res := r.installer().Install("gitleaks", TranscriptScan, func(e Explanation) Answer { asked = true; return yes(e) }) + if res.Ran || asked || !res.Declined || !strings.Contains(res.Summary(), "CI") { + t.Fatalf("%v: %+v", env, res) + } + } + r := newRig(t) + if res := r.installer().Install("gitleaks", TranscriptScan, no); res.Ran || !res.Declined || + res.Summary() != "gitleaks not installed (typed no); continuing on the native secret scanner" { + t.Fatalf("no: %q", res.Summary()) + } + if res := r.installer().Install("gitleaks", TranscriptScan, nil); res.Ran || !res.Declined { + t.Fatalf("nil confirm: %+v", res) } - if !strings.Contains(r.Why, "Homebrew") || !strings.Contains(r.Summary(), "continuing on") { - t.Fatalf("missing manager not said: %q", r.Summary()) + if res := r.installer().Install("frobnicate", TranscriptScan, yes); res.Ran || !strings.Contains(res.Why, "gitleaks") { + t.Fatalf("unknown: %+v", res) + } + in := r.installer() + in.Guard = r.home // a repository rooted at the home holds ~/.local/bin + if res := in.Install("gitleaks", TranscriptScan, yes); res.Ran || !strings.Contains(res.Why, "inside the repository") { + t.Fatalf("bin folder in the repository: %+v", res) + } + if r.hits != 0 { + t.Fatalf("a refusal downloaded: %d", r.hits) } } -// TestInstallRefusesAProgramInsideTheGuardedTree: a PATH entry that resolves -// the package manager into the repository is repository content, and never -// runs. -func TestInstallRefusesAProgramInsideTheGuardedTree(t *testing.T) { - fx := &fakeExec{} - repo := t.TempDir() - bin := filepath.Join(repo, "bin") - if err := os.MkdirAll(bin, 0o755); err != nil { +// --- running and removing abcd's copies -------------------------------------- + +func withHome(t *testing.T, home string, path func(string) (string, error)) { + t.Helper() + oh, op := userHome, pathLookup + userHome = func() (string, error) { return home, nil } + pathLookup = path + t.Cleanup(func() { userHome, pathLookup = oh, op }) +} + +// TestResolvePrefersAbcdsCopyWhateverPathSays is the fourth criterion's first +// half: abcd runs its recorded copy even when another comes first on PATH, +// and falls back to PATH once the copy is no longer the bytes it wrote. +func TestResolvePrefersAbcdsCopyWhateverPathSays(t *testing.T) { + r := newRig(t) + if res := r.installer().Install("gitleaks", TranscriptScan, yes); !res.Ready { + t.Fatal(res.Summary()) + } + first := filepath.Join(r.pathDir, "gitleaks") + withHome(t, r.home, func(string) (string, error) { return first, nil }) + if got, _ := Resolve("gitleaks"); got != r.dest() { + t.Fatalf("Resolve = %q, want abcd's copy %q", got, r.dest()) + } + os.WriteFile(r.dest(), []byte("changed by someone"), 0o755) + if got, _ := Resolve("gitleaks"); got != first { + t.Fatalf("a changed copy still resolves as abcd's: %q", got) + } + if got, _ := Resolve("gh"); got != first { + t.Fatalf("an unrecorded tool does not fall back to PATH: %q", got) + } +} + +// TestUninstallRemovesOnlyAbcdsIntactCopies is the fourth criterion's second +// half: the copies abcd installed go, a recorded copy changed since stays, a +// copy installed some other way is never reached, and the record goes. +func TestUninstallRemovesOnlyAbcdsIntactCopies(t *testing.T) { + r := newRig(t) + if res := r.installer().Install("gitleaks", TranscriptScan, yes); !res.Ready { + t.Fatal(res.Summary()) + } + other := filepath.Join(r.pathDir, "gh") + os.WriteFile(other, []byte("theirs"), 0o755) + // A second recorded copy that someone changed since. + changed := filepath.Join(r.home, ".local", "bin", "gh") + os.WriteFile(changed, []byte("edited"), 0o755) + rec, _ := readRecord(r.home) + rec.Tools["gh"] = Installed{Path: changed, SHA256: strings.Repeat("a", 64), Version: "2.103.2"} + if err := writeRecord(r.home, rec); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(bin, "brew"), []byte("#!/bin/sh\n"), 0o755); err != nil { + out, err := Uninstall(r.home) + if err != nil { t.Fatal(err) } - in := testInstaller(t, fx, bin, nil) - in.Guard = repo - r := in.Install("gitleaks", TranscriptScan, yes) - if r.Ran || len(fx.calls) != 0 { - t.Fatalf("ran a package manager from inside the repository: %+v", r) + if exists(r.dest()) || !exists(changed) || !exists(other) { + t.Fatalf("removed the wrong files: %+v", out) } - if !strings.Contains(r.Why, "inside") { - t.Fatalf("refusal does not say why: %q", r.Why) + b, _ := json.Marshal(out) + if !strings.Contains(string(b), "removed abcd's copy") || !strings.Contains(string(b), "changed since abcd installed it") { + t.Fatalf("removals = %s", b) + } + if exists(filepath.Join(r.home, ".abcd.noindex", "tools.json")) { + t.Fatal("the record outlived the copies") } } -// TestInstallReportsAFailedStepAndAFailedVerify: both failures are reported, -// never a success. -func TestInstallReportsAFailedStepAndAFailedVerify(t *testing.T) { - fx := &fakeExec{fail: map[string]error{"brew": errors.New("exit status 1")}, out: map[string]string{"brew": "Error: no bottle\n"}} - r := testInstaller(t, fx, binDir(t, "brew", "gitleaks"), nil).Install("gitleaks", TranscriptScan, yes) - if !r.Ran || r.Installed || r.Verified { - t.Fatalf("a failed step reads as installed: %+v", r) +// --- the transport ----------------------------------------------------------- + +func TestFetcherRefusesWhatTheDownloadMayNotReach(t *testing.T) { + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("x")) })) + defer elsewhere.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, elsewhere.URL+"/x", http.StatusFound) + })) + defer redirect.Close() + big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(make([]byte, 2048)) })) + defer big.Close() + host := func(s *httptest.Server) string { u, _ := url.Parse(s.URL); return u.Host } + ctx := context.Background() + + if _, err := newFetcher(nil, true, host(redirect)).get(ctx, redirect.URL, 1<<20, nil); err == nil || !strings.Contains(err.Error(), "not a host") { + t.Errorf("a redirect to another host was followed: %v", err) + } + if _, err := newFetcher(nil, false, host(big)).get(ctx, big.URL, 1<<20, nil); err == nil || !strings.Contains(err.Error(), "not https") { + t.Errorf("plain http was fetched: %v", err) } - if !strings.Contains(r.Summary(), "failed") || !strings.Contains(r.Summary(), "no bottle") || !strings.Contains(r.Summary(), "continuing on") { - t.Fatalf("failed step summary: %q", r.Summary()) + if _, err := newFetcher(nil, true, host(big)).get(ctx, big.URL, 1024, nil); err == nil || !strings.Contains(err.Error(), "bound") { + t.Errorf("an oversized answer was read: %v", err) } + if _, err := newFetcher(nil, true, "github.com").get(ctx, big.URL, 1<<20, nil); err == nil { + t.Error("a host outside the list was fetched") + } + for _, h := range releaseHosts { + if h != "github.com" && !strings.HasSuffix(h, ".githubusercontent.com") { + t.Errorf("release host %q is not GitHub's", h) + } + } +} + +func TestExtractReadsOnlyTheNamedRegularFile(t *testing.T) { + if b, err := extractMember(zipOf(t, "d/bin/gh", []byte("gh")), Zip, "d/bin/gh"); err != nil || string(b) != "gh" { + t.Fatalf("zip: %q %v", b, err) + } + if _, err := extractMember(tarGz(t, "other", []byte("x")), TarGz, "gitleaks"); err == nil { + t.Fatal("a missing member read as present") + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + tw.WriteHeader(&tar.Header{Name: "gitleaks", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}) + tw.Close() + zw.Close() + if _, err := extractMember(buf.Bytes(), TarGz, "gitleaks"); err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("a link member was read: %v", err) + } +} + +// --- the provenance check, on recorded real bundles -------------------------- + +// The fixtures under testdata/attest are recorded, not synthesised +// (2026-10-24): GitHub's attestations API answer for the sha256 of gh +// v2.103.2's gh_2.103.2_macOS_arm64.zip, whose entries carry no inline bundle, +// only a bundle_url into GitHub's attestation store (rewritten here to +// BUNDLE_BASE, served by the test); the two snappy-compressed bundles that +// store served; and the Sigstore public-good trust root, as recorded for +// internal/core/update's fixtures. +const ghMacArm64 = "5416000f5d17580a187339e0c4068a01ca5c87f7d4fe6ca64933696f967bc418" + +func attestRig(t *testing.T) *githubAttester { + t.Helper() + dir := filepath.Join("testdata", "attest") + var base string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch { + case strings.HasPrefix(req.URL.Path, "/repos/cli/cli/attestations/sha256:"+ghMacArm64): + b, _ := os.ReadFile(filepath.Join(dir, "gh-v2.103.2-macOS-arm64.attestations.json")) + w.Write(bytes.ReplaceAll(b, []byte("BUNDLE_BASE"), []byte(base+"/store"))) + case strings.HasPrefix(req.URL.Path, "/store/"): + b, err := os.ReadFile(filepath.Join(dir, filepath.Base(req.URL.Path))) + if err != nil { + http.NotFound(w, req) + return + } + w.Write(b) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(srv.Close) + base = srv.URL + u, _ := url.Parse(srv.URL) + tr, err := os.ReadFile(filepath.Join(dir, "trusted_root.json")) + if err != nil { + t.Fatal(err) + } + return &githubAttester{ + f: newFetcher(nil, true, u.Host), apiBase: srv.URL + "/repos/", + bundleOK: func(b *url.URL) bool { return b.Host == u.Host }, + trusted: func() (root.TrustedMaterial, error) { + r, err := root.NewTrustedRootFromJSON(tr) + if err != nil { + return nil, err + } + return r, nil + }, + } +} - fx = &fakeExec{fail: map[string]error{"gitleaks": errors.New("exit status 2")}} - r = testInstaller(t, fx, binDir(t, "brew", "gitleaks"), nil).Install("gitleaks", TranscriptScan, yes) - if !r.Installed || r.Verified { - t.Fatalf("a failed verify reads as verified: %+v", r) +func TestGhsRecordedBuildRecordVerifiesAgainstItsPinnedSigner(t *testing.T) { + g := attestRig(t) + d, _ := hex.DecodeString(ghMacArm64) + signer, err := g.verify(context.Background(), *registry["gh"].Release.Signer, d) + if err != nil { + t.Fatal(err) } - if !strings.Contains(r.Summary(), "verify") || !strings.Contains(r.Summary(), "failed") { - t.Fatalf("failed verify summary: %q", r.Summary()) + if signer != "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" { + t.Fatalf("signer = %q", signer) + } +} + +func TestABuildRecordFromAnotherSignerOrForOtherBytesFails(t *testing.T) { + g := attestRig(t) + d, _ := hex.DecodeString(ghMacArm64) + wrong := Signer{Repo: "cli/cli", Workflow: ".github/workflows/release.yml", Ref: "refs/heads/trunk"} + if _, err := g.verify(context.Background(), wrong, d); err == nil { + t.Fatal("a record signed by another workflow verified") + } + other := append([]byte(nil), d...) + other[0] ^= 0xff + if _, err := g.verify(context.Background(), *registry["gh"].Release.Signer, other); err == nil { + t.Fatal("a digest with no record verified") + } + g.bundleOK = func(*url.URL) bool { return false } + if _, err := g.verify(context.Background(), *registry["gh"].Release.Signer, d); err == nil || !strings.Contains(err.Error(), "outside GitHub's attestation store") { + t.Fatalf("a bundle_url outside the store was read: %v", err) } } -// TestRunArgvExecutesWithoutAShell drives the production runner: the argv is -// handed to the program as-is, so a metacharacter reaches it as a literal byte -// rather than being interpreted. +func TestDecodeSnappyRefusesMalformedInput(t *testing.T) { + for _, in := range [][]byte{nil, {0x05, 0x10, 'a'}, {0x03, 0x01, 0x00}, {0xff, 0xff, 0xff, 0xff, 0x0f}} { + if _, err := decodeSnappy(in, 1<<20); err == nil { + t.Errorf("decoded %x", in) + } + } + // A literal "ab" then a copy of length 4 at offset 2: "ababab". + got, err := decodeSnappy([]byte{0x06, 0x04, 'a', 'b', 0x01, 0x02}, 64) + if err != nil || string(got) != "ababab" { + t.Fatalf("got %q, %v", got, err) + } +} + +// --- the production runner --------------------------------------------------- + func TestRunArgvExecutesWithoutAShell(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "argv") script := filepath.Join(dir, "echoargs") - body := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + out + "\n" - if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > "+out+"\n"), 0o755); err != nil { t.Fatal(err) } if _, err := runArgv(context.Background(), []string{script, "a;b", "$(x)"}); err != nil { t.Fatal(err) } - got, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if string(got) != "a;b\n$(x)\n" { + if got, _ := os.ReadFile(out); string(got) != "a;b\n$(x)\n" { t.Fatalf("argv reached the program as %q", got) } } -// TestRunArgvKillsItsOwnGroupOnTimeout: a step that outlives its bound is -// stopped with everything it started, through the handle, promptly. func TestRunArgvKillsItsOwnGroupOnTimeout(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "hang") @@ -401,49 +777,42 @@ func TestRunArgvKillsItsOwnGroupOnTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() start := time.Now() - _, err := runArgv(ctx, []string{script}) - if err == nil || !strings.Contains(err.Error(), "did not finish") { - t.Fatalf("err = %v, want the timeout named", err) + if _, err := runArgv(ctx, []string{script}); err == nil || !strings.Contains(err.Error(), "did not finish") { + t.Fatalf("err = %v", err) } if d := time.Since(start); d > 10*time.Second { - t.Fatalf("runArgv waited %s: the group was not killed", d) + t.Fatalf("runArgv waited %s", d) } } -// TestMissingErrorKeepsTheCauseAndCarriesTheExplanation: a verb's refusal for a -// missing tool keeps its sentinel for errors.Is and appends the explanation. func TestMissingErrorKeepsTheCauseAndCarriesTheExplanation(t *testing.T) { cause := errors.New("gitleaks configured but not found") err := Missing(cause, "gitleaks", TranscriptScanArmed) - if !errors.Is(err, cause) { - t.Fatal("the cause is lost") - } - lines := strings.Split(err.Error(), "\n") - if lines[0] != cause.Error() || len(lines) < 5 { - t.Fatalf("error = %q, want the cause then the explanation", err.Error()) + if !errors.Is(err, cause) || strings.Split(err.Error(), "\n")[0] != cause.Error() { + t.Fatalf("error = %q", err.Error()) } } -// TestInstallSanitisesTheProgramsOutput is iss-2609261604498137: a program's -// output is untrusted text, and the result carries it to the terminal (the -// ahoy changed: and note: lines) and to --json, so no escape, C1 control or -// bidi override survives into Output or the Summary, on success or failure. +// TestInstallSanitisesTheProgramsOutput: a program's output is untrusted text +// that reaches the terminal and --json through the result, so no escape, C1 +// control or bidi override survives into Output or the Summary. func TestInstallSanitisesTheProgramsOutput(t *testing.T) { - const hostile = "\x1b[2J8.18.0‮\u009b31m\x07" + const hostile = "\x1b[2J8.30.1‮\u009b31m\x07" for _, tc := range []struct { name string - fx *fakeExec - }{ - {"verified", &fakeExec{out: map[string]string{"gitleaks": hostile + "\nsecond\n"}}}, - {"failed step", &fakeExec{fail: map[string]error{"brew": errors.New("exit status 1")}, out: map[string]string{"brew": "Error:\n" + hostile + "\n"}}}, - {"failed verify", &fakeExec{fail: map[string]error{"gitleaks": errors.New("exit status 2")}, out: map[string]string{"gitleaks": hostile}}}, - } { + fail error + }{{"verified", nil}, {"failed verify", errors.New("exit status 2")}} { t.Run(tc.name, func(t *testing.T) { - r := testInstaller(t, tc.fx, binDir(t, "brew", "gitleaks"), nil).Install("gitleaks", TranscriptScan, yes) - if !strings.Contains(r.Output, "8.18.0") { - t.Fatalf("output lost its text: %q", r.Output) + r := newRig(t) + r.fx.out["gitleaks"] = hostile + "\nsecond\n" + if tc.fail != nil { + r.fx.fail["gitleaks"] = tc.fail + } + res := r.installer().Install("gitleaks", TranscriptScan, yes) + if !strings.Contains(res.Output, "8.30.1") { + t.Fatalf("output lost its text: %q", res.Output) } - for _, s := range []string{r.Output, r.Summary()} { + for _, s := range []string{res.Output, res.Summary()} { if strings.ContainsAny(s, "\x1b‮\u009b\x07") { t.Errorf("a control reached the result: %q", s) } diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index ef85b4271a..8e20adc230 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -132,7 +132,7 @@ var categoryPromptOrder = []string{ // Fewer than two questions is a no-op, not a failure. Which categories a repo // has gaps in depends on the HOST: the dependency approval exists only while // the opt-in scanners are off PATH, and PATH is the one thing the hermetic repo -// does not redirect. A maintainer who follows abcd's own `brew install gitleaks` +// does not redirect. A maintainer who follows abcd's own gitleaks install // hint must not thereby turn preflight red — so a caller that needs a minimum // number of questions asserts that itself, over a repo state it controls. func assertCategoryQuestionOrder(t *testing.T, transcript string) { diff --git a/internal/surface/cli/ahoy_remote_gh_offer_test.go b/internal/surface/cli/ahoy_remote_gh_offer_test.go index 76b31c5694..170f19b348 100644 --- a/internal/surface/cli/ahoy_remote_gh_offer_test.go +++ b/internal/surface/cli/ahoy_remote_gh_offer_test.go @@ -23,12 +23,12 @@ func TestTerminalToolConfirmAsksOnlyAPersonAtATerminal(t *testing.T) { piped := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w} if ans := terminalToolConfirm(piped, false, &w)(e); ans.Yes || - !strings.Contains(ans.Why, "no terminal to ask at") || !strings.Contains(ans.Why, "brew install gh") { + !strings.Contains(ans.Why, "no terminal to ask at") || !strings.Contains(ans.Why, "gh_2.103.2_") { t.Fatalf("a piped y: %+v", ans) } tty := &stdinPrompter{r: bufio.NewReader(strings.NewReader("y\n")), w: &w, tty: true} if ans := terminalToolConfirm(tty, true, &w)(e); ans.Yes || - !strings.Contains(ans.Why, "--yes") || !strings.Contains(ans.Why, "brew install gh") { + !strings.Contains(ans.Why, "--yes") || !strings.Contains(ans.Why, "gh_2.103.2_") { t.Fatalf("--yes at a terminal: %+v", ans) } if ans := terminalToolConfirm(ahoy.RefusingPrompter{}, false, &w)(e); ans.Yes { @@ -49,7 +49,7 @@ func TestTerminalToolConfirmAsksOnlyAPersonAtATerminal(t *testing.T) { t.Fatalf("a y typed at a terminal was not a yes: %+v", ans) } asked := w.String() - q := strings.Index(asked, "Install gh now by running brew install gh? [y/N]") + q := strings.Index(asked, "Install gh now: download gh 2.103.2 into ") if q < 0 { t.Fatalf("the question does not show the exact step:\n%s", asked) } @@ -58,15 +58,14 @@ func TestTerminalToolConfirmAsksOnlyAPersonAtATerminal(t *testing.T) { t.Errorf("%q is not shown before the question:\n%s", want, asked) } } - if !strings.Contains(asked[q:], "running brew install gh") { + if !strings.Contains(asked[q:], "[y/N]") || !strings.Contains(asked[q:], "downloading gh; nothing is written until it passes its check") { t.Errorf("the step is not announced as it starts:\n%s", asked) } } // TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes is the end-to-end no: a // piped y and --yes each reach the gh offer and decline it, so the step is -// never attempted (brew's absence would otherwise be the reason), and the -// refusal carries the command to run. +// never attempted, and the refusal carries the by-hand download to make. func TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes(t *testing.T) { hermeticEnv(t) repo := gittest.NewRepo(t) @@ -95,7 +94,7 @@ func TestAhoyRemoteApplyNeverInstallsGhOnAScriptedYes(t *testing.T) { t.Fatalf("%v: not JSON: %v\n%s\n%s", tc.args, jerr, out, errOut) } notes := strings.Join(res.Notes, "\n") - if res.Status != "refused" || !strings.Contains(notes, tc.why) || !strings.Contains(notes, "brew install gh") { + if res.Status != "refused" || !strings.Contains(notes, tc.why) || !strings.Contains(notes, "download gh 2.103.2") { t.Errorf("%v: status %q, notes lack %q or the step:\n%s", tc.args, res.Status, tc.why, notes) } if strings.Contains(notes, "is not on PATH, so the step cannot run") { diff --git a/internal/surface/cli/ahoy_tool_confirm_test.go b/internal/surface/cli/ahoy_tool_confirm_test.go index 540a2075de..4d91c88faa 100644 --- a/internal/surface/cli/ahoy_tool_confirm_test.go +++ b/internal/surface/cli/ahoy_tool_confirm_test.go @@ -42,7 +42,7 @@ func TestToolConfirmAsksOnlyAtATerminal(t *testing.T) { t.Fatalf("a y typed at a terminal was not a yes: %+v", ans) } asked := w.String() - for _, want := range []string{"what abcd uses it for:", "without it:", "brew install gitleaks", "[y/N]"} { + for _, want := range []string{"what abcd uses it for:", "without it:", "download gitleaks 8.30.1 into", "[y/N]"} { if !strings.Contains(asked, want) { t.Errorf("the terminal question lacks %q:\n%s", want, asked) } @@ -97,8 +97,9 @@ func TestInstallToolRefusesAnUnknownName(t *testing.T) { } // toolFreePath leaves git on PATH (the install reads the checkout) and nothing -// else, so neither gitleaks nor a package manager is found and no install -// step can ever run from this test. +// else, so no gitleaks is found, and points the bin folder at a directory of +// the test's own; a test binary's installer has no network, so no download +// can ever be made from this test. func toolFreePath(t *testing.T) { t.Helper() gitBin, err := exec.LookPath("git") @@ -110,6 +111,7 @@ func toolFreePath(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", dir) + t.Setenv("XDG_BIN_HOME", t.TempDir()) // Both of the variables cienv.Runner reads, so the confirmation path runs // on a CI runner as it does on a workstation. t.Setenv("CI", "") @@ -142,7 +144,7 @@ func TestAhoyInstallPipedYesExplainsAndKeepsTheNativeScanner(t *testing.T) { joined := strings.Join(notes, "\n") for _, want := range []string{ "dependency: gitleaks — optional for", - "install step (Homebrew): brew install gitleaks", + "install: download gitleaks 8.30.1 into", "gitleaks not installed (no terminal to ask at", "continuing on the native secret scanner", } { @@ -154,8 +156,8 @@ func TestAhoyInstallPipedYesExplainsAndKeepsTheNativeScanner(t *testing.T) { // TestAhoyInstallNamedToolReachesTheStep is the host's relayed yes end to // end: with stdin closed, --install-tool answers the dependency question and -// the install is attempted; here the package manager is absent, so the -// result says so and nothing ran. +// the install is attempted; here the test binary has no network, so the +// download fails, the result says so, and nothing was written. func TestAhoyInstallNamedToolReachesTheStep(t *testing.T) { hermeticRepo(t) toolFreePath(t) @@ -170,7 +172,8 @@ func TestAhoyInstallNamedToolReachesTheStep(t *testing.T) { } } joined := strings.Join(notes, "\n") - if !strings.Contains(joined, "Homebrew (brew) is not on PATH") || !strings.Contains(joined, "continuing on the native secret scanner") { + if !strings.Contains(joined, "a test binary never reaches the network") || !strings.Contains(joined, "nothing was written") || + !strings.Contains(joined, "continuing on the native secret scanner") { t.Fatalf("the named install did not reach the step, or was silent:\n%s", joined) } } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 59600139ae..99d9aa7e4f 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4297,6 +4297,12 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " readme: %s\n", termsafe.Sanitize(receipt.Readme.Note)) fmt.Fprintf(w, " symlink: %s\n", symlinkNote(receipt)) fmt.Fprintf(w, " status line: %s\n", receipt.StatusLine.Note) + for _, r := range receipt.Tools { + fmt.Fprintf(w, " tool %s: %s (%s)\n", r.Tool, termsafe.Sanitize(r.Note), termsafe.Sanitize(r.Path)) + } + if receipt.ToolsNote != "" { + fmt.Fprintf(w, " tools: %s\n", termsafe.Sanitize(receipt.ToolsNote)) + } }) }, } @@ -4651,9 +4657,10 @@ func toolConfirm(p ahoy.Prompter, named map[string]bool, yes bool, w io.Writer) // --install-tool: the gh offer at ahoy remote apply (the product thinker's // DQ3 ruling, 2026-09-29). Its only yes is one typed at a terminal; --yes, a // piped stream and a caller with no terminal each decline, carrying the -// command the person can run themselves. +// by-hand install the person can make themselves (the URL, the sha256 to check +// and where the program goes: never a step that needs admin rights). func terminalToolConfirm(p ahoy.Prompter, yes bool, w io.Writer) tools.Confirm { - return askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "install it yourself with " + e.StepText() }) + return askToolAtTerminal(p, yes, w, func(e tools.Explanation) string { return "install it yourself: " + e.ManualText() }) } // askToolAtTerminal asks the install question of a person at a terminal and @@ -4669,10 +4676,10 @@ func askToolAtTerminal(p ahoy.Prompter, yes bool, w io.Writer, otherwise func(to for _, line := range e.Lines() { fmt.Fprintln(w, termsafe.Sanitize(line)) } - if !dp.confirmTool(e.Tool, "Install "+e.Tool+" now by running "+e.StepText()+"?") { + if !dp.confirmTool(e.Tool, "Install "+e.Tool+" now: "+e.StepText()+"?") { return tools.Answer{Why: "answered no at the terminal"} } - fmt.Fprintf(w, "running %s; a package manager can take a few minutes\n", e.StepText()) + fmt.Fprintf(w, "downloading %s; nothing is written until it passes its check\n", e.Tool) return tools.Answer{Yes: true, Why: "answered yes at the terminal"} } sp, ok := p.(*stdinPrompter) @@ -4682,11 +4689,11 @@ func askToolAtTerminal(p ahoy.Prompter, yes bool, w io.Writer, otherwise func(to for _, line := range e.Lines() { fmt.Fprintln(w, termsafe.Sanitize(line)) } - fmt.Fprintf(w, "Install %s now by running %s? [y/N] ", e.Tool, e.StepText()) + fmt.Fprintf(w, "Install %s now: %s? [y/N] ", e.Tool, e.StepText()) line, _ := sp.r.ReadString('\n') switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": - fmt.Fprintf(w, "running %s; a package manager can take a few minutes\n", e.StepText()) + fmt.Fprintf(w, "downloading %s; nothing is written until it passes its check\n", e.Tool) return tools.Answer{Yes: true, Why: "answered yes at the terminal"} } return tools.Answer{Why: "answered no at the terminal"} diff --git a/internal/surface/cli/site_setup_test.go b/internal/surface/cli/site_setup_test.go index a6dad0727c..4c4a252428 100644 --- a/internal/surface/cli/site_setup_test.go +++ b/internal/surface/cli/site_setup_test.go @@ -90,7 +90,7 @@ func TestSiteSetupRefusesAnUnmanagedFolder(t *testing.T) { // TestSiteSetupNeverInstallsGhOnAScriptedYes is the end-to-end no at site // setup (the DQ3 ruling): with gh missing from a repository whose origin is on // the forge, a piped y and --yes each reach the gh offer through the CLI and -// decline it, so the install step is never attempted (brew's absence would +// decline it, so the install is never attempted (the test binary's lack of a network would // otherwise be the reason), no question is put to the pipe, the notes carry // why and the command to run by hand, and the forge stage is not reached. func TestSiteSetupNeverInstallsGhOnAScriptedYes(t *testing.T) { @@ -125,7 +125,7 @@ func TestSiteSetupNeverInstallsGhOnAScriptedYes(t *testing.T) { t.Fatalf("not JSON (err %v): %v\n%s\n%s", err, jerr, out, errOut) } notes := strings.Join(res.Notes, "\n") - if !strings.Contains(notes, tc.why) || !strings.Contains(notes, "brew install gh") { + if !strings.Contains(notes, tc.why) || !strings.Contains(notes, "download gh 2.103.2") { t.Errorf("the notes lack %q or the command to run:\n%s", tc.why, notes) } if strings.Contains(notes, "is not on PATH, so the step cannot run") { From 953c55992628278c7540cb1fe6aebdffd98c1f0d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:00:46 +0200 Subject: [PATCH 11/26] docs: record the tools lane's limits and two review findings left for later Assisted-by: Claude:claude-opus-5-5 --- ...6829-the-third-party-tool-download-rules-in.md | 15 +++++++++++++++ ...28-at-abcd-ahoy-remote-apply-and-site-setup.md | 15 +++++++++++++++ ...5613-a-tool-install-is-refused-when-the-bin.md | 15 +++++++++++++++ ...72-the-go-identity-reader-trims-surrounding.md | 15 +++++++++++++++ ...41300370199-an-identity-pin-that-holds-only.md | 15 +++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610241242146829-the-third-party-tool-download-rules-in.md create mode 100644 .abcd/work/issues/open/iss-2610241242226828-at-abcd-ahoy-remote-apply-and-site-setup.md create mode 100644 .abcd/work/issues/open/iss-2610241242295613-a-tool-install-is-refused-when-the-bin.md create mode 100644 .abcd/work/issues/open/iss-2610241300265072-the-go-identity-reader-trims-surrounding.md create mode 100644 .abcd/work/issues/open/iss-2610241300370199-an-identity-pin-that-holds-only.md diff --git a/.abcd/work/issues/open/iss-2610241242146829-the-third-party-tool-download-rules-in.md b/.abcd/work/issues/open/iss-2610241242146829-the-third-party-tool-download-rules-in.md new file mode 100644 index 0000000000..459fa4271c --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241242146829-the-third-party-tool-download-rules-in.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241242146829" +slug: "the-third-party-tool-download-rules-in" +severity: "minor" +category: "tech-debt" +source: "agent-finding" +found_during: "setup redesign build, tools lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/tools/fetch.go" +remedy: "move the shared download policy into one package both import (e.g. a small internal/core/fetchpolicy), and have update and tools use it" +--- + +the third-party tool download rules in internal/core/tools/fetch.go are a second copy of the updater's in internal/core/update (allowed hosts, size and time limits, no proxy, CA overrides ignored), because update imports ahoy which imports tools; two copies of a trust policy drift apart. diff --git a/.abcd/work/issues/open/iss-2610241242226828-at-abcd-ahoy-remote-apply-and-site-setup.md b/.abcd/work/issues/open/iss-2610241242226828-at-abcd-ahoy-remote-apply-and-site-setup.md new file mode 100644 index 0000000000..e75f8aaf1d --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241242226828-at-abcd-ahoy-remote-apply-and-site-setup.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241242226828" +slug: "at-abcd-ahoy-remote-apply-and-site-setup" +severity: "minor" +category: "inconsistency" +source: "agent-finding" +found_during: "setup redesign build, tools lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/tools/release.go" +remedy: "apply the tool's compatible range at every gh call site, and offer abcd's copy (or say which copy runs) when the PATH gh is out of range" +--- + +at abcd ahoy remote apply and site setup any gh on PATH counts as present: only a missing gh is offered abcd's verified copy, so an incompatible older gh is used there without a word, unlike gitleaks at ahoy install. diff --git a/.abcd/work/issues/open/iss-2610241242295613-a-tool-install-is-refused-when-the-bin.md b/.abcd/work/issues/open/iss-2610241242295613-a-tool-install-is-refused-when-the-bin.md new file mode 100644 index 0000000000..1ef8dc6e34 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241242295613-a-tool-install-is-refused-when-the-bin.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241242295613" +slug: "a-tool-install-is-refused-when-the-bin" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "setup redesign build, tools lane, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/tools/install.go" +remedy: "say which program occupies the name and offer the by-hand steps (ManualText) or an alternative folder the person chooses" +--- + +a tool install is refused when the bin folder already holds a program of that name that abcd did not install, with no other route offered. diff --git a/.abcd/work/issues/open/iss-2610241300265072-the-go-identity-reader-trims-surrounding.md b/.abcd/work/issues/open/iss-2610241300265072-the-go-identity-reader-trims-surrounding.md new file mode 100644 index 0000000000..49cb1ddc50 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241300265072-the-go-identity-reader-trims-surrounding.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241300265072" +slug: "the-go-identity-reader-trims-surrounding" +severity: "minor" +category: "inconsistency" +source: "review-followup" +found_during: "review pair of feat/setup-redesign, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: ".githooks/pre-commit" +remedy: "trim in the hook the same way, or refuse such a record in both readers with the same message" +--- + +the Go identity reader trims surrounding whitespace from the recorded name and email but the shell pre-commit gate's sed does not, so a hand-edited record with a leading or trailing space passes abcd ahoy --identity and blocks every commit in the hook (fails closed). diff --git a/.abcd/work/issues/open/iss-2610241300370199-an-identity-pin-that-holds-only.md b/.abcd/work/issues/open/iss-2610241300370199-an-identity-pin-that-holds-only.md new file mode 100644 index 0000000000..c91898d9c0 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241300370199-an-identity-pin-that-holds-only.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241300370199" +slug: "an-identity-pin-that-holds-only" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "review pair of feat/setup-redesign, 2026-10-24" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/identity" +remedy: "migrate production_mode on its own for any contributor at a terminal, and drop a pin that names no human identity with its own explained step" +--- + +an identity pin that holds only production_mode, or a machine identity, is owned by no one under the migration rule, so it is never removed and its production_mode never moves to .abcd/config/provenance.json, and every contributor is told the repository stores a commit identity that is not theirs. From 103b5c9fdd8ee881793aeacda6d64b2df50d9b7e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:04:44 +0200 Subject: [PATCH 12/26] fix: count only abcd's own line for this folder as already added addsDir treated any startup-file line ending in abcd's marker as one that adds the folder asked about, so a marked line for another folder silenced the shell-settings offer. It now compares the line whole against MarkedLine(home, dir), as Remove's lineIndex does; the person's own spellings are still recognised as before. Assisted-by: Claude:claude-opus-5-5 --- internal/core/shellpath/shellpath.go | 13 ++++++++----- internal/core/shellpath/shellpath_test.go | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/core/shellpath/shellpath.go b/internal/core/shellpath/shellpath.go index 790ac91cab..a47332169b 100644 --- a/internal/core/shellpath/shellpath.go +++ b/internal/core/shellpath/shellpath.go @@ -309,10 +309,10 @@ func readFollowing(path string) ([]byte, bool) { } // addsDir reports whether raw already puts dir on the PATH: abcd's own marked -// line, or an uncommented line that names the PATH and the folder in any of -// the spellings a person writes it in. The check is textual and conservative: -// it can mistake a line for one that adds the folder, which costs the offer, -// never a second line. +// line for dir (MarkedLine, exactly), or an uncommented line that names the +// PATH and the folder in any of the spellings a person writes it in. The +// check is textual and conservative: it can mistake a line for one that adds +// the folder, which costs the offer, never a second line. func addsDir(raw []byte, home, dir string) bool { forms := []string{dir} if home != "" && within(home, dir) && dir != home { @@ -320,12 +320,15 @@ func addsDir(raw []byte, home, dir string) bool { rel = filepath.ToSlash(rel) forms = append(forms, "$HOME/"+rel, "${HOME}/"+rel, "~/"+rel) } + marked := MarkedLine(home, dir) for _, ln := range strings.Split(string(raw), "\n") { t := strings.TrimSpace(ln) if t == "" || strings.HasPrefix(t, "#") { continue } - if strings.HasSuffix(t, Marker) { + // abcd's own line for this folder, compared whole as Remove compares + // it: the marker on a line that adds another folder adds nothing. + if t == marked { return true } if !strings.Contains(strings.ToLower(t), "path") { diff --git a/internal/core/shellpath/shellpath_test.go b/internal/core/shellpath/shellpath_test.go index 4143bd7c19..eaa4ffc7d6 100644 --- a/internal/core/shellpath/shellpath_test.go +++ b/internal/core/shellpath/shellpath_test.go @@ -366,3 +366,20 @@ func TestApplyRefusesALinkSwappedInBeforeTheReplace(t *testing.T) { t.Errorf("the linked file changed: %q", got) } } + +// TestMarkedLineForAnotherFolderAddsNothing: abcd's marker on a line that adds +// some other folder is not a line that adds the folder asked about, so the +// plan still offers (the marked line is compared whole, as Remove compares it). +func TestMarkedLineForAnotherFolderAddsNothing(t *testing.T) { + e, dir := env(t, "/bin/zsh", "darwin") + other := `export PATH="$HOME/other/bin:$PATH" ` + Marker + write(t, filepath.Join(e.Home, ".zshrc"), other+"\n", 0o644) + if p := PlanFor(e, dir); p.State != Offer { + t.Errorf("plan = %+v, want an offer: the marked line adds another folder", p) + } + // abcd's own marked line for this folder is already added. + write(t, filepath.Join(e.Home, ".zshrc"), MarkedLine(e.Home, dir)+"\n", 0o644) + if p := PlanFor(e, dir); p.State != AlreadyAdded { + t.Errorf("plan = %+v, want already added", p) + } +} From 24896acfb5303043a596d560da328999eadf42ad Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:04:57 +0200 Subject: [PATCH 13/26] fix: use abcd's own tool copy only at a version abcd works with Plan returned UseOwned with Compatible true for any intact recorded copy, so a copy outside the range kept running and was never replaced. abcd's copy is now used only while its version is the pinned one; at another version in the range it still runs and the pin is offered as an upgrade that a no leaves alone, and outside the range it is not run and the pinned copy is offered over it. Install replaces a file only while it is still the bytes abcd recorded, and puts the earlier copy back if the new one fails its verify. Resolve runs the recorded copy only when its version is in range. Plan also gains PathOnly, which counts a copy on the PATH as usable without running its version command, for the detection pass the hooks run. Assisted-by: Claude:claude-opus-5-5 --- internal/core/tools/install.go | 104 +++++++++++++++++----- internal/core/tools/local.go | 16 +++- internal/core/tools/owned_version_test.go | 91 +++++++++++++++++++ 3 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 internal/core/tools/owned_version_test.go diff --git a/internal/core/tools/install.go b/internal/core/tools/install.go index 17a40046e8..d98738051a 100644 --- a/internal/core/tools/install.go +++ b/internal/core/tools/install.go @@ -131,6 +131,10 @@ type Installer struct { // Release is the pinned release for a registered tool; the registry's by // default, replaced in tests to point a download at a local server. Release func(name string) (Release, bool) + // PathOnly makes Plan start no program: a copy the PATH finds counts as + // usable without its version being read. The detection pass the hooks + // run sets it; an install never does. + PathOnly bool } // Default is the production installer for a verb run from guard (its @@ -230,7 +234,9 @@ type Copy struct { Compatible bool `json:"compatible"` // Why says why it is not compatible. Why string `json:"why,omitempty"` - abs string + // Unjudged reports that its version was not read (Installer.PathOnly). + Unjudged bool `json:"unjudged,omitempty"` + abs string } // Download is the checked download an install would make. @@ -268,13 +274,23 @@ type Plan struct { Owned *Copy `json:"owned,omitempty"` // Runs says which copy abcd runs, and which the person's shell runs. Runs string `json:"runs,omitempty"` + // Upgrade reports that the download replaces abcd's own copy, which + // still works, with the version abcd's release pins. + Upgrade bool `json:"upgrade,omitempty"` // Why says why the tool is unavailable. Why string `json:"why,omitempty"` } // Plan decides what to do about name. It never downloads, never writes, and // runs only the verify command of a copy PATH already finds, to read its -// version. +// version, and not even that under PathOnly. +// +// abcd's own recorded copy is used only while its version lies in the range +// abcd works with and is the one abcd's release pins. A copy of abcd's at +// another version in the range still runs, and the pin is offered as an +// upgrade over it; one outside the range is not run, and abcd's pinned copy +// is offered in its place. Either way the download replaces only a copy that +// is still the bytes abcd wrote (Install). func (in *Installer) Plan(name string) Plan { p := Plan{Tool: name} tool, ok := registry[name] @@ -288,15 +304,35 @@ func (in *Installer) Plan(name string) Plan { p.Pinned = rel.Version p.Compatible = rel.Compatible.String() if e, ok := owned(in.Home, name); ok { - p.Action = UseOwned - p.Owned = &Copy{Path: displayIn(in.Home, e.Path), Version: e.Version, Compatible: true, abs: e.Path} - p.Runs = "abcd runs its own copy at " + p.Owned.Path + " (" + e.Version + ")" - if shell := in.shellRuns(name, e.Path); shell != "" { - p.Runs += "; in your shell, " + name + " runs " + displayIn(in.Home, shell) + ", which comes first on your PATH" + c := Copy{Path: displayIn(in.Home, e.Path), Version: e.Version, Compatible: rel.Compatible.Contains(e.Version), abs: e.Path} + if !c.Compatible { + c.Why = "abcd's own copy is version " + e.Version + ", outside the versions abcd works with (" + p.Compatible + ")" + } + p.Owned = &c + if c.Compatible { + p.Runs = "abcd runs its own copy at " + c.Path + " (" + e.Version + ")" + if shell := in.shellRuns(name, e.Path); shell != "" { + p.Runs += "; in your shell, " + name + " runs " + displayIn(in.Home, shell) + ", which comes first on your PATH" + } + if e.Version == rel.Version { + p.Action = UseOwned + return p + } + p.Upgrade = true } - return p } - if prog, err := in.admit(name); err == nil { + if p.Owned != nil { + // abcd's own copy decides; another copy on the PATH is not judged. + } else if prog, err := in.admit(name); err == nil && in.PathOnly { + lex := prog + if l, err := in.LookPath(name); err == nil { + lex = l + } + p.Existing = &Copy{Path: displayIn(in.Home, lex), Unjudged: true, abs: prog} + p.Action = UseExisting + p.Runs = "abcd runs the copy already installed at " + p.Existing.Path + "; abcd ahoy install checks its version" + return p + } else if err == nil { c := in.judge(tool, rel, prog) if lex, err := in.LookPath(name); err == nil { c.Path = displayIn(in.Home, lex) // the spelling the person's PATH gives @@ -326,14 +362,19 @@ func (in *Installer) Plan(name string) Plan { Version: rel.Version, URL: asset.URL, SHA256: asset.SHA256, Member: asset.Member, Dest: displayIn(in.Home, dest), Checks: rel.Checks(), ChecksumOnly: rel.Signer == nil, dest: dest, } - if exists(dest) { - // Not abcd's: Owned found no intact recorded copy above. + if exists(dest) && (p.Owned == nil || p.Owned.abs != dest) { + // Not abcd's: Owned found no intact recorded copy there. p.Action = Unavailable p.Why = p.Download.Dest + " already holds a copy of " + name + " that abcd did not install, and abcd never changes another copy; " + "move or remove it yourself to let abcd install its own, or set XDG_BIN_HOME to another folder of yours" return p } p.Action = InstallOwn + if p.Upgrade { + // The copy that works keeps running unless the person says yes. + p.Runs += "; after the update abcd runs " + rel.Version + " at " + p.Download.Dest + return p + } p.Runs = "after the install abcd runs its own copy at " + p.Download.Dest + " whatever comes first on your PATH" if p.Existing != nil { if in.before(filepath.Dir(p.Existing.abs), dir) { @@ -498,18 +539,21 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm } dl := plan.Download r.pinned = dl.Version - if confirm == nil { - r.Declined = true - r.Why = "no one was asked" - return r + ans := Answer{Why: "no one was asked"} + if confirm != nil { + ans = confirm(e) } - ans := confirm(e) if !ans.Yes { r.Declined = true r.Why = ans.Why if r.Why == "" { r.Why = "the answer was no" } + if plan.Upgrade { + // abcd's own copy still works and keeps running. + r.Ready, r.Runs, r.OnDecline = true, plan.Runs, "" + r.Path, r.Version = plan.Owned.Path, plan.Owned.Version + } return r } if in.Guard == "" || !filepath.IsAbs(in.Guard) { @@ -558,9 +602,29 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm "you own, so set XDG_BIN_HOME to one" return r } + // The one file the download may replace is abcd's own recorded copy, + // judged again here: a copy that is no longer the bytes abcd wrote is + // someone else's, and is never changed. + var previous []byte if exists(dl.dest) { - r.Why = dl.Dest + " appeared while abcd was downloading, and abcd never changes a copy it did not install" - return r + own, mine := owned(in.Home, name) + if plan.Owned == nil || plan.Owned.abs != dl.dest || !mine || own.Path != dl.dest { + r.Why = dl.Dest + " appeared, or changed, while abcd was downloading, and abcd never changes a copy it did not install" + return r + } + if previous, err = os.ReadFile(dl.dest); err != nil { + r.Why = "could not keep abcd's copy at " + dl.Dest + " before replacing it (" + err.Error() + "); nothing was written" + return r + } + } + // putBack leaves the bin folder as it was: abcd's earlier copy where it + // replaced one, nothing where it wrote a new one. + putBack := func() { + if previous != nil { + _ = fsutil.WriteFileAtomic(dl.dest, previous, 0o755) + return + } + _ = os.Remove(dl.dest) } if err := fsutil.WriteFileAtomic(dl.dest, prog, 0o755); err != nil { r.Why = "could not write " + dl.Dest + " (" + err.Error() + "); abcd installs only into a folder you own, so set " + @@ -582,7 +646,7 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm } if r.Why != "" { r.Why += "; abcd removed the copy it wrote" - _ = os.Remove(dl.dest) + putBack() return r } progSum := sha256.Sum256(prog) @@ -593,7 +657,7 @@ func (in *Installer) Install(name string, capability Capability, confirm Confirm err = writeRecord(in.Home, rec) } if err != nil { - _ = os.Remove(dl.dest) + putBack() r.Why = "the copy works, but " + displayIn(in.Home, filepath.Join(in.Home, filepath.FromSlash(recordRel))) + " could not record it (" + err.Error() + "), so abcd could neither run it by its path nor remove it on uninstall; abcd removed it again" return r diff --git a/internal/core/tools/local.go b/internal/core/tools/local.go index e618d09120..6a960775c2 100644 --- a/internal/core/tools/local.go +++ b/internal/core/tools/local.go @@ -203,18 +203,26 @@ var userHome = os.UserHomeDir var pathLookup = exec.LookPath // Resolve is where abcd runs name from: abcd's own recorded copy when there is -// one and it is intact, whatever comes first on PATH (the intent's fourth -// criterion), and otherwise the PATH lookup. Every caller that runs a -// registered tool resolves it here, never by a bare exec.LookPath. +// one, it is intact and its version lies in the range abcd works with, +// whatever comes first on PATH (the intent's fourth criterion), and otherwise +// the PATH lookup. Every caller that runs a registered tool resolves it here, +// never by a bare exec.LookPath. func Resolve(name string) (string, error) { if home, err := userHome(); err == nil { - if e, ok := owned(home, name); ok { + if e, ok := owned(home, name); ok && ownedCompatible(name, e) { return e.Path, nil } } return pathLookup(name) } +// ownedCompatible reports whether abcd's recorded copy e of name is a version +// abcd works with, by the version the record names (nothing is run). +func ownedCompatible(name string, e Installed) bool { + rel, ok := registryRelease(name) + return ok && rel.Compatible.Contains(e.Version) +} + // Removal reports what uninstall did with one recorded copy. type Removal struct { Tool string `json:"tool"` diff --git a/internal/core/tools/owned_version_test.go b/internal/core/tools/owned_version_test.go new file mode 100644 index 0000000000..87d405e49f --- /dev/null +++ b/internal/core/tools/owned_version_test.go @@ -0,0 +1,91 @@ +package tools + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// ownCopy records a copy of gitleaks at the bin folder as abcd's, at version. +func ownCopy(t *testing.T, r *rig, version string) []byte { + t.Helper() + body := []byte("#!/bin/sh\necho " + version + "\n") + if err := os.MkdirAll(filepath.Dir(r.dest()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(r.dest(), body, 0o755); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + if err := writeRecord(r.home, Record{Tools: map[string]Installed{"gitleaks": {Path: r.dest(), + SHA256: hex.EncodeToString(sum[:]), Version: version}}}); err != nil { + t.Fatal(err) + } + return body +} + +// TestAnOwnedCopyOutsideTheRangeIsReplaced: abcd's own copy is used only while +// its version lies in the range abcd works with; outside it, abcd's pinned +// copy is offered over it, and on a yes replaces it. +func TestAnOwnedCopyOutsideTheRangeIsReplaced(t *testing.T) { + r := newRig(t) + ownCopy(t, r, "8.10.0") + in := r.installer() + if p := in.Plan("gitleaks"); p.Action != InstallOwn || p.Owned == nil || p.Owned.Compatible { + t.Fatalf("plan = %+v, want abcd's copy offered over its incompatible one", p) + } + withHome(t, r.home, func(string) (string, error) { return "", errNotFound }) + if got, err := Resolve("gitleaks"); err == nil { + t.Fatalf("Resolve ran abcd's incompatible copy %q", got) + } + res := in.Install("gitleaks", TranscriptScan, yes) + if !res.Ready || !res.Installed { + t.Fatalf("result = %+v", res) + } + rec, err := readRecord(r.home) + if err != nil || rec.Tools["gitleaks"].Version != "8.30.1" { + t.Fatalf("record = %+v, %v", rec, err) + } +} + +// TestAnOwnedCopyAtAnotherPinIsAnUpgrade: a compatible copy of abcd's at a +// version other than the pin is still run, and the pin is offered as an +// upgrade; a no keeps the copy that works. +func TestAnOwnedCopyAtAnotherPinIsAnUpgrade(t *testing.T) { + r := newRig(t) + body := ownCopy(t, r, "8.24.3") + in := r.installer() + if p := in.Plan("gitleaks"); p.Action != InstallOwn || p.Owned == nil || !p.Owned.Compatible { + t.Fatalf("plan = %+v, want the pin offered as an upgrade", p) + } + withHome(t, r.home, func(string) (string, error) { return "", errNotFound }) + if got, _ := Resolve("gitleaks"); got != r.dest() { + t.Fatalf("Resolve = %q, want abcd's compatible copy", got) + } + res := in.Install("gitleaks", TranscriptScan, no) + if !res.Ready || res.Ran { + t.Fatalf("a no to the upgrade left no copy ready: %+v", res) + } + if b, _ := os.ReadFile(r.dest()); string(b) != string(body) { + t.Fatal("a no changed the copy") + } +} + +// TestAForeignFileAtAnOwnedPathIsNeverReplaced: a record whose file is no +// longer abcd's bytes never licenses writing over it. +func TestAForeignFileAtAnOwnedPathIsNeverReplaced(t *testing.T) { + r := newRig(t) + ownCopy(t, r, "8.10.0") + if err := os.WriteFile(r.dest(), []byte("theirs now"), 0o755); err != nil { + t.Fatal(err) + } + res := r.installer().Install("gitleaks", TranscriptScan, yes) + if res.Installed || r.hits != 0 { + t.Fatalf("a changed file was replaced: %+v", res) + } + if b, _ := os.ReadFile(r.dest()); string(b) != "theirs now" { + t.Fatal("the file was changed") + } +} From ffa6f0ac4739cc4cd88997e1d117e324115dd6ad Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:07:37 +0200 Subject: [PATCH 14/26] fix: make setup's report, plan and questions say what the run does Review findings on the setup redesign: - A change the person said yes to that then fails (a tool download that fails its sha256 or signed build record, the abcd download that does not verify, a shell-settings edit, including one abcd could not put back) is now a needs-you step in the default report with its reason and the step by hand, not only a note behind --details. - The path.bin_dir_not_on_path step follows the shell-settings plan: once the startup file adds the folder it is to open a new terminal, where abcd will not edit the file it is the export line with the reason, and only where setup will ask does it name the question. - The shell question's one line names the line and the file; ? says the folder goes ahead of the rest of the PATH. - The plan lists a tool only where abcd offers its own copy and the shell question only where the run leaves an entry, and the run asks in the plan's order: download, shell settings, status line, identity, tools. At a terminal no dependency approval is asked, so customising never drops a tool's own question. - A required tool's step no longer calls it optional. - A bare Enter at the stdin door's plan screen asks again. - Detect, which the hooks run, starts no tool; install, the dry run and doctor judge a copy's version. Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/abcd_download.go | 17 +- internal/core/ahoy/apply.go | 57 ++- internal/core/ahoy/detect.go | 65 +++- internal/core/ahoy/onpath_checkout_test.go | 4 +- internal/core/ahoy/person_only.go | 30 +- internal/core/ahoy/prompt_order_test.go | 10 +- internal/core/ahoy/setup_plan.go | 4 +- internal/core/ahoy/setup_report.go | 14 +- internal/core/ahoy/setup_review_test.go | 350 ++++++++++++++++++ internal/core/ahoy/shell_path.go | 29 +- internal/core/ahoy/tools_route_test.go | 14 +- internal/core/shellpath/shellpath.go | 18 +- internal/surface/cli/cli.go | 5 +- internal/surface/cli/setup_plan_enter_test.go | 30 ++ 14 files changed, 598 insertions(+), 49 deletions(-) create mode 100644 internal/core/ahoy/setup_review_test.go create mode 100644 internal/surface/cli/setup_plan_enter_test.go diff --git a/internal/core/ahoy/abcd_download.go b/internal/core/ahoy/abcd_download.go index 7722d30092..13c7563a1c 100644 --- a/internal/core/ahoy/abcd_download.go +++ b/internal/core/ahoy/abcd_download.go @@ -130,29 +130,36 @@ func (a *applyCtx) stepDownload() { a.refuse(did + ": the download of abcd " + tag + " was declined; the README's install line puts it there by hand") return } + // Past the yes, every way of writing nothing is a step left for the + // person in the default report, with why, not only a note. + failed := func(why string) { + a.refuse(did + ": " + why) + a.needYou("Put abcd on your PATH with the README's install line, then run abcd ahoy install again", + "you said yes to the download of abcd "+tag+", and "+why+".") + } rel, err := a.opts.DownloadAbcd(tag) if err != nil { - a.refuse(did + ": the download of abcd " + tag + " failed or did not verify, so nothing was written: " + errText(err)) + failed("the download of abcd " + tag + " failed or did not verify, so nothing was written: " + errText(err)) return } sum := sha256.Sum256(rel.Bytes) got := hex.EncodeToString(sum[:]) if rel.SHA256 != "" && got != strings.ToLower(rel.SHA256) { - a.refuse(did + ": the downloaded abcd " + tag + " does not hash to the checksum it was verified against, so nothing was written") + failed("the downloaded abcd " + tag + " does not hash to the checksum it was verified against, so nothing was written") return } if err := os.MkdirAll(filepath.Dir(a.binTarget), 0o755); err != nil { - a.refuse(did + ": " + displayPath(filepath.Dir(a.binTarget)) + " could not be created: " + errText(err)) + failed(displayPath(filepath.Dir(a.binTarget)) + " could not be created: " + errText(err)) return } // Re-checked at the write: an entry that appeared while the question was // open is never replaced. if classifyBinTarget(a.binTarget, a.det.pluginRoot) != binTargetAbsent { - a.refuse(did + ": " + displayPath(a.binTarget) + " appeared while the question was open, and it is left as it is") + failed(displayPath(a.binTarget) + " appeared while the question was open, and it is left as it is") return } if err := fsutil.WriteFileAtomic(a.binTarget, rel.Bytes, 0o755); err != nil { - a.refuse(did + ": " + displayPath(a.binTarget) + " could not be written: " + errText(err)) + failed(displayPath(a.binTarget) + " could not be written: " + errText(err)) return } a.verifiedCopy = a.binTarget diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 3a33a01fd2..4b4fd03592 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -53,7 +53,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er if !gitInstalled() { return InstallResult{Status: "refused", Notes: []string{missingGitStop()}}, nil } - det, err := Detect(abs) + det, err := detectJudged(abs) if err != nil { return InstallResult{}, err } @@ -298,7 +298,6 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er } // Ordered apply steps. - ac.stepDependencies() ac.stepSkeleton() cfg := ac.stepConfigValues() ac.stepVisibility(cfg) @@ -355,6 +354,10 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er ac.stepIdentityMigrate() ac.stepIdentityRecord() ac.stepGitIdentity() + // The tools last of the questions only a person answers, in the order the + // plan lists them (personOnlyAsks): nothing above runs a tool, and the + // final detection below judges the copy this step leaves. + ac.stepDependencies() // After every step that asks its questions first: the kind is the last // answer a piped install gives (itd-2609150819432059). ac.stepArtefact() @@ -362,7 +365,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er ac.noteReachability() // Re-detect to compute what remains. - final, err := Detect(abs) + final, err := detectJudged(abs) if err != nil { return InstallResult{}, err } @@ -397,7 +400,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er writeKinds: ac.writeKinds, unverifiedCheck: ac.unverifiedCheck, unverifiedWhy: ac.unverifiedWhy, - needs: remainingSteps(abs, final.Gaps), + needs: append(ac.needs, remainingSteps(abs, final.Gaps)...), }, nil } @@ -540,6 +543,10 @@ type applyCtx struct { shellAdded bool // stepShellPath added the PATH line this run opts InstallOptions // the run's options, as the plan left them downloaded bool // stepDownload wrote the verified release to the PATH target + // needs is what a step the person approved left for them when it failed: + // the step to take by hand and why, reported in the default report ahead + // of the gaps the run left (itd-2610240918594138, criterion 2). + needs []ReportStep } // note is the receipt seam; it lives in receipt.go with the path scrub it @@ -557,6 +564,13 @@ func (a *applyCtx) refuse(reason string) { a.notes = append(a.notes, reason) } // separate call so a reader of the code never mistakes a success for a refusal. func (a *applyCtx) inform(line string) { a.notes = append(a.notes, line) } +// needYou records what a change the person said yes to, and that then failed, +// leaves them to do: the exact step, and why. A failure of an approved change +// is never only a note behind --details. +func (a *applyCtx) needYou(step, why string) { + a.needs = append(a.needs, ReportStep{Step: step, Why: why}) +} + // refuseMalformedConfig records, once per run, that .abcd/config.json could not // be parsed and that no step will touch it. Three steps read the file // (stepConfigValues, stepMarker, stepVersionStamp) and each must fail safe on @@ -620,14 +634,26 @@ func (a *applyCtx) has(id string) bool { return a.gapPresent[id] } // asked. The category approval gates reaching this step at all; it never // stands in for the per-tool answer. func (a *applyCtx) stepDependencies() { - if !a.approved[Dependency] { + if !a.approved[Dependency] && !a.asksToolsItself() { return } for _, g := range a.det.Gaps { if g.Category != Dependency || g.Tool == nil { continue } - res := newToolInstaller(a.cwd).Install(g.Tool.Tool, g.Tool.Capability, a.confirmTool) + // Whether the person said yes, so a download that then fails is a + // step left for them rather than a note. + said := false + confirm := a.confirmTool + if confirm != nil { + inner := confirm + confirm = func(e tools.Explanation) tools.Answer { + ans := inner(e) + said = ans.Yes + return ans + } + } + res := newToolInstaller(a.cwd).Install(g.Tool.Tool, g.Tool.Capability, confirm) if res.Ready && !res.Ran { continue } @@ -641,9 +667,19 @@ func (a *applyCtx) stepDependencies() { } } a.refuse(receiptPath(a.cwd, "dependency: "+res.Summary())) + if said { + a.needYou("Install "+g.Tool.Tool+" yourself: "+g.Tool.StepText(), + receiptPath(a.cwd, "abcd's download of "+g.Tool.Tool+" after your yes did not install: "+strings.TrimSuffix(res.Why, ".")+".")) + } } } +// asksToolsItself reports whether each tool's question reaches a person at a +// terminal, which makes it its own consent: the kind-of-change approval is +// then not asked for the tools (resolveApproval), and no answer to another +// approval drops a tool's question (itd-2610240823055889, criterion 3). +func (a *applyCtx) asksToolsItself() bool { return !a.autoYes && atTerminal(a.prompter) } + // newToolInstaller is the tool installer seam, guarded on the repository the // install runs in; a test swaps it for one that records instead of executing. var newToolInstaller = tools.Default @@ -2341,7 +2377,7 @@ func uninstallTools() ([]tools.Removal, string) { // Doctor runs the detection pass plus a read-only cross-machine audit. Zero // writes. func Doctor(cwd string) (DoctorReport, error) { - det, err := Detect(cwd) + det, err := detectJudged(cwd) if err != nil { return DoctorReport{}, err } @@ -2607,6 +2643,13 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr for _, c := range terminalOnlyCategories { delete(present, c) } + } else { + // At a terminal each tool is its own question (stepDependencies, + // asksToolsItself), so the tools are never put behind an approval + // whose no would drop their questions (itd-2610240823055889, + // criterion 3). A piped stream still meets the approval, in its + // fixed place, where no tool question can reach a person anyway. + delete(present, Dependency) } for _, c := range presentInPromptOrder(present) { if c == Dependency && opts.ApproveDependency { diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index ac2e53af0e..f00a68d74b 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -14,6 +14,7 @@ import ( "github.com/intentdriven/abcd/internal/core/identity" "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/provenance" + "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/core/tools" ) @@ -49,7 +50,18 @@ const ( // Detect runs the full detection pass over cwd and returns the canonical // envelope. Total over a normal folder: broken-plugin conditions surface as // gaps, never as a hard error. An error is returned only for malformed input. -func Detect(cwd string) (DetectionResult, error) { +// +// It is the pass the hooks and the status board run, so it starts no tool: a +// tool's copy found on the PATH counts as installed, and its version is +// judged only by the passes a person starts (detectJudged). +func Detect(cwd string) (DetectionResult, error) { return detect(cwd, false) } + +// detectJudged is Detect with each tool's copy held to the versions abcd +// works with, by running its version command: the pass install and the bare +// `abcd ahoy` run, never a hook. +func detectJudged(cwd string) (DetectionResult, error) { return detect(cwd, true) } + +func detect(cwd string, judgeTools bool) (DetectionResult, error) { abs, err := filepath.Abs(cwd) if err != nil { return DetectionResult{}, err @@ -109,7 +121,7 @@ func Detect(cwd string) (DetectionResult, error) { // machine-scope fact, reported from any folder. gaps = append(gaps, detectFilterRoots()...) if kind != UnmanagedFolder { - gaps = append(gaps, detectDependencies(abs)...) + gaps = append(gaps, detectDependencies(abs, judgeTools)...) gaps = append(gaps, detectSkeleton(abs)...) gaps = append(gaps, detectLocalTier(abs)...) gaps = append(gaps, detectCommittedTiers(abs)...) @@ -167,7 +179,7 @@ func Detect(cwd string) (DetectionResult, error) { // (readmeView), which the plugin page relays the question pair from; Detect // alone, which the hooks and the status board call, derives no part. func DryRun(cwd string) (DetectionResult, error) { - det, err := Detect(cwd) + det, err := detectJudged(cwd) if err != nil || !hasGapID(det.Gaps, ReadmeOfferGapID) { return det, err } @@ -276,7 +288,11 @@ var DependencyTools = []string{"gitleaks"} // trufflehog is not offered: nothing in abcd runs it (iss-2609261447331434), // and a gap asking a person to install a program abcd never uses is not an // explanation anyone can act on. -func detectDependencies(cwd string) []Gap { +// +// judge is whether a copy found on the PATH is held to that range by running +// its version command: true for the passes a person starts, false for the +// pass the hooks run, where a copy on the PATH counts as installed. +func detectDependencies(cwd string, judge bool) []Gap { capability := tools.TranscriptScan if cfg, err := gitleaks.LoadConfig(cwd); err == nil && cfg.Enabled { capability = tools.TranscriptScanArmed @@ -288,7 +304,9 @@ func detectDependencies(cwd string) []Gap { if err != nil { guard = cwd } - plan := newToolInstaller(guard).Plan("gitleaks") + in := newToolInstaller(guard) + in.PathOnly = !judge + plan := in.Plan("gitleaks") if plan.Action == tools.UseOwned || plan.Action == tools.UseExisting { return nil } @@ -303,7 +321,18 @@ func detectDependencies(cwd string) []Gap { Resolvable: true, Tool: &e, } - if plan.Existing != nil { + switch { + case plan.Upgrade: + // abcd's own copy works; the pin is offered over it, never required. + g.ID = "deps.gitleaks_upgrade" + g.Title = "abcd's copy of gitleaks is " + plan.Owned.Version + "; abcd's release pins " + plan.Pinned + g.Detail = plan.Owned.Path + " still works and keeps running unless you say yes; " + g.Detail + g.Required = false + case plan.Owned != nil: + g.ID = "deps.gitleaks_incompatible" + g.Title = "abcd's copy of gitleaks is not a version abcd works with" + g.Detail = plan.Owned.Path + ": " + plan.Owned.Why + "; " + g.Detail + case plan.Existing != nil: g.ID = "deps.gitleaks_incompatible" g.Title = "the gitleaks on PATH is not a version abcd works with" g.Detail = plan.Existing.Path + ": " + plan.Existing.Why + "; " + g.Detail @@ -1024,13 +1053,31 @@ func detectBinDirOnPath(dir string, installed bool) []Gap { if !installed { return nil } - return append([]Gap{{ + g := Gap{ ID: "path.bin_dir_not_on_path", Category: ConfigChange, Scope: "machine", Title: displayPath(dir) + " is not on PATH", Detail: "abcd is installed at " + displayPath(filepath.Join(dir, binName)) + ". " + pathReachMessage(dir), - FixHint: "Run `abcd ahoy install` at a terminal, which offers to add this line to your shell settings, or add it yourself: " + exportPathLine(dir), Required: true, Resolvable: false, - }}, detectShellPath(dir)...) + } + // The step is what is true of the person's shell settings, as the + // shell-settings plan reads them: never an offer setup will not make, and + // never the line again once the settings hold it. + switch plan := shellpath.PlanFor(shellEnv(), dir); plan.State { + case shellpath.AlreadyAdded: + file := displayPath(plan.File) + g.Detail = "Your shell reads " + file + " when it starts, and " + file + " already puts " + displayPath(dir) + + " on the PATH; this terminal started before it did, so `abcd` cannot be run by name in it yet." + g.FixHint = "Open a new terminal, then run `abcd` there." + case shellpath.Offer: + g.FixHint = "Run `abcd ahoy install` at a terminal, which offers to add this line to " + displayPath(plan.File) + + ", or add it yourself: " + plan.Export + default: + g.FixHint = "Add this line to your shell settings yourself: " + plan.Export + if plan.Reason != "" { + g.FixHint += " (abcd does not add it: " + displayText(plan.Reason) + ")" + } + } + return append([]Gap{g}, detectShellPath(dir)...) } // detectInstallMode reports the current PATH-target install mode: "dev (tip diff --git a/internal/core/ahoy/onpath_checkout_test.go b/internal/core/ahoy/onpath_checkout_test.go index 9afad38670..a5d9e57fe6 100644 --- a/internal/core/ahoy/onpath_checkout_test.go +++ b/internal/core/ahoy/onpath_checkout_test.go @@ -52,7 +52,7 @@ func TestAProgramInsideTheCheckoutIsNotInstalled(t *testing.T) { if onPath(repo, "gitleaks") { t.Error("onPath counts a gitleaks inside the checkout as installed") } - depGap(t, detectDependencies(repo), "deps.gitleaks_missing") + depGap(t, detectDependencies(repo, true), "deps.gitleaks_missing") }) } @@ -61,7 +61,7 @@ func TestAProgramInsideTheCheckoutIsNotInstalled(t *testing.T) { if !onPath(repo, "gitleaks") { t.Fatal("onPath does not count a gitleaks outside the checkout") } - for _, g := range detectDependencies(repo) { + for _, g := range detectDependencies(repo, true) { if g.ID == "deps.gitleaks_missing" { t.Fatalf("a gitleaks outside the checkout is reported missing: %+v", g) } diff --git a/internal/core/ahoy/person_only.go b/internal/core/ahoy/person_only.go index f6503ba445..163fbe33f0 100644 --- a/internal/core/ahoy/person_only.go +++ b/internal/core/ahoy/person_only.go @@ -5,6 +5,8 @@ import ( "slices" "github.com/intentdriven/abcd/internal/core/shellpath" + "github.com/intentdriven/abcd/internal/core/tools" + "github.com/intentdriven/abcd/internal/fsutil" ) // The steps only a person can approve (itd-2610240823055889, criterion 3): @@ -77,15 +79,20 @@ func approvalGaps(cwd string, det DetectionResult, opts InstallOptions, target s // personOnlyAsks is the plan's list of the questions that follow whatever the // answer to it, in the order the run asks them: the abcd download, the shell -// settings, the status line, the commit identity, and one question for each -// missing tool. +// settings, the status line, the commit identity, and one +// question for each tool abcd offers to install. Each is listed only where +// the run will put it. func personOnlyAsks(cwd string, det DetectionResult, opts InstallOptions, target string) []string { var out []string tag, dl := downloadOffer(cwd, det, opts, target) if dl { out = append(out, "the abcd download: abcd "+tag+" to "+displayPath(target)) } - out = append(out, shellSettingsAsks(cwd, det, target)...) + // The shell question judges the entry the run leaves, so it is listed + // only where there will be one. + if entryWillExist(cwd, det, target, dl) { + out = append(out, shellSettingsAsks(cwd, det, target)...) + } present := gapIDSet(det.Gaps) if present[StatusLineOfferGapID] && statusLineReachable(cwd, det, dl) { out = append(out, "the status line: one entry in your assistant's settings, "+displayPath(harnessSettingsPath())) @@ -102,10 +109,25 @@ func personOnlyAsks(cwd string, det DetectionResult, opts InstallOptions, target break } } + // A tool is asked about only where abcd offers its own copy: a copy it + // will use, or a tool it cannot install here, asks nothing. for _, g := range det.Gaps { - if g.Category == Dependency && g.Tool != nil && !onPath(cwd, g.Tool.Tool) { + if g.Category == Dependency && g.Tool != nil && g.Resolvable && g.Tool.Plan != nil && g.Tool.Plan.Action == tools.InstallOwn { out = append(out, "installing "+g.Tool.Tool+", a tool abcd uses") } } return out } + +// entryWillExist reports whether the run leaves abcd's PATH entry on disk: +// it is there now, the download question can put it there, or the plugin's +// verified cache supplies the copy. +func entryWillExist(cwd string, det DetectionResult, target string, downloadOffered bool) bool { + if target == "" { + return false + } + if present, err := fsutil.ExistsNoFollow(target); err == nil && present { + return true + } + return downloadOffered || (det.pluginRoot != "" && cachePresent(pluginDataDir(det.pluginRoot).dir, cwd)) +} diff --git a/internal/core/ahoy/prompt_order_test.go b/internal/core/ahoy/prompt_order_test.go index 5d4815f1d0..58c43ad75f 100644 --- a/internal/core/ahoy/prompt_order_test.go +++ b/internal/core/ahoy/prompt_order_test.go @@ -80,17 +80,21 @@ func TestResolveApprovalPromptsInCanonicalOrder(t *testing.T) { "user-state", "plugin-owned", } + // At a terminal each tool is its own question, so the dependency + // approval is not asked there (asksToolsItself). + atTerminal := want[1:] for i := 0; i < 64; i++ { p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(allCategoryGaps(), InstallOptions{}, nil, p) - if askedCategories(p.asked) != strings.Join(want, "|") { - t.Fatalf("run %d asked in a different order:\n got %v\nwant %v", i, askedCategories(p.asked), want) + if askedCategories(p.asked) != strings.Join(atTerminal, "|") { + t.Fatalf("run %d asked in a different order:\n got %v\nwant %v", i, askedCategories(p.asked), atTerminal) } } // Off a terminal the drain-rule and conventions-file questions are not // asked (stepDrainRule, stepConventionsFiles), and not counted as // declined: the rest keep their order, so a piped stream written before - // the offers existed still lines up. + // the offers existed still lines up. The dependency approval keeps its + // first place there. offTerminal := make([]string, 0, len(want)) for _, q := range want { if q != string(DrainRule) && q != string(ConventionsFile) { diff --git a/internal/core/ahoy/setup_plan.go b/internal/core/ahoy/setup_plan.go index 83d057fd67..d93a6db3d8 100644 --- a/internal/core/ahoy/setup_plan.go +++ b/internal/core/ahoy/setup_plan.go @@ -127,7 +127,9 @@ func buildSetupPlan(det DetectionResult, asksYou []string) SetupPlan { for _, scope := range []string{"repo", "machine", "user"} { var items []string for _, g := range det.Gaps { - if g.Scope != scope || !g.Resolvable || personOnlyGap(g.ID) || offers[g.ID] || g.Category == Readme { + // A tool is never one of the changes: it is installed only on + // its own question, which the plan lists among the questions. + if g.Scope != scope || !g.Resolvable || personOnlyGap(g.ID) || offers[g.ID] || g.Category == Readme || g.Category == Dependency { continue } if t := strings.Join(strings.Fields(g.Title), " "); t != "" && !slices.Contains(items, t) { diff --git a/internal/core/ahoy/setup_report.go b/internal/core/ahoy/setup_report.go index a81aef5222..a03547e10f 100644 --- a/internal/core/ahoy/setup_report.go +++ b/internal/core/ahoy/setup_report.go @@ -158,12 +158,20 @@ func remainingSteps(cwd string, gaps []Gap) []ReportStep { switch { case g.Category == Dependency && g.Tool != nil: // An optional tool is offered at a terminal, one question each, - // and is not a step the person owes. - if !g.Required || onPath(cwd, g.Tool.Tool) { + // and is not a step the person owes. The gap is the final + // detection's, so a required tool it still names is not usable. + if !g.Required { continue } + why := g.Tool.Tool + " is required here" + if g.Tool.CapabilityName != "" { + why = g.Tool.Tool + " is required for " + g.Tool.CapabilityName + } + if g.Tool.WithoutIt != "" { + why += "; without it " + g.Tool.WithoutIt + } out = append(out, ReportStep{Step: "Install " + g.Tool.Tool + " yourself: " + g.Tool.StepText(), - Why: g.Tool.Tool + " is optional; " + strings.TrimSuffix(g.Title, ".") + "."}) + Why: strings.TrimSuffix(why, ".") + "."}) case g.Required && g.FixHint != "" && !optionalGapID(g.ID): out = append(out, ReportStep{Step: g.FixHint, Why: strings.TrimSuffix(reportFirstSentence(g.Detail, g.Title), ".") + "."}) } diff --git a/internal/core/ahoy/setup_review_test.go b/internal/core/ahoy/setup_review_test.go new file mode 100644 index 0000000000..bf21eeb56a --- /dev/null +++ b/internal/core/ahoy/setup_review_test.go @@ -0,0 +1,350 @@ +package ahoy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/shellpath" + "github.com/intentdriven/abcd/internal/core/tools" +) + +// The review findings on the setup redesign (itd-2610240823055889, +// itd-2610240918594138, itd-2610240919030303): what a person approved and +// what then failed is a step in the default report, the report after a yes to +// the shell settings says what is true, and the plan screen lists exactly the +// questions the run asks, in the order it asks them. + +// needsJoined is the needs-you checklist as one text, step and why. +func needsJoined(rep SetupReport) string { + var b strings.Builder + for _, s := range rep.NeedsYou { + b.WriteString(s.Step + " | " + s.Why + "\n") + } + return b.String() +} + +// wrongPin makes the fake release's pinned sha256 one the download does not +// hash to. +func wrongPin(t *testing.T) { + t.Helper() + inner := newToolInstaller + newToolInstaller = func(guard string) *tools.Installer { + in := inner(guard) + rel := in.Release + in.Release = func(n string) (tools.Release, bool) { + r, ok := rel(n) + for k, a := range r.Assets { + a.SHA256 = strings.Repeat("0", 64) + r.Assets[k] = a + } + return r, ok + } + return in + } + t.Cleanup(func() { newToolInstaller = inner }) +} + +// TestAFailedToolDownloadIsANeedsYouStep is criterion 2 of +// itd-2610240918594138 ("setup says why") in the default report: a yes to +// the tool question whose download fails its sha256 is a needs-you step +// carrying the reason, not only a note behind --details. +func TestAFailedToolDownloadIsANeedsYouStep(t *testing.T) { + setupHermetic(t) + emptyPath(t) + var calls [][]string + fakeToolRun(t, &calls) + wrongPin(t) + opts := installOpts() + opts.ConfirmTool = func(tools.Explanation) tools.Answer { return tools.Answer{Yes: true, Why: "typed yes"} } + res, err := Install(gitRepo(t), opts, RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + needs := needsJoined(res.Report) + if !strings.Contains(needs, "gitleaks") || !strings.Contains(needs, "sha256") { + t.Fatalf("the failed download is not a needs-you step with its reason:\n%s\nnotes %q", needs, res.Notes) + } +} + +// TestAFailedAbcdDownloadIsANeedsYouStep: a yes to the abcd download that +// does not verify is a needs-you step naming the by-hand way and the reason. +func TestAFailedAbcdDownloadIsANeedsYouStep(t *testing.T) { + defer swapDownloadTag(func(string) string { return "v9.9.9" })() + setupUserScope(t, gitOnlyDir(t)) + t.Setenv("ABCD_PLUGIN_ROOT", "") + harnessFixture(t, harnessSettingsWith("")) + opts := planOpts() + opts.DownloadAbcd = func(string) (DownloadedRelease, error) { + return DownloadedRelease{}, errors.New("its build record does not check out") + } + p := &planPrompter{choice: PlanProceed, confirm: isDownloadQuestion} + res, err := Install(gitRepo(t), opts, p) + if err != nil { + t.Fatal(err) + } + needs := needsJoined(res.Report) + if !strings.Contains(needs, "README") || !strings.Contains(needs, "its build record does not check out") { + t.Fatalf("the failed download is not a needs-you step with its reason:\n%s\nnotes %q", needs, res.Notes) + } +} + +// TestAFailedShellEditIsANeedsYouStep: a yes to the shell settings whose edit +// could not be put back is a needs-you step carrying the by-hand copy. +func TestAFailedShellEditIsANeedsYouStep(t *testing.T) { + prev := shellApply + shellApply = func(shellpath.Env, shellpath.Plan) (shellpath.Applied, error) { + return shellpath.Applied{}, &shellpath.RestoreError{File: "~/.zshrc", Backup: "~/.abcd.noindex/backups/shell-zshrc.20261024T000000Z", Cause: "disk full"} + } + t.Cleanup(func() { shellApply = prev }) + _, res, _ := shellPathRun(t, true, true, interactiveOpts()) + needs := needsJoined(res.Report) + if !strings.Contains(needs, "~/.abcd.noindex/backups/shell-zshrc.20261024T000000Z") || !strings.Contains(needs, "~/.zshrc") { + t.Fatalf("the failed edit is not a needs-you step with the copy to put back:\n%s\nnotes %q", needs, res.Notes) + } +} + +// TestAfterAYesTheReportSaysOpenANewTerminal: once the line is in the +// startup file the step left is a new terminal, never adding the line again +// or an offer setup will not make. +func TestAfterAYesTheReportSaysOpenANewTerminal(t *testing.T) { + _, res, _ := shellPathRun(t, true, true, interactiveOpts()) + needs := needsJoined(res.Report) + if !strings.Contains(strings.ToLower(needs), "open a new terminal") { + t.Errorf("the report does not say to open a new terminal:\n%s", needs) + } + for _, bad := range []string{"offers to add", "add it yourself", "Add it to your shell settings"} { + if strings.Contains(needs, bad) { + t.Errorf("the report after a yes still says %q:\n%s", bad, needs) + } + } +} + +// TestAFileSetupWillNotEditIsNeverOffered: the PATH gap's fix hint for a +// startup file setup does not edit gives the line and the reason, and never +// says setup offers to add it. +func TestAFileSetupWillNotEditIsNeverOffered(t *testing.T) { + home, pluginRoot := setupUserScope(t, filepath.Join(t.TempDir(), "elsewhere")) + zshUser(t) + kept := filepath.Join(t.TempDir(), "zshrc") + if err := os.WriteFile(kept, []byte("alias g=git\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(kept, filepath.Join(home, ".zshrc")); err != nil { + t.Fatal(err) + } + linkOwned(t, filepath.Join(home, ".local", "bin", "abcd"), pluginRoot) + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + g := gapByID(det.Gaps, "path.bin_dir_not_on_path") + if g == nil { + t.Fatalf("no PATH gap: %+v", det.Gaps) + } + if strings.Contains(g.FixHint, "offers") || !strings.Contains(g.FixHint, `export PATH="$HOME/.local/bin:$PATH"`) || + !strings.Contains(g.FixHint, "link") { + t.Errorf("fix hint = %q, want the line and why setup does not add it", g.FixHint) + } +} + +// TestTheShellQuestionNamesTheLineAndTheFile is the one-line form of the +// shell question: it names the line and the file, and ? shows that the folder +// goes ahead of the rest of the PATH. +func TestTheShellQuestionNamesTheLineAndTheFile(t *testing.T) { + _, _, asked := shellPathRun(t, false, true, interactiveOpts()) + qs := shellPathAsked(asked) + if len(qs) != 1 { + t.Fatalf("asked %q", asked) + } + full := SetupConfirmQuestion(1, qs[0]) + if full.ID != ShellPathOfferGapID { + t.Fatalf("the question is not id'd as the shell offer: %q", full.ID) + } + brief := BriefQuestion(full) + for _, want := range []string{`export PATH="$HOME/.local/bin:$PATH"`, "~/.zshrc"} { + if !strings.Contains(brief.Ask, want) { + t.Errorf("the one line %q does not name %q", brief.Ask, want) + } + } + var material strings.Builder + for _, b := range full.Material { + material.WriteString(b.Text + strings.Join(b.Items, "\n") + "\n") + } + if !strings.Contains(material.String(), "ahead of") { + t.Errorf("the full question does not say the folder goes ahead of the rest of the PATH:\n%s", material.String()) + } +} + +// TestRequiredToolStepIsNotCalledOptional: a required tool's needs-you step +// never says it is optional. +func TestRequiredToolStepIsNotCalledOptional(t *testing.T) { + setupHermetic(t) + emptyPath(t) + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".abcd", "config", "gitleaks.json"), + []byte(`{"schema_version":1,"enabled":true}`), 0o644); err != nil { + t.Fatal(err) + } + steps := remainingSteps(repo, detectDependencies(repo, true)) + if len(steps) != 1 { + t.Fatalf("steps = %+v", steps) + } + if strings.Contains(steps[0].Why, "optional") || !strings.Contains(steps[0].Why, "required") { + t.Errorf("a required tool's step reads %q", steps[0].Why) + } +} + +// countingInstaller is fakeToolRun's installer with a gitleaks on its PATH +// whose version run is counted. +func countingInstaller(t *testing.T, version string) *int { + t.Helper() + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "gitleaks"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + runs := 0 + prev := newToolInstaller + newToolInstaller = func(guard string) *tools.Installer { + in := tools.Default(guard) + in.Home = t.TempDir() + in.LookPath = func(n string) (string, error) { + if p := filepath.Join(bin, n); fileExists(p) { + return p, nil + } + return "", errors.New("absent") + } + in.Getenv = func(k string) string { + if k == "PATH" { + return bin + } + return "" + } + in.Run = func(context.Context, []string) ([]byte, error) { runs++; return []byte(version + "\n"), nil } + return in + } + t.Cleanup(func() { newToolInstaller = prev }) + return &runs +} + +// TestDetectionNeverRunsTheTool: the detection pass the hooks and the status +// board run reads the PATH and the record alone; a copy's version is judged +// only by the install. +func TestDetectionNeverRunsTheTool(t *testing.T) { + setupHermetic(t) + runs := countingInstaller(t, "8.10.0") + if _, err := Detect(managedRepo(t)); err != nil { + t.Fatal(err) + } + if *runs != 0 { + t.Fatalf("detection ran the tool %d times", *runs) + } + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if *runs == 0 { + t.Fatal("the install never judged the copy's version") + } + if !strings.Contains(notesJoined(res.Notes), "8.10.0") { + t.Errorf("the install does not name the incompatible copy: %q", res.Notes) + } +} + +// TestThePlanListsAToolOnlyWhenItWillAsk: a tool whose copy abcd will use, or +// cannot install, is not listed among the questions; one it offers is. +func TestThePlanListsAToolOnlyWhenItWillAsk(t *testing.T) { + setupHermetic(t) + cwd := t.TempDir() + tool := func(action tools.PlanAction, resolvable bool) Gap { + e := tools.Explain("gitleaks", tools.TranscriptScan) + e.Plan = &tools.Plan{Tool: "gitleaks", Action: action} + return Gap{ID: "deps.gitleaks_missing", Category: Dependency, Resolvable: resolvable, Tool: &e} + } + for _, tc := range []struct { + gap Gap + want bool + }{ + {tool(tools.InstallOwn, true), true}, + {tool(tools.Unavailable, false), false}, + {tool(tools.UseExisting, true), false}, + } { + asks := personOnlyAsks(cwd, DetectionResult{Gaps: []Gap{tc.gap}}, InstallOptions{}, "") + listed := slices.ContainsFunc(asks, func(s string) bool { return strings.Contains(s, "gitleaks") }) + if listed != tc.want { + t.Errorf("%s (resolvable %v): listed %v, want %v: %q", tc.gap.Tool.Plan.Action, tc.gap.Resolvable, listed, tc.want, asks) + } + } +} + +// TestCustomiseStillAsksTheToolQuestion is criterion 3: a download is its own +// question, so customising the plan never drops it behind an approval. +func TestCustomiseStillAsksTheToolQuestion(t *testing.T) { + setupHermetic(t) + emptyPath(t) + var calls [][]string + fakeToolRun(t, &calls) + asked := 0 + opts := planOpts() + opts.ConfirmTool = func(tools.Explanation) tools.Answer { asked++; return tools.Answer{Why: "answered no"} } + p := &planPrompter{choice: PlanCustomise, confirm: func(q string) bool { + c := askedCategory(q) + return c != "" && c != Dependency + }} + if _, err := Install(gitRepo(t), opts, p); err != nil { + t.Fatal(err) + } + if asked != 1 { + t.Fatalf("the tool question was asked %d times under customise; asked %q", asked, p.asked) + } +} + +// TestPersonOnlyQuestionsComeInThePlansOrder: the questions the plan lists +// come in the order it lists them: download, shell settings, status line, +// identity, tools. +func TestPersonOnlyQuestionsComeInThePlansOrder(t *testing.T) { + release := []byte("#!/bin/sh\necho abcd v9.9.9\n") + sum := sha256.Sum256(release) + defer swapDownloadTag(func(string) string { return "v9.9.9" })() + setupUserScope(t, gitOnlyDir(t)) + t.Setenv("ABCD_PLUGIN_ROOT", "") + harnessFixture(t, harnessSettingsWith("")) + var calls [][]string + fakeToolRun(t, &calls) + var order []string + opts := planOpts() + opts.DownloadAbcd = func(string) (DownloadedRelease, error) { + return DownloadedRelease{Bytes: release, SHA256: hex.EncodeToString(sum[:])}, nil + } + opts.ConfirmTool = func(tools.Explanation) tools.Answer { order = append(order, "tools"); return tools.Answer{Why: "no"} } + p := &planPrompter{choice: PlanProceed, confirm: func(q string) bool { + switch { + case isDownloadQuestion(q): + order = append(order, "download") + return true + case isStatusLineOfferQuestion(q): + order = append(order, "status line") + } + return false + }} + if _, err := Install(gitRepo(t), opts, p); err != nil { + t.Fatal(err) + } + if want := []string{"download", "status line", "tools"}; !slices.Equal(order, want) { + t.Fatalf("asked in the order %q, want %q", order, want) + } + plan := strings.Join(p.plans[0].AsksYou, "\n") + dl, sl, tl := strings.Index(plan, "abcd download"), strings.Index(plan, "status line"), strings.Index(plan, "gitleaks") + if dl < 0 || sl < 0 || tl < 0 || dl > sl || sl > tl { + t.Errorf("the plan lists the questions out of the run's order:\n%s", plan) + } +} diff --git a/internal/core/ahoy/shell_path.go b/internal/core/ahoy/shell_path.go index e0b37fe0bb..00eabe7638 100644 --- a/internal/core/ahoy/shell_path.go +++ b/internal/core/ahoy/shell_path.go @@ -17,6 +17,7 @@ package ahoy // - `abcd ahoy uninstall` takes the line out again (uninstallShellPath). import ( + "errors" "path/filepath" "strings" @@ -34,8 +35,12 @@ const ShellPathOfferGapID = "path.shell_settings_offered" // tests never plan against the machine's own shell settings (TestMain). var shellEnv = shellpath.CurrentEnv -// shellPathAskPrefix opens the question the offer ends on. -const shellPathAskPrefix = "Add the line to " +// shellApply makes the edit on a yes. A variable so a test can make it fail. +var shellApply = shellpath.Apply + +// shellPathAskPrefix opens the question the offer ends on, which names the +// line (shellpath.ExportLine) and the file. +const shellPathAskPrefix = "Add `export PATH=" // detectShellPath raises the offer beside path.bin_dir_not_on_path while the // plan says the line may be added. @@ -87,9 +92,20 @@ func (a *applyCtx) stepShellPath() { if !a.prompter.Confirm(shellPathQuestion(dir, plan)) { return } - applied, err := shellpath.Apply(shellEnv(), plan) + applied, err := shellApply(shellEnv(), plan) if err != nil { a.refuse("the PATH line was not added to " + displayPath(plan.File) + ": " + displayText(err.Error()) + ".") + // The person said yes, so what is left is theirs to do, in the + // default report: put the file back by hand where abcd could not, and + // otherwise add the line themselves. + var re *shellpath.RestoreError + if errors.As(err, &re) { + a.needYou("Copy "+displayText(re.Backup)+" over "+displayText(re.File)+" by hand", + displayText(re.File)+" did not read back as abcd wrote it, and putting it back from the copy failed too ("+displayText(re.Cause)+").") + return + } + a.needYou("Add `"+plan.Export+"` to "+displayPath(plan.File)+" yourself", + "abcd's edit of "+displayPath(plan.File)+" after your yes wrote nothing: "+strings.TrimSuffix(displayText(err.Error()), ".")+".") return } a.shellAdded = true @@ -107,6 +123,8 @@ func (a *applyCtx) stepShellPath() { // shellPathQuestion is the reason, the file, the line and the question: core // never prints, so what the person consents to travels as the confirm's text. +// The one line names the line and the file, so the question shown without ? +// still says what a yes writes and where (itd-2610240919030303). func shellPathQuestion(dir string, plan shellpath.Plan) string { file := displayPath(plan.File) where := "the end of " + file @@ -114,9 +132,10 @@ func shellPathQuestion(dir string, plan shellpath.Plan) string { where = "a new " + file } return displayPath(dir) + " is not on your PATH. abcd can add this line to " + where + - ", keeping a copy first; abcd ahoy uninstall removes it:\n" + + ", keeping a copy first. It puts " + displayPath(dir) + " ahead of the rest of your PATH, so this abcd answers " + + "before any older copy, and abcd ahoy uninstall removes it:\n" + " " + plan.Line + "\n" + - shellPathAskPrefix + file + "?" + "Add `" + plan.Export + "` to " + file + "?" } // isShellPathQuestion reports whether text is the shell-settings offer. diff --git a/internal/core/ahoy/tools_route_test.go b/internal/core/ahoy/tools_route_test.go index 1c0429ef0e..87aea91ce4 100644 --- a/internal/core/ahoy/tools_route_test.go +++ b/internal/core/ahoy/tools_route_test.go @@ -32,7 +32,7 @@ func depGap(t *testing.T, gaps []Gap, id string) Gap { // command. func TestDependencyGapCarriesTheRegistryExplanation(t *testing.T) { emptyPath(t) - g := depGap(t, detectDependencies(t.TempDir()), "deps.gitleaks_missing") + g := depGap(t, detectDependencies(t.TempDir(), true), "deps.gitleaks_missing") if g.Tool == nil || g.Tool.Tool != "gitleaks" || !g.Tool.Known { t.Fatalf("gap carries no registry explanation: %+v", g) } @@ -59,7 +59,7 @@ func TestArmedRepositoryMakesGitleaksRequired(t *testing.T) { []byte(`{"schema_version":1,"enabled":true}`), 0o644); err != nil { t.Fatal(err) } - g := depGap(t, detectDependencies(repo), "deps.gitleaks_missing") + g := depGap(t, detectDependencies(repo, true), "deps.gitleaks_missing") if !g.Required || g.Tool == nil || g.Tool.Capability != tools.TranscriptScanArmed { t.Fatalf("armed repository's gap: %+v", g) } @@ -72,7 +72,7 @@ func TestArmedRepositoryMakesGitleaksRequired(t *testing.T) { // person to install it (iss-2609261447331434). func TestNoTrufflehogGap(t *testing.T) { emptyPath(t) - for _, g := range detectDependencies(t.TempDir()) { + for _, g := range detectDependencies(t.TempDir(), true) { if strings.Contains(g.ID, "trufflehog") { t.Fatalf("ahoy still offers trufflehog: %+v", g) } @@ -146,7 +146,7 @@ func depCtx(t *testing.T, confirm tools.Confirm) *applyCtx { repo := t.TempDir() return &applyCtx{ cwd: repo, - det: DetectionResult{Gaps: detectDependencies(repo)}, + det: DetectionResult{Gaps: detectDependencies(repo, true)}, approved: map[GapCategory]bool{Dependency: true}, confirmTool: confirm, } @@ -222,7 +222,7 @@ func TestDependencyYesRunsTheStepAndReportsIt(t *testing.T) { func TestEveryToolAhoyNamesIsRegistered(t *testing.T) { emptyPath(t) emitted := map[string]bool{} - for _, g := range detectDependencies(t.TempDir()) { + for _, g := range detectDependencies(t.TempDir(), true) { if g.Tool == nil { t.Fatalf("dependency gap %s carries no explanation", g.ID) } @@ -306,12 +306,12 @@ func TestAnIncompatibleGitleaksIsAGapOfItsOwn(t *testing.T) { } t.Cleanup(func() { newToolInstaller = prev }) - g := depGap(t, detectDependencies(t.TempDir()), "deps.gitleaks_incompatible") + g := depGap(t, detectDependencies(t.TempDir(), true), "deps.gitleaks_incompatible") if !strings.Contains(g.Detail, "8.10.0 is outside the versions abcd works with") || g.Tool == nil || g.Tool.Plan == nil { t.Fatalf("gap = %+v", g) } version = "8.24.3" - if gaps := detectDependencies(t.TempDir()); len(gaps) != 0 { + if gaps := detectDependencies(t.TempDir(), true); len(gaps) != 0 { t.Fatalf("a compatible gitleaks is a gap: %+v", gaps) } } diff --git a/internal/core/shellpath/shellpath.go b/internal/core/shellpath/shellpath.go index a47332169b..4e52aff3d2 100644 --- a/internal/core/shellpath/shellpath.go +++ b/internal/core/shellpath/shellpath.go @@ -522,12 +522,26 @@ func replace(path string, cur, next []byte, backup string) error { return nil } if rerr := fsutil.WriteFileAtomicPreserveMode(path, cur); rerr != nil { - return errors.New(shown + " did not read back as written, and putting it back failed too (" + - fsutil.RedactHome(rerr.Error()) + "): copy " + copyAt + " over it by hand") + return &RestoreError{File: shown, Backup: copyAt, Cause: fsutil.RedactHome(rerr.Error())} } return errors.New(shown + " did not read back as written, so it was put back from the copy at " + copyAt) } +// RestoreError is the one failure that leaves a startup file other than as it +// was: it did not read back as written, and putting it back from the copy +// failed too, so the person has a step to take by hand. Its fields are in the +// tilde form. +type RestoreError struct { + File string // the startup file + Backup string // the copy of it as it was + Cause string // why putting it back failed +} + +func (e *RestoreError) Error() string { + return e.File + " did not read back as written, and putting it back failed too (" + e.Cause + + "): copy " + e.Backup + " over it by hand" +} + // readBack confirms path holds exactly want. func readBack(path string, want []byte) error { got, err := fsutil.ReadGuarded(path, MaxFileBytes+int64(len(want))) diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 63d88d55a3..30a85e4b8e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4855,8 +4855,11 @@ func (p *stdinPrompter) Plan(plan ahoy.SetupPlan) (ahoy.PlanChoice, bool) { if c, ok := ahoy.PlanChoiceOf(line); ok { return c, true } + // A bare Enter is no answer, as at every [y/N] question here: the + // screen asks again, and end of input cancels. if line == "" && err == nil { - return ahoy.PlanProceed, true + fmt.Fprint(p.w, "Type proceed, customise or cancel.\n") + continue } return ahoy.PlanCancel, true } diff --git a/internal/surface/cli/setup_plan_enter_test.go b/internal/surface/cli/setup_plan_enter_test.go new file mode 100644 index 0000000000..71135b7143 --- /dev/null +++ b/internal/surface/cli/setup_plan_enter_test.go @@ -0,0 +1,30 @@ +package cli + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/ahoy" +) + +// TestPlanScreenBareEnterAsksAgain: at the stdin door's plan screen a bare +// Enter is no answer, as it is at every [y/N] question, so the screen asks +// again rather than proceeding; end of input cancels. +func TestPlanScreenBareEnterAsksAgain(t *testing.T) { + var w bytes.Buffer + p := &stdinPrompter{r: bufio.NewReader(strings.NewReader("\nproceed\n")), w: &w, tty: true} + c, asked := p.Plan(ahoy.SetupPlan{}) + if !asked || c != ahoy.PlanProceed { + t.Fatalf("choice %q asked %v", c, asked) + } + if n := strings.Count(w.String(), "[proceed/customise/cancel]"); n != 2 { + t.Fatalf("a bare Enter was taken as an answer (asked %d times):\n%s", n, w.String()) + } + w.Reset() + p = &stdinPrompter{r: bufio.NewReader(strings.NewReader("\n")), w: &w, tty: true} + if c, _ := p.Plan(ahoy.SetupPlan{}); c != ahoy.PlanCancel { + t.Fatalf("a bare Enter then end of input took %q, want cancel", c) + } +} From b6dc27cf9cdfb43aa8146e13c1e45a56ad747ea3 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:08:07 +0200 Subject: [PATCH 15/26] feat: ask to switch on the commit checks in a clone that does not run them Setup committed the name guard and the other hooks in .githooks and reported clean in a clone whose core.hooksPath was unset, so git never ran them. Where abcd's guard is committed and no hooks path is set at any scope, setup now asks a person at a terminal, as its own question after the shell settings ("Switch on this repository's commit checks, setting core.hooksPath to .githooks in this clone's .git/config?"), naming any .git/hooks hooks that would stop running and the command that undoes it. A yes sets the key with git config --local, reads it back and reports it in Done; the plan lists the question. --yes and a run off a terminal never set it, and the report then lists the exact command. A hooks path set outside the clone (a dispatcher) is never offered a local one, and a local value naming another directory is reported, not replaced. The install is partial, never clean or up to date, while the committed hooks do not run. Resolves: iss-2610241134315056 Assisted-by: Claude:claude-opus-5-5 --- ...6-ahoy-install-reports-clean-in-a-clone.md | 2 + internal/core/ahoy/apply.go | 21 +- internal/core/ahoy/commit_checks.go | 227 ++++++++++++++++++ internal/core/ahoy/detect.go | 2 + internal/core/ahoy/install_summary.go | 5 + internal/core/ahoy/outbound_shim.go | 2 +- internal/core/ahoy/person_only.go | 8 +- internal/core/ahoy/setup_question.go | 1 + .../core/ahoy/setup_questions_limits_test.go | 23 +- internal/core/ahoy/setup_report.go | 4 + internal/core/ahoy/setup_report_test.go | 3 +- internal/core/ahoy/setup_review_test.go | 96 ++++++++ internal/surface/cli/cli.go | 2 + 13 files changed, 389 insertions(+), 7 deletions(-) rename .abcd/work/issues/{open => resolved}/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md (78%) create mode 100644 internal/core/ahoy/commit_checks.go diff --git a/.abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md b/.abcd/work/issues/resolved/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md similarity index 78% rename from .abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md rename to .abcd/work/issues/resolved/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md index 5afa1df794..776599b5cf 100644 --- a/.abcd/work/issues/open/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md +++ b/.abcd/work/issues/resolved/iss-2610241134315056-ahoy-install-reports-clean-in-a-clone.md @@ -11,6 +11,8 @@ production_mode: hand-written found_at: "internal/core/ahoy/detect.go" remedy: "make an unarmed committed hook set a gap that setup offers to arm (one person-approved question naming core.hooksPath and the clone's .git/config), and never report clean while the hooks it installed do not run" refines: [iss-2610020704152920, iss-2609120447480903] +resolution: "setup asks at a terminal, as its own question, to set core.hooksPath to .githooks in the clone's .git/config, and never reports clean while the committed hooks do not run; off a terminal or on a no the report lists the command" +impact: fix --- ahoy install reports 'clean' in a clone whose committed hooks are not armed: in abcd's own checkout on 2026-10-24 the detection pass read banlist.hooks_path as 'unarmed' (core.hooksPath unset, so .githooks/pre-commit and pre-merge-commit never run) both before and after setup, yet no gap was listed and the install summary said 'abcd is set up in this repository'. diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 4b4fd03592..46d33e6049 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -71,6 +71,11 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er if err == nil && res.needs == nil && res.Status != "refused" && res.Status != "aborted" { res.needs = remainingSteps(abs, det.Gaps) } + // An early return judged nothing it did not change: committed hooks + // git does not run keep it from being reported up to date. + if err == nil && res.Status == "already_up_to_date" && hooksUnrun(det.Gaps) { + res.Status = "partial" + } }() // Not a repository, or no harness: nothing to act on, said in one message. @@ -329,6 +334,10 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // After stepPathEntry: the shell-settings offer judges the entry this run // left on disk, and is the first of the consent questions. ac.stepShellPath() + // After stepBanlist, which commits the guard, and before the outbound + // shim, which plans against the hooks path this leaves: the commit checks' + // own question (iss-2610241134315056). + ac.stepArmHooks() // After the PATH-entry steps: abcd.binary names the verified copy they left. ac.stepOutboundBinary() // After stepOutboundBinary: the shim finds abcd through the abcd.binary it @@ -382,6 +391,12 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er if len(remaining) > 0 { status = "partial" } + if hooksUnrun(final.Gaps) { + // Committed hooks git does not run are not a finished setup, whether + // the question was declined, not asked, or the value is the person's + // (iss-2610241134315056). + status = "partial" + } if ac.configMalformed { // The config could not be parsed, so nothing that depends on it ran. // config.malformed is a diagnostic (non-resolvable) gap and so never @@ -2443,14 +2458,14 @@ const credentialAtRestGapID = "history.credential_at_rest" // the retirement of a tool's own conventions file (see // stepConventionsFiles) and the README question (see stepReadme). In the // order they are reported. -var optionalGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, ShellPathOfferGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var optionalGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, ShellPathOfferGapID, HooksArmGapID, StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID, ConventionsRetireGapID} // terminalOnlyGapIDs are the optional offers put only to a person at a // terminal, and their categories: off one, neither the category nor the offer // is asked, so a piped answer stream keeps the order it had before they // existed. The README question has no category to ask (resolveApproval leaves // it out everywhere: the question is the consent). -var terminalOnlyGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, ShellPathOfferGapID, DrainRuleOfferGapID, ConventionsRetireGapID} +var terminalOnlyGapIDs = []string{LegacyPinGapID, UnrecordedGapID, ReadmeOfferGapID, ShellPathOfferGapID, HooksArmGapID, DrainRuleOfferGapID, ConventionsRetireGapID} var terminalOnlyCategories = []GapCategory{DrainRule, ConventionsFile} @@ -2621,6 +2636,8 @@ func resolveApproval(gaps []Gap, opts InstallOptions, flagChanges []string, p Pr // The shell-settings offer is its own consent too, asked only at a // terminal (stepShellPath). delete(present, ShellPath) + // So is pointing git at the committed hooks (stepArmHooks). + delete(present, HooksPathCategory) approved := map[GapCategory]bool{} switch { case opts.ApprovedCategories != nil: diff --git a/internal/core/ahoy/commit_checks.go b/internal/core/ahoy/commit_checks.go new file mode 100644 index 0000000000..4f9c4231aa --- /dev/null +++ b/internal/core/ahoy/commit_checks.go @@ -0,0 +1,227 @@ +package ahoy + +// The commit checks' own question (iss-2610241134315056). The hooks setup +// commits in .githooks run only in a clone whose core.hooksPath points there, +// and a fresh clone sets no hooks path, so a clone setup reported clean ran +// none of them. Setting the key chooses every hook git runs in the clone, so +// it is a change only the person approves, asked as its own question, on the +// pattern of the shell-settings offer (shell_path.go): +// +// - It is asked only of a person at a terminal: --yes never gives the yes, +// and off a terminal it is never put. Its category is never asked as an +// approval: the question is its own consent. +// - It is offered only where no hooks path is set at any scope. A hooks path +// set outside the clone (a global or system dispatcher, banlist.hooks_path +// "foreign") is left alone, never shadowed by a local one; a local value +// naming another directory is the person's, and is reported, not replaced. +// - Until the hooks run, the install is never reported clean: the gap stays, +// and the report lists the exact command. + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/gitutil" +) + +// HooksPathCategory covers pointing git at the committed hooks in this clone. +const HooksPathCategory GapCategory = "hooks-path" + +// HooksArmGapID is the offer to point git at the committed hooks. +const HooksArmGapID = "banlist.hooks_unarmed" + +// HooksElsewhereGapID is a clone whose own config points git at another +// hooks directory, so the committed hooks do not run; abcd does not replace +// the person's value. +const HooksElsewhereGapID = "banlist.hooks_path_elsewhere" + +// ArmHooksCommand is the one command that points this clone's git at the +// committed hooks, which the report lists when the question is not answered. +const ArmHooksCommand = "git config --local core.hooksPath " + guardHooksDirRelPath + +// armHooksAsk is the question the offer ends on. +const armHooksAsk = "Switch on this repository's commit checks, setting core.hooksPath to " + guardHooksDirRelPath + + " in this clone's .git/config?" + +// localHooksPathUnset reports whether this clone's own config sets no +// core.hooksPath. known is false when git did not answer (not a repository, +// a git that failed), which offers nothing. +func localHooksPathUnset(cwd string) (unset, known bool) { + out, err := gitutil.Run(cwd, "config", "--local", "--get", "core.hooksPath") + if err == nil { + return strings.TrimSpace(out) == "", true + } + // Exit status 1 is git's "no such key" in a repository it answers for. + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 { + return true, true + } + return false, false +} + +// detectHooksArm raises the offer (or, for a local value naming another +// directory, the diagnosis) while abcd's committed guard is installed and git +// does not run it in this clone. +func detectHooksArm(cwd string, h BanlistHealth) []Gap { + if h.Hook != HookInstalled || h.HooksPath != HooksPathStateUnarmed { + return nil + } + unset, known := localHooksPathUnset(cwd) + switch { + case !known: + return nil + case unset: + return []Gap{hooksArmGap()} + } + return []Gap{{ + ID: HooksElsewhereGapID, Category: ConfigChange, Scope: "repo", + Title: "git runs another hooks directory in this clone, not " + guardHooksDirRelPath, + Detail: "This clone's .git/config sets core.hooksPath to another directory (`git config --local --get core.hooksPath` prints it), " + + "so the commit checks committed in " + guardHooksDirRelPath + " never run here.", + FixHint: "Call the hooks in " + guardHooksDirRelPath + "/ from the hooks in that directory, or point git at them yourself: " + + ArmHooksCommand + " (abcd does not replace a value you set).", + Required: false, Resolvable: false, + }} +} + +func hooksArmGap() Gap { + return Gap{ + ID: HooksArmGapID, Category: HooksPathCategory, Scope: "repo", + Title: "git does not run this repository's commit checks in this clone", + Detail: GuardHookRelPath + " and the other hooks in " + guardHooksDirRelPath + " are committed, but core.hooksPath is not " + + "set in this clone, so git never runs them and no commit is checked.", + FixHint: "ahoy install asks at a terminal and sets it only on a yes; --yes never sets it. Or run it yourself: " + ArmHooksCommand, + Required: false, Resolvable: true, + } +} + +// hooksArmStep is the report's step for a clone whose committed hooks git +// does not run. +func hooksArmStep() ReportStep { + return ReportStep{Step: hooksArmAction, + Why: "git runs the hooks committed in " + guardHooksDirRelPath + " only once core.hooksPath points there, and this clone sets none."} +} + +// hooksArmAction is the step, the same words wherever it is listed, so the +// report lists it once. +const hooksArmAction = "Switch on the commit checks: run abcd ahoy install at a terminal and answer yes to that question, or run " + + ArmHooksCommand + +// hooksUnrun reports whether gaps say committed hooks git does not run, which +// keeps the install from being reported clean. +func hooksUnrun(gaps []Gap) bool { + return hasGapID(gaps, HooksArmGapID) || hasGapID(gaps, HooksElsewhereGapID) +} + +// armOffered reports whether this run puts the question: abcd's guard is, or +// is about to be, committed, and no hooks path is set at any scope. The plan +// lists it from the same judgement (personOnlyAsks). +func armOffered(cwd string, det DetectionResult) bool { + present := gapIDSet(det.Gaps) + if !present[HooksArmGapID] && !present["banlist.hook_missing"] { + return false + } + if hooksPathState(cwd) != HooksPathStateUnarmed { + return false + } + unset, known := localHooksPathUnset(cwd) + return known && unset +} + +// stepArmHooks makes the offer. It runs after stepBanlist, so it judges the +// guard this run committed, a first install's included, and before the +// outbound shim, which plans against the hooks path this step leaves. It +// never runs under --yes or off a terminal. +func (a *applyCtx) stepArmHooks() { + if a.autoYes || !atTerminal(a.prompter) { + return + } + root, err := os.OpenRoot(a.cwd) + if err != nil { + return + } + installed := classifyGuardHook(root, GuardHookRelPath) == HookInstalled + root.Close() + if !installed || hooksPathState(a.cwd) != HooksPathStateUnarmed { + return + } + if unset, known := localHooksPathUnset(a.cwd); !known || !unset { + return + } + if !a.prompter.Confirm(armHooksQuestion(a.cwd)) { + return + } + if _, err := gitutil.Run(a.cwd, "config", "--local", "core.hooksPath", guardHooksDirRelPath); err != nil { + a.refuse("core.hooksPath was not set: " + errText(err) + ".") + a.needYou("Switch on the commit checks yourself: "+ArmHooksCommand, + "you said yes, and git refused to set it: "+strings.TrimSuffix(errText(err), ".")+".") + return + } + // Read back: the write is the key git now runs hooks from. + if hooksPathState(a.cwd) != HooksPathStateArmed { + a.refuse("core.hooksPath was set, but git does not read it back as " + guardHooksDirRelPath + ".") + a.needYou("Check what git runs here with `git config --get core.hooksPath`, and set it with "+ArmHooksCommand, + "you said yes, and git does not read the value back as "+guardHooksDirRelPath+".") + return + } + a.changes = append(a.changes, "switched on the commit checks: core.hooksPath = "+guardHooksDirRelPath+" in this clone's .git/config") +} + +// armHooksQuestion is what the person consents to: which hooks start +// running, what stops running, how to undo it, and the question. +func armHooksQuestion(cwd string) string { + text := "git does not run the commit checks committed in " + guardHooksDirRelPath + if names := committedHooks(cwd); len(names) > 0 { + text += " (" + strings.Join(names, ", ") + ")" + } + text += " in this clone: core.hooksPath is not set. Setting it makes git run the hooks in " + + guardHooksDirRelPath + " instead of those in .git/hooks" + if own := ownGitHooks(cwd); len(own) > 0 { + text += ", so " + strings.Join(own, ", ") + " in .git/hooks stop running" + } + return text + "; `git config --local --unset core.hooksPath` undoes it.\n" + armHooksAsk +} + +// isArmHooksQuestion reports whether text is the commit-checks offer. +func isArmHooksQuestion(text string) bool { + _, ask := splitConfirm(text) + return ask == armHooksAsk +} + +// committedHooks names the hooks in the committed directory. +func committedHooks(cwd string) []string { + return hookNames(filepath.Join(cwd, guardHooksDirRelPath)) +} + +// ownGitHooks names the hooks in this clone's .git/hooks that git runs now, +// the samples git ships left out. +func ownGitHooks(cwd string) []string { + dir, err := gitCommonDir(cwd) + if err != nil { + return nil + } + return hookNames(filepath.Join(dir, "hooks")) +} + +// hookNames is the executable regular files in dir, sorted. +func hookNames(dir string) []string { + ents, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range ents { + if strings.HasSuffix(e.Name(), ".sample") || !e.Type().IsRegular() { + continue + } + if fi, err := e.Info(); err == nil && fi.Mode().Perm()&0o111 != 0 { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index f00a68d74b..ecac12f155 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -158,6 +158,8 @@ func detect(cwd string, judgeTools bool) (DetectionResult, error) { banlistHealth := detectBanlistHealth(abs) res.Banlist = &banlistHealth gaps = append(gaps, detectBanlistScaffold(banlistHealth)...) + // Committed hooks git does not run in this clone (iss-2610241134315056). + gaps = append(gaps, detectHooksArm(abs, banlistHealth)...) // The attribution prompt is opt-in, so this reports nothing at all for a repo // that never adopted it — and a hand-deleted hook for one that did. gaps = append(gaps, detectAttributionHook(abs, banlistHealth.HooksPath)...) diff --git a/internal/core/ahoy/install_summary.go b/internal/core/ahoy/install_summary.go index 254927c176..0ab4bac02c 100644 --- a/internal/core/ahoy/install_summary.go +++ b/internal/core/ahoy/install_summary.go @@ -352,6 +352,11 @@ var optionalSkippedHelp = map[string]SummaryItem{ Why: "Which parts of README.md abcd keeps current, if any, is the owner's choice, so abcd writes none without an answer.", Action: "Run abcd ahoy install at a terminal, without --yes, and answer it; or, to answer it without being asked, pass --readme-parts " + ReadmePartsChoices + " to abcd ahoy install; none answers no and is not asked again.", }, + HooksArmGapID: { + What: "git was not pointed at this repository's commit checks in this clone, so they do not run.", + Why: "core.hooksPath chooses every hook git runs in the clone, so only your own answer at a terminal sets it.", + Action: hooksArmAction, + }, ConventionsRetireGapID: { What: "An agent tool's own conventions file that only repeats AGENTS.md was left in place.", Why: "Removing a file from your project needs your own answer; until it goes, that tool reads it in place of AGENTS.md.", diff --git a/internal/core/ahoy/outbound_shim.go b/internal/core/ahoy/outbound_shim.go index d103f2a750..2d708b7d6a 100644 --- a/internal/core/ahoy/outbound_shim.go +++ b/internal/core/ahoy/outbound_shim.go @@ -134,7 +134,7 @@ func shimShown(cwd, gitDir, rel string) string { // where abcd's copy of the check is at chain for the person's hook to call. func notWired(why, where, chain string) string { return "the commit-message check is not wired into git in this clone: " + why + - ". abcd never replaces a hook, sets core.hooksPath or changes global git config, so it keeps its copy of the check at " + + ". Wiring the check never replaces a hook, sets core.hooksPath or changes global git config, so abcd keeps its copy of the check at " + chain + ", a name git never runs; to run the check, add this line to " + where + ": " + outboundShimChainLine } diff --git a/internal/core/ahoy/person_only.go b/internal/core/ahoy/person_only.go index 163fbe33f0..414a754aea 100644 --- a/internal/core/ahoy/person_only.go +++ b/internal/core/ahoy/person_only.go @@ -19,7 +19,8 @@ import ( // rather than by the approval of its category: the commit identity's gaps, // which stepGitIdentity asks about at a terminal. func personOnlyGap(id string) bool { - return slices.Contains(establishGapIDs, id) || id == UnrecordedGapID || id == LegacyPinGapID || id == ShellPathOfferGapID + return slices.Contains(establishGapIDs, id) || id == UnrecordedGapID || id == LegacyPinGapID || id == ShellPathOfferGapID || + id == HooksArmGapID } // shellSettingsAsks is the line the plan lists for the shell settings @@ -79,7 +80,7 @@ func approvalGaps(cwd string, det DetectionResult, opts InstallOptions, target s // personOnlyAsks is the plan's list of the questions that follow whatever the // answer to it, in the order the run asks them: the abcd download, the shell -// settings, the status line, the commit identity, and one +// settings, the commit checks, the status line, the commit identity, and one // question for each tool abcd offers to install. Each is listed only where // the run will put it. func personOnlyAsks(cwd string, det DetectionResult, opts InstallOptions, target string) []string { @@ -93,6 +94,9 @@ func personOnlyAsks(cwd string, det DetectionResult, opts InstallOptions, target if entryWillExist(cwd, det, target, dl) { out = append(out, shellSettingsAsks(cwd, det, target)...) } + if armOffered(cwd, det) { + out = append(out, "the commit checks: core.hooksPath set to "+guardHooksDirRelPath+" in this clone's .git/config") + } present := gapIDSet(det.Gaps) if present[StatusLineOfferGapID] && statusLineReachable(cwd, det, dl) { out = append(out, "the status line: one entry in your assistant's settings, "+displayPath(harnessSettingsPath())) diff --git a/internal/core/ahoy/setup_question.go b/internal/core/ahoy/setup_question.go index e314a150b9..c2dd763a5a 100644 --- a/internal/core/ahoy/setup_question.go +++ b/internal/core/ahoy/setup_question.go @@ -135,6 +135,7 @@ var setupConfirmIDs = []struct { {OracleRoutingMachineGapID, func(s string) bool { return s == machineRoutingQuestion() }}, {OracleRoutingRepoGapID, func(s string) bool { return s == repoRoutingQuestion() }}, {ShellPathOfferGapID, isShellPathQuestion}, + {HooksArmGapID, isArmHooksQuestion}, {StatusLineOfferGapID, isStatusLineOfferQuestion}, {DownloadQuestionID, isDownloadQuestion}, {DrainRuleOfferGapID, func(s string) bool { return s == drainRuleQuestion() }}, diff --git a/internal/core/ahoy/setup_questions_limits_test.go b/internal/core/ahoy/setup_questions_limits_test.go index cbffb9f37c..780f0c6544 100644 --- a/internal/core/ahoy/setup_questions_limits_test.go +++ b/internal/core/ahoy/setup_questions_limits_test.go @@ -1,6 +1,7 @@ package ahoy import ( + "os" "path/filepath" "sort" "strings" @@ -8,6 +9,7 @@ import ( "github.com/intentdriven/abcd/internal/core/question" "github.com/intentdriven/abcd/internal/core/shellpath" + "github.com/intentdriven/abcd/internal/gittest" ) // heldQuestion is the value question h as setup builds it (valueQuestion, the @@ -91,7 +93,8 @@ func TestEverySetupQuestionPassesTheLimits(t *testing.T) { qs = append(qs, confirmQuestion(t, len(qs)+1, OracleRoutingMachineGapID, machineRoutingQuestion(), oracleRoutingMachineQuestionTail), confirmQuestion(t, len(qs)+2, OracleRoutingRepoGapID, repoRoutingQuestion(), oracleRoutingRepoQuestionTail), - shellPathLimitQuestion(t, len(qs)+3)) + shellPathLimitQuestion(t, len(qs)+3), + armHooksLimitQuestion(t, len(qs)+4)) var asks []question.Ask for _, q := range qs { @@ -203,3 +206,21 @@ func shellPathLimitQuestion(t *testing.T, n int) question.Question { _, tail := splitConfirm(text) return confirmQuestion(t, n, ShellPathOfferGapID, text, tail) } + +// armHooksLimitQuestion is the commit-checks offer in its tallest form: a +// clone whose .git/hooks holds hooks of its own that would stop running. +func armHooksLimitQuestion(t *testing.T, n int) question.Question { + t.Helper() + repo := gittest.NewRepo(t).Root() + for _, dir := range []string{filepath.Join(repo, ".githooks"), filepath.Join(repo, ".git", "hooks")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, h := range []string{"pre-commit", "pre-merge-commit", "commit-msg", "prepare-commit-msg", "pre-push"} { + if err := os.WriteFile(filepath.Join(dir, h), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + } + return confirmQuestion(t, n, HooksArmGapID, armHooksQuestion(repo), armHooksAsk) +} diff --git a/internal/core/ahoy/setup_report.go b/internal/core/ahoy/setup_report.go index a03547e10f..b159624a43 100644 --- a/internal/core/ahoy/setup_report.go +++ b/internal/core/ahoy/setup_report.go @@ -156,6 +156,10 @@ func remainingSteps(cwd string, gaps []Gap) []ReportStep { var out []ReportStep for _, g := range gaps { switch { + case g.ID == HooksArmGapID: + out = append(out, hooksArmStep()) + case g.ID == HooksElsewhereGapID: + out = append(out, ReportStep{Step: g.FixHint, Why: strings.TrimSuffix(reportFirstSentence(g.Detail, g.Title), ".") + "."}) case g.Category == Dependency && g.Tool != nil: // An optional tool is offered at a terminal, one question each, // and is not a step the person owes. The gap is the final diff --git a/internal/core/ahoy/setup_report_test.go b/internal/core/ahoy/setup_report_test.go index 40c1ef116e..d743c31db4 100644 --- a/internal/core/ahoy/setup_report_test.go +++ b/internal/core/ahoy/setup_report_test.go @@ -51,7 +51,8 @@ func TestFullySetUpRerunIsOneLine(t *testing.T) { // A first commit gives the repository the root commit abcd registers it by. idMustGit(t, repo, "commit", "--allow-empty", "-q", "-m", "first") clearIdentityEnv(t) - if _, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed}); err != nil { + // Fully set up includes the commit checks git runs (iss-2610241134315056). + if _, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed, confirm: isArmHooksQuestion}); err != nil { t.Fatal(err) } again := &planPrompter{choice: PlanProceed} diff --git a/internal/core/ahoy/setup_review_test.go b/internal/core/ahoy/setup_review_test.go index bf21eeb56a..16041de43e 100644 --- a/internal/core/ahoy/setup_review_test.go +++ b/internal/core/ahoy/setup_review_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "os" + "os/exec" "path/filepath" "slices" "strings" @@ -13,6 +14,7 @@ import ( "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/core/tools" + "github.com/intentdriven/abcd/internal/gittest" ) // The review findings on the setup redesign (itd-2610240823055889, @@ -348,3 +350,97 @@ func TestPersonOnlyQuestionsComeInThePlansOrder(t *testing.T) { t.Errorf("the plan lists the questions out of the run's order:\n%s", plan) } } + +// hooksRepo is a real clone whose committed hooks setup writes, with no +// hooks path at any scope. +func hooksRepo(t *testing.T) string { + t.Helper() + setupHermetic(t) + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "gitconfig")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + return gittest.NewRepo(t).Root() +} + +func clonesHooksPath(t *testing.T, repo string) string { + t.Helper() + out, _ := exec.Command("git", "-C", repo, "config", "--local", "--get", "core.hooksPath").Output() + return strings.TrimSpace(string(out)) +} + +// isArmQuestion reports whether q is the commit-checks question. +func isArmQuestion(q string) bool { + _, ask := splitConfirm(q) + return strings.Contains(ask, "commit checks") +} + +// TestUnarmedHooksAreNeverClean is iss-2610241134315056: committed hooks git +// does not run leave the install partial, with the exact command to arm them. +func TestUnarmedHooksAreNeverClean(t *testing.T) { + repo := hooksRepo(t) + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(GuardHookRelPath))); err != nil { + t.Fatalf("no committed hook: %v", err) + } + if res.Status == "clean" || res.Status == "already_up_to_date" { + t.Fatalf("status %q with the committed hooks unarmed", res.Status) + } + if !strings.Contains(needsJoined(res.Report), "git config --local core.hooksPath .githooks") { + t.Errorf("the report does not give the command:\n%s", needsJoined(res.Report)) + } + if clonesHooksPath(t, repo) != "" { + t.Error("--yes armed the hooks") + } +} + +// TestArmingTheHooksIsItsOwnQuestion: at a terminal the person is asked, and +// a yes sets core.hooksPath in this clone's .git/config and says so. +func TestArmingTheHooksIsItsOwnQuestion(t *testing.T) { + repo := hooksRepo(t) + p := &planPrompter{choice: PlanProceed, confirm: isArmQuestion} + res, err := Install(repo, planOpts(), p) + if err != nil { + t.Fatal(err) + } + var qs []string + for _, q := range p.asked { + if isArmQuestion(q) { + qs = append(qs, q) + } + } + if len(qs) != 1 || !strings.Contains(qs[0], "core.hooksPath") || !strings.Contains(qs[0], ".git/config") { + t.Fatalf("the question was not asked once naming the key and the file: %q", qs) + } + if !strings.Contains(strings.Join(p.plans[0].AsksYou, "\n"), "commit checks") { + t.Errorf("the plan does not list the question: %q", p.plans[0].AsksYou) + } + if got := clonesHooksPath(t, repo); got != ".githooks" { + t.Fatalf("core.hooksPath = %q after a yes", got) + } + if !strings.Contains(strings.Join(res.Report.Done, "\n"), "core.hooksPath") { + t.Errorf("Done does not say the checks were switched on: %q", res.Report.Done) + } + if res.Status != "clean" && strings.Contains(needsJoined(res.Report), "core.hooksPath") { + t.Errorf("the report still asks for the hooks path:\n%s", needsJoined(res.Report)) + } +} + +// TestAForeignHooksPathIsNeverOffered: a global dispatcher is left alone. +func TestAForeignHooksPathIsNeverOffered(t *testing.T) { + repo := hooksRepo(t) + if err := os.WriteFile(os.Getenv("GIT_CONFIG_GLOBAL"), []byte("[core]\n\thooksPath = \""+filepath.Join(t.TempDir(), "dispatcher")+"\"\n"), 0o600); err != nil { + t.Fatal(err) + } + p := &planPrompter{choice: PlanProceed, confirm: func(string) bool { return true }} + if _, err := Install(repo, planOpts(), p); err != nil { + t.Fatal(err) + } + if slices.ContainsFunc(p.asked, isArmQuestion) { + t.Errorf("a foreign hooks path was offered a local one: %q", p.asked) + } + if clonesHooksPath(t, repo) != "" { + t.Error("a local hooks path was set over a dispatcher") + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 30a85e4b8e..1f729afc5e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -4666,6 +4666,8 @@ func optionalSkipReason(id string) string { return "an agent tool's own conventions file that only repeats AGENTS.md is removed from the project only against a prompt answered at a terminal" case ahoy.ReadmeOfferGapID: return "which parts of README.md abcd keeps current is the owner's choice, so abcd writes none until --readme-parts or the README question answers it" + case ahoy.HooksArmGapID: + return "core.hooksPath chooses every hook git runs in this clone, so it is only set against a prompt answered at a terminal; or run " + ahoy.ArmHooksCommand } return "" } From 4f47f7b5a7f977d59237dcb07d30baaaf571d690 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:08:19 +0200 Subject: [PATCH 16/26] docs: describe the setup review fixes and point the default mode at provenance.json commands/ahoy.md and the brief's ahoy page now say what setup does after the review fixes: failures of approved changes on the needs-you list, the PATH step that follows the shell settings, the shell question's one line, the plan listing exactly the questions the run asks and in its order, tools never behind an approval at a terminal, abcd's own tool copy used only at a version in range, detection that starts no tool, a bare Enter at the plan screen, and the commit-checks question. The spec bodies' criterion notes name the tests that hold each. The intent page said the default production mode came from .abcd/config/identity.json; it comes from .abcd/config/provenance.json, with the legacy fallback, and the identity page no longer calls identity.json the live pin. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 45 +++++++--- .../brief/04-surfaces/05-intent.md | 4 +- .../brief/04-surfaces/19-identity.md | 5 +- ...-person-sets-abcd-up-in-a-repository-in.md | 25 ++++-- ...-when-abcd-needs-another-tool-such-as-a.md | 22 +++-- ...5-when-the-folder-abcd-installs-into-is.md | 4 +- commands/ahoy.md | 85 +++++++++++++------ 7 files changed, 137 insertions(+), 53 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 46f542d6c7..d6cb268d1c 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -641,8 +641,10 @@ inspect it by hand (iss-2609120447482255). **Setup asks before it puts abcd's folder on the `PATH`.** When the folder the entry is in is not on `PATH`, install asks a person at a terminal, once, whether -to add one line to the startup file their login shell reads, naming the file and -the line (`path.shell_settings_offered`, adr-2610241022330872, +to add one line to the startup file their login shell reads, its one line +naming the line and the file (``Add `export PATH="$HOME/.local/bin:$PATH"` to +~/.zshrc?``), and `?` saying the rest, that the folder goes ahead of the rest +of the `PATH` among it (`path.shell_settings_offered`, adr-2610241022330872, itd-2610240919030303). zsh gets `${ZDOTDIR:-$HOME}/.zshrc`; bash gets the login file on macOS (the first of `.bash_profile`, `.bash_login` and `.profile`, else a new `.bash_profile`) and `~/.bashrc` on Linux. On a yes abcd keeps a copy of @@ -654,7 +656,16 @@ owned by the person and writable by no one else. Settings that already add the folder are left alone, and a file that is a link kept elsewhere (shared dotfiles) is never offered: its line goes on the checklist, as it does on a no, under the approve-everything flag, and off a terminal. Uninstall removes the -line abcd added and nothing else in the file. +line abcd added and nothing else in the file. The `path.bin_dir_not_on_path` +step says what is true of those settings, as the plan reads them: where a file +the shell reads already adds the folder (the person's line, or the one a yes +just added) it is to open a new terminal, because the shell reads the file +when it starts; where abcd will not edit the file it is the `export` line with +the reason; only where setup will ask does it name the question. Only abcd's +own marked line for this folder, compared whole, counts as abcd's: the marker +on a line that adds another folder adds nothing. An edit after a yes that +fails is a step on the report's checklist, the copy to put back by hand where +abcd could not restore the file. **The name-guard scaffolding is reported at the granularity the technical facilitator can act on.** Each absent artefact is a gap abcd will create; every other state is a @@ -781,11 +792,16 @@ anything, in this order: approve-everything flag, an explicit category set and the after-update run are not shown the plan. 3. **The person-only questions** (`person_only.go`): the abcd download, the - shell settings, the status line, the commit identity and each missing - tool, each its own question naming the value and where it is written, - whatever the answer to the plan, in that order. The identity is never - listed under, or declined by, the settings approval - (iss-2610240824005655). + shell settings, the commit checks (`commit_checks.go`), the status line, + the commit identity and each tool abcd offers to install, each its own + question naming the value and where it is written, whatever the answer to + the plan, asked in that order and listed by the plan only where the run + will put it: the shell settings only where the run leaves an entry, a tool + only where abcd offers its own copy. The identity is never listed under, + or declined by, the settings approval (iss-2610240824005655), and at a + terminal no `dependency` approval is asked, so customising never drops a + tool's question. At the stdin door's plan screen a bare Enter is no answer + and asks again. 4. **The abcd download** (`abcd_download.go`). Where nothing is at the `PATH` target and the plugin's verified cache cannot supply a copy, setup asks to download the release this abcd was stamped as (in a source checkout of @@ -802,8 +818,11 @@ anything, in this order: (`ask.ErrExplain`). The answers record keeps the whole question. 6. **The report** (`setup_report.go`): done, one line per change with where; needs you, a numbered checklist of exact steps, each with why; next, one - step. A repository already fully set up is one line, and a details flag - adds the full record. + step. A change the person approved that then failed (a tool or abcd + download that did not verify, a shell-settings edit, the commit checks) is + a needs-you step with its reason and the step by hand, not only a note. A + repository already fully set up is one line, and a details flag adds the + full record. Run from a source checkout of abcd through `go run ./cmd/abcd`, setup is a build of that checkout as it stands, so the stale-binary refusal treats it as @@ -838,11 +857,12 @@ approvals had before they listed anything. | `config-change` | visibility, oracle adapter, the `PATH` entry, the repository's own git identity, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | | `machine-identity` | the person's own commit identity record on their machine; moving the identity pin a repository still stores to its owner's machine (itd-2610240918547728) | never a category approval: each is its own question, asked only at a terminal, written only on that yes; never under the approve-everything flag or a piped answer, and reported as optional work it skipped | | `plugin-owned` | the marker block (itd-3); hook-manifest verification | silent overwrite on marker drift; a non-resolvable diagnostic for a malformed or missing manifest, for a conventions file whose block would land inside a fence or HTML comment nothing closes (`marker.unplaceable`), for one that exists but cannot be read whole, a folder, a pipe, a file past the size cap or one without read permission (`marker.unreadable`), and for one whose block is missing or out of date in a folder that does not let abcd create a file there, which the write needs for its lock and temporary file (`marker.unwritable`) | -| `dependency` | a tool a capability uses with no copy abcd can run: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json`; missing, or installed some other way at a version outside the range abcd works with | the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the download abcd's release pins and how far it is checked, any copy already installed and which copy will run, what the install does) and its download is made only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a compatible copy already installed is used and nothing installed; a no is reported as what the capability continues on | +| `dependency` | a tool a capability uses with no copy abcd can run: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json`; missing, or installed some other way at a version outside the range abcd works with | at a terminal each tool is its own question, asked last of the person-only questions, and no category approval is asked (so customising never drops it); off one the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the download abcd's release pins and how far it is checked, any copy already installed and which copy will run, what the install does) and its download is made only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a compatible copy already installed is used and nothing installed; a no is reported as what the capability continues on | | `status-line` | the offer of abcd's status line in the host harness | an advisory offer asked after its own question, written only on an answered consent; never under the approve-everything flag, and reported as optional work it skipped | | `oracle-routing` | the offer of abcd's proposed model-tier routing table (itd-2609170822093401): the machine's `~/.abcd.noindex/oracle-routing.json`, then, as a separate question, the repository's `.abcd/config/oracle-routing.json` | the proposal said in counts (how many agents, how many at each tier, their fan-out bounds), naming no agent so the question fits, and each file written only on its own answered consent, the machine one owner-only; never under the approve-everything flag, and reported as optional work it skipped; a decline records nothing, so the next install offers again; uninstall leaves both files | | `drain-rule` | the offer of the repository's drain eligibility record (ruling BX2, itd-82): abcd's strict baseline as an accepted decision record carrying the four `drain_` fields, minted through the decision store's seam | the rule stated in one question and the record written only on a consent answered at a terminal; never under the approve-everything flag and never off a terminal, where neither its category nor the offer is asked (so a piped answer stream keeps its order), and reported as optional work it skipped; a decline records nothing, so the next install offers again; raised only while no accepted record states the rule, so a record stating it badly is never offered a second; only ever the baseline, never a loosened rule | | `conventions-file` | an agent tool's own conventions file at the repository root, read in place of `AGENTS.md` (itd-2610030814013772): `CLAUDE.md` and `.claude/CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI at its default settings, `.rules`, `.cursorrules` and `.github/copilot-instructions.md` for Zed, each classified with one guarded read inside the project and never through a link, and named as it is spelt on disk | a file holding the owner's words (or one that cannot be read whole) is never edited, moved, merged or removed and raises the warning `conventions.owner_file`, which install reports in `warnings`, printed first; a file that only repeats `AGENTS.md` (a link the system resolves to the root `AGENTS.md`, or, before there is one, a link whose target names it; that link's target saved as text, an exact copy, a lone `@AGENTS.md` line, or one blank once abcd's block is stripped; a dangling link's target, a saved target or an import counts only in its plain spelling, `AGENTS.md` or `../AGENTS.md`, never one stepping into a folder and back) raises the optional `conventions.retire_offered`, asked after the drain rule as one question per file (`retire`, `keep`, `later`, the default) and only at a terminal, as the drain rule is; on `retire` the file is classified again and removed from the working tree only if it still repeats, else left and named, and the removal takes the entry it checked, in its folder held open, so a folder swapped for a link cannot carry it elsewhere; keep and later record nothing, so the next install asks again | +| `hooks-path` | pointing git at the committed hooks in this clone (`banlist.hooks_unarmed`, iss-2610241134315056): abcd's guard is committed and no hooks path is set at any scope | its own question at a terminal, never an approval: a yes sets `core.hooksPath` to `.githooks` in the clone's `.git/config` and reads it back; off a terminal, under the approve-everything flag or on a no, the report lists the command, and the install is never reported clean while the hooks it committed do not run; a hooks path set outside the clone, or a local one naming another directory, is never replaced | | `readme` | the README question (itd-2610031348087517), raised as the optional `readme.offered` while `readme.parts` is absent from `.abcd/config.json` | never asked as a category: the question pair is the consent, asked after `stepMarker` and before the status-line offer, only of a person at a terminal, in a run without the approve-everything flag, and only once `.abcd/config.json` exists; every other run reports the gap under optional work it skipped and names the install flag that answers the pair, which it does in any run; the chosen parts are written into `README.md` on every setup run (below) | | `user-state` | the registry entry, re-founding, stale or duplicate entries | guided; never auto-edit user-scope state, report extras read-only | @@ -1347,7 +1367,8 @@ byte-identical to a fresh install save for the setup date. the install runs, **then** the version is updated, the marker block refreshed, and existing config keys preserved. - **Given** a tool a capability uses has no copy abcd can run, **when** the - dependency category is approved, **then** the person is shown the tool + tool's own question is reached (at a terminal, whatever the approvals; off + one, once the dependency category is approved), **then** the person is shown the tool registry's explanation and asked per tool; the pinned, checked download is made only on a yes typed at a terminal or relayed by a host naming the tool, nothing is written unless it passes its check, the result reports what was diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 4223741505..7c9edd3944 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -367,7 +367,9 @@ origin: researcher-authored # arrival path (itd-178), DERIVED from which comma # Stamped at mint and never rewritten. production_mode: hand-written # how the text was produced: hand-written | dictated-and-formatted | # scribe-transcribed. Closed choice carried by a production-mode flag; an absent - # flag takes the repo's declared default from .abcd/config/identity.json. + # flag takes the repo's declared default from .abcd/config/provenance.json + # (in a repo not yet migrated, the production_mode of .abcd/config/identity.json), + # falling back to hand-written. # Written at mint too, but only by the path that has a value for it: # related_issues: [iss-N | rdi-N, ...] — the intent half of the promote join (itd-4 AC3), # written by the capture/reading promote path and appended to diff --git a/.abcd/development/brief/04-surfaces/19-identity.md b/.abcd/development/brief/04-surfaces/19-identity.md index 425d4fa9b4..0e18d164c1 100644 --- a/.abcd/development/brief/04-surfaces/19-identity.md +++ b/.abcd/development/brief/04-surfaces/19-identity.md @@ -55,8 +55,9 @@ across lines. abcd's own block is the "Identity (canonical)" section of `.abcd/positioning.json` records the block's location, the family severity, and the surfaces held to it. It sits beside the repo's other per-concern -configuration rather than under `.abcd/config/`, where `identity.json` already -means the git commit-author pin. +configuration rather than under `.abcd/config/`, where `identity.json` was the +git commit-author pin a repository stored before that pin moved to each +owner's own record on their machine. The severity is the whole family's weight, and it is the one knob that changes what a failed check costs. `warn`, the default, reports drift as a warning, and diff --git a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md index 1d86d62766..b1a2783259 100644 --- a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md +++ b/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md @@ -83,10 +83,16 @@ approvals' gap list. 3. **A person-only step is its own question naming value and where.** Met for the identity (`TestIdentityQuestionStandsWithoutTheSettingsApproval`, `TestSettingsApprovalDoesNotListTheIdentity`), the download and the status - line (`TestDownloadComesBeforeTheStatusLine`), and each tool (its question - unchanged, now with the explanation as its material). The shell settings - question is the seam `stepShellSettings`/`shellSettingsAsks`, which - itd-2610240919030303 fills. + line (`TestDownloadComesBeforeTheStatusLine`), the commit checks + (`TestArmingTheHooksIsItsOwnQuestion`), and each tool (its question + unchanged, now with the explanation as its material, and at a terminal + never behind the dependency approval: + `TestCustomiseStillAsksTheToolQuestion`). The plan lists exactly the + questions the run puts, in the order it puts them (download, shell + settings, commit checks, status line, identity, tools): + `TestThePlanListsAToolOnlyWhenItWillAsk`, + `TestPersonOnlyQuestionsComeInThePlansOrder`. The shell settings question + is `stepShellPath`/`shellSettingsAsks`, which itd-2610240919030303 fills. 4. **The download before the status line.** Met: `TestDownloadComesBeforeTheStatusLine` (a yes wires the status line in the same run; a no never asks about it), `TestFetchVerifiedChecksBothRecords`. @@ -100,9 +106,16 @@ approvals' gap list. `TestQuestionMarkDrawsTheWholeQuestion`. The answers door writes each question whole, since it is a record, not a person. 7. **A three-part report.** Met: `TestReportHasThreeParts`; the CLI prints - it. + it. A change the person approved that then failed is a needs-you step with + its reason, not only a note (`TestAFailedAbcdDownloadIsANeedsYouStep`, + `TestAFailedShellEditIsANeedsYouStep`), the step after a yes to the shell + settings is to open a new terminal + (`TestAfterAYesTheReportSaysOpenANewTerminal`), and committed hooks git + does not run keep the install from being reported clean + (`TestUnarmedHooksAreNeverClean`, iss-2610241134315056). 8. **A fully set-up repository prints one line.** Met: - `TestFullySetUpRerunIsOneLine`. + `TestFullySetUpRerunIsOneLine`. At the stdin door's plan screen a bare + Enter is no answer and asks again (`TestPlanScreenBareEnterAsksAgain`). ## Footprint diff --git a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md index cd0d27c7d2..e4118e4ba8 100644 --- a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md +++ b/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md @@ -45,9 +45,11 @@ the downloaded asset. No version is ever looked up (invariant 7). `Installer.Plan(name)` decides, from the disk, PATH and the version a copy already installed reports through the tool's verify argv, with no network: -`use-abcd-copy` (an intact recorded copy), `use-existing` (a copy installed -some other way whose version lies in `Compatible`), `install` (abcd's own copy -offered, saying which of the two will run in abcd and in the person's shell), +`use-abcd-copy` (an intact recorded copy at the pinned version), `use-existing` +(a copy installed some other way whose version lies in `Compatible`), `install` +(abcd's own copy offered, saying which of the two will run in abcd and in the +person's shell; over abcd's own copy too, as an upgrade while that copy is in +`Compatible`, which keeps running on a no, and in its place when it is not), or `unavailable` (no download for the platform, or the bin folder holds a program of that name abcd did not install). `Installer.Install` refuses in CI, uses the plan, asks the front door's `Confirm` with the plan in the @@ -111,13 +113,21 @@ check is not built here. record, failed download), `TestAFailedVerifyRemovesTheCopy`, `TestHowEachToolIsCheckedIsSaid`, `TestGhsRecordedBuildRecordVerifiesAgainstItsPinnedSigner` and `TestABuildRecordFromAnotherSignerOrForOtherBytesFails` over the - recorded real gh v2.103.2 bundles. + recorded real gh v2.103.2 bundles. At setup, a yes whose download then + fails is a needs-you step in the default report carrying the reason + (`TestAFailedToolDownloadIsANeedsYouStep`), and a required tool's step + says it is required (`TestRequiredToolStepIsNotCalledOptional`). 3. A compatible copy is used and nothing installed; otherwise abcd's copy is offered, saying which runs, and the other copy is never changed: `TestACompatibleCopyIsUsedAndNothingInstalled`, `TestAnIncompatibleCopyGetsAbcdsOwnAndSaysWhichRuns`, - `TestABinFolderCopyAbcdDidNotInstallIsNeverReplaced`; at ahoy - `TestAnIncompatibleGitleaksIsAGapOfItsOwn`. + `TestABinFolderCopyAbcdDidNotInstallIsNeverReplaced`; abcd's own copy is + used only in `Compatible`, replaced only while it is still the bytes abcd + wrote: `TestAnOwnedCopyOutsideTheRangeIsReplaced`, + `TestAnOwnedCopyAtAnotherPinIsAnUpgrade`, + `TestAForeignFileAtAnOwnedPathIsNeverReplaced`; at ahoy + `TestAnIncompatibleGitleaksIsAGapOfItsOwn`, and the detection pass the + hooks run starts no tool (`TestDetectionNeverRunsTheTool`). 4. abcd runs its copy whatever PATH says, and uninstall removes only its copies: `TestResolvePrefersAbcdsCopyWhateverPathSays`, `TestUninstallRemovesOnlyAbcdsIntactCopies`; at ahoy diff --git a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md index 218c003ee6..1b7587648e 100644 --- a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md +++ b/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md @@ -25,8 +25,8 @@ zsh and bash, on macOS and Linux (the intent's two scope conditions). Any other ### Acceptance criteria -1. Not on the PATH, setup at a terminal: the offer is asked once, naming the file and the line. On a yes a copy is kept (when the file existed), one line is added, and the notes say to open a new terminal. Tests: `TestShellPathOfferAddsTheLineOnYes`, `TestOfferAddsOneLineAndRemoveTakesOnlyIt`. -2. Settings already add the folder: nothing is asked or edited (`TestShellPathAlreadyAddedDoesNothing`, `TestAlreadyAddedByThePerson`). The settings file is a link kept elsewhere: nothing is asked or edited, and the line is on the checklist with the reason (`TestShellPathLinkedFileIsNotEdited`, `TestLinkedFileGoesOnTheChecklist`). +1. Not on the PATH, setup at a terminal: the offer is asked once, its one line naming the line and the file (`TestTheShellQuestionNamesTheLineAndTheFile`). On a yes a copy is kept (when the file existed), one line is added, and the notes say to open a new terminal. Tests: `TestShellPathOfferAddsTheLineOnYes`, `TestOfferAddsOneLineAndRemoveTakesOnlyIt`; the report then says to open a new terminal, never to add the line again (`TestAfterAYesTheReportSaysOpenANewTerminal`), and an edit that fails is a needs-you step (`TestAFailedShellEditIsANeedsYouStep`). +2. Settings already add the folder: nothing is asked or edited (`TestShellPathAlreadyAddedDoesNothing`, `TestAlreadyAddedByThePerson`). The settings file is a link kept elsewhere: nothing is asked or edited, and the line is on the checklist with the reason (`TestShellPathLinkedFileIsNotEdited`, `TestLinkedFileGoesOnTheChecklist`, `TestAFileSetupWillNotEditIsNeverOffered`). Only abcd's own marked line for the folder, compared whole, counts as already added (`TestMarkedLineForAnotherFolderAddsNothing`). 3. A no: the line is on the checklist (`TestShellPathDeclinedPutsTheLineOnTheChecklist`; `--yes` and off a terminal in `TestShellPathNeverAskedWithoutAPerson`). Removal takes out abcd's line and nothing else, byte for byte, including the newline it added and a file it created (`TestRemoveRestoresAFileWithNoFinalNewline`, `TestCreatedFileIsRemovedWithTheLine`, `TestRemoveWithoutRecordFindsTheMarkedLine`, and the uninstall half of `TestShellPathOfferAddsTheLineOnYes`). ## Footprint diff --git a/commands/ahoy.md b/commands/ahoy.md index 17bba54aec..1e37678ec3 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -334,14 +334,22 @@ target's `.abcd/.work.local/scratch/` (create the directory via (`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy --json`, `banlist.hooks_path`) and act on it: - - `unarmed` — point git at the committed hooks, once per clone: + - `unarmed` — point git at the committed hooks, once per clone. Where no + hooks path is set at any scope, `abcd ahoy install` run at a terminal asks + this as its own question (the `banlist.hooks_unarmed` gap), naming + `core.hooksPath` and the clone's `.git/config`, sets it on a yes and reads + it back; off a terminal, under `--yes` or on a no, the report lists the + command, and the install is `partial`, never `clean`, until git runs the + hooks: ```bash - git config core.hooksPath .githooks + git config --local core.hooksPath .githooks ``` Report the hooks as scaffolded but unarmed if the user declines that - config change — never silently. + config change — never silently. A local value naming another directory + is the person's (`banlist.hooks_path_elsewhere`): abcd does not replace + it. - `foreign` — a hooks path set outside the clone (a global or system hooks dispatcher) is in force. Leave it alone: never set a local `core.hooksPath` over it, which would shadow the dispatcher in this clone. @@ -446,18 +454,22 @@ it can, each on one line it (the visibility, the docs target, deep scanning, the house-style rule, the artefact kind, the model routing tables, the drain rule, the README parts, and the status line's parts, all on), and the questions it asks - whatever the answer. It asks once: `proceed`, `customise` or `cancel`. + whatever the answer. It asks once: `proceed`, `customise` or `cancel`; a + bare Enter is no answer, so it asks again, and end of input cancels. Proceed makes every change and takes every default listed; customise asks each kind of change and each value in turn before anything is written; cancel, or deciding later, writes nothing. For a repository abcd does not manage yet, proceed and customise also adopt it, so the plan is the one question that adopts. 3. **The questions only a person can answer.** The abcd download, the shell - settings, the status line (one entry in the assistant's settings), the - commit identity and each missing tool are each their own question, naming - the value and where it is written, whatever the answer to the plan, in that - order. None is ever asked behind the plan or behind an approval of a kind - of change. + settings, the commit checks (`core.hooksPath` in this clone's + `.git/config`), the status line (one entry in the assistant's settings), + the commit identity and each tool abcd offers to install are each their own + question, naming the value and where it is written, whatever the answer to + the plan, in that order, and the plan lists exactly the ones the run will + ask. None is ever asked behind the plan or behind an approval of a kind of + change: at a terminal a tool's question is its own consent, so no + `dependency` approval is asked there, and customising never drops it. 4. **The abcd download.** Where abcd is not on the `PATH` and the plugin's verified cache cannot supply a copy, setup offers to download abcd before it offers the status line: the release the running abcd was stamped as, or, in @@ -476,15 +488,20 @@ it can, each on one line or `interview.list` set to `numbered`) and `NO_COLOR` work as before. 6. **The report.** The run ends in three parts: **Done**, one line per change with where it landed; **Needs you**, a numbered checklist, each item the - exact step and why; and **Next**, one step. `--details` adds the full + exact step and why; and **Next**, one step. A change the person said yes to + that then failed (a tool download that failed its sha256 or its signed build + record, an abcd download that did not verify, a shell-settings edit, the + commit checks) is a **Needs you** item carrying the reason and the step by + hand, such as the copy to put back over a startup file abcd could not + restore, never only a note behind `--details`. `--details` adds the full record (every path written and every note). A repository already fully set up is reported in one line, `abcd is already set up here; nothing changed.`, and nothing is written. A first run at a terminal with the suggested answers therefore asks the plan and the person-only questions alone: the abcd download, the shell settings, -the plan screen and the commit identity, plus the status line where it can be -wired and one question for each missing tool it offers to install. +the commit checks and the commit identity, plus the status line where it can be +wired and one question for each tool it offers to install. In a source checkout of abcd, `go run ./cmd/abcd ahoy install` is treated as current: `go run` builds the checkout as it stands, so its unstamped vintage is @@ -531,11 +548,18 @@ abcd-owned entry already on `PATH`, which is adopted exactly where it stands. system-wide one — and fails loudly when it is not writable. abcd never escalates privileges, so never suggest re-running any of this under `sudo`; report the failure and let the user pick a directory they own. If the report carries a -`path.bin_dir_not_on_path` gap, relay its one-line `export` fix verbatim. Do not +`path.bin_dir_not_on_path` gap, relay its fix hint verbatim: it is what is true +of the person's shell settings. Where the file their shell reads already adds +the folder (their own line, or the one this run added), the step is to open a +new terminal, because the shell reads that file when it starts; where abcd +will not edit the file, it is the one-line `export` with the reason; only +where setup will ask does it name the question. Do not edit the user's shell settings yourself: `abcd ahoy install` run at a terminal -asks the person whether to add that line to the file their shell reads, names -the file and the line, and adds it only on a yes (the -`path.shell_settings_offered` gap). It keeps a copy of the file first, never +asks the person whether to add that line to the file their shell reads, and +its one line names both, as in ``Add `export PATH="$HOME/.local/bin:$PATH"` to +~/.zshrc?``; `?` shows the rest, including that the line puts the folder ahead +of the rest of the `PATH`. It adds the line only on a yes (the +`path.shell_settings_offered` gap), keeps a copy of the file first, never edits a file that is a link kept elsewhere, and `abcd ahoy uninstall` removes the line again. Off a terminal, under `--yes`, or on a no, the line stays the person's to add. @@ -667,8 +691,9 @@ and say where they were given. The plan screen is the first: relay its plan (the changes, each default with where to change it, and the questions that follow) as its material, and its three answers, `proceed`, `customise` and `cancel`, as options. Then relay each person-only question the run reaches -(the download, the shell settings, the status line, the commit identity, a -tool) as its own question, never folded into the plan's answer: +(the download, the shell settings, the commit checks, the status line, the +commit identity, a tool) as its own question, never folded into the plan's +answer: ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --answers <file> --answered-in "Claude Code" --json @@ -777,12 +802,24 @@ behalf. For an autonomous routine the human identity is set by whatever launches it, before its first commit: on a fresh machine with no record, the commit check lets through the human identity the launcher supplied and refuses a machine's. -**The tool question.** When a `dependency` gap is present and its category is -approved, each tool abcd has no usable copy of is its own question, and a piped -answer never answers it: installing puts a program on the machine. A copy -already installed some other way is used, and nothing asked or installed, when -its version lies in the range abcd works with; abcd's own recorded copy is used -the same way. At a terminal the install shows the explanation and asks +**The tool question.** When a `dependency` gap is present, each tool abcd has +no usable copy of is its own question, asked last of the person-only +questions, and a piped answer never answers it: installing puts a program on +the machine. At a terminal that question is its own consent, so no +`dependency` approval is asked first and customising the plan never drops it; +off a terminal the category's approval keeps its place in the fixed order, and +no tool is installed. A copy already installed some other way is used, and +nothing asked or installed, when its version lies in the range abcd works +with. abcd's own recorded copy is used while its version is the one abcd's +release pins; at another version in the range it still runs and, when setup +runs its steps, the pin is offered as an update (`deps.gitleaks_upgrade`, never +required, and a no keeps the copy that works), and outside the +range it is not run and abcd's pinned copy is offered over it +(`deps.gitleaks_incompatible`). The download replaces only a copy that is still +the bytes abcd wrote, never another file. The detection pass the hooks and the +status board run starts no tool: it counts a copy on the `PATH` as installed, +and only `abcd ahoy install` (and `--dry-run`) runs a copy's version command to +judge it. At a terminal the install shows the explanation and asks `Install <tool> now: download <tool> <version> into <bin folder>, <checks>? [y/N]`. Through this page, ask the technical facilitator with the host's question tool instead: present the gap's `tool` explanation (what it is, whether this From 28e4531590610a3f4baba02b52758c13319192bd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:06:24 +0200 Subject: [PATCH 17/26] docs: resolve the shell-settings re-check finding Resolves: iss-2610241158083013 Assisted-by: Claude:claude-opus-5-5 --- ...ss-2610241158083013-the-shell-settings-edit-checks-the.md | 5 +++++ 1 file changed, 5 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2610241158083013-the-shell-settings-edit-checks-the.md (76%) diff --git a/.abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md b/.abcd/work/issues/resolved/iss-2610241158083013-the-shell-settings-edit-checks-the.md similarity index 76% rename from .abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md rename to .abcd/work/issues/resolved/iss-2610241158083013-the-shell-settings-edit-checks-the.md index 968be073ae..9b66eb4d74 100644 --- a/.abcd/work/issues/open/iss-2610241158083013-the-shell-settings-edit-checks-the.md +++ b/.abcd/work/issues/resolved/iss-2610241158083013-the-shell-settings-edit-checks-the.md @@ -10,6 +10,11 @@ origin: researcher-authored production_mode: hand-written found_at: "the shellpath package's Apply step (branch feat/setup-redesign-shellpath)" remedy: "re-check the leaf with an lstat immediately before the rename and refuse if it changed, or open the file with O_NOFOLLOW and compare its identity before and after" +resolution: "The shell-settings write now re-checks the file with Lstat and SameFile just before the replace, so a link swapped in after the plan is refused rather than followed." +impact: internal +resolved_by: + intent: "itd-2610240919030303" + commit: "65cb33df8" --- the shell-settings edit checks the profile file and then replaces it atomically, leaving a short window in which a symlink swapped in at the path would be replaced by a regular file rather than refused (the replace renames over the leaf, so it never writes through the link, but the person's link is lost). From 0eed9caa7934c9102eed5501b47213960b6be00a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:09:18 +0200 Subject: [PATCH 18/26] docs: say setup asks the identity and commit-checks questions on their own Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index d6cb268d1c..b9848e9b93 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -329,8 +329,8 @@ when there is none (iss-2609250834251447); the explicit flag writes that key, clone-local, to the `PATH` copy whose build provenance the same run verified, and writes nothing, saying why, when there is no such copy, when the entry is the dev shim or when it lies inside the working tree (iss-2610152024267547). -It never sets `core.hooksPath`, which chooses every hook git runs in the -clone. So that the check runs with no manual step, the explicit flag alone also +The flag never sets `core.hooksPath`, which chooses every hook git runs in the +clone; setup's own commit-checks question is the one place that does. So that the check runs with no manual step, the explicit flag alone also writes the same embedded template, marked as abcd's, as the clone's own `commit-msg` in the common git directory's `hooks/` (shared by every worktree), where no hooks path is set at any scope and nothing occupies that path; it @@ -854,7 +854,7 @@ approvals had before they listed anything. | `category` | Examples | Apply behaviour | |---|---|---| | `safe-autocreate` | the repo skeleton, history-store directories, the name-guard artefacts | applied once the category is approved, no per-item prompt; create-if-absent, never overwriting | -| `config-change` | visibility, oracle adapter, the `PATH` entry, the repository's own git identity, the artefact kind | transparent confirm; skip-if-set with a "current value" notice | +| `config-change` | visibility, oracle adapter, the `PATH` entry, the artefact kind (the repository's own git identity carries this category but is its own question, never listed under or declined by the settings approval) | transparent confirm; skip-if-set with a "current value" notice | | `machine-identity` | the person's own commit identity record on their machine; moving the identity pin a repository still stores to its owner's machine (itd-2610240918547728) | never a category approval: each is its own question, asked only at a terminal, written only on that yes; never under the approve-everything flag or a piped answer, and reported as optional work it skipped | | `plugin-owned` | the marker block (itd-3); hook-manifest verification | silent overwrite on marker drift; a non-resolvable diagnostic for a malformed or missing manifest, for a conventions file whose block would land inside a fence or HTML comment nothing closes (`marker.unplaceable`), for one that exists but cannot be read whole, a folder, a pipe, a file past the size cap or one without read permission (`marker.unreadable`), and for one whose block is missing or out of date in a folder that does not let abcd create a file there, which the write needs for its lock and temporary file (`marker.unwritable`) | | `dependency` | a tool a capability uses with no copy abcd can run: gitleaks, optional over the native secret scanner and required where the repository armed it in `.abcd/config/gitleaks.json`; missing, or installed some other way at a version outside the range abcd works with | at a terminal each tool is its own question, asked last of the person-only questions, and no category approval is asked (so customising never drops it); off one the category approval reaches the step; each tool is then explained from the tool registry (what it is, optional or required here, what works without it, the download abcd's release pins and how far it is checked, any copy already installed and which copy will run, what the install does) and its download is made only on a per-tool yes — typed at a terminal, or relayed by a host as a flag naming the tool — never under the approve-everything flag, a piped answer or CI; a compatible copy already installed is used and nothing installed; a no is reported as what the capability continues on | @@ -1068,7 +1068,8 @@ prompt wait rather than decline, which is the contract every prompting CLI has. A run that must neither block nor prompt closes stdin and pre-answers with flags. -**Installing a tool is the one question a piped answer never answers.** It runs +**A piped answer never installs a tool**, just as it never answers the identity, +shell-settings or commit-checks questions. Installing a tool runs a program on the machine, so it is asked only of a person at a terminal, after the tool registry's explanation is shown, and its default is no. Off a terminal the answer is a flag naming the tool, which is how a host relays the answer its @@ -1175,13 +1176,12 @@ automation's name such as `semantic-release-bot`), recorded or not, because the human is the author of record either way. The machine identities are one list, `internal/core/identity/tool-identities.txt`, which the CI attribution gate reads too, role asymmetry included: a `noreply@` mailbox is -a machine as the author, and the forge's own committer stamp passes. Once the -config-change category is approved, the install proposes the human identity -(the person's own record, else their global git identity, read from disk, never -from the repository, and never looked up over -the network, per adr-38, and never a machine's) and sets `user.name` and -`user.email` in the repository's own `.git/config` only when the person confirms -at a terminal. Otherwise it writes nothing and its notes say why: under the +a machine as the author, and the forge's own committer stamp passes. The +install proposes the human identity (the person's own record, else their global +git identity, read from disk, never from the repository, and never looked up +over the network, per adr-38, and never a machine's) as its own question, behind +no category approval, and sets `user.name` and `user.email` in the repository's +own `.git/config` only when the person confirms at a terminal. Otherwise it writes nothing and its notes say why: under the approve-everything flag; with no terminal, where it asks nothing at all, so a piped or routine run can neither write unasked nor wait on an answer no one can give; when an environment override or an `author.*` or `committer.*` key @@ -1217,7 +1217,11 @@ write is create-if-absent **and** contained: paths resolve through an `os.Root` at the repo, so a symlink committed at the hooks directory or at the local tier cannot land an artefact outside it. The private stub is written only where git reports the path as ignored. A clone arms the hooks once by pointing git at the -hooks directory; abcd never sets that config, and no surface reports a committed +hooks directory. Setup asks that as its own question at a terminal and sets +`core.hooksPath` to `.githooks` in the clone's `.git/config` only on a yes, +reading the value back; it never sets it under the approve-everything flag or +off a terminal, and never over a hooks path set elsewhere. Until the hooks run, +the install is reported partial, never clean, and no surface reports a committed hook as a running one. **The README parts** (spc-2610031704338417) are a named sibling of the From ae328dd9ae67d9285288c192c0516fc70ad8e32a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:09:58 +0200 Subject: [PATCH 19/26] docs: capture the setup descriptions the docs review found incomplete Assisted-by: Claude:claude-opus-5-5 --- ...5682-after-the-setup-redesign-several-setup.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610242109545682-after-the-setup-redesign-several-setup.md diff --git a/.abcd/work/issues/open/iss-2610242109545682-after-the-setup-redesign-several-setup.md b/.abcd/work/issues/open/iss-2610242109545682-after-the-setup-redesign-several-setup.md new file mode 100644 index 0000000000..e1874a5417 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610242109545682-after-the-setup-redesign-several-setup.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610242109545682" +slug: "after-the-setup-redesign-several-setup" +severity: "minor" +category: "documentation" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/brief/04-surfaces/01-ahoy.md" +remedy: "In one docs change: add the four entries to the tree in 01-ahoy.md and widen the backups line; add the shell line and tool copies to the uninstall summary; add deps.gitleaks_upgrade to the tool paragraph; extend the --yes flag help and regenerate docs/reference/cli; and state the .bash_profile, .bash_login, .profile order and ZDOTDIR in docs/how-to/install.md." +--- + +After the setup redesign, several setup descriptions are incomplete rather than false: the brief's abcd home tree omits identity/, tools.json and shell-path.json and says backups/ holds only the assistant's settings; the uninstall summary omits that uninstall removes the shell PATH line and abcd's own tool copies; the tool paragraph omits the owned-copy upgrade offer; the --yes help text does not list the shell settings, the commit checks or tools among what it skips; and the public install how-to simplifies which bash startup file is edited. From ddce20f8b478a33a03275aa1b7faa59c636f8e6d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:13:18 +0200 Subject: [PATCH 20/26] docs: ship the identity, tools and shell-settings intents The setup-flow spec closes with a remainder for criterion 5, the question budget, held for the product thinker's ruling (dcn-2610241228598485), so that intent stays planned. Delivers: itd-2610240918547728 Delivers: itd-2610240918594138 Delivers: itd-2610240919030303 Assisted-by: Claude:claude-opus-5-5 --- ...-each-person-s-commit-identity-lives-on.md | 2 +- ...very-person-who-commits-to-a-repository.md | 4 +++- ...-when-abcd-needs-another-tool-such-as-a.md | 4 +++- ...3-when-the-folder-abcd-installs-into-is.md | 4 +++- ...-person-sets-abcd-up-in-a-repository-in.md | 0 ...very-person-who-commits-to-a-repository.md | 0 ...-when-abcd-needs-another-tool-such-as-a.md | 0 ...5-when-the-folder-abcd-installs-into-is.md | 0 ...-2610242112593216-setup-question-budget.md | 21 +++++++++++++++++++ 9 files changed, 31 insertions(+), 4 deletions(-) rename .abcd/development/intents/{planned => shipped}/itd-2610240918547728-every-person-who-commits-to-a-repository.md (96%) rename .abcd/development/intents/{planned => shipped}/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md (97%) rename .abcd/development/intents/{planned => shipped}/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md (95%) rename .abcd/development/specs/{open => closed}/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md (100%) rename .abcd/development/specs/{open => closed}/spc-2610241007568207-every-person-who-commits-to-a-repository.md (100%) rename .abcd/development/specs/{open => closed}/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md (100%) rename .abcd/development/specs/{open => closed}/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md (100%) create mode 100644 .abcd/development/specs/open/spc-2610242112593216-setup-question-budget.md diff --git a/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md b/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md index f881bc2022..a84df2e643 100644 --- a/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md +++ b/.abcd/development/decisions/adrs/2610241022541063-each-person-s-commit-identity-lives-on.md @@ -35,7 +35,7 @@ person's. And an unattended routine on a fresh machine (a cloud container) has no person at a terminal to establish an identity for. The product thinker accepted the reversal on 2026-10-24, in the Decisions of -[itd-2610240918547728](../../intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md), +[itd-2610240918547728](../../intents/shipped/itd-2610240918547728-every-person-who-commits-to-a-repository.md), after considering and declining the alternative of dropping the pin and checking only that the author is a human. diff --git a/.abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md b/.abcd/development/intents/shipped/itd-2610240918547728-every-person-who-commits-to-a-repository.md similarity index 96% rename from .abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md rename to .abcd/development/intents/shipped/itd-2610240918547728-every-person-who-commits-to-a-repository.md index 5d0b89b19d..dbb82ee11d 100644 --- a/.abcd/development/intents/planned/itd-2610240918547728-every-person-who-commits-to-a-repository.md +++ b/.abcd/development/intents/shipped/itd-2610240918547728-every-person-who-commits-to-a-repository.md @@ -59,7 +59,9 @@ _Each criterion confirmed by the product thinker, one at a time, on 2026-10-24._ ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-4c9b5e516397 --> +Fidelity review OWED (receipt rcp-4c9b5e516397). +<!-- abcd-review-end receipt=rcp-4c9b5e516397 --> ## Grounds diff --git a/.abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/intents/shipped/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md similarity index 97% rename from .abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md rename to .abcd/development/intents/shipped/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md index c7a3c630c7..793d17da56 100644 --- a/.abcd/development/intents/planned/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md +++ b/.abcd/development/intents/shipped/itd-2610240918594138-when-abcd-needs-another-tool-such-as-a.md @@ -57,7 +57,9 @@ _Each criterion confirmed by the product thinker, one at a time, on 2026-10-24; ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-cf94c62eaabe --> +Fidelity review OWED (receipt rcp-cf94c62eaabe). +<!-- abcd-review-end receipt=rcp-cf94c62eaabe --> ## Grounds diff --git a/.abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md b/.abcd/development/intents/shipped/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md similarity index 95% rename from .abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md rename to .abcd/development/intents/shipped/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md index 374b4a3b26..4ebaa22bc2 100644 --- a/.abcd/development/intents/planned/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md +++ b/.abcd/development/intents/shipped/itd-2610240919030303-when-the-folder-abcd-installs-into-is.md @@ -55,4 +55,6 @@ _Each criterion confirmed by the product thinker, one at a time, on 2026-10-24._ ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ +<!-- abcd-review: OWED receipt=rcp-7e2730a34ce4 --> +Fidelity review OWED (receipt rcp-7e2730a34ce4). +<!-- abcd-review-end receipt=rcp-7e2730a34ce4 --> diff --git a/.abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md b/.abcd/development/specs/closed/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md similarity index 100% rename from .abcd/development/specs/open/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md rename to .abcd/development/specs/closed/spc-2610241007434505-a-person-sets-abcd-up-in-a-repository-in.md diff --git a/.abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md b/.abcd/development/specs/closed/spc-2610241007568207-every-person-who-commits-to-a-repository.md similarity index 100% rename from .abcd/development/specs/open/spc-2610241007568207-every-person-who-commits-to-a-repository.md rename to .abcd/development/specs/closed/spc-2610241007568207-every-person-who-commits-to-a-repository.md diff --git a/.abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md b/.abcd/development/specs/closed/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md similarity index 100% rename from .abcd/development/specs/open/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md rename to .abcd/development/specs/closed/spc-2610241008092356-when-abcd-needs-another-tool-such-as-a.md diff --git a/.abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md b/.abcd/development/specs/closed/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md similarity index 100% rename from .abcd/development/specs/open/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md rename to .abcd/development/specs/closed/spc-2610241008191355-when-the-folder-abcd-installs-into-is.md diff --git a/.abcd/development/specs/open/spc-2610242112593216-setup-question-budget.md b/.abcd/development/specs/open/spc-2610242112593216-setup-question-budget.md new file mode 100644 index 0000000000..c014f46b41 --- /dev/null +++ b/.abcd/development/specs/open/spc-2610242112593216-setup-question-budget.md @@ -0,0 +1,21 @@ +--- +id: spc-2610242112593216 +slug: setup-question-budget +intent: itd-2610240823055889 +origin: researcher-authored +production_mode: hand-written +--- +# setup-question-budget + +## Summary + +What spc-2610241007434505 did not deliver for itd-2610240823055889: criterion 5, the question budget. On a fresh account with the assistant's settings file present, setup asks the download, shell settings, commit checks, status line and identity questions plus one per missing tool. That is more than the four-plus-tools the criterion allows, because criterion 3 keeps the status line its own approved question and the commit-checks question was added after review (iss-2610241134315056). This spec holds until the product thinker rules on dcn-2610241228598485: count these in the budget, fold the status line into the download question, or move them onto the plan screen. It then brings the run within the ruled budget, with a test counting the questions a fresh account is asked. + +## Footprint + +- packages: internal/core/ahoy, internal/surface/cli +- tests: internal/core/ahoy/question_budget_test.go + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. <title>`, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ From c95c25121ec37d534f1a1749fbbdbe59393ac18a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:23:36 +0100 Subject: [PATCH 21/26] fix: settle the setup tests and record the entry a forced --dev writes The whole-package run of the setup review fixes found what the narrow runs missed: - A forced mode switch (--dev over a pinned entry) wrote the dev shim but no longer recorded it, so every hook ignored it (symlink.unrecorded). stepPathEntry was gated on the settings approval, which used to arrive only because the identity gaps were approved with the settings; it now also records on the forced switch, the word stepSymlink already writes the entry on. This failed at the branch head before the review fixes too. - The bare-Enter, approval-order and question-row tests: the dependency approval is not asked at a terminal, the shell and commit-checks questions are shortened to fit the 24-row limit, and the commit-checks question now reads "Run the commit checks: set core.hooksPath to .githooks in .git/config?". - Waiting returns the commit-checks question, so an after-update run can put it. - Tests that install into a real clone under --yes, about something else, read the status the run would have had but for the unarmed hooks (heldByHooks), or arm the clone first; the one-line rerun answers yes to the identity record as well as the commit checks. Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/ahoy.go | 4 ++++ internal/core/ahoy/apply.go | 17 ++++++++++++----- internal/core/ahoy/banlist_scaffold_test.go | 2 +- internal/core/ahoy/commit_checks.go | 18 ++++-------------- internal/core/ahoy/dev_install_test.go | 2 +- internal/core/ahoy/docslint_seed_test.go | 2 +- internal/core/ahoy/marker_test.go | 4 ++-- internal/core/ahoy/outbound_shim_test.go | 4 ++-- internal/core/ahoy/outbound_switch_test.go | 4 ++-- internal/core/ahoy/prompt_order_test.go | 7 ++++--- internal/core/ahoy/remote_userinfo_test.go | 2 +- internal/core/ahoy/retired_docs_target_test.go | 4 ++-- internal/core/ahoy/setup_report_test.go | 6 ++++-- internal/core/ahoy/setup_review_test.go | 12 +++++++++++- internal/core/ahoy/shell_path.go | 3 +-- internal/core/ahoy/waiting.go | 2 ++ internal/surface/cli/ahoy_prompt_stdin_test.go | 1 + .../surface/cli/ahoy_record_tiers_lint_test.go | 14 ++++++++++++++ 18 files changed, 69 insertions(+), 39 deletions(-) diff --git a/internal/core/ahoy/ahoy.go b/internal/core/ahoy/ahoy.go index 7a912668de..38c3b8bf13 100644 --- a/internal/core/ahoy/ahoy.go +++ b/internal/core/ahoy/ahoy.go @@ -298,6 +298,10 @@ type InstallResult struct { // skipped and ends with no .abcd/config.json to keep its answer in, so the // summary names the starter files first (iss-2610120505551170). readmeAwaitsConfig bool + // heldByHooks is the status the run would have had but for committed + // hooks git does not run, which make it partial (iss-2610241134315056); + // "" when they did not. + heldByHooks string } // ReadmeAwaitsConfig reports whether the run left the README question diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 46d33e6049..f23ac5e95f 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -74,7 +74,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er // An early return judged nothing it did not change: committed hooks // git does not run keep it from being reported up to date. if err == nil && res.Status == "already_up_to_date" && hooksUnrun(det.Gaps) { - res.Status = "partial" + res.heldByHooks, res.Status = res.Status, "partial" } }() @@ -391,17 +391,18 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er if len(remaining) > 0 { status = "partial" } - if hooksUnrun(final.Gaps) { + heldByHooks := "" + if hooksUnrun(final.Gaps) && status != "partial" { // Committed hooks git does not run are not a finished setup, whether // the question was declined, not asked, or the value is the person's // (iss-2610241134315056). - status = "partial" + heldByHooks, status = status, "partial" } if ac.configMalformed { // The config could not be parsed, so nothing that depends on it ran. // config.malformed is a diagnostic (non-resolvable) gap and so never // counts in Remaining, but a run that refused its own config is not clean. - status = "partial" + status, heldByHooks = "partial", "" } return InstallResult{ Status: status, @@ -416,6 +417,7 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er unverifiedCheck: ac.unverifiedCheck, unverifiedWhy: ac.unverifiedWhy, needs: append(ac.needs, remainingSteps(abs, final.Gaps)...), + heldByHooks: heldByHooks, }, nil } @@ -2007,7 +2009,12 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { // already produced, whose otherwise-clean install is why symlink.unrecorded is // a gap in its own right. func (a *applyCtx) stepPathEntry() { - if a.det.pluginRoot == "" || a.binTarget == "" || !a.approved[ConfigChange] { + // A forced mode switch (--dev, or a plain install over the shim) is the + // person's own word, and stepSymlink writes the entry on it alone; the + // entry it writes is recorded on the same word, or every hook ignores it + // (symlink.unrecorded). The settings approval used to arrive with it only + // because the identity gaps were approved with the settings. + if a.det.pluginRoot == "" || a.binTarget == "" || (!a.approved[ConfigChange] && !a.modeForced) { return } target := a.binTarget diff --git a/internal/core/ahoy/banlist_scaffold_test.go b/internal/core/ahoy/banlist_scaffold_test.go index 45e11b6028..0f9b342a6b 100644 --- a/internal/core/ahoy/banlist_scaffold_test.go +++ b/internal/core/ahoy/banlist_scaffold_test.go @@ -26,7 +26,7 @@ func TestInstallScaffoldsTheBanlistArtefacts(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Fatalf("install status = %q (remaining=%v), want clean", res.Status, res.Remaining) } diff --git a/internal/core/ahoy/commit_checks.go b/internal/core/ahoy/commit_checks.go index 4f9c4231aa..d354a97618 100644 --- a/internal/core/ahoy/commit_checks.go +++ b/internal/core/ahoy/commit_checks.go @@ -44,8 +44,7 @@ const HooksElsewhereGapID = "banlist.hooks_path_elsewhere" const ArmHooksCommand = "git config --local core.hooksPath " + guardHooksDirRelPath // armHooksAsk is the question the offer ends on. -const armHooksAsk = "Switch on this repository's commit checks, setting core.hooksPath to " + guardHooksDirRelPath + - " in this clone's .git/config?" +const armHooksAsk = "Run the commit checks: set core.hooksPath to " + guardHooksDirRelPath + " in .git/config?" // localHooksPathUnset reports whether this clone's own config sets no // core.hooksPath. known is false when git did not answer (not a repository, @@ -174,14 +173,10 @@ func (a *applyCtx) stepArmHooks() { // armHooksQuestion is what the person consents to: which hooks start // running, what stops running, how to undo it, and the question. func armHooksQuestion(cwd string) string { - text := "git does not run the commit checks committed in " + guardHooksDirRelPath - if names := committedHooks(cwd); len(names) > 0 { - text += " (" + strings.Join(names, ", ") + ")" - } - text += " in this clone: core.hooksPath is not set. Setting it makes git run the hooks in " + - guardHooksDirRelPath + " instead of those in .git/hooks" + text := "git does not run the hooks committed in " + guardHooksDirRelPath + " here: core.hooksPath is not set. " + + "Setting it runs them instead of .git/hooks" if own := ownGitHooks(cwd); len(own) > 0 { - text += ", so " + strings.Join(own, ", ") + " in .git/hooks stop running" + text += " (" + strings.Join(own, ", ") + " there stop running)" } return text + "; `git config --local --unset core.hooksPath` undoes it.\n" + armHooksAsk } @@ -192,11 +187,6 @@ func isArmHooksQuestion(text string) bool { return ask == armHooksAsk } -// committedHooks names the hooks in the committed directory. -func committedHooks(cwd string) []string { - return hookNames(filepath.Join(cwd, guardHooksDirRelPath)) -} - // ownGitHooks names the hooks in this clone's .git/hooks that git runs now, // the samples git ships left out. func ownGitHooks(cwd string) []string { diff --git a/internal/core/ahoy/dev_install_test.go b/internal/core/ahoy/dev_install_test.go index 5c14ec4fc1..a215f94f3d 100644 --- a/internal/core/ahoy/dev_install_test.go +++ b/internal/core/ahoy/dev_install_test.go @@ -151,7 +151,7 @@ func TestInstallPinnedToDevTransition(t *testing.T) { t.Fatal(err) } if res.Status != "clean" { - t.Fatalf("transition status = %q, want clean", res.Status) + t.Fatalf("transition status = %q (remaining %v, notes %q), want clean", res.Status, res.Remaining, res.Notes) } if !containsChange(res.Changes, "install_mode: pinned -> dev") { t.Errorf("changes = %v, want an install_mode pinned -> dev echo", res.Changes) diff --git a/internal/core/ahoy/docslint_seed_test.go b/internal/core/ahoy/docslint_seed_test.go index 4018b08bf3..f036de858e 100644 --- a/internal/core/ahoy/docslint_seed_test.go +++ b/internal/core/ahoy/docslint_seed_test.go @@ -38,7 +38,7 @@ func TestScaffoldedDocsLintRefusesAViolation(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Fatalf("install status = %q (remaining=%v), want clean", res.Status, res.Remaining) } cfgPath := filepath.Join(repo, filepath.FromSlash(banlist.PublicConfigRelPath)) diff --git a/internal/core/ahoy/marker_test.go b/internal/core/ahoy/marker_test.go index b819d20f90..037c1f6fce 100644 --- a/internal/core/ahoy/marker_test.go +++ b/internal/core/ahoy/marker_test.go @@ -339,7 +339,7 @@ func TestDefaultAdoptionWritesNoNameIntoConventionsFiles(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Fatalf("default adoption status = %q (remaining %v, notes %v), want clean", res.Status, res.Remaining, res.Notes) } @@ -396,7 +396,7 @@ func TestFirstInstallPlantsTheBlockWhereTheProjectChoseIt(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Fatalf("status = %q (remaining %v, notes %v), want clean", res.Status, res.Remaining, res.Notes) } for _, name := range tc.want { diff --git a/internal/core/ahoy/outbound_shim_test.go b/internal/core/ahoy/outbound_shim_test.go index b8c0537377..af38794d46 100644 --- a/internal/core/ahoy/outbound_shim_test.go +++ b/internal/core/ahoy/outbound_shim_test.go @@ -172,7 +172,7 @@ func TestOutboundShimWritesNoHookGitRunsWhereGitRunsOtherHooks(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "already_up_to_date" { + if settled(res) != "already_up_to_date" { t.Errorf("a rerun status = %q (writes %v), want already_up_to_date", res.Status, res.Writes) } assertNote(t, res, "core.hooksPath") @@ -311,7 +311,7 @@ func TestOutboundShimRefreshesOnlyItsOwnStaleCopy(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "already_up_to_date" { + if settled(res) != "already_up_to_date" { t.Errorf("a rerun over a current shim status = %q (writes %v), want already_up_to_date", res.Status, res.Writes) } if fi, err := os.Stat(shimPath(repo)); err != nil || !fi.ModTime().Equal(old) { diff --git a/internal/core/ahoy/outbound_switch_test.go b/internal/core/ahoy/outbound_switch_test.go index ed3b3c0aaf..e9dfb6f5fd 100644 --- a/internal/core/ahoy/outbound_switch_test.go +++ b/internal/core/ahoy/outbound_switch_test.go @@ -62,7 +62,7 @@ func TestOutboundSwitchWritesAbcdBinaryToTheVerifiedCopy(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "already_up_to_date" { + if settled(res) != "already_up_to_date" { t.Errorf("a second --outbound status = %q (writes %v, notes %v), want already_up_to_date", res.Status, res.Writes, res.Notes) } } @@ -190,7 +190,7 @@ func TestHalfOptedRepositoryIsCompleted(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Errorf("completing install status = %q (remaining %v), want clean", res.Status, res.Remaining) } if !outboundOptedIn(repo.Root()) || !guard.ForgeTextOptedIn(repo.Root()) { diff --git a/internal/core/ahoy/prompt_order_test.go b/internal/core/ahoy/prompt_order_test.go index 58c43ad75f..7d4ef08b1d 100644 --- a/internal/core/ahoy/prompt_order_test.go +++ b/internal/core/ahoy/prompt_order_test.go @@ -144,12 +144,13 @@ func TestResolveApprovalAsksUnknownCategoriesLast(t *testing.T) { Gap{ID: "alpha.a", Category: GapCategory("alpha"), Resolvable: true}, ) for i := 0; i < 32; i++ { + // At a terminal the dependency approval is not asked (asksToolsItself). p := &recordingPrompter{confirm: true, terminal: true} resolveApproval(gaps, InstallOptions{}, nil, p) - if len(p.asked) != 11 { - t.Fatalf("asked %d questions, want 11: %v", len(p.asked), p.asked) + if len(p.asked) != 10 { + t.Fatalf("asked %d questions, want 10: %v", len(p.asked), p.asked) } - tail := askedCategories(p.asked[9:]) + tail := askedCategories(p.asked[8:]) if tail != "alpha|zeta" { t.Fatalf("run %d: unknown categories not asked last and sorted: %v", i, p.asked) } diff --git a/internal/core/ahoy/remote_userinfo_test.go b/internal/core/ahoy/remote_userinfo_test.go index 9b82a3fc88..44121abfe8 100644 --- a/internal/core/ahoy/remote_userinfo_test.go +++ b/internal/core/ahoy/remote_userinfo_test.go @@ -113,7 +113,7 @@ func TestInstallScrubsALegacyCredentialFromTheHistoryStore(t *testing.T) { if res, err := Install(r.Root(), installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) - } else if res.Status != "clean" { + } else if settled(res) != "clean" { t.Fatalf("precondition: first install status = %q (remaining %v), want clean", res.Status, res.Remaining) } sha := deriveIdentity(r.Root()).RootSHA diff --git a/internal/core/ahoy/retired_docs_target_test.go b/internal/core/ahoy/retired_docs_target_test.go index cb7d9ab36a..f75201cac2 100644 --- a/internal/core/ahoy/retired_docs_target_test.go +++ b/internal/core/ahoy/retired_docs_target_test.go @@ -86,7 +86,7 @@ func TestInstallWritesNoToolConventionsFile(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != tc.wantStatus { + if settled(res) != tc.wantStatus { t.Errorf("status = %q (remaining %v, notes %v), want %q", res.Status, res.Remaining, res.Notes, tc.wantStatus) } for _, name := range toolFilesSetupNeverWrites { @@ -275,7 +275,7 @@ func TestChangingTheOneSettingMovesTheBlock(t *testing.T) { if err != nil { t.Fatal(err) } - if res.Status != "clean" { + if settled(res) != "clean" { t.Fatalf("status = %q (remaining %v, notes %v), want clean", res.Status, res.Remaining, res.Notes) } claude := filepath.Join(repo, "CLAUDE.md") diff --git a/internal/core/ahoy/setup_report_test.go b/internal/core/ahoy/setup_report_test.go index d743c31db4..9c8df53cd4 100644 --- a/internal/core/ahoy/setup_report_test.go +++ b/internal/core/ahoy/setup_report_test.go @@ -51,8 +51,10 @@ func TestFullySetUpRerunIsOneLine(t *testing.T) { // A first commit gives the repository the root commit abcd registers it by. idMustGit(t, repo, "commit", "--allow-empty", "-q", "-m", "first") clearIdentityEnv(t) - // Fully set up includes the commit checks git runs (iss-2610241134315056). - if _, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed, confirm: isArmHooksQuestion}); err != nil { + // Fully set up includes the commit checks git runs (iss-2610241134315056) + // and the person's own identity record, each answered yes. + first := func(q string) bool { return isArmHooksQuestion(q) || setupConfirmID(q) == UnrecordedGapID } + if _, err := Install(repo, planOpts(), &planPrompter{choice: PlanProceed, confirm: first}); err != nil { t.Fatal(err) } again := &planPrompter{choice: PlanProceed} diff --git a/internal/core/ahoy/setup_review_test.go b/internal/core/ahoy/setup_review_test.go index 16041de43e..0dc9144d86 100644 --- a/internal/core/ahoy/setup_review_test.go +++ b/internal/core/ahoy/setup_review_test.go @@ -292,7 +292,7 @@ func TestThePlanListsAToolOnlyWhenItWillAsk(t *testing.T) { // question, so customising the plan never drops it behind an approval. func TestCustomiseStillAsksTheToolQuestion(t *testing.T) { setupHermetic(t) - emptyPath(t) + t.Setenv("PATH", gitOnlyDir(t)) var calls [][]string fakeToolRun(t, &calls) asked := 0 @@ -444,3 +444,13 @@ func TestAForeignHooksPathIsNeverOffered(t *testing.T) { t.Error("a local hooks path was set over a dispatcher") } } + +// settled is res's status but for committed hooks git does not run, which +// a test about something else on a real clone under --yes leaves unarmed: +// those runs are partial for that reason alone (iss-2610241134315056). +func settled(res InstallResult) string { + if res.heldByHooks != "" { + return res.heldByHooks + } + return res.Status +} diff --git a/internal/core/ahoy/shell_path.go b/internal/core/ahoy/shell_path.go index 00eabe7638..d5775ea978 100644 --- a/internal/core/ahoy/shell_path.go +++ b/internal/core/ahoy/shell_path.go @@ -132,8 +132,7 @@ func shellPathQuestion(dir string, plan shellpath.Plan) string { where = "a new " + file } return displayPath(dir) + " is not on your PATH. abcd can add this line to " + where + - ", keeping a copy first. It puts " + displayPath(dir) + " ahead of the rest of your PATH, so this abcd answers " + - "before any older copy, and abcd ahoy uninstall removes it:\n" + + ", keeping a copy first; it goes ahead of the rest of your PATH, and ahoy uninstall removes it:\n" + " " + plan.Line + "\n" + "Add `" + plan.Export + "` to " + file + "?" } diff --git a/internal/core/ahoy/waiting.go b/internal/core/ahoy/waiting.go index 3f3527c1fb..42af1969dc 100644 --- a/internal/core/ahoy/waiting.go +++ b/internal/core/ahoy/waiting.go @@ -180,6 +180,8 @@ func armedQuestions(cwd string, det DetectionResult) []WaitingItem { } byGap("identity.refounding_candidate", "history.lineage") byGap(ReadmeOfferGapID, ReadmePromptKey) + // The commit checks' own question, where git runs no committed hook here. + byGap(HooksArmGapID, HooksArmGapID) for _, id := range []string{StatusLineOfferGapID, OracleRoutingMachineGapID, OracleRoutingRepoGapID, DrainRuleOfferGapID} { byGap(id, id) } diff --git a/internal/surface/cli/ahoy_prompt_stdin_test.go b/internal/surface/cli/ahoy_prompt_stdin_test.go index 40740354bb..e16ad3794a 100644 --- a/internal/surface/cli/ahoy_prompt_stdin_test.go +++ b/internal/surface/cli/ahoy_prompt_stdin_test.go @@ -216,6 +216,7 @@ func gitRepoWithIdentity(t *testing.T, repo, name, email string) { func TestAhoyInstallYesDisclosesOptionalIdentityPin(t *testing.T) { repo := hermeticRepo(t) gitRepoWithIdentity(t, repo, "Alex Reppel", "alex@example.com") + armClone(t, repo) // A zsh login shell, so the shell-settings offer is raised whatever shell // the machine running the test uses (itd-2610240919030303). t.Setenv("SHELL", "/bin/zsh") diff --git a/internal/surface/cli/ahoy_record_tiers_lint_test.go b/internal/surface/cli/ahoy_record_tiers_lint_test.go index 279b3543d5..159b7e49c7 100644 --- a/internal/surface/cli/ahoy_record_tiers_lint_test.go +++ b/internal/surface/cli/ahoy_record_tiers_lint_test.go @@ -27,6 +27,7 @@ func TestAhoyInstallLeavesTheLayoutLintClean(t *testing.T) { if b, err := git.CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, b) } + armClone(t, repo) out := runCLI(t, "ahoy", "install", "--yes", "--adopt", "--visibility", "private", "--docs-target", "agents_md", "--oracle-backend", "host-delegated", "--scan-deep", "false", "--json") @@ -88,3 +89,16 @@ func TestAhoyInstalledRepoClonesLintClean(t *testing.T) { } } } + +// armClone points the clone's git at the committed hooks, as a person's yes +// to the commit-checks question does: a --yes run never sets it, and a clone +// whose committed hooks do not run is never reported clean or up to date +// (iss-2610241134315056), which is not what these tests are about. +func armClone(t *testing.T, repo string) { + t.Helper() + cmd := exec.Command("git", "-C", repo, "config", "--local", "core.hooksPath", ".githooks") + cmd.Env = gittest.Env(t) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git config core.hooksPath: %v: %s", err, b) + } +} From d68fae4b6c47698a22473adda9fe8e5483585bd9 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:00:00 +0100 Subject: [PATCH 22/26] test: give the session-end retry test room to retry on a loaded machine TestHookSessionEndRetriesATransientRootFailure failed in every run on this branch, its head before the setup review fixes included: the hook's 1.2 s retry budget is spent by the first detection on a loaded machine, so the hook says no time is left to ask again and stages nothing. The test holds the retry, not the machine's speed, so it now runs with a 30 s budget; the hook's own budget is unchanged. Assisted-by: Claude:claude-opus-5-5 --- internal/surface/cli/hook_subagent_stage_retry_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/surface/cli/hook_subagent_stage_retry_test.go b/internal/surface/cli/hook_subagent_stage_retry_test.go index 30627ec61e..e10832d6e0 100644 --- a/internal/surface/cli/hook_subagent_stage_retry_test.go +++ b/internal/surface/cli/hook_subagent_stage_retry_test.go @@ -232,6 +232,12 @@ func TestHookSessionEndRetriesATransientRootFailure(t *testing.T) { t.Fatal(err) } failRootCommitGit(t, 1) + // The retry is what is held here, not the machine's speed: the hook's + // 1.2 s budget is spent by one detection on a loaded machine, which then + // leaves no time to ask again, so the test gives the retry room. + prev := sessionEndRetryBudget + sessionEndRetryBudget = 30 * time.Second + t.Cleanup(func() { sessionEndRetryBudget = prev }) _, stderr, failed := runHookAllowingFailure(endPayload(t, "sess-end-retry", repo, tp), "hook", "session-end") if failed { From d2196f80aed2d458532c2487ad8442ebb8976b5b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:50 +0200 Subject: [PATCH 23/26] docs: capture two hold-ups met during the setup redesign build Assisted-by: Claude:claude-opus-5-5 --- ...129778-abcd-capture-accepts-a-found-at-path.md | 15 +++++++++++++++ ...17-a-narrow-test-run-waits-at-the-load-gate.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610241414129778-abcd-capture-accepts-a-found-at-path.md create mode 100644 .abcd/work/issues/open/iss-2610241415121917-a-narrow-test-run-waits-at-the-load-gate.md diff --git a/.abcd/work/issues/open/iss-2610241414129778-abcd-capture-accepts-a-found-at-path.md b/.abcd/work/issues/open/iss-2610241414129778-abcd-capture-accepts-a-found-at-path.md new file mode 100644 index 0000000000..04ea141604 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241414129778-abcd-capture-accepts-a-found-at-path.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241414129778" +slug: "abcd-capture-accepts-a-found-at-path" +severity: "minor" +category: "process" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture" +remedy: "Check --found-at against the tree at capture time; refuse a path that does not exist (exit 2, naming the path and the closest existing one), and accept a location described in words only through an explicit flag such as --found-at-text, so a typo or a not-yet-written file is caught when it is made." +--- + +abcd capture accepts a --found-at path that does not exist in the tree without saying so: on 2026-10-24, during the setup redesign build, a capture naming a file that had not been written yet produced a record whose location points nowhere, and the mistake was noticed only later by hand. diff --git a/.abcd/work/issues/open/iss-2610241415121917-a-narrow-test-run-waits-at-the-load-gate.md b/.abcd/work/issues/open/iss-2610241415121917-a-narrow-test-run-waits-at-the-load-gate.md new file mode 100644 index 0000000000..b9b8b299c3 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610241415121917-a-narrow-test-run-waits-at-the-load-gate.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610241415121917" +slug: "a-narrow-test-run-waits-at-the-load-gate" +severity: "minor" +category: "process" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/implement" +remedy: "Give the load gate a size class, as Bazel's test sizes do: a run that declares itself small (one package, a -run filter, or an explicit --small) is admitted on a short lane alongside at most one large run, instead of queueing FIFO behind every preflight, and the waiter reports its position and what it is waiting behind." +--- + +A narrow test run waits at the load gate behind whole preflights from other sessions: on 2026-10-24, during the setup redesign build, a single-package test run queued through abcd implement load --wait sat behind several full preflights at a load average above 100, never started, and was cancelled by hand, so a few seconds of checking cost the lane most of an hour. From 7d8158ca96675dfcb1d294b2a11ba57f63ab3f46 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:15:20 +0200 Subject: [PATCH 24/26] docs: capture the duplicated download rule Assisted-by: Claude:claude-opus-5-5 --- ...5122817-the-rule-for-downloading-a-verified.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610242115122817-the-rule-for-downloading-a-verified.md diff --git a/.abcd/work/issues/open/iss-2610242115122817-the-rule-for-downloading-a-verified.md b/.abcd/work/issues/open/iss-2610242115122817-the-rule-for-downloading-a-verified.md new file mode 100644 index 0000000000..542771eefc --- /dev/null +++ b/.abcd/work/issues/open/iss-2610242115122817-the-rule-for-downloading-a-verified.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610242115122817" +slug: "the-rule-for-downloading-a-verified" +severity: "minor" +category: "tech-debt" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/update" +remedy: "Extract one download-and-verify primitive (allowed hosts, redirect policy, size cap, binding to the release tag and checksum) into internal/core/update or a small shared package, and have the tool installer's fetch call it, with a test that both callers refuse the same off-host redirect." +--- + +The rule for downloading a verified release now exists twice: abcd's own update path (internal/core/update) and the new tool installer on the setup redesign branch each decide which hosts may be reached and how a download is bound to its release, so any fix to that rule must be made in both and can drift between them. From 4d1e88665c4a3b303b883600cadd687dc192d976 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:01:48 +0100 Subject: [PATCH 25/26] docs: capture the session-end retry budget that runs out under load Assisted-by: Claude:claude-opus-5-5 --- ...201361740-the-session-end-hook-s-retry-of-a.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610251201361740-the-session-end-hook-s-retry-of-a.md diff --git a/.abcd/work/issues/open/iss-2610251201361740-the-session-end-hook-s-retry-of-a.md b/.abcd/work/issues/open/iss-2610251201361740-the-session-end-hook-s-retry-of-a.md new file mode 100644 index 0000000000..5508bb7166 --- /dev/null +++ b/.abcd/work/issues/open/iss-2610251201361740-the-session-end-hook-s-retry-of-a.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610251201361740" +slug: "the-session-end-hook-s-retry-of-a" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "manual-capture" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli" +remedy: "Count the retry budget in attempts with a short backoff (for example three tries) rather than in wall-clock time that the first try can consume, or start the clock after the first try returns; keep a hard ceiling so a session end never hangs, and say on stderr when the store was skipped." +--- + +The session-end hook's retry of a transient repository-root failure has a 1.2-second budget, so on a loaded machine the first detection uses it all and the hook never retries: on 2026-10-25 TestHookSessionEndRetriesATransientRootFailure failed this way at a load near 20, and the same budget applies to real session ends, where the transcript store step is then skipped. From efd10b945ce972e52ca8f3bd1b0ec81d23acf52a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:51:31 +0100 Subject: [PATCH 26/26] fix: satisfy the self-checks the setup redesign's new code trips The tools registry and four test files spelled the home folder's name; they now reach it through abcdhome.Display and abcdhome.Path. The go run vintage check asks git's top level through gitutil.Toplevel and its test runs git under gittest.Env. The identity, provenance and shellpath functions no production code outside their package reaches are unexported; ReadRecordAt, a bare wrapper, is gone, and the ahoy tests read the identity record through identity.LoadRecordForTest. The stale identity.LoadPin line leaves the reach baseline. Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/apply_test.go | 4 +-- internal/core/ahoy/gorun_vintage.go | 2 +- internal/core/ahoy/gorun_vintage_test.go | 5 +++- internal/core/ahoy/identity_establish_test.go | 2 +- internal/core/ahoy/identity_gap_test.go | 4 +-- .../core/ahoy/install_summary_truth_test.go | 2 +- internal/core/ahoy/setup_review_test.go | 5 ++-- internal/core/ahoy/swallowed_writes_test.go | 2 +- internal/core/ahoy/tools_route_test.go | 5 ++-- internal/core/identity/establish.go | 8 ++--- internal/core/identity/hook_test.go | 9 +++--- internal/core/identity/identity.go | 8 ++--- internal/core/identity/identity_test.go | 14 ++++----- internal/core/identity/migrate.go | 10 +++---- internal/core/identity/migrate_test.go | 10 +++---- internal/core/identity/record.go | 29 ++++++++++--------- internal/core/provenance/declared.go | 2 +- internal/core/provenance/provenance.go | 10 +++---- internal/core/provenance/provenance_test.go | 8 ++--- internal/core/shellpath/shellpath.go | 16 +++++----- internal/core/shellpath/shellpath_test.go | 4 +-- internal/core/tools/registry.go | 8 +++-- internal/core/tools/tools_test.go | 3 +- .../reachaudit/testdata/core-unreached.txt | 1 - 24 files changed, 92 insertions(+), 79 deletions(-) diff --git a/internal/core/ahoy/apply_test.go b/internal/core/ahoy/apply_test.go index ffb771bae5..f263cf4aa1 100644 --- a/internal/core/ahoy/apply_test.go +++ b/internal/core/ahoy/apply_test.go @@ -210,7 +210,7 @@ func TestInstallRecordsTheIdentityAtATerminal(t *testing.T) { if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) } - if _, _, ok, _ := identity.LoadRecord(home, ""); ok { + if _, _, ok, _ := identity.LoadRecordForTest(home, ""); ok { t.Fatal("precondition: --yes must not record the current identity") } det, err := Detect(repo) @@ -227,7 +227,7 @@ func TestInstallRecordsTheIdentityAtATerminal(t *testing.T) { if _, err := Install(repo, InstallOptions{}, &recordingPrompter{confirm: true, terminal: true}); err != nil { t.Fatal(err) } - got, _, ok, err := identity.LoadRecord(home, "") + got, _, ok, err := identity.LoadRecordForTest(home, "") if err != nil || !ok { t.Fatalf("an install at a terminal did not record the identity: ok=%v err=%v", ok, err) } diff --git a/internal/core/ahoy/gorun_vintage.go b/internal/core/ahoy/gorun_vintage.go index 0b3726c2c1..fd0e9ae1d0 100644 --- a/internal/core/ahoy/gorun_vintage.go +++ b/internal/core/ahoy/gorun_vintage.go @@ -42,7 +42,7 @@ func goRunOfCheckout(cwd string) bool { // checkoutModule is the module path the go.mod at the top of the checkout // holding cwd declares, or "" when there is none. func checkoutModule(cwd string) string { - top, err := gitutil.Run(cwd, "rev-parse", "--show-toplevel") + top, err := gitutil.Toplevel(cwd) if err != nil || top == "" { return "" } diff --git a/internal/core/ahoy/gorun_vintage_test.go b/internal/core/ahoy/gorun_vintage_test.go index b125504535..9fcb700966 100644 --- a/internal/core/ahoy/gorun_vintage_test.go +++ b/internal/core/ahoy/gorun_vintage_test.go @@ -7,13 +7,16 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/vintage" + "github.com/intentdriven/abcd/internal/gittest" ) // sourceCheckout is a git checkout whose go.mod declares module. func sourceCheckout(t *testing.T, module string) string { t.Helper() dir := t.TempDir() - if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { + cmd := exec.Command("git", "-C", dir, "init", "-q") + cmd.Env = gittest.Env(t) + if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, out) } if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+module+"\n\ngo 1.26\n"), 0o644); err != nil { diff --git a/internal/core/ahoy/identity_establish_test.go b/internal/core/ahoy/identity_establish_test.go index 0ba40884be..d98b5c980f 100644 --- a/internal/core/ahoy/identity_establish_test.go +++ b/internal/core/ahoy/identity_establish_test.go @@ -268,7 +268,7 @@ func TestStepIdentityRecord_RefusesAToolIdentity(t *testing.T) { dir := idGitRepo(t, "Claude", "noreply@anthropic.com") a := establishCtx(dir, &identityPrompter{terminal: true, answer: true}, UnrecordedGapID) a.stepIdentityRecord() - if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + if _, _, ok, _ := identity.LoadRecordForTest(os.Getenv("HOME"), ""); ok { t.Fatal("a tool identity was recorded as the person") } if len(a.notes) == 0 { diff --git a/internal/core/ahoy/identity_gap_test.go b/internal/core/ahoy/identity_gap_test.go index 263f93d78c..894fec3abc 100644 --- a/internal/core/ahoy/identity_gap_test.go +++ b/internal/core/ahoy/identity_gap_test.go @@ -135,7 +135,7 @@ func TestStepIdentityRecord_RecordsOnYesAtATerminal(t *testing.T) { if len(p.asked) != 1 || !strings.Contains(p.asked[0], "Alex Reppel <alex@example.com>") { t.Fatalf("want one question proposing the usual identity, got %q", p.asked) } - got, _, ok, err := identity.LoadRecord(os.Getenv("HOME"), "") + got, _, ok, err := identity.LoadRecordForTest(os.Getenv("HOME"), "") if err != nil || !ok || got.Name != "Alex Reppel" || got.Email != "alex@example.com" { t.Fatalf("record not written: %+v ok=%v err=%v", got, ok, err) } @@ -163,7 +163,7 @@ func TestStepIdentityRecord_WritesNothingOffATerminalUnderYesOrOnNo(t *testing.T p := &identityPrompter{terminal: true, answer: true} ctx := a(dir, p) ctx.stepIdentityRecord() - if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + if _, _, ok, _ := identity.LoadRecordForTest(os.Getenv("HOME"), ""); ok { t.Fatal("a record was written without a yes at a terminal") } if name != "declined" && len(p.asked) != 0 { diff --git a/internal/core/ahoy/install_summary_truth_test.go b/internal/core/ahoy/install_summary_truth_test.go index 978cfd2b6a..167df2cc79 100644 --- a/internal/core/ahoy/install_summary_truth_test.go +++ b/internal/core/ahoy/install_summary_truth_test.go @@ -22,7 +22,7 @@ func TestIdentityRecordActionSaysHowToCorrectIt(t *testing.T) { p := &identityPrompter{terminal: true, answer: true} a := &applyCtx{cwd: dir, gapPresent: gapPresent, prompter: p} a.stepIdentityRecord() - rec, _, ok, err := identity.LoadRecord(os.Getenv("HOME"), "") + rec, _, ok, err := identity.LoadRecordForTest(os.Getenv("HOME"), "") if err != nil || !ok || rec.Name != "Wrong Person" || len(p.asked) != 0 { t.Fatalf("precondition: a re-run leaves a record as it is; got %+v ok=%v err=%v asked=%q", rec, ok, err, p.asked) } diff --git a/internal/core/ahoy/setup_review_test.go b/internal/core/ahoy/setup_review_test.go index 0dc9144d86..9137591647 100644 --- a/internal/core/ahoy/setup_review_test.go +++ b/internal/core/ahoy/setup_review_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/abcdhome" "github.com/intentdriven/abcd/internal/core/shellpath" "github.com/intentdriven/abcd/internal/core/tools" "github.com/intentdriven/abcd/internal/gittest" @@ -102,12 +103,12 @@ func TestAFailedAbcdDownloadIsANeedsYouStep(t *testing.T) { func TestAFailedShellEditIsANeedsYouStep(t *testing.T) { prev := shellApply shellApply = func(shellpath.Env, shellpath.Plan) (shellpath.Applied, error) { - return shellpath.Applied{}, &shellpath.RestoreError{File: "~/.zshrc", Backup: "~/.abcd.noindex/backups/shell-zshrc.20261024T000000Z", Cause: "disk full"} + return shellpath.Applied{}, &shellpath.RestoreError{File: "~/.zshrc", Backup: abcdhome.Display("backups", "shell-zshrc.20261024T000000Z"), Cause: "disk full"} } t.Cleanup(func() { shellApply = prev }) _, res, _ := shellPathRun(t, true, true, interactiveOpts()) needs := needsJoined(res.Report) - if !strings.Contains(needs, "~/.abcd.noindex/backups/shell-zshrc.20261024T000000Z") || !strings.Contains(needs, "~/.zshrc") { + if !strings.Contains(needs, abcdhome.Display("backups", "shell-zshrc.20261024T000000Z")) || !strings.Contains(needs, "~/.zshrc") { t.Fatalf("the failed edit is not a needs-you step with the copy to put back:\n%s\nnotes %q", needs, res.Notes) } } diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go index 71c061bde4..b29bf1e421 100644 --- a/internal/core/ahoy/swallowed_writes_test.go +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -42,7 +42,7 @@ func TestIdentityRecordFailureIsNoted(t *testing.T) { dir := idGitRepo(t, gitName, email) a := &applyCtx{cwd: dir, gapPresent: map[string]bool{UnrecordedGapID: true}, prompter: &identityPrompter{terminal: true, answer: true}} a.stepIdentityRecord() - if _, _, ok, _ := identity.LoadRecord(os.Getenv("HOME"), ""); ok { + if _, _, ok, _ := identity.LoadRecordForTest(os.Getenv("HOME"), ""); ok { t.Fatal("precondition: no record is written") } if !notesCarryAll(a.notes, "did not record who you commit as") { diff --git a/internal/core/ahoy/tools_route_test.go b/internal/core/ahoy/tools_route_test.go index 87aea91ce4..2561d6b3fc 100644 --- a/internal/core/ahoy/tools_route_test.go +++ b/internal/core/ahoy/tools_route_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/abcdhome" "github.com/intentdriven/abcd/internal/core/tools" ) @@ -332,11 +333,11 @@ func TestUninstallRemovesTheToolCopiesAbcdRecorded(t *testing.T) { } } sum := sha256.Sum256([]byte("program")) - if err := os.MkdirAll(filepath.Join(home, ".abcd.noindex"), 0o700); err != nil { + if err := os.MkdirAll(abcdhome.Path(home), 0o700); err != nil { t.Fatal(err) } rec := `{"schema_version":1,"tools":{"gitleaks":{"path":"` + ours + `","sha256":"` + hex.EncodeToString(sum[:]) + `","version":"8.30.1"}}}` - if err := os.WriteFile(filepath.Join(home, ".abcd.noindex", "tools.json"), []byte(rec), 0o600); err != nil { + if err := os.WriteFile(abcdhome.Path(home, "tools.json"), []byte(rec), 0o600); err != nil { t.Fatal(err) } receipt, err := Uninstall(t.TempDir(), "") diff --git a/internal/core/identity/establish.go b/internal/core/identity/establish.go index 45e5049359..6c390f1d29 100644 --- a/internal/core/identity/establish.go +++ b/internal/core/identity/establish.go @@ -26,16 +26,16 @@ type Proposal struct { // anyone's identity but their own. ok is false when neither source holds a // complete human identity. func Propose(root string) (Proposal, bool, error) { - return ProposeWithHome(root, UserHome()) + return proposeWithHome(root, userHome()) } -// ProposeWithHome is Propose for the person whose home directory is home. -func ProposeWithHome(root, home string) (Proposal, bool, error) { +// proposeWithHome is Propose for the person whose home directory is home. +func proposeWithHome(root, home string) (Proposal, bool, error) { rootSHA, err := gitutil.RootCommit(root) if err != nil { return Proposal{}, false, err } - rec, ref, recorded, err := LoadRecord(home, rootSHA) + rec, ref, recorded, err := loadRecord(home, rootSHA) if err != nil { return Proposal{}, false, err } diff --git a/internal/core/identity/hook_test.go b/internal/core/identity/hook_test.go index 4c43afb755..d5b762ea2e 100644 --- a/internal/core/identity/hook_test.go +++ b/internal/core/identity/hook_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/abcdhome" "github.com/intentdriven/abcd/internal/gittest" ) @@ -110,7 +111,7 @@ func TestPreCommitHook_IdentityGate(t *testing.T) { }{ {name: "no record passes a human", gitName: "Whoever", gitEmail: "who@ever.com"}, {name: "match passes", record: `{"name":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "a@b.com"}, - {name: "mismatch blocks, naming the record and how to change it", record: `{"name":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "typo@b.com", wantBlock: true, wantSay: "~/.abcd.noindex/identity.json, or remove it"}, + {name: "mismatch blocks, naming the record and how to change it", record: `{"name":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "typo@b.com", wantBlock: true, wantSay: abcdhome.Display("identity.json") + ", or remove it"}, {name: "pretty-printed match passes", record: "{\n \"name\": \"Alex\",\n \"email\": \"a@b.com\"\n}\n", gitName: "Alex", gitEmail: "a@b.com"}, {name: "non-canonical key blocks (fail closed)", record: `{"NAME":"Alex","email":"a@b.com"}`, gitName: "Alex", gitEmail: "a@b.com", wantBlock: true}, {name: "empty value blocks (fail closed)", record: `{"name":"","email":""}`, gitName: "Alex", gitEmail: "a@b.com", wantBlock: true}, @@ -192,7 +193,7 @@ func TestPreCommitHook_RefusesAnUntrustedRecord(t *testing.T) { if err := os.WriteFile(filepath.Join(real, "identity.json"), []byte(rec), 0o600); err != nil { t.Fatal(err) } - if err := os.Symlink(real, filepath.Join(home, ".abcd.noindex")); err != nil { + if err := os.Symlink(real, abcdhome.Path(home)); err != nil { t.Fatal(err) } }, wantBlock: true}, @@ -201,7 +202,7 @@ func TestPreCommitHook_RefusesAnUntrustedRecord(t *testing.T) { if err := os.MkdirAll(real, 0o700); err != nil { t.Fatal(err) } - if err := os.Symlink(real, filepath.Join(home, ".abcd.noindex")); err != nil { + if err := os.Symlink(real, abcdhome.Path(home)); err != nil { t.Fatal(err) } }}, @@ -240,7 +241,7 @@ func TestPreCommitHook_RepoRecordOutranksTheMachineRecord(t *testing.T) { hookGit(t, dir, env, "commit", "--no-verify", "-m", "root") sha := gittest.RootCommit(t, dir) writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) - ref, err := RepoRecordRef(sha) + ref, err := repoRecordRef(sha) if err != nil { t.Fatal(err) } diff --git a/internal/core/identity/identity.go b/internal/core/identity/identity.go index 6fedef944b..ca39b96b83 100644 --- a/internal/core/identity/identity.go +++ b/internal/core/identity/identity.go @@ -239,16 +239,16 @@ func commitConfigEnv() []string { // record for the repository at root (from the home directory os.UserHomeDir // names), and compares them. func Check(root string) (Result, error) { - return CheckWithHome(root, UserHome()) + return checkWithHome(root, userHome()) } -// CheckWithHome is Check for the person whose home directory is home. -func CheckWithHome(root, home string) (Result, error) { +// checkWithHome is Check for the person whose home directory is home. +func checkWithHome(root, home string) (Result, error) { rootSHA, err := gitutil.RootCommit(root) if err != nil { return Result{}, err } - rec, ref, recorded, err := LoadRecord(home, rootSHA) + rec, ref, recorded, err := loadRecord(home, rootSHA) if err != nil { return Result{}, err } diff --git a/internal/core/identity/identity_test.go b/internal/core/identity/identity_test.go index 3e9ab0a4f4..dbac2ba636 100644 --- a/internal/core/identity/identity_test.go +++ b/internal/core/identity/identity_test.go @@ -149,7 +149,7 @@ func TestCheck_RepoRecordOutranksTheMachineRecord(t *testing.T) { runGitT(t, dir, "commit", "--allow-empty", "-m", "root", "--no-verify") sha := gittest.RootCommit(t, dir) writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) - ref, err := RepoRecordRef(sha) + ref, err := repoRecordRef(sha) if err != nil { t.Fatal(err) } @@ -286,9 +286,9 @@ func TestWriteRecord_RoundTrip(t *testing.T) { if err := WriteRecord(home, MachineRecordRef(), want); err != nil { t.Fatal(err) } - got, ref, ok, err := LoadRecord(home, "") + got, ref, ok, err := loadRecord(home, "") if err != nil || !ok { - t.Fatalf("LoadRecord after WriteRecord: ok=%v err=%v", ok, err) + t.Fatalf("loadRecord after WriteRecord: ok=%v err=%v", ok, err) } if got != want || ref != MachineRecordRef() { t.Fatalf("round-trip mismatch: got %+v at %+v, want %+v", got, ref, want) @@ -304,7 +304,7 @@ func TestWriteRecord_RoundTrip(t *testing.T) { func TestWriteRecord_RepoScope(t *testing.T) { home := recordHome(t) - ref, err := RepoRecordRef(strings.Repeat("ab", 20)) + ref, err := repoRecordRef(strings.Repeat("ab", 20)) if err != nil { t.Fatal(err) } @@ -312,11 +312,11 @@ func TestWriteRecord_RepoScope(t *testing.T) { if err := WriteRecord(home, ref, want); err != nil { t.Fatal(err) } - got, at, ok, err := LoadRecord(home, strings.Repeat("ab", 20)) + got, at, ok, err := loadRecord(home, strings.Repeat("ab", 20)) if err != nil || !ok || got != want || at != ref { t.Fatalf("got %+v at %+v ok=%v err=%v", got, at, ok, err) } - if _, err := RepoRecordRef("../../etc"); err == nil { + if _, err := repoRecordRef("../../etc"); err == nil { t.Fatal("a root name that is not an object name must be refused") } } @@ -364,7 +364,7 @@ func TestWriteRecord_LegalIdentitiesRoundTrip(t *testing.T) { if err := WriteRecord(home, MachineRecordRef(), want); err != nil { t.Fatalf("legal identity %+v must write: %v", want, err) } - got, _, ok, err := LoadRecord(home, "") + got, _, ok, err := loadRecord(home, "") if err != nil || !ok || got != want { t.Fatalf("round-trip %+v: got %+v ok=%v err=%v", want, got, ok, err) } diff --git a/internal/core/identity/migrate.go b/internal/core/identity/migrate.go index 6712963f15..96361fc6af 100644 --- a/internal/core/identity/migrate.go +++ b/internal/core/identity/migrate.go @@ -34,12 +34,12 @@ type LegacyPin struct { Unknown []string } -// LoadLegacyPin reads the committed pin at root through the guarded reader +// loadLegacyPin reads the committed pin at root through the guarded reader // (fsutil.ReadGuardedInRoot): resolved inside an os.Root at the working tree, // a symlinked pin refused rather than followed, only a regular file accepted, // and capped at 64 KiB. found is false when the repository has none; a pin // that is there and refused is an error. -func LoadLegacyPin(root string) (LegacyPin, bool, error) { +func loadLegacyPin(root string) (LegacyPin, bool, error) { osRoot, err := os.OpenRoot(root) if err != nil { return LegacyPin{}, false, err @@ -111,7 +111,7 @@ type MigrationPlan struct { // would do for the person whose home directory is home. found is false when the // repository stores no pin. func PlanMigration(root, home string) (MigrationPlan, bool, error) { - pin, found, err := LoadLegacyPin(root) + pin, found, err := loadLegacyPin(root) if err != nil || !found { return MigrationPlan{}, found, err } @@ -140,11 +140,11 @@ func PlanMigration(root, home string) (MigrationPlan, bool, error) { return MigrationPlan{}, true, err } plan.Target = MachineRecordRef() - if ref, err := RepoRecordRef(rootSHA); err == nil { + if ref, err := repoRecordRef(rootSHA); err == nil { plan.Target = ref } if plan.Owner { - plan.Existing, plan.ExistingFound, err = ReadRecordAt(home, plan.Target) + plan.Existing, plan.ExistingFound, err = readRecord(home, plan.Target) if err != nil { return MigrationPlan{}, true, err } diff --git a/internal/core/identity/migrate_test.go b/internal/core/identity/migrate_test.go index 5b61a82e90..1997356464 100644 --- a/internal/core/identity/migrate_test.go +++ b/internal/core/identity/migrate_test.go @@ -77,7 +77,7 @@ func TestMigration_NonOwnerIsNotGivenThePin(t *testing.T) { if _, err := ApplyMigration(dir, home, plan); err == nil { t.Fatal("ApplyMigration must refuse a person who is not the owner") } - if _, _, found, err := LoadRecord(home, gittestRoot(t, dir)); found || err != nil { + if _, _, found, err := loadRecord(home, gittestRoot(t, dir)); found || err != nil { t.Fatalf("the maintainer's identity reached a contributor's machine: found=%v err=%v", found, err) } if _, err := os.Stat(filepath.Join(dir, LegacyPinRelPath)); err != nil { @@ -114,7 +114,7 @@ func TestMigration_KeepsADifferentRecord(t *testing.T) { dir := committedRepo(t, "A Maintainer", "maintainer@example.com") writeRepoPin(t, dir, `{"name":"A Maintainer","email":"maintainer@example.com"}`) home := recordHome(t) - ref, err := RepoRecordRef(gittestRoot(t, dir)) + ref, err := repoRecordRef(gittestRoot(t, dir)) if err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestMigration_KeepsADifferentRecord(t *testing.T) { if !res.KeptDifferent || res.Recorded || !res.Removed { t.Fatalf("want the existing record kept and the pin removed: %+v", res) } - got, _, _, _ := LoadRecord(home, gittestRoot(t, dir)) + got, _, _, _ := loadRecord(home, gittestRoot(t, dir)) if got.Email != "maintainer@work.example" { t.Fatalf("the person's own record was overwritten: %+v", got) } @@ -174,7 +174,7 @@ func TestLoadLegacyPin_ReadsThroughTheGuardedReader(t *testing.T) { if err := os.Symlink(filepath.Join("..", "..", filepath.Base(real)), pin); err != nil { t.Fatal(err) } - if _, _, err := LoadLegacyPin(dir); err == nil { + if _, _, err := loadLegacyPin(dir); err == nil { t.Fatal("a symlinked pin was followed") } if err := os.Remove(pin); err != nil { @@ -183,7 +183,7 @@ func TestLoadLegacyPin_ReadsThroughTheGuardedReader(t *testing.T) { if err := os.WriteFile(pin, []byte(`{"name":"A Maintainer","email":"maintainer@example.com","pad":"`+strings.Repeat("x", 70<<10)+`"}`), 0o644); err != nil { t.Fatal(err) } - if _, _, err := LoadLegacyPin(dir); err == nil { + if _, _, err := loadLegacyPin(dir); err == nil { t.Fatal("a pin over 64 KiB was read") } } diff --git a/internal/core/identity/record.go b/internal/core/identity/record.go index 81f97d173e..3cd990b8a9 100644 --- a/internal/core/identity/record.go +++ b/internal/core/identity/record.go @@ -68,10 +68,10 @@ func MachineRecordRef() RecordRef { return RecordRef{Scope: ScopeMachine, Rel: abcdhome.Rel(machineRecordLeaf), Display: abcdhome.Display(machineRecordLeaf)} } -// RepoRecordRef is the record for the repository whose root commit is rootSHA. +// repoRecordRef is the record for the repository whose root commit is rootSHA. // The name must be a full object name (40 or 64 lowercase hex digits): it // becomes a file name, so nothing else is accepted. -func RepoRecordRef(rootSHA string) (RecordRef, error) { +func repoRecordRef(rootSHA string) (RecordRef, error) { if !validRootSHA(rootSHA) { return RecordRef{}, fmt.Errorf("%q is not a full root-commit object name", rootSHA) } @@ -79,11 +79,11 @@ func RepoRecordRef(rootSHA string) (RecordRef, error) { return RecordRef{Scope: ScopeRepository, Rel: abcdhome.Rel(repoRecordDir, leaf), Display: abcdhome.Display(repoRecordDir, leaf)}, nil } -// RecordRefs is the lookup order for a repository: its own record, when the +// recordRefs is the lookup order for a repository: its own record, when the // repository has a root commit, then the machine-wide default. -func RecordRefs(rootSHA string) []RecordRef { +func recordRefs(rootSHA string) []RecordRef { var refs []RecordRef - if ref, err := RepoRecordRef(rootSHA); err == nil { + if ref, err := repoRecordRef(rootSHA); err == nil { refs = append(refs, ref) } return append(refs, MachineRecordRef()) @@ -105,7 +105,7 @@ func validRootSHA(s string) bool { // read: the gate fails closed on it, exactly as the shell guard does. var ErrRecordRefused = errors.New("identity record refused") -// LoadRecord returns the record that applies to the repository whose root +// loadRecord returns the record that applies to the repository whose root // commit is rootSHA ("" for a repository with no commit yet), for the person // whose home directory is home: the repository's own record, else the // machine-wide default. found is false when neither exists. @@ -117,11 +117,11 @@ var ErrRecordRefused = errors.New("identity record refused") // guard, or does not parse, or lacks a field, is an error wrapping // ErrRecordRefused, never a fall-through to the next file: a record the gate // cannot read must not read as no record. -func LoadRecord(home, rootSHA string) (rec Record, ref RecordRef, found bool, err error) { +func loadRecord(home, rootSHA string) (rec Record, ref RecordRef, found bool, err error) { if home == "" { return Record{}, RecordRef{}, false, nil } - for _, ref := range RecordRefs(rootSHA) { + for _, ref := range recordRefs(rootSHA) { rec, ok, err := readRecord(home, ref) if err != nil { return Record{}, ref, false, err @@ -133,11 +133,14 @@ func LoadRecord(home, rootSHA string) (rec Record, ref RecordRef, found bool, er return Record{}, RecordRef{}, false, nil } -// ReadRecordAt reads the one record ref names, with LoadRecord's guards. -func ReadRecordAt(home string, ref RecordRef) (Record, bool, error) { - return readRecord(home, ref) +// LoadRecordForTest is loadRecord for another package's tests, which read +// back the record a verb wrote; no production code outside this package reads +// the record by hand. +func LoadRecordForTest(home, rootSHA string) (Record, RecordRef, bool, error) { + return loadRecord(home, rootSHA) } +// readRecord reads the one record ref names, with loadRecord's guards. func readRecord(home string, ref RecordRef) (Record, bool, error) { data, refusal, err := fsutil.ReadHomeDeclaration(home, ref.Rel, recordLimit) switch refusal { @@ -213,8 +216,8 @@ func marshalRecord(r Record) ([]byte, error) { return buf.Bytes(), nil } -// UserHome is the person's home directory, or "" when it cannot be resolved. -func UserHome() string { +// userHome is the person's home directory, or "" when it cannot be resolved. +func userHome() string { home, err := os.UserHomeDir() if err != nil { return "" diff --git a/internal/core/provenance/declared.go b/internal/core/provenance/declared.go index 988f2888ed..033b123993 100644 --- a/internal/core/provenance/declared.go +++ b/internal/core/provenance/declared.go @@ -59,7 +59,7 @@ func DeclaredMode(root string) (Mode, error) { if err := json.Unmarshal(data, &f); err != nil { return "", fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) } - m, err = ModeOrDefault(strings.TrimSpace(f.ProductionMode)) + m, err = modeOrDefault(strings.TrimSpace(f.ProductionMode)) if err != nil { return "", fmt.Errorf("malformed %s: %w", LegacyPinRelPath, err) } diff --git a/internal/core/provenance/provenance.go b/internal/core/provenance/provenance.go index 3e70c32b2d..3fc87767ed 100644 --- a/internal/core/provenance/provenance.go +++ b/internal/core/provenance/provenance.go @@ -131,7 +131,7 @@ var modeValues = []Mode{ModeHandWritten, ModeDictatedAndFormatted, ModeScribeTra // ParseMode is the boundary validator for a PRESENT production mode. An empty // value is an error here, never a default: the defaulting rule has exactly one -// door (ModeOrDefault), so a caller cannot absorb an unset value by accident and +// door (modeOrDefault), so a caller cannot absorb an unset value by accident and // a config member that is present but blank is a fault rather than a shrug. func ParseMode(v string) (Mode, error) { for _, known := range modeValues { @@ -142,11 +142,11 @@ func ParseMode(v string) (Mode, error) { return "", fmt.Errorf("invalid production mode %q: want exactly one of %s (lower-case, no surrounding whitespace)", v, ModeList()) } -// ModeOrDefault is the ONE defaulting door: an absent value means DefaultMode, +// modeOrDefault is the ONE defaulting door: an absent value means DefaultMode, // and anything else goes through ParseMode. Every write path normalises here, so // the "absent means hand-written" rule is stated once rather than at each // writer. -func ModeOrDefault(v string) (Mode, error) { +func modeOrDefault(v string) (Mode, error) { if v == "" { return DefaultMode, nil } @@ -188,7 +188,7 @@ func NewStamp(kind Kind, mode string) (Stamp, error) { return Stamp{}, fmt.Errorf("unknown origin kind %q: want %s or %s", kind, KindResearcherAuthored, KindExtractedFromRecord) } - m, err := ModeOrDefault(mode) + m, err := modeOrDefault(mode) if err != nil { return Stamp{}, err } @@ -216,7 +216,7 @@ func NewReadingStamp(run, item, mode string) (Stamp, error) { "origin %s needs an item identifier spelled <%s-N>, and %q is not one; the run and the item resolve as a pair, never separately", KindContributedByReading, issueschema.ReadingItemFamily, item) } - m, err := ModeOrDefault(mode) + m, err := modeOrDefault(mode) if err != nil { return Stamp{}, err } diff --git a/internal/core/provenance/provenance_test.go b/internal/core/provenance/provenance_test.go index d237c498d8..82629fbcf8 100644 --- a/internal/core/provenance/provenance_test.go +++ b/internal/core/provenance/provenance_test.go @@ -79,11 +79,11 @@ func TestProductionModeVocabularyIsClosed(t *testing.T) { } // The empty value defaults ONLY through the defaulting door, so a writer // cannot absorb an unset mode by accident. - if got, err := ModeOrDefault(""); err != nil || got != DefaultMode { - t.Errorf("ModeOrDefault(\"\") = %q, %v; want %q, nil", got, err, DefaultMode) + if got, err := modeOrDefault(""); err != nil || got != DefaultMode { + t.Errorf("modeOrDefault(\"\") = %q, %v; want %q, nil", got, err, DefaultMode) } - if _, err := ModeOrDefault("typed"); err == nil { - t.Error("ModeOrDefault(\"typed\"): want an error") + if _, err := modeOrDefault("typed"); err == nil { + t.Error("modeOrDefault(\"typed\"): want an error") } } diff --git a/internal/core/shellpath/shellpath.go b/internal/core/shellpath/shellpath.go index 4e52aff3d2..b29a4c0633 100644 --- a/internal/core/shellpath/shellpath.go +++ b/internal/core/shellpath/shellpath.go @@ -147,14 +147,14 @@ func ExportLine(home, dir string) string { return `export PATH="` + shellDir(home, dir) + `:$PATH"` } -// MarkedLine is ExportLine with Marker, the one line abcd adds. -func MarkedLine(home, dir string) string { +// markedLine is ExportLine with Marker, the one line abcd adds. +func markedLine(home, dir string) string { return ExportLine(home, dir) + " " + Marker } -// ChecklistLine is the line the checklist names when the person declines, or +// checklistLine is the line the checklist names when the person declines, or // abcd will not edit the file: the export line, for the person to add. -func ChecklistLine(home, dir string) string { return ExportLine(home, dir) } +func checklistLine(home, dir string) string { return ExportLine(home, dir) } // shellDir renders dir for a double-quoted shell string. func shellDir(home, dir string) string { @@ -174,7 +174,7 @@ const unsafeInQuotes = "\"$`\\\n" // startup file the person's login shell reads, and which file and line that // is. It never writes. func PlanFor(env Env, dir string) Plan { - p := Plan{dir: dir, Line: MarkedLine(env.Home, dir), Export: ExportLine(env.Home, dir)} + p := Plan{dir: dir, Line: markedLine(env.Home, dir), Export: ExportLine(env.Home, dir)} if onPath(env.Path, dir) { p.State = OnPath return p @@ -309,7 +309,7 @@ func readFollowing(path string) ([]byte, bool) { } // addsDir reports whether raw already puts dir on the PATH: abcd's own marked -// line for dir (MarkedLine, exactly), or an uncommented line that names the +// line for dir (markedLine, exactly), or an uncommented line that names the // PATH and the folder in any of the spellings a person writes it in. The // check is textual and conservative: it can mistake a line for one that adds // the folder, which costs the offer, never a second line. @@ -320,7 +320,7 @@ func addsDir(raw []byte, home, dir string) bool { rel = filepath.ToSlash(rel) forms = append(forms, "$HOME/"+rel, "${HOME}/"+rel, "~/"+rel) } - marked := MarkedLine(home, dir) + marked := markedLine(home, dir) for _, ln := range strings.Split(string(raw), "\n") { t := strings.TrimSpace(ln) if t == "" || strings.HasPrefix(t, "#") { @@ -570,7 +570,7 @@ func Remove(env Env, dir string) (Removed, error) { if dir == "" || env.Home == "" { return Removed{Note: "abcd added no line to a shell settings file"}, nil } - line := MarkedLine(env.Home, dir) + line := markedLine(env.Home, dir) for _, shell := range []string{"zsh", "bash"} { _, read, _ := files(env, shell) for _, f := range read { diff --git a/internal/core/shellpath/shellpath_test.go b/internal/core/shellpath/shellpath_test.go index eaa4ffc7d6..c8d8194688 100644 --- a/internal/core/shellpath/shellpath_test.go +++ b/internal/core/shellpath/shellpath_test.go @@ -57,7 +57,7 @@ func TestOfferAddsOneLineAndRemoveTakesOnlyIt(t *testing.T) { if p.State != Offer || p.File != rc || p.Line != wantLine || p.Create { t.Fatalf("plan = %+v, want an offer to add %q to %s", p, wantLine, rc) } - if p.Export != `export PATH="$HOME/.local/bin:$PATH"` || ChecklistLine(e.Home, dir) != p.Export { + if p.Export != `export PATH="$HOME/.local/bin:$PATH"` || checklistLine(e.Home, dir) != p.Export { t.Errorf("checklist line = %q", p.Export) } got, err := Apply(e, p) @@ -378,7 +378,7 @@ func TestMarkedLineForAnotherFolderAddsNothing(t *testing.T) { t.Errorf("plan = %+v, want an offer: the marked line adds another folder", p) } // abcd's own marked line for this folder is already added. - write(t, filepath.Join(e.Home, ".zshrc"), MarkedLine(e.Home, dir)+"\n", 0o644) + write(t, filepath.Join(e.Home, ".zshrc"), markedLine(e.Home, dir)+"\n", 0o644) if p := PlanFor(e, dir); p.State != AlreadyAdded { t.Errorf("plan = %+v, want already added", p) } diff --git a/internal/core/tools/registry.go b/internal/core/tools/registry.go index b208768bbf..516af105de 100644 --- a/internal/core/tools/registry.go +++ b/internal/core/tools/registry.go @@ -21,7 +21,11 @@ // install.go and adr-2610241057124151. package tools -import "sort" +import ( + "sort" + + "github.com/intentdriven/abcd/internal/abcdhome" +) // Requirement is whether a capability needs a tool or merely works better with // it (adr-22: most dependencies are optional adapters over a native default). @@ -103,7 +107,7 @@ func downloadEffects(program string) string { return "abcd downloads the one " + program + " build its own release names from " + program + "'s GitHub release page, checks it against the sha256 abcd's release fixes before anything is written, " + "and puts the program in your own bin folder (~/.local/bin unless XDG_BIN_HOME names another); it needs no admin " + - "rights, uses no package manager, and records the copy in ~/.abcd.noindex/tools.json so abcd runs exactly " + + "rights, uses no package manager, and records the copy in " + abcdhome.Display("tools.json") + " so abcd runs exactly " + "that copy and abcd ahoy uninstall removes it." } diff --git a/internal/core/tools/tools_test.go b/internal/core/tools/tools_test.go index 0813b911ba..3a0d004b54 100644 --- a/internal/core/tools/tools_test.go +++ b/internal/core/tools/tools_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "github.com/intentdriven/abcd/internal/abcdhome" "github.com/sigstore/sigstore-go/pkg/root" ) @@ -601,7 +602,7 @@ func TestUninstallRemovesOnlyAbcdsIntactCopies(t *testing.T) { if !strings.Contains(string(b), "removed abcd's copy") || !strings.Contains(string(b), "changed since abcd installed it") { t.Fatalf("removals = %s", b) } - if exists(filepath.Join(r.home, ".abcd.noindex", "tools.json")) { + if exists(abcdhome.Path(r.home, "tools.json")) { t.Fatal("the record outlived the copies") } } diff --git a/internal/reachaudit/testdata/core-unreached.txt b/internal/reachaudit/testdata/core-unreached.txt index ed21bb51b8..6205a4848c 100644 --- a/internal/reachaudit/testdata/core-unreached.txt +++ b/internal/reachaudit/testdata/core-unreached.txt @@ -28,7 +28,6 @@ internal/core/guard.Load internal/core/guard.Merge internal/core/guard.Validate internal/core/history.DefaultConfig -internal/core/identity.LoadPin internal/core/implement.LoadResultFromEvent internal/core/implement.ReadingCorpus internal/core/implement.Roles