diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 40dcf2c9f..10aa9d299 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1340000, - "measured_tokens_est": 1325292, - "measured_bytes": 5102376, - "measured_at": "0dd22bdc043952a36800be0d41d72ac65b23055d" + "tokens_est": 1350000, + "measured_tokens_est": 1332620, + "measured_bytes": 5130589, + "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" } }, "entailment": { @@ -132,10 +132,10 @@ "intent-projection" ], "window": { - "tokens_est": 390000, - "measured_tokens_est": 383234, - "measured_bytes": 1475453, - "measured_at": "0dd22bdc043952a36800be0d41d72ac65b23055d" + "tokens_est": 400000, + "measured_tokens_est": 387345, + "measured_bytes": 1491280, + "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1350000, - "measured_tokens_est": 1334328, - "measured_bytes": 5137164, - "measured_at": "0dd22bdc043952a36800be0d41d72ac65b23055d" + "tokens_est": 1360000, + "measured_tokens_est": 1341656, + "measured_bytes": 5165377, + "measured_at": "c72b6658f76425e3a5265054190527f5a411c17d" } } } diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 1c929ae1a..9844259c1 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -69,7 +69,7 @@ the table above is the sub-verb set, and the modes are the bare verb's flags. the repository this checkout's own origin names, and the changes an apply would make. A toggle it could not read reports `unknown`, never `disabled`. The same request also reads the repository's merge hygiene, which abcd mirrors - and never sets: those settings encode a maintainer's workflow rather than a + and never sets: those settings encode the technical facilitator's workflow rather than a security posture, and each is reported only when the API answered for it, because `false` and "the API did not say" are different facts (iss-2608270512210664). @@ -216,16 +216,21 @@ are caller-controlled and line-oriented. `trusted-roots` and `local-transcript-roots` are the two that widen what a session will trust, so each is honoured only when it is a regular file this uid owns that no one else can write, and a file failing either test is ignored with one line saying which -test it failed. `path-entry` is read through the shared guarded read instead: -a symlinked, non-regular or oversized file is refused, but its ownership and its -permissions are not checked, and the hook shims that consult it check neither. +test it failed. `path-entry` and `cache-attestation` are held to the same test, +by the install verb and by the hook shims that consult `path-entry`, and a record +failing it vouches for nothing. `load-limits` is a setting, not a declaration, but it is read through the same guard as the two that widen trust, and a file failing it, or holding a line that does not parse, is reported loudly and both of its limits take their defaults. `rules.json` is read through that guard too, because it injects text into every session on the machine, but a file failing it — or failing to parse — fails the rules load outright: nothing injects until it is fixed, and the file is named on -stderr. +stderr. None of these files is honoured behind a `~/.abcd` that is itself a +symlink, and nothing ahoy or the bootstrap writes there goes through one: each +refuses the link and names it, as the rules loader does for `rules.json`, while a +symlinked `~/.abcd` holding none of them reads as absent (the rule is stated once, +under *The two `.abcd/` scopes* in +[`05-internals/03-configuration.md`](../05-internals/03-configuration.md#the-two-abcd-scopes)). There is **no workspace, host, or development-environment layer.** A folder a user keeps their repos in groups nothing, and abcd does not privilege it. abcd @@ -293,19 +298,31 @@ which is false (iss-95). **The `PATH` entry is classified, not assumed.** Detection scans `PATH`, resolving symlinks, and classifies each hit as abcd's own entry, the dev shim, or a foreign binary. An abcd-owned entry anywhere on `PATH` is the install; with -none, the default location answers the same question. Three states are named +none, the default location answers the same question. A symlink whose target +has gone is abcd's own when it is the one a plugin update stranded or when the +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 `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). Install carries the two +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 +nothing, because the shell skips it, and what it still threatens is to answer +whatever reappears at its target, so neither the gap nor the note says it is what +runs. An owned one that is not the entry install acts on — typically a link a +plugin update stranded ahead of the one-liner's copy — is removed with its record +by an install that leaves a working entry of abcd's own behind it +(iss-2609280932480608), the same danglingness rule that clears one at the target +(iss-2609100506256636); an unowned one is named and left. Install carries the two non-resolvable ones on its own result as notes, since a fresh user cannot run the doctor by name on a machine where abcd is not yet on `PATH`. -**The name-guard scaffolding is reported at the granularity a maintainer 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 a -maintainer put there. A pre-commit guard present without abcd's own marker line is +**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 +technical facilitator put there. A pre-commit guard present without abcd's own marker line is foreign, and is reported rather than claimed as installed. A lint config with no usable banned-names array, one that cannot be read, and one git ignores — so CI never sees it, the state a public repo is in by default — are three distinct @@ -353,8 +370,14 @@ produces (iss-2609012039117381) — and only when the home-scoped `path-entry` record names that exact path as this machine's installed binary. The record is a string comparison and no hashing, because adr-46 keeps the fast path at one file test. Both install routes write it, and the ahoy installer writes it for **every** -entry shape it leaves on `PATH`: the owned copy, the pinned symlink it degrades -to when there is no verified artefact to copy from, and the dev shim. An entry +entry shape it leaves on `PATH`: the owned copy, the dev shim, and a working +pinned symlink into the plugin root that an earlier release wrote. The installer +never writes that symlink itself: with no verified artefact to copy from it +writes no entry, because a link into the plugin root dangles at the next plugin +update, and it names the install one-liner as the command to run first — the +one route that fetches and verifies the release binary on an explicit ask +(adr-38) — after which a re-run adopts the copy in place. A pin into the plugin +root is a required `symlink.legacy` gap whatever the cache holds (iss-2609100506263330). An entry the record does not name is an install this rung refuses, and it is the one state where a filesystem test alone would call the install healthy while every hook quietly degrades, so the board raises it as a gap in its own right and @@ -602,8 +625,11 @@ the same three-shape predicate detection classifies with, and only one of the three is a pointer at all: the dev shim; the owned copy the `path-entry` record names and whose bytes still hash to the recorded value, which is the default install and a regular file pointing at nothing; and lastly a legacy symlink -whose target is this plugin's binary. Anything else is foreign and is left where -it stands. It leaves the entire `.abcd/` namespace and the history store intact. +whose target is this plugin's binary, or whose target has gone when it is the +link a plugin update stranded or the one the `path-entry` record names. The +recorded dangling link needs no plugin root to be recognised, so uninstall finds +it on `PATH` and removes it with its record on a machine where abcd itself is +gone. Anything else is foreign and is left where it stands. It leaves the entire `.abcd/` namespace and the history store intact. **Uninstall then install is a tested round-trip invariant**: afterwards the detection pass must report zero actionable gaps, and the resulting state must be diff --git a/.abcd/development/brief/04-surfaces/09-reflect.md b/.abcd/development/brief/04-surfaces/09-reflect.md index 1cbb4e928..aad366e6b 100644 --- a/.abcd/development/brief/04-surfaces/09-reflect.md +++ b/.abcd/development/brief/04-surfaces/09-reflect.md @@ -89,8 +89,8 @@ they are rendered into link text. The receipt shape this surface consumes is the predecessor store's phase-audit report, and the predecessor wrote it under `.abcd/logbook/`. **That location is -retired here.** A 2026-07-12 maintainer adjudication, made on iss-56, placed -runtime artefacts in the gitignored `.abcd/.work.local/logs/` tier instead; +retired here.** A 2026-07-12 adjudication on iss-56 placed runtime artefacts +in the gitignored `.abcd/.work.local/logs/` tier instead; iss-73 carried out the relocation, and a detector holds it: `TestNoRetiredLogbookLocationInSource` fails the build if any non-test Go source under `internal/` so much as names `logbook`. A delivered `reflect` therefore diff --git a/.abcd/development/brief/04-surfaces/10-docs.md b/.abcd/development/brief/04-surfaces/10-docs.md index 7d0ad2c5f..dadc33e00 100644 --- a/.abcd/development/brief/04-surfaces/10-docs.md +++ b/.abcd/development/brief/04-surfaces/10-docs.md @@ -7,7 +7,7 @@ checked in six months. The currency lint answers that in one read-only pass, so it can run on every commit at no cost and gate a release without a network. The citation sub-tree is the writing half, and it is the only place abcd reaches -the network on behalf of documentation. It runs when a maintainer asks, never +the network on behalf of documentation. It runs when the technical facilitator asks, never in a gate, which is what keeps the lint itself deterministic and offline. ## Sub-verbs @@ -53,7 +53,7 @@ in a gate, which is what keeps the lint itself deterministic and offline. rather than recorded as broken. - **The citation confirmation** records that a human verified a citation the fetcher could not read, either from named URLs or from a receipt file. Today the - maintainer clears the printed checklist and names the URLs on the command line; + technical facilitator clears the printed checklist and names the URLs on the command line; the receipt form ships against a producer that does not exist yet, a generated checklist page that would hand the file back (a later rung of the same intent). Both forms write the same dated entry, so when the page arrives it is a second @@ -116,6 +116,20 @@ promotion is reachable only by a human typing the flag. banned tokens, each a blocker, so the published surface stays host-agnostic; the `` escape covers the sanctioned exception, attribution. +- **The two roles, named.** abcd's own text says which person it means: the + product thinker, who decides what to build, or the technical facilitator, + who decides how. The `roles/retired-role-word` banned token refuses the one + word that blurred them, as a blocker, and it reaches past the documentation: + its `extra_roots` add the plugin command pages, this repository's rules + overrides and the bundled rules source (itd-2609212137129937). An entry's + `extra_roots` widen that entry alone, reading every text file there and not + only markdown (a rules file is JSON), with `exempt_paths`, the escape and the + fence default applying as they do under `roots`; the rest of the family is + not armed there, and a missing extra root is a configuration error, as a + missing root is. `roots` itself holds markdown: a non-markdown file named + there would be read by nothing, so it is refused and pointed at + `extra_roots` (iss-2609281045487620). The escape covers an acknowledgement and a persona's outside + job title, nothing else. - **Harness leak**, a separate rule from those tokens and armed here as a blocker, refusing the two shapes a harness stamps onto text the repository did not ask it to stamp: a live agent-session URL, and a tool's own "generated diff --git a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md index 589eefab2..f1b579b2d 100644 --- a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md +++ b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md @@ -81,7 +81,7 @@ target's, is ahoy's shape, so it lives in the generated appendix of [`01-ahoy.md`](01-ahoy.md#appendix-the-shipped-surface) and is not repeated here. The identity verb's render is the follow-on surface and writes nothing: it proposes -a correction as a diff, and adopting it is always the maintainer's move. +a correction as a diff, and adopting it is always the product thinker's move. ## Flow diff --git a/.abcd/development/brief/04-surfaces/17-guard.md b/.abcd/development/brief/04-surfaces/17-guard.md index e187f5fdb..55e207c3f 100644 --- a/.abcd/development/brief/04-surfaces/17-guard.md +++ b/.abcd/development/brief/04-surfaces/17-guard.md @@ -227,9 +227,18 @@ the package to it. Text beside one in the same word is also read as bash leaves it when the output is empty. One nested past the depth the guard reads, one holding a case command, or more of them where the program name could be than the guard follows, is refused rather than left unread. A parameter expansion -holding a substitution prints its output, so its word is unknown from the `${` -on, and inside double quotes one ends at its own `}`, where a nested `"` opens a -string of its own. A here-document body is data, but the substitutions the shell +(`$VAR`, `$1`, `$@`, `${VAR:-git}`) prints a value the line does not hold, so it +is an unknown word by the same rule: `--$VAR` is every flag it could become, and +`$GIT` as the program is any program its known text allows. A `${…}` ends at its +own `}`, where a nested `"` opens a string of its own, and a substitution in its +text runs and is read. A variable that is the whole word is read as an operand, +as a wholly-substituted word is (`git push origin "$branch"`), and a string +handed to a shell carries its variables for that shell to expand, where they +are read by the same rule, the string's own quotes applying to the value. What a variable carries in from an earlier command +is not read: as a script it is not a stream, and as the program's name it is not +a `pkill` or `killall`, whose entries name only the program and an operand, nor a +bare interpreter inside a string, because a variable is how ordinary commands +carry a program or a path between commands. A here-document body is data, but the substitutions the shell runs in a body whose delimiter is unquoted are read as commands, and such a body is read by the lines bash compares with its delimiter, joined across a trailing odd run of backslashes. A backtick's text is read after bash's own pass over @@ -265,7 +274,10 @@ piped into `xargs kill`, is read as the kill by name it is — through a group and into one, whose every command is read as handed what is piped into it, through a shell string that runs the search, and into a shell string `xargs` runs or a pipe or redirect feeds, whose every command is read as handed its -input — and a `pkill` or +input, or whose positional parameters or own text hold the search's output; out +of an unquoted here-document's substitutions into the command that reads the +document and every command its output is piped on to; and into a substitution in a command that reads a pipe, which runs +with that pipe as its input — and a `pkill` or `killall` selecting by user, group or terminal, its value written apart or attached, as selecting every session under the account; `pkill`'s signal name is read as a signal first, in any case. In a repository with more @@ -274,9 +286,9 @@ because the stash stack is shared across worktrees. Where the reading is a guess, over-blocking is the direction the guard takes. What an allow still does not see is a hazard that never reaches command position -at all: a word that is wholly a command substitution standing where a flag -would be, which is read as an operand because that is how a commit message or a -branch name is spelled every day; one behind a wrapper flag the per-wrapper +at all: a word that is wholly a command substitution or a variable standing +where a flag would be, which is read as an operand because that is how a commit +message or a branch name is spelled every day; one behind a wrapper flag the per-wrapper table does not name; a REST path an entry names by its root segment when the host serves that API under a prefix; an IFS the shell already holds when the line starts, or gains during the line @@ -285,12 +297,12 @@ since every line is read from the default IFS; a pid list a kill reads through a grep` chain; a payload inside a non-shell interpreter such as `python -c`, which is one opaque token and today a silent allow; and any dangerous form no entry -describes. Nor does an allow see through a parameter expansion that carries no -substitution (`$VAR`, `${VAR:-git}`), wherever it stands — as the command's -program name, as a flag, or inside a payload the guard does read — because the -guard sees the variable and not what the shell will expand it to, and warning on -every variable would bury the warnings that matter; so the obvious evasions above -do not include a hazard spelled through a variable. Nor does an allow see what a +describes. Nor does an allow see what a variable carries in from an earlier +command: a pid list (`p=$(pgrep make); kill $p`), a stream path handed to a +shell, shell text run through `eval "$X"` or placed in a string a shell runs, +or `pkill` or `killall` as a variable's value standing as the program with an +operand (`$P make`), because reading each would refuse the ordinary commands a +variable carries a value for; whether to is an open ruling. Nor does an allow see what a lone substitution prints when it stands as the whole command (`$(cat msg.txt)`, `$(date)`): such a name can be any program, but with no operand after it no entry matches, so it allows by the posture above, where an allow means no entry diff --git a/.abcd/development/brief/04-surfaces/19-identity.md b/.abcd/development/brief/04-surfaces/19-identity.md index 53ea839b7..a1db7c330 100644 --- a/.abcd/development/brief/04-surfaces/19-identity.md +++ b/.abcd/development/brief/04-surfaces/19-identity.md @@ -3,7 +3,7 @@ A project's tagline gets written once and copied four times: the README strapline, the plugin manifest, the conventions file, a string baked into the binary's banner. Then one of them is improved. `/abcd:identity` records the -canonical wording in one place, tells the maintainer which surfaces have drifted +canonical wording in one place, tells the product thinker which surfaces have drifted away from it, and prints the exact diff that would bring each back. The report and the rendered diff are **strictly read-only**. Initialisation @@ -104,7 +104,7 @@ skipped rather than reported, because a file that does not exist carries no drift. **Autonomous rewriting is permanently out of scope.** The render proposes; the -maintainer adopts. Changing the positioning deliberately is an edit to the block, +product thinker adopts. Changing the positioning deliberately is an edit to the block, after which the same proposal flow chases the surfaces. Initialisation never re-interviews a repo that already has a block — it adopts it. Run diff --git a/.abcd/development/brief/04-surfaces/20-banlist.md b/.abcd/development/brief/04-surfaces/20-banlist.md index 94780361e..3eb1d9d69 100644 --- a/.abcd/development/brief/04-surfaces/20-banlist.md +++ b/.abcd/development/brief/04-surfaces/20-banlist.md @@ -68,7 +68,8 @@ Read the prefix as the place the verb writes, not as proof of what wrote an entry. The prefix is also the gate's reach. The rest of the `banned_tokens` family is -a writing rule for the documentation and reads the configuration's `roots`; a +a writing rule for the documentation and reads the configuration's `roots` +(and any `extra_roots` an entry declares for itself, [`10-docs.md`](10-docs.md)); a name ban is about the whole public surface, so the `names/` entries alone also read the configuration's `name_roots`, every text file there and not only markdown, with `exempt_paths` excusing a historical tree as it does under @@ -222,25 +223,25 @@ would immediately declare unenforceable is worse than an absent one. | artefact | where | written when | |---|---|---| | commit guard | `.githooks/pre-commit` | when absent. Committed, so every clone inherits it; a clone arms it once with `git config core.hooksPath .githooks` | -| merge guard | `.githooks/pre-merge-commit` | only beside abcd's own guard. git runs no `pre-commit` for a merge commit, so the same guard needs a second entry point; the shim delegates to whatever occupies `pre-commit`, so beside a foreign hook it would both claim coverage it has not got and silently start running the maintainer's hook on merges | +| merge guard | `.githooks/pre-merge-commit` | only beside abcd's own guard. git runs no `pre-commit` for a merge commit, so the same guard needs a second entry point; the shim delegates to whatever occupies `pre-commit`, so beside a foreign hook it would both claim coverage it has not got and silently start running the technical facilitator's hook on merges | | EOL pin | `.gitattributes` | only beside abcd's own guard. One appended line keeps the hooks at LF, because a `core.autocrlf` checkout rewrites a script git executes and its shebang stops resolving | -| public family | `.abcd/docs-lint.json` | only where git says the path would be tracked. Seeded with abcd's own Writing-Guide rules armed (the `present_tense` and `spelling` token families, held to the set abcd runs on itself, and the `links_resolve`, `harness_leak` and `stray_root_docs` rules) and with **no** banned names: abcd cannot know which names a repo may not publish, and a ban nobody declared would fail a build over a word the repository never chose. The `harness` token family is left for the repository to declare, since refusing to name a specific agent tool is wrong for a repository whose content teaches those tools (iss-2609150805167646). The `punctuation/em-dash-in-list-item` token is abcd's house style, so it is seeded at the severity the adopter chooses at install, blocking or warning, and at warning under blanket approval (the product thinker's ruling of 2026-09-23 in the decision log) | +| public family | `.abcd/docs-lint.json` | only where git says the path would be tracked. Seeded with abcd's own Writing-Guide rules armed (the `present_tense` and `spelling` token families, held to the set abcd runs on itself, and the `links_resolve`, `harness_leak` and `stray_root_docs` rules) and with **no** banned names: abcd cannot know which names a repo may not publish, and a ban nobody declared would fail a build over a word the repository never chose. The `harness` token family is left for the repository to declare, since refusing to name a specific agent tool is wrong for a repository whose content teaches those tools (iss-2609150805167646), and the `roles` family is abcd's own vocabulary over abcd's own trees, so it is not seeded. The `punctuation/em-dash-in-list-item` token is abcd's house style, so it is seeded at the severity the adopter chooses at install, blocking or warning, and at warning under blanket approval (the product thinker's ruling of 2026-09-23 in the decision log) | | private stub | `.abcd/.work.local/private-names.txt` | only where git itself reports the path as ignored. A stub git would track is the hazard, not the remedy | Every write is create-if-absent and keyed on the gap actually detected, so nothing -overwrites a file the maintainer owns. Every write is also **contained**: paths +overwrites a file the technical facilitator owns. Every write is also **contained**: paths resolve through an `os.Root` opened at the repo, so a symlink committed at `.githooks` or at the local tier cannot land a hook or a stub outside the repo while the surfaces report the in-repo path. Presence is not identity, and identity is not integrity. Each hook carries an `# abcd-name-guard: v1` line, matched as a whole line; a hook without it is a -**foreign** hook that abcd reports and never replaces, because a maintainer's own +**foreign** hook that abcd reports and never replaces, because the technical facilitator's own `pre-commit` is legitimate and calling it "the abcd guard" would mean nothing checks the banlist while the status board says something does. But any file carrying that line is treated as abcd's, including one edited to check nothing. -The marker answers "did abcd put this here", well enough to keep abcd off a -maintainer's hook; it is not a signature. "Committed" is likewise not "armed" — +The marker answers "did abcd put this here", well enough to keep abcd off the +technical facilitator's hook; it is not a signature. "Committed" is likewise not "armed" — git runs the hook the clone's hooks path selects, which abcd neither sets nor fully observes, so every surface prints the arming instruction rather than claiming the guard is running. diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index 0f038e3d3..2435eb47d 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -7,7 +7,7 @@ every sentence the site publishes is a span of a repository file, selected by pa and heading, and a gate refuses to publish text that is not ([adr-47](../../decisions/adrs/0047-abcdev-app-rendered-from-this-repository-alone.md)). -What that costs a maintainer: a sentence that would improve the site has to be +What that costs the product thinker: a sentence that would improve the site has to be written into `docs/` or the record, where it must also read true on the forge. What it buys: the site cannot say anything the repository does not, and nobody has to remember to update it. diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 1e13e67a6..f181b6200 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -72,7 +72,7 @@ ingest and the next reading — moves the commit the next assembly names. A run whose target is not an ancestor is not a run at this target and is not listed; a run across which anything else moved is listed and refused, naming the first such path, because the material it read is not the material this assembly names. That -is a reading of the rule rather than the rule itself, and a maintainer's ruling on +is a reading of the rule rather than the rule itself, and the product thinker's ruling on it is owed. Everything else in the readings store stays excluded there as at every other position, and the manifest asserts it family by family. None or more than one qualifying run refuses and lists what it looked at, so the diff --git a/.abcd/development/brief/04-surfaces/27-implement.md b/.abcd/development/brief/04-surfaces/27-implement.md index d707b6318..013325a7a 100644 --- a/.abcd/development/brief/04-surfaces/27-implement.md +++ b/.abcd/development/brief/04-surfaces/27-implement.md @@ -110,15 +110,26 @@ the condition, when it claims while holding another live claim Checking a step that is not a claim — a lane, the release, a review, an audit or a landing — asks before it; an allowed step writes nothing. -The second session's own agent ceiling (criterion 5) is recorded and reported, -not enforced. The session states it when it joins (1 to 64); the -record and the `session_open` line carry it, a resume cannot restate it, and -every check verdict reports it (`ceiling`). abcd runs no agent and counts none -— `agent_start` and `agent_end` are lines the session writes — so there is no -count here to hold it against; keeping it, and logging a `ceiling_wait` at it, -is the session's discipline, which the verdict puts in front of it at every -step. On a refused claim the second session -also logs a `backoff` with its reason and minutes. +A session's own agent ceiling (criterion 5; for the second session, on top of +the first's) is held against the agents the session declares. The session +states it when it joins (1 to 64); the record and the `session_open` line carry +it, and a resume cannot restate it. abcd runs no agent, so the count it holds +the ceiling against is the session's own lines: the agents its `agent_start` +lines since it joined name, less those an `agent_end` of the same `agent` has +ended. An `agent_start` that would take the count past the ceiling is refused at +exit 2 with a `refusal` line (`agent_ceiling`, naming the agent, the agents +alive and the ceiling); restating an agent already alive is not a new one. Every +check verdict reports the count (`agents_alive`) beside the ceiling +(`ceiling`). An agent the session never logs — a fork, one the host started +outside the log — is invisible to the count, which is why the run's no-fork +rule stays the discipline for that half (iss-2609240646542516); a run that went +over anyway says so with a `ceiling_overrun` line. On a refused claim the +second session also logs a `backoff` with its reason and minutes. + +Every bound keys on the role in the session's record, which is the session's +own statement: the release refusal, like the others, rests on a cooperative, +unauthenticated role, a discipline between cooperating sessions rather than a +wall against one that lies about its role. The reading corpus is derived, never restated: the union of every position's `object.paths` in the checkout's committed `.abcd/config/reading-presets.json`, @@ -139,25 +150,52 @@ keyed on a flag the second session could omit would guard nothing. Logging appends one of the run's own events, with its key-value fields (`backoff`, `lane_open`, `lane_close`, `agent_start`, `agent_end`, `ceiling_wait`, `gate_run`, `review`, `fallback`, `stop`, `refusal`, `pr`, -`capture`, `context`). Every line carries `ts` (RFC 3339, UTC), `session` and `event`, then +`capture`, `context`, `ceiling_overrun`, `intervention`, `decision`). Every line carries `ts` (RFC 3339, UTC), `session` and `event`, then the fields; it reaches the file in one `O_APPEND` write through -`fsutil.AppendLineIn`, so two writers each land whole lines. The session, window, +`fsutil.AppendLineIn`, which refuses a symlinked or non-regular leaf as its read +twin does, so two writers each land whole lines and a log leaf planted as a link +onto a claim file appends nothing. The session, window, claim and load events are refused here: they are written by their own sub-verbs, so the log cannot record a claim the run state does not hold, or a load warning the check did not give. +An event missing a field the report reads is refused when it is written, naming +the field, rather than found missing afterwards (iss-2609240646555891): a +`lane_close` needs `lane` and `outcome`; an `agent_start` its `agent`; an +`agent_end` its `agent`, `role`, `model` and a number under `minutes`, +`wall_minutes` or `wall_min`; a `ceiling_overrun` (iss-2609240646549900) the +agents `alive`, the `ceiling`, the `lane` and the `minutes` over. The evidence +events an autonomous run keeps so a later run can need no person carry theirs: +an `intervention` its `kind` (`session_open`, `account`, `ruling`, `restart`, +`close_session`, `file_restore`, `permission` or `other`), `by`, `what`, `why` +and `autonomy_gap`; a `stop` its `cause`; a `decision` its `what`, the +`alternative` not taken and `why`. An `at` or `last_productive` given is an +RFC 3339 time, and a `detected_after_min` or `noticed_after_min` a number. + The report derives, per mode, the windows, wall clock, lanes opened and landed (a `lane_close` whose outcome is `merged` or `landed`), the second session's lanes landed, collisions (`claim_denied`), lapsed claims, backoffs and their minutes, agent minutes (`agent_end`'s `minutes`, `wall_minutes` or -`wall_min`, the key the run's hand-kept lines carry), ceiling wait and refusals, -with each session's share. An event belongs to the window open when it happened. +`wall_min`, the key the run's hand-kept lines carry), ceiling wait, ceiling +overruns and refusals, with each session's share. An event belongs to the window +open when it happened, save a `session_open` logged at most a minute before the +next `window_mode`, which belongs to that window: a session joining a second +before the first sets the mode is joining that window (iss-2609240646544930). A `context` line is an orchestrator's context measurement (`used_pct`, `role`, `note`); the report totals them per session across the whole run, not per mode, with the last `used_pct` seen, because a session's context is carried across windows. `leader` is the mode with the most lanes landed per wall-clock hour — a figure the run's report cites when it names the mode it would keep, not a verdict of the verb's. +Over the whole run the report counts the evidence (interventions by kind with +the minutes they went undetected, stops with the minutes before each was +noticed, decisions), names per event the lines lacking a field logging requires +(`missing_fields`: lines written by hand or before the requirement, which every +figure above reads as absent), and names each of `lane_open`, `lane_close`, +`agent_start`, `agent_end` and `gate_run` whose last line falls more than six +hours before the run's last line, load samples aside (`coverage`). Every line +still parses in those cases, so without the two lists nothing would say that a +figure is short. Lines that are not a JSON object with `ts`, `session` and `event` are listed as `unparsed`, never dropped silently. The report can read one day, or one log file named directly. diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index ecc7c13b1..0ae1cc703 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -57,16 +57,28 @@ No run is created until every check passes, and each is a read (criteria 1 and 2 is one step, the whole spec. - **peers** — no peer holds the intent: no sibling worktree or local branch holds it in a bucket other than this checkout's (a lane that shipped or - re-drafted it), read through the peer listing, and no session holds a live - claim on it in the shared run state. A copy in the same bucket is not a + re-drafted it), read through the peer listing, and no session other than the + one the build is started for holds a live claim on it in the shared run + state. A copy in the same bucket is not a holding: every branch cut from the default branch carries one. The check fails closed on what it cannot see into, on both sides: a peer the listing names and cannot read (git or the filesystem will not answer for it, or its ledger holds one id in two folders) and an unreadable claim file each count as a holding, naming why. A peer of another repository, or one holding no records at the committed layout, holds nothing of this checkout's and does - not count. A lane that has neither moved nor claimed the intent is invisible - to both sources (iss-2609252050506863). + not count. + +A build started for a named session, one joined to the shared run, claims its intent in the shared run state when it creates the run, with +the run id as the lane and the longest lease a claim takes, so a build of the +same intent from any other checkout of the repository — another worktree or a +second clone on the machine — meets the claim at its peers check before this +run's lane has moved or claimed anything (iss-2609252050506863). The session's +own claim on the intent is its own, not a peer's. A session the shared run does +not hold is refused at the `claim` step before anything is created; a claim +refused under the lock leaves no run behind; a run whose state cannot be written +releases the claim it took. A build started without a session holds no claim +and says so, in the text and as a null `claim` in the JSON: until its lane +shows, another checkout cannot see it. A refusal names the check, the reason and the remedy, carries every check's row, and writes nothing. A peer's holding is contention rather than a fault in the @@ -225,6 +237,8 @@ _Generated from the command tree; a drift test fails `go test` when this appendi Sub-verbs: none. -Flags: none. +| Flag | Type | +|---|---| +| `--session` | string | diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index 22edbe427..4d4393e5e 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -370,6 +370,34 @@ are pulled in. **Never the design record.** The same inventory is drawn as a tre in [`../04-surfaces/01-ahoy.md`](../04-surfaces/01-ahoy.md#what-abcd-manages--repos-and-abcd); the two are one list and must agree. +**A symlinked `~/.abcd` hosts nothing abcd trusts.** Every file in the user +scope whose contents abcd acts on — `rules.json`, `trusted-roots`, +`local-transcript-roots`, `path-entry`, `cache-attestation`, `config.json`, +`oracle-routing.json`, `statusline.json`, `load-limits` and `credentials.json` — +is refused when `~/.abcd`, or a directory below it on the way to the file, is a +symlink: the rule the rules loader states for `rules.json`, applied by one check +(`fsutil.HomeScopeLink`, read through `fsutil.ReadHomeDeclaration`) so it cannot +drift per file. A symlinked `~/.abcd` holding no such file reads as absent and +costs nothing. A file that is there behind the link is refused the way its reader +refuses any declaration that is not the caller's word: the rules load fails, a +declaration is ignored with a note, the path entry and the cache attestation +vouch for nothing, the credential store refuses loudly. Every write abcd makes +into those files — the credential and the provider block `ahoy connect` adds, the +path entry, the routing table and the status-line setting `ahoy install` writes, +and the path entry and cache attestation `hooks/bootstrap.sh` writes — refuses +the link rather than writing through it, naming it and the repair: replace the +link with a real directory. The hook shims refuse a `path-entry` behind the link +too, before they read it. The home directory itself may be a link; only +`~/.abcd` and what lies under it are judged. The stores are not declarations: +`transcripts/`, `voyage/`, `lab/`, `inbox/` and `runs/` refuse a symlinked +level through their own create-then-prove seam (`fsutil.EnsureRealDir`), and the +`sources/` corpus is the caller's to place. The `history/` registry applies +both: it is neither read nor written behind a symlinked `~/.abcd` or +`~/.abcd/history`, and it is created through the same create-then-prove seam +(iss-2609281129171021). `ahoy install` skips the registration with a note naming +the link and the repair, and the detector reports it as a diagnostic rather +than a gap install would try and fail to close. + **Repo scope, in-tree `.abcd/`** — this repository's record and working files: the three-tier layout below, the config file with its `meta` block, the rules overrides, the per-surface machine records under `config/`, the lint and site diff --git a/.abcd/development/brief/glossary/README.md b/.abcd/development/brief/glossary/README.md index 0003e13ab..547152c3a 100644 --- a/.abcd/development/brief/glossary/README.md +++ b/.abcd/development/brief/glossary/README.md @@ -86,6 +86,7 @@ glossary/ │ ├── persona.md │ ├── phase.md │ ├── plan.md +│ ├── product-thinker.md │ ├── reading-position.md │ ├── record-families.md │ ├── record.md @@ -93,6 +94,7 @@ glossary/ │ ├── spec.md │ ├── step.md │ ├── surface.md +│ ├── technical-facilitator.md │ ├── transport.md │ └── voyage.md ├── distribution/ @@ -218,7 +220,8 @@ The complete write-back protocol is a **design target** of `/abcd:intent grill`' | [oracle](core/oracle.md) | stable | An AI model invoked to review, reason over, or validate a project's artefacts — host-delegated by default, or reached through an opt-in oracle adapter. | | [persona](core/persona.md) | stable | A placeholder stakeholder character drawn from the abcd personas registry, used in press releases, intents, and design documents to represent a real user archetype without using real names. | | [phase](core/phase.md) | superseded | An ordered stretch of development work that bundles a set of intents and brief plumbing-phases and ends in a milestone; abcd's sequencing layer, recorded as a document in roadmap/phases/. Unqualified it always carries that sense, the brief's own numbered build milestones being plumbing-phases. | -| [plan](core/plan.md) | stable | The maintainer's sign-off act `abcd intent plan `, which mints a spec, links both sides and moves a draft intent to planned/. Three further senses share the word — the ordered build plan the phase docs hold, a dated design plan under development/plans/, and a session's planning brief — and each is qualified where it appears. | +| [plan](core/plan.md) | stable | The product thinker's sign-off act `abcd intent plan `, which mints a spec, links both sides and moves a draft intent to planned/. Three further senses share the word — the ordered build plan the phase docs hold, a dated design plan under development/plans/, and a session's planning brief — and each is qualified where it appears. | +| [product-thinker](core/product-thinker.md) | stable | The person who decides what is built and why — who rules on intents, signs off acceptance criteria, adopts or declines a proposal, and owns the decisions no mode automates (adjudication, dependency sign-off, irreversible acts). One of the two people abcd addresses, beside the technical facilitator. | | [reading-position](core/reading-position.md) | stable | One of the four questions a cold reading can be commissioned to answer — widening, entailment, comparative or detection. The position fixes the reading's object, its question and the supply regime its output is validated against; `abcd reading assemble --position` names it. | | [record-families](core/record-families.md) | stable | The one page that maps abcd's record families (intent, spec, step, bundle, issue, release, status) and how they relate: what each groups, what groups it, its lifecycle and the verb that moves it. | | [record](core/record.md) | stable | One identified, filed document that a command mints and a lint gate reads — an itd-N, spc-N, adr-N, iss-N or rdg-id. "The development record" is the whole durable corpus those records make up, and "a record family" is one lifecycle-bucketed set of them; each of the three is qualified where the other two could be read. | @@ -226,6 +229,7 @@ The complete write-back protocol is a **design target** of `/abcd:intent grill`' | [spec](core/spec.md) | stable | A specced block of work in abcd's native spec store that implements one or more intents, broken into ordered tasks with acceptance criteria. | | [step](core/step.md) | stable | One of the ordered, independently landable pieces a spec lists under its Steps section; each step is one lane and one pull request, and a spec with no steps is one step. | | [surface](core/surface.md) | stable | A verb's front door — the markdown command file under commands/ plus the transport package under internal/surface/ that reaches the core. "A surface chapter" is the brief's design record for one such front door, and "a rendered surface" is a public text held to the repository's identity block; both are qualified. | +| [technical-facilitator](core/technical-facilitator.md) | stable | The person who decides how the work is carried out — who runs the agents and operates the machinery between the product thinker's decisions: the gates, merges, CI, hooks, installs and the mechanics of the record. One of the two people abcd addresses; itd-97 holds that the role is a mode, not a person. | | [transport](core/transport.md) | stable | The mechanism by which curated context and artefacts are packaged and delivered to an oracle for review or reasoning. | | [voyage](core/voyage.md) | stable | The operations namespace at `~/.abcd/voyage//` — an append-only record of what abcd *did* to produce a lifeboat (every disembark and embark run), as against the lifeboat itself, which is what gets carried. | diff --git a/.abcd/development/brief/glossary/core/plan.md b/.abcd/development/brief/glossary/core/plan.md index 72ed948ce..86ec1a78d 100644 --- a/.abcd/development/brief/glossary/core/plan.md +++ b/.abcd/development/brief/glossary/core/plan.md @@ -1,7 +1,7 @@ --- term: plan bounded_context: core -definition: The maintainer's sign-off act `abcd intent plan `, which mints a spec, links both sides and moves a draft intent to planned/. Three further senses share the word — the ordered build plan the phase docs hold, a dated design plan under development/plans/, and a session's planning brief — and each is qualified where it appears. +definition: The product thinker's sign-off act `abcd intent plan `, which mints a spec, links both sides and moves a draft intent to planned/. Three further senses share the word — the ordered build plan the phase docs hold, a dated design plan under development/plans/, and a session's planning brief — and each is qualified where it appears. aliases: ["intent plan", "planning sign-off"] forbidden_synonyms: ["approve", "estimate", "schedule", "backlog"] status: stable @@ -16,7 +16,7 @@ versions: null # plan Unqualified, **plan** is the verb: `abcd intent plan `. The invocation *is* the -maintainer's sign-off on an intent's acceptance criteria — never run unattended, never inferred +product thinker's sign-off on an intent's acceptance criteria — never run unattended, never inferred from consent. It mints the spec stub, links intent and spec, stamps an identity onto every unmarked scope condition, and moves the record `drafts/ → planned/`. The surface page is [`commands/intent.md`](../../../../../commands/intent.md); the rule it serves is that no diff --git a/.abcd/development/brief/glossary/core/product-thinker.md b/.abcd/development/brief/glossary/core/product-thinker.md new file mode 100644 index 000000000..d60250089 --- /dev/null +++ b/.abcd/development/brief/glossary/core/product-thinker.md @@ -0,0 +1,51 @@ +--- +term: product-thinker +bounded_context: core +definition: The person who decides what is built and why — who rules on intents, signs off acceptance criteria, adopts or declines a proposal, and owns the decisions no mode automates (adjudication, dependency sign-off, irreversible acts). One of the two people abcd addresses, beside the technical facilitator. +aliases: ["product thinker"] +forbidden_synonyms: [] +status: stable +introduced_in: itd-2609212137129937 +starts_when: null +ends_when: null +not_to_be_confused_with: [core/technical-facilitator, core/persona, core/record-families] +versions: null +--- + + +# product-thinker + +The **product thinker** decides *what*: which intents are pursued, what their acceptance +criteria promise, which proposal is adopted, and every ruling the record carries. `abcd intent +plan ` is their sign-off act ([plan](plan.md)), and the planning interview's questions +are theirs. They answer on a surface of their own, so a stop that waits on them is announced: +`abcd mode product-thinker` parks the loop on them, and the status line reads `waiting on the +product thinker` until they answer. + +The role does not change with who runs the machinery. itd-97 (a draft) holds that the +facilitator is a mode, not a person — a project runs duo, with a human technical facilitator, +or solo, with abcd doing the facilitator's work — and in both the product thinker's decision +points stay human. + +## When to use + +Name the product thinker wherever a sentence asks a person to decide what to build, to rule, +to adopt, or to sign off: a question put to them, a stop that waits on them, a ruling cited. + +## When NOT to use + +Not for how the work is carried out — running the agents, the gates, merges, CI and hooks are +the [technical facilitator](technical-facilitator.md)'s. Not for a [persona](persona.md): a +persona is a modelled archetype in a press release, and its role hint is an outside job title. +Where a sentence genuinely means either person, it says "the person" or names both. + +## Examples + +- "Adopting a proposal is the product thinker's move." +- "Set `abcd mode product-thinker`, then ask the product thinker once, in their own words." + +## Related terms + +- [technical-facilitator](technical-facilitator.md), the other person abcd addresses. +- [plan](plan.md), the product thinker's sign-off act. +- [record-families](record-families.md), the records whose lifecycle their rulings move. diff --git a/.abcd/development/brief/glossary/core/technical-facilitator.md b/.abcd/development/brief/glossary/core/technical-facilitator.md new file mode 100644 index 000000000..82f73f9ff --- /dev/null +++ b/.abcd/development/brief/glossary/core/technical-facilitator.md @@ -0,0 +1,49 @@ +--- +term: technical-facilitator +bounded_context: core +definition: The person who decides how the work is carried out — who runs the agents and operates the machinery between the product thinker's decisions: the gates, merges, CI, hooks, installs and the mechanics of the record. One of the two people abcd addresses; itd-97 holds that the role is a mode, not a person. +aliases: ["technical facilitator", "facilitator"] +forbidden_synonyms: [] +status: stable +introduced_in: itd-2609212137129937 +starts_when: null +ends_when: null +not_to_be_confused_with: [core/product-thinker, core/record-families] +versions: null +--- + + +# technical-facilitator + +The **technical facilitator** decides *how*: the person at the terminal running the agents, who +operates the gates, merges, CI, hooks and installs, and addresses the mechanism and the record +ids that the [product thinker](product-thinker.md) is spared. A stop that waits on them is +announced as one: `abcd mode facilitator` parks the loop on them, and the status line reads +`waiting on the technical facilitator` until they answer. + +itd-97 (a draft) holds that **the facilitator is a mode, not a person**. A project runs duo, +with a human technical facilitator beside the product thinker, or solo, where abcd itself +performs the facilitator's work; the gates are the same in both, and only who operates the +machinery between the product thinker's decisions changes. + +## When to use + +Name the technical facilitator wherever a sentence asks a person to act on the machinery: to +answer an install question, clear a gate, merge, settle where a file lives, or keep a hook they +put in place. + +## When NOT to use + +Not for what is built, a ruling, an adoption, a dependency sign-off or an irreversible act — +those are the product thinker's. Where a sentence genuinely means either person, it says "the +person" or names both. + +## Examples + +- "abcd writes what is missing and never replaces what the technical facilitator put there." +- "Set `abcd mode facilitator`, ask the technical facilitator first, then pipe the answer." + +## Related terms + +- [product-thinker](product-thinker.md), the other person abcd addresses. +- [record-families](record-families.md), the records whose mechanics they operate. diff --git a/.abcd/development/intents/planned/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md b/.abcd/development/intents/shipped/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md similarity index 97% rename from .abcd/development/intents/planned/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md rename to .abcd/development/intents/shipped/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md index f4f30602d..b93c1f217 100644 --- a/.abcd/development/intents/planned/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md +++ b/.abcd/development/intents/shipped/itd-2609212137129937-abcd-s-own-text-names-the-product-thinker-or-the-technical.md @@ -68,7 +68,8 @@ _None open._ ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-12ccf7627aac). ## Grounds diff --git a/.abcd/development/personas.json b/.abcd/development/personas.json index 88013db69..802e2e091 100644 --- a/.abcd/development/personas.json +++ b/.abcd/development/personas.json @@ -13,7 +13,7 @@ { "name": "Henry", "role_hints": ["junior developer", "new hire"] }, { "name": "Iris", "role_hints": ["product manager", "designer", "product lead", "product thinker"] }, { "name": "Jack", "role_hints": ["consultant", "agency lead"] }, - { "name": "Kira", "role_hints": ["open-source maintainer", "DX engineer", "framework author"] }, + { "name": "Kira", "role_hints": ["open-source project lead", "DX engineer", "framework author"] }, { "name": "Liam", "role_hints": ["mobile developer", "iOS/Android lead"] }, { "name": "Maya", "role_hints": ["AI/agent researcher", "prompt engineer", "researcher-developer", "autonomous-development practitioner"] }, { "name": "Nia", "role_hints": ["facilitator", "scrum master"] } diff --git a/.abcd/development/principles/memory-graduates-to-record.md b/.abcd/development/principles/memory-graduates-to-record.md index e6d8835ea..bdcb8039b 100644 --- a/.abcd/development/principles/memory-graduates-to-record.md +++ b/.abcd/development/principles/memory-graduates-to-record.md @@ -3,15 +3,15 @@ **The rule.** An agent's persistent memory is for facts about *this user and this machine*. A lesson whose "why" is a correction **any** agent should receive belongs in the repository's committed record, never only in one -user's local memory. The test is the *why* behind the note: "the maintainer's -commits use this identity" is about the user — memory; "author names come from +user's local memory. The test is the *why* behind the note: "the technical +facilitator's commits use this identity" is about the user — memory; "author names come from the publisher's current record" is a correction every future agent needs — record. Twice-recalled is the promotion signal: a memory item that has changed behaviour in two sessions is a convention wearing the wrong home. **Why.** Local memory is unarmed and non-portable. A convention held there is enforced by nothing, travels to no other contributor, and — worst — makes the -tooling *appear* sufficient: the repo works for its maintainer partly because +tooling *appear* sufficient: the repo works for its technical facilitator partly because their agent's memory silently compensates, so the gap never surfaces until a second user hits it. The 2026-08-21 memory-portability audit found exactly this shape three times over (iss-2608210923436594): attribution handling, @@ -41,8 +41,9 @@ record, not in every minter's prompt or any one user's memory. *class* may graduate ("commit under the user's own forge identity"), the *value* may not. - Graduation is a proposal, not an automatic write: the promotion of a memory - item into a rules domain or conventions section is a maintainer decision, - like any record change. + item into a rules domain or conventions section is the product thinker's + or the technical facilitator's decision, whichever the rule concerns (what + to build, or how), like any record change. **Live instance.** The audit's three captures are the corpus: citation integrity had no repo home at all; the recurring attribution failure modes diff --git a/.abcd/development/principles/pre-existing-is-not-a-defence.md b/.abcd/development/principles/pre-existing-is-not-a-defence.md index 889f31f43..8dbd0bc6f 100644 --- a/.abcd/development/principles/pre-existing-is-not-a-defence.md +++ b/.abcd/development/principles/pre-existing-is-not-a-defence.md @@ -13,7 +13,8 @@ consequence is fixed in that release rather than carried past it, because a release that steps over a known defect of exactly the class it is named for spends the credibility it exists to build. The users who read a security release note and act on it are trusting a claim about the state of the system; a defect -the maintainers had already confirmed and stepped over makes that claim false in +the product thinker and the technical facilitator had already confirmed and +stepped over makes that claim false in the specific way that is hardest to recover from. **Why.** The failure has a shape, and the shape is persuasive rather than diff --git a/.abcd/development/principles/the-users-directory-is-theirs.md b/.abcd/development/principles/the-users-directory-is-theirs.md index 2f2820a23..d3f56c5d0 100644 --- a/.abcd/development/principles/the-users-directory-is-theirs.md +++ b/.abcd/development/principles/the-users-directory-is-theirs.md @@ -9,8 +9,8 @@ own space, it can list and reclaim. **Why.** A directory is the user's map of their own work: what is there is what they made, and a folder that fills on its own stops being a map. The -2026-09-06 session that created twenty-two worktrees beside the maintainer's -other projects did nothing wrong by isolating — parallel sessions need +2026-09-06 session that created twenty-two worktrees beside the other projects +of the person who ran it did nothing wrong by isolating — parallel sessions need separate checkouts — and everything wrong by location; the objection was not "why so many" but "I don't want a user to be surprised that a folder is all of a sudden full of stuff". Twenty-one spent worktrees had already been cleared by diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 9fcfadf04..7cd5bba2b 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -349,7 +349,15 @@ "group": "records", "block": "people", "sentence": "Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it.", - "flags": [] + "flags": [ + { + "name": "session", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] }, { "path": "abcd capture", diff --git a/.abcd/development/specs/open/spc-2609212141412864-abcd-s-own-text-names-the-product-thinker-or-the-technical.md b/.abcd/development/specs/closed/spc-2609212141412864-abcd-s-own-text-names-the-product-thinker-or-the-technical.md similarity index 100% rename from .abcd/development/specs/open/spc-2609212141412864-abcd-s-own-text-names-the-product-thinker-or-the-technical.md rename to .abcd/development/specs/closed/spc-2609212141412864-abcd-s-own-text-names-the-product-thinker-or-the-technical.md diff --git a/.abcd/docs-lint.json b/.abcd/docs-lint.json index ddbed5da9..2b587a2c1 100644 --- a/.abcd/docs-lint.json +++ b/.abcd/docs-lint.json @@ -170,6 +170,21 @@ ], "message": "names a specific bundled tool in user-facing content; abcd's published surface stays host-agnostic. Use a generic term (a spec/task backend), or add if naming it is genuinely necessary." }, + { + "id": "roles/retired-role-word", + "pattern": "(?i)\\bmaintainer", + "severity": "blocker", + "successor": "the product thinker (what to build, rulings, sign-off) or the technical facilitator (how: gates, merges, mechanics); the person, or both, where it is genuinely either", + "allow_context": [ + "(?i) for an acknowledgement or a persona's outside job title only." + }, { "id": "names/record/glossary-self-identification", "pattern": "(?i)this glossary file", diff --git a/.abcd/rules.json b/.abcd/rules.json index 4d9010c6b..56b446b4b 100644 --- a/.abcd/rules.json +++ b/.abcd/rules.json @@ -47,7 +47,7 @@ "rules": [ "Intents are press-release-first; acceptance criteria are BDD (Given/When/Then).", "Personas come from .abcd/development/personas.json: pick by role, never by name, and refer to every persona as they/them. The persona_registry record-lint rule enforces the registry (itd-79). (The bundled default names Alice/Bob/Carol for a registry-less adopter; this repo ships the registry, so the roster is the full fourteen.)", - "Lifecycle is drafts/ -> planned/ -> shipped/; a planned intent's adoption is a maintainer decision.", + "Lifecycle is drafts/ -> planned/ -> shipped/; a planned intent's adoption is the product thinker's decision.", "The change that lands a planned intent's work closes its spec in the same change (go run ./cmd/abcd spec close ), which ships the intent; nothing runs it for you, and a planned intent whose code is on main is invisible to the release cut." ] }, diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index dcc0f047b..47bd6986e 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2573,4 +2573,9 @@ together (the script's header says why there is no escape hatch). - 2026-09-26 — The build loop's worktree store is keyed on the FULL root sha: a lane lives at `~/.abcd/worktrees//-` with the 40-hex root commit, the form the history, transcript and voyage stores use and the one the store's draft (itd-2609091014076309) specifies. The lanes of autonomous run A made by hand under the abbreviated key (`~/.abcd/worktrees/488a0aa9//`) are the pre-verb convention, not a second form of the store: the loop never reads or adopts a lane under that key, and those worktrees are retired with `git worktree remove` like any other (implementer of fix round fix2-loop2, autonomous run A, on item 4 of the loop2 review; spc-2609202134338445 piece 6). - 2026-09-28 — Rulings Z, AR and the cancel policy, given by the user as technical facilitator at 15:10:37Z (autonomous run A, recorded by lane cap45 for orchestrator abcd-a8). (Z) The macOS leg of ci.yml's `check` job and the main ruleset's merge-queue `check_response_timeout_minutes` both rise from 30 to 45 minutes, because a 30-minute cap cancelled passing macOS runs (#728, #730 twice, #733; iss-2609281514435020). This amends the standing rule that never raises the check job's timeout or edits the ruleset, for exactly this one change: 45 minutes, those two settings; every other timeout, every required-check name or split and every other ruleset field stays as it is, and the ubuntu leg keeps 30. The workflow and the `.abcd/work/rulesets/main-protection.json` mirror change through a reviewed pull request; the live ruleset is changed with `gh api` by the run's orchestrator after that pull request merges, and until then the live queue still fails a group at 30 minutes. (AR) All three speed-ups of iss-2609261924541555 are built: a test-only switch that skips the disk flush in the atomic write code and the slowest -race package first in the race step (lane ciSpeed), and the scanner's per-identity git calls folded into one (lane scanFold), a trust path that is security-reviewed before it lands. (Cancel policy) The rerun-once rule stands: a check cancelled at the cap is rerun once, and a second cancellation for the same reason stops that pull request and opens a speed lane, never another raise of the timeout; and a step on the macOS leg warns, in the log and the step summary, once the check has run past 35 minutes, without ever failing the job, so a speed lane opens before any cancellation. - 2026-09-28 — Release v0.11.1 is cut by autonomous run A, and the run's agenda line is: approve the publish step. Under ruling A2 of the product thinker's run A interview (2026-09-23 07:52Z: the run approves the release environment itself once every gate is green) and the product thinker's releases ruling of 2026-09-25T08:04:52Z ("cut additional releases if that makes sense, but bundle multiple intents for it"), the run approves the `release` environment's deployment of v0.11.1 only after the merge queue, the verify job and every other gate on the tagged commit report green, and stops with a handover instead if any does not. The cut: v0.11.1, impact additive (no breaking record since v0.11.0; the run had been calling it v0.12.0 until `launch ship` derived the version), 354 records since v0.11.0: eighteen shipped intents, all additive, and 336 resolved or declined issues (228 fixes, eighteen additive, 90 internal and outside the changelog); the release guard and the findings guard passed with no waiver. Content commit 8a6c83d5, on top of 9ead1d1bf, which the docs-currency gate's findings required. Both semantic gates ran at tier full. The docs-currency-reviewer (Fable 5.1) read the first roll 7c7f5525 and found four minor findings (two stale terminology rows, a README sample status line no state renders, and the root command sentence missing three dispatched record families), all fixed in 9ead1d1bf. The brief-surface cross-check (44 pinned checkers, Opus 5.5, at most four alive) found 127; an independent classification (Fable 5.1) found 126 real at the content commit: the two user-facing and three behaviour findings are captured as four records (iss-2609282105240689, iss-2609282105242542, iss-2609282105241960, iss-2609282105240081), the one major among them, the guard registry passing a bare `rm -rf /` (iss-2609282105242542), deferred out loud past v0.11.0 because it already shipped in v0.11.0 and a registry change needs its own tests and review, all four to be fixed in the first lane after the tag; the design-record drift goes to the systematic brief pass iss-2609091956001547. Landed before the cut on the cutting session's ruling: #736 (integration branch 11); integration branches 12, 13 and 14, reviewed and ready, hold until the tag and fall into the next release. +- 2026-09-27 — iss-2609100506263330 takes the record's option (b): with no verified release artefact in the persistent plugin data directory, `ahoy install` writes no PATH entry and names the install one-liner as the command to run first, rather than writing a symlink into the plugin root that the next plugin update strands (lane implementer drainH, autonomous run A, orchestrator abcd-51). Option (a), fetching the artefact inside install, is not taken: install's documented meaning is local configuration, and adr-38 lets the network answer only a verb whose documented meaning is the fetch; the one fetch-and-verify primitive (`abcd update`'s) also imports ahoy, so reaching it from install would need a second downloader or an import inversion. A symlink into the plugin root that an earlier release wrote is left where it stands on a cold cache, raised as a required `symlink.legacy` gap whatever the cache holds, and recorded so the hooks accept it meanwhile. A dangling link `~/.abcd/path-entry` names (read through the same owned, not-group-or-other-writable guard the hook shims apply) is classified abcd's own and repaired by install or removed by uninstall with its record; an unrecorded dangling link keeps iss-2609100506256636's ruling — no provenance claimed, left untouched by detection and by any run with nothing to write in its place, cleared only when install writes the verified copy there. +- 2026-09-28 — iss-2609280932480608 is fixed rather than deferred (ruling by orchestrator abcd-9f, autonomous run A; lane fix2-drainH): iss-2609100506256636's rule, danglingness not provenance, applies past the one entry install acts on, so a gap-driven install removes every abcd-owned dangling `PATH` entry other than its target, with its record, and names each in a note. The removal waits for the target to be a working entry of abcd's own after the step, not merely for the run to have something to write: a cold-cache run that adopts the one-liner's copy writes nothing and still leaves `abcd` answering, while a run that leaves nothing working at the target keeps the dangling entry and names the command to run first. An unowned dangling link is never removed by this step. A dangling link runs nothing — the shell skips it — so the shadow note and the dangling gap no longer say it is what runs or that it shadows later entries. Correction to the 2026-09-27 entry above on iss-2609100506263330: an unrecorded dangling link is not cleared only when install writes the verified copy there, as that entry says, but whenever install writes an entry of its own there — the verified copy, or the `--dev` shim when the plugin binary it rebuilds beside exists; `clearDanglingEntry` clears it ahead of either write, and a run with nothing to write in its place still leaves it untouched. +- 2026-09-27 — The kill-by-search reading follows a search into and out of shell strings, and keeps one over-block, recorded so it is not mistaken for a defect (lane drainG2, autonomous run A, on iss-2609262259360005 and the review of the first reading). Every command of a string that `xargs` runs is read as handed xargs's input, so `pgrep make | xargs sh -c 'kill 4242'` blocks as `kill-by-search` though its kill names a pid: `xargs -I{}` replaces the input into any part of the string, and telling a command that reads `"$@"` or `$1` from one that does not would be a text match on the string, which the feed mechanism does not make. The same holds for the standard input a shell passes to the commands of its string (`pgrep make | sh -c 'xargs kill'`). The accepted over-block of the first reading, that a lower-case signal name (`pkill -term -g `) blocked as `pkill-by-owner` because its letters read as the `-t` and `-u` selectors, is removed rather than recorded: `pkill`'s first `-NAME` word naming a signal is read as its signal, in any case and with or without `SIG`, as procps-ng and BSD pkill read it, which is also what lets `-U` and `-G` be read attached (`pkill -Ubob`) without taking `-HUP`, `-USR1` or `-SIGTERM` for them. Only the first such word is the signal, so `pkill -9 -term -g ` still blocks: both implementations hand the second word to their option parser as `-t erm`. `killall` is not read this way, because psmisc killall reads a signal name only when it begins with a capital letter and parses `killall -term` as `killall -t erm`. +- 2026-09-27 — A pipe into a group and a redirect into a shell string reach every command there, and the reading keeps two over-blocks, recorded so they are not mistaken for defects (lane fix3-drainG, autonomous run A, on iss-2609270028388291 from review-drainG2). A pipe into a `{ … }` or `( … )` group is the standard input of every command in it, so each command emitted inside the group reads it, not only those before the group's first separator; a shell in the group reads it as a stream too. A command in a group is read as handed its group's input even when a pipe inside the group hands it another, because the command before that pipe may pass the group's input on (`cat`), and which commands do is not modelled; so `pgrep make | { true; echo 4242 | { xargs kill; }; }` blocks. The runs of the open groups are held as one covering run, which keeps each command's reading constant however deep groups nest, at the cost of also counting a search that sits inside an outer piped group before an inner one opens. A shell passes its standard input to the commands of its string, and a here-string or a process substitution redirected into the shell is that input, as a pipe is; the `<` that redirects a process substitution is not kept by the tokenizer, so a process substitution handed to the shell as an operand is read as its input as well, which is what `sh -c 'xargs kill < "$1"' _ <(pgrep make)` does with it. +- 2026-09-28 — A parameter expansion is an unknown word, read for what its value can spell and not for what an earlier command carried into it, and the reading keeps its over-reads, recorded so they are not mistaken for defects (lane drainG3, autonomous run A, on iss-2609251824244354; this supersedes allow (4) of the first 2026-09-25 entry and ruling (c) of the third, which parked the plain-variable half). `$X`, `$1`, `$@`, `$*`, `$-` and a `${…}` read whole to its own `}` leave the unknown word's mark where the value goes, so `--$X` is every flag it could become and `$GIT` in command position is every program its known text allows; `$$`, `$!`, `$?` and `$#` print numbers and stay text, as an arithmetic expansion's output is a number. Allow (1) of the first 2026-09-25 entry extends to a word that is wholly a variable: it is one operand, so `git push origin "$branch"` stays allowed and `git push $X origin main` is not seen. A string handed to a shell is read with each variable-only word written back out (`sh -c "… $X …"` reads `$X` as that shell does), so a bare variable in a string raises no new warn, which keeps the gap `shellRawUninspectable` names: a value holding shell syntax is not read. The 4,315-input false-positive sweep (Makefile recipes, script lines and whole scripts, the repo-mined and adversarial corpora, and 81 everyday variable lines) moved from 4,000 allow / 47 block / 19 warn to 3,991 / 49 / 26 with 249 unparsable lines unchanged, after three readings of a carried value were left out because together they added 37 blocks on ordinary work: a variable handed to a shell or `source` as its script is not a stream (`bash "$script"`), and a variable standing as the program fires no entry that names only its program and an operand (pkill-by-pattern, killall-by-name) and is not a bare interpreter inside a string (`"$GO" build`, `$EDITOR notes.md`). Those, a pid list carried through a variable, and `eval "$X"` (which allows, as it did) are iss-2609281134544802, deferred past v0.11.0 for a ruling. Over-reads kept, each the variable twin of a ruled substitution over-read: a variable program name with a variable first operand can be `git clean` and warns (`exec "$BIN" "$@"`, `"$BASH" "$GATE"`; five lines of the sweep), `git -c core.quotePath=false "$@"` warns under git-clean, `git grep` whose pattern holds a variable and a `(` warns under the fail-safe as its substitution twin does, two printf continuation lines of a script read on their own (`"$n" "$n" "$spec" "$n"`) block as gh-repo-delete, `git -c "$KV" commit` blocks as a commit that may move core.hooksPath, and a stream piped into a shell whose script is a variable (`curl … | bash "$f"`) blocks, because a word wholly an expansion may be no word. The kill-by-search reading gains three feeds in the same lane (iss-2609270036253187): an unquoted here-document's substitutions are the standard input of the command that opened it, a substitution runs with the pipe into its own command as its input, and a shell string is handed the output of the substitutions in its command's words as its positional parameters or text, so every command of such a string is read as handed it (`sh -c 'kill 4242' _ "$(pgrep make)"` blocks), the over-block the 2026-09-27 entry accepts for a string xargs runs. - 2026-09-28 — v0.11.1 is published: autonomous run A approved the `release` environment at 22:44:40Z under ruling A2 and the releases ruling of 2026-09-25T08:04:52Z, after the merge queue, the verify job, the tag job and main's own CI on the tagged commit 2bc519f7 reported green (every check run succeeded apart from those skipped by design; the macOS leg of the push CI was the last to report). The release published at 22:47:00Z with four binaries, the plugin archive, checksums.txt and the site archive; the run verified the darwin-arm64 binary and the plugin archive against checksums.txt, `abcd --version` reports v0.11.1, the plugin archive's SHA-256 equals the digest the catalog pins and its address answers, and the build-provenance attestation verifies as signed by release.yml on main. Unlike v0.11.0, the site rendered and deployed inside the release run, so no redeploy was needed; abcdev.app shows v0.11.1. The version is v0.11.1 rather than the v0.12.0 the run had expected, because the cut derives the version from the records and nothing since v0.11.0 is breaking. diff --git a/.abcd/work/issues/open/iss-2609281134544802-a-variable-s-carried-value-is-not-read-by-the-shell-guard.md b/.abcd/work/issues/open/iss-2609281134544802-a-variable-s-carried-value-is-not-read-by-the-shell-guard.md new file mode 100644 index 000000000..7d32f592f --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281134544802-a-variable-s-carried-value-is-not-read-by-the-shell-guard.md @@ -0,0 +1,20 @@ +--- +schema_version: 1 +id: "iss-2609281134544802" +slug: "a-variable-s-carried-value-is-not-read-by-the-shell-guard" +severity: "major" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: lane drainG3" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/guard/unknown.go" +deferred_after: v0.11.0 +deferral_reason: "lane drainG3 (run A, 2026-09-28) built the parameter-expansion reading of iss-2609251824244354 and measured it: reading a variable's carried value as a pid list, a stream path, shell text or a pkill/killall program name refuses ordinary commands (22 program-name and 15 stream blocks in the 4,315-input sweep), and leaving it unread keeps each spelling open; whether each class blocks, warns or allows is a product ruling not yet made, so these four classes, and only they, are carried past v0.11.0." +--- + +The shell guard reads a parameter expansion as an unknown word (iss-2609251824244354, lane drainG3) for the flags and program names its value can spell, but not as data an earlier command carried into the variable, and four classes stay unread: (1) a pid list a search printed into a variable (p=$(pgrep make); kill $p, or pgrep make | while read p; do kill $p; done), the plain-variable half DECISIONS 2026-09-25 (c) parked on iss-2609251824244354; (2) a stream path in a variable handed to a shell or source as its script (bash "$f" after f=<(curl ...)); (3) shell text a variable holds, run through eval "$X", sh -c "$X", or placed in a string a shell runs, whose value may hold a separator or quotes (eval "$X" allows, as it did before the reading); (4) pkill or killall as a variable's value standing as the program with an operand ($P make), and a variable-named bare interpreter inside a string. Reading each would refuse ordinary commands: the false-positive sweep of 4,315 inputs showed 22 new program-name-unknown blocks ("$GO" build, exec "$BIN", $EDITOR notes.md) and 15 new interpreter-reads-stream blocks (bash "$SCRIPT") before these readings were left out. Classes 1 and 3 carry the same cost, from the sweep's everyday-variable lines, each of which allows today: a reading of class 1 cannot tell a pid a search printed from one the script recorded, so it would refuse kill "$pid", kill -TERM "$PID" and kill -- -"$pg" (a background job's pid, a pidfile's, a process group's); a reading of class 3 cannot tell a hostile string from a composed one, so it would refuse eval "$cmd", the line that runs a command a script built up. Each class is a block, warn or allow call the product thinker has not ruled on. + +## Deferral 2026-09-28 + +Deferred past v0.11.0: lane drainG3 (run A, 2026-09-28) built the parameter-expansion reading of iss-2609251824244354 and measured it: reading a variable's carried value as a pid list, a stream path, shell text or a pkill/killall program name refuses ordinary commands (22 program-name and 15 stream blocks in the 4,315-input sweep), and leaving it unread keeps each spelling open; whether each class blocks, warns or allows is a product ruling not yet made, so these four classes, and only they, are carried past v0.11.0. diff --git a/.abcd/work/issues/open/iss-2609281310017733-home-scope-link-check-is-by-path-a-same-uid-race-can-swap-a.md b/.abcd/work/issues/open/iss-2609281310017733-home-scope-link-check-is-by-path-a-same-uid-race-can-swap-a.md new file mode 100644 index 000000000..6c5707c01 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609281310017733-home-scope-link-check-is-by-path-a-same-uid-race-can-swap-a.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609281310017733" +slug: "home-scope-link-check-is-by-path-a-same-uid-race-can-swap-a" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/fsutil/home.go" +--- + +The symlinked ~/.abcd rule is enforced by path, not by descriptor, so a same-uid race can still slip a link in between the check and the use. fsutil.HomeScopeLink Lstats each directory below home (internal/fsutil/home.go), and the reader then opens the file by its full path (ReadHomeDeclaration, then ReadDeclaration's own Lstat, open and SameFile), while the writers check and then MkdirAll, lock or create by path (credential.SetMachine, oracle writeProviderBlock, ahoy writePathEntry, writeMachineRouting, wireStatusLine, and the history registry's EnsureRealDirAll after historyRoot). A process running as the same uid that swaps ~/.abcd for a symlink after the Lstat reads or writes through the link. It is the same residual the rules loader's Lstat carried, and it needs the caller's own uid, so it widens nothing an attacker at that uid could not do directly; it is recorded so the rule's guarantee is stated at the strength it actually has. os.Root would not close it: it follows a symlink that stays inside the root, and a dotfiles ~/.abcd usually points inside home. The airtight form uses only the standard library (syscall on darwin and linux, no new dependency): open home, then syscall.Openat(homefd, ".abcd", O_DIRECTORY|O_NOFOLLOW), then Openat(dirfd, leaf, O_NOFOLLOW) (O_CREAT with O_EXCL or O_NOFOLLOW for writers, Mkdirat for a missing level), and judge and read or write through those descriptors. Left open by the fix round that routed every reader and writer through HomeScopeLink (branch fix/drain-symlinked-home); not built there. diff --git a/.abcd/work/issues/open/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md b/.abcd/work/issues/resolved/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md similarity index 72% rename from .abcd/work/issues/open/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md rename to .abcd/work/issues/resolved/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md index 6235c0eb4..72d8bc1c0 100644 --- a/.abcd/work/issues/open/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md +++ b/.abcd/work/issues/resolved/iss-2609100506263330-ahoy-install-can-leave-a-path-entry-a-plugin-update-breaks.md @@ -9,6 +9,10 @@ found_during: "autonomous-run field experiment in a managed repository, 2026-09- origin: researcher-authored production_mode: hand-written found_at: "internal (ahoy install, PATH entry)" +resolution: "ahoy install on a cold cache writes no PATH entry and names the README install one-liner as the command to run first, instead of a symlink into the plugin root the next plugin update strands; a working plugin-root pin an earlier release wrote is a required symlink.legacy gap whatever the cache holds and is left in place until a verified copy exists; a dangling link ~/.abcd/path-entry names, read through the hook's own ownership guard, classifies as abcd's own, so ahoy offers its repair, install replaces it with the verified copy and uninstall removes it with its record, while an unrecorded dangling link claims no provenance. Follow-up commit 2c804452e makes the uninstall half hold when no plugin root resolves, the case the owned dangling gap names uninstall for: the record is judged before the root, and the recorded dangling link is found wherever it sits on PATH." +impact: fix +resolved_by: + commit: "e390beafd4f017b1554769d5317fd06d406fe6b0" --- `ahoy install` can report success while leaving a PATH entry that a later plugin update silently breaks, and the condition that decides which happens is invisible to the operator. @@ -51,3 +55,7 @@ entered `open/` since the anchor, and this record sat in `open/` at v0.9.0, so it counts as standing backlog, while iss-2609120447482506 entered after that anchor. The raised severity keeps the grade honest; it does not put the finding back in front of this cut's guard. + +## Grounds + +- pursued: we expect a refusal naming a runnable command to be easier to act on than a warned-about link that dangles later, and the recorded path to be sufficient provenance for a dangling link because the record is written only by an install the operator ran and read under the hook's ownership guard; it is shown wrong if operators routinely lack a way to run the one-liner (no network, no curl) while their hooks can provision the cache, or if a recorded path is ever re-occupied by a dangling link abcd did not write diff --git a/.abcd/work/issues/open/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md b/.abcd/work/issues/resolved/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md similarity index 72% rename from .abcd/work/issues/open/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md rename to .abcd/work/issues/resolved/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md index ed25d0aae..144b5a90d 100644 --- a/.abcd/work/issues/open/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md +++ b/.abcd/work/issues/resolved/iss-2609230720193756-the-implement-run-state-has-two-same-uid-hygiene-gaps-the.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written +resolution: "AppendLineIn refuses a symlinked or non-regular leaf; readClaim treats a claim whose session or lane is not a name as unreadable; lock files are created 0600; the command page and brief say the bounds rest on a cooperative, unauthenticated role." +impact: fix +resolved_by: + commit: "f85d271ac" --- The implement run state has two same-uid hygiene gaps the security review of itd-2609221656373558 rated LOW and shipped past on the record: fsutil.AppendLineIn's openAppendIn (internal/fsutil/fsutil.go) opens the log leaf without the Lstat and symlink refusal its read twin ReadGuardedInRoot applies, so an in-root symlink leaf redirects every appended line (demonstrated: a log symlinked onto a claim file makes the claim unreadable); and readClaim (internal/core/implement/claim.go) checks a claim file's session and lane only for non-empty before they reach HeldError and the release refusal, printed to stderr unsanitised, so a hand-edited claim can put terminal escapes on the operator's screen. Remedy, each test-held: refuse a symlink leaf in openAppendIn as the read primitive does, and run validName on the claim's session and lane in readClaim treating failure as unreadable. Also: .lock is created 0644 beside 0600 run files (internal/fsutil/flock.go), and commands/implement.md does not say the release refusal rests on a cooperative, unauthenticated role. The next lane that extends internal/core/implement (the build verb, itd-2609201916151817) takes it. + +## Grounds + +- pursued: an in-root symlink leaf can no longer redirect an appended log line and a hand-edited claim can no longer put escapes on the operator's screen; a log append landing in a claim file, or an escape in a HeldError, would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md b/.abcd/work/issues/resolved/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md similarity index 67% rename from .abcd/work/issues/open/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md rename to .abcd/work/issues/resolved/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md index 7fdd2cb9c..fb39d0f45 100644 --- a/.abcd/work/issues/open/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md +++ b/.abcd/work/issues/resolved/iss-2609240646542516-nothing-counts-live-agents-so-the-ceiling-is-arithmetic.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/implement/bounds.go" +resolution: "implement log counts the agents a session's own agent_start/agent_end lines declare alive since it joined, refuses an agent_start past the session's ceiling (logged as refusal condition agent_ceiling), and check reports agents_alive. Live processes are not guessed: a fork or an agent started outside the log stays invisible, so the lane brief's no-fork rule stays the discipline for that half, as the record says." +impact: additive +resolved_by: + commit: "b3f77f3d0" --- Nothing in `abcd implement` counts the agents alive, so a run's ceiling rests on the orchestrator's arithmetic: `implement join --ceiling N` records the ceiling and the report repeats it, and the v0.10.0 changelog says as much (recorded and reported, not enforced). Autonomous run A went over its ceiling three ways. At the start two lane agents each forked themselves into three parallel workers that no count saw, until the lane brief forbade forks. At 10:28Z on 2026-09-23 the orchestrator had six agents alive against a ruled five. During the v0.10.0 cut, at 05:06Z on 2026-09-24, it launched two cross-check agents into one free slot, five alive against four, and stopped one within a minute. Each was found by the orchestrator recounting, never by a tool. Wanted, for the implement verb (itd-2609201916151817): agent_start and agent_end kept by the verb itself, a live count derived from them, and a refusal to start an agent over the ceiling. A fork cannot be seen from outside the host, so the lane brief's no-fork rule stays the discipline for that half. + +## Grounds + +- pursued: an orchestrator that logs its agents is refused the fifth start under a ceiling of four; an overrun found only by the orchestrator recounting logged agents would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md b/.abcd/work/issues/resolved/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md similarity index 69% rename from .abcd/work/issues/open/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md rename to .abcd/work/issues/resolved/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md index f34104510..2f236a871 100644 --- a/.abcd/work/issues/open/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md +++ b/.abcd/work/issues/resolved/iss-2609240646544930-a-join-before-the-window-mode-counts-in-the-last-window.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/implement/report.go" +resolution: "Compare attributes a session_open logged at most a minute (JoinGrace) before the next window_mode to that window; a join further back stays in the window it happened in." +impact: fix +resolved_by: + commit: "b3f77f3d0" --- `abcd implement report` gives each event to the window open when it happened, so a session that joins a second before the first session sets the window's mode is counted in the previous window. In autonomous run A the second session's session_open landed one second before the first session's `implement mode claim --window 2`, and the report lists that session under `single` with no lanes while its one lane, landed, sits under `claim`. Wanted: a join is attributed to the window whose window_mode line follows it within a short grace, or the report names a session whose open and whose work fall in different windows. + +## Grounds + +- pursued: a session joining a second before the mode line is reported under the window it joined; a session split across two modes by a sub-minute gap would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md b/.abcd/work/issues/resolved/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md similarity index 71% rename from .abcd/work/issues/open/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md rename to .abcd/work/issues/resolved/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md index 40c7ab6b3..53bfee480 100644 --- a/.abcd/work/issues/open/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md +++ b/.abcd/work/issues/resolved/iss-2609240646549900-implement-log-has-no-ceiling-overrun-event.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/implement/log.go" +resolution: "implement log accepts ceiling_overrun (alive, ceiling, lane, minutes, each required) and the report counts overruns and their minutes per mode and session, with an overrun column in the text table." +impact: additive +resolved_by: + commit: "b3f77f3d0" --- `abcd implement log` has no event for going over the ceiling. Its loggable events are backoff, lane_open, lane_close, agent_start, agent_end, ceiling_wait, gate_run, review, fallback, stop, refusal, pr, capture and context. When the orchestrator of autonomous run A launched a fifth agent under a ceiling of four during the v0.10.0 cut, the only fit was `refusal` with a hand-chosen `kind=ceiling_overrun` field, and the earlier breach of 2026-09-23 (six alive against five) went in as a `refusal` with the condition in prose. `abcd implement report` counts ceiling waits and cannot count breaches, so the run's report is silent on the one ceiling figure that went wrong. Wanted: a `ceiling_overrun` event (agents alive, the ceiling, the lane, the minutes over) and a report column for it. + +## Grounds + +- pursued: a run that goes over its ceiling can now log it as its own event and the report shows the count; a report silent on a logged overrun would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md b/.abcd/work/issues/resolved/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md similarity index 70% rename from .abcd/work/issues/open/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md rename to .abcd/work/issues/resolved/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md index 461db152d..3bad0ab93 100644 --- a/.abcd/work/issues/open/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md +++ b/.abcd/work/issues/resolved/iss-2609240646555891-implement-report-cannot-see-missing-fields-in-the-run-log.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23" origin: researcher-authored production_mode: hand-written found_at: "internal/core/implement/report.go" +resolution: "implement log refuses lane_close without lane/outcome, agent_start without agent, agent_end without agent/role/model/numeric minutes; the report names per event the lines lacking a required field (missing_fields) and each measured event whose lines stop more than six hours before the run's last line (coverage)." +impact: additive +resolved_by: + commit: "b3f77f3d0" --- `abcd implement report` derives its figures from fields no writer is required to supply, and says nothing when they are missing. It counts a lane as landed only when its lane_close carries `outcome` merged or landed, and agent minutes only from agent_end lines, while `abcd implement log` accepts any set of fields on any event. In autonomous run A the orchestrator that took over at the first rotation (16:15Z on 2026-09-23) wrote lane_close lines with `pr` and `merge` fields and no outcome, used lane_open and lane_close for review and fix rounds, and wrote no agent_start, agent_end, gate_run or ceiling_wait line for the rest of the run. Over both days the report counts ten lanes landed for the first session and one for the second, where thirty pull requests merged, and its agent minutes stop at 16:08Z on the first day. None of this is flagged, because every line parses. Wanted: per-event required fields (an outcome on lane_close; role, model and minutes on agent_end) refused at `implement log` time, and a report line naming any event kind whose coverage stops partway through the run. + +## Grounds + +- pursued: a run log whose figures are short now says so in the report, and new lines cannot omit the counted fields; a report that counts ten lanes landed against thirty merges without naming a gap would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md b/.abcd/work/issues/resolved/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md similarity index 72% rename from .abcd/work/issues/open/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md rename to .abcd/work/issues/resolved/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md index 346682c76..99aab6cd9 100644 --- a/.abcd/work/issues/open/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md +++ b/.abcd/work/issues/resolved/iss-2609251824244354-the-shell-guard-reads-a-command-substitution-s-output-as-an.md @@ -11,6 +11,14 @@ production_mode: hand-written found_at: "internal/core/guard/unknown.go" deferred_after: "v0.10.0" deferral_reason: "fix round 2 of lane guard (run A 2026-09-25) was scoped by its brief to command substitutions, fix round 3 resolved the command-position half, and fix round 4 the ${…} that carries a substitution; reading $VAR, a ${…} holding no substitution and $@ as unknown words touches every variable in the everyday corpus (git push origin \"$branch\", gh api paths, eval \"$X\") and needs its own tokenizer pass for ${…} bodies and its own false-positive sweep. The unknown-word primitive (unknown.go), read by every word reader, is the seam it lands on." +resolution: "A parameter expansion is an unknown word: a dash glued to a variable is a flag of unknown name, a variable in command position is any program its known text allows, and a string a shell reads carries its variables written out, so each twin of the substitution spellings blocks (TestParameterExpansionIsAnUnknownWord). The carried-value classes the sweep showed would refuse ordinary work are split out, deferred for a ruling, as iss-2609281134544802." +impact: fix +resolved_by: + commit: "7cf24a47a" --- The shell guard reads a command substitution's output as an unknown word, but not a parameter expansion's. A dash glued to a variable (git push --$X origin main, rm -$F after a cd) is read as the literal text --$X, which names no flag, so every blocker allows it while its --$(echo x) twin blocks; and ${GIT:-git} standing in command position is compared as text. bash builds the hazard from either. Found while closing review2-guard (its finding 1 names --$X as pre-existing). The record named a second half, a substitution standing in command position, which fix round 3 of lane guard resolved (a name a substitution prints is every program its known tail allows); this record is the parameter-expansion half alone. Fix round 4 made a ${…} that carries a command substitution (${X:-$(…)}, --${X:-$(…)}) unknown from its ${ on (iss-2609252120211621). What remains is exactly a parameter expansion with no substitution in it: $X, ${X}, ${X:-word} and the other operators with literal words, and $@ / $*, standing as the command's program name, as a flag or glued to one, as an operand an entry constrains, or inside a payload the guard reads. + +## Grounds + +- pursued: every spelling where a variable supplies a flag or a program name blocks as its substitution twin does, and the sweep adds no block on a whole command an agent writes; a variable-spelled flag or program that allows, or a new block on an everyday line, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md b/.abcd/work/issues/resolved/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md similarity index 65% rename from .abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md rename to .abcd/work/issues/resolved/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md index 9205d4c96..a9edc2854 100644 --- a/.abcd/work/issues/open/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md +++ b/.abcd/work/issues/resolved/iss-2609252050506863-the-build-s-peers-check-cannot-see-a-lane-that-has-neither.md @@ -8,6 +8,14 @@ source: "user-observation" found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written +resolution: "abcd build --session claims the intent in the shared run state for a joined session when it creates the run, so another checkout's build of the same intent is refused at its peers check from the start; the session's own claim is not its peer. A build without --session holds no claim and now says so in its result." +impact: additive +resolved_by: + commit: "bd6f6acdd" --- The build's peers check cannot see a lane that has neither moved nor claimed the intent: the peer listing (peers.Report.Locate) sees a holding only when a sibling worktree or branch holds the intent in another bucket, and the claim half sees only a live claim in the shared run store, so a lane at steps 1 to 4 of any run (worktree made, brief written, implementer working, nothing committed that moves the intent) and a second clone of the repository are both invisible, and a second 'abcd build' of the same intent in another checkout starts a duplicate run. The other half of the same check, a peer named and not read being skipped silently while an unreadable claim refuses (fail-open on one side, fail-closed on the other), is iss-2609252049491342, fixed in the same lane. Closing this half needs Start to write a claim into the shared run store when it creates a run, which needs a session identity the host driver does not have yet. + +## Grounds + +- pursued: a second build of one intent from another checkout, the first started with --session, is refused as held; a duplicate run started past such a claim would show it wrong. A session-less build stays invisible by design and is named as such. diff --git a/.abcd/work/issues/open/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md b/.abcd/work/issues/resolved/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md similarity index 52% rename from .abcd/work/issues/open/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md rename to .abcd/work/issues/resolved/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md index 2799f4a90..6a2370997 100644 --- a/.abcd/work/issues/open/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md +++ b/.abcd/work/issues/resolved/iss-2609260958587561-the-credential-store-writes-through-a-symlinked-abcd-home.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-apiadapter" origin: researcher-authored production_mode: hand-written found_at: "internal/core/credential/credential.go" +resolution: "credential.SetMachine refuses a symlinked ~/.abcd before creating anything, lock included, naming the link and the repair; the store read refuses a store behind the link; fsutil file locks are created 0600 and an older lock is narrowed on the next writer's descriptor, so no other local user can open the lock to hold it." +impact: fix +resolved_by: + commit: "734b26af4" --- The machine credential store is less strict than its sibling the rules loader about ~/.abcd: credential.SetMachine writes credentials.json and its lock through a ~/.abcd symlinked to an existing directory (a dotfiles checkout), where the rules loader refuses rules.json behind a symlinked ~/.abcd, so a secret can land in a dotfiles repository; and the store's lock files are created 0644 while a pre-existing 0755 ~/.abcd is never tightened, so any local user who can open a lock (a read-only descriptor holds LOCK_EX) can stall every connect for its five-second wait. + +## Grounds + +- pursued: SetMachine through a symlinked ~/.abcd leaves the link target empty and errors naming ~/.abcd, Resolve refuses a store behind it, and the store lock is owner-only even after a 0644 lock from an earlier version (TestSetMachineRefusesASymlinkedAbcdHome, TestResolveRefusesAStoreBehindASymlinkedAbcdHome, TestTheStoreLockIsOwnerOnly); a lock with a group or other bit after a write, or any file behind the link, would show it wrong. diff --git a/.abcd/work/issues/open/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md b/.abcd/work/issues/resolved/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md similarity index 61% rename from .abcd/work/issues/open/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md rename to .abcd/work/issues/resolved/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md index b492c02e9..b246f41e6 100644 --- a/.abcd/work/issues/open/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md +++ b/.abcd/work/issues/resolved/iss-2609270036253187-two-more-paths-by-which-a-process-search-s-output-reaches-a.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review-drainG2" origin: researcher-authored production_mode: hand-written found_at: "internal/core/guard/tokenize.go" +resolution: "Kill-by-search reads the three paths: an unquoted here-document whose body holds the search, redirected into an xargs kill; a substitution in a command that reads a pipe, which runs with that pipe as its input; and a shell string handed the search in its positional parameters or its own text (TestKillFedThroughAHereDocOrAnInheritedPipeIsBlocked)." +impact: fix +resolved_by: + commit: "7cf24a47a" --- Two more paths by which a process search's output reaches a kill are not read by kill-by-search: an unquoted here-document whose body holds a command substitution, redirected into an xargs kill (a here-string of the same substitution blocks), and a command substitution inside a command that reads a pipe, which inherits that pipe as its standard input (an xargs kill in the substitution reads the search piped into its command). A third, a search handed to a shell string as a positional parameter the string's kill reads, is not read either: it is a pid carried through a variable, the half DECISIONS 2026-09-25 (c) defers with iss-2609251824244354, unless it is read the way xargs's input to a string is (DECISIONS 2026-09-27), which is a call for the next guard lane. Found while fixing iss-2609270028388291. + +## Grounds + +- pursued: each path from a search to a kill through a here-document, an inherited pipe or a string parameter blocks, and the no-leak shapes stay allowed; an allowed spelling of one of the three, or a kill of a named pid that now blocks outside the recorded string over-block, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609280932480608-an-abcd-owned-dangling-path-entry-that-sits-earlier-on-path.md b/.abcd/work/issues/resolved/iss-2609280932480608-an-abcd-owned-dangling-path-entry-that-sits-earlier-on-path.md new file mode 100644 index 000000000..e5c1140db --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609280932480608-an-abcd-owned-dangling-path-entry-that-sits-earlier-on-path.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609280932480608" +slug: "an-abcd-owned-dangling-path-entry-that-sits-earlier-on-path" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/store.go" +resolution: "ahoy install removes every abcd-owned dangling PATH entry other than its target, with its record, once the target is a working entry of abcd's own (clearStrandedEntries, gated on entryAnswers), so the gap a stranded link ahead of the adopted copy raised no longer stays in Remaining; the shadow note and the dangling gap say a dangling link runs nothing — the shell skips it — instead of 'is what runs', 'a binary it does not own' or 'shadows every later PATH entry'. Unowned dangling links are never removed by this step." +impact: fix +resolved_by: + commit: "7711d1677" +--- + +An abcd-owned dangling PATH entry that sits EARLIER on PATH than the owned copy ahoy adopts is never repaired: install acts only on the adopted target (effectiveBinTarget skips dangling entries), so the symlink.dangling gap the earlier entry raises stays in Remaining on every run and ahoy install can never report clean. The install-time shadow note for it is also wrong in two ways: it says the dangling link 'is what runs when you type abcd' (a link that resolves to nothing runs nothing; the shell skips it) and ends 'abcd never clobbers a binary it does not own' about an entry it classifies as its own. Reproduced (lane drainH, 2026-09-28): a stranded plugin-update link in one PATH directory ahead of a one-liner-installed owned copy in ~/.local/bin, on a warm cache and on a cold one; the warm-cache half is the same at base 0f9d652a. Needed: decide whether install removes an owned dangling entry that is not its target (it destroys nothing, and uninstall already removes owned entries), and give the shadow note owned-entry and dangling-entry wording. + +## Grounds + +- pursued: an install over a stranded owned link ahead of a working owned entry now finishes with nothing Remaining, cold or warm; a run that leaves the stranded link or its record behind, removes an unowned dangling link, or removes one while nothing working stands at the target would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609281017573862-the-abcd-path-entry-and-cache-attestation-readers-do-not.md b/.abcd/work/issues/resolved/iss-2609281017573862-the-abcd-path-entry-and-cache-attestation-readers-do-not.md new file mode 100644 index 000000000..5c19063a5 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609281017573862-the-abcd-path-entry-and-cache-attestation-readers-do-not.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609281017573862" +slug: "the-abcd-path-entry-and-cache-attestation-readers-do-not" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25: review-drainH" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/fsutil" +resolution: "Every reader of a file abcd trusts in ~/.abcd reads through fsutil.ReadHomeDeclaration, which refuses a file behind a symlinked ~/.abcd (fsutil.HomeScopeLink) and reads an empty symlinked ~/.abcd as absent; path-entry and cache-attestation are refused through ahoy.homeScope, the hook shims test the link before reading path-entry, and hooks/bootstrap.sh refuses to write either record through it." +impact: fix +resolved_by: + commit: "734b26af4" +--- + +The ~/.abcd/path-entry and cache-attestation readers do not refuse a symlinked ~/.abcd, unlike the rules loader: fsutil.ReadDeclaration lstats the FILE (regular, this uid, not group- or other-writable, O_NOFOLLOW re-open) but follows a symlinked parent directory, and the hook shims' find -maxdepth 0 -type f -user ... ! -perm -0020 ! -perm -0002 check likewise follows the parent. The two readers agree with each other, but AGENTS.md states the rules.json rule as refused behind a symlinked ~/.abcd, so a dotfiles-symlinked ~/.abcd hosts a path-entry record that vouches for which binary the hooks run while the rules loader in the same home would refuse its rules.json. Found by review-drainH (LOW, pre-existing, shared with cache-attestation); related to iss-2609260958587561 (the credential store's copy of the same gap) but not covered by it. + +## Grounds + +- pursued: a well-formed, owned, owner-only path-entry or cache-attestation behind a symlinked ~/.abcd vouches for nothing in ahoy, the hook shims run no PATH abcd on it, and the bootstrap writes nothing behind the link (TestHomeScopedRecordsRefuseASymlinkedAbcdHome, TestBinaryHooksRefuseAPathBinaryVouchedForBehindASymlinkedAbcdHome, TestBootstrapRefusesASymlinkedAbcdHome); a reader that still followed the link, or a bare fsutil.ReadDeclaration call outside fsutil, would show it wrong (TestHomeDeclarationsReadThroughReadHomeDeclaration). diff --git a/.abcd/work/issues/resolved/iss-2609281045487620-a-file-named-in-a-docs-lint-or-record-lint-config-s-roots.md b/.abcd/work/issues/resolved/iss-2609281045487620-a-file-named-in-a-docs-lint-or-record-lint-config-s-roots.md new file mode 100644 index 000000000..416a2bd57 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609281045487620-a-file-named-in-a-docs-lint-or-record-lint-config-s-roots.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609281045487620" +slug: "a-file-named-in-a-docs-lint-or-record-lint-config-s-roots" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: lane roles" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lint/lint.go" +resolution: "a non-markdown file in roots is a configuration error pointing at extra_roots, in LintAt and DocumentsInRoots" +impact: fix +resolved_by: + commit: "79923a98a309874d0cfb33de857dba8c818c2c7a" +--- + +A file named in a docs-lint or record-lint config's roots that is not markdown is read as nothing and reported clean: the per-root walk keeps only .md files, so a root such as .abcd/rules.json passes the does-not-exist check, contributes zero documents, and every rule the config arms reads none of it while the lint exits 0. Confirmed by a test on a scratch copy (roots [docs, rules.json] with a ban matching the JSON: no finding, no error; DocumentsInRoots counts it as zero). Found while widening the role ban past the documentation, where the spec's 'roots widened to .abcd/rules.json' would have silently checked nothing. Wanted: a non-markdown file in roots is a configuration error that points at a token's extra_roots, the way a missing root already is (GitHub #360). + +## Grounds + +- pursued: we expect a config naming a non-markdown file in roots to fail loud rather than report clean; shown wrong if TestRootsRefuseANonMarkdownFile passes while such a root lints clean diff --git a/.abcd/work/issues/resolved/iss-2609281129171021-the-ahoy-history-registry-abcd-history-index-json-and-its.md b/.abcd/work/issues/resolved/iss-2609281129171021-the-ahoy-history-registry-abcd-history-index-json-and-its.md new file mode 100644 index 000000000..e60470e67 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609281129171021-the-ahoy-history-registry-abcd-history-index-json-and-its.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609281129171021" +slug: "the-ahoy-history-registry-abcd-history-index-json-and-its" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25: lane drainHome" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/ahoy/store.go" +resolution: "historyRoot() applies fsutil.HomeScopeLink to .abcd/history/index.json, so every registry reader and writer refuses a symlinked ~/.abcd or ~/.abcd/history; the registry's directories are created through fsutil.EnsureRealDirAll from the resolved home; ahoy install skips the registration with a note naming the link and the repair, and the detector raises the non-actionable history.home_symlinked diagnostic." +impact: fix +resolved_by: + commit: "7dbd624be" +--- + +The ahoy history registry (~/.abcd/history/index.json and its lock) is created and written through a symlinked ~/.abcd: historyRoot joins the home directory and withHistoryLock and bootstrapHistory call os.MkdirAll, which follows the link, so on a machine whose ~/.abcd is symlinked into a dotfiles checkout the registry of every managed repository, remote URLs included, lands in that repository. Its sibling stores refuse a symlinked level through their create-then-prove seam (transcripts, voyage, lab, inbox, runs use fsutil.EnsureRealDir/EnsureRealDirAll/IsRealDir), and every file abcd trusts in ~/.abcd refuses the link through fsutil.HomeScopeLink (iss-2609281017573862, iss-2609260958587561). The registry is a store, not a trust declaration, and refusing it changes ahoy install's registration on such machines, so it was left for its own ruling: route it through fsutil.EnsureRealDirAll on ".abcd/history" below the home directory and say what the install does when the registry cannot be created. + +## Grounds + +- pursued: with ~/.abcd a symlink to an empty directory, ahoy install must leave that directory empty and carry a note naming the skipped registration, the symlinked ~/.abcd and the repair (TestInstallRegistersNothingThroughASymlinkedAbcdHome, which failed before the fix with the target holding history/), and a home that is itself a link must still register (TestInstallRegistersThroughAHomeThatIsItselfALink); what would show it wrong is any file appearing behind the link after an install or a detect, or a linked home losing its registration. diff --git a/.abcd/work/issues/resolved/iss-2609281229109140-the-append-primitive-s-symlinked-leaf-refusal-has-a-race.md b/.abcd/work/issues/resolved/iss-2609281229109140-the-append-primitive-s-symlinked-leaf-refusal-has-a-race.md new file mode 100644 index 000000000..22235254a --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609281229109140-the-append-primitive-s-symlinked-leaf-refusal-has-a-race.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609281229109140" +slug: "the-append-primitive-s-symlinked-leaf-refusal-has-a-race" +severity: "minor" +category: "security" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainI" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/fsutil/fsutil.go" +resolution: "openAppendIn now re-Lstats the leaf after every open and refuses unless it is a regular file that SameFile-matches the opened descriptor, so a symlink planted between an empty pre-open Lstat and the open is refused and its target untouched; the comment names Lstat+SameFile as the mechanism, not O_NOFOLLOW." +impact: fix +resolved_by: + commit: "35b18357f" +--- + +The append primitive's symlinked-leaf refusal has a race: fsutil.openAppendIn opens through os.Root, where O_NOFOLLOW is inert (os.Root follows an in-root leaf symlink on ELOOP), so the refusal rests on the pre-open Lstat and the SameFile check against the opened descriptor, and SameFile is skipped when the pre-open Lstat saw nothing. A symlink planted between an ENOENT Lstat and the open is followed and appended through; the comment claims every open carries O_NOFOLLOW as though that refused it. Needs a same-uid racer in a microsecond window. + +## Grounds + +- pursued: AppendLineIn refuses a leaf linked after its Lstat with ErrNotRegular and leaves the target unchanged (TestAppendLineInRefusesALeafLinkedAfterItsLstat, RED before the post-open Lstat, GREEN after); an append through a link planted in that window succeeding would show it wrong. A hard link still passes SameFile and is out of scope. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c7aba5290..8ba1c0781 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,8 +15,9 @@ inbound = outbound statement is the whole of it. ## How changes land - **Issue first.** External contributions start from an accepted issue: open one - (or pick an open one) and get a maintainer's nod before writing code. A pull - request with no accepted issue behind it may be declined on scope alone — + (or pick an open one) and get the product thinker's nod (the person who decides + what abcd builds) before writing code. A pull request with no accepted issue + behind it may be declined on scope alone — that is policy, not a judgement of the work. - **Branch + PR** for substantive changes; CI gates the merge. Its `check` job builds, vets and tests (plain and race-enabled) on macOS + Linux, and on the @@ -83,8 +84,8 @@ inbound = outbound statement is the whole of it. - **Docs** are Diátaxis (one type per page, present tense); the design record lives under `.abcd/`, never in `docs/`. Prose follows the canonical [writing style guide](../docs/reference/writing-style.md). -- **New dependencies need explicit maintainer sign-off** before they land in - `go.mod`. +- **New dependencies need the product thinker's explicit sign-off** before + they land in `go.mod`. - **Run the plugin from your checkout.** The marketplace lists one plugin, and its source is the latest release's pinned archive, so installing from the marketplace gives you the last cut release, never your working tree. There is diff --git a/README.md b/README.md index 337fba463..0f86caf2c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Later, `/plugin update abcd` takes the latest cut release: the marketplace names Outside a plugin session, `abcd` runs from a terminal in any repository, with no harness involved. A checksum-verified one-liner provisions it, no administrator rights required: ```sh -sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; cd "$(mktemp -d)"; os=$(uname -s | tr "[:upper:]" "[:lower:]"); arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-$os-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | if command -v sha256sum >/dev/null; then sha256sum -c -; else shasum -a 256 -c -; fi; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' +sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; [ ! -L "$HOME/.abcd" ] || { echo "abcd install: ~/.abcd is a symlink, which abcd refuses rather than follows; replace it with a real directory and re-run" >&2; exit 1; }; cd "$(mktemp -d)"; os=$(uname -s | tr "[:upper:]" "[:lower:]"); arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-$os-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | if command -v sha256sum >/dev/null; then sha256sum -c -; else shasum -a 256 -c -; fi; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' ``` The [install guide](docs/how-to/install.md) covers building from source and what to do when `abcd` isn't found afterwards. diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index b743f52c9..a7382f0c2 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,6 +12,16 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. +## 2026-09-28 (itd-2609212137129937 — quotes are attributed by role) + +abcd's own text names the product thinker or the technical facilitator, and the +composer's example attribution was the one word that named neither. + +### press-release-composer 0.1.1 + +PATCH: the example quote and the attribution rule attribute by role ("a product +thinker", "a technical facilitator"). The output schema is unchanged, so a +document that was valid before stays valid. Unmeasured, as before. ## 2026-09-26 (iss-2609181121301638, iss-2609181121305984, iss-2609262011046013 — the requests state the conditions and the shapes) The fidelity review request lists every scope condition under its `cond-…` diff --git a/agents/press-release-composer.md b/agents/press-release-composer.md index 203cf98d9..cc54af28e 100644 --- a/agents/press-release-composer.md +++ b/agents/press-release-composer.md @@ -1,7 +1,7 @@ --- name: press-release-composer description: Compose a lifeboat's press release from its packed brief, spine, and distilled principles — a single grounded document that must cite at least one resolvable source. Host-delegated; feeds `abcd disembark press-release --press-release-json`. -prompt_version: 0.1.0 +prompt_version: 0.1.1 reads_untrusted_input: true capability_scope: task_classes: [surface_render] @@ -41,12 +41,12 @@ reject the **whole payload**. Use exactly these keys: { "schema_version": 1, "mode": "delegated", - "prompt_version": "0.1.0", + "prompt_version": "0.1.1", "headline": "abcd carries a project's theory across a session boundary.", "subhead": "A host-agnostic configuration layer for development.", "body": "The full press-release prose, composed from the packed brief and spine.", "quotes": [ - {"attribution": "a maintainer", "text": "The record survives the session; the lifeboat is how."} + {"attribution": "a product thinker", "text": "The record survives the session; the lifeboat is how."} ], "evidence": ["brief/01-product/01-press-release.md", "rescue/spine.md", "principles.json"] } @@ -56,12 +56,12 @@ Field rules: - `schema_version`: integer `1`. Required — a missing or `0` value is rejected. - `mode`: `"delegated"`. If present it must be exactly `"delegated"`. -- `prompt_version`: `"0.1.0"`. Required in your delegated output. +- `prompt_version`: `"0.1.1"`. Required in your delegated output. - `headline`: one line, required. `subhead`: one line, optional (omit the key if none). `body`: the prose, required; the binary caps and sanitises it. - `quotes`: optional array of `{attribution, text}` pull-quotes, each sanitised and - capped. Omit the key if none. Attribute quotes generically (e.g. "a maintainer") - — do not invent a named person. + capped. Omit the key if none. Attribute quotes generically, by role (e.g. "a product + thinker", "a technical facilitator") — do not invent a named person. - `evidence`: the packed paths this document rests on (see citation discipline). No other keys. Do not claim `mode: "deterministic"`. diff --git a/commands/ahoy.md b/commands/ahoy.md index 25193fca6..68aac3f6d 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -109,13 +109,31 @@ 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. +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. +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, +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. A `symlink.legacy` gap is a symlink into the plugin +root that an earlier release wrote: it works until the next plugin update, and +its fix hint says whether install replaces it now or which command comes first. +A `symlink.dangling` gap whose detail calls the entry abcd-owned — including an +entry `~/.abcd/path-entry` records — is repaired by install the same way. When +that entry is not the one install acts on (a link a plugin update stranded ahead +of the copy the one-liner wrote), install removes it with its record once the +entry it does act on is working, and a note names what it removed. + A `symlink.shadowed` gap (or a note saying the same) means another `abcd` comes first on `PATH`, so the entry abcd just wrote is NOT what runs — typically a binary an older install copied into a system directory. Relay it prominently: the install is not finished from the user's point of view. abcd will not remove that binary, and neither should you offer to; state the two remedies it gives (delete the stale one, or install ahead of it with `--bin-dir`) and let the user -choose. +choose. When the occupant is a link whose target is gone, the gap says it runs +nothing (the shell skips it) and asks for it to be removed; relay that, not the +"not what runs" framing above. Prompts read stdin whether or not stdin is a terminal, so an answer can be relayed without one: @@ -145,10 +163,10 @@ user (keys, tools, cost). When you relay such a question, relay that explanation verbatim with it; never describe an answer in your own words, and never offer an answer the question does not list. -That is a channel for passing on an answer the user has GIVEN — ask first, then -pipe; it is never a licence to answer on their behalf. Note that `yes |` -approves EVERY question, so only reach for it once the user has agreed to all of -them. +That is a channel for passing on an answer the technical facilitator has GIVEN +— set `abcd mode facilitator`, ask the technical facilitator first, then pipe; +it is never a licence to answer on their behalf. Note that `yes |` approves +EVERY question, so only reach for it once they have agreed to all of them. **Stdin must end, or the prompt waits.** With stdin at end-of-input every question declines, so a run that was told nothing writes nothing — but a stdin @@ -180,10 +198,10 @@ it, before its first commit. 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 now by running ? [y/N]`. -Through this page, ask the user 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 does), and only on their yes -run +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 +does), and only on their yes run ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --install-tool --json @@ -206,11 +224,11 @@ for is refused, naming the ones it does. **The house-style question.** When the install seeds `.abcd/docs-lint.json`, it asks `docs_lint.em_dash_in_list_item (blocking/warning) [warning]`: whether an em dash inside a list item, abcd's own house style rather than a currency rule, -blocks the docs lint or only warns. Relay the question to the user and pass on -their answer; never answer it for them. The answer is written into the seeded -config as that token's severity (`blocker` or `warn`), where the user can change -it later. `--yes` does not ask and seeds a warning, and the result's `notes` -says so. End of input or a bare Enter takes the warning. An answer that is +blocks the docs lint or only warns. Relay the question to the technical +facilitator and pass on their answer; never answer it for them. The answer is +written into the seeded config as that token's severity (`blocker` or `warn`), +where the technical facilitator can change it later. `--yes` does not ask and +seeds a warning, and the result's `notes` says so. End of input or a bare Enter takes the warning. An answer that is neither word (the `y` of `yes |`) also seeds the warning, with a note naming what was heard. The question comes after the category approvals and the configuration values and before the status-line offer, and is asked only when @@ -298,7 +316,9 @@ the source tip on every call and fails loudly on a broken build. Re-running **This writes.** It removes the BEGIN/END marker block and abcd's own `PATH` entry — the owned copy (or a legacy pinned symlink), found wherever it sits on -`PATH`, along with its provenance record — and leaves `.abcd/` intact, so the +`PATH`, along with its provenance record; a dangling link that record names is +removed with it even when no plugin root resolves any more — and leaves +`.abcd/` intact, so the repo's record survives. The persistent download cache is left to the harness's own uninstall to delete. Report `marker.removed` and the entry note; the receipt's `symlink.target` is already rendered in tilde form, so relay it as @@ -358,8 +378,9 @@ caller must CONFIRM the specific toggles named. A repo that sets as it is and is not contacted at all. The confirmation is the fourth gate, not a formality: an unanswered run declines -and changes nothing, so present the question and the repository it names before -answering it. `--yes` says yes in advance, and it is the user's word to give — +and changes nothing, so set `abcd mode facilitator` and present the question +and the repository it names to the technical facilitator before answering it. +`--yes` says yes in advance, and it is the technical facilitator's word to give — never pass it on their behalf. A run that changed nothing exits NON-ZERO (`refused` or `aborted`), so a failed invocation is never mistaken for a write that landed; `opted_out` is the one non-change that exits clean, because leaving @@ -412,7 +433,9 @@ to the first model listed, and only when that call succeeds writes the key into the owner-only `~/.abcd/credentials.json` and the provider block (the base URL, the key's name and the models, the allowlist) into `~/.abcd/config.json`. Nothing goes into the repository or the harness's settings, and a failed -verification writes nothing. `--home none` sets up a server that takes no key. +verification writes nothing. A `~/.abcd` that is a symlink (into a dotfiles +checkout, say) is refused with nothing written, naming the link: the key would +otherwise land wherever it points. `--home none` sets up a server that takes no key. The `external` and `keychain` homes arrive with the credential store (itd-2609221017023290) and are refused, naming it, before any call. diff --git a/commands/build.md b/commands/build.md index bda4b82a1..3ab886177 100644 --- a/commands/build.md +++ b/commands/build.md @@ -16,9 +16,19 @@ last one stopped. ## Start the run ```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" build --json +"${CLAUDE_PLUGIN_ROOT}/abcd" build [--session ] --json ``` +Pass `--session` with the host session's id when it has joined the shared run +(`implement join`): a new run then claims the intent there for that session, +with the run id as the lane, so a build of the same intent from any other +checkout of the repository is refused as held from the start, before this run's +lane has moved or claimed anything. The session's own live claim on the intent +is not counted as a peer's. A session that has not joined is refused at the +`claim` step with nothing written. Without `--session` the run holds no claim, +and the result says so (`claim` is null): another checkout cannot see the run +until its lane shows. + For an intent with no run in progress, the checks run first, and every one must pass: @@ -35,7 +45,8 @@ pass: - `hold` — the intent carries no `held:`. - `steps` — the spec's `## Steps` reads, and at least one step is not landed. - `peers` — no peer holds the intent: no sibling worktree or local branch holds - it in another bucket, and no session holds a live claim on it. A peer that + it in another bucket, and no session other than `--session` holds a live + claim on it. A peer that cannot be read (a worktree git will not answer for, a ledger holding one id twice) and an unreadable claim count as holding it: what they hold is unknown. @@ -48,7 +59,9 @@ goes back to the planning interview, a hold to the person who placed it. When the checks pass, the payload names the `run_id`, the `state` file (`.abcd/.work.local/run//state.json`), the first `lane` (the spec's first -unlanded step), the `pending` spec steps, and `next`, the move to make. The +unlanded step), the `pending` spec steps, `claim` (the claim `--session` took, +or null), +and `next`, the move to make. The local tier is never created: in a repository abcd does not manage the verb refuses. Starting again while the run is in progress creates nothing, runs no check, and reports `resumed: true` with the same run and an empty `checks`: the diff --git a/commands/capture.md b/commands/capture.md index d9709de8e..6852f6d51 100644 --- a/commands/capture.md +++ b/commands/capture.md @@ -124,9 +124,10 @@ Nothing is refused or dropped. The JSON's `match` object carries `matches` `near_misses` (the best five below the threshold, with their scores), `threshold`, and `skipped` when nothing was compared: a text with fewer than eight distinct terms, a record set that could not be read, or a match -configuration the reader refuses. Relay each match with its id and relation -and ask the user to confirm it. A confirmed link is left as it is; a wrong one -is removed by deleting its line, which leaves an ordinary record. The match +configuration the reader refuses. Set `abcd mode facilitator`, relay each +match with its id and relation, and ask the technical facilitator to confirm +it. A confirmed link is left as it is; a wrong one is removed by deleting its +line, which leaves an ordinary record. The match never proposes `reverses` or `supersedes`: a reversal is a person's judgement. The threshold and the compared fields are configuration: `match.threshold` @@ -329,12 +330,13 @@ The refusal names the construct and its body line. The repair is a hand edit: close or remove the opener in a text editor, then re-run. Promote and resolve given no `--grounds` append nothing and act. -**Ask for the expectation and its falsifier.** "Promoted it because it is next" +**Ask the product thinker for the expectation and its falsifier** (set `abcd +mode product-thinker` first). "Promoted it because it is next" restates the decision and records nothing; "promoted it because we expect a stamped identity to survive rewording, which nothing else does" is a conjecture somebody can later find wrong. abcd refuses only the degenerate texts — empty, too short, or the vocabulary word repeated back — and cannot tell a conjecture -from a restatement. That part is yours: put the question to the user and write +from a restatement. That part is yours: put the question to the product thinker and write down their answer. The value is APPENDED as a `- : ` bullet under the record's diff --git a/commands/disembark.md b/commands/disembark.md index 2ba89040e..bf50c8c4f 100644 --- a/commands/disembark.md +++ b/commands/disembark.md @@ -58,10 +58,11 @@ most valuable thing left. Reach that with an explicit flag on any of the three: "${CLAUDE_PLUGIN_ROOT}/abcd" disembark probe --include-ignored --json ``` -**Offer it; never assume it.** Widening the scan is the user's choice to make, -not a default to infer from a repo looking abandoned. When a probe comes back -thin over a repo that plainly had work in it, say the scan honoured `.gitignore` -and ask whether to widen — do not re-run wide on your own judgement. +**Offer it; never assume it.** Widening the scan is the technical facilitator's +choice to make, not a default to infer from a repo looking abandoned. When a +probe comes back thin over a repo that plainly had work in it, say the scan +honoured `.gitignore`, set `abcd mode facilitator`, and ask the technical +facilitator whether to widen — do not re-run wide on your own judgement. The wide scan declares itself: the report carries `included_ignored: true` (`scope: WIDE` in the text rendering), and the marker scan's `searched` line says diff --git a/commands/docs.md b/commands/docs.md index bca6915ca..7fc814990 100644 --- a/commands/docs.md +++ b/commands/docs.md @@ -65,8 +65,9 @@ Only URLs the documentation actually cites can be confirmed. The receipt records user for their method, and never record one: the schema has no field for it and loading rejects unknown keys. -Confirm on the user's word that they checked. An agent must never run `confirm` -on its own initiative to clear a red gate. +Set `abcd mode facilitator` first, then confirm on the technical facilitator's +word that they checked. An agent must never run `confirm` on its own initiative +to clear a red gate. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install provisions the binary into the plugin root, so this is the rung that fires for a diff --git a/commands/guard.md b/commands/guard.md index 4cfda6835..1005246b5 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -192,12 +192,25 @@ Text written beside one in the same word is also read as bash leaves it when the output is empty, so a flag glued to one is still the flag. A word that is wholly a substitution is read as an operand, not as a flag: that is how a commit message or a branch name is spelled every day (`git commit -m "$(cat msg)"`), so -`git push $(printf -- --force)` is not seen. A parameter expansion holding a -substitution (`${X:-$(…)}`) prints that substitution's output, so its word is -unknown from the `${` on: `--${X:-$(…)}` is every long flag, and a wholly -`${…}` word is read as a wholly-substituted one is. Inside double quotes a -`${…}` ends at its own `}`, and a `"` in it opens a nested string rather than -closing the outer one, so `echo "${MSG:-"don't"}"` is one word and runs. A +`git push $(printf -- --force)` is not seen. A parameter expansion (`$VAR`, +`$1`, `$@`, `${VAR:-git}`, `${X:-$(…)}`) prints a value the line does not +hold, so it is read by the same rule: `--$VAR` is every long flag, `-$F` every +short one, and `$GIT` or `${GIT:-git}` as the program is any program its known +text allows; a single-quoted or escaped `$` is text. A word that is wholly a +variable is read as an operand, as a wholly-substituted one is, so `git push +origin "$branch"` stays allowed and `git push $X origin main` is not seen. A +string handed to a shell carries its variables for that shell to expand +(`sh -c "git push --$X …"` is read as that shell reads it), and the string's +own quotes apply to the value, so `'--$X'` inside it is a flag too. A variable's value +is not read as what an earlier command carried into it: as a shell's or +`source`'s script it is not a stream (`bash "$script"`), and as the program's +name it is not `pkill` or `killall`, whose entries name only the program and an +operand, nor a bare interpreter inside a string, so `"$GO" build ./...` and +`$EDITOR notes.md` stay allowed. A variable standing as the program with +another variable as its first operand (`exec "$BIN" "$@"`) can be `git clean` +and warns, as a substitution there does. Inside double quotes a `${…}` ends at +its own `}`, and a `"` in it opens a nested string rather than closing the +outer one, so `echo "${MSG:-"don't"}"` is one word and runs. A here-document body is data, but where its delimiter is unquoted (`<)`) is not. The search is followed through a group (`{ pgrep xargs kill; }`), through a shell string that runs it (`kill $(sh -c 'pgrep …')`), and into a shell string that `xargs` runs or that reads the pipe or a redirect (`pgrep … | xargs sh -c 'kill "$@"' _`, `pgrep … | sh -c 'xargs -kill'`, `sh -c 'xargs kill' < <(pgrep …)`); behind `xargs` and a launcher the -guard does not know, the fail-safe warns. Every command of a string `xargs` +kill'`, `sh -c 'xargs kill' < <(pgrep …)`), or whose positional parameters or +own text hold the search's output (`sh -c 'kill "$1"' _ "$(pgrep …)"`); out +of an unquoted here-document whose body holds the search (`xargs kill <` stops a group and stays allowed. A pid list carried through a variable or a file, or taken from a `ps | grep` chain, is not seen. +Every command of a string a shell is handed with such output in its words is +read as handed it, so `sh -c 'kill 4242' _ "$(pgrep …)"` is a **block** too. What an allow still does not see is a hazard that never reaches command position at all: one launched through a known wrapper carrying a value-taking flag the @@ -303,10 +323,11 @@ guard does not name (`sudo -u bob ` is seen; the bundled short form whose API path an entry names by its ROOT segment but the host serves under a prefix (a GitHub Enterprise Server install mounts the same endpoints under `/api/v3/`; the `https://api.github.com/…` URL -form **is** read), a parameter expansion that carries no substitution (`$VAR`, -`${VAR:-git}`) wherever it stands — as the program's name, as a flag -(`--$VAR`), or inside a payload the guard reads — because the guard sees the -variable, not what the shell expands it to, an IFS the shell already holds when +form **is** read), what a variable carries in from an earlier command — a pid +list, a stream path, shell text run through `eval "$X"` or a string a shell +runs, or `pkill` or `killall` as the program a variable names (`$P make`) — +because reading each would refuse the ordinary commands a variable carries a +value for, an IFS the shell already holds when the line starts or gains during the line through a name the guard does not read (`declare $(echo I)FS=x`, a sourced file; every line is read from the default IFS), a hazard inside a non-shell interpreter's payload (`python -c`, `perl -e`) — one opaque token the tokenizer cannot read, today a silent allow, not diff --git a/commands/identity.md b/commands/identity.md index 8d18cd602..d686b10e4 100644 --- a/commands/identity.md +++ b/commands/identity.md @@ -27,9 +27,10 @@ records the block. ``` prints a unified diff per drifted surface. It **writes nothing**, and no flag -makes it: adopting a proposal is the maintainer's move. Show the diff, then ask -whether to apply it. If the maintainer would rather change what the project says -than what its surfaces say, the fix is an edit to the identity block, after which +makes it: adopting a proposal is the product thinker's move, because what the +project says is theirs to decide. Show the diff, set `abcd mode product-thinker`, +then ask the product thinker whether to apply it. If they would rather change +what the project says than what its surfaces say, the fix is an edit to the identity block, after which this same command chases the surfaces. ## `init` — record the block @@ -49,7 +50,8 @@ repointing the canon is a deliberate edit. ### The interview -Ask once, in the maintainer's own words: +Set `abcd mode product-thinker`, then ask the product thinker once, in their +own words: 1. **Title** (required) — what the project is called, as it should read in a heading. diff --git a/commands/implement.md b/commands/implement.md index b3b7900d3..06aaf58b7 100644 --- a/commands/implement.md +++ b/commands/implement.md @@ -47,13 +47,18 @@ one: the first session learns of a second only by reading the run state, and never waits on it. Joining again with the same role is a resume; asking for the other role is refused. -A second session states its own agent ceiling with `--ceiling`: the most agents -it runs at once, kept on top of the first session's, never instead of it. abcd -runs and counts no agent, so the ceiling is the session's own discipline: it is -recorded, carried on the `session_open` line, and reported by every `check` -(`ceiling` in the verdict), and a resume cannot restate it. Before starting an -agent, the second session counts its own running agents against it and, at the -ceiling, waits and logs a `ceiling_wait`. +A session states its own agent ceiling with `--ceiling`: the most agents it +runs at once (for a second session, kept on top of the first session's, never +instead of it). It is recorded and carried on the `session_open` line, and a +resume cannot restate it. abcd runs no agent: it counts the agents the +session's own `agent_start` and `agent_end` lines declare alive, since it +joined, matched by their `agent` field. An `agent_start` past the ceiling is +refused at exit 2 and the refusal logged (condition `agent_ceiling`); every +`check` reports `agents_alive` beside `ceiling`. At the ceiling, wait, log a +`ceiling_wait`, and log the `agent_end` of an agent that finished before +starting the next. An agent the session never logs — a fork, one started +outside the log — is invisible to the count, so never start one; if the run +went over the ceiling anyway, log a `ceiling_overrun`. The first session opens each window by naming its mode — `single`, `claim`, `batch` or `split-roles`: @@ -101,8 +106,14 @@ The second session is refused at exit 2, and the refusal is logged, when it: declared `--path` is refused, since nothing can say the lane is clear; - reaches the release step — only the first session cuts a release. -It also keeps its own agent ceiling (stated on joining, reported by `check`), -which no verb here enforces. +It also keeps its own agent ceiling (stated on joining, held against its logged +`agent_start` lines, reported by `check`). + +The role these bounds key on is the session's own statement, not an +identity: the release refusal, like every bound here, rests on a cooperative, +unauthenticated role. Two sessions of one account can each write anything +under that account's home, so the bounds keep two cooperating sessions apart; +they are not a wall against a session that lies about its role. Before a step that is not a claim, ask: @@ -122,12 +133,33 @@ first session; a stop condition the second session meets stops only itself. One line per event, appended in a single write, so two sessions writing at once each land whole lines. The events are `backoff`, `lane_open`, `lane_close`, `agent_start`, `agent_end`, `ceiling_wait`, `gate_run`, `review`, `fallback`, -`stop`, `refusal`, `pr`, `capture` and `context`. For the comparison to count -them: a `lane_close` with `outcome=merged` (or `landed`) is a lane landed; -`backoff` and `ceiling_wait` carry `minutes`, and `agent_end` carries `minutes`, -`wall_minutes` or `wall_min`; a `context` line carries `used_pct` (with `role` -and `note`), the orchestrator's share of its context window in use. The session, window and claim -events belong to their own sub-verbs and are refused here. +`stop`, `refusal`, `pr`, `capture`, `context`, `ceiling_overrun`, +`intervention` and `decision`. The session, window and claim events belong to +their own sub-verbs and are refused here. + +An event missing a field the report reads is refused at exit 2, naming the +field, with nothing written: + +| Event | Required fields | Checked when given | +|---|---|---| +| `lane_close` | `lane`, `outcome` | | +| `agent_start` | `agent` | | +| `agent_end` | `agent`, `role`, `model`, and `minutes` (or `wall_minutes`, `wall_min`), a number | | +| `ceiling_overrun` | `alive`, `ceiling`, `minutes` (numbers), `lane` | | +| `intervention` | `kind`, `by`, `what`, `why`, `autonomy_gap` | `at` (RFC 3339), `detected_after_min` (a number) | +| `stop` | `cause` | `last_productive` (RFC 3339), `noticed_after_min` (a number); `recovery` | +| `decision` | `what`, `alternative`, `why` | `at` (RFC 3339) | + +An intervention's `kind` is one of `session_open`, `account`, `ruling`, +`restart`, `close_session`, `file_restore`, `permission` or `other`, and its +`autonomy_gap` says what abcd or the host would need so no person is needed. A +`decision` records a judgement call a person would normally make, with the +alternative not taken. + +For the comparison to count them: a `lane_close` with `outcome=merged` (or +`landed`) is a lane landed; `backoff` and `ceiling_wait` carry `minutes`; a +`context` line carries `used_pct` (with `role` and `note`), the orchestrator's +share of its context window in use. ## Compare the modes @@ -137,8 +169,17 @@ events belong to their own sub-verbs and are refused here. Read-only. Per mode: windows, wall clock, lanes opened and landed, the second session's lanes landed, collisions, lapsed claims, backoffs and the minutes -backed off, agent minutes, ceiling wait and refusals, with each session's share; -and, per session across the run, its context lines and the last `used_pct` seen. +backed off, agent minutes, ceiling wait, ceiling overruns and refusals, with +each session's share; and, per session across the run, its context lines and +the last `used_pct` seen. A join logged up to a minute before a window opens +counts in that window. Over the whole run, `evidence` counts the +interventions (by kind, with the minutes they went undetected), stops and +decisions; `missing_fields` names, per event, the lines lacking a field `log` +requires — lines written by hand or before the requirement, which the figures +read as absent; and `coverage` names each of `lane_open`, `lane_close`, +`agent_start`, `agent_end` and `gate_run` whose lines stop more than six hours +before the run's last line. Relay the last two whole: a figure they name is +short. `leader` is the mode with the most lanes landed per wall-clock hour — a figure, not a verdict: the run's own report names the mode it would keep and says why. Relay any `unparsed` lines; they are counted nowhere. diff --git a/commands/ingest.md b/commands/ingest.md index 160e2c9c7..e36479549 100644 --- a/commands/ingest.md +++ b/commands/ingest.md @@ -31,8 +31,10 @@ docs), `motion_picture` (video). - **Class.** Web content is `public` by default. Signals for `--confidential`: the user says so; it is their own unpublished/submitted work; internal or NDA material; AI-generated content (never citable); a - private repo's documentation. When confidential, ask the user for every - identifying name variant (aliases — repo names, codenames, domains) and + private repo's documentation. When confidential, set `abcd mode + product-thinker` or `abcd mode facilitator` for whichever of the product + thinker or the technical facilitator is adding the source, then ask them for + every identifying name variant (aliases — repo names, codenames, domains) and pick `--permission`: `no-public-citation`, `internal-never-cite`, `ai-generated-never-cite`, or `ask-author`. The class is declared once, here; `add` refuses to guess it. diff --git a/commands/intent.md b/commands/intent.md index e73dd69fd..45300036f 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -109,8 +109,9 @@ is written onto the draft as `duplicates: []` (the same proposal filed again) or `refines: []` (the other record is broader), at most three links. The create is never refused by the match. The JSON's `match` object carries the matches, the `near_misses` below the threshold with their scores, and `skipped` -when nothing was compared. Relay each match and ask the user to confirm it; a -wrong link is removed by deleting its line, which leaves an ordinary draft. +when nothing was compared. Set `abcd mode product-thinker`, relay each match +and ask the product thinker to confirm it; a wrong link is removed by deleting +its line, which leaves an ordinary draft. A single whitespace-free word is refused (exit 2, nothing written): a lone token reads as a mistyped sub-verb, never as a draft title. A near-miss of a @@ -241,14 +242,15 @@ reconstructed. The refusal covers both, deliberately, because nothing in the enforcement can tell relocated text from invented text — which is why the state those three records are in is not reachable through this verb. -**Ask for the expectation and its falsifier.** "Planned it because it is next" +**Ask the product thinker for the expectation and its falsifier** (set `abcd +mode product-thinker` first). "Planned it because it is next" restates the decision and records nothing; "planned it because we expect a stamped identity to survive rewording, which nothing else does" is a conjecture somebody can later find wrong. abcd refuses only the degenerate texts — empty, too short, or the vocabulary word repeated back — and text the site cannot render, such as an unclosed backtick, an image or raw HTML, because the entry is append-only and the record must still build; it cannot tell a conjecture from a -restatement. That part is yours: put the question to the human and write +restatement. That part is yours: put the question to the product thinker and write down their answer, not a paraphrase of the route taken. A hand-typed bullet is held to the same floor: `- pursued: yes` is not an entry, and the gate reports the record as carrying none. @@ -311,9 +313,10 @@ scaffold prompt is reported as unanswered, never as a recorded claim. ## Planning interview (host-run, with the human present) -The interview turns a draft into an intent the maintainer has signed off. Run -it only in a live session with the human; deferral of any question is a valid -answer, but silence is not consent. +The interview turns a draft into an intent the product thinker has signed off. +The sign-off is the product thinker's; set the mode before the first question +and reset it whenever the hat changes, as the rule below says. Run it only in a live session with the product thinker; deferral of +any question is a valid answer, but silence is not consent. **How every question is asked (the GRILL rule domain).** One question at a time, through the harness's interactive question tool, never as a numbered @@ -324,7 +327,9 @@ always offered. A recommendation the human asks for is given in prose apart from the question. The next question waits for the last answer. The register follows the addressee: a product thinker gets outcomes in product terms with no record ids or internals; a technical facilitator gets the mechanism and the -ids. Where the hat is unknown, that is the first question. +ids. Where the hat is unknown, that is the first question. The mode carries +the addressee: before each question set `abcd mode product-thinker` or `abcd +mode facilitator`, and the question names that role. **Prerequisite — two adversarial reviews.** Before the interview, the draft has been through two independent adversarial reviewers with different lenses @@ -341,11 +346,12 @@ gate that will refuse the move mechanically is a recorded seed until built. parts → homes, typed links, advisory reversal flags. A part that is not this intent moves to its home (or is captured) before planning proceeds; grade the run into the calibration note either way. -3. **Press release:** confirm or refine the user moment with the human. -4. **Open questions:** resolve each with the human, or record an explicit +3. **Press release:** confirm or refine the user moment with the product + thinker. +4. **Open questions:** resolve each with the product thinker, or record an explicit deferral in the draft. An open question that gates scope blocks planning. -5. **Mechanism claim (prompted, nullable):** ask why the authors expect this - to work, and record the answer in `## Mechanism` as a falsifiable "we +5. **Mechanism claim (prompted, nullable):** ask the product thinker why the + authors expect this to work, and record the answer in `## Mechanism` as a falsifiable "we expect X because Y" — not the outcome restated. Declining is a real answer: record it as the exact token `None stated.` alone on its line. Silence is not a decline, and the draft's scaffold line is not a claim. @@ -372,7 +378,7 @@ gate that will refuse the move mechanically is a recorded seed until built. "${CLAUDE_PLUGIN_ROOT}/abcd" intent plan [--impact ] [--production-mode ] --json ``` - This invocation IS the maintainer's sign-off act — never run it unattended + This invocation IS the product thinker's sign-off act — never run it unattended or infer consent. It mints the spec stub, links both sides, stamps an identity onto every unmarked scope condition, and moves the intent `drafts/ → planned/`. Every relative markdown link that named the draft's @@ -383,8 +389,8 @@ gate that will refuse the move mechanically is a recorded seed until built. **`--impact` is the judgement the interview settled**, stamped here because this is the moment it is made: a draft filed without one gets it now, in the same shape the create path writes (`impact: `), validated at the same - bar — one of `additive`, `breaking`, `fix`, never `internal`. Ask the human - for the class if the draft does not carry it, and pass their answer; never + bar — one of `additive`, `breaking`, `fix`, never `internal`. Ask the product + thinker for the class if the draft does not carry it, and pass their answer; never type it into the frontmatter. The rules are the close's: a value that disagrees with one the record already carries is refused before anything moves (a plan does not revise a recorded judgement — the human edits the @@ -396,7 +402,7 @@ gate that will refuse the move mechanically is a recorded seed until built. **Several drafts as one bundle.** When the interview settles that two or more drafts are distinct user moments that only make sense delivered together, they are planned as one bundle: ONE shared spec, every member - moved together. Ask the human for the bundle's name — a short kebab-case + moved together. Ask the product thinker for the bundle's name — a short kebab-case name, which every member carries as `bundle: ` and which becomes the shared spec's slug — and pass their answer; never invent one. The CLI refuses several intents without `--bundle`, and `--bundle` with one: @@ -854,8 +860,9 @@ condition carries rather than to its wording, and joined to what occasioned it. - **With a condition id** it appends one dated block to `## Audit Notes`: the identity, the value, the occasion and the grounds, with the narrowing under a `narrowed` value. The occasion is a reading item at any position, or - an intent in `shipped/` whose delivery changed the condition's standing. Ask - the researcher for the value and the grounds; the reading names the tension + an intent in `shipped/` whose delivery changed the condition's standing. Set + `abcd mode product-thinker` and ask the product thinker, who reads as the + researcher here, for the value and the grounds; the reading names the tension and never marks the condition itself. A condition's standing is its latest reading-occasioned block where it has one, diff --git a/commands/launch.md b/commands/launch.md index 22bd0644d..841d94278 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -77,8 +77,9 @@ The rest of this page describes the verbs. This section describes the **day** what you click, what runs on its own, and where it stops and waits for you. Read it if you are cutting a release and are not the person who built the machinery. -Nothing here publishes by accident. The release stops and asks for a human twice: -once when you merge, and once at a deployment gate that no merge can bypass. +Nothing here publishes by accident. The release stops for a human twice: +for the technical facilitator at the merge, and for the product thinker at a +deployment gate that no merge can bypass, because publishing cannot be undone. ### The shape of it diff --git a/commands/prepare-this-repo.md b/commands/prepare-this-repo.md index b3f466cfa..91e82ad56 100644 --- a/commands/prepare-this-repo.md +++ b/commands/prepare-this-repo.md @@ -146,7 +146,8 @@ target's `.abcd/.work.local/scratch/` (create the directory via Re-interviewing over an answer the repo already gives is how a project ends up with two canons. - **Only if there is no block**, ask once, in the maintainer's own words: + **Only if there is no block**, set `abcd mode product-thinker`, then ask the + product thinker once, in their own words: - **Title** (required) — what the project is called, as it should read in a heading. @@ -166,7 +167,7 @@ target's `.abcd/.work.local/scratch/` (create the directory via surfaces render from it. From then on `abcd lint` reports any surface that drifts from it, and `abcd identity render` proposes the correction as a diff. abcd never rewrites a surface itself — adopting a proposal is always - the maintainer's move. + the product thinker's move. 5. **Commit gates.** Scaffold them from the binary — every hook it writes is embedded in it, so the step applies the same artefacts on a fresh clone as on @@ -221,7 +222,8 @@ substance: private repository names in anything committed; repo-relative paths only. - Examples and user stories use the personas Alice, Bob, and Carol — never other names. -- Refer to the maintainer as they/them in every artefact. +- Refer to the product thinker and the technical facilitator as they/them in + every artefact. - Never commit or push without being asked; substantive work goes on a branch and PR; new dependencies need explicit sign-off first. @@ -237,7 +239,7 @@ are committed. ## Definition of done - The gap report exists in the target's `.abcd/.work.local/scratch/` and was - presented to the maintainer. + presented to the technical facilitator. - The three-tier layout exists with a repo-specific `CONTEXT.md`; any legacy `.work/` layout was fully migrated (with sign-off) or fully left alone. - `AGENTS.md` carries verified repo facts and the marked, nameless @@ -245,7 +247,7 @@ are committed. - One identity block is recorded and registered — adopted where the repo already had one, interviewed only where it did not — and `abcd lint identity` reports every registered surface as `ok`, or the drift it reports was shown - to the maintainer with the proposed diff. + to the product thinker with the proposed diff. - Nothing from `private-names.txt` and no abcd-internal content appears in any committed or published artefact. - Every asset the adopt phase applied resolved from this record or from the diff --git a/commands/reading.md b/commands/reading.md index 29aef0c8c..a9947ad56 100644 --- a/commands/reading.md +++ b/commands/reading.md @@ -116,7 +116,7 @@ ancestor is not a run at this target and is not listed; a run across which anything else changed is listed and refused, naming the first path that moved. The manifest records both commits — `candidate_run_target` beside `target_commit` — so a reader can diff them. *This reading of "at the target" is -an interpretation, and the maintainer's ruling is owed* (iss-2609021857343626). +an interpretation, and the product thinker's ruling is owed* (iss-2609021857343626). That run's items travel projected to two body fields — the configuration and what admits it — keyed by the item identifier the comparative body cites, and nothing else from the readings store travels with diff --git a/commands/site.md b/commands/site.md index 2afe60db6..f391274c1 100644 --- a/commands/site.md +++ b/commands/site.md @@ -80,8 +80,8 @@ page count rendered from the record, the record's size (records, links, mentions), the unresolved references against the committed baseline, the chart packing's overlap count (which is zero or the picture is wrong), and the version and commit stamped into the footer. An -unresolved-reference count above the baseline is worth naming to the maintainer -even though this verb does not gate on it. +unresolved-reference count above the baseline is worth naming to the technical +facilitator even though this verb does not gate on it. A failure names its cause and its place: a markdown construct outside the rendered subset is reported as `file:line`, and so is an image the page names @@ -123,7 +123,8 @@ sets up the site of a repository abcd manages, in three stages, and emits Both remote stages ask before they write, naming each change. An unanswered run declines them and exits `1`; `--yes` confirms in advance — pass it only -when the user has asked for the forge and host changes. A second run over an +when the technical facilitator has asked for the forge and host changes, and set +`abcd mode facilitator` before asking them. A second run over an unchanged repository reports `no_change` and writes nothing. The first run names the host after the repository; `--name` and `--domain` diff --git a/commands/source.md b/commands/source.md index 0b0ebf559..2196e6262 100644 --- a/commands/source.md +++ b/commands/source.md @@ -41,7 +41,9 @@ there is no corpus: say so, and stop. Only the user decides to create one, with [--permission ] [--ban-authors] [--text ] --json ``` -- The class is required and never defaulted. Ask the user when in doubt. +- The class is required and never defaulted. When in doubt, set `abcd mode + product-thinker` or `abcd mode facilitator` for whichever of the product + thinker or the technical facilitator is adding the source, then ask them. - For a confidential source, put the title, aliases and authors in a `--meta` JSON file (`{"title": …, "aliases": […], "author": [{"family": …, "given": …}], "keywords": […]}`) so they stay out of argv, and choose an **opaque** key diff --git a/commands/update.md b/commands/update.md index d11beb775..a3b373a1e 100644 --- a/commands/update.md +++ b/commands/update.md @@ -64,8 +64,10 @@ error.** Every refusal is a named shape with a remedy in `refusal`: install` switches modes first. This names the install shape, not the version string: a binary that `abcd version` reports as `dev` is any locally built one, and a link to such a binary is `foreign`, not `dev-shim`. -- `owned-dangling` — a plugin update stranded the entry; `abcd ahoy install` - repoints it. +- `owned-dangling` — abcd's own entry points at a binary that is gone (a + plugin update strands it); `abcd ahoy install` replaces it with a verified + copy of the current release, or names the command to run first when none is + available. - `owned-superseded` — the entry is abcd's own pin into a plugin vintage the harness has moved past, so `abcd` answers an older release than the plugin holds; `abcd ahoy install` replaces it with the current release. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 983a9048d..73f9f79da 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -81,13 +81,23 @@ outside the one the session is working in, that is not world-writable, **and** `~/.abcd/path-entry` records that exact path as the `abcd` installed on this machine. The [install](#cli) one-liner writes that record, and so does abcd's own install verb — whichever entry it leaves on `PATH`: the copy of the -verified release binary it prefers, the symlink it degrades to when there is no -verified copy to make, and the track-latest shim `--dev` writes. The copy is -made only from a cache that `~/.abcd/cache-attestation` vouches for — the -directory it names, holding the hash it names — so a data directory pointed at -by an environment variable alone is never promoted onto `PATH`; the install -says which record is missing or disagrees and degrades to the symlink until a -session with network access re-authenticates the cache. The record is read +verified release binary, the track-latest shim `--dev` writes, and a symlink +into the plugin root that an earlier release wrote and that still works. The +copy is made only from a cache that `~/.abcd/cache-attestation` vouches for — +the directory it names, holding the hash it names — so a data directory pointed +at by an environment variable alone is never promoted onto `PATH`. Neither +record counts when `~/.abcd` is a symlink — a dotfiles checkout, say — and +nothing abcd writes goes through one: the hooks, the install verb and the +one-liner each refuse it and say so, as the rules loader refuses a +`rules.json` there, so replace the link with a real directory before +installing. With no verified copy to make, the install writes no entry rather than a symlink into +the plugin root, which the next plugin update would break: it says which record +is missing or disagrees, and names the command to run first, the +[install](#cli) one-liner, after which re-running the install adopts the copy +the one-liner wrote. A symlink into the plugin root that an earlier release +wrote is named by `abcd ahoy` as a gap while it still works, and an entry the +record names that has since stopped resolving is reported as abcd's own and +replaced by the install. The record is read only from a home directory the session can trust: one that is absolute and not inside the repository being installed, since a home the environment can point anywhere could name the attestation too. A refused home is named as the @@ -139,10 +149,12 @@ others — a file inside the checkout can never vouch for the checkout. Nothing infers the exception for you. That covers the hooks. For the `abcd` command in your own terminal, keep the -[install](#cli) below, or put the plugin-root binary on your `PATH` by -running it once by its absolute path — `'/abcd' ahoy install`. -The path is absolute because `abcd` is not on your `PATH` yet, which is what -that one run fixes. `` is the directory the agent harness unpacked +[install](#cli) below, or put abcd on your `PATH` by running the plugin-root +binary once by its absolute path — `'/abcd' ahoy install`. That +run copies the release binary the session's hooks verified and cached; where no +verified copy is cached it writes nothing and names the [install](#cli) +one-liner instead. The path is absolute because `abcd` is not on your `PATH` +yet, which is what that one run fixes. `` is the directory the agent harness unpacked the abcd plugin into, with the binary sitting directly inside it as `abcd`; the bootstrap's success notice prints that full binary path, so the shortest route is to copy the command straight out of the notice. That notice appears once per @@ -216,13 +228,13 @@ single-user location. ### macOS ```sh -sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; cd "$(mktemp -d)"; arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; esac; b="abcd-darwin-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | shasum -a 256 -c -; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' +sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; [ ! -L "$HOME/.abcd" ] || { echo "abcd install: ~/.abcd is a symlink, which abcd refuses rather than follows; replace it with a real directory and re-run" >&2; exit 1; }; cd "$(mktemp -d)"; arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; esac; b="abcd-darwin-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | shasum -a 256 -c -; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' ``` ### Linux ```sh -sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; cd "$(mktemp -d)"; arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-linux-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | sha256sum -c -; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' +sh -c 'set -eu; unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR; [ ! -L "$HOME/.abcd" ] || { echo "abcd install: ~/.abcd is a symlink, which abcd refuses rather than follows; replace it with a real directory and re-run" >&2; exit 1; }; cd "$(mktemp -d)"; arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64) arch=arm64;; esac; b="abcd-linux-$arch"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/$b"; curl -q --proto =https --proto-redir =https -fsSLO "https://github.com/intentdriven/abcd/releases/latest/download/checksums.txt"; l=$(grep " $b$" checksums.txt); printf "%s\n" "$l" | sha256sum -c -; mkdir -p "$HOME/.local/bin"; install -m 0755 "$b" "$HOME/.local/bin/abcd"; mkdir -p "$HOME/.abcd"; printf "path=%s\nbinary_sha256=%s\n" "$HOME/.local/bin/abcd" "${l%% *}" > "$HOME/.abcd/path-entry"; "$HOME/.local/bin/abcd" --version' ``` ### Windows diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 76d7da91a..d504fc3d3 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -205,7 +205,7 @@ abcd banlist remove --private acme-internal Start the loop that takes one READY intent to delivered: Writes the run's state file in the local tier; refuses an open question, a hold or a peer holding it. -**Usage:** `abcd build ` +**Usage:** `abcd build [--session ] [flags]` Start the implement loop for one intent, or resume the run already in progress for it. A new run's checks run first, and every one must pass: @@ -223,11 +223,24 @@ never created: only a repository abcd manages has one. Starting again while the in progress creates nothing and names the run without judging the checks again (the run's own lanes change what they read), so a killed process resumes where it stopped. +--session names the host session's id in the shared run state (`abcd implement join`): +a new run then claims the intent there for that session, the run id as its lane, so a +build of the same intent from any other checkout of the repository is refused as held +before this run's lane has moved or claimed anything, and the session's own claim on the +intent is not counted as a peer's. A session that has not joined is refused. Without it +the run holds no claim, and the result says so. + The run then moves one step per `abcd implement step`, driven by the host session. Exit 2 on a refusal, exit 3 when a peer holds the intent or the run state is locked (back off and take other work). +**Flags:** + +``` + --session string the host session's id in the shared run state; a new run claims the intent for it +``` + **Example:** ``` @@ -1249,7 +1262,8 @@ Say whether this session may take a step, before it takes it. The first session take every step. The second is refused the release step always, a lane in a split-roles window, and a lane whose --path reaches the reading corpus; review, audit and land are open to it. A refusal exits 2 and is logged; an allowed step -writes nothing. The verdict reports the agent ceiling the session joined with. +writes nothing. The verdict reports the agent ceiling the session joined with and the +agents its log lines declare alive (agents_alive). **Flags:** @@ -1309,14 +1323,16 @@ run state. Joining again with the same role is a resume and is logged as one; as for the other role is refused. The role is the session's own statement, recorded here and read by every bound — never taken from the environment. ---ceiling states the session's own agent ceiling: for the second session, the most -agents it runs at once, on top of the first session's. abcd counts no agents, so the -ceiling is recorded and reported by every `check`, not enforced; a resume keeps it. +--ceiling states the session's own agent ceiling: the most agents it runs at once (for +the second session, on top of the first session's). abcd runs no agent: it counts the +agents the session's own agent_start and agent_end lines declare alive, refuses an +agent_start past the ceiling, and reports the count with every `check`. An agent the +session never logs is invisible to it. A resume keeps the ceiling. **Flags:** ``` - --ceiling int this session's own agent ceiling (1 to 64; 0 states none), recorded and reported by check + --ceiling int this session's own agent ceiling (1 to 64; 0 states none), held against its logged agent_start lines --model string the model this session runs, recorded on the session_open line --reason string why the session opens (run start, window, resume), recorded on the line --role string first | second @@ -1411,10 +1427,15 @@ Append one event line to today's run log (`~/.abcd/runs//.js in a single append, so two sessions writing at once each land whole lines. The line carries ts, session and event, then each --field. A value that reads as a number or a boolean is written as one when it reads back as the same text, so `sha=0123456` -stays a string. The events: backoff, lane_open, lane_close, agent_start, agent_end, ceiling_wait, gate_run, review, fallback, stop, refusal, pr, capture, context. +stays a string. The events: backoff, lane_open, lane_close, agent_start, agent_end, ceiling_wait, gate_run, review, fallback, stop, refusal, pr, capture, context, ceiling_overrun, intervention, decision. The claim, window and session events are written by their own sub-verbs and are refused here, so the log cannot record a claim the run state does not hold. +An event missing a field the report reads is refused, naming it: lane_close (lane, outcome); agent_start (agent); agent_end (agent, role, model, minutes|wall_minutes|wall_min); stop (cause); ceiling_overrun (alive, ceiling, lane, minutes); intervention (kind, by, what, why, autonomy_gap); decision (what, alternative, why). +An intervention's kind is one of session_open, account, ruling, restart, close_session, file_restore, permission, other; an at or +last_productive is an RFC 3339 time, and a *_min or minutes field a number. An agent_start +that would take a session past the ceiling it joined with is refused, and the refusal logged. + **Flags:** ``` @@ -1518,11 +1539,15 @@ Derive, per division mode, the figures the run's report compares: windows, wall clock, lanes opened and landed (a lane_close whose outcome is merged or landed), the second session's lanes landed, collisions (claim_denied), lapsed claims, backoffs and the minutes backed off, agent minutes (agent_end's minutes, wall_minutes -or wall_min), ceiling wait and refusals, per session within each mode. Each event -belongs to the window open when it happened; each session's context lines are totalled +or wall_min), ceiling wait, ceiling overruns and refusals, per session within each mode. +Each event belongs to the window open when it happened, and a join logged at most a +minute before a window_mode to that window; each session's context lines are totalled across the run, with the last used_pct seen. `leader` is the mode with the most lanes landed per wall-clock hour — -a figure, not a verdict. Lines the reader cannot use are listed, never dropped -silently. +a figure, not a verdict. Over the whole run it counts the evidence (interventions by +kind, stops, decisions), names the lines lacking a field `log` requires of their event +(missing_fields), and names each of lane_open, lane_close, agent_start, agent_end and +gate_run whose lines stop more than six hours before the run's last line (coverage). +Lines the reader cannot use are listed, never dropped silently. By default the run's whole log is read, every day of it; --date reads one day, and --log reads one log file named directly. Reads only; creates nothing. diff --git a/hooks/bootstrap.sh b/hooks/bootstrap.sh index f84fd0977..756bcb0c6 100755 --- a/hooks/bootstrap.sh +++ b/hooks/bootstrap.sh @@ -47,7 +47,13 @@ binary_quoted="'$(printf '%s' "$binary" | sed "s/'/'\\\\''/g")'" # committed fakehome/.abcd/cache-attestation would become the record the # PATH promotion trusts, reopening through repository content the very class # the attestation exists to outrank; -# - a HOME INSIDE that directory is the same shape by another spelling. +# - a HOME INSIDE that directory is the same shape by another spelling; +# - a ~/.abcd that is a SYMLINK (a dotfiles checkout, typically) is the rule +# the rules loader applies to rules.json and every Go reader and writer of +# ~/.abcd applies through fsutil.HomeScopeLink: a record written through +# the link lands wherever it points and is one every reader refuses, so +# `ahoy install` would send the reader back here for a record this script +# would write the same way (iss-2609281017573862). # # HOME being the working directory itself is ordinary (a session started in the # home directory) and is not refused. A refusal empties home_dir, so every @@ -109,6 +115,9 @@ elif [ "${home_dir#/}" = "$home_dir" ]; then elif home_inside_cwd; then home_refusal='HOME lies inside the directory this hook is running in, so its ~/.abcd records would be repository content rather than a write into your own home' home_dir='' +elif [ -L "$home_dir/.abcd" ]; then + home_refusal='~/.abcd is a symlink, which abcd refuses rather than follows (replace the link with a real directory)' + home_dir='' fi # The persistent data dir is taken from the harness or not at all. Its diff --git a/hooks/hooks.json b/hooks/hooks.json index 550a8667f..f421c0326 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router; fi; printf '%s\\n' \"abcd: the plugin binary is missing and could not be provisioned, so the rules loader is inactive for this prompt — hooks/bootstrap.sh installs it when the session has network access, or install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; i=$(cat); case \"$i\" in *'\"tool_name\":\"AskUserQuestion\"'*|*'\"tool_name\": \"AskUserQuestion\"'*) k=\"questions through AskUserQuestion run UNGUARDED\" ;; *) k=\"shell commands run UNGUARDED\" ;; esac; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then printf '%s' \"$i\" | \"$g\" guard hook; s=$?; [ $s -eq 0 ] || [ $s -eq 1 ] || [ $s -eq 2 ] || { echo \"abcd guard: FAILED TO RUN (exit $s) — $k in this session; run 'abcd ahoy' to see guard health\" >&2; exit 1; }; exit $s; fi; printf '%s\\n' \"abcd guard: the plugin binary is missing, so $k until it is provisioned — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -37,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; if [ ! -x \"$r/abcd\" ] && [ -x \"$r/hooks/bootstrap.sh\" ] && [ -z \"$(find \"$r/.bootstrap.attempt\" -maxdepth 0 -mmin -10 2>/dev/null)\" ]; then : > \"$r/.bootstrap.attempt\" 2>/dev/null || true; \"$r/hooks/bootstrap.sh\" >/dev/null 2>&1 /dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook prompt-router-reset; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so rules will not re-inject after compaction — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -47,7 +47,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook session-end; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this session's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook session-end; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this session's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } @@ -57,7 +57,7 @@ "hooks": [ { "type": "command", - "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook subagent-stop; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this sub-agent's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" + "command": "r=\"${CLAUDE_PLUGIN_ROOT:-}\"; [ -n \"$r\" ] || exit 0; g=\"\"; if [ -f \"$r/abcd\" ] && [ -x \"$r/abcd\" ]; then g=\"$r/abcd\"; fi; if [ -z \"$g\" ]; then c=$(command -v abcd 2>/dev/null); if [ -n \"$c\" ]; then y=\"\"; case \"$c\" in /*) dd=${c%/*}; [ -n \"$dd\" ] || dd=/; d=$(cd -P \"$dd\" 2>/dev/null && pwd -P); if [ -z \"$d\" ]; then y=\"its directory could not be resolved\"; else q=$(pwd -P); case \"$d/\" in \"$q\"/*) y=\"it lives inside the working tree\" ;; esac; fi; if [ -z \"$y\" ]; then case \"$(/bin/ls -ld \"$d\" 2>/dev/null)\" in ????????w*) y=\"its directory is world-writable\" ;; esac; fi ;; *) y=\"it did not resolve to an absolute path\" ;; esac; if [ -z \"$y\" ]; then o=\"\"; w=\"\"; e=\"${HOME:-}/.abcd/path-entry\"; if [ -n \"${HOME:-}\" ] && [ -f \"$e\" ]; then if [ -L \"${HOME}/.abcd\" ]; then w=2; elif [ -n \"$(find \"$e\" -maxdepth 0 -type f -user \"$(id -un 2>/dev/null)\" ! -perm -0020 ! -perm -0002 2>/dev/null)\" ]; then while IFS= read -r ln || [ -n \"$ln\" ]; do case \"$ln\" in path=*) if [ \"${ln#path=}\" = \"$c\" ]; then o=1; fi ;; esac; done < \"$e\"; else w=1; fi; fi; if [ \"$w\" = 2 ]; then y=\"~/.abcd is a symlink, so its path-entry record is not read (replace the link with a real directory)\"; elif [ -n \"$w\" ]; then y=\"its ~/.abcd/path-entry record is not owned by you or is writable by others\"; else [ -n \"$o\" ] || y=\"~/.abcd/path-entry does not record it as the abcd installed here\"; fi; fi; if [ -n \"$y\" ]; then p=$(printf '%s' \"$c\" | tr -d '\\000-\\037\\177'); printf '%s\\n' \"abcd: ignoring the abcd found on PATH at $p because $y \u2014 a hook runs only the abcd recorded in ~/.abcd/path-entry by the documented install, from an ordinary user directory such as ~/.local/bin that neither the project nor another local user can replace; re-run the install per https://github.com/intentdriven/abcd#install to record it\" >&2; else g=\"$c\"; fi; fi; fi; if [ -n \"$g\" ]; then exec \"$g\" hook subagent-stop; fi; printf '%s\\n' \"abcd: the plugin binary is missing, so this sub-agent's transcript was not captured — install per https://github.com/intentdriven/abcd#install\" >&2; exit 1" } ] } diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index 11ecb95ba..57e1c49dd 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -3,6 +3,7 @@ package ahoy import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "os" "path/filepath" @@ -300,10 +301,15 @@ func nearestExistingDir(dir string) string { // adoptedBinTarget is the PATH entry abcd owns and acts on when no --bin-dir is // given: an existing owned entry adopted exactly where it stands, else a -// dangling one of ours repaired in place (installing elsewhere would leave it -// shadowing the new entry from earlier in PATH), else the default location. +// dangling one of ours repaired in place (the entry the user already has on +// PATH, rather than a second one elsewhere), else the default location. // Empty when the home directory cannot be resolved — there is no user-scope // location to write, and inventing a privileged one is what iss-171 removes. +// +// With no plugin root the one shape still found on PATH is the dangling link +// ~/.abcd/path-entry records: the record vouches for it without a root, and it +// is exactly what is left when abcd is gone, the case the owned dangling gap +// sends to `ahoy uninstall`. func adoptedBinTarget(pluginRoot string) string { if pluginRoot != "" { if e, ok := ownedPathEntry(pluginRoot); ok { @@ -312,6 +318,8 @@ func adoptedBinTarget(pluginRoot string) string { if e, ok := danglingPathEntry(pluginRoot); ok { return e.path } + } else if p, ok := recordedDanglingPathEntry(); ok { + return p } return binTarget() } @@ -329,7 +337,7 @@ type applyCtx struct { // writeKinds runs parallel to writes: what each write is, for the summary. writeKinds []writeKind changes []string // human-readable value changes an explicit override forced - notes []string // loud refusals: what abcd deliberately did not do, and why + notes []string // loud refusals (refuse) and informational lines (inform), in the order they arose autoYes bool // --yes: every category auto-approved without interaction devMode bool // --dev: install the track-latest shim instead of the symlink modeForced bool // the requested install mode differs from the on-disk state @@ -352,6 +360,12 @@ type applyCtx struct { // human — user-scope paths in tilde form. func (a *applyCtx) refuse(reason string) { a.notes = append(a.notes, reason) } +// inform records something abcd DID that the operator should hear about and +// that no write receipt says — a removal, typically. It travels in the same +// notes as a refusal, because the person reads both in one place, but it is a +// 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) } + // 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 @@ -777,6 +791,14 @@ func (a *applyCtx) stepHistory() { // the store and the reason, never a silent omission. root, err := historyRoot() if err != nil { + // A symlinked ~/.abcd (or ~/.abcd/history) is refused like every other + // file abcd keeps there: the registry would land wherever the link + // points. The error is the whole sentence, the link and the remedy + // included (iss-2609281129171021). + if errors.Is(err, fsutil.ErrHomeScopeSymlinked) { + a.refuse("skipped this machine's history registration, so nothing was written: " + err.Error()) + return + } a.refuse("could not set up this machine's session store: " + errText(err)) return } @@ -1025,8 +1047,9 @@ func markerFilesDropped(from, to string) []string { } // stepSymlink installs the PATH entry: an abcd-owned regular-file copy of the -// verified cache artefact (default, spc-35), the spc-21 pinned symlink when no -// cache exists to copy from, or the track-latest dev shim under --dev. It runs +// verified cache artefact (default, spc-35), or the track-latest dev shim under +// --dev. With no verified artefact to copy it writes nothing and names the +// command to run first (iss-2609100506263330). It runs // on a fresh install (the symlink.missing / symlink.dangling gaps), to heal a // legacy symlink into the plugin root (symlink.legacy — that link dies at the // next plugin update), or when a mode switch was forced on an already-present @@ -1042,9 +1065,18 @@ func (a *applyCtx) stepSymlink() { return } target := a.binTarget - // A dangling entry of ours is cleared first: it resolves to nothing, so - // removing it destroys nothing, while leaving it in place would keep a link - // that shadows every later PATH entry — including the one being installed. + a.placeEntry(target) + if gapDriven { + a.clearStrandedEntries(target) + } +} + +// placeEntry writes, repairs or adopts the PATH entry at target — the one entry +// the run acts on — or refuses, loudly, when target is not abcd's to write. +func (a *applyCtx) placeEntry(target string) { + // A dangling entry at the target is cleared first: it resolves to nothing, + // so removing it destroys nothing, and the entry this run writes takes its + // place. a.clearDanglingEntry(target) kind := classifyBinTarget(target, a.det.pluginRoot) if kind == binTargetForeign { @@ -1054,8 +1086,8 @@ func (a *applyCtx) stepSymlink() { // explicit --bin-dir the detection gap does not even describe this location. a.refuse("refused to write the PATH entry " + displayPath(target) + // dangling is carried, not defaulted: clearDanglingEntry leaves a - // dangling link in place when there is no plugin binary to repoint - // it at, and that is the one way a dangling entry still reaches this + // dangling link in place when this run has nothing to put there, + // and that is the one way a dangling entry still reaches this // refusal — describing it as an ordinary foreign link would name the // wrong repair. ": it is occupied by " + describeEntry(pathEntry{path: target, kind: kind, dangling: linkIsDangling(target)}) + @@ -1069,6 +1101,54 @@ func (a *applyCtx) stepSymlink() { a.installOwnedEntry(target, kind) } +// clearStrandedEntries removes every abcd-owned PATH entry OTHER than target +// whose own target has gone (iss-2609280932480608). It is iss-2609100506256636's +// rule — danglingness, not provenance, is what makes a link safe to clear — +// applied past the one entry the run acts on: install adopts or writes target, +// and an owned dangling link elsewhere on PATH (typically one a plugin update +// stranded ahead of the one-liner's copy) would otherwise raise +// symlink.dangling on every run, which no run could close. +// +// It acts only once target is a working entry of abcd's own. A link that +// resolves to nothing runs nothing — the shell skips it — so removing it takes +// nothing away; but on a run that left nothing working at target, the dangling +// entry stays as it is and the refusal already given names what to run first, +// the same stance clearDanglingEntry takes at the target itself. Unowned +// dangling links are never touched here: that claim needs the record, or the +// sibling rule, that makes the entry abcd's. +func (a *applyCtx) clearStrandedEntries(target string) { + if !entryAnswers(target, a.det.pluginRoot) { + return + } + for _, e := range scanPathEntries(a.det.pluginRoot) { + if !e.owned() || !e.dangling || sameEntry(e.path, target) { + continue + } + // Re-read right before the removal: only a symlink that still + // resolves to nothing is removed. + if fi, err := os.Lstat(e.path); err != nil || fi.Mode()&os.ModeSymlink == 0 || !linkIsDangling(e.path) { + continue + } + if err := os.Remove(e.path); err != nil { + a.refuse("could not remove abcd's own dangling PATH entry " + displayPath(e.path) + ": " + errText(err)) + continue + } + removePathEntryFor(e.path) + a.inform("removed abcd's own PATH entry " + displayPath(e.path) + ": it pointed at a binary that is gone, so it ran nothing, and " + + displayPath(target) + " is abcd's working entry.") + } +} + +// entryAnswers reports whether target is a working entry of abcd's own: one +// the ownership predicate claims and whose target resolves. +func entryAnswers(target, pluginRoot string) bool { + switch classifyBinTarget(target, pluginRoot) { + case binTargetOwnedSymlink, binTargetDevShim, binTargetOwnedCopy: + return !linkIsDangling(target) + } + return false +} + // installOwnedEntry writes the default PATH entry. With a verified cache // artefact available it installs the abcd-owned regular-file COPY (spc-35): // the artefact is read once, hashed, checked against the cache meta's recorded @@ -1076,9 +1156,12 @@ func (a *applyCtx) stepSymlink() { // refuses loudly and installs nothing — and the very bytes that were verified // are written 0755 with the provenance recorded in the data dir's path-entry. // A legacy owned symlink or a dev shim at the target is replaced (the heal); an -// owned copy already matching is left alone. Without a usable cache it -// degrades, loudly, to the spc-21 pinned symlink — there is nothing on disk -// whose provenance a copy could record. The cache is reached through the +// owned copy already matching is left alone, and so is any owned copy when +// there is no usable cache to refresh it from. Otherwise, without a usable +// cache it writes nothing and refuses, naming the command that provides a verified copy +// (coldCacheRefusal): the only other entry there is to write is a symlink into +// the plugin root, which the next plugin update strands +// (iss-2609100506263330). The cache is reached through the // hook's CLAUDE_PLUGIN_DATA or, from the terminal the bootstrap's notice sends // the reader to, through the plugin root's .data-dir stamp // (iss-2609012111168716). Both are ROUTES, not trust: the cache is promoted @@ -1119,7 +1202,10 @@ func (a *applyCtx) installOwnedEntry(target string, kind binTargetKind) { // to write it into that home for the same reason, so the reader // would be sent round a loop that cannot close. remedy := "Start a session with network access so the hooks re-authenticate the cache and attest it, then re-run `abcd ahoy install`." - if _, refusedHome := homeScope(); refusedHome != "" { + switch _, herr := homeScopeErr(); { + case errors.Is(herr, fsutil.ErrHomeScopeSymlinked): + remedy = "Replace the symlinked ~/.abcd with a real directory first: the hooks decline to write the attestation through the link for the same reason, so no session will produce it until then." + case herr != nil: remedy = "Re-run from a session whose HOME names your own home directory: the hooks refuse to write the attestation into this one for the same reason, so no further session will produce it." } a.refuse("ignored the cache in the plugin data directory (" + look.story + "): " + unbound + @@ -1127,19 +1213,27 @@ func (a *applyCtx) installOwnedEntry(target string, kind binTargetKind) { } } if !present || unbound != "" { - if kind != binTargetOwnedSymlink { - // Notes is the loud channel (see refuse): the degradation must be - // SAID, because a symlink into the plugin root dies at the next - // plugin update and a silent fallback would hide why — and it names - // every source tried, so the reader knows which one to restore. - why := look.explainMissingCache() - if unbound != "" { - why = look.story + ", whose cache no attestation binds (above)" - } - a.refuse("no verified release artefact is available in the persistent plugin data directory (" + why + - "), so the PATH entry was written as a symlink to the plugin-root binary — it will stop working when a plugin update replaces that directory. Start a session so the hooks provision the cache and record its location in the plugin root, then re-run `abcd ahoy install` to upgrade it to an owned copy.") + if kind == binTargetOwnedCopy { + // The verified copy is already in place — the one the install + // one-liner writes is exactly this shape — and a cold cache has + // nothing to refresh it from, so it is adopted as it stands. + // Refusing here would answer the operator who has just run the + // remedy below with that same remedy, a loop it cannot close. + return } - a.installPinnedSymlink(target, kind) + // No verified artefact, so no entry is written (iss-2609100506263330). + // A symlink into the plugin root is the only other thing there is to + // write, and it is known to dangle at the next plugin update — a + // warned-about install that breaks later is harder to diagnose than a + // refusal now. Fetching the artefact here is not this verb's to do: its + // documented meaning is local configuration, and the network answers + // only a verb whose meaning is the fetch (adr-38). So the refusal names + // the command that is: the install one-liner. + why := look.explainMissingCache() + if unbound != "" { + why = look.story + ", whose cache no attestation binds (above)" + } + a.refuse(coldCacheRefusal(target, kind, a.det.pluginRoot, why)) return } dataDir := look.dir @@ -1243,7 +1337,8 @@ func (a *applyCtx) installDevShim(target string, kind binTargetKind) { // stepPathEntry records the installed PATH entry in ~/.abcd/path-entry, for the // two shapes whose ownership does not already rest on that record: the spc-21 -// pinned symlink and the --dev shim. The owned copy stamps itself inside +// pinned symlink an earlier release wrote, which install records when it finds +// one working, and the --dev shim. The owned copy stamps itself inside // installOwnedEntry — its very classification reads the record back, so it // cannot be recognised here before it has been recorded — and this step then // leaves it alone. @@ -1311,11 +1406,21 @@ func (a *applyCtx) stepPathEntry() { a.recordEntry(target, digest) } -// clearDanglingEntry removes an abcd-owned symlink at target whose destination -// no longer exists. It is deliberately narrow: only a SYMLINK, only one that -// resolves to nothing, and only when the binary it would be repointed at exists. -// Nothing is destroyed (the link already answered nothing) and the alternative is -// worse — a dangling `abcd` earlier on PATH shadows the working install. +// clearDanglingEntry removes a symlink at target whose destination no longer +// exists, so the entry this run writes can take its place. It is deliberately +// narrow: only a SYMLINK, only one that resolves to nothing, and only when this +// run has something to put there — the verified release artefact for the owned +// copy, or the plugin binary the --dev shim rebuilds beside. Nothing is +// destroyed (the link already answered nothing) and the alternative is worse — +// a link that answers whatever reappears at the path it names. When there is +// nothing to write, the link stays exactly as it is: an entry abcd owns is then +// named by installOwnedEntry's refusal together with the command to run first, +// and one it does not own by the foreign refusal in stepSymlink. +// +// A link the provenance record names takes its record with it +// (iss-2609100506263330): the record would otherwise outlive the entry and hand +// the ownership claim to whatever occupies that path next. The entry written in +// its place records itself afresh. func (a *applyCtx) clearDanglingEntry(target string) { fi, err := os.Lstat(target) if err != nil || fi.Mode()&os.ModeSymlink == 0 { @@ -1324,64 +1429,18 @@ func (a *applyCtx) clearDanglingEntry(target string) { if present, serr := fsutil.Exists(target); serr != nil || present { return } - if !fileExists(pluginBinaryPath(a.det.pluginRoot)) { - // Nothing to repoint it at. Leaving the link is the lesser evil (removing - // it would take abcd off PATH entirely for no gain), and installPinnedSymlink - // states the reason — its source check runs before the owned-entry early - // return precisely so this case is never silent. + if a.devMode { + if !fileExists(pluginBinaryPath(a.det.pluginRoot)) { + return + } + } else if !ownedCopySourceReady(a.cwd, a.det.pluginRoot) { return } if err := os.Remove(target); err != nil { a.refuse("could not remove the dangling PATH entry " + displayPath(target) + ": " + errText(err)) - } -} - -// installPinnedSymlink writes the owned symlink to the pinned binary, replacing a -// dev shim if one is there. An existing owned symlink is left as-is (idempotent). -// It REFUSES to create a link whose target does not exist: a dangling `abcd` on -// PATH shadows whatever else would have answered, so a broken plugin install must -// not be converted into a broken PATH (iss-171). -func (a *applyCtx) installPinnedSymlink(target string, kind binTargetKind) { - // The source check comes FIRST, before the idempotent early return: an owned - // entry whose binary is gone classifies as owned, so checking the kind first - // would return silently and leave a dangling link reported as a healthy - // install with no reason recorded anywhere. - source := pluginBinaryPath(a.det.pluginRoot) - if !fileExists(source) { - a.refuse("refused to write the PATH entry " + displayPath(target) + - ": its target " + displayPath(source) + " does not exist — a dangling link would shadow any working abcd on PATH. Reinstall the plugin, then re-run `abcd ahoy install`.") - return - } - if kind == binTargetOwnedSymlink { - // Idempotent only when the pin already resolves to the current binary. A - // pin into a superseded vintage (iss-2609161805447092) classifies as - // owned too, and returning here would leave it answering the old - // release with the gap that named this verb as the remedy still open. - if dest, err := os.Readlink(target); err == nil && resolveSymlinkDest(target, dest) == resolvePath(source) { - return - } - if err := os.Remove(target); err != nil { - a.refuse("could not replace the superseded PATH entry " + displayPath(target) + ": " + errText(err)) - return - } - } - if kind == binTargetDevShim { - if err := os.Remove(target); err != nil { - a.refuse("could not replace the dev PATH entry " + displayPath(target) + " with the pinned one: " + errText(err) + - "; the dev entry is left as it was") - return - } - a.echoChange("install_mode", "dev", "pinned") - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - a.refuse("could not create the install directory " + displayPath(filepath.Dir(target)) + ": " + errText(err)) return } - if err := os.Symlink(source, target); err == nil { - a.note(writeCommandEntry, target) - } else { - a.refuse("could not write the PATH entry " + displayPath(target) + ": " + errText(err)) - } + removePathEntryFor(target) } // noteReachability describes, on the install result itself, whether the entry @@ -1573,6 +1632,17 @@ func Uninstall(cwd, binDir string) (UninstallReceipt, error) { default: receipt.Symlink.Note = "not a symlink; left untouched" } + case recordedDanglingLink(target): + // Judged before the plugin root: the record vouches for a dangling link + // without one, and a machine with no plugin root left is the case the + // owned dangling gap names uninstall for ("if abcd is gone"). + if err := os.Remove(target); err == nil { + receipt.Symlink.Removed = true + receipt.Symlink.Note = "removed dangling entry" + removePathEntryFor(target) + } else { + receipt.Symlink.Note = "remove failed" + } case !ok: receipt.Symlink.Note = "plugin root unresolved; left untouched" default: diff --git a/internal/core/ahoy/attribution_hook_test.go b/internal/core/ahoy/attribution_hook_test.go index da20f3b9b..84f81f7d5 100644 --- a/internal/core/ahoy/attribution_hook_test.go +++ b/internal/core/ahoy/attribution_hook_test.go @@ -20,12 +20,22 @@ func attributionOpts() InstallOptions { // TestAttributionHookScaffoldsFromTheBinary is itd-162's first acceptance // criterion. The adopt phase used to install the prepare-commit-msg hook from a // maintainer-local templates directory "if present", so on every other machine the -// step silently did nothing. HOME points at an empty directory here: the template -// comes out of the binary or it does not arrive at all. +// step silently did nothing. HOME holds nothing but the cache attestation the +// hermetic setup provisions: the template comes out of the binary or it does +// not arrive at all. func TestAttributionHookScaffoldsFromTheBinary(t *testing.T) { home, _ := setupHermetic(t) - if entries, err := os.ReadDir(home); err != nil || len(entries) != 0 { - t.Fatalf("the hermetic HOME is not empty (%d entries, err=%v); the fixture proves nothing", len(entries), err) + var found []string + if err := filepath.WalkDir(home, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + found = append(found, p) + } + return nil + }); err != nil || len(found) != 1 || found[0] != userCacheAttestationPath() { + t.Fatalf("the hermetic HOME holds more than the cache attestation (%v, err=%v); the fixture proves nothing", found, err) } repo := t.TempDir() if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { diff --git a/internal/core/ahoy/banlist_scaffold.go b/internal/core/ahoy/banlist_scaffold.go index 37eeac441..74450872e 100644 --- a/internal/core/ahoy/banlist_scaffold.go +++ b/internal/core/ahoy/banlist_scaffold.go @@ -673,7 +673,7 @@ func publicFamilyGaps(h BanlistHealth) []Gap { Title: "public banned-names family is not enforceable", Detail: "git ignores " + banlist.PublicConfigRelPath + ", so the family it carries never reaches CI — and the public layer's whole claim is that it is committed and enforced for everyone.", FixHint: "commit " + banlist.PublicConfigRelPath + " (`git add -f`), or ban the name on the private layer instead; " + - "under `visibility: public` the abcd fence ignores the whole .abcd/ namespace, which is a placement question a maintainer must settle.", + "under `visibility: public` the abcd fence ignores the whole .abcd/ namespace, which is a placement question the technical facilitator must settle.", Required: false, Resolvable: false, }} diff --git a/internal/core/ahoy/bin_install_test.go b/internal/core/ahoy/bin_install_test.go index 698cb8c07..b4000ba08 100644 --- a/internal/core/ahoy/bin_install_test.go +++ b/internal/core/ahoy/bin_install_test.go @@ -42,6 +42,26 @@ func linkOwned(t *testing.T, path, pluginRoot string) { } } +// assertOwnedCopyAt fails unless target is the owned copy install writes: a +// regular file holding the verified cache artefact that the provenance record +// vouches for. +func assertOwnedCopyAt(t *testing.T, target string) { + t.Helper() + fi, err := os.Lstat(target) + if err != nil { + t.Fatalf("install did not create %s: %v", target, err) + } + if !fi.Mode().IsRegular() { + t.Fatalf("the PATH entry %s is not a regular file (mode %v); a symlink into the plugin root dangles at the next update", target, fi.Mode()) + } + if got, _ := os.ReadFile(target); string(got) != string(cacheArtefact) { + t.Errorf("the PATH entry %s does not hold the verified artefact: %q", target, got) + } + if !isOwnedCopyFile(target) { + t.Errorf("the PATH entry %s is not recorded as abcd's owned copy", target) + } +} + func gapByID(gaps []Gap, id string) *Gap { for i := range gaps { if gaps[i].ID == id { @@ -221,12 +241,13 @@ func TestInstallRelativePluginRootWritesResolvableEntry(t *testing.T) { } } -// TestInstallRepointsEntryStrandedByPluginUpdate is iss-345's repair half: with -// the fresh plugin root holding a binary, install repoints the stranded entry -// in place — no refusal, and no second entry planted at the default location. +// TestInstallRepairsEntryStrandedByPluginUpdate is iss-345's repair half: with +// a verified release artefact available, install replaces the stranded entry +// in place with the owned copy — no refusal, no second entry planted at the +// default location, and no new link for the next update to strand. // The entry deliberately lives OFF ~/.local/bin so adopt-in-place and // write-the-default cannot land on the same path and mask each other. -func TestInstallRepointsEntryStrandedByPluginUpdate(t *testing.T) { +func TestInstallRepairsEntryStrandedByPluginUpdate(t *testing.T) { home, pluginRoot := setupUserScope(t) other := filepath.Join(t.TempDir(), "opt", "bin") t.Setenv("PATH", other) @@ -237,19 +258,12 @@ func TestInstallRepointsEntryStrandedByPluginUpdate(t *testing.T) { t.Fatal(err) } - res, err := Install(repo, installOpts(), RefusingPrompter{}) - if err != nil { + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) } - dest, rerr := os.Readlink(link) - if rerr != nil { - t.Fatalf("the stranded entry is no longer a symlink: %v", rerr) - } - if resolveSymlinkDest(link, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("entry was not repointed at the fresh plugin binary: %s -> %s (notes: %v)", link, dest, res.Notes) - } + assertOwnedCopyAt(t, link) if _, err := os.Lstat(filepath.Join(home, ".local", "bin", "abcd")); !os.IsNotExist(err) { - t.Errorf("install planted a second entry at ~/.local/bin beside the repointed one: %v", err) + t.Errorf("install planted a second entry at ~/.local/bin beside the repaired one: %v", err) } } @@ -407,9 +421,10 @@ func TestGapTextCarriesNoAbsoluteHomePath(t *testing.T) { // TestInstallDefaultsToUserLocalBin is decision 5 of the install-experience // plan: install writes ~/.local/bin/abcd, creating the directory, with no -// privilege escalation anywhere. +// privilege escalation anywhere. The entry is the owned copy of the verified +// release artefact (spc-35). func TestInstallDefaultsToUserLocalBin(t *testing.T) { - home, pluginRoot := setupUserScope(t) + home, _ := setupUserScope(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) repo := t.TempDir() @@ -422,17 +437,7 @@ func TestInstallDefaultsToUserLocalBin(t *testing.T) { t.Fatal(err) } target := filepath.Join(binDir, "abcd") - fi, err := os.Lstat(target) - if err != nil { - t.Fatalf("install did not create %s: %v", target, err) - } - if fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("install wrote a regular file, want a symlink") - } - dest, _ := os.Readlink(target) - if resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) - } + assertOwnedCopyAt(t, target) // The write is recorded on the receipt through the same note seam as every // other apply step, and the receipt scrub owned by iss-177 renders a // user-scope write home-relative — so the entry appears in tilde form. @@ -490,11 +495,13 @@ func TestInstallAdoptsOwnedInstallInPlace(t *testing.T) { // TestInstallRefusesToCreateDanglingSymlink is the shadowing refusal: with no // binary at /abcd, install must NOT write a symlink at all — a -// dangling link on PATH shadows whatever else would have answered. +// dangling link on PATH shadows whatever else would have answered. On a cold +// cache nothing is written, and the refusal names the command to run first. func TestInstallRefusesToCreateDanglingSymlink(t *testing.T) { home, pluginRoot := setupUserScope(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) + coldCache(t) if err := os.Remove(pluginBinaryPath(pluginRoot)); err != nil { t.Fatal(err) } @@ -511,8 +518,8 @@ func TestInstallRefusesToCreateDanglingSymlink(t *testing.T) { t.Fatalf("install created a symlink to a non-existent target: %v", err) } joined := strings.Join(res.Notes, "\n") - if !strings.Contains(joined, "does not exist") { - t.Errorf("the refusal was silent; notes = %v", res.Notes) + if !strings.Contains(joined, "no PATH entry was written") || !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the refusal was silent or named no command to run first; notes = %v", res.Notes) } } @@ -553,7 +560,7 @@ func TestInstallBinDirUnwritableFailsLoudly(t *testing.T) { // TestInstallBinDirWritableInstallsThere pins the opt-in: an explicit, writable // --bin-dir is where the entry lands. func TestInstallBinDirWritableInstallsThere(t *testing.T) { - _, pluginRoot := setupUserScope(t) + setupUserScope(t) dir := filepath.Join(t.TempDir(), "opt", "bin") t.Setenv("PATH", dir) repo := t.TempDir() @@ -566,14 +573,7 @@ func TestInstallBinDirWritableInstallsThere(t *testing.T) { if _, err := Install(repo, opts, RefusingPrompter{}); err != nil { t.Fatal(err) } - target := filepath.Join(dir, "abcd") - dest, err := os.Readlink(target) - if err != nil { - t.Fatalf("--bin-dir install did not create %s: %v", target, err) - } - if resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) - } + assertOwnedCopyAt(t, filepath.Join(dir, "abcd")) } // TestUninstallReceiptCarriesNoAbsoluteHomePath is the second half of the @@ -759,8 +759,11 @@ func TestInstallFreshPathGapIsCarriedOnNotes(t *testing.T) { // TestInstallDanglingEntryWithMissingBinaryRefusesLoudly: the owned-symlink // early return preceded the source check, so a dangling entry plus a missing // plugin binary produced status=partial with no note and no reason anywhere. +// On a cold cache there is nothing to replace it with, and the note says so and +// names the command to run first. func TestInstallDanglingEntryWithMissingBinaryRefusesLoudly(t *testing.T) { home, pluginRoot := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) linkOwned(t, filepath.Join(binDir, "abcd"), pluginRoot) @@ -776,8 +779,8 @@ func TestInstallDanglingEntryWithMissingBinaryRefusesLoudly(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(notesJoined(res.Notes), "does not exist") { - t.Errorf("a dangling entry with no binary to repoint at was silent; notes = %v", res.Notes) + if joined := notesJoined(res.Notes); !strings.Contains(joined, "points at a binary that is gone") || !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("a dangling entry with nothing to replace it with was silent; notes = %v", res.Notes) } } @@ -979,10 +982,10 @@ func TestDetectSupersededVintagePinIsItsOwnGap(t *testing.T) { } } -// TestInstallRepointsSupersededVintagePin: `ahoy install` is the remedy the -// superseded gap names, so it must adopt the pin in place and point it at the -// current plugin binary — no refusal, no second entry. -func TestInstallRepointsSupersededVintagePin(t *testing.T) { +// TestInstallReplacesSupersededVintagePin: `ahoy install` is the remedy the +// superseded gap names, so it must adopt the pin in place and replace it with +// the owned copy of the current release — no refusal, no second entry. +func TestInstallReplacesSupersededVintagePin(t *testing.T) { home, pluginRoot := setupUserScope(t) other := filepath.Join(t.TempDir(), "opt", "bin") t.Setenv("PATH", other) @@ -993,18 +996,11 @@ func TestInstallRepointsSupersededVintagePin(t *testing.T) { t.Fatal(err) } - res, err := Install(repo, installOpts(), RefusingPrompter{}) - if err != nil { + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) } - dest, rerr := os.Readlink(link) - if rerr != nil { - t.Fatalf("the superseded pin is no longer a symlink: %v", rerr) - } - if resolveSymlinkDest(link, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("pin was not repointed at the current plugin binary: %s -> %s (notes: %v)", link, dest, res.Notes) - } + assertOwnedCopyAt(t, link) if _, err := os.Lstat(filepath.Join(home, ".local", "bin", "abcd")); !os.IsNotExist(err) { - t.Errorf("install planted a second entry at ~/.local/bin beside the repointed one: %v", err) + t.Errorf("install planted a second entry at ~/.local/bin beside the repaired one: %v", err) } } diff --git a/internal/core/ahoy/cache_attestation.go b/internal/core/ahoy/cache_attestation.go index df24fe4a4..71398b8cb 100644 --- a/internal/core/ahoy/cache_attestation.go +++ b/internal/core/ahoy/cache_attestation.go @@ -88,11 +88,11 @@ func userCacheAttestationPath() string { // the sibling record. It is not the accepted same-uid residual // (iss-2609012039107700), which it neither closes nor claims to. func readCacheAttestation() (cacheAttestation, bool) { - path := userCacheAttestationPath() - if path == "" { + home, refused := homeScope() + if refused != "" { return cacheAttestation{}, false } - raw, _, err := fsutil.ReadDeclaration(path, maxPathEntryBytes) + raw, _, err := fsutil.ReadHomeDeclaration(home, ".abcd/"+cacheAttestationFile, maxPathEntryBytes) if err != nil { return cacheAttestation{}, false } diff --git a/internal/core/ahoy/cache_attestation_test.go b/internal/core/ahoy/cache_attestation_test.go index 6efb3a9a9..b5aeaca1f 100644 --- a/internal/core/ahoy/cache_attestation_test.go +++ b/internal/core/ahoy/cache_attestation_test.go @@ -78,6 +78,7 @@ func assertNoOwnedCopy(t *testing.T, target string, res InstallResult) { // it, and the note says which record is missing. func TestInstallRefusesUnattestedEnvDataDir(t *testing.T) { home, _ := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) data := t.TempDir() @@ -100,13 +101,13 @@ func TestInstallRefusesUnattestedEnvDataDir(t *testing.T) { if strings.Contains(joined, home) { t.Errorf("notes must render home paths in tilde form, never absolute; notes = %v", res.Notes) } - // Install degrades exactly as it does with no cache at all: the pinned - // symlink, said out loud. - if fi, err := os.Lstat(target); err != nil || fi.Mode()&os.ModeSymlink == 0 { - t.Errorf("an unattested cache must degrade to the spc-21 symlink: %v (%v)", fi, err) + // Install refuses exactly as it does with no cache at all: no entry, and + // the command to run first, said out loud (iss-2609100506263330). + if fi, err := os.Lstat(target); !os.IsNotExist(err) { + t.Errorf("an unattested cache must leave no PATH entry at all: %v (%v)", fi, err) } - if !strings.Contains(joined, "symlink") { - t.Errorf("the degradation must be named; notes = %v", res.Notes) + if !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the refusal must name the command to run first; notes = %v", res.Notes) } } @@ -229,12 +230,14 @@ func TestInstallPromotesAttestedCacheByEitherRoute(t *testing.T) { } } -// TestDetectOffersNoHealFromUnattestedCache: the symlink.legacy gap promises a -// heal to the owned copy, so it is offered only when install would actually -// perform it — never from a cache no attestation binds, or detection and -// install would disagree about the same directory. +// TestDetectOffersNoHealFromUnattestedCache: the symlink.legacy gap's fix hint +// promises a heal to the owned copy only when install would actually perform +// it — never from a cache no attestation binds, or detection and install would +// disagree about the same directory. Unbound, the hint names the command that +// provides a verified copy first. func TestDetectOffersNoHealFromUnattestedCache(t *testing.T) { home, pluginRoot := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) linkOwned(t, filepath.Join(binDir, "abcd"), pluginRoot) @@ -246,8 +249,8 @@ func TestDetectOffersNoHealFromUnattestedCache(t *testing.T) { if err != nil { t.Fatal(err) } - if g := gapByID(det.Gaps, "symlink.legacy"); g != nil { - t.Fatalf("detection offers a heal from an unattested cache: %+v", *g) + if g := gapByID(det.Gaps, "symlink.legacy"); g == nil || strings.Contains(g.FixHint, "abcd-owned copy") || !strings.Contains(g.FixHint, installRemedyAnchor) { + t.Fatalf("detection offers a heal from an unattested cache, or names no command to run first: %+v", g) } attestDataCache(t, data, cacheArtefact) @@ -255,7 +258,7 @@ func TestDetectOffersNoHealFromUnattestedCache(t *testing.T) { if err != nil { t.Fatal(err) } - if g := gapByID(det.Gaps, "symlink.legacy"); g == nil { + if g := gapByID(det.Gaps, "symlink.legacy"); g == nil || !strings.Contains(g.FixHint, "abcd-owned copy") { t.Fatalf("once attested, the same cache must be offered as the heal: %+v", det.Gaps) } } @@ -297,6 +300,7 @@ func TestReadCacheAttestationIgnoresMalformed(t *testing.T) { t.Run("absent", func(t *testing.T) { setupHermetic(t) + coldCache(t) if rec, ok := readCacheAttestation(); ok { t.Errorf("no record must read as absent, got %+v", rec) } @@ -304,6 +308,7 @@ func TestReadCacheAttestationIgnoresMalformed(t *testing.T) { t.Run("symlinked record", func(t *testing.T) { home, _ := setupHermetic(t) + coldCache(t) real := filepath.Join(t.TempDir(), "elsewhere") if err := os.WriteFile(real, []byte(good), 0o600); err != nil { t.Fatal(err) diff --git a/internal/core/ahoy/cold_cache_adopt_test.go b/internal/core/ahoy/cold_cache_adopt_test.go new file mode 100644 index 000000000..870c08f3d --- /dev/null +++ b/internal/core/ahoy/cold_cache_adopt_test.go @@ -0,0 +1,226 @@ +package ahoy + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestColdCacheRemedyIsAdoptedByTheReRun closes the loop the cold-cache +// refusal promises (iss-2609100506263330): the refusal names the install +// one-liner and says a re-run of `abcd ahoy install` then adopts what it +// wrote. So the state the one-liner leaves — a regular 0755 file at +// ~/.local/bin/abcd and a two-line ~/.abcd/path-entry naming it with its hash, +// no plugin_root line — must be adopted in place by an install on the SAME +// cold cache: no refusal, no remedy re-offered, the bytes untouched, nothing +// left for the operator to do. A re-run that refused again would send the +// operator round a loop the remedy cannot close. +func TestColdCacheRemedyIsAdoptedByTheReRun(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + target := filepath.Join(binDir, "abcd") + + // Exactly what the one-liner writes. + bin := []byte("#!/bin/sh\necho one-liner-install\n") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, bin, 0o755); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(bin) + writeUserPathEntry(t, "path="+target+"\nbinary_sha256="+hex.EncodeToString(sum[:])+"\n") + if err := os.Chmod(userPathEntryPath(), 0o644); err != nil { + t.Fatal(err) + } + + if kind := classifyBinTarget(target, pluginRoot); kind != binTargetOwnedCopy { + t.Fatalf("classify = %v; the one-liner's recorded copy is abcd's owned copy", kind) + } + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + for _, g := range det.Gaps { + if strings.HasPrefix(g.ID, "symlink.") { + t.Errorf("the one-liner's install raised %s on a cold cache: %+v", g.ID, g) + } + } + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if joined := notesJoined(res.Notes); strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the re-run the remedy names re-offered the remedy instead of adopting the copy; notes = %v", res.Notes) + } + if got, err := os.ReadFile(target); err != nil || !bytes.Equal(got, bin) { + t.Errorf("the re-run changed the one-liner's copy (%q, %v)", got, err) + } + if fi, err := os.Lstat(target); err != nil || !fi.Mode().IsRegular() { + t.Errorf("the entry is no longer the regular-file copy (%v, %v)", fi, err) + } + if m, _ := detectSignal(t, gitRepo(t), "install_mode").(string); m != "pinned" { + t.Errorf("install_mode = %q after the re-run, want pinned", m) + } +} + +// TestColdCacheRemedyIsAdoptedBehindAStrandedEntry is the same promise on a +// machine that also carries an entry a plugin update stranded earlier on PATH: +// the symlink.dangling gap that entry raises drives the install step, and the +// step lands on the owned copy the one-liner wrote. A cold cache has nothing +// to refresh that copy from, so the copy is adopted as it stands — never +// described as "no PATH entry was written", and never answered with the +// one-liner the operator has just run, which would send them round a loop. +func TestColdCacheRemedyIsAdoptedBehindAStrandedEntry(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + stale := filepath.Join(t.TempDir(), "usr-local-bin") + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", stale+string(os.PathListSeparator)+binDir) + stranded := filepath.Join(stale, "abcd") + linkStranded(t, stranded, pluginRoot) + target := filepath.Join(binDir, "abcd") + bin := []byte("#!/bin/sh\necho one-liner-install\n") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, bin, 0o755); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(bin) + writeUserPathEntry(t, "path="+target+"\nbinary_sha256="+hex.EncodeToString(sum[:])+"\n") + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + joined := notesJoined(res.Notes) + if strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the re-run re-offered the one-liner the operator just ran; notes = %v", res.Notes) + } + if strings.Contains(joined, "no PATH entry was written at "+displayPath(target)) { + t.Errorf("the re-run said no entry was written where the owned copy stands; notes = %v", res.Notes) + } + if got, err := os.ReadFile(target); err != nil || !bytes.Equal(got, bin) { + t.Errorf("the re-run changed the one-liner's copy (%q, %v)", got, err) + } + // iss-2609280932480608: the stranded entry ahead of the copy is abcd's own + // and resolves to nothing, and the copy behind it answers — so the run + // removes it rather than leaving a symlink.dangling gap no run can close. + if _, err := os.Lstat(stranded); !os.IsNotExist(err) { + t.Errorf("the owned dangling entry ahead of the adopted copy is still there: %v", err) + } + if len(res.Remaining) != 0 { + t.Errorf("Remaining = %v, want nothing: the stranded entry is abcd's own and the copy behind it answers", res.Remaining) + } + assertNoLiveShadowClaim(t, joined) +} + +// TestWarmInstallRemovesAnOwnedDanglingEntryAheadOfIt is the warm-cache half of +// iss-2609280932480608: the same machine — a stranded entry abcd owns earlier +// on PATH, the one-liner's copy behind it — with a verified cache. The install +// adopts the copy, and the stranded entry, which resolves to nothing, goes, so +// the run finishes clean. +func TestWarmInstallRemovesAnOwnedDanglingEntryAheadOfIt(t *testing.T) { + home, pluginRoot := setupUserScope(t) + seedDataCache(t, cacheArtefact) + stale := filepath.Join(t.TempDir(), "usr-local-bin") + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", stale+string(os.PathListSeparator)+binDir) + stranded := filepath.Join(stale, "abcd") + linkStranded(t, stranded, pluginRoot) + target := filepath.Join(binDir, "abcd") + bin := []byte("#!/bin/sh\necho one-liner-install\n") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, bin, 0o755); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(bin) + writeUserPathEntry(t, "path="+target+"\nbinary_sha256="+hex.EncodeToString(sum[:])+"\n") + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if kind := classifyBinTarget(target, pluginRoot); kind != binTargetOwnedCopy { + t.Fatalf("the owned copy at %s is no longer abcd's copy: %v", target, kind) + } + if _, err := os.Lstat(stranded); !os.IsNotExist(err) { + t.Errorf("the owned dangling entry ahead of the new copy is still there: %v", err) + } + if len(res.Remaining) != 0 { + t.Errorf("Remaining = %v, want nothing", res.Remaining) + } + assertNoLiveShadowClaim(t, notesJoined(res.Notes)) +} + +// TestUnownedDanglingEntryAheadIsLeftAlone bounds the removal: an unrecorded +// dangling link ahead of the install is not abcd's to remove, even though it +// resolves to nothing, so the install writes its copy behind it and leaves it. +func TestUnownedDanglingEntryAheadIsLeftAlone(t *testing.T) { + home, _ := setupUserScope(t) + seedDataCache(t, cacheArtefact) + stale := filepath.Join(t.TempDir(), "usr-local-bin") + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", stale+string(os.PathListSeparator)+binDir) + link := filepath.Join(stale, "abcd") + dest := plantDanglingLink(t, link) + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(binDir, "abcd")); err != nil || !bytes.Equal(got, cacheArtefact) { + t.Fatalf("the verified copy was not written (%q, %v)", got, err) + } + assertLinkUntouched(t, link, dest, "warm install behind an unowned dangling link") + assertNoLiveShadowClaim(t, notesJoined(res.Notes)) +} + +// assertNoLiveShadowClaim fails when a note or gap says a link whose target is +// gone is what runs, or calls it a binary abcd does not own: a link that +// resolves to nothing runs nothing, and the shell skips it. +func assertNoLiveShadowClaim(t *testing.T, text string) { + t.Helper() + for _, bad := range []string{"is what runs", "binary it does not own", "shadows every later PATH entry"} { + if strings.Contains(text, bad) { + t.Errorf("a dangling link was described with %q: %s", bad, text) + } + } +} + +// TestDanglingEntryWordingNeverClaimsItRuns pins the wording itself, for the +// owned and the unowned shape: the shadow note and the dangling gap both +// describe a link whose target is gone, and neither may say it runs or that it +// shadows what comes after it. +func TestDanglingEntryWordingNeverClaimsItRuns(t *testing.T) { + target := filepath.Join(t.TempDir(), "bin", "abcd") + for _, e := range []pathEntry{ + {path: "/opt/example/abcd", kind: binTargetOwnedSymlink, dangling: true}, + {path: "/opt/example/abcd", kind: binTargetForeign, dangling: true}, + } { + msg := shadowMessage(e, target) + assertNoLiveShadowClaim(t, msg) + if !strings.Contains(msg, "runs nothing") { + t.Errorf("the shadow note for a dangling entry must say it runs nothing: %s", msg) + } + } + for _, owned := range []bool{true, false} { + g := danglingEntryGap("/opt/example/abcd", owned) + assertNoLiveShadowClaim(t, g.Detail) + } + // The live shapes keep the wording that is true of them. + live := shadowMessage(pathEntry{path: "/opt/example/abcd", kind: binTargetForeign}, target) + if !strings.Contains(live, "is what runs") { + t.Errorf("a live foreign entry ahead of the install is what runs: %s", live) + } +} diff --git a/internal/core/ahoy/cold_cache_entry_test.go b/internal/core/ahoy/cold_cache_entry_test.go new file mode 100644 index 000000000..657ef5135 --- /dev/null +++ b/internal/core/ahoy/cold_cache_entry_test.go @@ -0,0 +1,500 @@ +package ahoy + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/fsutil" +) + +// iss-2609100506263330: `ahoy install` on a COLD cache — no verified release +// artefact in the persistent plugin data directory — used to write the PATH +// entry as a symlink into the versioned plugin root, report success, and leave +// an entry the next plugin update strands. It now refuses the link form and +// names a command the operator can run first (the README install one-liner, +// which fetches the release binary and verifies it against the release's own +// checksums). And a later run recognises an entry ~/.abcd/path-entry records +// as abcd's even once it dangles, rather than calling it foreign, so the +// repair every owned shape gets is offered for it too. + +// installRemedyAnchor is the fragment every cold-cache refusal must carry: the +// address of the one-liner the operator runs first. +const installRemedyAnchor = "github.com/intentdriven/abcd#install" + +// gitRepo returns an adoptable repository (a bare .git dir). +func gitRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + return repo +} + +// plantDanglingLink writes a symlink at path to a binary that does not exist, +// in a directory that is NOT a sibling of the plugin root — so neither the +// stranded-sibling rule nor the current-root rule can claim it. Only the +// provenance record can. It returns the link's destination. +func plantDanglingLink(t *testing.T, path string) string { + t.Helper() + dest := filepath.Join(t.TempDir(), "gone", "abcd") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(dest, path); err != nil { + t.Fatal(err) + } + return dest +} + +// assertLinkUntouched fails unless path is still the symlink to dest. +func assertLinkUntouched(t *testing.T, path, dest, when string) { + t.Helper() + fi, err := os.Lstat(path) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s: the dangling link at %s was removed or replaced (%v, %v)", when, path, fi, err) + } + if got, _ := os.Readlink(path); got != dest { + t.Fatalf("%s: the link at %s was repointed to %q, want it left at %q", when, path, got, dest) + } +} + +// TestInstallOnColdCacheWritesNoLink is choice B's install half: no verified +// artefact, so nothing is written at the PATH target — not a symlink into the +// plugin root, not anything — and the refusal names the command to run first. +// The run is not reported clean, and no provenance record is written for an +// entry that does not exist. +func TestInstallOnColdCacheWritesNoLink(t *testing.T) { + home, _ := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + target := filepath.Join(binDir, "abcd") + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if fi, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("a cold-cache install wrote %s (%v); it must write no entry at all — a link into the plugin root dangles at the next plugin update", target, fi) + } + if res.Status == "clean" { + t.Errorf("a cold-cache install that wrote no PATH entry reported clean; remaining %v", res.Remaining) + } + joined := notesJoined(res.Notes) + if !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the refusal must name the command to run first (%s); notes = %v", installRemedyAnchor, res.Notes) + } + if !strings.Contains(joined, "ahoy install") { + t.Errorf("the refusal must say to re-run `abcd ahoy install` afterwards; notes = %v", res.Notes) + } + if _, err := os.Lstat(userPathEntryPath()); !os.IsNotExist(err) { + t.Errorf("no entry was written, so no provenance record may be either: %v", err) + } + if m, _ := detectSignal(t, gitRepo(t), "install_mode").(string); m != "" { + t.Errorf("install_mode = %q after a cold-cache install; nothing is installed", m) + } +} + +// TestColdCacheInstallLeavesNothingAPluginUpdateCanStrand is choice B's update +// half: the harness replaces the plugin root on update. Because the cold +// install wrote no link, there is no entry on PATH afterwards for the update +// to strand — the state the record describes cannot arise. +func TestColdCacheInstallLeavesNothingAPluginUpdateCanStrand(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + + if _, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + // The plugin update: the old root is replaced by a fresh sibling. + if err := os.RemoveAll(pluginRoot); err != nil { + t.Fatal(err) + } + for _, e := range scanPathEntries(pluginRoot) { + if e.dangling { + t.Fatalf("a plugin update stranded %s, which the cold-cache install wrote", e.path) + } + t.Errorf("a cold-cache install left %s on PATH", e.path) + } +} + +// TestInstallOnWarmCacheSurvivesPluginUpdate is the other half of the same +// promise: with a verified cache the entry is the owned copy, and a plugin +// update that replaces the root leaves it resolving and runnable. +func TestInstallOnWarmCacheSurvivesPluginUpdate(t *testing.T) { + home, pluginRoot := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + seedDataCache(t, cacheArtefact) + target := filepath.Join(binDir, "abcd") + + if _, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(pluginRoot); err != nil { + t.Fatal(err) + } + if present, err := fsutil.Exists(target); err != nil || !present { + t.Fatalf("the entry no longer resolves after the plugin root was replaced (%v, %v)", present, err) + } + if err := exec.Command(target).Run(); err != nil { + t.Errorf("the entry must keep executing after a plugin update: %v", err) + } +} + +// TestDetectLegacyPinOnColdCacheIsAGap is the folded evidence's first ask: a +// live symlink into the CURRENT plugin root works today and dies at the next +// update, and on a cold cache it used to raise nothing at all. It is a gap now +// whatever the cache holds, and on a cold cache its fix hint names the command +// to run first rather than an install that could not act on it. +func TestDetectLegacyPinOnColdCacheIsAGap(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + linkOwned(t, link, pluginRoot) + + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + g := gapByID(det.Gaps, "symlink.legacy") + if g == nil { + t.Fatalf("a pin into the plugin root on a cold cache raised no symlink.legacy gap: %+v", det.Gaps) + } + if !g.Required { + t.Errorf("symlink.legacy must be required: %+v", g) + } + if !strings.Contains(g.FixHint, installRemedyAnchor) { + t.Errorf("on a cold cache the fix hint must name the command to run first (%s): %q", installRemedyAnchor, g.FixHint) + } + + // Install on the same cold cache leaves the working pin as it stands and + // says why, rather than reporting the pin as a clean install. + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if dest, err := os.Readlink(link); err != nil || resolveSymlinkDest(link, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { + t.Fatalf("install on a cold cache must leave the working pin in place (%q, %v)", dest, err) + } + if res.Status == "clean" { + t.Errorf("a pin that dies at the next plugin update was reported clean; notes %v", res.Notes) + } + if !strings.Contains(notesJoined(res.Notes), installRemedyAnchor) { + t.Errorf("install must name the command to run first; notes = %v", res.Notes) + } +} + +// TestRecordedDanglingEntryIsAbcdsAndRepairs is part (C): an entry the +// provenance record names that has since become dangling is abcd's own, not a +// foreign occupant. Bare detection names it with the owned wording and writes +// nothing; the explicit install repairs it, replacing it with the verified +// owned copy; `abcd update` routes it to that repair. +func TestRecordedDanglingEntryIsAbcdsAndRepairs(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + dest := plantDanglingLink(t, link) + vouchedPathEntry(t, link) + recBefore, err := os.ReadFile(userPathEntryPath()) + if err != nil { + t.Fatal(err) + } + + if kind := classifyBinTarget(link, pluginRoot); kind != binTargetOwnedSymlink { + t.Errorf("classify = %v; a dangling entry ~/.abcd/path-entry records is abcd's own", kind) + } + if got := ResolveUpdateTarget().Kind; got != UpdateTargetDangling { + t.Errorf("update target = %q, want %q: `abcd update` must route abcd's own dangling entry to its repair", got, UpdateTargetDangling) + } + + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + if hasGap(det.Gaps, "symlink.foreign") { + t.Errorf("a recorded dangling entry was reported foreign: %+v", det.Gaps) + } + var dangling []Gap + for _, g := range det.Gaps { + if g.ID == "symlink.dangling" { + dangling = append(dangling, g) + } + } + if len(dangling) != 1 { + t.Fatalf("want exactly one symlink.dangling gap for the one entry, got %d: %+v", len(dangling), det.Gaps) + } + if !dangling[0].Resolvable || !strings.Contains(dangling[0].Detail, "abcd-owned") { + t.Errorf("the gap must carry the owned wording and offer the repair: %+v", dangling[0]) + } + // Bare ahoy writes nothing. + assertLinkUntouched(t, link, dest, "bare detection") + if recAfter, _ := os.ReadFile(userPathEntryPath()); !bytes.Equal(recAfter, recBefore) { + t.Errorf("bare detection rewrote the provenance record: %q -> %q", recBefore, recAfter) + } + + // The repair, on a verified cache. + seedDataCache(t, cacheArtefact) + if _, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + fi, err := os.Lstat(link) + if err != nil || !fi.Mode().IsRegular() { + t.Fatalf("the repair must leave a regular owned copy at %s (%v, %v)", link, fi, err) + } + if got, _ := os.ReadFile(link); !bytes.Equal(got, cacheArtefact) { + t.Errorf("the repaired entry must hold the verified artefact; got %q", got) + } + if kind := classifyBinTarget(link, pluginRoot); kind != binTargetOwnedCopy { + t.Errorf("after the repair classify = %v, want the owned copy (the record must name the new bytes)", kind) + } +} + +// TestRecordedDanglingEntryOnColdCacheIsLeftAndNamed: the repair needs a +// verified artefact. Without one, install touches nothing, keeps the entry +// classified as abcd's, and names the command to run first. +func TestRecordedDanglingEntryOnColdCacheIsLeftAndNamed(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + dest := plantDanglingLink(t, link) + vouchedPathEntry(t, link) + + res, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + assertLinkUntouched(t, link, dest, "cold-cache install") + if kind := classifyBinTarget(link, pluginRoot); kind != binTargetOwnedSymlink { + t.Errorf("classify = %v after a cold-cache install; the entry is still abcd's", kind) + } + joined := notesJoined(res.Notes) + if !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the cold-cache refusal must name the command to run first; notes = %v", res.Notes) + } + if strings.Contains(joined, "does not own") { + t.Errorf("abcd's own recorded entry was described as one abcd does not own; notes = %v", res.Notes) + } +} + +// TestUnrecordedDanglingEntryIsNotClaimed: the same dangling link with NO +// record naming it is not abcd's. Detection never asserts provenance for it, +// classification stays foreign, and neither bare detection nor a cold-cache +// install touches it. +func TestUnrecordedDanglingEntryIsNotClaimed(t *testing.T) { + home, pluginRoot := setupUserScope(t) + coldCache(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + dest := plantDanglingLink(t, link) + // A record exists, but for a different entry: it vouches for nothing here. + vouchedPathEntry(t, filepath.Join(t.TempDir(), "abcd")) + + if kind := classifyBinTarget(link, pluginRoot); kind != binTargetForeign { + t.Errorf("classify = %v; an unrecorded dangling link is not abcd's", kind) + } + det, err := Detect(managedRepo(t)) + if err != nil { + t.Fatal(err) + } + for _, g := range det.Gaps { + if g.ID == "symlink.dangling" && strings.Contains(g.Detail, "abcd-owned") { + t.Errorf("an unrecorded dangling link was claimed as abcd-owned: %+v", g) + } + } + assertLinkUntouched(t, link, dest, "bare detection") + + if _, err := Install(gitRepo(t), installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + assertLinkUntouched(t, link, dest, "cold-cache install") +} + +// TestDanglingEntryRecordNotTrustedUnlessOwned: the record is honoured only as +// the hook reads it — a regular file this uid owns that group and other cannot +// write. A record failing that vouches for nothing, so the dangling link it +// names stays unclaimed. +func TestDanglingEntryRecordNotTrustedUnlessOwned(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T) + }{ + {"group-writable", func(t *testing.T) { + if err := os.Chmod(userPathEntryPath(), 0o664); err != nil { + t.Fatal(err) + } + }}, + {"other-writable", func(t *testing.T) { + if err := os.Chmod(userPathEntryPath(), 0o646); err != nil { + t.Fatal(err) + } + }}, + {"owned by another uid", func(t *testing.T) { + restore := fsutil.SwapOwnerUIDForTest(func(string) (uint32, error) { + return uint32(os.Getuid()) + 1, nil + }) + t.Cleanup(restore) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + home, pluginRoot := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + plantDanglingLink(t, link) + vouchedPathEntry(t, link) + tc.setup(t) + + if kind := classifyBinTarget(link, pluginRoot); kind != binTargetForeign { + t.Errorf("classify = %v; a record this session does not solely own vouches for nothing", kind) + } + if got := ResolveUpdateTarget().Kind; got == UpdateTargetDangling { + t.Errorf("update target = %q on an untrusted record", got) + } + }) + } +} + +// TestUninstallTakesARecordedDanglingEntryWithItsRecord sweeps the uninstall +// sibling: the owned dangling gap names `ahoy uninstall` as a remedy, so +// uninstall classifies with the same predicate — it removes the link the +// record names together with the record, and leaves an unrecorded one alone. +func TestUninstallTakesARecordedDanglingEntryWithItsRecord(t *testing.T) { + t.Run("recorded", func(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + plantDanglingLink(t, link) + vouchedPathEntry(t, link) + + receipt, err := Uninstall(managedRepo(t), "") + if err != nil { + t.Fatal(err) + } + if !receipt.Symlink.Removed { + t.Fatalf("uninstall left abcd's own recorded dangling entry in place: %+v", receipt.Symlink) + } + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Errorf("the entry is still there: %v", err) + } + if _, err := os.Lstat(userPathEntryPath()); !os.IsNotExist(err) { + t.Errorf("the record outlived the entry it names: %v", err) + } + }) + t.Run("unrecorded", func(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + dest := plantDanglingLink(t, link) + + receipt, err := Uninstall(managedRepo(t), "") + if err != nil { + t.Fatal(err) + } + if receipt.Symlink.Removed { + t.Fatalf("uninstall removed a dangling link abcd cannot prove it wrote: %+v", receipt.Symlink) + } + assertLinkUntouched(t, link, dest, "uninstall") + }) +} + +// noPluginRoot leaves nothing for the plugin-root ladder to resolve: both root +// variables empty and an executable whose ancestors hold no plugin layout — +// the machine the owned dangling gap means by "if abcd is gone". It fails the +// test unless the premise holds, so a leak through the ladder cannot turn the +// assertions below into a test of the rooted path. +func noPluginRoot(t *testing.T) { + t.Helper() + t.Setenv("ABCD_PLUGIN_ROOT", "") + t.Setenv("CLAUDE_PLUGIN_ROOT", "") + exe := filepath.Join(t.TempDir(), "elsewhere", "abcd") + saved := osExecutable + t.Cleanup(func() { osExecutable = saved }) + osExecutable = func() (string, error) { return exe, nil } + if root, ok := resolvePluginRoot(); ok { + t.Fatalf("premise: no plugin root may resolve, got %q", root) + } +} + +// TestUninstallTakesARecordedDanglingEntryWithNoPluginRoot is the case the +// owned dangling gap's fix hint sends to `ahoy uninstall`: abcd is gone, so no +// plugin root resolves. The record vouches for the link without one, so +// uninstall removes the link together with its record — at the default +// location and wherever else on PATH it sits — and an unrecorded dangling link +// in the same shape stays untouched, its unrelated record with it. +func TestUninstallTakesARecordedDanglingEntryWithNoPluginRoot(t *testing.T) { + for _, tc := range []struct { + name string + elsewhere bool + }{{"default location", false}, {"elsewhere on PATH", true}} { + t.Run("recorded/"+tc.name, func(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + link := filepath.Join(binDir, "abcd") + t.Setenv("PATH", binDir) + if tc.elsewhere { + other := filepath.Join(t.TempDir(), "opt-bin") + link = filepath.Join(other, "abcd") + t.Setenv("PATH", other+string(os.PathListSeparator)+binDir) + } + plantDanglingLink(t, link) + vouchedPathEntry(t, link) + noPluginRoot(t) + + receipt, err := Uninstall(managedRepo(t), "") + if err != nil { + t.Fatal(err) + } + if !receipt.Symlink.Removed { + t.Fatalf("uninstall left abcd's own recorded dangling entry in place with no plugin root: %+v", receipt.Symlink) + } + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Errorf("the entry is still there: %v", err) + } + if _, err := os.Lstat(userPathEntryPath()); !os.IsNotExist(err) { + t.Errorf("the record outlived the entry it names: %v", err) + } + }) + } + t.Run("unrecorded", func(t *testing.T) { + home, _ := setupUserScope(t) + binDir := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", binDir) + link := filepath.Join(binDir, "abcd") + dest := plantDanglingLink(t, link) + vouchedPathEntry(t, filepath.Join(t.TempDir(), "abcd")) + recBefore, err := os.ReadFile(userPathEntryPath()) + if err != nil { + t.Fatal(err) + } + noPluginRoot(t) + + receipt, err := Uninstall(managedRepo(t), "") + if err != nil { + t.Fatal(err) + } + if receipt.Symlink.Removed { + t.Fatalf("uninstall removed a dangling link abcd cannot prove it wrote: %+v", receipt.Symlink) + } + assertLinkUntouched(t, link, dest, "uninstall with no plugin root") + if recAfter, _ := os.ReadFile(userPathEntryPath()); !bytes.Equal(recAfter, recBefore) { + t.Errorf("uninstall touched a record naming another entry: %q -> %q", recBefore, recAfter) + } + }) +} diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 0bab8a334..c0b1e5a64 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -1,6 +1,7 @@ package ahoy import ( + "errors" "os" "os/exec" "path/filepath" @@ -398,9 +399,25 @@ func toolRoles(res identity.Result) string { return "" } +// historyHomeLinkGapID is the diagnostic for a history registry behind a +// symlinked ~/.abcd (iss-2609281129171021). +const historyHomeLinkGapID = "history.home_symlinked" + func detectHistoryStore(rootSHA string) []Gap { var gaps []Gap root, err := historyRoot() + if errors.Is(err, fsutil.ErrHomeScopeSymlinked) { + // A note, not an actionable gap: install refuses to create the registry + // through the link, so a required "not bootstrapped" gap would be one it + // reports as outstanding on every run and can never close. Only the + // operator can, by replacing the link. + return []Gap{{ + ID: historyHomeLinkGapID, Category: UserState, Scope: "machine", + Title: "history registry not kept behind a symlinked home directory", + Detail: "This machine's history registry is not read or written: " + err.Error() + ".", + FixHint: "Replace the link with a real directory, then re-run `abcd ahoy install` to register this repository.", + }} + } if err != nil { return nil } @@ -578,10 +595,12 @@ func detectPathSymlink(cwd, pluginRoot string, pluginOK bool) []Gap { } var gaps []Gap - // A link of ours whose binary has gone shadows whatever else on PATH would - // have answered. It is neither "installed" nor "missing" — it is its own gap. - if e, ok := danglingPathEntry(pluginRoot); ok { - gaps = append(gaps, danglingEntryGap(e.path, true)) + // A link of ours whose binary has gone runs nothing (the shell skips it), and + // answers again whatever reappears at its target. It is neither + // "installed" nor "missing" — it is its own gap. + top, topOK := danglingPathEntry(pluginRoot) + if topOK { + gaps = append(gaps, danglingEntryGap(top.path, true)) } target := effectiveBinTarget(pluginRoot) @@ -627,18 +646,28 @@ func detectPathSymlink(cwd, pluginRoot string, pluginOK bool) []Gap { gaps = append(gaps, unrecordedEntryGap(target)...) // A working install TODAY, and a casualty of the next plugin // update: the link points into a directory the harness replaces and - // garbage-collects (spc-35). Heal-able only while a verified cache - // artefact exists to copy from — without one there is nothing - // better to offer than the symlink that works. - if ownedCopySourceReady(cwd, pluginRoot) { - gaps = append(gaps, Gap{ - ID: "symlink.legacy", Category: ConfigChange, Scope: "machine", - Title: "PATH entry is a symlink into the plugin root", - Detail: displayPath(target) + " points into the harness-owned plugin directory, which every plugin update replaces and later deletes — the entry will dangle after the next update.", - FixHint: "ahoy install replaces it with an abcd-owned copy of the verified release binary, which survives updates.", - Required: true, Resolvable: true, - }) + // garbage-collects (spc-35). It is a gap whatever the cache holds + // (iss-2609100506263330): silence on a cold cache reported a pin + // the next update breaks as a clean install. With a verified cache + // artefact install heals it to the owned copy; without one install + // leaves the working pin where it stands, and the hint names the + // command that provides the verified copy first. + // A pin whose plugin binary is already gone is not working today: + // the symlink.dangling gap above carries it. + if linkIsDangling(target) { + break } + fix := "ahoy install replaces it with an abcd-owned copy of the verified release binary, which survives updates." + if !ownedCopySourceReady(cwd, pluginRoot) { + fix = "No verified release binary is available to replace it with yet. " + coldCacheRemedy + } + gaps = append(gaps, Gap{ + ID: "symlink.legacy", Category: ConfigChange, Scope: "machine", + Title: "PATH entry is a symlink into the plugin root", + Detail: displayPath(target) + " points into the harness-owned plugin directory, which every plugin update replaces and later deletes — the entry will dangle after the next update.", + FixHint: fix, + Required: true, Resolvable: true, + }) case supersededSiblingDest(target, dest, pluginRoot): // Ours, pinned into a vintage the harness has moved past but not yet // deleted (iss-2609161805447092): a working install that answers an @@ -651,16 +680,22 @@ func detectPathSymlink(cwd, pluginRoot string, pluginOK bool) []Gap { FixHint: "ahoy install replaces it with the current release.", Required: true, Resolvable: true, }) - case strandedSiblingDest(target, dest, pluginRoot): - // Ours, stranded by a plugin update: the symlink.dangling gap above - // already carries it, and a foreign-worded gap here would tell the - // user to hand-resolve a link abcd itself wrote (iss-345). + case strandedSiblingDest(target, dest, pluginRoot) || recordedDanglingLink(target): + // Ours, stranded by a plugin update (iss-345) or named by the + // provenance record (iss-2609100506263330): a foreign-worded gap + // would tell the user to hand-resolve a link abcd itself wrote. The + // symlink.dangling gap above carries it when the entry is on PATH; + // an entry off PATH (an explicit --bin-dir, a bin dir not yet on + // PATH) is not in that scan, so it is named here, once. + if !topOK || !sameEntry(top.path, target) { + gaps = append(gaps, danglingEntryGap(target, true)) + } case linkIsDangling(target): // A link abcd cannot prove it wrote, that resolves to NOTHING // (iss-2609100506256636). Refusing to clobber a foreign entry is // right — it is somebody's working install — but this one is - // nobody's: it runs nothing, and it shadows every later PATH entry - // including a healthy abcd. Reporting it as foreign made the state + // nobody's: it runs nothing, and it answers whatever reappears at + // its target ahead of a healthy abcd. Reporting it as foreign made the state // unreachable from inside the tool, because that gap is // `resolvable: false` and there is no --force and no uninstall path // for an entry abcd does not own, so `ahoy install` could never @@ -693,11 +728,11 @@ func detectPathSymlink(cwd, pluginRoot string, pluginOK bool) []Gap { // prove it wrote, and never points at `ahoy uninstall`, which removes only what // abcd owns. func danglingEntryGap(path string, owned bool) Gap { - detail := displayPath(path) + " points at a target that does not exist, so it runs nothing and shadows every later PATH entry." - fix := "ahoy install replaces it once the plugin binary is present: a link that resolves to nothing is nobody's working install." + detail := displayPath(path) + " points at a target that does not exist. It runs nothing — the shell skips it — but whatever reappears at that target would answer `abcd` first." + fix := "ahoy install replaces it with a verified copy of the release binary once one is available: a link that resolves to nothing is nobody's working install." if owned { - detail = displayPath(path) + " is an abcd-owned entry whose target no longer exists, so it shadows every later PATH entry." - fix = "ahoy install repoints it once the plugin binary is present; remove it with `ahoy uninstall` if abcd is gone." + detail = displayPath(path) + " is an abcd-owned entry whose target no longer exists. It runs nothing — the shell skips it — but whatever reappears at that target would answer `abcd` first." + fix = "ahoy install replaces it with a verified copy of the release binary, and names the command to run first when none is available; remove it with `ahoy uninstall` if abcd is gone." } return Gap{ ID: "symlink.dangling", Category: ConfigChange, Scope: "machine", diff --git a/internal/core/ahoy/detect_test.go b/internal/core/ahoy/detect_test.go index ff7ea2f22..007849f83 100644 --- a/internal/core/ahoy/detect_test.go +++ b/internal/core/ahoy/detect_test.go @@ -49,7 +49,7 @@ func setupHermetic(t *testing.T) (home, pluginRoot string) { var kept []string for _, dir := range filepath.SplitList(os.Getenv("PATH")) { // Lstat, not Stat: the scanner under test classifies a DANGLING abcd symlink - // as an entry that still shadows PATH, so a Stat here — which follows the link + // as an entry that still occupies PATH (it runs nothing), so a Stat here — which follows the link // to a not-exist error and keeps the directory — would leak that entry into // every hermetic assertion. Lstat sees the link itself and drops it. if fi, err := os.Lstat(filepath.Join(dir, "abcd")); err == nil && !fi.IsDir() { @@ -58,9 +58,32 @@ func setupHermetic(t *testing.T) (home, pluginRoot string) { kept = append(kept, dir) } t.Setenv("PATH", strings.Join(kept, string(os.PathListSeparator))) + // The persistent data dir a session's hooks provision: a verified cache + // the home-scoped attestation binds. It is the ordinary state `ahoy + // install` meets, and the only one in which it writes a PATH entry at all + // (iss-2609100506263330); a test about the cold cache says so with + // coldCache. + data := t.TempDir() + seedDataCacheAt(t, data, cacheArtefact) + attestDataCache(t, data, cacheArtefact) + t.Setenv("CLAUDE_PLUGIN_DATA", data) return home, pluginRoot } +// coldCache undoes the provisioned data dir setupHermetic lays down: no +// CLAUDE_PLUGIN_DATA and no attestation, so no verified release artefact is +// available to install from — the terminal on a machine whose hooks never +// provisioned the cache. +func coldCache(t *testing.T) { + t.Helper() + t.Setenv("CLAUDE_PLUGIN_DATA", "") + if p := userCacheAttestationPath(); p != "" { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } +} + func TestClassifyUnmanagedFolder(t *testing.T) { setupHermetic(t) dir := t.TempDir() diff --git a/internal/core/ahoy/dev_install_test.go b/internal/core/ahoy/dev_install_test.go index ad9433d78..5c14ec4fc 100644 --- a/internal/core/ahoy/dev_install_test.go +++ b/internal/core/ahoy/dev_install_test.go @@ -109,11 +109,11 @@ func TestDevInstallWritesShimAndSurfacesMode(t *testing.T) { } } -// TestNormalInstallRegressionPin is coverage (b): a plain normal install still -// creates the pinned-binary symlink and records NO install section — byte-for-byte -// the pre-dev-mode behaviour. +// TestNormalInstallRegressionPin is coverage (b): a plain normal install +// creates the pinned entry — the owned copy of the verified release artefact — +// and records NO install section. func TestNormalInstallRegressionPin(t *testing.T) { - _, pluginRoot := setupHermetic(t) + setupHermetic(t) repo := t.TempDir() if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { t.Fatal(err) @@ -121,18 +121,7 @@ func TestNormalInstallRegressionPin(t *testing.T) { if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { t.Fatal(err) } - target := binTarget() - fi, err := os.Lstat(target) - if err != nil { - t.Fatalf("PATH target not created: %v", err) - } - if fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("normal install did not create a symlink") - } - dest, _ := os.Readlink(target) - if resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) - } + assertOwnedCopyAt(t, binTarget()) if configHasInstallSection(t, repo) { t.Errorf("normal install wrote an install section; want none (regression)") } @@ -188,9 +177,10 @@ func TestInstallPinnedToDevTransition(t *testing.T) { } // TestInstallDevToPinnedTransition is coverage (c), the other direction: a --dev -// install followed by a plain install applies-as-update, restoring the symlink. +// install followed by a plain install applies-as-update, restoring the pinned +// owned copy. func TestInstallDevToPinnedTransition(t *testing.T) { - _, pluginRoot := setupHermetic(t) + setupHermetic(t) repo := t.TempDir() if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { t.Fatal(err) @@ -208,17 +198,7 @@ func TestInstallDevToPinnedTransition(t *testing.T) { if !containsChange(res.Changes, "install_mode: dev -> pinned") { t.Errorf("changes = %v, want an install_mode dev -> pinned echo", res.Changes) } - fi, err := os.Lstat(binTarget()) - if err != nil { - t.Fatal(err) - } - if fi.Mode()&os.ModeSymlink == 0 { - t.Errorf("transition did not restore the symlink") - } - dest, _ := os.Readlink(binTarget()) - if resolveSymlinkDest(binTarget(), dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("restored symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) - } + assertOwnedCopyAt(t, binTarget()) if got := detectInstallModeSignal(t, repo); got != "pinned" { t.Errorf("install_mode signal = %q, want pinned", got) } diff --git a/internal/core/ahoy/dev_shim_failure_test.go b/internal/core/ahoy/dev_shim_failure_test.go index fe7b794d2..d0383319c 100644 --- a/internal/core/ahoy/dev_shim_failure_test.go +++ b/internal/core/ahoy/dev_shim_failure_test.go @@ -84,14 +84,15 @@ func TestInstallDevShimNotesEveryFailure(t *testing.T) { } } -// TestInstallPinnedSymlinkNotesAFailedShimRemoval is the twin of the dev-shim +// TestInstallOwnedEntryNotesAFailedShimRemoval is the twin of the dev-shim // case in the opposite direction: switching a dev shim back to the pinned -// entry removes the shim first, and that removal returned bare on failure. -func TestInstallPinnedSymlinkNotesAFailedShimRemoval(t *testing.T) { +// owned copy removes the shim first, and that removal returned bare on failure. +func TestInstallOwnedEntryNotesAFailedShimRemoval(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores directory permissions, so the forced failure cannot occur") } _, pluginRoot := setupHermetic(t) + seedDataCache(t, cacheArtefact) d := t.TempDir() t.Cleanup(func() { _ = os.Chmod(d, 0o755) }) target := filepath.Join(d, "abcd") @@ -102,11 +103,11 @@ func TestInstallPinnedSymlinkNotesAFailedShimRemoval(t *testing.T) { t.Fatal(err) } a := &applyCtx{cwd: t.TempDir(), det: DetectionResult{pluginRoot: pluginRoot}} - a.installPinnedSymlink(target, binTargetDevShim) + a.installOwnedEntry(target, binTargetDevShim) if len(a.writes) != 0 { t.Errorf("a failed switch reported writes %v", a.writes) } - if !strings.Contains(strings.Join(a.notes, "\n"), "could not replace the dev PATH entry") { + if !strings.Contains(strings.Join(a.notes, "\n"), "could not replace the existing PATH entry") { t.Errorf("no note for the failed shim removal; notes = %q", a.notes) } } diff --git a/internal/core/ahoy/docslint_seed_test.go b/internal/core/ahoy/docslint_seed_test.go index 039b5609f..1ca899963 100644 --- a/internal/core/ahoy/docslint_seed_test.go +++ b/internal/core/ahoy/docslint_seed_test.go @@ -139,6 +139,11 @@ var deliberateSeedOmissions = map[string]string{ // it is seeded once it has run there, not into every prepared repository // on its first release. "link_anchors": "warn-first in abcd's own tree before it is seeded", + // abcd's own role vocabulary (itd-2609212137129937): the ban holds abcd's + // text to naming the product thinker or the technical facilitator, and its + // extra_roots name abcd's own trees (the plugin command pages, the bundled + // rules source), which a prepared repository does not have. + "roles/": "abcd's own role vocabulary, over abcd's own trees", // The persona rule reads abcd's persona roster // (.abcd/development/personas.json), which a prepared repository does not // have; armed there, it refuses to load for want of a registry. diff --git a/internal/core/ahoy/history_home_link_test.go b/internal/core/ahoy/history_home_link_test.go new file mode 100644 index 000000000..db5a40e03 --- /dev/null +++ b/internal/core/ahoy/history_home_link_test.go @@ -0,0 +1,140 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// linkBareAbcdHome makes home's ~/.abcd a symlink to an EMPTY directory +// elsewhere (the dotfiles shape before abcd has written anything) and returns +// that directory, so a test can see whether anything was written through the +// link. +func linkBareAbcdHome(t *testing.T, home string) string { + t.Helper() + target := filepath.Join(t.TempDir(), "dotfiles-abcd") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + return target +} + +// TestInstallRegistersNothingThroughASymlinkedAbcdHome is iss-2609281129171021: +// every reader and writer of a file abcd trusts in ~/.abcd refuses a symlinked +// ~/.abcd, and the history registry was the one writer left following it — +// `ahoy install` created ~/.abcd/history/index.json and the per-repo meta.json +// wherever the link pointed (a dotfiles checkout). The registration is skipped, +// nothing lands behind the link, and the install says why, naming the link and +// the remedy. +func TestInstallRegistersNothingThroughASymlinkedAbcdHome(t *testing.T) { + home, _ := setupHermetic(t) + target := linkBareAbcdHome(t, home) + repo := committedRepo(t) + t.Chdir(repo) + + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(target) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("install wrote through the symlinked ~/.abcd: the link target holds %v", names) + } + var registry string + for _, n := range res.Notes { + if strings.Contains(n, "registration") && strings.Contains(n, "~/.abcd is a symlink") { + registry = n + } + } + if registry == "" { + t.Fatalf("no note names the skipped registration and the symlinked ~/.abcd; notes = %v", res.Notes) + } + if !strings.Contains(registry, "replace the link with a real directory") { + t.Errorf("the registration note must carry the remedy; got %q", registry) + } + if !strings.Contains(registry, "nothing was written") { + t.Errorf("the registration note must say nothing was written; got %q", registry) + } +} + +// TestDetectReportsASymlinkedHistoryStoreAsADiagnostic: the detector must not +// answer a symlinked ~/.abcd with "~/.abcd/history/ not bootstrapped", a +// required gap install would then try, and refuse, to close on every run. It +// raises one diagnostic instead — not required, not resolvable — naming the +// link and the remedy. +func TestDetectReportsASymlinkedHistoryStoreAsADiagnostic(t *testing.T) { + home, _ := setupHermetic(t) + linkBareAbcdHome(t, home) + + det, err := Detect(managedRepoWithCommit(t)) + if err != nil { + t.Fatal(err) + } + var diag *Gap + for i, g := range det.Gaps { + switch g.ID { + case "history.bootstrap_missing", "history.meta_missing": + t.Errorf("a symlinked ~/.abcd raised the actionable gap %q", g.ID) + case "history.home_symlinked": + diag = &det.Gaps[i] + } + } + if diag == nil { + t.Fatalf("no history.home_symlinked diagnostic; gaps = %v", gapIDs(det.Gaps)) + } + if diag.Required || diag.Resolvable { + t.Errorf("the diagnostic must be neither required nor resolvable: %+v", *diag) + } + if !strings.Contains(diag.Detail, "~/.abcd is a symlink") { + t.Errorf("the diagnostic must name the symlinked ~/.abcd; got %q", diag.Detail) + } +} + +// TestInstallRegistersThroughAHomeThatIsItselfALink is the other half: home +// itself reached through a link (/home -> /usr/home, a relocated account) is +// the machine's layout, not a declaration, and the fsutil rule never judges +// it. The registry's non-following create must not refuse it either. +func TestInstallRegistersThroughAHomeThatIsItselfALink(t *testing.T) { + realHome, _ := setupHermetic(t) + linkedHome := filepath.Join(t.TempDir(), "home") + if err := os.Symlink(realHome, linkedHome); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", linkedHome) + repo := committedRepo(t) + t.Chdir(repo) + + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(realHome, ".abcd", "history", "index.json")); err != nil { + t.Fatalf("a home reached through a link must still be registered: %v; notes = %v", err, res.Notes) + } +} + +// managedRepoWithCommit is a managed repository (a marker block) with a root +// commit, so the detector reaches the history-store checks with a sha. +func managedRepoWithCommit(t *testing.T) string { + t.Helper() + repo := committedRepo(t) + body := "# Project\n\n\nx\n\n" + if err := os.WriteFile(filepath.Join(repo, "CLAUDE.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return repo +} diff --git a/internal/core/ahoy/home_link_writers_test.go b/internal/core/ahoy/home_link_writers_test.go new file mode 100644 index 000000000..32f64b3d7 --- /dev/null +++ b/internal/core/ahoy/home_link_writers_test.go @@ -0,0 +1,44 @@ +//go:build unix + +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMachineWritesRefuseASymlinkedAbcdHome: the model-tier routing table and +// the status-line setting are written into ~/.abcd, and each is read back +// through a guard that refuses a symlinked ~/.abcd — so a write through the +// link would land in a dotfiles repository AND be a file its own reader +// refuses. Each writer refuses loudly, names the link, and leaves nothing +// behind it (iss-2609281017573862, iss-2609260958587561's shape). +func TestMachineWritesRefuseASymlinkedAbcdHome(t *testing.T) { + writers := map[string]func(a *applyCtx){ + "oracle routing": func(a *applyCtx) { a.writeMachineRouting([]byte("{}\n")) }, + "status line": func(a *applyCtx) { a.wireStatusLine(harnessSettings{}, "/example/abcd", nil, "") }, + } + for name, write := range writers { + t.Run(name, func(t *testing.T) { + home, _ := setupHermetic(t) + dotfiles := t.TempDir() + if err := os.RemoveAll(filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(dotfiles, filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + a := &applyCtx{} + write(a) + joined := strings.Join(a.notes, "\n") + if !strings.Contains(joined, "~/.abcd is a symlink") { + t.Errorf("the %s write must refuse naming the symlinked ~/.abcd; notes = %q", name, a.notes) + } + if entries, _ := os.ReadDir(dotfiles); len(entries) != 0 { + t.Fatalf("the %s write left %d file(s) behind the link, first %q", name, len(entries), entries[0].Name()) + } + }) + } +} diff --git a/internal/core/ahoy/home_scope_test.go b/internal/core/ahoy/home_scope_test.go index f82518bad..d1a52e936 100644 --- a/internal/core/ahoy/home_scope_test.go +++ b/internal/core/ahoy/home_scope_test.go @@ -152,3 +152,75 @@ func TestInstallSendsARefusedHomeToTheRightRemedy(t *testing.T) { t.Errorf("a refused home must not be answered with the re-authenticate remedy; notes = %v", res.Notes) } } + +// symlinkAbcdHome replaces home's ~/.abcd with a symlink to a directory +// elsewhere holding the same records — the dotfiles shape — and returns that +// directory, so a test can see what a write through the link would land in. +func symlinkAbcdHome(t *testing.T, home string) string { + t.Helper() + elsewhere := t.TempDir() + plantHomeRecords(t, elsewhere) + if err := os.RemoveAll(filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(elsewhere, ".abcd"), filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + return filepath.Join(elsewhere, ".abcd") +} + +// TestHomeScopedRecordsRefuseASymlinkedAbcdHome is iss-2609281017573862: the +// rules loader refuses a rules.json behind a symlinked ~/.abcd, and the two +// records that decide which binary the hooks run and which binary is promoted +// onto PATH were read through the very same link. Well-formed records, owned +// and owner-only: the ONLY defect is that ~/.abcd is a link. +func TestHomeScopedRecordsRefuseASymlinkedAbcdHome(t *testing.T) { + home, _ := setupHermetic(t) + target := symlinkAbcdHome(t, home) + t.Chdir(adoptableRepo(t)) + assertHomeScopeRefused(t, "reaches its records through a symlinked ~/.abcd") + + _, problem := cacheBindingProblem("/harness/data") + if !strings.Contains(problem, "~/.abcd is a symlink") { + t.Errorf("the binding refusal must name the symlinked ~/.abcd, got %q", problem) + } + + // The writer refuses too: a record written through the link is one every + // reader then refuses, and it lands in whatever the link points at. + if err := os.Remove(filepath.Join(target, "path-entry")); err != nil { + t.Fatal(err) + } + if err := writePathEntry(filepath.Join(t.TempDir(), "abcd"), strings.Repeat("b", 64), ""); err == nil { + t.Error("writePathEntry wrote through a symlinked ~/.abcd") + } + if _, err := os.Lstat(filepath.Join(target, "path-entry")); !os.IsNotExist(err) { + t.Errorf("a path-entry landed behind the symlinked ~/.abcd: %v", err) + } +} + +// TestInstallSendsASymlinkedAbcdHomeToTheRightRemedy: a symlinked ~/.abcd is a +// refused home like the two shapes above, and its remedy is replacing the +// link, never "start a session with network access" — the hooks decline to +// write the attestation through the link for the same reason this run declines +// to read it. +func TestInstallSendsASymlinkedAbcdHomeToTheRightRemedy(t *testing.T) { + setupUserScope(t) + repo := adoptableRepo(t) + home := t.TempDir() + symlinkAbcdHome(t, home) + seedDataCache(t, cacheArtefact) + t.Chdir(repo) + t.Setenv("HOME", home) + + res, err := Install(repo, installOpts(), RefusingPrompter{}) + if err != nil { + t.Fatal(err) + } + joined := notesJoined(res.Notes) + if !strings.Contains(joined, "~/.abcd is a symlink") { + t.Errorf("the refusal must name the symlinked ~/.abcd; notes = %v", res.Notes) + } + if strings.Contains(joined, "Start a session with network access") { + t.Errorf("a symlinked ~/.abcd must not be answered with the re-authenticate remedy; notes = %v", res.Notes) + } +} diff --git a/internal/core/ahoy/oracle_routing.go b/internal/core/ahoy/oracle_routing.go index 1d747b2c1..e509b2347 100644 --- a/internal/core/ahoy/oracle_routing.go +++ b/internal/core/ahoy/oracle_routing.go @@ -124,6 +124,12 @@ func (a *applyCtx) writeMachineRouting(body []byte) { a.refuse("the model-tier routing was not written: the home directory could not be resolved, so ~/.abcd/oracle-routing.json has nowhere to go.") return } + // The resolver refuses a machine table behind a symlinked ~/.abcd, so a + // write through the link would land wherever it points and never be read. + if err := fsutil.HomeScopeLink(userHome(), ".abcd/"+layered.OracleRouting.MachineRel); err != nil { + a.refuse("the model-tier routing was not written: " + err.Error() + ".") + return + } if !absent(p) { a.refuse("the model-tier routing was not written: ~/.abcd/oracle-routing.json appeared while the question was open, and it is left as it is.") return diff --git a/internal/core/ahoy/owned_copy.go b/internal/core/ahoy/owned_copy.go index affbd9642..f7414db34 100644 --- a/internal/core/ahoy/owned_copy.go +++ b/internal/core/ahoy/owned_copy.go @@ -3,6 +3,7 @@ package ahoy import ( "crypto/sha256" "encoding/hex" + "errors" "os" "path/filepath" "runtime" @@ -68,20 +69,45 @@ func cacheMetaPath(dataDir string) string { // record" — and cacheBindingProblem renders the reason, because an operator // told only "start a session with network access" would re-run hooks that // decline to write the record for the same reason. +// +// A ~/.abcd that is a SYMLINK is refused here too, through the rule every +// home-scoped reader and writer applies (fsutil.HomeScopeLink), the one AGENTS.md +// states for the rules loader: a dotfiles-symlinked ~/.abcd hosts no record abcd +// trusts, so it neither vouches for which binary the hooks run nor binds the +// cache a release binary is promoted out of (iss-2609281017573862). The hook +// shims and hooks/bootstrap.sh refuse the same link. func homeScope() (string, string) { + home, err := homeScopeErr() + if err != nil { + return "", err.Error() + } + return home, "" +} + +// homeScopeErr is homeScope with the refusal as an error, so a caller can tell +// the symlinked ~/.abcd (errors.Is fsutil.ErrHomeScopeSymlinked), whose remedy +// is replacing the link, from a HOME that names no home at all. +func homeScopeErr() (string, error) { home, err := os.UserHomeDir() if err != nil || home == "" { - return "", "no home directory is resolved (HOME is unset), so there is no ~/.abcd for the record to live in" + return "", errors.New("no home directory is resolved (HOME is unset), so there is no ~/.abcd for the record to live in") } if !filepath.IsAbs(home) { - return "", "HOME is a relative path, so ~/.abcd resolves against whatever directory the verb happens to run in rather than naming one home" + return "", errors.New("HOME is a relative path, so ~/.abcd resolves against whatever directory the verb happens to run in rather than naming one home") } if cwd, err := os.Getwd(); err == nil && insideRepo(cwd, home) && resolvePath(cwd) != resolvePath(home) { - return "", "HOME lies inside the repository the verb is running against, so its ~/.abcd records would be repository content rather than a write into the caller's own home" + return "", errors.New("HOME lies inside the repository the verb is running against, so its ~/.abcd records would be repository content rather than a write into the caller's own home") } - return home, "" + if err := fsutil.HomeScopeLink(home, pathEntryRel); err != nil { + return "", err + } + return home, nil } +// pathEntryRel is the provenance record's place in the home, in the slash form +// the home-scoped primitives take. +const pathEntryRel = ".abcd/path-entry" + // userPathEntryPath is the PATH-copy provenance record, home-scoped and // abcd-owned (~/.abcd/path-entry, alongside the history store). It deliberately // does NOT live in the harness data dir: CLAUDE_PLUGIN_DATA is exported only to @@ -96,7 +122,7 @@ func userPathEntryPath() string { if refused != "" { return "" } - return filepath.Join(home, ".abcd", "path-entry") + return filepath.Join(home, filepath.FromSlash(pathEntryRel)) } // cacheRecordedSHA reads the cache meta's binary_sha256, or "" when the record @@ -143,11 +169,11 @@ type pathEntryRecord struct { // (iss-2609091927085132); that is NOT the accepted same-uid residual // (iss-2609012039107700), which this check neither closes nor claims to. func readPathEntry() (pathEntryRecord, bool) { - path := userPathEntryPath() - if path == "" { + home, refused := homeScope() + if refused != "" { return pathEntryRecord{}, false } - raw, _, err := fsutil.ReadDeclaration(path, maxPathEntryBytes) + raw, _, err := fsutil.ReadHomeDeclaration(home, pathEntryRel, maxPathEntryBytes) if err != nil { return pathEntryRecord{}, false } @@ -179,10 +205,11 @@ func readPathEntry() (pathEntryRecord, bool) { // resolvePluginRoot reads it as a candidate. An empty pluginRoot records no // such line — a degraded install has no root to record. func writePathEntry(target, shaHex, pluginRoot string) error { - path := userPathEntryPath() - if path == "" { - return os.ErrNotExist + home, err := homeScopeErr() + if err != nil { + return err } + path := filepath.Join(home, filepath.FromSlash(pathEntryRel)) body := "path=" + target + "\nbinary_sha256=" + shaHex + "\n" if pluginRoot != "" { body += "plugin_root=" + pluginRoot + "\n" @@ -271,9 +298,49 @@ func IsOwnedPathCopy(target string) bool { return isOwnedCopyFile(target) } +// coldCacheRemedy is the one command an operator runs when no verified release +// artefact is available to install from (iss-2609100506263330). "A session +// whose hooks provisioned the cache" is a condition the operator cannot check, +// create or observe; this is a command they can type. The one-liner fetches the release binary and verifies it against that +// release's own checksums.txt, writes it to ~/.local/bin/abcd and records it in +// ~/.abcd/path-entry, which is exactly the owned-copy shape `ahoy install` then +// adopts where it stands. One string, shared by the install refusal and the +// symlink.legacy fix hint, so the two cannot drift apart. +const coldCacheRemedy = "Install a verified copy first with the install one-liner in the README " + + "(https://github.com/intentdriven/abcd#install): it downloads the release binary, checks it against that release's own checksums.txt, " + + "writes it to ~/.local/bin/abcd and records it in ~/.abcd/path-entry. Then re-run `abcd ahoy install`, which adopts it." + +// coldCacheRefusal is the install note for a run that had no verified artefact +// to install from. It says what was left at target and why, in the words the +// entry's shape calls for, and always ends in coldCacheRemedy. why is the +// data-dir story (which sources were tried, or why the cache found was not +// bound). +func coldCacheRefusal(target string, kind binTargetKind, pluginRoot, why string) string { + at := displayPath(target) + lead := "no PATH entry was written at " + at + switch kind { + case binTargetOwnedSymlink: + switch { + case linkIsDangling(target): + lead = "abcd's own PATH entry " + at + " points at a binary that is gone, and it was left as it is" + default: + if dest, err := os.Readlink(target); err == nil && resolveSymlinkDest(target, dest) == resolvePath(pluginBinaryPath(pluginRoot)) { + lead = "the PATH entry " + at + " is a symlink into the plugin root and was left as it is; it works now and stops working when a plugin update replaces that directory" + } else { + lead = "the PATH entry " + at + " pins an earlier plugin vintage and was left as it is" + } + } + case binTargetDevShim: + lead = "the dev shim at " + at + " was left as it is rather than replaced with a pinned entry" + } + return lead + ": no verified release artefact is available in the persistent plugin data directory (" + why + + "), and abcd does not write a symlink into the plugin root in its place, because that link stops working when a plugin update replaces the directory. " + + coldCacheRemedy +} + // ownedCopySourceReady reports whether a verified cache artefact exists to copy // from — the precondition for installing (or healing to) an owned copy. When it -// does not hold, install degrades loudly to the spc-21 pinned symlink. The data +// does not hold, install writes no entry and refuses with coldCacheRemedy. The data // dir is resolved for pluginRoot (a hook's environment, or the root's stamp // from a terminal); cwd is the repository the verb runs against, which is what // dataDirHazard needs to judge the resolved directory's shape. diff --git a/internal/core/ahoy/owned_copy_test.go b/internal/core/ahoy/owned_copy_test.go index 3ad38c1bc..dc0208bfe 100644 --- a/internal/core/ahoy/owned_copy_test.go +++ b/internal/core/ahoy/owned_copy_test.go @@ -208,11 +208,13 @@ func TestInstallRefusesCorruptCacheArtefact(t *testing.T) { } } -// TestInstallWithoutCacheDegradesLoudlyToSymlink: no persistent data dir means -// no artefact whose provenance can be recorded, so install falls back to the -// spc-21 pinned symlink — and says so, never silently. -func TestInstallWithoutCacheDegradesLoudlyToSymlink(t *testing.T) { - home, pluginRoot := setupUserScope(t) +// TestInstallWithoutCacheRefusesLoudly: no persistent data dir means no +// artefact whose provenance can be recorded, so install writes no PATH entry +// — never a symlink into the plugin root, which the next plugin update strands +// (iss-2609100506263330) — and says so, naming the command to run first. +func TestInstallWithoutCacheRefusesLoudly(t *testing.T) { + home, _ := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) repo := t.TempDir() @@ -225,17 +227,12 @@ func TestInstallWithoutCacheDegradesLoudlyToSymlink(t *testing.T) { t.Fatal(err) } target := filepath.Join(binDir, "abcd") - fi, err := os.Lstat(target) - if err != nil || fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("without a cache the entry must be the spc-21 symlink: %v (%v)", fi, err) - } - dest, _ := os.Readlink(target) - if resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { - t.Errorf("symlink dest = %q, want %q", dest, pluginBinaryPath(pluginRoot)) + if fi, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("without a cache no PATH entry may be written: %v (%v)", fi, err) } joined := notesJoined(res.Notes) - if !strings.Contains(joined, "symlink") { - t.Errorf("the degradation must be named on the result, never silent; notes = %v", res.Notes) + if !strings.Contains(joined, installRemedyAnchor) { + t.Errorf("the refusal must be named on the result with the command to run first, never silent; notes = %v", res.Notes) } // The note says which sources were tried — the environment and the root's // stamp — so a reader following the documented terminal instruction learns @@ -542,10 +539,10 @@ func TestInstallFromTerminalReachesCacheThroughRootStamp(t *testing.T) { // TestInstallDegradesLoudlyWhenRootStampIsInvalid: the stamp is a route to // the cache, never a trust claim, so a recorded path that is not an existing // absolute directory — or one that holds no verified artefact — is not -// followed. The install still degrades to the symlink, loudly, and the note -// names both sources it tried, so the reader learns why a documented -// instruction did not land the owned copy. -func TestInstallDegradesLoudlyWhenRootStampIsInvalid(t *testing.T) { +// followed. The install writes no entry, loudly, and the note names both +// sources it tried, so the reader learns why a documented instruction did not +// land the owned copy. +func TestInstallRefusesLoudlyWhenRootStampIsInvalid(t *testing.T) { cases := map[string]func(t *testing.T) string{ "relative": func(t *testing.T) string { return "relative/data" }, "absent": func(t *testing.T) string { return filepath.Join(t.TempDir(), "gone") }, @@ -554,6 +551,7 @@ func TestInstallDegradesLoudlyWhenRootStampIsInvalid(t *testing.T) { for name, recorded := range cases { t.Run(name, func(t *testing.T) { home, pluginRoot := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) if err := os.WriteFile(filepath.Join(pluginRoot, ".data-dir"), []byte("data_dir="+recorded(t)+"\n"), 0o644); err != nil { @@ -569,9 +567,8 @@ func TestInstallDegradesLoudlyWhenRootStampIsInvalid(t *testing.T) { t.Fatal(err) } target := filepath.Join(binDir, "abcd") - fi, err := os.Lstat(target) - if err != nil || fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("an unusable stamp must degrade to the spc-21 symlink: %v (%v)", fi, err) + if fi, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("an unusable stamp must leave no PATH entry: %v (%v)", fi, err) } joined := notesJoined(res.Notes) for _, want := range []string{"CLAUDE_PLUGIN_DATA", ".data-dir"} { diff --git a/internal/core/ahoy/path_entry_record_test.go b/internal/core/ahoy/path_entry_record_test.go index db36370f2..132e1f8a3 100644 --- a/internal/core/ahoy/path_entry_record_test.go +++ b/internal/core/ahoy/path_entry_record_test.go @@ -70,36 +70,37 @@ func repoForInstall(t *testing.T) string { return repo } -// TestPinnedSymlinkInstallRecordsThePathEntry is the headline defect. With no -// verified cache artefact to copy from, install degrades — loudly, and by -// design — to the spc-21 pinned symlink. That symlink is a working `abcd` on -// PATH, and docs/how-to/install.md routes readers to this very path, yet -// nothing recorded it, so every hook refused the binary the guide had just told -// the user to install. +// TestPinnedSymlinkInstallRecordsThePathEntry is the headline defect. The +// spc-21 pinned symlink an earlier release wrote into the plugin root is a +// working `abcd` on PATH, yet nothing recorded it, so every hook refused the +// binary the guide had just told the user to install. With no verified cache +// artefact to replace it with, install leaves the working pin where it stands +// (iss-2609100506263330) — and records it, so the hooks accept it meanwhile. func TestPinnedSymlinkInstallRecordsThePathEntry(t *testing.T) { home, pluginRoot := setupUserScope(t) + coldCache(t) binDir := filepath.Join(home, ".local", "bin") t.Setenv("PATH", binDir) - // No seedDataCache: this is the documented degraded path. + target := filepath.Join(binDir, "abcd") + linkOwned(t, target, pluginRoot) res, err := Install(repoForInstall(t), installOpts(), RefusingPrompter{}) if err != nil { t.Fatal(err) } - target := filepath.Join(binDir, "abcd") fi, err := os.Lstat(target) if err != nil { - t.Fatalf("install did not create %s: %v (notes %v)", target, err, res.Notes) + t.Fatalf("install removed %s: %v (notes %v)", target, err, res.Notes) } if fi.Mode()&os.ModeSymlink == 0 { - t.Fatalf("this test must exercise the degraded pinned-symlink path; got a regular file") + t.Fatalf("this test must exercise the pinned-symlink path; got a regular file") } if dest, _ := os.Readlink(target); resolveSymlinkDest(target, dest) != resolvePath(pluginBinaryPath(pluginRoot)) { t.Fatalf("the pinned symlink does not point at the plugin binary") } assertShimWouldAccept(t, binDir) - if res.Status != "clean" { - t.Errorf("status = %q (remaining %v), want clean — an install the hooks accept has nothing left over", res.Status, res.Remaining) + if containsString(res.Remaining, "symlink.unrecorded") { + t.Errorf("the pin is still unrecorded after the install that records it; remaining %v", res.Remaining) } } @@ -157,9 +158,11 @@ func TestUnrecordedOwnedEntryIsItsOwnGap(t *testing.T) { still func(t *testing.T, target string) bool }{ { + // On a cold cache: with a verified artefact, install heals the pin + // to the owned copy, which is a change of shape by design. name: "pinned symlink", opts: installOpts, - plant: func(t *testing.T, target, pluginRoot string) { linkOwned(t, target, pluginRoot) }, + plant: func(t *testing.T, target, pluginRoot string) { coldCache(t); linkOwned(t, target, pluginRoot) }, still: func(t *testing.T, target string) bool { fi, err := os.Lstat(target) return err == nil && fi.Mode()&os.ModeSymlink != 0 diff --git a/internal/core/ahoy/statusline_apply.go b/internal/core/ahoy/statusline_apply.go index a4744ad16..beb8efa29 100644 --- a/internal/core/ahoy/statusline_apply.go +++ b/internal/core/ahoy/statusline_apply.go @@ -155,7 +155,13 @@ func (a *applyCtx) wireStatusLine(hs harnessSettings, entry string, switches map a.refuse("the status line was not wired: the home directory could not be resolved, so " + statusline.SettingsDisplay + " has nowhere to go.") return } - settingBytes, created, err := statusLineSettingBytes(settingPath, switches, previous) + // The setting's reader refuses a file behind a symlinked ~/.abcd, so a write + // through the link would land wherever the link points and never be read. + if err := fsutil.HomeScopeLink(userHome(), statusline.SettingsRelPath); err != nil { + a.refuse("refused to wire the status line: " + err.Error() + "; nothing was written.") + return + } + settingBytes, created, err := statusLineSettingBytes(userHome(), switches, previous) if err != nil { a.refuse("refused to wire the status line: " + errText(err) + "; nothing was written.") return @@ -301,8 +307,8 @@ func uninstallStatusLine() StatusLineReceipt { // is an error here rather than a file to fill: writing into a file that is // not the caller's word would be taking somebody else's configuration as // theirs. -func statusLineSettingBytes(path string, switches map[statusline.ElementKey]bool, previous string) (data []byte, created bool, err error) { - raw, err := readUserStatusLineSetting(path) +func statusLineSettingBytes(home string, switches map[statusline.ElementKey]bool, previous string) (data []byte, created bool, err error) { + raw, err := readUserStatusLineSetting(home) switch { case err != nil: return nil, false, err @@ -337,8 +343,8 @@ func statusLineSettingBytes(path string, switches map[statusline.ElementKey]bool // absent file; a file the guard refuses is an error naming the reason, never // a silent fallback, because what the callers take from the file is a shell // command the harness will run. -func readUserStatusLineSetting(path string) ([]byte, error) { - raw, why, err := statusline.ReadSettingsFile(path) +func readUserStatusLineSetting(home string) ([]byte, error) { + raw, why, err := statusline.ReadSettingsFile(home) if err != nil { return nil, err } @@ -355,11 +361,11 @@ func readUserStatusLineSetting(path string) ([]byte, error) { // that back to the harness is the recursion the wiring refuses to record, // from the other end. func recordedPreviousCommand() (string, error) { - path := userStatusLineSettingPath() - if path == "" { + home := userHome() + if home == "" { return "", nil } - raw, err := readUserStatusLineSetting(path) + raw, err := readUserStatusLineSetting(home) if err != nil || raw == nil { return "", err } diff --git a/internal/core/ahoy/statusline_detect.go b/internal/core/ahoy/statusline_detect.go index 9c588bdfc..ad8e81992 100644 --- a/internal/core/ahoy/statusline_detect.go +++ b/internal/core/ahoy/statusline_detect.go @@ -292,13 +292,22 @@ func statusCommandFor(entry string) string { // userStatusLineSettingPath is ~/.abcd/statusline.json, or "" when no home // resolves. func userStatusLineSettingPath() string { - home, err := os.UserHomeDir() - if err != nil || home == "" { + home := userHome() + if home == "" { return "" } return filepath.Join(home, filepath.FromSlash(statusline.SettingsRelPath)) } +// userHome is the caller's home directory, or "" when none resolves. +func userHome() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + // detectStatusLine raises the status-line gaps for one read of the harness. // // The OFFER is raised while the harness has a line that is not abcd's — absent diff --git a/internal/core/ahoy/store.go b/internal/core/ahoy/store.go index a680849d4..50235b90f 100644 --- a/internal/core/ahoy/store.go +++ b/internal/core/ahoy/store.go @@ -251,8 +251,8 @@ func scanPathEntries(pluginRoot string) []pathEntry { // cannot be anyone's install. // // Stat FOLLOWS the link, so this is asked of EVERY entry, ours or not: a -// foreign dangling `abcd` still occupies the name and still shadows the entries -// behind it, and a check that only looked at our own would report it as nothing +// foreign dangling `abcd` still occupies the name — it runs nothing, the shell +// skips it — and a check that only looked at our own would report it as nothing // at all. A stat error other than not-exist is not proof of a dangling link, so // it reads as healthy rather than manufacturing a gap. func linkIsDangling(path string) bool { @@ -273,7 +273,8 @@ func ownedPathEntry(pluginRoot string) (pathEntry, bool) { } // danglingPathEntry returns the first abcd-owned entry on PATH whose target has -// gone — a link that shadows whatever else on PATH would have answered. +// gone — a link that runs nothing now and answers whatever reappears at its +// target. func danglingPathEntry(pluginRoot string) (pathEntry, bool) { for _, e := range scanPathEntries(pluginRoot) { if e.owned() && e.dangling { @@ -283,6 +284,24 @@ func danglingPathEntry(pluginRoot string) (pathEntry, bool) { return pathEntry{}, false } +// recordedDanglingPathEntry returns the first `abcd` on PATH that is a dangling +// link ~/.abcd/path-entry names — the one owned shape that needs no plugin root +// to recognise, so it is found when none resolves. +func recordedDanglingPathEntry() (string, bool) { + for _, dir := range pathDirs() { + candidate := filepath.Join(dir, binName) + // Lstat first: recordedDanglingLink reads an ABSENT path as dangling, + // and a record naming a path nothing occupies is no entry at all. + if fi, err := os.Lstat(candidate); err != nil || fi.Mode()&os.ModeSymlink == 0 { + continue + } + if recordedDanglingLink(candidate) { + return candidate, true + } + } + return "", false +} + // effectiveBinTarget is the PATH entry every verb acts on: an existing owned // install (adopted where it stands), else the default target. func effectiveBinTarget(pluginRoot string) string { @@ -371,7 +390,17 @@ func describeEntry(e pathEntry) string { // shadowMessage is the single wording for a shadowed install, shared by the // detection gap and the install-time note so the two can never drift. +// +// A link whose target is gone gets its own wording: it runs nothing — the +// shell skips an entry it cannot execute — so it neither "is what runs" nor is +// "a binary" of anyone's. What is still true of it is that it answers again the +// moment something reappears at the path it points to. func shadowMessage(e pathEntry, target string) string { + if e.dangling { + return displayPath(e.path) + " (" + describeEntry(e) + ") comes before " + + displayPath(target) + " on PATH. It runs nothing — the shell skips a link whose target is gone — " + + "but whatever reappears at the path it points to would run instead of " + displayPath(target) + ". Remove it." + } return displayPath(e.path) + " (" + describeEntry(e) + ") comes before " + displayPath(target) + " on PATH, so it is what runs when you type `abcd`. " + "Remove or rename it, or install ahead of it with `abcd ahoy install --bin-dir `. " + @@ -496,6 +525,9 @@ func classifyBinTarget(target, pluginRoot string) binTargetKind { if supersededSiblingDest(target, dest, pluginRoot) { return binTargetOwnedSymlink } + if recordedDanglingLink(target) { + return binTargetOwnedSymlink + } return binTargetForeign } if isDevShimFile(target) { @@ -535,6 +567,25 @@ func strandedSiblingDest(symlinkPath, dest, pluginRoot string) bool { return err == nil && !present } +// recordedDanglingLink reports whether the symlink at target resolves to +// nothing AND ~/.abcd/path-entry names this very entry (iss-2609100506263330). +// The sibling rules above recognise the stranded link only while the plugin +// root it pointed into still shares a parent with the current one; once that +// no longer holds, the link abcd wrote and recorded would otherwise read as +// foreign, and the repair its own record entitles it to would never be offered. +// +// The record is read exactly as the hook shims read it — through +// readPathEntry's fsutil.ReadDeclaration, so a record another uid owns or that +// group or other can write vouches for nothing — and it claims the link only +// while the link is DANGLING: a recorded link that resolves to a live binary +// somewhere else was retargeted by something other than abcd, and stays +// foreign. A dangling link runs nothing and the shims' `command -v` never +// yields it, so claiming it widens no execution path; it lets `ahoy install` +// repair it and `ahoy uninstall` remove it with its record. +func recordedDanglingLink(target string) bool { + return linkIsDangling(target) && pathEntryNames(target) +} + // supersededSiblingDest is the live twin of strandedSiblingDest // (iss-2609161805447092): the destination is the binary of a sibling plugin // root that STILL EXISTS — the harness kept the old cache directory — while the @@ -597,13 +648,51 @@ func isDir(p string) bool { // ~/.abcd/history store // --------------------------------------------------------------------------- +// historyRelPath is the registry's directory relative to the caller's home. +const historyRelPath = ".abcd/history" + // historyRoot returns ~/.abcd/history. HOME is respected so tests can redirect. +// +// It is the registry's single chokepoint for the rule every reader and writer +// of ~/.abcd applies (fsutil.HomeScopeLink): a symlinked ~/.abcd, or a +// symlinked ~/.abcd/history, is refused with a *fsutil.HomeScopeLinkError +// naming the link, so no caller reads a registry through the link or creates +// one wherever it points (iss-2609281129171021). Every caller refuses on the +// error; stepHistory reports it and writes nothing. func historyRoot() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(home, ".abcd", "history"), nil + if err := fsutil.HomeScopeLink(home, historyRelPath+"/index.json"); err != nil { + return "", err + } + return filepath.Join(home, filepath.FromSlash(historyRelPath)), nil +} + +// ensureHistoryRoot is historyRoot for a writer: it creates ~/.abcd/history one +// real directory at a time and proves every level, so a link planted after +// historyRoot's check is refused rather than followed (os.MkdirAll would follow +// it). The walk starts at home with its symlinks resolved, because home itself +// reached through a link (/home -> /usr/home) is the machine's layout and is +// never judged; ~/.abcd and ~/.abcd/history are. +func ensureHistoryRoot() (string, error) { + root, err := historyRoot() + if err != nil { + return "", err + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base, err := filepath.EvalSymlinks(home) + if err != nil { + return "", err + } + if err := fsutil.EnsureRealDirAll(base, historyRelPath, 0o755); err != nil { + return "", err + } + return root, nil } // historyIndex is the ~/.abcd/history/index.json registry. @@ -787,13 +876,10 @@ var beforeHistoryIndexCreateHook func() // any prompting before acquiring it and re-check the answer-relevant state inside // fn after re-loading. func withHistoryLock(fn func() error) error { - root, err := historyRoot() + root, err := ensureHistoryRoot() if err != nil { return err } - if err := os.MkdirAll(root, 0o755); err != nil { - return err - } lockPath := filepath.Join(root, historyLockFilename) return fsutil.WithFileLock(lockPath, historyLockTimeout, func() error { if afterHistoryReloadHook != nil { @@ -813,13 +899,10 @@ func withHistoryLock(fn func() error) error { // sees either no file yet or the finished index — never a 0-byte one that would // make a concurrent loadHistoryIndex parse-fail and drop its own registration. func bootstrapHistory() (bool, error) { - root, err := historyRoot() + root, err := ensureHistoryRoot() if err != nil { return false, err } - if err := os.MkdirAll(root, 0o755); err != nil { - return false, err - } path := filepath.Join(root, "index.json") // Fast path: already seeded (the common idempotent re-run). if _, err := os.Stat(path); err == nil { diff --git a/internal/core/ahoy/swallowed_writes_test.go b/internal/core/ahoy/swallowed_writes_test.go index 12d79f204..25a91567c 100644 --- a/internal/core/ahoy/swallowed_writes_test.go +++ b/internal/core/ahoy/swallowed_writes_test.go @@ -99,7 +99,9 @@ func TestSessionStoreFailureIsNoted(t *testing.T) { } a := &applyCtx{cwd: t.TempDir(), approved: map[GapCategory]bool{SafeAutocreate: true}} a.stepHistory() - if !notesCarryAll(a.notes, "session store", "not a directory") { + // The registry is created one real directory at a time + // (fsutil.EnsureRealDirAll), so the reason is the level it refused. + if !notesCarryAll(a.notes, "session store", "~/.abcd", "not a real directory") { t.Errorf("no note says the session store was not created, and why; notes: %v", a.notes) } }) diff --git a/internal/core/banlist/public_test.go b/internal/core/banlist/public_test.go index 999bf449e..dcf251cbe 100644 --- a/internal/core/banlist/public_test.go +++ b/internal/core/banlist/public_test.go @@ -133,8 +133,10 @@ func TestAddPublicEntryGatesUserFacingContent(t *testing.T) { // The public config's roots are ["docs", "README.md"]; both must resolve now // that an unresolvable configured root fails loud (GitHub #360). write("README.md", "# readme\n") - // Its name_roots must resolve too (iss-279). - for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md"} { + // Its name_roots must resolve too (iss-279), and the role ban's extra_roots + // (itd-2609212137129937). + for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md", + "commands/README.md", ".abcd/rules.json", "internal/core/rules/defaults/rules.json"} { write(r, "# t\n") } provisionDocsLintTrees(t, cfg, docs) @@ -438,8 +440,10 @@ func TestAddPublicIsCaseInsensitiveLikeTheCuratedEntries(t *testing.T) { if err := os.WriteFile(filepath.Join(docs, "README.md"), []byte("# readme\n"), 0o644); err != nil { t.Fatal(err) } - // Its name_roots must resolve too (iss-279). - for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md"} { + // Its name_roots must resolve too (iss-279), and the role ban's extra_roots + // (itd-2609212137129937). + for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md", + "commands/README.md", ".abcd/rules.json", "internal/core/rules/defaults/rules.json"} { p := filepath.Join(docs, filepath.FromSlash(r)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) diff --git a/internal/core/credential/credential.go b/internal/core/credential/credential.go index d245f2514..e38d407f3 100644 --- a/internal/core/credential/credential.go +++ b/internal/core/credential/credential.go @@ -10,8 +10,10 @@ // // The file is refused, loudly and never treated as absent, unless it is a // regular file (not a symlink), owned by the caller, and readable and writable -// by the owner alone (mode 0600 or tighter): a secret that group or other can -// read is not kept, and one that somebody else wrote is not the caller's. +// by the owner alone (mode 0600 or tighter), in a ~/.abcd that is not itself a +// symlink: a secret that group or other can read is not kept, one that +// somebody else wrote is not the caller's, and one behind a symlinked ~/.abcd +// lives in whatever the link points at. // // The value never leaves Resolve except as its return: no error formats it, // nothing logs it, and nothing here writes to the repository. The one write is @@ -79,6 +81,10 @@ type machine struct{ home string } // developer-identity path reaches output. const StorePath = "~/.abcd/" + StoreFileName +// storeRel is the store's place in the home, in the slash form the +// home-scoped primitives take. +const storeRel = ".abcd/" + StoreFileName + func (m machine) Resolve(name string) (string, error) { if !nameRe.MatchString(name) { return "", errors.New("credential: the name is not a plain credential name") @@ -108,6 +114,13 @@ func readStore(home string) (map[string]string, error) { } return nil, fmt.Errorf("credential: %s could not be examined, so it is not read", StorePath) } + // A store that is there behind a symlinked ~/.abcd sits wherever the link + // points — a dotfiles checkout, typically — and is refused as the rules + // loader refuses a rules.json there; a symlinked ~/.abcd holding no store + // is no store (the Lstat above). + if err := fsutil.HomeScopeLink(home, storeRel); err != nil { + return nil, fmt.Errorf("credential: %s is not read: %v", StorePath, err) + } if !fi.Mode().IsRegular() { return nil, fmt.Errorf("credential: %s is not a regular file (a symlink is never followed), so it is not read", StorePath) } @@ -116,10 +129,12 @@ func readStore(home string) (map[string]string, error) { } // ReadDeclaration re-checks the leaf on its own descriptor and refuses a // file this uid does not own. - raw, refusal, err := fsutil.ReadDeclaration(p, maxStoreBytes) + raw, refusal, err := fsutil.ReadHomeDeclaration(home, storeRel, maxStoreBytes) switch { case refusal == fsutil.DeclarationAbsent && errors.Is(err, os.ErrNotExist): return map[string]string{}, nil + case refusal == fsutil.DeclarationBehindSymlink: + return nil, fmt.Errorf("credential: %s is not read: %v", StorePath, err) case refusal == fsutil.DeclarationForeignOwner: return nil, fmt.Errorf("credential: %s is not owned by you, so it is not read", StorePath) case err != nil: @@ -154,8 +169,10 @@ const MaxValueBytes = 4096 // padded with white space or carrying a control, bidirectional or zero-width // character; a store Resolve would refuse (a symlink, group- or other- // readable, not owned by the caller, malformed), so a write never launders an -// unsafe file; and a name already holding a different value, because a stored -// secret is never replaced by a second one unasked. The same value already +// unsafe file; a ~/.abcd that is a symlink, because the secret would land +// wherever the link points (fsutil.HomeScopeLink); and a name already holding a +// different value, because a stored secret is never replaced by a second one +// unasked. The same value already // stored is no change (changed is false). The file is written atomically at // mode 0600, and ~/.abcd is created owner-only when it is absent. The read, // the change and the write hold the store's lock (fsutil.WithFileLock, beside @@ -170,6 +187,13 @@ func SetMachine(home, name, value string) (changed bool, err error) { if err := CheckValue(value); err != nil { return false, err } + // A ~/.abcd symlinked into a dotfiles checkout would carry the secret into + // that repository, and the store's own read refuses a file behind the link + // (iss-2609260958587561). Refused before anything is created, the lock + // included. + if err := fsutil.HomeScopeLink(home, storeRel); err != nil { + return false, fmt.Errorf("credential: nothing was written to %s: %v", StorePath, err) + } dir := filepath.Join(home, ".abcd") if err := os.MkdirAll(dir, 0o700); err != nil { return false, fmt.Errorf("credential: ~/.abcd could not be created, so nothing was written") diff --git a/internal/core/credential/home_link_test.go b/internal/core/credential/home_link_test.go new file mode 100644 index 000000000..a8266c0e3 --- /dev/null +++ b/internal/core/credential/home_link_test.go @@ -0,0 +1,99 @@ +//go:build unix + +package credential + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// dotfilesHome returns a home whose ~/.abcd is a symlink to a directory in a +// "dotfiles checkout", and that directory. +func dotfilesHome(t *testing.T) (home, dotfiles string) { + t.Helper() + home, dotfiles = t.TempDir(), t.TempDir() + if err := os.Symlink(dotfiles, filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + return home, dotfiles +} + +// TestSetMachineRefusesASymlinkedAbcdHome is iss-2609260958587561: a secret +// written through a ~/.abcd symlinked into a dotfiles checkout lands in that +// repository. The write is refused loudly, names the link and the repair, and +// leaves nothing behind the link — not the store and not its lock. +func TestSetMachineRefusesASymlinkedAbcdHome(t *testing.T) { + home, dotfiles := dotfilesHome(t) + changed, err := SetMachine(home, "openrouter", "sk-example-0123456789") + if err == nil || changed { + t.Fatalf("SetMachine wrote through a symlinked ~/.abcd: changed %v, err %v", changed, err) + } + for _, want := range []string{"~/.abcd is a symlink", "real directory"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal must say %q: %v", want, err) + } + } + if strings.Contains(err.Error(), "sk-example") { + t.Fatalf("the refusal echoed the value: %v", err) + } + entries, rerr := os.ReadDir(dotfiles) + if rerr != nil { + t.Fatal(rerr) + } + if len(entries) != 0 { + t.Fatalf("SetMachine left %d file(s) behind the link, first %q", len(entries), entries[0].Name()) + } +} + +// A store that is there behind a symlinked ~/.abcd is refused on read too, +// loudly; a symlinked ~/.abcd holding no store reads as no store. +func TestResolveRefusesAStoreBehindASymlinkedAbcdHome(t *testing.T) { + home, dotfiles := dotfilesHome(t) + if _, err := Machine(home).Resolve("openrouter"); err != ErrNotSet { + t.Fatalf("a symlinked ~/.abcd with no store must resolve to ErrNotSet, got %v", err) + } + if err := os.WriteFile(filepath.Join(dotfiles, StoreFileName), []byte(`{"openrouter":"sk-example-0123456789"}`), 0o600); err != nil { + t.Fatal(err) + } + v, err := Machine(home).Resolve("openrouter") + if err == nil || err == ErrNotSet || v != "" { + t.Fatalf("a store behind a symlinked ~/.abcd must be refused loudly: value %q, err %v", v, err) + } + if !strings.Contains(err.Error(), "~/.abcd is a symlink") { + t.Errorf("the refusal must name the link: %v", err) + } +} + +// The second half of iss-2609260958587561: the store's lock was created 0644, +// and a read-only descriptor holds LOCK_EX, so any local user could stall every +// write for its five-second wait. The lock is the owner's alone, and a lock an +// earlier version created 0644 is tightened on the next write. +func TestTheStoreLockIsOwnerOnly(t *testing.T) { + home := t.TempDir() + if _, err := SetMachine(home, "openrouter", "sk-example-0123456789"); err != nil { + t.Fatal(err) + } + lock := filepath.Join(home, ".abcd", storeLockFileName) + assertOwnerOnly(t, lock) + + if err := os.Chmod(lock, 0o644); err != nil { + t.Fatal(err) + } + if _, err := SetMachine(home, "other", "sk-example-9876543210"); err != nil { + t.Fatal(err) + } + assertOwnerOnly(t, lock) +} + +func assertOwnerOnly(t *testing.T, p string) { + t.Helper() + fi, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm()&0o077 != 0 { + t.Fatalf("%s is mode %04o; the lock must be the owner's alone", filepath.Base(p), fi.Mode().Perm()) + } +} diff --git a/internal/core/guard/braceexpand_test.go b/internal/core/guard/braceexpand_test.go index 909910bee..53ad01573 100644 --- a/internal/core/guard/braceexpand_test.go +++ b/internal/core/guard/braceexpand_test.go @@ -40,7 +40,9 @@ func TestBraceExpansionMatchesBash(t *testing.T) { {`{5..1}`, []string{"5", "4", "3", "2", "1"}}, {`{1..5..-2}`, []string{"1", "3", "5"}}, {`{a..e..2}`, []string{"a", "c", "e"}}, - {`${x:-a,b}`, []string{"${x:-a,b}"}}, + // One word, never split at the comma inside `${…}`; its value is + // unknown (iss-2609251824244354), so its known text is empty. + {`${x:-a,b}`, []string{""}}, {`"$"{a,b}`, []string{"$a", "$b"}}, {`{a,b}\}`, []string{"a}", "b}"}}, {`{a,\,b}`, []string{"a", ",b"}}, diff --git a/internal/core/guard/heredocpipe_test.go b/internal/core/guard/heredocpipe_test.go new file mode 100644 index 000000000..fc3907716 --- /dev/null +++ b/internal/core/guard/heredocpipe_test.go @@ -0,0 +1,54 @@ +package guard + +import ( + "strings" + "testing" +) + +// TestAHereDocBodyReachesTheCommandsItsOwnerPipesInto — review-drainG3 +// finding 2 (iss-2609270036253187). What an unquoted here-document's +// substitutions print is its command's standard input, and that command's +// output is what the pipe after it hands on: `cat < 0 && s.tokens[i-1] == "<<<") + }) +} + +// wordFeeds returns the commands whose output the words of s that keep holds, +// as one run of its list: every command the tokenizer emitted while it read +// s's words is a substitution in one of them, in word order, so the covering +// run stays one feed however many words carry one. nil when none does. +func wordFeeds(s segment, keep func(int) bool) []feed { if len(s.feeds) == 0 { return nil } @@ -248,12 +299,9 @@ func redirectedInput(s segment) []feed { } } } - for i, tok := range s.tokens { + for i := range s.tokens { tally(1) - switch { - case tok == "<<<": - add(i + 1) - case strings.HasPrefix(tok, "<<<"), strings.Contains(tok, procSubOperand): + if keep(i) { add(i) } } @@ -969,7 +1017,7 @@ func isPlainCommand(s string) bool { } for i := 0; i < len(s); i++ { switch s[i] { - case '\\', '$', '\'', '"', '#', unknownMark: + case '\\', '$', '\'', '"', '#', unknownMark, varMark: return false } } @@ -1192,6 +1240,12 @@ func pipesIntoInterpreter(psegs []segment) bool { if !nameCouldBeAny(s.tokens[a.idx], shellFamily) { continue } + // A variable's value as the program name is not read as a bare + // interpreter, as it is not read as a pkill (variableCarried): + // `sh -c "$GO build"` would warn for every string that runs one. + if anyProgram(s.tokens[a.idx]) && variableCarried(s, a.idx) { + continue + } if values, unresolved := shellCPayloads(s.tokens, []int{a.idx}); len(values) == 0 && !unresolved { return true } @@ -1208,15 +1262,18 @@ func pipesIntoInterpreter(psegs []segment) bool { // a here-string; or a shell or `source` handed a process substitution as its // script, which is the same stream behind a file name (`bash <(curl …)`, // `bash < <(curl …)`, which the tokenizer reads alike). A name a substitution -// prints can be any shell. +// prints can be any shell. A script operand that is a variable's value is +// not read as a stream: a stream path in a variable is data an earlier +// command carried (variableCarried). func readsScriptStream(s segment) bool { for _, a := range commandSites(s) { tok := s.tokens[a.idx] args := s.tokens[a.idx+1:] - if nameCouldBeAny(tok, shellFamily) && shellReadsStream(args, s.stdinStream) { + carried := func(i int) bool { return variableCarried(s, a.idx+1+i) } + if nameCouldBeAny(tok, shellFamily) && shellReadsStream(args, s.stdinStream, carried) { return true } - if nameCouldBeAny(tok, sourceBuiltins) && sourceReadsStream(args, s.stdinStream) { + if nameCouldBeAny(tok, sourceBuiltins) && sourceReadsStream(args, s.stdinStream, carried) { return true } } @@ -1252,7 +1309,7 @@ func scriptIsStream(op string, stdin bool) bool { // `--version` or `--help` prints and exits without reading anything. Each // unknown word is read every way readWord reads it, and a stream any reading // runs is enough. -func shellReadsStream(args []string, stdin bool) bool { +func shellReadsStream(args []string, stdin bool, carried func(int) bool) bool { seen := map[int]bool{} stack := []int{0} for len(stack) > 0 { @@ -1276,7 +1333,7 @@ func shellReadsStream(args []string, stdin bool) bool { if stdin { return true } - } else if scriptIsStream(args[i+1], stdin) { + } else if !carried(i+1) && scriptIsStream(args[i+1], stdin) { return true } case a == "-": @@ -1303,7 +1360,7 @@ func shellReadsStream(args []string, stdin bool) bool { if (r.flag || r.takes) && clusterCouldCarry(a, 's') && stdin { return true } - if r.operand && scriptIsStream(a, stdin) { + if r.operand && !carried(i) && scriptIsStream(a, stdin) { return true } case a == "--rcfile" || a == "--init-file": @@ -1339,12 +1396,12 @@ var shellStreamValueOptions = []string{"-o", "-O", "+o", "+O", "--rcfile", "--in // sourceReadsStream reports whether `source`/`.` is handed a stream as the // file it reads: its first operand, after an optional `--`. -func sourceReadsStream(args []string, stdin bool) bool { +func sourceReadsStream(args []string, stdin bool, carried func(int) bool) bool { for i, a := range args { if a == "--" && i == 0 { continue } - return scriptIsStream(a, stdin) + return !carried(i) && scriptIsStream(a, stdin) } return false } diff --git a/internal/core/guard/payloadvariable_test.go b/internal/core/guard/payloadvariable_test.go new file mode 100644 index 000000000..263471241 --- /dev/null +++ b/internal/core/guard/payloadvariable_test.go @@ -0,0 +1,58 @@ +package guard + +import "testing" + +// TestAStringsQuotingAppliesToTheVariablesValue — review-drainG3 finding 1 +// (iss-2609251824244354). A string the guard reads as a payload carries a +// variable the enclosing shell has already expanded: bash hands the inner +// shell `git push '--force'`, and the string's own quote or backslash applies +// to the VALUE. The payload reading spelled the variable back as `$X` text +// and re-read it, so the same quote applied to the NAME there, and `'--$X'` +// read as the literal `--$X`, which names no flag. A variable's value now +// reaches the re-read as its own mark (varMark), which no quote or escape +// turns back into text, as a substitution's output reaches it. +func TestAStringsQuotingAppliesToTheVariablesValue(t *testing.T) { + runVerdictCases(t, []verdictCase{ + {`sh -c "git push '--$X' origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git push -\\$F origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git push --for\\$X origin main"`, VerdictBlock, "git-push-force"}, + {`eval "git push '--$X' origin main"`, VerdictBlock, "git-push-force"}, + {`bash -c "git push '-$F' origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git commit -m x '--$X'"`, VerdictBlock, "git-commit-no-verify"}, + {`sh -c "cd s && rm '-$F' *"`, VerdictBlock, "rm-rf-after-cd-chain"}, + {`sh -c "'$GIT' push --force origin main"`, VerdictBlock, ""}, + {`sh -c "git push '--${X}' origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git push $'--$X' origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "sh -c \"git push '--$X' origin main\""`, VerdictBlock, "git-push-force"}, + {`env -S "git push --$X origin main"`, VerdictBlock, ""}, + + // What the review's twins already read, and still do. + {`sh -c "git push --$X origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git push \"--$X\" origin main"`, VerdictBlock, "git-push-force"}, + {`sh -c "git push '--$(echo force)' origin main"`, VerdictBlock, "git-push-force"}, + + // A word that is wholly a variable is one operand (DECISIONS + // 2026-09-28, allow (1) of the first 2026-09-25 entry), and a quote or + // a backslash round it in a string leaves it one: `\$X` there is its + // bare twin `sh -c "git push $X origin main"`, as `git push $X origin + // main` is at the top level. + {`sh -c "git push \\$X origin main"`, VerdictAllow, ""}, + {`sh -c "git push '$X' origin main"`, VerdictAllow, ""}, + {`sh -c "git push $X origin main"`, VerdictAllow, ""}, + + // A variable in a string is still read as the carve-outs read it at + // the top level, and a quoted one in operand position is an operand. + {`sh -c "git push origin '$BRANCH'"`, VerdictAllow, ""}, + {`sh -c "git push origin \\$BRANCH"`, VerdictAllow, ""}, + {`sh -c "echo '$HOME'"`, VerdictAllow, ""}, + {`bash -c "cd '$DIR' && make test"`, VerdictAllow, ""}, + {`sh -c "curl -o '$OUT' https://example.com/x"`, VerdictAllow, ""}, + {`sh -c "git push origin main" '$X'`, VerdictAllow, ""}, + {`sh -c 'git push "--$X" origin main'`, VerdictBlock, "git-push-force"}, + + // An escape that decodes to the mark's byte is that byte, not a + // variable (readAnsiCQuote). + {`git push $'--\x01' origin main`, VerdictAllow, ""}, + {`git push $'--\001' origin main`, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/tokenize.go b/internal/core/guard/tokenize.go index b1a571294..0ed8a955f 100644 --- a/internal/core/guard/tokenize.go +++ b/internal/core/guard/tokenize.go @@ -3,6 +3,7 @@ package guard import ( "bytes" "fmt" + "sort" "strconv" "strings" "unicode/utf8" @@ -70,6 +71,16 @@ type segment struct { // fromFixedOutput records a segment fixedOutputSegment built: another // reading of the segment carrying the output, not another command. fromFixedOutput bool + // variable records, per token index, that every unknown part of the word + // is a parameter expansion's value, a variable set before the line ran, + // and none a substitution's output (iss-2609251824244354), with the word + // spelled with varMark where each value goes (`--\x01`), or "" for a word + // brace expansion made. nil when no word is. Three readings take it: a + // variable's value is not read as a stream path or as a program whose + // entry names nothing but itself and its operands (variableCarried in + // unknown.go), and a string handed to a shell carries the value's mark + // for the re-read to take as a variable's (payloadView). + variable map[int]string // arrivals caches commandArrivals(tokens) once Check has its final // segments (walked records that it is set), so the walk to command position // is paid once per segment rather than once per entry. A segment built @@ -357,6 +368,11 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // curPieces holds those outputs for the word being built. lits map[int]wordLiteral curPieces []litPiece + // vars rides with the segment (segment.variable); curVar and curSub + // record, for the word being built, that a parameter expansion and a + // substitution left their mark in it (addCur). + vars map[int]string + curVar, curSub bool // curMask is parallel to cur and records, per byte, whether it reached // the tokenizer unquoted (wordStruct) and whether it began its word // (wordRawStart) — what the brace expander needs to read a word the way @@ -398,6 +414,14 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // command emitted reads a pipe. Both land on segment.stdinStream. curStdin bool pipeNext bool + // docOwners is parallel to pending and records, per here-document, the + // index in segs of the command that opened it, or -1 while that + // command is still being built; curDocs holds the documents the + // command being built opened. The substitutions an unquoted body runs + // print the command's standard input (segment.stdinIn), as a + // here-string's do. + docOwners []int + curDocs []int // feeds rides with the segment and records, per token index, the // commands whose output the word holds (segment.feeds); curFeeds holds // them for the word being built. pipeFrom is where, in segs, the @@ -425,6 +449,20 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { list = &segList{} ) defer func() { list.segs = segs }() + // stdinHere is what a group or a substitution opening here reads on its + // standard input from outside the command line's own words: what was + // piped into the groups open here, widened by the pipe into the command + // being built, when one reaches it. + stdinHere := func() []feed { + if pipeNext && len(segs) > pipeFrom { + in := feed{list: list, lo: pipeFrom, hi: len(segs)} + if len(groupIn) > 0 { + in.lo = min(in.lo, groupIn[0].lo) + } + return []feed{in} + } + return groupIn + } // openGroup is read where a `{ … }` or `( … )` group opens, and returns // the groupIn its close restores. A pipe into the group widens groupIn to // cover what it hands on as well as what the groups around it were handed: @@ -437,13 +475,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // command reads one run however deep the groups nest. openGroup := func() (saved []feed) { saved = groupIn - if pipeNext && len(segs) > pipeFrom { - in := feed{list: list, lo: pipeFrom, hi: len(segs)} - if len(groupIn) > 0 { - in.lo = min(in.lo, groupIn[0].lo) - } - groupIn = []feed{in} - } + groupIn = stdinHere() return saved } // feedFrom records, for the word being built, that it holds the output of @@ -505,14 +537,32 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { } // addCur appends bytes to the word being built with one mask value for all // of them: wordStruct for bytes read unquoted, zero for quoted, escaped or - // decoded ones. + // decoded ones. Every byte a word takes passes here but a varMark an + // ANSI-C escape decoded (addText), so here is where a mark in the text + // read is recorded, whatever quote it stands in: a varMark is a + // variable's value (unknown.go) and becomes unknownMark in the word, and + // an unknownMark is a substitution's output. addCur := func(b []byte, mask byte) { - cur = append(cur, b...) - for range b { + for _, c := range b { + switch c { + case varMark: + c = unknownMark + curVar = true + case unknownMark: + curSub = true + } + cur = append(cur, c) curMask = append(curMask, mask) } hasCur = true } + // addText appends one decoded byte as text: an ANSI-C escape that + // decodes to varMark hands bash that byte, and no mark (readAnsiCQuote). + addText := func(c byte) { + cur = append(cur, c) + curMask = append(curMask, 0) + hasCur = true + } // recordFeeds files the word being built's feeds under the index it is // about to take. recordFeeds := func() { @@ -524,6 +574,30 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { } feeds[len(toks)] = curFeeds } + // recordVar files the word being built under segment.variable when every + // mark in it is a parameter expansion's, spelled with each mark written + // as varMark, which a payload re-read takes for a variable's value + // whatever quote the string puts round it (unknown.go). A substitution's + // mark in the word, from anywhere, sets curSub, so none is spelled as a + // variable's. A brace expansion's words are not spelled, and are filed as + // a variable's only for the two readings that need no spelling. + recordVar := func(spell bool) { + if !curVar || curSub { + return + } + text := "" + if spell { + text = strings.ReplaceAll(string(cur), unknownText, varText) + } + if vars == nil { + vars = map[int]string{} + } + vars[len(toks)] = text + } + // addVar leaves the mark of a parameter expansion where its value goes. + addVar := func() { + addCur([]byte{varMark}, 0) + } flushToken := func() { if !hasCur { return @@ -544,11 +618,12 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { if words, ok := expandBraces(bword{b: cur, m: curMask}, &braceLim); ok { for _, w := range words { recordFeeds() + recordVar(false) toks = append(toks, unknownFromOpenExpansion(string(w.b))) globs = append(globs, w.globbed()) } cur, curMask, hasCur, curGlob, curBrace = nil, nil, false, false, false - curPieces, curFeeds = nil, nil + curPieces, curFeeds, curVar, curSub = nil, nil, false, false return } braceGroup = true @@ -565,7 +640,8 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { } curPieces = nil recordFeeds() - curFeeds = nil + recordVar(true) + curFeeds, curVar, curSub = nil, false, false // An unquoted `{` or `}` in command position opens or closes a group. if len(curMask) == 1 && curMask[0]&wordStruct != 0 && allReserved(toks) { switch tok { @@ -584,6 +660,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { flushSegment := func() { flushToken() if len(toks) > 0 { + for _, k := range curDocs { + docOwners[k] = len(segs) + } var piped feed if pipeNext && len(segs) > pipeFrom { piped = feed{list: list, lo: pipeFrom, hi: len(segs)} @@ -591,33 +670,43 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { segs = append(segs, segment{ tokens: toks, chain: chain, braceGroup: braceGroup, globbed: globsOrNil(globs), stdinStream: curStdin || pipeNext || len(groupIn) > 0, literal: lits, feeds: feeds, piped: piped, - stdinIn: groupIn, home: list, at: len(segs), + stdinIn: groupIn, home: list, at: len(segs), variable: vars, }) toks = nil globs = nil lits = nil + vars = nil feeds = nil braceGroup = false pipeNext = false } - curStdin = false + curStdin, curDocs = false, nil } // follow reads the text of a command substitution the scan found whole — // inside double quotes, or inside an arithmetic expansion — as commands of // their own, emitted now because they run first, in this command's chain. // Past the depth budget, or when the text does not tokenize, it raises the - // fail-closed flag instead (iss-2609251640353405). + // fail-closed flag instead (iss-2609251640353405). A substitution runs + // with its command's standard input — what was piped into the groups + // around it and into the command itself — so each of its commands reads + // that too (iss-2609270036253187), as openSubstitution hands it to one + // read in place. follow := func(text string) { if depth >= maxQuotedSubstitutionDepth { unread() return } + in := stdinHere() isegs, err := tokenizeAt(text, depth+1, budget) if err != nil { isegs = []segment{{substitutionUnread: true}} } for _, is := range isegs { is.chain = chain + if len(in) > 0 { + is.stdinIn = append(append([]feed(nil), is.stdinIn...), in...) + is.stdinStream = true + } segs = append(segs, is) } } @@ -725,17 +814,23 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // becoming a command called `-rf`. openSubstitution := func(kind parenKind, pos int, procSub bool) { saved := &enclosing{ - toks: toks, globs: globs, lits: lits, cur: cur, curMask: curMask, hasCur: hasCur, curGlob: curGlob, + toks: toks, globs: globs, lits: lits, vars: vars, curVar: curVar, curSub: curSub, + cur: cur, curMask: curMask, hasCur: hasCur, curGlob: curGlob, curBrace: curBrace, braceGroup: braceGroup, chain: chain, procSub: procSub, - curStdin: curStdin, pipeNext: pipeNext, pieces: curPieces, + curStdin: curStdin, pipeNext: pipeNext, curDocs: curDocs, pieces: curPieces, feeds: feeds, curFeeds: curFeeds, pipeFrom: pipeFrom, segStart: len(segs), braceFrom: braceFrom, groupIn: groupIn, } toks, globs, lits, cur, curMask, hasCur, curGlob, curBrace, braceGroup = nil, nil, nil, nil, nil, false, false, false, false - curPieces = nil - curStdin, pipeNext = false, false + curPieces, vars, curVar, curSub = nil, nil, false, false // A substitution is a command string of its own: its pipelines begin - // inside it. Its standard input is its command's, so groupIn carries on. + // inside it. Its standard input is its command's: what was piped into + // the groups around it, and the pipe into the command it sits in + // (iss-2609270036253187), which openGroup widens groupIn by, as a + // group's opening word does. The enclosing record keeps the groupIn + // its close restores. + openGroup() + curStdin, pipeNext, curDocs = false, false, nil feeds, curFeeds, pipeFrom, braceFrom = nil, nil, len(segs), nil parens = append(parens, parenFrame{kind: kind, pos: pos, saved: saved}) } @@ -774,8 +869,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { e := f.saved toks, globs, lits, cur, curMask, hasCur, curGlob, curBrace, braceGroup, chain = e.toks, e.globs, e.lits, e.cur, e.curMask, e.hasCur, e.curGlob, e.curBrace, e.braceGroup, e.chain - curStdin, pipeNext, curPieces = e.curStdin, e.pipeNext, e.pieces + curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn + vars, curVar, curSub = e.vars, e.curVar, e.curSub if !f.bare { addCur([]byte(arithmeticOperand), 0) // The number it prints is computed from what the substitutions @@ -794,16 +890,38 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { flushSegment() toks, globs, lits, cur, curMask, hasCur, curGlob, curBrace, braceGroup, chain = e.toks, e.globs, e.lits, e.cur, e.curMask, e.hasCur, e.curGlob, e.curBrace, e.braceGroup, e.chain - curStdin, pipeNext, curPieces = e.curStdin, e.pipeNext, e.pieces + curStdin, pipeNext, curDocs, curPieces = e.curStdin, e.pipeNext, e.curDocs, e.pieces feeds, curFeeds, pipeFrom, braceFrom, groupIn = e.feeds, e.curFeeds, e.pipeFrom, e.braceFrom, e.groupIn + vars, curVar, curSub = e.vars, e.curVar, e.curSub feedFrom(e.segStart) if e.procSub { addCur([]byte(procSubOperand), 0) } else { addCur([]byte{unknownMark}, 0) + curSub = true } lastList = false } + // parameterExpansion reads a `${…}` whose text between the braces is body. + // What it prints — the variable's value, a default, a pattern's leftover — + // is not in the command line, so it leaves unknownMark in its word, as a + // substitution does (iss-2609251824244354), and the known text after its + // `}` stays fixed. The substitutions its text holds run, in a default or an + // alternative or a pattern, so they are followed as a double-quoted + // string's are (expandedBody), and the word holds their output. + // + // One whose text ran no substitution prints a variable's value, or a word + // the line spells, and the word is filed as a variable's + // (segment.variable); one that ran a substitution may print its output. + parameterExpansion := func(body string) { + start := len(segs) + expandedBody(body) + feedFrom(start) + addVar() + if len(segs) > start { + curSub = true + } + } for i := 0; i < len(line); { c := line[i] @@ -894,27 +1012,21 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // An arithmetic expansion is read as one: its output is a number, // and only a command substitution inside it runs a command. // - // A `${` opens a parameter expansion, which ends at its own `}` + // A parameter expansion prints a value that is not in the command + // line, as a substitution does, and leaves the unknown word's mark + // where it goes (iss-2609251824244354). A `${` ends at its own `}` // (closingDolBrace), and inside it a `"` opens a nested string - // instead of closing this one (review5-guard finding 2). The text - // up to that `}` is read once more here for the substitutions the - // expansion runs, with each nested quote removed, and no close is - // looked for past the `}`. + // instead of closing this one (review5-guard finding 2); the + // substitutions its text runs are followed (parameterExpansion), + // and no close is looked for inside it. followSubs, braces := true, true - braceEnd := -1 for j < len(line) { - if braceEnd >= 0 && j >= braceEnd { - if j == braceEnd { - addCur([]byte{'}'}, 0) - j++ - } - braceEnd = -1 - continue - } - if braces && braceEnd < 0 && line[j] == '$' && j+1 < len(line) && line[j+1] == '{' { + if braces && line[j] == '$' && j+1 < len(line) && line[j+1] == '{' { switch end := closingDolBrace(line, j+2, budget); { case end >= 0: - braceEnd = end + parameterExpansion(line[j+2 : end]) + j = end + 1 + continue case end == closeUnread: unread() followSubs, braces = false, false @@ -928,12 +1040,13 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { j += 2 continue } - scan := line - if braceEnd >= 0 { - scan = line[:braceEnd] + if k := simpleParamEnd(line, j+1); line[j] == '$' && k >= 0 { + addVar() + j = k + continue } if followSubs && line[j] == '$' && j+2 < len(line) && line[j+1] == '(' && line[j+2] == '(' { - end := arithmeticEnd(scan, j, budget) + end := arithmeticEnd(line, j, budget) if end == closeUnread { unread() followSubs = false @@ -952,9 +1065,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { open, inner := j+2, closeNone if line[j] == '`' { open = j + 1 - inner = closingBacktick(scan, open, budget) + inner = closingBacktick(line, open, budget) } else { - inner = closingParen(scan, open, budget) + inner = closingParen(line, open, budget) } if inner < 0 { if inner == closeUnread { @@ -971,6 +1084,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { follow(text) feedFrom(start) addCur([]byte{unknownMark}, 0) + curSub = true // A `$(cat <<'EOF' … EOF)` prints its document verbatim, // and so does its backtick spelling; flushToken reads the // word with that text in the output's place. @@ -994,11 +1108,6 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { continue } if line[j] == '"' { - if braceEnd >= 0 { - // A nested string's quote, removed as bash removes it. - j++ - continue - } closed = true break } @@ -1033,10 +1142,24 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // A body whose delimiter is unquoted is expanded before the // command reads it, and every command substitution in it runs // (review4-guard finding 1). Its text stays data; what runs is - // read as commands of this command's chain. - for _, body := range bodies { + // read as commands of this command's chain, and what they print + // is the opening command's standard input, as a here-string's + // output is (iss-2609270036253187). The owner's output is what + // a pipe after it hands on, and the body is read here, after + // the whole pipeline, so each command the owner's output + // reaches is handed the body as well (handOnDocs). + emitted := len(segs) + var docs []docRun + for k, body := range bodies { + start := len(segs) expandedBody(body) + if o := docOwners[k]; o >= 0 && len(segs) > start { + run := feed{list: list, lo: start, hi: len(segs)} + segs[o].stdinIn = append(append([]feed(nil), segs[o].stdinIn...), run) + docs = append(docs, docRun{owner: o, run: run}) + } } + handOnDocs(segs[:emitted], list, docs) if !ok { // The delimiter line never came. bash RUNS this (it recovers // silently, taking input-to-EOF as the body), so an error is @@ -1051,7 +1174,7 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { markHeredocUnterminated(&segs, chain) } i = next - pending = nil + pending, docOwners = nil, nil } // lastList is NOT cleared here: a blank or comment-only line after a // list operator does not end the list, and every token-producing @@ -1122,7 +1245,9 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { continue } flushToken() + curDocs = append(curDocs, len(pending)) pending = append(pending, hd) + docOwners = append(docOwners, -1) curStdin = true i = next case c == '>' || (c == '<' && !strings.HasPrefix(line[i:], "<<")): @@ -1191,11 +1316,17 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { // blocker naming `--force` missed — a silent allow of the very argv // bash hands the child. Quoting must not change argument semantics // (doc.go): `git push $'--force'` fires, like `git push '--force'`. - decoded, next, err := readAnsiCQuote(line, i+2) + decoded, forged, next, err := readAnsiCQuote(line, i+2) if err != nil { return fail(err) } - addCur(decoded, 0) + prev := 0 + for _, k := range forged { + addCur(decoded[prev:k], 0) + addText(decoded[k]) + prev = k + 1 + } + addCur(decoded[prev:], 0) lastList = false i = next case c == '$' && i+1 < len(line) && line[i+1] == '"': @@ -1229,6 +1360,30 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { parens[len(parens)-1].end = end lastList = false i += 3 + case c == '$' && simpleParamEnd(line, i+1) >= 0: + // A parameter expansion (iss-2609251824244354): the value it prints + // is not in the command line, so the word holds unknownMark where + // it goes (unknown.go), and `--$X` is a flag of unknown name as + // `--$(x)` is. A `$` that is quoted or escaped never reaches here. + addVar() + lastList = false + i = simpleParamEnd(line, i+1) + case c == '$' && i+1 < len(line) && line[i+1] == '{': + // A `${…}` expansion, read as the double-quoted one is. One whose + // `}` is missing is a syntax error bash refuses, and stays text. + end := closingDolBrace(line, i+2, budget) + if end == closeUnread { + unread() + } + if end < 0 { + addCur([]byte{c}, wordStruct) + lastList = false + i++ + break + } + parameterExpansion(line[i+2 : end]) + lastList = false + i = end + 1 case c == '&' || c == '|' || c == ';' || c == '(' || c == ')' || c == '`': // A backtick is command substitution, identical to `$( … )`: the inner // command EXECUTES before its output is used. `$( … )` already splits @@ -1386,10 +1541,12 @@ func tokenizeAt(line string, depth int, budget *int) ([]segment, error) { addCur([]byte{c}, mask) lastList = false i++ - case c == unknownMark: + case c == unknownMark || c == varMark: // A payload re-read from a word that carried a substitution's - // output: the mark stays where the output goes, and the word it - // lands in is unknown (unknown.go). + // output or a variable's value: the mark stays where it goes, and + // the word it lands in is unknown (unknown.go). addCur records + // which: an unknownMark's origin is not known here, so it is read + // as a substitution's; a varMark is a variable's (payloadView). addCur([]byte{c}, 0) lastList = false i++ @@ -1678,6 +1835,31 @@ func closingDoubleQuote(line string, i int, budget *int) int { return closeNone } +// simpleParamEnd returns the index just past a parameter expansion written +// without braces whose name begins at i, the byte after its `$`: a name +// (`$HOME`, `$branch_2`), one positional digit (`$1`; `$10` is `$1` then a +// 0), or `$@`, `$*` or `$-`, whose values are any text. It returns -1 where +// the `$` opens no such expansion. `$$`, `$!`, `$?` and `$#` print a number, +// which no flag, name or path an entry names can be, as an arithmetic +// expansion's does, and stay the text they are. +func simpleParamEnd(line string, i int) int { + if i >= len(line) { + return -1 + } + switch c := line[i]; { + case c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'): + j := i + 1 + for j < len(line) && (line[j] == '_' || (line[j] >= 'a' && line[j] <= 'z') || + (line[j] >= 'A' && line[j] <= 'Z') || (line[j] >= '0' && line[j] <= '9')) { + j++ + } + return j + case (c >= '0' && c <= '9') || c == '@' || c == '*' || c == '-': + return i + 1 + } + return -1 +} + // closingDolBrace returns the index of the `}` that closes a `${` standing // inside double quotes, whose body starts at i, or one of closeNone and // closeUnread. It reads the body as bash's parser does (parse_matched_pair @@ -1864,6 +2046,9 @@ type enclosing struct { toks []string globs []bool lits map[int]wordLiteral + vars map[int]string + curVar bool + curSub bool cur []byte curMask []byte hasCur bool @@ -1878,6 +2063,7 @@ type enclosing struct { // record (tokenizeAt), suspended with the rest of it. curStdin bool pipeNext bool + curDocs []int // pieces is the enclosing word's fixed outputs so far (tokenizeAt). pieces []litPiece // feeds, curFeeds and pipeFrom are the enclosing command's own records of @@ -2135,6 +2321,85 @@ func skipSubstitution(line string, j, end int) (next int, alt bool) { return end, alt } +// docRun is the run of commands an unquoted here-document's body ran, and +// the index of the command that opened the document (its owner). +type docRun struct { + owner int + run feed +} + +// handOnDocs hands each here-document body in docs to the commands of segs +// after its owner that read the owner's output: one whose pipe, or whose +// inherited input (a pipe into its group, into the command a substitution +// sits in), is a run of list holding the owner. `cat <= lo }) + b := sort.Search(len(docs), func(k int) bool { return docs[k].owner >= hi }) + if a >= b { + return feed{}, false + } + if !ordered { + return all, true + } + return feed{list: list, lo: docs[a].run.lo, hi: docs[b-1].run.hi}, true + } + for p := docs[0].owner + 1; p < len(segs); p++ { + tally(1) + s := &segs[p] + var got feed + add := func(w feed) { + if w.list != list { + return + } + f, ok := span(w.lo, min(w.hi, p)) + if !ok { + return + } + if got.list == nil { + got = f + return + } + got.lo, got.hi = min(got.lo, f.lo), max(got.hi, f.hi) + } + add(s.piped) + for _, w := range s.stdinIn { + add(w) + } + if got.list != nil { + s.stdinIn = append(append([]feed(nil), s.stdinIn...), got) + } + } +} + // readAnsiCQuote decodes a bash ANSI-C `$'...'` body that begins at start (the // byte just after the opening quote) and returns the decoded bytes together with // the index just past the closing quote. bash reads the string in two steps, @@ -2152,7 +2417,13 @@ func skipSubstitution(line string, j, end int) (next int, alt bool) { // dropped, so `$'\x00'git` is `git`. The guard reads it the same way, and that // is also what keeps unknownMark unforgeable (unknown.go): no decoded byte is // ever a NUL. -func readAnsiCQuote(line string, start int) ([]byte, int, error) { +// +// forged lists the offsets in the decoded bytes of each varMark an escape +// decoded (`$'\x01'`, `\001`, `\cA`, `\u0001`): the byte bash hands on, which +// the tokenizer keeps as text, so no escape puts a variable's mark into a +// word either. A varMark written raw in the body is the payload spelling's +// (payloadView) and is read as one. +func readAnsiCQuote(line string, start int) ([]byte, []int, int, error) { end := -1 for i := start; i < len(line); i++ { if line[i] == '\\' { @@ -2165,10 +2436,11 @@ func readAnsiCQuote(line string, start int) ([]byte, int, error) { } } if end < 0 { - return nil, 0, fmt.Errorf("%w: unterminated $'' quote", ErrUnparsableCommand) + return nil, nil, 0, fmt.Errorf("%w: unterminated $'' quote", ErrUnparsableCommand) } body := line[start:end] var out []byte + var forged []int for i := 0; i < len(body); { if body[i] != '\\' || i+1 >= len(body) { out = append(out, body[i]) @@ -2177,12 +2449,19 @@ func readAnsiCQuote(line string, start int) ([]byte, int, error) { } decoded, next := decodeAnsiCEscape(body, i+1) if nul := bytes.IndexByte(decoded, 0); nul >= 0 { - return append(out, decoded[:nul]...), end + 1, nil + return append(out, decoded[:nul]...), forged, end + 1, nil + } + if string(decoded) != body[i:next] { + for k, b := range decoded { + if b == varMark { + forged = append(forged, len(out)+k) + } + } } out = append(out, decoded...) i = next } - return out, end + 1, nil + return out, forged, end + 1, nil } // decodeAnsiCEscape resolves one ANSI-C escape whose leading backslash has @@ -2499,7 +2778,8 @@ func heredocBlockSignal() payloadSignal { // at pos (the first byte after the newline that ended the command line), and // returns the position just past the last body, together with — when collect // is set — the text of each body the shell EXPANDS, one whose delimiter is -// unquoted, with a `<<-` body's leading tabs stripped as bash strips them. A +// unquoted, with a `<<-` body's leading tabs stripped as bash strips them, one +// entry per document read, in pending's order (empty for a quoted one). A // closing scan, which only steps over a body, does not collect. The last // return is false if // any body never finds its terminating delimiter line before the input ends — @@ -2531,9 +2811,7 @@ func skipHeredocBodies(line string, pos int, pending []heredoc, collect bool) (i if !found { return pos, expanded, false } - if body != "" { - expanded = append(expanded, body) - } + expanded = append(expanded, body) } return pos, expanded, true } diff --git a/internal/core/guard/unknown.go b/internal/core/guard/unknown.go index 10dd009af..18d84fc20 100644 --- a/internal/core/guard/unknown.go +++ b/internal/core/guard/unknown.go @@ -12,10 +12,13 @@ import ( // // A command substitution (`$( … )`, or its backtick spelling) runs a command // and hands its OUTPUT to the word it sits in, and the output is not in the -// command line. The tokenizer therefore writes unknownMark into the word where -// the output goes, and every reader of a token asks this file what the word -// can be. An arithmetic expansion is not unknown in that sense: its output is a -// number, which no flag, subcommand or path the registry names can be. +// command line. Nor is a parameter expansion's value (`$X`, `$1`, `$@`, +// `${X:-git}`, iss-2609251824244354). The tokenizer therefore writes +// unknownMark into the word where the output or the value goes, and every +// reader of a token asks this file what the word can be. An arithmetic +// expansion is not unknown in that sense: its output is a number, which no +// flag, subcommand or path the registry names can be, and neither are `$$`, +// `$!`, `$?` and `$#`. // // The rule is that an unknown word fails closed in every role it could play, // and a reader that can read a word more than one way reads it every way — the @@ -56,7 +59,10 @@ import ( // and its branch (`git commit -m "$(cat msg)"`, `git push origin // "$(git branch --show-current)"`), and reading it as every flag would refuse // both. The same reason keeps an operand's `+` refspec prefix read from its -// known text only. Both residuals are recorded in .abcd/work/DECISIONS.md. +// known text only. Both residuals are recorded in .abcd/work/DECISIONS.md, +// and a word that is wholly a variable reads the same way (`git push origin +// "$branch"`). A variable's value is read as a flag and a program name, and +// not as data an earlier command carried (variableCarried). // unknownMark stands, inside a token, for the output of a substitution the // guard did not run. It is the NUL byte, and it is unforgeable by construction: @@ -69,6 +75,30 @@ const unknownMark = '\x00' // unknownText is unknownMark as a string, for building tokens. const unknownText = "\x00" +// varMark stands, in the TEXT of a string the guard re-reads as a payload, +// for a variable's value the enclosing shell has already put there +// (payloadView). The tokenizer turns it into unknownMark in the word it lands +// in, and records that the word's unknown part is a variable's +// (segment.variable), wherever it stands: unquoted, inside a quote, behind a +// backslash or in an ANSI-C string. That is what the enclosing shell did to +// the value — the string's own quoting applies to the value, not to a name — +// so `sh -c "git push '--$X'"` is read as the flag of unknown name bash +// builds (review-drainG3 finding 1). Spelling the variable back as `$X` text +// applied that quoting to the name instead, and `'--$X'` read as text. +// +// It is the byte 0x01. An ANSI-C escape that decodes to it stays text +// (readAnsiCQuote), but the byte itself can reach the text read: written raw +// in the line, or carried into a string's text from the level above. There +// it is read as a variable's value — every flag and program name its known +// text allows, less the readings variableCarried drops, each of which a +// literal 0x01 byte in its place cannot produce either: it names no program, +// no stream and no flag. So a byte read as the mark reads no narrower than +// the literal byte bash hands on. +const varMark = '\x01' + +// varText is varMark as a string, for spelling a payload's text. +const varText = "\x01" + // isUnknown reports whether a word carries a substitution's output. func isUnknown(tok string) bool { return strings.IndexByte(tok, unknownMark) >= 0 } @@ -99,9 +129,10 @@ func knownLead(tok string) string { // could only end in a brace and a flag that could only be one that did. The // outermost `${` still open where a mark lands starts the unknown; the text // before it is kept, so `--${X:-$(x)}` is a dash-word and `${X:-$(x)}` a -// word that is wholly unknown. A `${…}` that closes before any mark, and a -// `$X` with no substitution in it, are left as written: that is the half -// iss-2609251824244354 defers. +// word that is wholly unknown. The tokenizer reads a `${…}` whole where it +// finds its `}` (parameterExpansion), so a `${` reaches here only as text it +// could not close, or behind an escaped `$`, where reading the rest as +// unknown is the fail-closed side. func unknownFromOpenExpansion(tok string) string { if !isUnknown(tok) { return tok @@ -124,6 +155,32 @@ func unknownFromOpenExpansion(tok string) string { return tok } +// variableCarried reports whether the word at index i of s is unknown only +// because it holds parameter expansions (segment.variable): its value is a +// variable's, set before the line ran. Such a word is read as every flag and +// every program name its known text allows, as a substitution's output is, +// with two exceptions, each because a variable is how ordinary commands +// carry a path and a program between commands, and reading it the other way +// refuses them (iss-2609251824244354's false-positive sweep). As a shell's or +// `source`'s script it is not a stream (`bash "$script"`, `. "$ENV_FILE"`): a +// stream path in a variable is data an earlier command carried, the half +// DECISIONS 2026-09-25 (c) defers for a pid list. As a program name nothing +// fixes, it fires no entry that names only its program and a count of +// operands (namesOnlyItsProgram): every command with an operand fits one, so +// `"$GO" build` would read as a pkill. +func variableCarried(s segment, i int) bool { + _, ok := s.variable[i] + return ok && i < len(s.tokens) && isUnknown(s.tokens[i]) +} + +// namesOnlyItsProgram reports whether an entry's pattern constrains nothing +// but its program and how many operands follow it (pkill-by-pattern, +// killall-by-name). +func namesOnlyItsProgram(p Pattern) bool { + return p.Subcommand == "" && p.Subcommand2 == "" && len(p.Flags) == 0 && len(p.FlagValues) == 0 && + len(p.ArgPaths) == 0 && len(p.ArgPrefixes) == 0 && len(p.ArgsFrom) == 0 && p.AfterCD == nil +} + // vanishable reports whether a word is nothing but substitutions, so an // unquoted one may leave no word at all. func vanishable(tok string) bool { diff --git a/internal/core/guard/unknownsites_test.go b/internal/core/guard/unknownsites_test.go index ff0d9f445..b027a372e 100644 --- a/internal/core/guard/unknownsites_test.go +++ b/internal/core/guard/unknownsites_test.go @@ -95,6 +95,7 @@ var wordReaders = map[string]string{ "allReserved": "exempt: reserved words are grammar, which no substitution prints", "keywordAt": "exempt: reserved words are grammar, which no substitution prints", "readHeredocDelim": "exempt: the `<<-` operator is grammar", + "simpleParamEnd": "exempt: reads the `$-` special parameter's name, grammar that makes the word unknown", "validatePattern": "exempt: reads registry patterns, not command words", "validEntryID": "exempt: reads a registry id, not a command word", } diff --git a/internal/core/history/home_link_test.go b/internal/core/history/home_link_test.go new file mode 100644 index 000000000..c644ce9be --- /dev/null +++ b/internal/core/history/home_link_test.go @@ -0,0 +1,38 @@ +//go:build unix + +package history + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLocalTranscriptRootsBehindASymlinkedAbcdHomePullNothingIn: the +// declaration that moves a checkout's transcripts into its own tree is a +// home-scoped declaration like trusted-roots, and was read through a symlinked +// ~/.abcd the rules loader refuses (iss-2609281017573862). Behind the link it +// pulls nothing in, and says why; in a real ~/.abcd it is honoured. +func TestLocalTranscriptRootsBehindASymlinkedAbcdHomePullNothingIn(t *testing.T) { + repo := t.TempDir() + dotfiles := t.TempDir() + declareLocal(t, dotfiles, repo, 0o600) + home := t.TempDir() + if err := os.Symlink(filepath.Join(dotfiles, ".abcd"), filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + ok, note := localDeclared(repo) + if ok { + t.Fatal("a local-transcript-roots declaration behind a symlinked ~/.abcd pulled the checkout in") + } + if !strings.Contains(note, LocalRootsDisplay) || !strings.Contains(note, "~/.abcd is a symlink") { + t.Errorf("the ignored declaration must say it was refused for the link: %q", note) + } + + t.Setenv("HOME", dotfiles) + if ok, note := localDeclared(repo); !ok { + t.Fatalf("the same declaration in a real ~/.abcd must be honoured; note %q", note) + } +} diff --git a/internal/core/history/location.go b/internal/core/history/location.go index a030d3c41..00812b827 100644 --- a/internal/core/history/location.go +++ b/internal/core/history/location.go @@ -216,14 +216,15 @@ func localDeclared(repoRoot string) (bool, string) { if err != nil || home == "" { return false, "" } - path := filepath.Join(home, filepath.FromSlash(LocalRootsRelPath)) - // The three-part guard is fsutil.ReadDeclaration's, not this function's — see - // the note at rules.trustedRootDeclared. Only the WORDING stays here. - raw, refusal, err := fsutil.ReadDeclaration(path, maxLocalRootsBytes) + // The guard is fsutil.ReadHomeDeclaration's, not this function's — see the + // note at rules.trustedRootDeclared. Only the WORDING stays here. + raw, refusal, err := fsutil.ReadHomeDeclaration(home, LocalRootsRelPath, maxLocalRootsBytes) switch refusal { case fsutil.DeclarationOK: case fsutil.DeclarationAbsent: return false, "" // no declaration is the ordinary case, not a diagnostic. + case fsutil.DeclarationBehindSymlink: + return false, ignoredDeclaration(termsafe.Sanitize(err.Error())) case fsutil.DeclarationNotRegular: return false, ignoredDeclaration("it is not a regular file") case fsutil.DeclarationWritableByOthers: diff --git a/internal/core/implement/bounds.go b/internal/core/implement/bounds.go index cc5217510..559e14e69 100644 --- a/internal/core/implement/bounds.go +++ b/internal/core/implement/bounds.go @@ -124,9 +124,13 @@ type Verdict struct { Mode Mode `json:"mode,omitempty"` Allowed bool `json:"allowed"` // Ceiling is the session's own agent ceiling as it joined with it, zero when - // it stated none: reported with every verdict so the session about to act - // sees the limit it keeps (see MaxCeiling). + // it stated none: reported with every verdict, beside AgentsAlive, so the + // session about to act sees the limit it keeps (see MaxCeiling). Ceiling int `json:"ceiling,omitempty"` + // AgentsAlive is the count of the session's agents its own log lines declare + // alive (see Run.AgentsAlive): what an agent_start is held against when the + // session stated a ceiling. + AgentsAlive int `json:"agents_alive"` } // Check says whether a session may take a step, and logs the refusal when it may @@ -155,6 +159,9 @@ func (r *Run) Check(session string, step Step, paths []string) (Verdict, error) return err } out = Verdict{Session: session, Role: s.Role, Step: step, Allowed: true, Ceiling: s.Ceiling} + if out.AgentsAlive, err = r.AgentsAlive(s); err != nil { + return err + } w, ok, err := r.CurrentMode() if err != nil { return err diff --git a/internal/core/implement/claim.go b/internal/core/implement/claim.go index 4176b78de..5aad816f1 100644 --- a/internal/core/implement/claim.go +++ b/internal/core/implement/claim.go @@ -384,9 +384,9 @@ func (e *UnreadableClaimError) Error() string { return fmt.Sprintf("the claim file %s is unreadable; nothing can say who holds %s", e.Path, e.Record) } -// readClaim reads one claim file. An unparseable one is an -// *UnreadableClaimError lapsing UnreadableClaimGrace after the file was last -// written. +// readClaim reads one claim file. An unparseable one — or one whose session or +// lane is not a name — is an *UnreadableClaimError lapsing UnreadableClaimGrace +// after the file was last written. func (r *Run) readClaim(root *os.Root, record string) (Claim, error) { rel := claimRel(record) data, err := fsutil.ReadGuardedInRoot(root, rel, maxRecordBytes) @@ -394,7 +394,11 @@ func (r *Run) readClaim(root *os.Root, record string) (Claim, error) { return Claim{}, err } var c Claim - if err := json.Unmarshal(data, &c); err != nil || c.Record != record || c.Session == "" || c.ExpiresAt.IsZero() { + // The session and the lane reach a refusal printed to the operator, so each + // must be a name, as the claim verb wrote it: a hand-edited file carrying a + // terminal escape or a path reads as unreadable, never as a holder. + if err := json.Unmarshal(data, &c); err != nil || c.Record != record || c.ExpiresAt.IsZero() || + validName("session", c.Session) != nil || validName("lane", c.Lane) != nil { bad := &UnreadableClaimError{Record: record, Path: filepath.Join(r.Dir, filepath.FromSlash(rel))} fi, serr := root.Stat(rel) if serr != nil { diff --git a/internal/core/implement/hygiene_test.go b/internal/core/implement/hygiene_test.go new file mode 100644 index 000000000..2422347e0 --- /dev/null +++ b/internal/core/implement/hygiene_test.go @@ -0,0 +1,91 @@ +package implement + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestAHandAppendedLogFileIsNotFollowedThroughASymlink: the run log's leaf is +// never appended through a symlink, so a log symlinked onto a claim file +// cannot make the claim unreadable (iss-2609230720193756). +func TestAHandAppendedLogFileIsNotFollowedThroughASymlink(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + if _, err := r.Claim(ClaimRequest{Session: "alpha", Record: "itd-7", Lane: "l7"}); err != nil { + t.Fatal(err) + } + claimPath := filepath.Join(r.Dir, "claims", "itd-7.json") + before, err := os.ReadFile(claimPath) + if err != nil { + t.Fatal(err) + } + day := filepath.Join(r.Dir, logFileName(r.now())) + if err := os.Remove(day); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("claims", "itd-7.json"), day); err != nil { + t.Fatal(err) + } + if _, err := r.Log("alpha", EventLaneOpen, map[string]string{"lane": "l7"}); err == nil { + t.Fatal("appending through a symlinked log leaf succeeded") + } + after, err := os.ReadFile(claimPath) + if err != nil || string(after) != string(before) { + t.Fatalf("the claim changed under a symlinked log: %q, %v", after, err) + } +} + +// TestAClaimNamingAnInvalidSessionOrLaneIsUnreadable: a hand-edited claim whose +// session or lane is not a name — terminal escapes, a path — reads as an +// unreadable claim, so it never reaches a refusal message printed to the +// operator's screen (iss-2609230720193756). +func TestAClaimNamingAnInvalidSessionOrLaneIsUnreadable(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + for name, c := range map[string]Claim{ + "escape in session": {Record: "itd-8", Session: "evil\x1b[2J", Lane: "l8"}, + "escape in lane": {Record: "itd-8", Session: "beta", Lane: "l8\x1b]0;x\x07"}, + "path as lane": {Record: "itd-8", Session: "beta", Lane: "../../x"}, + } { + c.ClaimedAt = r.now() + c.ExpiresAt = r.now().Add(time.Hour) + data, err := encodeClaim(c) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(r.Dir, "claims", "itd-8.json"), data, 0o600); err != nil { + t.Fatal(err) + } + root, err := r.root() + if err != nil { + t.Fatal(err) + } + _, err = r.readClaim(root, "itd-8") + root.Close() + var bad *UnreadableClaimError + if !errors.As(err, &bad) { + t.Errorf("%s: readClaim = %v; want an unreadable claim", name, err) + } + if _, err := r.Release("alpha", "itd-8"); err == nil || strings.ContainsAny(err.Error(), "\x1b\x07") { + t.Errorf("%s: release = %v; want a refusal carrying no escape", name, err) + } + } +} + +// TestTheRunLockIsTheOwnersAlone: the run's lock file is created 0600 like +// every other file in the run state (iss-2609230720193756). +func TestTheRunLockIsTheOwnersAlone(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + fi, err := os.Stat(filepath.Join(r.Dir, lockFileName)) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != fileMode { + t.Fatalf(".lock mode = %o, want %o", perm, fileMode) + } +} diff --git a/internal/core/implement/load.go b/internal/core/implement/load.go index 4240f6aba..86f84869f 100644 --- a/internal/core/implement/load.go +++ b/internal/core/implement/load.go @@ -6,7 +6,6 @@ import ( "fmt" "math" "os" - "path/filepath" "runtime" "time" @@ -285,12 +284,13 @@ func readLimits(home string, cores int) (machineload.Limits, LoadLimits) { } home = h } - path := filepath.Join(home, ".abcd", machineload.LimitsFileName) - raw, refusal, err := fsutil.ReadDeclaration(path, maxLimitsBytes) + raw, refusal, err := fsutil.ReadHomeDeclaration(home, ".abcd/"+machineload.LimitsFileName, maxLimitsBytes) switch refusal { case fsutil.DeclarationOK: case fsutil.DeclarationAbsent: return out(def, LimitsDefault, "") + case fsutil.DeclarationBehindSymlink: + return out(def, LimitsDefaultAfterMalformed, err.Error()) case fsutil.DeclarationNotRegular: return out(def, LimitsDefaultAfterMalformed, "it is not a regular file (a symlink, a directory or a device)") case fsutil.DeclarationWritableByOthers: diff --git a/internal/core/implement/load_test.go b/internal/core/implement/load_test.go index ebeeb59b0..e6890eb2c 100644 --- a/internal/core/implement/load_test.go +++ b/internal/core/implement/load_test.go @@ -294,6 +294,24 @@ func TestMalformedLimitsFileFallsBackWhole(t *testing.T) { t.Fatal(err) } }, "not a regular file"}, + // iss-2609281017573862: behind a ~/.abcd symlinked into a dotfiles + // checkout the file is not the caller's word, as rules.json is not. + "symlinked abcd home": {func(t *testing.T) { + path := writeLimits(t, "stray-minutes 5\n", 0o600) + dir := filepath.Dir(path) + moved := filepath.Join(t.TempDir(), "dotfiles-abcd") + if err := os.Rename(dir, moved); err != nil { + t.Fatal(err) + } + if err := os.Symlink(moved, dir); err != nil { + t.Fatal(err) + } + // The cases share one home: put the real directory back. + t.Cleanup(func() { + _ = os.Remove(dir) + _ = os.Rename(moved, dir) + }) + }, "~/.abcd is a symlink"}, "another owner": {func(t *testing.T) { writeLimits(t, "stray-minutes 5\n", 0o600) restore := fsutil.SwapOwnerUIDForTest(func(string) (uint32, error) { return uint32(os.Getuid()) + 1, nil }) diff --git a/internal/core/implement/log.go b/internal/core/implement/log.go index 23feff1e2..9a62e2aa1 100644 --- a/internal/core/implement/log.go +++ b/internal/core/implement/log.go @@ -11,6 +11,7 @@ import ( "slices" "sort" "strconv" + "strings" "time" "github.com/intentdriven/abcd/internal/fsutil" @@ -54,6 +55,21 @@ const ( // of its context window in use), role and note. The run measures it because // it is also an experiment in keeping a session alive for days. EventContext = "context" + // EventCeilingOverrun is a session going over its agent ceiling: alive (the + // agents alive), ceiling, lane and minutes (how long it was over). The verb + // refuses an agent_start past the ceiling, so an overrun is what the host + // did anyway — a fork, an agent started outside the log — and says so. + EventCeilingOverrun = "ceiling_overrun" + + // The evidence events: what an autonomous run records so a later run can be + // built to need no person. An intervention is a person acting on the run + // (kind, by, what, why, autonomy_gap — what abcd or the host would need so + // no person is needed — and optionally at and detected_after_min); a stop is + // a stall (cause, and optionally last_productive, noticed_after_min and + // recovery); a decision is a judgement call a person would normally make + // (what, alternative, why, and optionally at). + EventIntervention = "intervention" + EventDecision = "decision" // EventLoad is the load check's warning (itd-2609231434459890), written by // `implement load` alone and only when it warns inside a live run. The run's @@ -74,7 +90,104 @@ var verbOwnedEvents = []string{ var loggableEvents = []string{ EventBackoff, EventLaneOpen, EventLaneClose, EventAgentStart, EventAgentEnd, EventCeilingWait, EventGateRun, EventReview, EventFallback, EventStop, - EventRefusal, EventPR, EventCapture, EventContext, + EventRefusal, EventPR, EventCapture, EventContext, EventCeilingOverrun, + EventIntervention, EventDecision, +} + +// InterventionKinds is the closed vocabulary of an intervention's kind. +var InterventionKinds = []string{ + "session_open", "account", "ruling", "restart", "close_session", "file_restore", "permission", "other", +} + +// fieldKind is what a checked field must hold. +type fieldKind int + +const ( + fieldText fieldKind = iota // any non-empty value + fieldNumber // a non-negative number + fieldTime // an RFC 3339 timestamp + fieldKindOf // one of InterventionKinds +) + +// fieldRule is one field an event is checked for. A rule with alternatives is +// met by any one of them (agent_end's minutes under the keys the report reads). +type fieldRule struct { + names []string + kind fieldKind + optional bool +} + +// eventFields are the fields `implement log` requires, or checks when given, per +// event: the ones the report counts, so a line it would read as absent is +// refused when it is written rather than found missing afterwards +// (iss-2609240646555891). An event not listed takes any fields. +var eventFields = map[string][]fieldRule{ + EventLaneClose: {{names: []string{"lane"}}, {names: []string{"outcome"}}}, + EventAgentStart: {{names: []string{"agent"}}}, + EventAgentEnd: {{names: []string{"agent"}}, {names: []string{"role"}}, {names: []string{"model"}}, + {names: []string{"minutes", "wall_minutes", "wall_min"}, kind: fieldNumber}}, + EventCeilingOverrun: {{names: []string{"alive"}, kind: fieldNumber}, {names: []string{"ceiling"}, kind: fieldNumber}, + {names: []string{"lane"}}, {names: []string{"minutes"}, kind: fieldNumber}}, + EventIntervention: {{names: []string{"kind"}, kind: fieldKindOf}, {names: []string{"by"}}, {names: []string{"what"}}, + {names: []string{"why"}}, {names: []string{"autonomy_gap"}}, + {names: []string{"at"}, kind: fieldTime, optional: true}, + {names: []string{"detected_after_min"}, kind: fieldNumber, optional: true}}, + EventStop: {{names: []string{"cause"}}, + {names: []string{"last_productive"}, kind: fieldTime, optional: true}, + {names: []string{"noticed_after_min"}, kind: fieldNumber, optional: true}}, + EventDecision: {{names: []string{"what"}}, {names: []string{"alternative"}}, {names: []string{"why"}}, + {names: []string{"at"}, kind: fieldTime, optional: true}}, +} + +// RequiredFields returns the fields `implement log` requires on event, each as +// its accepted names ("minutes|wall_minutes|wall_min"), for help text and the +// report's missing-field count. +func RequiredFields(event string) []string { + var out []string + for _, r := range eventFields[event] { + if !r.optional { + out = append(out, strings.Join(r.names, "|")) + } + } + return out +} + +// checkFields refuses an event whose fields break its rules, naming the field. +func checkFields(event string, fields map[string]string) error { + for _, r := range eventFields[event] { + name, v, ok := "", "", false + for _, n := range r.names { + if val, has := fields[n]; has { + name, v, ok = n, val, true + break + } + } + if !ok { + if r.optional { + continue + } + return refusal("%s needs the field %s (the report reads it; required: %s)", + event, strings.Join(r.names, " or "), strings.Join(RequiredFields(event), ", ")) + } + if strings.TrimSpace(v) == "" { + return refusal("%s field %s is empty", event, name) + } + switch r.kind { + case fieldNumber: + if f, err := strconv.ParseFloat(v, 64); err != nil || f < 0 || math.IsNaN(f) || math.IsInf(f, 0) { + return refusal("%s field %s is %q, not a number of zero or more", event, name, v) + } + case fieldTime: + if _, err := time.Parse(time.RFC3339, v); err != nil { + return refusal("%s field %s is %q, not an RFC 3339 time (2006-01-02T15:04:05Z)", event, name, v) + } + case fieldKindOf: + if !slices.Contains(InterventionKinds, v) { + return refusal("%s field %s is %q (one of: %s)", event, name, v, strings.Join(InterventionKinds, ", ")) + } + } + } + return nil } // LoggableEvents returns the events `implement log` accepts, for help text and @@ -215,6 +328,61 @@ func (r *Run) append(session, event string, fields map[string]any) (time.Time, e // logFileName is the day file an event at ts belongs in. func logFileName(ts time.Time) string { return ts.UTC().Format(time.DateOnly) + ".jsonl" } +// AgentsAlive counts the agents a session has declared alive: the agents named +// by its agent_start lines since it joined, each with no agent_end of the same +// agent after it. It counts what the session wrote and nothing else — abcd runs +// no agent, and a line the session never wrote (a fork, an agent the host +// started outside the log) is invisible here — so it is the declared count the +// ceiling is held against, not a census of processes. Lines naming no agent +// cannot be matched and are not counted. +func (r *Run) AgentsAlive(s Session) (int, error) { + alive, err := r.aliveAgents(s) + return len(alive), err +} + +// aliveAgents is AgentsAlive's set: the agent names alive, by the log. +func (r *Run) aliveAgents(s Session) (map[string]bool, error) { + events, _, err := r.ReadLog() + if err != nil { + return nil, err + } + sort.SliceStable(events, func(i, j int) bool { return events[i].TS.Before(events[j].TS) }) + alive := map[string]bool{} + for _, e := range events { + if e.Session != s.Session || e.TS.Before(s.JoinedAt) { + continue + } + name := e.String("agent") + if name == "" { + continue + } + switch e.Event { + case EventAgentStart: + alive[name] = true + case EventAgentEnd: + delete(alive, name) + } + } + return alive, nil +} + +// agentCeiling refuses, and logs the refusal of, an agent_start that would take +// the session past its ceiling. An agent already alive, restated, is not a new +// one. The caller holds the lock. +func (r *Run) agentCeiling(s Session, agent string) error { + alive, err := r.aliveAgents(s) + if err != nil { + return err + } + if len(alive) < s.Ceiling || alive[agent] { + return nil + } + return r.refuseLogged(s.Session, "agent_ceiling", + map[string]any{"agent": agent, "alive": len(alive), "ceiling": s.Ceiling}, + fmt.Sprintf("session %s has %d agent(s) alive of its ceiling %d; log the agent_end of one before starting %s", + s.Session, len(alive), s.Ceiling, agent)) +} + // Log appends one event on a joined session's word: the run's hand-kept events // (lane_open, agent_end, backoff, …) through the same single-write append the // verbs use. The event must be one of LoggableEvents — the verb-owned events are @@ -245,11 +413,20 @@ func (r *Run) Log(session, event string, fields map[string]string) (Event, error } typed[k] = typedValue(v) } + if err := checkFields(event, fields); err != nil { + return Event{}, err + } var out Event err := r.withLock(func() error { - if _, err := r.requireSession(session); err != nil { + s, err := r.requireSession(session) + if err != nil { return err } + if event == EventAgentStart && s.Ceiling > 0 { + if err := r.agentCeiling(s, fields["agent"]); err != nil { + return err + } + } ts, err := r.append(session, event, typed) out = Event{TS: ts, Session: session, Event: event} return err diff --git a/internal/core/implement/log_fields_test.go b/internal/core/implement/log_fields_test.go new file mode 100644 index 000000000..65fb3ac4a --- /dev/null +++ b/internal/core/implement/log_fields_test.go @@ -0,0 +1,209 @@ +package implement + +import ( + "errors" + "strings" + "testing" + "time" +) + +// TestLogRefusesAnEventMissingTheFieldsTheReportCounts: a lane_close with no +// outcome and an agent_end with no role, model or minutes are refused at log +// time with nothing written, naming the field (iss-2609240646555891); the same +// events with their fields are written. +func TestLogRefusesAnEventMissingTheFieldsTheReportCounts(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + before := len(eventNames(t, r)) + for name, c := range map[string]struct { + event string + fields map[string]string + want string + }{ + "lane_close without outcome": {EventLaneClose, map[string]string{"lane": "l1", "pr": "12"}, "outcome"}, + "lane_close without lane": {EventLaneClose, map[string]string{"outcome": "merged"}, "lane"}, + "agent_end without minutes": {EventAgentEnd, map[string]string{"agent": "a1", "role": "implementer", "model": "opus"}, "minutes"}, + "agent_end without role": {EventAgentEnd, map[string]string{"agent": "a1", "model": "opus", "minutes": "3"}, "role"}, + "agent_end without model": {EventAgentEnd, map[string]string{"agent": "a1", "role": "implementer", "minutes": "3"}, "model"}, + "agent_end minutes not a number": {EventAgentEnd, + map[string]string{"agent": "a1", "role": "implementer", "model": "opus", "minutes": "ten"}, "minutes"}, + "agent_start without agent": {EventAgentStart, map[string]string{"role": "implementer", "model": "opus"}, "agent"}, + } { + _, err := r.Log("alpha", c.event, c.fields) + if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v; want a refusal naming %s", name, err, c.want) + } + } + if after := len(eventNames(t, r)); after != before { + t.Fatalf("refused log calls wrote %d lines", after-before) + } + if _, err := r.Log("alpha", EventLaneClose, map[string]string{"lane": "l1", "outcome": "merged"}); err != nil { + t.Fatalf("a complete lane_close: %v", err) + } + if _, err := r.Log("alpha", EventAgentStart, map[string]string{"agent": "a1", "role": "implementer", "model": "opus"}); err != nil { + t.Fatalf("a complete agent_start: %v", err) + } + // Any of the three minute keys the report reads satisfies the field. + if _, err := r.Log("alpha", EventAgentEnd, map[string]string{"agent": "a1", "role": "implementer", "model": "opus", "wall_min": "4"}); err != nil { + t.Fatalf("a complete agent_end: %v", err) + } +} + +// TestTheEvidenceEventsAreLoggable: the run's evidence events — intervention, +// stop and decision — are written by the verb with their fields, their closed +// kind and their timestamps checked, and refused without the fields that make +// them evidence. +func TestTheEvidenceEventsAreLoggable(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + good := map[string]map[string]string{ + EventIntervention: {"at": "2026-09-28T09:20:00Z", "kind": "account", "by": "product thinker", + "what": "switched account", "why": "limit reached", "detected_after_min": "20", "autonomy_gap": "no account failover"}, + EventStop: {"cause": "CI-only failure", "last_productive": "2026-09-28T10:26:12Z", "noticed_after_min": "6", + "recovery": "reproduced locally"}, + EventDecision: {"at": "2026-09-28T09:45:00Z", "what": "merge drainH first", "alternative": "merge integ9 first", + "why": "smaller diff"}, + } + for ev, f := range good { + if _, err := r.Log("alpha", ev, f); err != nil { + t.Fatalf("log %s: %v", ev, err) + } + got := lastEvent(t, r, ev) + for k, v := range f { + if got.String(k) != v { + t.Errorf("%s field %s = %q, want %q", ev, k, got.String(k), v) + } + } + } + before := len(eventNames(t, r)) + for name, c := range map[string]struct { + event string + fields map[string]string + want string + }{ + "intervention of an unknown kind": {EventIntervention, map[string]string{"kind": "coffee", "by": "x", "what": "x", "why": "x", "autonomy_gap": "x"}, "kind"}, + "intervention without its gap": {EventIntervention, map[string]string{"kind": "ruling", "by": "x", "what": "x", "why": "x"}, "autonomy_gap"}, + "intervention with a bad at": {EventIntervention, map[string]string{"at": "yesterday", "kind": "ruling", "by": "x", "what": "x", "why": "x", "autonomy_gap": "x"}, "at"}, + "intervention detected not a number": {EventIntervention, + map[string]string{"kind": "ruling", "by": "x", "what": "x", "why": "x", "autonomy_gap": "x", "detected_after_min": "soon"}, "detected_after_min"}, + "stop without a cause": {EventStop, map[string]string{"recovery": "x"}, "cause"}, + "stop with a bad last_productive": {EventStop, map[string]string{"cause": "x", "last_productive": "noon"}, "last_productive"}, + "decision without the alternative": {EventDecision, map[string]string{"what": "x", "why": "x"}, "alternative"}, + } { + _, err := r.Log("alpha", c.event, c.fields) + if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v; want a refusal naming %s", name, err, c.want) + } + } + if after := len(eventNames(t, r)); after != before { + t.Fatalf("refused log calls wrote %d lines", after-before) + } +} + +// TestCeilingOverrunIsALoggableEvent: going over the ceiling has its own event, +// carrying the agents alive, the ceiling, the lane and the minutes over +// (iss-2609240646549900), and each is required. +func TestCeilingOverrunIsALoggableEvent(t *testing.T) { + r, _ := newRun(t) + join(t, r, "alpha", RoleFirst) + f := map[string]string{"alive": "5", "ceiling": "4", "lane": "cut", "minutes": "1"} + if _, err := r.Log("alpha", EventCeilingOverrun, f); err != nil { + t.Fatalf("log ceiling_overrun: %v", err) + } + for _, k := range []string{"alive", "ceiling", "lane", "minutes"} { + short := map[string]string{} + for kk, v := range f { + if kk != k { + short[kk] = v + } + } + if _, err := r.Log("alpha", EventCeilingOverrun, short); !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), k) { + t.Errorf("ceiling_overrun without %s: %v; want a refusal naming it", k, err) + } + } +} + +// TestAgentStartIsRefusedAtTheCeiling: the verb counts the agents a session +// has declared alive — its agent_start lines since it joined whose agent has no +// agent_end after — and refuses an agent_start that would take the count past +// the session's ceiling, logging the refusal (iss-2609240646542516). A session +// that stated no ceiling is not counted against one. +func TestAgentStartIsRefusedAtTheCeiling(t *testing.T) { + r, c := newRun(t) + if _, err := r.Join("alpha", RoleFirst, "", "", 2); err != nil { + t.Fatal(err) + } + start := func(agent string) error { + c.advance(time.Minute) + _, err := r.Log("alpha", EventAgentStart, map[string]string{"agent": agent, "role": "implementer", "model": "opus"}) + return err + } + end := func(agent string) error { + c.advance(time.Minute) + _, err := r.Log("alpha", EventAgentEnd, map[string]string{"agent": agent, "role": "implementer", "model": "opus", "minutes": "1"}) + return err + } + if err := start("a1"); err != nil { + t.Fatal(err) + } + if err := start("a2"); err != nil { + t.Fatal(err) + } + v, err := r.Check("alpha", StepReview, nil) + if err != nil || v.AgentsAlive != 2 || v.Ceiling != 2 { + t.Fatalf("check at the ceiling = %+v, %v; want 2 alive of 2", v, err) + } + err = start("a3") + if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "ceiling") { + t.Fatalf("a third agent under a ceiling of 2 = %v; want a refusal", err) + } + ref := lastEvent(t, r, EventRefusal) + if ref.String("condition") != "agent_ceiling" || ref.String("agent") != "a3" || ref.String("alive") != "2" || ref.String("ceiling") != "2" { + t.Fatalf("refusal line = %+v", ref.Fields) + } + if got := lastEvent(t, r, EventAgentStart); got.String("agent") != "a2" { + t.Fatalf("the refused agent_start was written: %+v", got.Fields) + } + // Restarting an agent already alive does not count it twice. + if err := start("a2"); err != nil { + t.Fatalf("restating a live agent: %v", err) + } + if err := end("a1"); err != nil { + t.Fatal(err) + } + if err := start("a3"); err != nil { + t.Fatalf("a slot freed by an agent_end: %v", err) + } + + // No ceiling stated: nothing to count against. + join(t, r, "beta", RoleSecond) + for i := 0; i < 5; i++ { + if _, err := r.Log("beta", EventAgentStart, map[string]string{"agent": "b" + string(rune('0'+i)), "role": "reviewer", "model": "fable"}); err != nil { + t.Fatalf("beta agent %d: %v", i, err) + } + } +} + +// TestAgentsAliveCountsOnlySinceTheSessionJoined: a session that left and joined +// again starts from none alive — an agent its earlier self never ended does not +// hold a slot for ever. +func TestAgentsAliveCountsOnlySinceTheSessionJoined(t *testing.T) { + r, c := newRun(t) + if _, err := r.Join("alpha", RoleFirst, "", "", 1); err != nil { + t.Fatal(err) + } + if _, err := r.Log("alpha", EventAgentStart, map[string]string{"agent": "old", "role": "implementer", "model": "opus"}); err != nil { + t.Fatal(err) + } + c.advance(time.Minute) + if _, err := r.Leave("alpha", "rotation"); err != nil { + t.Fatal(err) + } + c.advance(time.Minute) + if _, err := r.Join("alpha", RoleFirst, "", "", 1); err != nil { + t.Fatal(err) + } + if _, err := r.Log("alpha", EventAgentStart, map[string]string{"agent": "new", "role": "implementer", "model": "opus"}); err != nil { + t.Fatalf("an agent after a rejoin: %v", err) + } +} diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go index e73de84d8..deaf39c0a 100644 --- a/internal/core/implement/loop/check.go +++ b/internal/core/implement/loop/check.go @@ -90,7 +90,11 @@ const maxIntentBytes = 256 * 1024 // - steps: the spec's `## Steps` reads, and leaves a step to build. // - peers: no peer holds the record — no sibling worktree or local branch // holds it in another bucket, and no session holds a live claim on it. -func Check(repoRoot, key string) (CheckResult, error) { +func Check(repoRoot, key string) (CheckResult, error) { return check(repoRoot, key, "") } + +// check is Check on behalf of session: a live claim session itself holds on the +// record is its own, not a peer's. An empty session owns no claim. +func check(repoRoot, key, session string) (CheckResult, error) { res := CheckResult{Key: key} if row, ok := keyCheck(key); !ok { res.Checks = append(res.Checks, row) @@ -131,7 +135,7 @@ func Check(repoRoot, key string) (CheckResult, error) { res.steps = steps res.Checks = append(res.Checks, stepsRow) - peersRow, err := peersCheck(repoRoot, ready) + peersRow, err := peersCheck(repoRoot, ready, session) if err != nil { return res, err } @@ -291,8 +295,9 @@ func stepsCheck(repoRoot string, r intent.ReadyResult) (CheckRow, []PendingStep, // branch holding the intent in another bucket than this checkout's — a lane // that shipped or re-drafted it), and the run's claim store (a session holding // a live claim on it). A peer holding the record in the same bucket holds a -// copy, not the record: every branch cut from the default branch does. -func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { +// copy, not the record: every branch cut from the default branch does. A live +// claim held by session — the one the build is started for — is its own. +func peersCheck(repoRoot string, r intent.ReadyResult, session string) (CheckRow, error) { row := CheckRow{Name: CheckPeers} rep, err := peers.Scan(repoRoot) if err != nil { @@ -321,6 +326,9 @@ func peersCheck(repoRoot string, r intent.ReadyResult) (CheckRow, error) { } for _, c := range claims { if (c.Live || c.Unreadable) && recordid.SameID(c.Record, r.IntentID) { + if session != "" && !c.Unreadable && c.Session == session { + continue + } if c.Unreadable { holders = append(holders, "an unreadable claim file holds it") continue diff --git a/internal/core/implement/loop/claim_test.go b/internal/core/implement/loop/claim_test.go new file mode 100644 index 000000000..1797737ba --- /dev/null +++ b/internal/core/implement/loop/claim_test.go @@ -0,0 +1,114 @@ +package loop + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/implement" +) + +// sharedRun opens the shared run state for repo under the test's HOME and joins +// session to it. +func sharedRun(t *testing.T, root, sha, session string) *implement.Run { + t.Helper() + run, err := implement.Open(strings.TrimSpace(sha)) + if err != nil { + t.Fatal(err) + } + if _, err := run.Join(session, implement.RoleFirst, "", "", 0); err != nil { + t.Fatal(err) + } + return run +} + +// TestAStartForASessionClaimsTheIntentWhereAnotherCheckoutSeesIt: a build +// started for a joined session claims its intent in the shared run state, so a +// second build of the same intent from another checkout of the repository — +// one whose lane has neither moved nor claimed anything yet — is refused as +// contention naming the session, not started as a duplicate +// (iss-2609252050506863). +func TestAStartForASessionClaimsTheIntentWhereAnotherCheckoutSeesIt(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + sha := repo.Git("rev-list", "--max-parents=0", "HEAD") + run := sharedRun(t, repo.Root(), sha, "host-a") + + first, err := Start(repo.Root(), "itd-10", Options{Session: "host-a"}) + if err != nil { + t.Fatal(err) + } + if first.Claim == nil || first.Claim.Claim.Session != "host-a" || first.Claim.Claim.Lane != first.RunID { + t.Fatalf("the start reports no claim for its session: %+v", first.Claim) + } + claims, err := run.Claims() + if err != nil || len(claims) != 1 || claims[0].Record != "itd-10" || claims[0].Session != "host-a" || !claims[0].Live { + t.Fatalf("shared claims = %+v, %v; want host-a's live claim on itd-10", claims, err) + } + + // Another checkout of the same repository: same root commit, its own tier. + other := filepath.Join(t.TempDir(), "second") + repo.Git("worktree", "add", "-q", "-b", "second", other) + if err := os.MkdirAll(filepath.Join(other, ".abcd", ".work.local"), 0o755); err != nil { + t.Fatal(err) + } + _, err = Start(other, "itd-10", Options{}) + r := mustRefusal(t, err) + if r.Check != CheckPeers || !r.Contention || !strings.Contains(r.Reason, "host-a") { + t.Fatalf("a second build from another checkout = %+v; want the peers check naming host-a", r) + } + runTierAbsent(t, other) + + // The session's own start again resumes; it is not its own peer. + again, err := Start(repo.Root(), "itd-10", Options{Session: "host-a"}) + if err != nil || !again.Resumed || again.RunID != first.RunID { + t.Fatalf("resume = %+v, %v", again, err) + } +} + +// TestAStartForASessionIsNotRefusedByItsOwnClaim: a session that claimed the +// intent itself (`implement claim`) before building it is not a peer of its own +// build; the claim is renewed for the run. +func TestAStartForASessionIsNotRefusedByItsOwnClaim(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + sha := repo.Git("rev-list", "--max-parents=0", "HEAD") + run := sharedRun(t, repo.Root(), sha, "host-a") + if _, err := run.Claim(implement.ClaimRequest{Session: "host-a", Record: "itd-10", Lane: "mine"}); err != nil { + t.Fatal(err) + } + res, err := Start(repo.Root(), "itd-10", Options{Session: "host-a"}) + if err != nil { + t.Fatalf("a session's own claim refused its build: %v", err) + } + if res.Claim == nil || !res.Claim.Renewed { + t.Fatalf("claim = %+v; want the session's claim renewed for the run", res.Claim) + } + // Without the session named, the same claim is a peer's. + repo2 := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + sha2 := repo2.Git("rev-list", "--max-parents=0", "HEAD") + run2 := sharedRun(t, repo2.Root(), sha2, "host-b") + if _, err := run2.Claim(implement.ClaimRequest{Session: "host-b", Record: "itd-10", Lane: "mine"}); err != nil { + t.Fatal(err) + } + if _, err := Start(repo2.Root(), "itd-10", Options{}); mustRefusal(t, err).Check != CheckPeers { + t.Fatalf("an unnamed start past a live claim: %v", err) + } +} + +// TestAStartForASessionThatHasNotJoinedWritesNothing: the claim is the +// session's, so a session the shared run does not hold is refused before the +// run is created. +func TestAStartForASessionThatHasNotJoinedWritesNothing(t *testing.T) { + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("")) + sha := repo.Git("rev-list", "--max-parents=0", "HEAD") + sharedRun(t, repo.Root(), sha, "host-a") + _, err := Start(repo.Root(), "itd-10", Options{Session: "ghost"}) + if r := mustRefusal(t, err); r.Step != StepClaim || r.Contention || !strings.Contains(r.Reason, "ghost") { + t.Fatalf("a start for an unjoined session = %+v; want the claim step refused naming it", r) + } + runTierAbsent(t, repo.Root()) + if _, err := Start(repo.Root(), "itd-10", Options{Session: "../x"}); err == nil { + t.Fatal("a start for a session that is not a name succeeded") + } + runTierAbsent(t, repo.Root()) +} diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go index d4001ea5c..32e86e56a 100644 --- a/internal/core/implement/loop/loop.go +++ b/internal/core/implement/loop/loop.go @@ -13,8 +13,10 @@ import ( "path/filepath" "time" + "github.com/intentdriven/abcd/internal/core/implement" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" ) // Options are the seams tests set; the zero value is production. @@ -23,8 +25,19 @@ type Options struct { Now func() time.Time // Minter mints the run id; the zero value is production. Minter recordid.Minter + // Session is the joined session of the shared run state + // (~/.abcd/runs//) a new run is started for. When set, Start + // claims the intent for it there, so a build of the same intent from any + // other checkout of the repository sees the run before its lane has moved + // or claimed anything (iss-2609252050506863). Empty, the run holds no + // claim and is invisible to another checkout until its lane shows. + Session string } +// StepClaim is the refusal step of a start whose shared-run claim is refused +// for a reason of the caller's own (a session that has not joined, a bound). +const StepClaim = "claim" + func (o Options) now() time.Time { if o.Now != nil { return o.Now().UTC().Truncate(time.Second) @@ -127,7 +140,11 @@ type StartResult struct { // Checks are the pre-start checks' rows when the call created the run; a // resumed start runs none, and carries none. Checks []CheckRow `json:"checks"` - Next string `json:"next"` + // Claim is the shared-run claim a new run took for Options.Session; nil when + // no session was named or the start resumed a run, and then null in the + // JSON, never absent, so the payload says the run holds no claim. + Claim *implement.ClaimResult `json:"claim"` + Next string `json:"next"` } // StepResult is what Advance and Receipt return. @@ -158,6 +175,15 @@ type StepResult struct { // starting again after a kill loses nothing and repeats nothing, and the checks // run only when a run is created. Only the key's shape is checked before the // lookup, so a path is never built from a key that is not an intent id. +// +// With o.Session named, a new run also claims its intent in the shared run +// state for that session, with the run id as the lane and the longest lease a +// claim takes: the peers check then counts that session's own live claim on the +// intent as its own, and a build from another checkout counts it as a peer's. A +// session that has not joined is refused before anything is created; a claim +// refused under the lock (a racing holder, a second session's bound) leaves no +// run behind; and a run whose state cannot be written releases the claim it +// took. func Start(repoRoot, key string, o Options) (StartResult, error) { if err := tierPresent(repoRoot); err != nil { return StartResult{}, err @@ -168,7 +194,15 @@ func Start(repoRoot, key string, o Options) (StartResult, error) { if res, ok, err := resumeLive(repoRoot, key); err != nil || ok { return res, err } - chk, err := Check(repoRoot, key) + var shared *implement.Run + if o.Session != "" { + run, err := sharedRunFor(repoRoot, o.Session) + if err != nil { + return StartResult{}, err + } + shared = run + } + chk, err := check(repoRoot, key, o.Session) if err != nil { return StartResult{}, err } @@ -197,6 +231,15 @@ func Start(repoRoot, key string, o Options) (StartResult, error) { if err := fsutil.EnsureRealDirAll(repoRoot, runRel(id), dirPerm); err != nil { return fmt.Errorf("creating %s: %w", runRel(id), err) } + var claim *implement.ClaimResult + if shared != nil { + c, err := shared.Claim(implement.ClaimRequest{Session: o.Session, Record: chk.Intent, Lane: id, Lease: implement.MaxLease}) + if err != nil { + _ = root.Remove(runRel(id)) + return claimRefusal(o.Session, chk.Intent, err) + } + claim = &c + } now := o.now() st := State{ SchemaVersion: SchemaVersion, @@ -215,14 +258,52 @@ func Start(repoRoot, key string, o Options) (StartResult, error) { st.Record = append(st.Record, Entry{At: now, Lane: st.Lanes[0].ID, Step: "start", Note: fmt.Sprintf("checks passed; %s opened for step %d of %s (%s)", st.Lanes[0].ID, st.Lanes[0].SpecStep, st.Spec, st.Lanes[0].StepTitle)}) if err := writeState(root, st); err != nil { + if claim != nil && !claim.Renewed { + _, _ = shared.Release(o.Session, chk.Intent) + } return err } res = startResult(st, chk.Checks, false) + res.Claim = claim return nil }) return res, err } +// sharedRunFor opens the shared run state for session, refusing at the claim +// step a checkout with no root commit, a session that is not a name, and a run +// the session has not joined, before anything is created. +func sharedRunFor(repoRoot, session string) (*implement.Run, error) { + sha := gitutil.RootCommit(repoRoot) + run, err := implement.OpenJoined(sha, session) + if err == nil { + _, err = run.Joined(session) + } + if err != nil { + if errors.Is(err, implement.ErrRefused) { + return nil, refuse(StepClaim, "", "", err.Error(), + "join the shared run first: `abcd implement join --session --role first|second`") + } + return nil, err + } + return run, nil +} + +// claimRefusal maps a refused shared-run claim onto the loop's refusal: a +// record another session holds is the peers check's contention, anything else +// the claim step's refusal. +func claimRefusal(session, record string, err error) error { + switch { + case errors.Is(err, implement.ErrContention): + return contend("check", CheckPeers, "", err.Error(), + "take other work, or coordinate with the peer; `abcd implement` shows what each session holds") + case errors.Is(err, implement.ErrRefused): + return refuse(StepClaim, "", "", fmt.Sprintf("session %s cannot claim %s: %v", session, record, err), + "start the build without --session, or from a session whose bounds allow the lane") + } + return err +} + // resumeLive returns the live run for key, under the lock, when this checkout // has one. A checkout with no run directory has none, and the lookup creates // nothing; a run directory that is not a real directory is left to the create diff --git a/internal/core/implement/report.go b/internal/core/implement/report.go index 47425389d..e1496a0f6 100644 --- a/internal/core/implement/report.go +++ b/internal/core/implement/report.go @@ -2,6 +2,7 @@ package implement import ( "math" + "slices" "sort" "time" ) @@ -12,6 +13,9 @@ import ( // when it happened — the last window_mode line at or before it — and each // window to its mode, so a mode's figures are the sum over every window that ran // it. Events before the first window_mode fall in a window labelled "unset". +// One exception: a session_open logged at most JoinGrace before the next +// window_mode belongs to that window — a session joining a second before the +// first sets the mode is joining that window (iss-2609240646544930). // // What each figure reads, so a hand-written line can be written to be counted: // @@ -26,8 +30,18 @@ import ( // that was then abandoned (reported beside agent minutes, never folded into // them, since the backed-off agent may or may not have logged an agent_end); // - ceiling wait: ceiling_wait lines' minutes, per session; +// - ceiling overruns: ceiling_overrun lines, and their minutes over; // - context: per session over the whole run, not per mode, the number of -// context lines and the last used_pct among them. +// context lines and the last used_pct among them; +// - evidence: over the whole run, interventions (by kind, with the minutes +// each went unnoticed), stops (with the minutes before each was noticed) and +// decisions; +// - missing fields: per event and field, the lines lacking a field +// `implement log` requires of that event — lines written by hand, or before +// the requirement, that the figures above read as absent; +// - coverage: each of the measured hand-kept events (CoverageEvents) whose +// last line falls more than CoverageGapAfter before the run's last line, so +// a figure that stops partway through the run says so. // // A session counts as the second when its session_open says role "second" (or // "B", the label the run's hand-written lines use). @@ -35,6 +49,18 @@ import ( // UnsetMode labels the events logged before any window opened. const UnsetMode = "unset" +// JoinGrace is how long before a window_mode a session_open may be logged and +// still count in that window. +const JoinGrace = time.Minute + +// CoverageGapAfter is how long before the run's last line a measured event's +// last line may fall before the report names its coverage as stopping. +const CoverageGapAfter = 6 * time.Hour + +// CoverageEvents are the hand-kept events the report's figures rest on and a +// run writes throughout, so their stopping partway is a gap, not a lull. +var CoverageEvents = []string{EventLaneOpen, EventLaneClose, EventAgentStart, EventAgentEnd, EventGateRun} + // landedOutcomes are the lane_close outcomes that count as landed. var landedOutcomes = map[string]bool{"merged": true, "landed": true} @@ -47,26 +73,31 @@ type SessionTally struct { AgentMinutes float64 `json:"agent_minutes"` BackoffMinutes float64 `json:"backoff_minutes"` CeilingWaitMinutes float64 `json:"ceiling_wait_minutes"` + CeilingOverruns int `json:"ceiling_overruns"` Collisions int `json:"collisions"` } // ModeTally is one division mode's figures over every window that ran it. type ModeTally struct { - Mode string `json:"mode"` - Windows int `json:"windows"` - WallMinutes float64 `json:"wall_minutes"` - LanesOpened int `json:"lanes_opened"` - LanesLanded int `json:"lanes_landed"` - SecondLanesLanded int `json:"second_session_lanes_landed"` - Collisions int `json:"collisions"` - Lapses int `json:"claims_lapsed"` - Backoffs int `json:"backoffs"` - BackoffMinutes float64 `json:"backoff_minutes"` - AgentMinutes float64 `json:"agent_minutes"` - CeilingWaitMinutes float64 `json:"ceiling_wait_minutes"` - Refusals int `json:"refusals"` - LandedPerHour float64 `json:"lanes_landed_per_hour"` - Sessions []SessionTally `json:"sessions"` + Mode string `json:"mode"` + Windows int `json:"windows"` + WallMinutes float64 `json:"wall_minutes"` + LanesOpened int `json:"lanes_opened"` + LanesLanded int `json:"lanes_landed"` + SecondLanesLanded int `json:"second_session_lanes_landed"` + Collisions int `json:"collisions"` + Lapses int `json:"claims_lapsed"` + Backoffs int `json:"backoffs"` + BackoffMinutes float64 `json:"backoff_minutes"` + AgentMinutes float64 `json:"agent_minutes"` + CeilingWaitMinutes float64 `json:"ceiling_wait_minutes"` + // CeilingOverruns are the ceiling_overrun lines, and CeilingOverrunMinutes + // the minutes over they carry. + CeilingOverruns int `json:"ceiling_overruns"` + CeilingOverrunMinutes float64 `json:"ceiling_overrun_minutes"` + Refusals int `json:"refusals"` + LandedPerHour float64 `json:"lanes_landed_per_hour"` + Sessions []SessionTally `json:"sessions"` } // SessionContext is one session's context measurement across the whole run: @@ -81,6 +112,39 @@ type SessionContext struct { LastAt time.Time `json:"last_at"` } +// Evidence is the run's evidence events counted over the whole run: what a run +// that needs no person would have to do without. +type Evidence struct { + Interventions int `json:"interventions"` + InterventionsByKind map[string]int `json:"interventions_by_kind"` + // DetectedAfterMinutes sums the interventions' detected_after_min: how long + // the needs went unnoticed. + DetectedAfterMinutes float64 `json:"detected_after_minutes"` + Stops int `json:"stops"` + // StopNoticedAfterMinutes sums the stops' noticed_after_min. + StopNoticedAfterMinutes float64 `json:"stop_noticed_after_minutes"` + Decisions int `json:"decisions"` +} + +// FieldGap is a field `implement log` requires of an event, and how many of the +// log's lines of that event lack it. +type FieldGap struct { + Event string `json:"event"` + Field string `json:"field"` + Lines int `json:"lines"` + Of int `json:"of"` +} + +// CoverageGap is a measured event whose lines stop partway through the run. +type CoverageGap struct { + Event string `json:"event"` + Lines int `json:"lines"` + Last time.Time `json:"last"` + // RunLast is the run's last line, load samples aside. + RunLast time.Time `json:"run_last"` + HoursBefore float64 `json:"hours_before"` +} + // Report is the derived comparison. type Report struct { Events int `json:"events"` @@ -88,6 +152,12 @@ type Report struct { Modes []ModeTally `json:"modes"` // Context is each session's context measurement, by session id. Context []SessionContext `json:"context"` + // Evidence counts the run's interventions, stops and decisions. + Evidence Evidence `json:"evidence"` + // MissingFields names, per event and required field, the lines lacking it. + MissingFields []FieldGap `json:"missing_fields"` + // Coverage names each measured event whose lines stop partway. + Coverage []CoverageGap `json:"coverage"` // Leader is the mode with the most lanes landed per wall-clock hour, "" when // no mode landed a lane or two modes tie. It is a figure, not a verdict: the // run's report names which mode it would keep, and says why. @@ -109,6 +179,7 @@ func Compare(events []Event, unparsed []Unparsed) Report { } return sorted[i].Event == EventWindowMode && sorted[j].Event != EventWindowMode }) + sorted = joinsIntoTheirWindow(sorted) roles := map[string]string{} for _, e := range sorted { @@ -168,7 +239,9 @@ func Compare(events []Event, unparsed []Unparsed) Report { cur = 0 } w := &windows[cur] - w.last = e.TS + if e.TS.After(w.last) { + w.last = e.TS + } t := get(w.mode) s := sess(w.mode, e.Session) minutes := func(keys ...string) float64 { @@ -209,6 +282,10 @@ func Compare(events []Event, unparsed []Unparsed) Report { m := minutes("minutes") t.CeilingWaitMinutes += m s.CeilingWaitMinutes += m + case EventCeilingOverrun: + t.CeilingOverruns++ + t.CeilingOverrunMinutes += minutes("minutes") + s.CeilingOverruns++ case EventRefusal: t.Refusals++ case EventContext: @@ -226,7 +303,8 @@ func Compare(events []Event, unparsed []Unparsed) Report { closeWindow() rep := Report{Events: len(sorted), Unparsed: unparsed, Modes: []ModeTally{}, Context: []SessionContext{}, - LeaderBasis: "lanes landed per wall-clock hour"} + LeaderBasis: "lanes landed per wall-clock hour", Evidence: evidenceOf(sorted), + MissingFields: missingFields(sorted), Coverage: coverageGaps(sorted)} for _, c := range contexts { rep.Context = append(rep.Context, *c) } @@ -239,6 +317,7 @@ func Compare(events []Event, unparsed []Unparsed) Report { t.LandedPerHour = round2(float64(t.LanesLanded) / (t.WallMinutes / 60)) } t.WallMinutes = round2(t.WallMinutes) + t.CeilingOverrunMinutes = round2(t.CeilingOverrunMinutes) t.Sessions = []SessionTally{} for _, s := range sessions[t.Mode] { t.Sessions = append(t.Sessions, *s) @@ -269,6 +348,148 @@ func Compare(events []Event, unparsed []Unparsed) Report { return rep } +// joinsIntoTheirWindow moves each session_open logged at most JoinGrace before +// the next window_mode to just after that window_mode, keeping every other +// event where it is. events are in time order. +func joinsIntoTheirWindow(events []Event) []Event { + after := map[int][]int{} // window_mode index -> the joins moved behind it + moved := map[int]bool{} + for i, e := range events { + if e.Event != EventSessionOpen { + continue + } + for j := i + 1; j < len(events); j++ { + if events[j].Event != EventWindowMode { + continue + } + if gap := events[j].TS.Sub(e.TS); gap > 0 && gap <= JoinGrace { + after[j] = append(after[j], i) + moved[i] = true + } + break + } + } + if len(moved) == 0 { + return events + } + out := make([]Event, 0, len(events)) + for i, e := range events { + if moved[i] { + continue + } + out = append(out, e) + for _, k := range after[i] { + out = append(out, events[k]) + } + } + return out +} + +// evidenceOf counts the evidence events. +func evidenceOf(events []Event) Evidence { + ev := Evidence{InterventionsByKind: map[string]int{}} + num := func(e Event, key string) float64 { + if v, ok := e.Number(key); ok && v > 0 && !math.IsInf(v, 0) { + return v + } + return 0 + } + for _, e := range events { + switch e.Event { + case EventIntervention: + ev.Interventions++ + kind := e.String("kind") + if kind == "" { + kind = "unstated" + } + ev.InterventionsByKind[kind]++ + ev.DetectedAfterMinutes += num(e, "detected_after_min") + case EventStop: + ev.Stops++ + ev.StopNoticedAfterMinutes += num(e, "noticed_after_min") + case EventDecision: + ev.Decisions++ + } + } + ev.DetectedAfterMinutes = round2(ev.DetectedAfterMinutes) + ev.StopNoticedAfterMinutes = round2(ev.StopNoticedAfterMinutes) + return ev +} + +// missingFields counts, per event and required field, the lines lacking it. A +// field is present when any of its accepted names carries a non-empty value. +func missingFields(events []Event) []FieldGap { + type key struct{ event, field string } + gaps := map[key]int{} + of := map[string]int{} + for _, e := range events { + rules := eventFields[e.Event] + if len(rules) == 0 { + continue + } + of[e.Event]++ + for _, r := range rules { + if r.optional { + continue + } + has := false + for _, n := range r.names { + if e.String(n) != "" { + has = true + break + } + } + if !has { + gaps[key{e.Event, r.names[0]}]++ + } + } + } + out := []FieldGap{} + for k, n := range gaps { + out = append(out, FieldGap{Event: k.event, Field: k.field, Lines: n, Of: of[k.event]}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Event != out[j].Event { + return out[i].Event < out[j].Event + } + return out[i].Field < out[j].Field + }) + return out +} + +// coverageGaps names each measured event whose last line falls more than +// CoverageGapAfter before the run's last line, load samples aside (the load +// check writes them whatever the run is doing). events are in time order. +func coverageGaps(events []Event) []CoverageGap { + var runLast time.Time + last := map[string]time.Time{} + lines := map[string]int{} + for _, e := range events { + if e.Event == EventLoad { + continue + } + if e.TS.After(runLast) { + runLast = e.TS + } + if slices.Contains(CoverageEvents, e.Event) { + lines[e.Event]++ + if e.TS.After(last[e.Event]) { + last[e.Event] = e.TS + } + } + } + out := []CoverageGap{} + for _, ev := range CoverageEvents { + t, ok := last[ev] + if !ok || runLast.Sub(t) <= CoverageGapAfter { + continue + } + out = append(out, CoverageGap{Event: ev, Lines: lines[ev], Last: t, RunLast: runLast, + HoursBefore: round2(runLast.Sub(t).Hours())}) + } + return out +} + // Compare derives the comparison over the run's whole log. func (r *Run) Compare() (Report, error) { events, bad, err := r.ReadLog() diff --git a/internal/core/implement/report_test.go b/internal/core/implement/report_test.go index da6f23c67..9f337e7e8 100644 --- a/internal/core/implement/report_test.go +++ b/internal/core/implement/report_test.go @@ -126,3 +126,120 @@ func TestReadLogReadsEveryDay(t *testing.T) { t.Fatalf("events = %v, %v", evs[0].Event, evs[1].Event) } } + +// TestAJoinJustBeforeTheWindowItOpensIsThatWindows: a session_open logged a +// second before the first session sets the window's mode belongs to that +// window, not the one before (iss-2609240646544930), so its lanes and the +// session itself are reported under one mode. +func TestAJoinJustBeforeTheWindowItOpensIsThatWindows(t *testing.T) { + log := `{"ts":"2026-09-23T06:00:00Z","session":"A","event":"window_mode","mode":"single"} +{"ts":"2026-09-23T06:10:00Z","session":"A","event":"lane_open","lane":"l1"} +{"ts":"2026-09-23T07:00:00Z","session":"B","event":"session_open","role":"second"} +{"ts":"2026-09-23T07:00:01Z","session":"A","event":"window_mode","mode":"claim","window":2} +{"ts":"2026-09-23T07:30:00Z","session":"B","event":"lane_open","lane":"l2"} +{"ts":"2026-09-23T07:40:00Z","session":"B","event":"lane_close","lane":"l2","outcome":"merged"} +` + events, bad := ParseLog("x.jsonl", []byte(log)) + rep := Compare(events, bad) + for _, m := range rep.Modes { + for _, s := range m.Sessions { + if s.Session == "B" && m.Mode != "claim" { + t.Errorf("session B is reported under %s; its join and its lane are the claim window's", m.Mode) + } + } + if m.Mode == "single" && m.WallMinutes != 10 { + t.Errorf("single window wall minutes = %v, want 10 (the join is not its last event)", m.WallMinutes) + } + } + // A join well before the next window stays in the window it happened in. + log2 := `{"ts":"2026-09-23T06:00:00Z","session":"A","event":"window_mode","mode":"single"} +{"ts":"2026-09-23T06:30:00Z","session":"B","event":"session_open","role":"second"} +{"ts":"2026-09-23T07:00:00Z","session":"A","event":"window_mode","mode":"claim","window":2} +` + events, bad = ParseLog("y.jsonl", []byte(log2)) + rep = Compare(events, bad) + if rep.Modes[0].Mode != "single" || len(rep.Modes[0].Sessions) != 2 { + t.Errorf("a join half an hour before the next window moved: %+v", rep.Modes) + } +} + +// TestCeilingOverrunsAreCounted: each ceiling_overrun line is counted in its +// mode and for its session, beside the ceiling waits (iss-2609240646549900). +func TestCeilingOverrunsAreCounted(t *testing.T) { + log := `{"ts":"2026-09-23T06:00:00Z","session":"A","event":"window_mode","mode":"single"} +{"ts":"2026-09-23T06:10:00Z","session":"A","event":"ceiling_overrun","alive":5,"ceiling":4,"lane":"cut","minutes":1} +{"ts":"2026-09-23T06:20:00Z","session":"A","event":"ceiling_overrun","alive":6,"ceiling":5,"lane":"l2","minutes":"12"} +` + events, bad := ParseLog("x.jsonl", []byte(log)) + rep := Compare(events, bad) + if len(rep.Modes) != 1 || rep.Modes[0].CeilingOverruns != 2 || rep.Modes[0].CeilingOverrunMinutes != 13 { + t.Fatalf("modes = %+v; want 2 overruns, 13 minutes over", rep.Modes) + } + if s := rep.Modes[0].Sessions[0]; s.CeilingOverruns != 2 { + t.Fatalf("session tally = %+v", s) + } +} + +// TestTheReportNamesMissingFieldsAndCoverageThatStops: a lane_close with no +// outcome and an agent_end with no minutes are counted by the field they miss, +// and an event kind whose lines stop hours before the run's last line is named +// (iss-2609240646555891) — every line still parses, so nothing else would say. +func TestTheReportNamesMissingFieldsAndCoverageThatStops(t *testing.T) { + log := `{"ts":"2026-09-23T06:00:00Z","session":"A","event":"window_mode","mode":"single"} +{"ts":"2026-09-23T06:05:00Z","session":"A","event":"agent_start","agent":"a1","role":"implementer","model":"opus"} +{"ts":"2026-09-23T07:00:00Z","session":"A","event":"agent_end","agent":"a1","role":"implementer","model":"opus","minutes":55} +{"ts":"2026-09-23T08:00:00Z","session":"A","event":"lane_close","lane":"l1","pr":12,"merge":"abc1234"} +{"ts":"2026-09-23T09:00:00Z","session":"A","event":"agent_end","lane":"l2"} +{"ts":"2026-09-24T09:00:00Z","session":"A","event":"lane_close","lane":"l3","outcome":"merged"} +{"ts":"2026-09-24T09:05:00Z","session":"A","event":"load","load1":3} +` + events, bad := ParseLog("x.jsonl", []byte(log)) + rep := Compare(events, bad) + missing := map[string]int{} + for _, m := range rep.MissingFields { + missing[m.Event+"."+m.Field] = m.Lines + } + for k, want := range map[string]int{"lane_close.outcome": 1, "agent_end.minutes": 1, "agent_end.role": 1, "agent_end.model": 1, "agent_end.agent": 1} { + if missing[k] != want { + t.Errorf("missing %s = %d, want %d (all: %v)", k, missing[k], want, missing) + } + } + if _, ok := missing["lane_close.lane"]; ok { + t.Errorf("a field every line carries is reported missing: %v", missing) + } + stops := map[string]CoverageGap{} + for _, g := range rep.Coverage { + stops[g.Event] = g + } + g, ok := stops[EventAgentEnd] + if !ok || !g.Last.Equal(time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)) || g.HoursBefore != 24 { + t.Errorf("agent_end coverage = %+v, %v; want its lines to stop 24h before the run's last", g, ok) + } + if _, ok := stops[EventAgentStart]; !ok { + t.Errorf("agent_start's coverage stops too: %+v", rep.Coverage) + } + if _, ok := stops[EventLaneClose]; ok { + t.Errorf("lane_close runs to the end and is named: %+v", rep.Coverage) + } +} + +// TestTheReportCountsTheEvidenceEvents: interventions (by kind, with the +// minutes each went unnoticed), stops and decisions are counted over the run; +// the older stop lines that carry none of the evidence fields still count, and +// nothing fails on them. +func TestTheReportCountsTheEvidenceEvents(t *testing.T) { + log := `{"ts":"2026-09-23T06:00:00Z","session":"A","event":"stop","condition":"needs a ruling"} +{"ts":"2026-09-28T09:00:00Z","session":"A","event":"intervention","at":"2026-09-24T02:40:00Z","kind":"file_restore","by":"pt","what":"x","why":"y","autonomy_gap":"z","detected_after_min":"20"} +{"ts":"2026-09-28T09:01:00Z","session":"A","event":"intervention","kind":"ruling","by":"pt","what":"x","why":"y","autonomy_gap":"z"} +{"ts":"2026-09-28T09:02:00Z","session":"A","event":"intervention","kind":"ruling","by":"pt","what":"x","why":"y","autonomy_gap":"z","detected_after_min":5} +{"ts":"2026-09-28T09:03:00Z","session":"A","event":"stop","cause":"CI-only","last_productive":"2026-09-28T08:00:00Z","noticed_after_min":"6","recovery":"r"} +{"ts":"2026-09-28T09:04:00Z","session":"A","event":"decision","what":"x","alternative":"y","why":"z"} +` + events, bad := ParseLog("x.jsonl", []byte(log)) + rep := Compare(events, bad) + ev := rep.Evidence + if ev.Interventions != 3 || ev.InterventionsByKind["ruling"] != 2 || ev.InterventionsByKind["file_restore"] != 1 || + ev.DetectedAfterMinutes != 25 || ev.Stops != 2 || ev.StopNoticedAfterMinutes != 6 || ev.Decisions != 1 { + t.Fatalf("evidence = %+v", ev) + } +} diff --git a/internal/core/implement/session.go b/internal/core/implement/session.go index e68578e66..58a4808a8 100644 --- a/internal/core/implement/session.go +++ b/internal/core/implement/session.go @@ -88,12 +88,17 @@ type Session struct { // MaxCeiling bounds a stated agent ceiling. // -// The ceiling is the second session's own limit on the agents it runs at once, -// on top of the first session's (itd-2609221656373558, criterion 5). abcd runs -// no agent and counts none — agent_start and agent_end are lines the session -// writes by hand — so the ceiling is recorded and reported, never enforced -// here: the session states it on joining, the record and the session_open line -// carry it, and every check reports it back to the session about to act. +// The ceiling is a session's own limit on the agents it runs at once — for the +// second session, on top of the first session's (itd-2609221656373558, +// criterion 5). The session states it on joining, and the record and the +// session_open line carry it. abcd runs no agent, so what it holds the ceiling +// against is what the session declares: the agents its own agent_start and +// agent_end lines leave alive (Run.AgentsAlive). An agent_start past the +// ceiling is refused and the refusal logged, and every check reports the count +// beside the ceiling. An agent the session never logs — a fork, one the host +// started outside the log — is invisible, so the count is a discipline the +// session keeps with the verb's help, not a census of processes +// (iss-2609240646542516). const MaxCeiling = 64 // maxRecordBytes caps a session or claim record on read. @@ -270,6 +275,11 @@ func (r *Run) requireSession(id string) (Session, error) { return s, err } +// Joined returns a joined session's record, or refuses one the run does not +// hold, writing nothing: for a caller that acts for a session in the run state +// and must know it has joined before it creates anything of its own. +func (r *Run) Joined(id string) (Session, error) { return r.requireSession(id) } + // Sessions lists the joined sessions, by join time. func (r *Run) Sessions() ([]Session, error) { out := []Session{} diff --git a/internal/core/intent/ready.go b/internal/core/intent/ready.go index d253704cb..13b043ed9 100644 --- a/internal/core/intent/ready.go +++ b/internal/core/intent/ready.go @@ -139,7 +139,7 @@ func Ready(repoRoot, intentID string) (ReadyResult, error) { // bucketCheck reports the lifecycle-state gate: only a planned intent may be // implemented. For a draft the remedy names the exact route to planned — -// through the maintainer's sign-off, via the planning interview when the +// through the product thinker's sign-off, via the planning interview when the // Acceptance Criteria are not yet enumerable. Terminal buckets carry no remedy: // there is nothing to fix, the answer is simply no. func bucketCheck(it Intent, acCount int, content string) ReadyCheck { @@ -151,7 +151,7 @@ func bucketCheck(it Intent, acCount int, content string) ReadyCheck { case BucketDrafts: c.Detail = fmt.Sprintf("%s is a draft — an intent that is not planned cannot be implemented", it.ID) if acCount > 0 { - c.Remedy = fmt.Sprintf("confirm the Acceptance Criteria with the maintainer, then run `abcd intent plan %s`", it.ID) + c.Remedy = fmt.Sprintf("confirm the Acceptance Criteria with the product thinker, then run `abcd intent plan %s`", it.ID) } else { c.Remedy = fmt.Sprintf("run the planning interview (/abcd:intent) to write and confirm Acceptance Criteria, then run `abcd intent plan %s`", it.ID) } @@ -180,7 +180,7 @@ func acCheck(acCount int) ReadyCheck { c.Detail = fmt.Sprintf("%d top-level bullet(s) in '## Acceptance Criteria'", acCount) } else { c.Detail = "no top-level bullets in '## Acceptance Criteria' (itd-1 discipline)" - c.Remedy = "add at least one Given-When-Then bullet — the planning interview walks this with the maintainer" + c.Remedy = "add at least one Given-When-Then bullet — the planning interview walks this with the product thinker" } return c } diff --git a/internal/core/intent/ready_test.go b/internal/core/intent/ready_test.go index 37ea4f1d0..89e6582a6 100644 --- a/internal/core/intent/ready_test.go +++ b/internal/core/intent/ready_test.go @@ -857,3 +857,29 @@ func TestReadyReportsTheStepsShape(t *testing.T) { }) } } + +// TestReadyRemediesNameTheProductThinker: the readiness remedies that send an +// agent to a human name the role that signs off an intent's acceptance +// criteria — the product thinker — rather than one word for two people +// (itd-2609212137129937). +func TestReadyRemediesNameTheProductThinker(t *testing.T) { + root := t.TempDir() + writeFile(t, root, draftsDir+"/itd-10-alpha.md", draftWithAC("itd-10", "alpha")) + writeFile(t, root, draftsDir+"/itd-11-beta.md", draftSeeded("itd-11", "beta")) + withAC, err := Ready(root, "itd-10") + if err != nil { + t.Fatal(err) + } + seeded, err := Ready(root, "itd-11") + if err != nil { + t.Fatal(err) + } + for _, remedy := range []string{ + checkByName(t, withAC, "bucket").Remedy, + checkByName(t, seeded, "acceptance_criteria").Remedy, + } { + if !strings.Contains(remedy, "the product thinker") { + t.Errorf("remedy %q does not name the product thinker", remedy) + } + } +} diff --git a/internal/core/layered/home_link_test.go b/internal/core/layered/home_link_test.go new file mode 100644 index 000000000..71ea2b295 --- /dev/null +++ b/internal/core/layered/home_link_test.go @@ -0,0 +1,31 @@ +//go:build unix + +package layered + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMachineLayerBehindASymlinkedAbcdHomeIsRefused: what the machine layer +// says decides which model a step reaches, and it was read through a symlinked +// ~/.abcd the rules loader refuses (iss-2609281017573862). The file itself is +// well-formed, owned and owner-only; the refusal is the link's, and loud. A +// symlinked ~/.abcd holding no such file is an absent layer. +func TestMachineLayerBehindASymlinkedAbcdHomeIsRefused(t *testing.T) { + f := newFixture(t) + dotfiles := t.TempDir() + if err := os.Symlink(dotfiles, filepath.Join(f.roots.Home, ".abcd")); err != nil { + t.Fatal(err) + } + if _, err := Load(Config, f.roots); err != nil { + t.Fatalf("a symlinked ~/.abcd holding no config.json must read as an absent layer: %v", err) + } + f.write(filepath.Join(dotfiles, filepath.FromSlash(Config.MachineRel)), `{"pace":{"work_minutes":5}}`) + _, err := Load(Config, f.roots) + if err == nil || !strings.Contains(err.Error(), "~/.abcd is a symlink") { + t.Fatalf("err = %v, want a refusal naming the symlinked ~/.abcd", err) + } +} diff --git a/internal/core/layered/layered.go b/internal/core/layered/layered.go index 5bbfd4e0b..a2f3f4b36 100644 --- a/internal/core/layered/layered.go +++ b/internal/core/layered/layered.go @@ -49,7 +49,6 @@ import ( "fmt" "io" "os" - "path/filepath" "sort" "strings" "unicode/utf8" @@ -251,14 +250,16 @@ func readRepo(repoRoot, rel string) ([]byte, error) { } // readMachine reads ~/.abcd/ through the home-declaration guard: a regular -// file, owned by the caller and writable by nobody else, because what it says -// decides which model a step reaches. An absent file returns (nil, nil). +// file, owned by the caller and writable by nobody else, reached through no +// symlinked directory, because what it says decides which model a step +// reaches. An absent file returns (nil, nil). func readMachine(home, rel string) ([]byte, error) { - p := filepath.Join(home, ".abcd", filepath.FromSlash(rel)) - raw, refusal, err := fsutil.ReadDeclaration(p, MaxFileBytes) + raw, refusal, err := fsutil.ReadHomeDeclaration(home, ".abcd/"+rel, MaxFileBytes) switch refusal { case fsutil.DeclarationOK: return raw, nil + case fsutil.DeclarationBehindSymlink: + return nil, err case fsutil.DeclarationAbsent: if errors.Is(err, os.ErrNotExist) { return nil, nil diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 6a81f4519..3f705b8f8 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -71,6 +71,16 @@ type BannedToken struct { // whole public surface, where a fence is published as readily as prose // (iss-2609252251320133). Set it to override either default. SkipCodeFences *bool `json:"skip_code_fences"` + // ExtraRoots are repo-relative directories or files THIS token alone reads in + // addition to the configuration's Roots. Every text file there is read, not + // only markdown, because a rules file written in JSON is abcd's own text as + // much as a page is; exempt_paths, exempt_if_status, the allow_context escape + // and the fence default apply as they do under Roots. It widens one ban + // without arming the rest of the family — a spelling or present-tense token — + // over trees that are not documentation (itd-2609212137129937: the role word + // is refused in the command pages and the rules files). A file the Roots walk + // already read is not read twice. + ExtraRoots []string `json:"extra_roots,omitempty"` } // skipFences resolves the SkipCodeFences pointer to its effective value. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 520cac6aa..bdb40ad92 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -253,13 +253,17 @@ func LintAt(cfg Config, repoRoot string, now time.Time) ([]Finding, error) { // shipped scaffold's `roots: ["docs", …]` does exactly this in an adopter // whose docs live elsewhere. Fail loud instead (os.Stat, not IsDir: `roots` // legitimately admits files such as README.md) — GitHub #360. - if _, err := os.Stat(rootAbs); err != nil { + st, err := os.Stat(rootAbs) + if err != nil { if os.IsNotExist(err) { return nil, &configError{"roots entry " + quote(root) + " does not exist; a configured root that does not resolve silently disarms every per-file rule for that tree — fix the roots list or create the tree"} } return nil, err } + if err := markdownRoot(root, st); err != nil { + return nil, err + } ignored := ignoredUnderRoot(repoRoot, root) mdFiles, err := markdownFilesPruned(rootAbs, &ignored) if err != nil { @@ -430,6 +434,12 @@ func LintAt(cfg Config, repoRoot string, now time.Time) ([]Finding, error) { findings = append(findings, nf...) } + tf, err := lintTokenExtraRoots(cfg, repoRoot, scanned) + if err != nil { + return nil, err + } + findings = append(findings, tf...) + // stray_root_docs is repo-root scoped and non-recursive — independent of // cfg.Roots, so it runs once, outside the per-root loop. if strayCfg, ok := cfg.Rules["stray_root_docs"]; ok && strayCfg.Enabled { @@ -2998,7 +3008,11 @@ func DocumentsInRoots(cfg Config, repoRoot string) (int, error) { return 0, &configError{"roots entry " + quote(root) + " " + err.Error() + "; the lint reads only inside the repository"} } - if _, err := os.Stat(rootAbs); err != nil { + st, err := os.Stat(rootAbs) + if err != nil { + return 0, err + } + if err := markdownRoot(root, st); err != nil { return 0, err } ignored := ignoredUnderRoot(repoRoot, root) @@ -3011,6 +3025,18 @@ func DocumentsInRoots(cfg Config, repoRoot string) (int, error) { return n, nil } +// markdownRoot refuses a roots entry that is a file but not markdown. The +// per-root walk keeps markdown alone, so such a root would contribute nothing +// while every rule reported it clean (iss-2609281045487620); a ban meant to +// reach a non-markdown file declares it in that token's extra_roots. +func markdownRoot(root string, st os.FileInfo) error { + if st.IsDir() || hasMarkdownExt(st.Name()) { + return nil + } + return &configError{"roots entry " + quote(root) + + " is not markdown; the per-file rules read markdown alone, so it would be checked by nothing — name a non-markdown file in a banned token's extra_roots instead"} +} + func markdownFiles(rootAbs string) ([]string, error) { return markdownFilesPruned(rootAbs, nil) } @@ -3168,36 +3194,63 @@ const nameTokenPrefix = "names/" // lintNameRoots runs the name gate — the `names/` banned tokens alone — over // cfg.NameRoots (iss-279). A name ban is about the whole public surface, not // the documentation's writing, so it reads every text file there, markdown or -// not; the rest of the token family stays a docs rule. Roots are contained and -// gitignore-pruned exactly as the per-root walk's are, a file that walk already -// read is not read twice, a binary file (a NUL in it) is not text, and -// exempt_paths / exempt_if_status excuse a file here as they do there. -func lintNameRoots(cfg Config, repoRoot string, scanned map[string]bool) ([]Finding, error) { +// not; the rest of the token family stays a docs rule. +func lintNameRoots(cfg Config, repoRoot string, walked map[string]bool) ([]Finding, error) { var names []BannedToken for _, t := range cfg.BannedTokens { if strings.HasPrefix(t.ID, nameTokenPrefix) { names = append(names, t) } } - checker, err := NewTokenChecker(names) + return lintTokensOver(cfg, repoRoot, walked, names, cfg.NameRoots, "name_roots", "the name gate") +} + +// lintTokenExtraRoots runs each banned token that declares extra_roots over +// those roots, that token alone (itd-2609212137129937): a ban widened past the +// documentation without arming the rest of the family there. +func lintTokenExtraRoots(cfg Config, repoRoot string, walked map[string]bool) ([]Finding, error) { + var out []Finding + for _, t := range cfg.BannedTokens { + if len(t.ExtraRoots) == 0 { + continue + } + fs, err := lintTokensOver(cfg, repoRoot, walked, []BannedToken{t}, t.ExtraRoots, + "extra_roots of banned token "+quote(t.ID), "the "+quote(t.ID)+" ban") + if err != nil { + return nil, err + } + out = append(out, fs...) + } + return out, nil +} + +// lintTokensOver runs tokens over roots beyond cfg.Roots, reading every text +// file there. Roots are contained and gitignore-pruned exactly as the per-root +// walk's are, a file that walk already read (walked) or this pass already read +// is not read twice, a binary file (a NUL in it) is not text, and exempt_paths / +// exempt_if_status excuse a file here as they do there. key names the +// configuration field in a refusal and gate the check a missing root disarms. +func lintTokensOver(cfg Config, repoRoot string, walked map[string]bool, tokens []BannedToken, roots []string, key, gate string) ([]Finding, error) { + checker, err := NewTokenChecker(tokens) if err != nil || checker.Len() == 0 { return nil, err } + seen := map[string]bool{} var out []Finding - for _, root := range cfg.NameRoots { + for _, root := range roots { if err := containedRepoPath(root); err != nil { - return nil, &configError{"name_roots entry " + quote(root) + " " + err.Error() + + return nil, &configError{key + " entry " + quote(root) + " " + err.Error() + "; the lint reads only inside the repository"} } rootAbs := filepath.Join(repoRoot, root) if err := resolvedInsideRoot(repoRoot, rootAbs); err != nil { - return nil, &configError{"name_roots entry " + quote(root) + " " + err.Error() + + return nil, &configError{key + " entry " + quote(root) + " " + err.Error() + "; the lint reads only inside the repository"} } if _, err := os.Stat(rootAbs); err != nil { if os.IsNotExist(err) { - return nil, &configError{"name_roots entry " + quote(root) + - " does not exist; a configured root that does not resolve silently disarms the name gate for that tree — fix the list or create the tree"} + return nil, &configError{key + " entry " + quote(root) + + " does not exist; a configured root that does not resolve silently disarms " + gate + " for that tree — fix the list or create the tree"} } return nil, err } @@ -3207,10 +3260,10 @@ func lintNameRoots(cfg Config, repoRoot string, scanned map[string]bool) ([]Find return nil, err } for _, fileAbs := range files { - if scanned[fileAbs] { + if walked[fileAbs] || seen[fileAbs] { continue } - scanned[fileAbs] = true + seen[fileAbs] = true rel := repoRel(repoRoot, fileAbs) if contentExempt(rel, nil, cfg) { continue @@ -3228,8 +3281,8 @@ func lintNameRoots(cfg Config, repoRoot string, scanned map[string]bool) ([]Find // link, its target being read wherever a root reaches it. st, err := os.Stat(realPath) if err != nil { - return nil, errors.New("name_roots file " + quote(rel) + " cannot be examined (" + bareCause(err) + - "); the name gate refuses to pass a file it could not read") + return nil, errors.New(key + " file " + quote(rel) + " cannot be examined (" + bareCause(err) + + "); " + gate + " refuses to pass a file it could not read") } if !st.Mode().IsRegular() { continue diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index c5f3490a1..d2f7ca98d 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -278,8 +278,10 @@ func TestDocsLintHarnessNameGate(t *testing.T) { // The real docs-lint.json roots are ["docs", "README.md"]; both must resolve // now that an unresolvable configured root fails loud (GitHub #360). writeFile(t, root, "README.md", "# readme\n") - // Its name_roots must resolve too (iss-279). - for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md"} { + // Its name_roots must resolve too (iss-279), and the role ban's extra_roots + // (itd-2609212137129937). + for _, r := range []string{".abcd/README.md", "AGENTS.md", ".github/CONTRIBUTING.md", "scripts/README.md", + "commands/README.md", ".abcd/rules.json", "internal/core/rules/defaults/rules.json"} { writeFile(t, root, r, "# t\n") } // So must links_resolve's extra roots (iss-46), read from the config so a diff --git a/internal/core/lint/roleban_test.go b/internal/core/lint/roleban_test.go new file mode 100644 index 000000000..2025261bc --- /dev/null +++ b/internal/core/lint/roleban_test.go @@ -0,0 +1,78 @@ +package lint + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" +) + +// TestRepoRoleWordIsRefusedOnEveryLintRoot pins this repository's own ban +// (itd-2609212137129937 AC2): the one role-vocabulary entry in the docs lint's +// banned_tokens refuses the retired word on every root the spec names — the +// docs roots, the command pages, this repository's rules overrides and the +// bundled rules source — as a blocker, and the marked escape is the only way +// past it. +func TestRepoRoleWordIsRefusedOnEveryLintRoot(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", ".abcd", "docs-lint.json")) + if err != nil { + t.Fatal(err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + word := "main" + "tainer" // spelled apart so this file never trips the ban it tests + var ban *BannedToken + for i, tok := range cfg.BannedTokens { + if regexp.MustCompile(tok.Pattern).MatchString("a " + word + "'s ruling") { + if ban != nil { + t.Fatalf("two banned tokens match the role word (%s, %s); the list holds one", ban.ID, tok.ID) + } + ban = &cfg.BannedTokens[i] + } + } + if ban == nil { + t.Fatal("no docs-lint banned token refuses the role word") + } + if ban.Severity != "blocker" { + t.Errorf("the role ban is %q, want blocker", ban.Severity) + } + for _, want := range []string{"commands", ".abcd/rules.json", "internal/core/rules/defaults"} { + found := false + for _, r := range ban.ExtraRoots { + found = found || r == want + } + if !found { + t.Errorf("the role ban does not reach %s (extra_roots %q)", want, ban.ExtraRoots) + } + } + + // Run the repository's own entry over a tree shaped like this one. + root := t.TempDir() + pages := map[string]string{ + "docs/page.md": "Ask the " + word + ".\n", + "README.md": "The " + word + " decides.\n", + "commands/verb.md": "the " + word + "'s move\n", + ".abcd/rules.json": "{\"r\": \"a " + word + " decision\"}\n", + "internal/core/rules/defaults/rules.json": "{\"r\": \"a " + word + " decision\"}\n", + "commands/credit.md": "the " + word + " of a tool we use \n", + } + for p, body := range pages { + writeFile(t, root, p, body) + } + lintCfg := Config{Roots: []string{"docs", "README.md"}, BannedTokens: []BannedToken{*ban}} + fs, err := Lint(lintCfg, root) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{"docs/page.md", "README.md", "commands/verb.md", ".abcd/rules.json", "internal/core/rules/defaults/rules.json"} { + if !hasFinding(fs, filepath.FromSlash(p), ban.ID, 1) { + t.Errorf("the role word in %s was not refused: %+v", p, fs) + } + } + if hasFinding(fs, filepath.FromSlash("commands/credit.md"), ban.ID, 1) { + t.Errorf("the marked escape did not suppress the finding: %+v", fs) + } +} diff --git a/internal/core/lint/tokenroots_test.go b/internal/core/lint/tokenroots_test.go new file mode 100644 index 000000000..201d4dfe6 --- /dev/null +++ b/internal/core/lint/tokenroots_test.go @@ -0,0 +1,107 @@ +package lint + +import ( + "path/filepath" + "strings" + "testing" +) + +func roleToken(extra ...string) BannedToken { + return BannedToken{ + ID: "roles/test", Pattern: `(?i)\bgatekeeper`, Message: "names neither role", + Severity: "blocker", Successor: "the product thinker or the technical facilitator", + AllowContext: []string{`(?i)\n") + writeFile(t, root, "commands/old/history.md", "the gatekeeper said so\n") + writeFile(t, root, "rules/rules.json", "{\n \"rule\": \"a gatekeeper decision\"\n}\n") + cfg := Config{ + Roots: []string{"docs"}, + BannedTokens: []BannedToken{roleToken("commands", "rules/rules.json", "docs"), { + ID: "present_tense/previously", Pattern: `(?i)\bpreviously\b`, Message: "narration", + Severity: "blocker", Successor: "present tense", AllowContext: []string{`docs-lint: allow`}, + }}, + ExemptPaths: []string{filepath.Join("commands", "old") + string(filepath.Separator)}, + } + fs, err := Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + for _, want := range []struct { + file string + line int + }{{filepath.Join("docs", "page.md"), 3}, {filepath.Join("commands", "verb.md"), 3}, {filepath.Join("rules", "rules.json"), 2}} { + if !hasFinding(fs, want.file, "roles/test", want.line) { + t.Errorf("the role token did not reach %s:%d: %+v", want.file, want.line, fs) + } + } + if n := countRule(fs, "roles/test"); n != 3 { + t.Errorf("want 3 role findings (fence, escape and exempt path skipped, docs reported once), got %d: %+v", n, fs) + } + if n := countRule(fs, "present_tense/previously"); n != 0 { + t.Errorf("another token ran over the role token's extra roots: %+v", fs) + } +} + +// An extra root that does not resolve would disarm the token for that tree while +// the lint reported clean, so it is a configuration error, as a missing root is; +// an extra root reaching outside the repository is refused before it is read. +func TestTokenExtraRootsRefuseAMissingOrEscapingRoot(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "docs/page.md", "# Page\n") + for _, extra := range []string{"commands", "../outside"} { + cfg := Config{Roots: []string{"docs"}, BannedTokens: []BannedToken{roleToken(extra)}} + if _, err := Lint(cfg, root); err == nil || !strings.Contains(err.Error(), "extra_roots") { + t.Errorf("extra root %q: want a configuration error naming extra_roots, got %v", extra, err) + } + } +} + +// The configuration decoder is strict, so the key must load from JSON. +func TestTokenExtraRootsLoadFromConfig(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "commands/verb.md", "the gatekeeper\n") + writeFile(t, root, "docs/page.md", "# Page\n") + writeFile(t, root, "cfg.json", `{"roots":["docs"],"banned_tokens":[{"id":"roles/test","pattern":"(?i)\\bgatekeeper","message":"m","severity":"blocker","successor":"s","allow_context":["x"],"extra_roots":["commands"]}],"rules":{}}`) + cfg, err := LoadConfig(filepath.Join(root, "cfg.json")) + if err != nil { + t.Fatal(err) + } + fs, err := Lint(cfg, root) + if err != nil { + t.Fatal(err) + } + if !hasFinding(fs, filepath.Join("commands", "verb.md"), "roles/test", 1) { + t.Errorf("extra_roots from the config file did not reach the command page: %+v", fs) + } +} + +// A file named in roots that is not markdown was walked, kept by the markdown +// filter as nothing, and reported clean: a ban meant to reach a rules file read +// zero lines of it and said so with a green. roots holds markdown; a +// non-markdown file there is a configuration error that points at extra_roots. +func TestRootsRefuseANonMarkdownFile(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "docs/page.md", "# Page\n") + writeFile(t, root, "rules.json", "{\"r\": \"a gatekeeper decision\"}\n") + cfg := Config{Roots: []string{"docs", "rules.json"}, BannedTokens: []BannedToken{roleToken()}} + if _, err := Lint(cfg, root); err == nil || !strings.Contains(err.Error(), "extra_roots") { + t.Errorf("want a configuration error naming the non-markdown root and extra_roots, got %v", err) + } + if _, err := DocumentsInRoots(cfg, root); err == nil { + t.Error("DocumentsInRoots counted a non-markdown root as zero documents instead of refusing it") + } +} diff --git a/internal/core/oracle/connect.go b/internal/core/oracle/connect.go index 5383cb8e0..7b3bd6f4c 100644 --- a/internal/core/oracle/connect.go +++ b/internal/core/oracle/connect.go @@ -228,12 +228,19 @@ var configLockTimeout = 5 * time.Second // after this one's check is refused rather than replaced. func writeProviderBlock(home, name string, block map[string]any) error { origin := layered.Config.MachineOrigin() - p := filepath.Join(home, ".abcd", filepath.FromSlash(layered.Config.MachineRel)) + rel := ".abcd/" + layered.Config.MachineRel + // The machine layer refuses a file behind a symlinked ~/.abcd, so a block + // written through the link would land wherever it points (a dotfiles + // checkout) and never be read back. + if err := fsutil.HomeScopeLink(home, rel); err != nil { + return fmt.Errorf("oracle adapter: the provider block was not written to %s: %v", origin, err) + } + p := filepath.Join(home, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { return fmt.Errorf("oracle adapter: ~/.abcd could not be created, so the provider block was not written") } err := fsutil.WithFileLock(filepath.Join(filepath.Dir(p), configLockFileName), configLockTimeout, func() error { - return writeProviderBlockLocked(p, name, block) + return writeProviderBlockLocked(home, name, block) }) switch { case errors.Is(err, fsutil.ErrLockContention): @@ -248,10 +255,12 @@ func writeProviderBlock(home, name string, block map[string]any) error { // writeProviderBlockLocked is writeProviderBlock's read, change and write, // run under the file's lock. -func writeProviderBlockLocked(p, name string, block map[string]any) error { +func writeProviderBlockLocked(home, name string, block map[string]any) error { origin := layered.Config.MachineOrigin() + rel := ".abcd/" + layered.Config.MachineRel + p := filepath.Join(home, filepath.FromSlash(rel)) root := map[string]json.RawMessage{} - raw, refusal, err := fsutil.ReadDeclaration(p, layered.MaxFileBytes) + raw, refusal, err := fsutil.ReadHomeDeclaration(home, rel, layered.MaxFileBytes) switch { case refusal == fsutil.DeclarationAbsent && errors.Is(err, os.ErrNotExist): case refusal != fsutil.DeclarationOK || err != nil: diff --git a/internal/core/oracle/home_link_test.go b/internal/core/oracle/home_link_test.go new file mode 100644 index 000000000..bc1ed5ca9 --- /dev/null +++ b/internal/core/oracle/home_link_test.go @@ -0,0 +1,42 @@ +//go:build unix + +package oracle + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestConnectRefusesASymlinkedAbcdHome: the provider block (and, in the abcd +// home, the key beside it) is written into ~/.abcd, so through a ~/.abcd +// symlinked into a dotfiles checkout it would land in that repository — and +// the machine layer that reads it back refuses it there (iss-2609260958587561's +// shape, in the setup's other writer). Refused loudly, and nothing is left +// behind the link, in either key home the setup can write. +func TestConnectRefusesASymlinkedAbcdHome(t *testing.T) { + for _, keyHome := range []string{KeyHomeNone, KeyHomeABCD} { + t.Run(keyHome, func(t *testing.T) { + p := newProvFake(t, 200, chat("local-model", "ok")) + f := newFx(t) + dotfiles := t.TempDir() + if err := os.Symlink(dotfiles, filepath.Join(f.roots.Home, ".abcd")); err != nil { + t.Fatal(err) + } + req := connectReq(f, p.base()) + req.Provider, req.Home = "desk", keyHome + if keyHome == KeyHomeNone { + req.Key = "" + } + _, err := Connect(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "~/.abcd is a symlink") { + t.Fatalf("err = %v, want a refusal naming the symlinked ~/.abcd", err) + } + if entries, _ := os.ReadDir(dotfiles); len(entries) != 0 { + t.Fatalf("Connect left %d file(s) behind the link, first %q", len(entries), entries[0].Name()) + } + }) + } +} diff --git a/internal/core/rules/defaults/rules.json b/internal/core/rules/defaults/rules.json index 6d314d55a..c4a5c45a1 100644 --- a/internal/core/rules/defaults/rules.json +++ b/internal/core/rules/defaults/rules.json @@ -49,7 +49,7 @@ "rules": [ "Intents are press-release-first; acceptance criteria are BDD (Given/When/Then).", "Personas are always Alice, Bob, Carol — never other names.", - "Lifecycle is drafts/ -> planned/ -> shipped/; a planned intent's adoption is a maintainer decision.", + "Lifecycle is drafts/ -> planned/ -> shipped/; a planned intent's adoption is the product thinker's decision.", "The change that lands a planned intent's work closes its spec in the same change (abcd spec close ), which ships the intent; nothing runs it for you, and a planned intent whose code is on main is invisible to the release cut." ] }, diff --git a/internal/core/rules/home_link_test.go b/internal/core/rules/home_link_test.go new file mode 100644 index 000000000..198355ee3 --- /dev/null +++ b/internal/core/rules/home_link_test.go @@ -0,0 +1,40 @@ +//go:build unix + +package rules + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestTrustedRootsBehindASymlinkedAbcdHomeReAdmitNothing: the rules loader +// refuses a rules.json behind a symlinked ~/.abcd, and trusted-roots — which +// re-admits a root the loader would otherwise refuse — sat in the same home and +// was read through the link (iss-2609281017573862). A well-formed, owned, +// owner-only declaration naming the marker: the ONLY defect is the link. It +// re-admits nothing, and says why. +func TestTrustedRootsBehindASymlinkedAbcdHomeReAdmitNothing(t *testing.T) { + marker := t.TempDir() + dotfiles := t.TempDir() + declareTrusted(t, dotfiles, marker) + home := t.TempDir() + if err := os.Symlink(filepath.Join(dotfiles, ".abcd"), filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + ok, note := trustedRootDeclared(marker) + if ok { + t.Fatal("a trusted-roots declaration behind a symlinked ~/.abcd re-admitted a root") + } + if !strings.Contains(note, TrustedRootsDisplay) || !strings.Contains(note, "~/.abcd is a symlink") { + t.Errorf("the ignored declaration must say it was refused for the link: %q", note) + } + + // The control: the same declaration in a real ~/.abcd is honoured. + t.Setenv("HOME", dotfiles) + if ok, note := trustedRootDeclared(marker); !ok { + t.Fatalf("the same declaration in a real ~/.abcd must re-admit the root; note %q", note) + } +} diff --git a/internal/core/rules/root.go b/internal/core/rules/root.go index 6bb127f76..23f5d25ec 100644 --- a/internal/core/rules/root.go +++ b/internal/core/rules/root.go @@ -358,17 +358,18 @@ func trustedRootDeclared(marker string) (bool, string) { if err != nil || home == "" { return false, "" } - path := filepath.Join(home, filepath.FromSlash(TrustedRootsRelPath)) - // The three-part guard is fsutil.ReadDeclaration's, not this function's: the + // The guard is fsutil.ReadHomeDeclaration's, not this function's: the // three home-scoped declaration records differ in what they declare, never in // what makes a declaration trustworthy, and the copy that skipped two of the // checks was the one whose consequence is code execution // (iss-2609091927085132). Only the WORDING stays here. - raw, refusal, err := fsutil.ReadDeclaration(path, maxTrustedRootsBytes) + raw, refusal, err := fsutil.ReadHomeDeclaration(home, TrustedRootsRelPath, maxTrustedRootsBytes) switch refusal { case fsutil.DeclarationOK: case fsutil.DeclarationAbsent: return false, "" // no declaration is the ordinary case, not a diagnostic. + case fsutil.DeclarationBehindSymlink: + return false, ignoredDeclaration(termsafe.Sanitize(err.Error())) case fsutil.DeclarationNotRegular: return false, ignoredDeclaration("it is not a regular file") case fsutil.DeclarationWritableByOthers: diff --git a/internal/core/rules/rules.go b/internal/core/rules/rules.go index fa9cee5b4..07a7c0101 100644 --- a/internal/core/rules/rules.go +++ b/internal/core/rules/rules.go @@ -410,7 +410,9 @@ func readRepoLayer(repoRoot string) (over RuleSet, ok bool, err error) { // And the ~/.abcd directory itself must not be a symlink when a rules.json sits // behind it, the pre-check the repo's .abcd gets — checked only once a file is // there, because a machine whose ~/.abcd is a dotfiles symlink holding no -// rules.json reads nothing and must keep behaving exactly as it did. +// rules.json reads nothing and must keep behaving exactly as it did. That check +// is fsutil.ReadHomeDeclaration's (fsutil.HomeScopeLink), the one every other +// home-scoped reader and writer applies, so the rule cannot drift per file. // // Every refusal is an error naming the file in tilde form, never a silent // fallback to the defaults: an unreadable user layer that degraded to a partial @@ -422,9 +424,7 @@ func readUserLayer(home string) (over RuleSet, ok bool, err error) { if home == "" || !filepath.IsAbs(home) { return RuleSet{}, false, nil } - dir := filepath.Join(home, ".abcd") - path := filepath.Join(home, filepath.FromSlash(UserRelPath)) - data, refusal, err := fsutil.ReadDeclaration(path, maxRulesFileBytes) + data, refusal, err := fsutil.ReadHomeDeclaration(home, UserRelPath, maxRulesFileBytes) // Absent is the lstat's answer, so a permission error here is a HOME or // ~/.abcd this uid cannot search, never the file's own mode: that reads as // no user layer, as it does for the sibling home-scoped declarations @@ -434,11 +434,10 @@ func readUserLayer(home string) (over RuleSet, ok bool, err error) { if refusal == fsutil.DeclarationAbsent && (os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) || errors.Is(err, fs.ErrPermission)) { return RuleSet{}, false, nil } - if di, lerr := os.Lstat(dir); lerr == nil && di.Mode()&os.ModeSymlink != 0 { - return RuleSet{}, false, fmt.Errorf("rules: ~/.abcd is a symlink (refusing to follow it to %s)", UserDisplayPath) - } switch refusal { case fsutil.DeclarationOK: + case fsutil.DeclarationBehindSymlink: + return RuleSet{}, false, fmt.Errorf("rules: ~/.abcd is a symlink (refusing to follow it to %s)", UserDisplayPath) case fsutil.DeclarationNotRegular: return RuleSet{}, false, fmt.Errorf("rules: %s is not a regular file (a symlink, FIFO or device is refused)", UserDisplayPath) case fsutil.DeclarationWritableByOthers: diff --git a/internal/core/site/installsurface_test.go b/internal/core/site/installsurface_test.go index e0302857f..13ec70288 100644 --- a/internal/core/site/installsurface_test.go +++ b/internal/core/site/installsurface_test.go @@ -563,3 +563,28 @@ func keysOf(m map[string]bool) []string { sort.Strings(out) return out } + +// symlinkedHomeGuard is the test every install form that records the owned +// PATH copy in ~/.abcd/path-entry runs before it fetches anything. +const symlinkedHomeGuard = `[ ! -L "$HOME/.abcd" ]` + +// TestInstallSurfacesRefuseASymlinkedAbcdHome: a form that writes +// ~/.abcd/path-entry refuses a ~/.abcd that is a symlink before it downloads +// anything. The hooks, ahoy and the bootstrap refuse a record behind the link +// (iss-2609281017573862), so a form that wrote one through it would report a +// finished install whose record every reader ignores — and put that record in +// whatever the link points at, a dotfiles checkout typically. +func TestInstallSurfacesRefuseASymlinkedAbcdHome(t *testing.T) { + for _, s := range loadInstallSurfaces(t) { + if !strings.Contains(s.script, ".abcd/path-entry") { + continue + } + t.Run(s.name, func(t *testing.T) { + guard := strings.Index(s.script, symlinkedHomeGuard) + fetch := strings.Index(s.script, "curl ") + if guard < 0 || (fetch >= 0 && guard > fetch) { + t.Errorf("%s writes ~/.abcd/path-entry but does not test %s before its first fetch", s.source, symlinkedHomeGuard) + } + }) + } +} diff --git a/internal/core/statusline/home_link_test.go b/internal/core/statusline/home_link_test.go new file mode 100644 index 000000000..69e5908d4 --- /dev/null +++ b/internal/core/statusline/home_link_test.go @@ -0,0 +1,38 @@ +//go:build unix + +package statusline + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestASettingBehindASymlinkedAbcdHomeIsIgnored: the setting's +// previous_command is a shell command the harness runs, and it was read +// through a symlinked ~/.abcd the rules loader refuses (iss-2609281017573862). +// Behind the link the setting is not the caller's word: the defaults render +// and a note says why. The same file in a real ~/.abcd is taken. +func TestASettingBehindASymlinkedAbcdHomeIsIgnored(t *testing.T) { + dotfiles := t.TempDir() + writeSettings(t, dotfiles, `{"schema_version":1,"previous_command":"/bin/echo hi"}`) + home := t.TempDir() + if err := os.Symlink(filepath.Join(dotfiles, ".abcd"), filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + got, notes, err := LoadFrom(home) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if got.PreviousCommand != "" || got.Installed { + t.Fatalf("a setting behind a symlinked ~/.abcd was taken: %+v", got) + } + if len(notes) != 1 || !strings.Contains(notes[0], "~/.abcd is a symlink") { + t.Fatalf("notes = %q, want one naming the symlinked ~/.abcd", notes) + } + + if got, _, err := LoadFrom(dotfiles); err != nil || got.PreviousCommand != "/bin/echo hi" { + t.Fatalf("the same setting in a real ~/.abcd must be taken: %+v, %v", got, err) + } +} diff --git a/internal/core/statusline/settings.go b/internal/core/statusline/settings.go index 16ba4d35f..69f8d4e7a 100644 --- a/internal/core/statusline/settings.go +++ b/internal/core/statusline/settings.go @@ -187,9 +187,7 @@ func Load() (Settings, []string, error) { // indistinguishable from a setting that was taken. func LoadFrom(home string) (Settings, []string, error) { out := Defaults() - path := filepath.Join(home, filepath.FromSlash(SettingsRelPath)) - - raw, why, err := ReadSettingsFile(path) + raw, why, err := ReadSettingsFile(home) if err != nil { return Settings{}, nil, err } @@ -311,12 +309,19 @@ func refusedPresence(why string, fallback Pair) string { // The guard is the one the two sibling home-scoped declarations use // (rules.trustedRootDeclared, history.localDeclared): lstat first, the three // refusals above, then fsutil.ReadGuarded under the byte cap — one open, -// O_NOFOLLOW, size-checked against both the fstat and the bytes read. -func ReadSettingsFile(path string) (raw []byte, why string, err error) { +// O_NOFOLLOW, size-checked against both the fstat and the bytes read. A file +// reached through a symlinked ~/.abcd is not the caller's word either +// (fsutil.HomeScopeLink, the rule the rules loader applies to rules.json), so +// the file is named by the home it lives in rather than by a path. +func ReadSettingsFile(home string) (raw []byte, why string, err error) { + path := filepath.Join(home, filepath.FromSlash(SettingsRelPath)) fi, err := os.Lstat(path) if err != nil { return nil, "", nil } + if lerr := fsutil.HomeScopeLink(home, SettingsRelPath); lerr != nil { + return nil, lerr.Error(), nil + } if !fi.Mode().IsRegular() { return nil, "it is not a regular file", nil } diff --git a/internal/core/update/update.go b/internal/core/update/update.go index ae3f57082..87aae3add 100644 --- a/internal/core/update/update.go +++ b/internal/core/update/update.go @@ -193,7 +193,7 @@ func Plan(t ahoy.UpdateTarget) *Refusal { return &Refusal{ Shape: string(t.Kind), Detail: "the entry at " + targetPath + " is an abcd-owned link whose binary is gone (a plugin update strands it)", - Remedy: "run `abcd ahoy install` — it repoints the entry at the current plugin binary", + Remedy: "run `abcd ahoy install` — it replaces the entry with a verified copy of the current release, and names the command to run first when no verified copy is available", } case ahoy.UpdateTargetSuperseded: return &Refusal{ diff --git a/internal/fsutil/append_leaf_test.go b/internal/fsutil/append_leaf_test.go new file mode 100644 index 000000000..1c55623be --- /dev/null +++ b/internal/fsutil/append_leaf_test.go @@ -0,0 +1,111 @@ +package fsutil + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// TestAppendLineInRefusesASymlinkedLeaf: the append primitive keeps the leaf +// refusal its read twin ReadGuardedInRoot applies — a symlink or a non-regular +// file at rel is refused, and the file a symlink names is left untouched, even +// when it lies inside the root (iss-2609230720193756). +func TestAppendLineInRefusesASymlinkedLeaf(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "claim.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("claim.json", filepath.Join(dir, "log.jsonl")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "adir"), 0o700); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := AppendLineIn(root, "log.jsonl", []byte(`{"a":1}`), 0o600); !errors.Is(err, ErrNotRegular) { + t.Fatalf("append through a symlinked leaf = %v; want ErrNotRegular", err) + } + if got, _ := os.ReadFile(filepath.Join(dir, "claim.json")); string(got) != "{}\n" { + t.Fatalf("the symlink's target was appended to: %q", got) + } + if err := AppendLineIn(root, "adir", []byte(`{"a":1}`), 0o600); err == nil { + t.Fatal("append to a directory succeeded") + } + // A real file, absent or present, is appended to as before. + for i := 0; i < 2; i++ { + if err := AppendLineIn(root, "real.jsonl", []byte(`{"a":1}`), 0o600); err != nil { + t.Fatalf("append %d to a real file: %v", i, err) + } + } + if got, _ := os.ReadFile(filepath.Join(dir, "real.jsonl")); string(got) != "{\"a\":1}\n{\"a\":1}\n" { + t.Fatalf("real file = %q", got) + } +} + +// TestLockFilesAreTheOwnersAlone: both lock primitives create their lock file +// 0600 — a lock carries nothing another account needs, and 0644 sat beside the +// 0600 files it guards (iss-2609230720193756). +func TestLockFilesAreTheOwnersAlone(t *testing.T) { + dir := t.TempDir() + if err := WithFileLock(filepath.Join(dir, "a.lock"), time.Second, func() error { return nil }); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := WithFileLockIn(root, "b.lock", time.Second, func() error { return nil }); err != nil { + t.Fatal(err) + } + for _, n := range []string{"a.lock", "b.lock"} { + fi, err := os.Stat(filepath.Join(dir, n)) + if err != nil { + t.Fatal(err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Errorf("%s mode = %o, want 600", n, perm) + } + } +} + +// TestAppendLineInRefusesALeafLinkedAfterItsLstat: a symlink planted at the leaf +// between the pre-open Lstat (which saw nothing) and the open is refused, and +// the file it names is left untouched. os.Root follows an in-root leaf link +// whatever flags the open carries, so the refusal rests on a post-open Lstat +// that must name the very file opened (iss-2609281229109140). +func TestAppendLineInRefusesALeafLinkedAfterItsLstat(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "claim.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + planted := false + beforeAppendOpen = func(_ *os.Root, rel string) { + if err := os.Symlink("claim.json", filepath.Join(dir, rel)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + planted = true + } + t.Cleanup(func() { beforeAppendOpen = nil }) + err = AppendLineIn(root, "log.jsonl", []byte(`{"a":1}`), 0o600) + if !planted { + t.Fatal("the seam never ran: the link was not planted between the Lstat and the open") + } + if !errors.Is(err, ErrNotRegular) { + t.Fatalf("append through a leaf linked after its Lstat = %v; want ErrNotRegular", err) + } + if got, _ := os.ReadFile(filepath.Join(dir, "claim.json")); string(got) != "{}\n" { + t.Fatalf("the planted link's target was appended to: %q", got) + } +} diff --git a/internal/fsutil/flock.go b/internal/fsutil/flock.go index 08ead81c7..97c53e8be 100644 --- a/internal/fsutil/flock.go +++ b/internal/fsutil/flock.go @@ -86,11 +86,32 @@ func lockStillNamesFd(lockPath string, fd int) (bool, error) { return held.Dev == named.Dev && held.Ino == named.Ino, nil } +// lockPerm is the mode a lock file is created at: the owner's alone. flock +// needs no write access — a READ-ONLY descriptor holds LOCK_EX — so a lock any +// other local user can open is one they can hold for as long as they like, +// stalling every writer behind it for its whole wait (iss-2609260958587561). +// Every writer that takes the lock opens it O_RDWR, which a 0644 file already +// admitted to the owner alone, so nothing that could take the lock before loses +// it. +const lockPerm = 0o600 + +// tightenLock narrows a lock file an earlier version created group- or +// other-readable to lockPerm, on the descriptor already open, so the file the +// guard judged is the file changed. It is best-effort: a lock this uid does not +// own cannot be changed, and refusing the lock over its mode would fail a +// writer that worked before. +func tightenLock(fd int, mode uint32) { + if mode&0o077 != 0 { + _ = syscall.Fchmod(fd, lockPerm) + } +} + // openLockFd opens lockPath with O_CREAT|O_RDWR|O_NOFOLLOW and verifies, on the // same descriptor, that it is a regular file — refusing a symlinked or -// non-regular lock path with ErrLockPathUnsafe. +// non-regular lock path with ErrLockPathUnsafe. The file is created, or +// narrowed, to lockPerm. func openLockFd(lockPath string) (int, error) { - fd, err := syscall.Open(lockPath, syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW, 0o644) + fd, err := syscall.Open(lockPath, syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW, lockPerm) if err != nil { if err == syscall.ELOOP { return -1, fmt.Errorf("%w: lock path is a symlink: %s", ErrLockPathUnsafe, lockPath) @@ -106,6 +127,7 @@ func openLockFd(lockPath string) (int, error) { syscall.Close(fd) return -1, fmt.Errorf("%w: lock path is not a regular file: %s", ErrLockPathUnsafe, lockPath) } + tightenLock(fd, uint32(st.Mode)) return fd, nil } @@ -183,7 +205,7 @@ func openLockIn(root *os.Root, rel string) (*os.File, error) { case lerr != nil && !errors.Is(lerr, os.ErrNotExist): return nil, lerr } - f, err := openOrCreateIn(root, rel, os.O_RDWR|syscall.O_NOFOLLOW, 0o644) + f, err := openOrCreateIn(root, rel, os.O_RDWR|syscall.O_NOFOLLOW, lockPerm) if err != nil { return nil, err } @@ -196,6 +218,7 @@ func openLockIn(root *os.Root, rel string) (*os.File, error) { f.Close() return nil, fmt.Errorf("%w: lock path changed or is not a regular file: %s", ErrLockPathUnsafe, rel) } + tightenLock(int(f.Fd()), uint32(st.Mode().Perm())) return f, nil } diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index ea8ab42ef..c803726dd 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -116,6 +116,9 @@ const ( DeclarationForeignOwner // DeclarationUnreadable: it passed the guards but the read itself failed. DeclarationUnreadable + // DeclarationBehindSymlink: the file is there, but a directory between the + // home and it (~/.abcd first) is a symlink (ReadHomeDeclaration only). + DeclarationBehindSymlink ) // ErrDeclarationWritable and ErrDeclarationForeignOwner are the two guards that @@ -662,8 +665,12 @@ var ErrNotOneLine = errors.New("fsutil: payload is not exactly one line") // line must be one line: empty, or carrying '\n' or '\r', is ErrNotOneLine and // nothing is opened. The newline is added here, so a caller cannot forget it and // run two records together. rel is resolved inside root, so a symlinked ancestor -// is refused rather than followed; a new file is created at perm, and an existing -// one keeps its mode. A short write is reported as io.ErrShortWrite. +// is refused rather than followed, and the leaf gets the refusal its read twin +// ReadGuardedInRoot applies: a symlink or anything but a regular file at rel is +// ErrNotRegular, even when the symlink stays inside the root, so a log leaf +// planted as a link onto another file of the store can never append into it. A +// new file is created at perm, and an existing one keeps its mode. A short write +// is reported as io.ErrShortWrite. func AppendLineIn(root *os.Root, rel string, line []byte, perm os.FileMode) error { if len(line) == 0 || strings.ContainsAny(string(line), "\n\r") { return ErrNotOneLine @@ -684,6 +691,11 @@ func AppendLineIn(root *os.Root, rel string, line []byte, perm os.FileMode) erro return err } +// beforeAppendOpen, when set, runs between openAppendIn's pre-open Lstat and +// its open. It is a test seam, nil outside tests: it lets a test plant a leaf +// in the window a racer would, deterministically. +var beforeAppendOpen func(root *os.Root, rel string) + // openAppendIn opens rel for appending, creating it at perm when absent, without // ever asking the kernel for a NON-exclusive create relative to the root's // directory descriptor. That single call — openat(dirfd, O_CREAT|O_APPEND) — was @@ -692,14 +704,61 @@ func AppendLineIn(root *os.Root, rel string, line []byte, perm os.FileMode) erro // sequence below uses only the two opens that behave under the race: a plain // open of an existing file, and an exclusive create that exactly one racer wins // while the others see ErrExist and fall back to the plain open. +// +// The leaf is vetted as ReadGuardedInRoot vets it, and the refusal rests on +// Lstat and SameFile, not on the open's flags: os.Root resolves an in-root leaf +// symlink itself (it retries an ELOOP by reading the link), so the O_NOFOLLOW +// the opens carry does not refuse one. An Lstat before the open refuses a +// symlink or a non-regular file before anything is created. After the open, the +// descriptor must be a regular file, and an unconditional second Lstat of rel +// must see a regular file that is the same file as the descriptor — and the +// same one the first Lstat saw, when it saw one. So a link planted at the leaf +// between the first Lstat and the open, which the open follows, is refused and +// its target is never written. O_NONBLOCK stays, so a FIFO swapped in cannot +// hang the writer. A hard link to another file passes SameFile and is out of +// this refusal's reach. func openAppendIn(root *os.Root, rel string, perm os.FileMode) (*os.File, error) { - f, err := root.OpenFile(rel, os.O_APPEND|os.O_WRONLY, 0) - if err == nil || !errors.Is(err, os.ErrNotExist) { - return f, err + pre, lerr := root.Lstat(rel) + switch { + case lerr == nil && !pre.Mode().IsRegular(): + return nil, ErrNotRegular + case lerr != nil && !errors.Is(lerr, os.ErrNotExist): + return nil, lerr + } + if beforeAppendOpen != nil { + beforeAppendOpen(root, rel) + } + const flag = os.O_APPEND | os.O_WRONLY | syscall.O_NOFOLLOW | syscall.O_NONBLOCK + f, err := root.OpenFile(rel, flag, 0) + if err != nil && errors.Is(err, os.ErrNotExist) { + f, err = root.OpenFile(rel, flag|os.O_CREATE|os.O_EXCL, perm) + if err != nil && errors.Is(err, os.ErrExist) { + f, err = root.OpenFile(rel, flag, 0) + } + } + if err != nil { + if errors.Is(err, syscall.ELOOP) { + return nil, ErrNotRegular + } + return nil, err } - f, err = root.OpenFile(rel, os.O_APPEND|os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) - if err == nil || !errors.Is(err, os.ErrExist) { - return f, err + st, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if !st.Mode().IsRegular() || (lerr == nil && !os.SameFile(pre, st)) { + f.Close() + return nil, ErrNotRegular + } + post, err := root.Lstat(rel) + if err != nil { + f.Close() + return nil, err + } + if !post.Mode().IsRegular() || !os.SameFile(post, st) { + f.Close() + return nil, ErrNotRegular } - return root.OpenFile(rel, os.O_APPEND|os.O_WRONLY, 0) + return f, nil } diff --git a/internal/fsutil/home.go b/internal/fsutil/home.go new file mode 100644 index 000000000..15190450e --- /dev/null +++ b/internal/fsutil/home.go @@ -0,0 +1,114 @@ +package fsutil + +import ( + "errors" + "os" + "path" + "path/filepath" + "strings" +) + +// ErrHomeScopeSymlinked is the refusal for a home-scoped path whose DIRECTORY +// is a symbolic link: ~/.abcd itself, or a directory below it on the way to the +// file. It is the one rule every reader and writer of the caller's machine +// scope applies, stated for the rules loader in AGENTS.md: a dotfiles-symlinked +// ~/.abcd can never host a file abcd trusts. HomeScopeLink returns it wrapped +// in a *HomeScopeLinkError, so errors.Is finds it. +var ErrHomeScopeSymlinked = errors.New("fsutil: a directory of this home-scoped path is a symlink") + +// HomeScopeLinkError names the symlinked directory in tilde form. Its message +// is the whole operator-facing sentence, the remedy included, so every reader +// and writer that refuses says the same thing. +type HomeScopeLinkError struct { + // Link is the symlinked directory in tilde form ("~/.abcd"). + Link string +} + +func (e *HomeScopeLinkError) Error() string { + return e.Link + " is a symlink, which abcd refuses rather than follows; replace the link with a real directory to keep abcd's files there" +} + +func (e *HomeScopeLinkError) Unwrap() error { return ErrHomeScopeSymlinked } + +// HomeScopeLink is the check behind that rule. rel is a slash path relative to +// home (".abcd/path-entry", ".abcd/credentials.json"); every DIRECTORY +// component of it — ~/.abcd first, never home itself and never the leaf, whose +// own guard is the reader's or the writer's — is Lstat'd, and the first that +// is a symlink is refused with a *HomeScopeLinkError naming it in tilde form +// (wrapping ErrHomeScopeSymlinked). A component that is absent, or that this +// uid cannot search, is not a link and ends the walk: nothing below it can be +// reached through a link that is not there. +// +// It exists because every home-scoped primitive guards the LEAF (O_NOFOLLOW, +// an Lstat of the file) and resolves the directories above it through the +// kernel, which follows a link without comment. A ~/.abcd symlinked into a +// dotfiles checkout therefore hosted a path-entry that decides which binary the +// hook shims execute, a cache attestation that decides which binary is promoted +// onto PATH, and a credentials.json written into a repository — while the rules +// loader beside them refused its rules.json there (iss-2609281017573862, +// iss-2609260958587561). One check, called by all of them, is what keeps the +// rule from drifting per reader. +// +// home itself is deliberately not judged: a home directory reached through a +// link (/home -> /usr/home, a relocated account) is the machine's layout, not a +// declaration the caller could have made somewhere else, and refusing it would +// refuse every account laid out that way. +// +// rel must be a clean relative path (ValidRelPath); anything else is refused +// with an *os.PathError wrapping os.ErrInvalid rather than judged. An escaping +// rel ("../x/f") leaves home before any directory below it is reached, and an +// absolute one would judge home itself, so passing either would vouch for a +// path the walk never covered. +func HomeScopeLink(home, rel string) error { + if !ValidRelPath(rel) { + return &os.PathError{Op: "homescopelink", Path: rel, Err: os.ErrInvalid} + } + dir := path.Dir(rel) + if dir == "." { + return nil + } + cur := home + shown := "~" + for _, part := range strings.Split(dir, "/") { + cur = filepath.Join(cur, part) + shown += "/" + part + fi, err := os.Lstat(cur) + if err != nil { + return nil + } + if fi.Mode()&os.ModeSymlink != 0 { + return &HomeScopeLinkError{Link: shown} + } + } + return nil +} + +// ReadHomeDeclaration is ReadDeclaration for a declaration file named by its +// place in the caller's home: home joined with rel (a slash path, +// ".abcd/trusted-roots"). It is the read every home-scoped declaration goes +// through, and it adds the one fact ReadDeclaration cannot see from a path — +// that no directory between home and the file is a symlink (HomeScopeLink). +// +// The order keeps AGENTS.md's rule exactly: a file that is not there is +// DeclarationAbsent whatever the directories are, so a symlinked ~/.abcd +// holding no such file reads as absent and costs its owner nothing; a file +// that IS there behind a symlinked directory is DeclarationBehindSymlink, +// refused before a byte of it is read. The Lstat that decides absence follows +// the directories, which is what lets a file behind a link be found in order to +// be refused. +// +// A rel that is not a clean relative path is DeclarationUnreadable before +// anything is looked at: it names no place in the home to read. +func ReadHomeDeclaration(home, rel string, limit int64) ([]byte, DeclarationRefusal, error) { + if !ValidRelPath(rel) { + return nil, DeclarationUnreadable, &os.PathError{Op: "readhomedeclaration", Path: rel, Err: os.ErrInvalid} + } + p := filepath.Join(home, filepath.FromSlash(rel)) + if _, err := os.Lstat(p); err != nil { + return nil, DeclarationAbsent, err + } + if err := HomeScopeLink(home, rel); err != nil { + return nil, DeclarationBehindSymlink, err + } + return ReadDeclaration(p, limit) +} diff --git a/internal/fsutil/home_test.go b/internal/fsutil/home_test.go new file mode 100644 index 000000000..81066ec6b --- /dev/null +++ b/internal/fsutil/home_test.go @@ -0,0 +1,160 @@ +//go:build unix + +package fsutil + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// symlinkedHome lays out a home whose ~/.abcd is a symlink to a directory +// elsewhere (the dotfiles shape) and returns the home and the link's target. +func symlinkedHome(t *testing.T) (home, target string) { + t.Helper() + home, target = t.TempDir(), t.TempDir() + if err := os.Symlink(target, filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + return home, target +} + +// A declaration behind a symlinked ~/.abcd is refused before a byte is read, +// with the refusal naming the link; the same file in a real ~/.abcd is read. +// This is the rule AGENTS.md states for the rules loader, held by the one +// primitive every home-scoped declaration reads through (iss-2609281017573862). +func TestReadHomeDeclarationRefusesAFileBehindASymlinkedAbcdHome(t *testing.T) { + home, target := symlinkedHome(t) + writeDeclaration(t, target, "trusted-roots", "/example\n") + raw, refusal, err := ReadHomeDeclaration(home, ".abcd/trusted-roots", 1024) + if refusal != DeclarationBehindSymlink || !errors.Is(err, ErrHomeScopeSymlinked) || raw != nil { + t.Fatalf("a declaration behind a symlinked ~/.abcd must be refused: refusal %d, err %v, raw %q", refusal, err, raw) + } + if !strings.Contains(err.Error(), "~/.abcd is a symlink") { + t.Fatalf("the refusal must name the link in tilde form: %v", err) + } + + real := t.TempDir() + if err := os.Mkdir(filepath.Join(real, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + writeDeclaration(t, filepath.Join(real, ".abcd"), "trusted-roots", "/example\n") + if raw, refusal, err := ReadHomeDeclaration(real, ".abcd/trusted-roots", 1024); refusal != DeclarationOK || err != nil || string(raw) != "/example\n" { + t.Fatalf("the same declaration in a real ~/.abcd must be read: refusal %d, err %v, raw %q", refusal, err, raw) + } +} + +// A symlinked ~/.abcd holding no such file reads as absent — the half of the +// rule that spares a dotfiles home nothing is declared in. +func TestReadHomeDeclarationReadsAnEmptySymlinkedAbcdHomeAsAbsent(t *testing.T) { + home, _ := symlinkedHome(t) + _, refusal, err := ReadHomeDeclaration(home, ".abcd/trusted-roots", 1024) + if refusal != DeclarationAbsent || !os.IsNotExist(err) { + t.Fatalf("a symlinked ~/.abcd with no declaration must read as absent: refusal %d, err %v", refusal, err) + } +} + +// Every directory below ~/.abcd is judged too, and home itself never is: a +// home reached through a link is the machine's layout, not a declaration. +func TestHomeScopeLinkJudgesTheDirectoriesBelowHomeOnly(t *testing.T) { + realHome := t.TempDir() + if err := os.MkdirAll(filepath.Join(realHome, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + linkedHome := filepath.Join(t.TempDir(), "home") + if err := os.Symlink(realHome, linkedHome); err != nil { + t.Fatal(err) + } + if err := HomeScopeLink(linkedHome, ".abcd/credentials.json"); err != nil { + t.Fatalf("a home reached through a link is not refused: %v", err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(realHome, ".abcd", "sub")); err != nil { + t.Fatal(err) + } + err := HomeScopeLink(realHome, ".abcd/sub/file") + var le *HomeScopeLinkError + if !errors.As(err, &le) || le.Link != "~/.abcd/sub" { + t.Fatalf("a symlinked directory below ~/.abcd must be refused by name: %v", err) + } + if err := HomeScopeLink(t.TempDir(), ".abcd/credentials.json"); err != nil { + t.Fatalf("an absent ~/.abcd is not a link: %v", err) + } +} + +// TestHomeDeclarationsReadThroughReadHomeDeclaration is the one-canonical- +// primitive detector for the rule above: every home-scoped declaration outside +// this package reads through ReadHomeDeclaration, never through the bare +// ReadDeclaration, whose path argument cannot say where the home ends and so +// cannot judge ~/.abcd. Before the rule was routed, nine readers called the +// bare form and every one of them followed a symlinked ~/.abcd. +func TestHomeDeclarationsReadThroughReadHomeDeclaration(t *testing.T) { + var offenders []string + err := filepath.WalkDir(filepath.Join(".."), func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if filepath.Base(p) == "fsutil" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + if strings.Contains(string(data), "fsutil.ReadDeclaration(") { + offenders = append(offenders, p) + } + return nil + }) + if err != nil { + t.Fatalf("walk internal/: %v", err) + } + if len(offenders) > 0 { + t.Fatalf("home-scoped declarations read through the bare fsutil.ReadDeclaration (route through fsutil.ReadHomeDeclaration):\n %s", + strings.Join(offenders, "\n ")) + } +} + +// TestHomeScopeLinkRefusesARelThatIsNotARelativePath: rel names a place in +// the caller's home, so a rel that is not a clean relative path is refused +// rather than judged. Passing it would say "no link here" about a path the +// walk never covered: an escaping rel ("../x/f") left home before any +// directory was judged, and an absolute one judged HOME ITSELF, the one +// directory the rule deliberately leaves alone. ReadHomeDeclaration refuses +// it the same way, as unreadable rather than as a symlink it did not find. +func TestHomeScopeLinkRefusesARelThatIsNotARelativePath(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil { + t.Fatal(err) + } + for _, rel := range []string{ + "", + "../x/f", + ".abcd/../../x/f", + "/etc/f", + "./.abcd/f", + ".abcd//f", + ".abcd/f/", + } { + err := HomeScopeLink(home, rel) + if !errors.Is(err, os.ErrInvalid) { + t.Errorf("HomeScopeLink(%q) = %v, want a refusal wrapping os.ErrInvalid", rel, err) + } + if errors.Is(err, ErrHomeScopeSymlinked) { + t.Errorf("HomeScopeLink(%q) reports a symlink that is not there: %v", rel, err) + } + if _, refusal, err := ReadHomeDeclaration(home, rel, 1<<10); refusal != DeclarationUnreadable || !errors.Is(err, os.ErrInvalid) { + t.Errorf("ReadHomeDeclaration(%q) = refusal %d, err %v; want DeclarationUnreadable wrapping os.ErrInvalid", rel, refusal, err) + } + } + if err := HomeScopeLink(home, ".abcd/credentials.json"); err != nil { + t.Fatalf("a valid rel under a real ~/.abcd must pass: %v", err) + } +} diff --git a/internal/surface/cli/ahoy_bin_install_test.go b/internal/surface/cli/ahoy_bin_install_test.go index 80b147a74..7f04b8e17 100644 --- a/internal/surface/cli/ahoy_bin_install_test.go +++ b/internal/surface/cli/ahoy_bin_install_test.go @@ -28,6 +28,7 @@ func userScopeEnv(t *testing.T, pathDirs ...string) (home, pluginRoot string) { t.Setenv("CLAUDE_PLUGIN_ROOT", "") t.Setenv("ABCD_BIN_TARGET", "") t.Setenv("PATH", strings.Join(pathDirs, string(os.PathListSeparator))) + provisionHermeticCache(t, home) return home, pluginRoot } diff --git a/internal/surface/cli/bootstrap_cache_test.go b/internal/surface/cli/bootstrap_cache_test.go index 7671943ed..87e685065 100644 --- a/internal/surface/cli/bootstrap_cache_test.go +++ b/internal/surface/cli/bootstrap_cache_test.go @@ -1372,3 +1372,29 @@ func assertHomeRefusalNamed(t *testing.T, out string) { t.Errorf("the notice must say the cache attestation was not written; output %q", out) } } + +// TestBootstrapRefusesASymlinkedAbcdHome is iss-2609281017573862 at the +// writer. The Go readers refuse a record behind a symlinked ~/.abcd, as the +// rules loader refuses rules.json there, so an attestation written through the +// link lands in whatever the link points at (a dotfiles checkout) and is then a +// record nobody honours — and `ahoy install` would send the reader back to the +// hooks for a record they would write the same way. The hook refuses the home +// instead, and the notice says why. +func TestBootstrapRefusesASymlinkedAbcdHome(t *testing.T) { + root, data, fx := attestableRun(t) + home := t.TempDir() + dotfiles := t.TempDir() + if err := os.Symlink(dotfiles, filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + out, code := runBootstrapInHome(t, t.TempDir(), root, data, home, fx) + if code != 0 { + t.Fatalf("a refused ~/.abcd is a note on a successful install, not a fault: got %d (output %q)", code, out) + } + if entries, _ := os.ReadDir(dotfiles); len(entries) != 0 { + t.Fatalf("the bootstrap wrote %d file(s) behind the symlinked ~/.abcd, first %q", len(entries), entries[0].Name()) + } + if !strings.Contains(out, "~/.abcd is a symlink") || !strings.Contains(out, "cache attestation") { + t.Errorf("the notice must say the cache attestation was not written because ~/.abcd is a symlink; output %q", out) + } +} diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index acdbeb789..8f6de4ee9 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/intentdriven/abcd/internal/core/implement/loop" "github.com/intentdriven/abcd/internal/fsutil" @@ -76,8 +77,9 @@ func errorsIsNoCheckout(err error) bool { return errors.Is(err, gitutil.ErrNoChe // newBuildCommand builds `abcd build `. func newBuildCommand(asJSON *bool) *cobra.Command { - return &cobra.Command{ - Use: "build ", + var session string + cmd := &cobra.Command{ + Use: "build [--session ]", Long: "Start the implement loop for one intent, or resume the run already in progress for it.\n" + "A new run's checks run first, and every one must pass:\n" + "the intent is READY (planned, criteria written, its spec linked and written), asks no\n" + @@ -92,6 +94,12 @@ func newBuildCommand(asJSON *bool) *cobra.Command { "never created: only a repository abcd manages has one. Starting again while the run is\n" + "in progress creates nothing and names the run without judging the checks again (the\n" + "run's own lanes change what they read), so a killed process resumes where it stopped.\n\n" + + "--session names the host session's id in the shared run state (`abcd implement join`):\n" + + "a new run then claims the intent there for that session, the run id as its lane, so a\n" + + "build of the same intent from any other checkout of the repository is refused as held\n" + + "before this run's lane has moved or claimed anything, and the session's own claim on the\n" + + "intent is not counted as a peer's. A session that has not joined is refused. Without it\n" + + "the run holds no claim, and the result says so.\n\n" + "The run then moves one step per `abcd implement step`, driven by the host session.\n\n" + "Exit 2 on a refusal, exit 3 when a peer holds the intent or the run state is locked\n" + "(back off and take other work).", @@ -102,7 +110,7 @@ func newBuildCommand(asJSON *bool) *cobra.Command { if err != nil { return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) } - res, err := loop.Start(root, args[0], loop.Options{}) + res, err := loop.Start(root, args[0], loop.Options{Session: session}) if err != nil { return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) } @@ -115,10 +123,19 @@ func newBuildCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, " state: %s\n", res.State) renderLaneLine(w, res.Lane) renderPending(w, res.Pending) + switch { + case res.Claim != nil: + fmt.Fprintf(w, " claim: %s for session %s until %s\n", res.Claim.Claim.Record, + termsafe.Sanitize(res.Claim.Claim.Session), res.Claim.Claim.ExpiresAt.Format(time.RFC3339)) + case !res.Resumed: + fmt.Fprintln(w, " claim: none (no --session): a build in another checkout cannot see this run until its lane shows") + } fmt.Fprintf(w, "next: %s\n", termsafe.Sanitize(fsutil.RedactHome(res.Next))) }) }, } + cmd.Flags().StringVar(&session, "session", "", "the host session's id in the shared run state; a new run claims the intent for it") + return cmd } // renderLaneLine renders one lane as a line, and its footprint once its steps diff --git a/internal/surface/cli/build_surface_test.go b/internal/surface/cli/build_surface_test.go index 1afd112b6..3a0df7f78 100644 --- a/internal/surface/cli/build_surface_test.go +++ b/internal/surface/cli/build_surface_test.go @@ -317,3 +317,51 @@ func TestImplementStepWithoutARunIsRefused(t *testing.T) { } runDirAbsent(t, repo.Root()) } + +// TestBuildForASessionClaimsTheIntent: `build --session` claims the intent in +// the shared run state for a joined session and says so; an unjoined session +// is refused at exit 2 with nothing written; a build without it says the run +// holds no claim (iss-2609252050506863). +func TestBuildForASessionClaimsTheIntent(t *testing.T) { + repo := buildRepo(t) + ref := refusalDocs(t, 2, "build", "itd-10", "--session", "ghost", "--json") + if ref["step"] != "claim" { + t.Fatalf("an unjoined session's build = %v; want the claim step refused", ref) + } + runDirAbsent(t, repo.Root()) + mustImplement(t, "implement", "join", "--session", "host-a", "--role", "first", "--json") + out := mustImplement(t, "build", "itd-10", "--session", "host-a") + if !strings.Contains(out, "claim: itd-10 for session host-a") { + t.Fatalf("build --session does not report its claim:\n%s", out) + } + if out := mustImplement(t, "implement", "--json"); !strings.Contains(out, `"record": "itd-10"`) { + t.Fatalf("the shared run holds no claim on itd-10:\n%s", out) + } +} + +// TestBuildWithoutASessionSaysItHoldsNoClaim: the invisibility of a run started +// without --session is named, not silent. +func TestBuildWithoutASessionSaysItHoldsNoClaim(t *testing.T) { + buildRepo(t) + if out := mustImplement(t, "build", "itd-10"); !strings.Contains(out, "claim: none (no --session)") { + t.Fatalf("build without --session is silent about its claim:\n%s", out) + } +} + +// TestBuildWithoutASessionSaysItHoldsNoClaimInJSON: the JSON says what the text +// says — the claim key is present and null, never absent (iss-2609252050506863). +func TestBuildWithoutASessionSaysItHoldsNoClaimInJSON(t *testing.T) { + buildRepo(t) + var doc map[string]json.RawMessage + out := mustImplement(t, "build", "itd-10", "--json") + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("build --json is not an object: %v\n%s", err, out) + } + claim, ok := doc["claim"] + if !ok { + t.Fatalf("build --json without --session omits the claim key:\n%s", out) + } + if string(claim) != "null" { + t.Fatalf("build --json without --session: claim = %s; want null", claim) + } +} diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 0a9102ae0..291815fa1 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -235,6 +235,7 @@ func hermeticRepo(t *testing.T) string { // status line to a hermetic install (spc-70). t.Setenv("CLAUDE_CONFIG_DIR", "") t.Setenv("ABCD_BIN_TARGET", filepath.Join(t.TempDir(), "bin", "abcd")) + provisionHermeticCache(t, home) repo := t.TempDir() if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { @@ -821,6 +822,19 @@ func TestDocsLintCleanTreePasses(t *testing.T) { } } +// provisionHermeticCache lays down the persistent plugin data dir a session's +// hooks provision — a verified cache the home-scoped attestation binds — and +// points CLAUDE_PLUGIN_DATA at it. It is the ordinary state `ahoy install` +// meets, and the only one in which it writes a PATH entry at all +// (iss-2609100506263330). Setting the variable also keeps a data dir the +// developer's own session exports out of a hermetic run. +func provisionHermeticCache(t *testing.T, home string) { + t.Helper() + data := seedInstallShapeCache(t, []byte("#!/bin/sh\n# abcd release artefact fixture\nexit 0\n")) + attestInstallShapeCache(t, home, data) + t.Setenv("CLAUDE_PLUGIN_DATA", data) +} + // hermeticEnv redirects HOME, the plugin root and the PATH symlink target to // temp locations without chdir'ing anywhere, so a caller can classify an // arbitrary folder shape. It never touches the real machine. @@ -841,6 +855,7 @@ func hermeticEnv(t *testing.T) { t.Setenv("ABCD_PLUGIN_ROOT", pluginRoot) t.Setenv("CLAUDE_PLUGIN_ROOT", "") t.Setenv("ABCD_BIN_TARGET", filepath.Join(t.TempDir(), "bin", "abcd")) + provisionHermeticCache(t, home) // Scrub any real abcd off PATH (iss-249, and the same guard setupHermetic in the // ahoy package applies). Without it, effectiveBinTarget finds the developer's own // installed abcd — exactly the machines that dogfood the installer — and `ahoy diff --git a/internal/surface/cli/hooks_install_shapes_test.go b/internal/surface/cli/hooks_install_shapes_test.go index e7f70d3f7..b533a4252 100644 --- a/internal/surface/cli/hooks_install_shapes_test.go +++ b/internal/surface/cli/hooks_install_shapes_test.go @@ -185,7 +185,9 @@ func TestHooksRunEveryShapeAhoyInstalls(t *testing.T) { name string dev bool // data is the persistent plugin data dir; empty means no verified - // cache, which is the documented degradation to the pinned symlink. + // cache, which only the dev shim installs without. With no verified + // cache a plain install writes no entry at all (iss-2609100506263330), + // so there is no pinned-symlink shape for it to leave. data func(t *testing.T) string // ran reports whether the entry, once accepted, actually executes abcd. // The dev shim rebuilds from source on every call and there is no `go` @@ -193,7 +195,6 @@ func TestHooksRunEveryShapeAhoyInstalls(t *testing.T) { // different question from whether the hook was willing to run it. ran bool }{ - {name: "pinned symlink", data: func(*testing.T) string { return "" }, ran: true}, {name: "owned copy", data: func(t *testing.T) string { return seedInstallShapeCache(t, artefact) }, ran: true}, {name: "dev shim", dev: true, data: func(*testing.T) string { return "" }}, } { @@ -227,7 +228,7 @@ func TestHooksRunEveryShapeAhoyInstalls(t *testing.T) { // would hand the ownership claim to a foreign binary and every hook would run // it — the hijack the ownership rung exists to refuse. func TestUninstallStopsTheHooksRunningTheEntry(t *testing.T) { - home, binDir := runAhoyInstall(t, false, "") + home, binDir := runAhoyInstall(t, false, seedInstallShapeCache(t, []byte("#!/bin/sh\nexit 0\n"))) repo := t.TempDir() if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil { t.Fatal(err) diff --git a/internal/surface/cli/hooks_selfprovision_test.go b/internal/surface/cli/hooks_selfprovision_test.go index 35f1c006e..2876b41e2 100644 --- a/internal/surface/cli/hooks_selfprovision_test.go +++ b/internal/surface/cli/hooks_selfprovision_test.go @@ -579,6 +579,7 @@ var pathRefusalReasons = []string{ "its directory is world-writable", pathRefusalUnowned, pathRefusalUnownedRecord, + pathRefusalSymlinkedHome, } // pathRefusalUnownedRecord is the refusal when the RECORD ITSELF is not this @@ -848,3 +849,34 @@ func TestSubagentStopNeverBootstraps(t *testing.T) { t.Fatalf("SubagentStop stderr must keep the one-line transcript-not-captured remedy: %q", stderr) } } + +// pathRefusalSymlinkedHome is the refusal when the record sits behind a +// symlinked ~/.abcd: the file itself may be well-formed and owned, but the +// directory holding it is a link the rules loader refuses too. +const pathRefusalSymlinkedHome = "~/.abcd is a symlink, so its path-entry record is not read" + +// TestBinaryHooksRefuseAPathBinaryVouchedForBehindASymlinkedAbcdHome is +// iss-2609281017573862 at the shim. `[ -f "$e" ]` and the `find` guard judge +// the record and follow the directory above it, so a ~/.abcd symlinked into a +// dotfiles checkout hosted a record that decides which binary every hook runs +// — while the rules loader in the same home refuses its rules.json. The record +// here is well-formed, owned and owner-only; the ONLY defect is the link. +func TestBinaryHooksRefuseAPathBinaryVouchedForBehindASymlinkedAbcdHome(t *testing.T) { + for _, h := range binaryHooks { + t.Run(h.event, func(t *testing.T) { + root := hookRoot(t, failingBootstrap, false) + pathDir := t.TempDir() + pathStub(t, pathDir) + dotfiles := t.TempDir() + writeHookPathEntry(t, dotfiles, filepath.Join(pathDir, "abcd")) + home := t.TempDir() + if err := os.Symlink(filepath.Join(dotfiles, ".abcd"), filepath.Join(home, ".abcd")); err != nil { + t.Fatal(err) + } + _, stderr, code := hookRunHome(t, h.event, root, pathDir, t.TempDir(), home) + assertPathBinaryRefused(t, h, root, stderr, code, + []string{filepath.Join(pathDir, "abcd")}, + []string{pathRefusalSymlinkedHome}) + }) + } +} diff --git a/internal/surface/cli/implement.go b/internal/surface/cli/implement.go index 87eaa652f..41a156573 100644 --- a/internal/surface/cli/implement.go +++ b/internal/surface/cli/implement.go @@ -245,9 +245,11 @@ func newImplementJoinCommand(asJSON *bool) *cobra.Command { "run state. Joining again with the same role is a resume and is logged as one; asking\n" + "for the other role is refused. The role is the session's own statement, recorded\n" + "here and read by every bound — never taken from the environment.\n\n" + - "--ceiling states the session's own agent ceiling: for the second session, the most\n" + - "agents it runs at once, on top of the first session's. abcd counts no agents, so the\n" + - "ceiling is recorded and reported by every `check`, not enforced; a resume keeps it.", + "--ceiling states the session's own agent ceiling: the most agents it runs at once (for\n" + + "the second session, on top of the first session's). abcd runs no agent: it counts the\n" + + "agents the session's own agent_start and agent_end lines declare alive, refuses an\n" + + "agent_start past the ceiling, and reports the count with every `check`. An agent the\n" + + "session never logs is invisible to it. A resume keeps the ceiling.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { r, err := implement.ParseRole(role) @@ -273,7 +275,7 @@ func newImplementJoinCommand(asJSON *bool) *cobra.Command { cmd.Flags().StringVar(&role, "role", "", "first | second") cmd.Flags().StringVar(&model, "model", "", "the model this session runs, recorded on the session_open line") cmd.Flags().StringVar(&reason, "reason", "", "why the session opens (run start, window, resume), recorded on the line") - cmd.Flags().IntVar(&ceiling, "ceiling", 0, "this session's own agent ceiling (1 to 64; 0 states none), recorded and reported by check") + cmd.Flags().IntVar(&ceiling, "ceiling", 0, "this session's own agent ceiling (1 to 64; 0 states none), held against its logged agent_start lines") return cmd } @@ -421,7 +423,8 @@ func newImplementCheckCommand(asJSON *bool) *cobra.Command { "take every step. The second is refused the release step always, a lane in a\n" + "split-roles window, and a lane whose --path reaches the reading corpus; review,\n" + "audit and land are open to it. A refusal exits 2 and is logged; an allowed step\n" + - "writes nothing. The verdict reports the agent ceiling the session joined with.", + "writes nothing. The verdict reports the agent ceiling the session joined with and the\n" + + "agents its log lines declare alive (agents_alive).", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { st, err := implement.ParseStep(args[0]) @@ -438,6 +441,7 @@ func newImplementCheckCommand(asJSON *bool) *cobra.Command { switch { case v.Ceiling > 0: fmt.Fprintf(w, " its own agent ceiling is %d, kept on top of the first session's\n", v.Ceiling) + fmt.Fprintf(w, " %d agent(s) alive of its ceiling %d, by its agent_start and agent_end lines\n", v.AgentsAlive, v.Ceiling) case v.Role == implement.RoleSecond: fmt.Fprintln(w, " no agent ceiling recorded: it keeps its own on top of the first session's (join with --ceiling to state it)") } @@ -459,6 +463,17 @@ func stepWords() []string { return out } +// requiredFieldsHelp lists each loggable event's required fields for help text. +func requiredFieldsHelp() string { + var parts []string + for _, e := range implement.LoggableEvents() { + if req := implement.RequiredFields(e); len(req) > 0 { + parts = append(parts, e+" ("+strings.Join(req, ", ")+")") + } + } + return strings.Join(parts, "; ") +} + // fieldArgRe is one --field operand: key=value. var fieldArgRe = regexp.MustCompile(`^([^=]+)=(.*)$`) @@ -474,7 +489,11 @@ func newImplementLogCommand(asJSON *bool) *cobra.Command { "a boolean is written as one when it reads back as the same text, so `sha=0123456`\n" + "stays a string. The events: " + strings.Join(implement.LoggableEvents(), ", ") + ".\n" + "The claim, window and session events are written by their own sub-verbs and are\n" + - "refused here, so the log cannot record a claim the run state does not hold.", + "refused here, so the log cannot record a claim the run state does not hold.\n\n" + + "An event missing a field the report reads is refused, naming it: " + requiredFieldsHelp() + ".\n" + + "An intervention's kind is one of " + strings.Join(implement.InterventionKinds, ", ") + "; an at or\n" + + "last_productive is an RFC 3339 time, and a *_min or minutes field a number. An agent_start\n" + + "that would take a session past the ceiling it joined with is refused, and the refusal logged.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { kv := map[string]string{} @@ -512,11 +531,15 @@ func newImplementReportCommand(asJSON *bool) *cobra.Command { "clock, lanes opened and landed (a lane_close whose outcome is merged or landed),\n" + "the second session's lanes landed, collisions (claim_denied), lapsed claims,\n" + "backoffs and the minutes backed off, agent minutes (agent_end's minutes, wall_minutes\n" + - "or wall_min), ceiling wait and refusals, per session within each mode. Each event\n" + - "belongs to the window open when it happened; each session's context lines are totalled\n" + + "or wall_min), ceiling wait, ceiling overruns and refusals, per session within each mode.\n" + + "Each event belongs to the window open when it happened, and a join logged at most a\n" + + "minute before a window_mode to that window; each session's context lines are totalled\n" + "across the run, with the last used_pct seen. `leader` is the mode with the most lanes landed per wall-clock hour —\n" + - "a figure, not a verdict. Lines the reader cannot use are listed, never dropped\n" + - "silently.\n\n" + + "a figure, not a verdict. Over the whole run it counts the evidence (interventions by\n" + + "kind, stops, decisions), names the lines lacking a field `log` requires of their event\n" + + "(missing_fields), and names each of lane_open, lane_close, agent_start, agent_end and\n" + + "gate_run whose lines stop more than six hours before the run's last line (coverage).\n" + + "Lines the reader cannot use are listed, never dropped silently.\n\n" + "By default the run's whole log is read, every day of it; --date reads one day, and\n" + "--log reads one log file named directly. Reads only; creates nothing.", Args: cobra.NoArgs, @@ -531,12 +554,23 @@ func newImplementReportCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, ", %d line(s) unreadable", len(rep.Unparsed)) } fmt.Fprintln(w) - fmt.Fprintf(w, "%-12s %7s %8s %6s %6s %6s %10s %9s %8s %8s\n", - "mode", "windows", "wall min", "opened", "landed", "B land", "collisions", "backoff m", "agent m", "ceiling") + fmt.Fprintf(w, "%-12s %7s %8s %6s %6s %6s %10s %9s %8s %8s %7s\n", + "mode", "windows", "wall min", "opened", "landed", "B land", "collisions", "backoff m", "agent m", "ceiling", "overrun") for _, m := range rep.Modes { - fmt.Fprintf(w, "%-12s %7d %8.1f %6d %6d %6d %10d %9.1f %8.1f %8.1f\n", + fmt.Fprintf(w, "%-12s %7d %8.1f %6d %6d %6d %10d %9.1f %8.1f %8.1f %7d\n", termsafe.Sanitize(m.Mode), m.Windows, m.WallMinutes, m.LanesOpened, m.LanesLanded, - m.SecondLanesLanded, m.Collisions, m.BackoffMinutes, m.AgentMinutes, m.CeilingWaitMinutes) + m.SecondLanesLanded, m.Collisions, m.BackoffMinutes, m.AgentMinutes, m.CeilingWaitMinutes, + m.CeilingOverruns) + } + ev := rep.Evidence + fmt.Fprintf(w, "evidence: %d intervention(s), %.0f min undetected; %d stop(s), %.0f min unnoticed; %d decision(s)\n", + ev.Interventions, ev.DetectedAfterMinutes, ev.Stops, ev.StopNoticedAfterMinutes, ev.Decisions) + for _, g := range rep.MissingFields { + fmt.Fprintf(w, "missing field: %d of %d %s line(s) carry no %s\n", g.Lines, g.Of, g.Event, g.Field) + } + for _, g := range rep.Coverage { + fmt.Fprintf(w, "coverage stops: %s's last line (of %d) is at %s, %.1f h before the run's last\n", + g.Event, g.Lines, g.Last.Format(time.RFC3339), g.HoursBefore) } for _, c := range rep.Context { fmt.Fprintf(w, "context: %s %d measurement(s), last %.0f%% used at %s\n", termsafe.Sanitize(c.Session), diff --git a/internal/surface/cli/implement_evidence_surface_test.go b/internal/surface/cli/implement_evidence_surface_test.go new file mode 100644 index 000000000..bf77be3d8 --- /dev/null +++ b/internal/surface/cli/implement_evidence_surface_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "strings" + "testing" +) + +// TestImplementCountsDeclaredAgentsAndLogsTheEvidence: through the CLI, an +// agent_start past the session's ceiling is refused at exit 2, check reports the +// agents alive, the evidence events and a ceiling overrun are logged, and the +// report renders the overruns, the evidence and a missing field. +func TestImplementCountsDeclaredAgentsAndLogsTheEvidence(t *testing.T) { + implementRepo(t) + mustImplement(t, "implement", "join", "--session", "alpha", "--role", "first", "--ceiling", "1", "--json") + mustImplement(t, "implement", "mode", "claim", "--session", "alpha", "--window", "1", "--json") + mustImplement(t, "implement", "log", "agent_start", "--session", "alpha", + "--field", "agent=a1", "--field", "role=implementer", "--field", "model=opus", "--json") + if msg := refusalEnvelope(t, 2, "implement", "log", "agent_start", "--session", "alpha", + "--field", "agent=a2", "--field", "role=reviewer", "--field", "model=fable", "--json"); !strings.Contains(msg, "ceiling") { + t.Fatalf("an agent past the ceiling = %q; want the ceiling named", msg) + } + if out := mustImplement(t, "implement", "check", "review", "--session", "alpha"); !strings.Contains(out, "1 agent(s) alive of its ceiling 1") { + t.Fatalf("check does not report the agents alive:\n%s", out) + } + if out := mustImplement(t, "implement", "check", "review", "--session", "alpha", "--json"); !strings.Contains(out, `"agents_alive": 1`) { + t.Fatalf("check --json does not report the agents alive:\n%s", out) + } + if msg := refusalEnvelope(t, 2, "implement", "log", "lane_close", "--session", "alpha", "--field", "lane=l1", "--json"); !strings.Contains(msg, "outcome") { + t.Fatalf("a lane_close without an outcome = %q; want the field named", msg) + } + mustImplement(t, "implement", "log", "ceiling_overrun", "--session", "alpha", "--field", "alive=2", "--field", "ceiling=1", + "--field", "lane=cut", "--field", "minutes=3", "--json") + mustImplement(t, "implement", "log", "intervention", "--session", "alpha", "--field", "kind=ruling", "--field", "by=product thinker", + "--field", "what=ruled", "--field", "why=asked", "--field", "autonomy_gap=no ruling channel", "--json") + mustImplement(t, "implement", "log", "decision", "--session", "alpha", "--field", "what=merge a", "--field", "alternative=merge b", + "--field", "why=smaller", "--json") + mustImplement(t, "implement", "log", "stop", "--session", "alpha", "--field", "cause=CI only", "--field", "recovery=reproduced", "--json") + + out := mustImplement(t, "implement", "report") + for _, want := range []string{"overrun", "evidence: 1 intervention(s)", "1 stop(s)", "1 decision(s)"} { + if !strings.Contains(out, want) { + t.Errorf("report does not render %q:\n%s", want, out) + } + } + out = mustImplement(t, "implement", "report", "--json") + for _, want := range []string{`"ceiling_overruns": 1`, `"interventions": 1`, `"missing_fields"`, `"coverage"`} { + if !strings.Contains(out, want) { + t.Errorf("report --json does not carry %s:\n%s", want, out) + } + } +} diff --git a/internal/surface/cli/roles_vocabulary_test.go b/internal/surface/cli/roles_vocabulary_test.go new file mode 100644 index 000000000..aec23f206 --- /dev/null +++ b/internal/surface/cli/roles_vocabulary_test.go @@ -0,0 +1,216 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/mode" + "github.com/spf13/cobra" +) + +// The two people abcd addresses are the product thinker, who decides what to +// build, and the technical facilitator, who decides how +// (itd-2609212137129937). One word blurred them, and agents said it because +// abcd's own pages did. These tests hold the two surfaces an agent reads — +// the rendered help and the plugin pages — to naming the role. + +// retiredRoleWord is spelled apart so this file never carries the word it +// refuses. +var retiredRoleWord = regexp.MustCompile(`(?i)\b` + "main" + "tainer") + +// pluginPages are the markdown pages the plugin ships for an agent to read: the +// command pages and the agent prompts. The agent prompts' CHANGELOG is a dated +// history log whose entries keep their words, so it is not a page. +func pluginPages(t *testing.T) []string { + t.Helper() + var pages []string + for _, glob := range []string{"commands/*.md", "agents/*.md"} { + m, err := filepath.Glob(filepath.Join("..", "..", "..", filepath.FromSlash(glob))) + if err != nil { + t.Fatal(err) + } + for _, p := range m { + if filepath.Base(p) == "CHANGELOG.md" { + continue + } + pages = append(pages, p) + } + } + if len(pages) < 20 { + t.Fatalf("found %d plugin pages; the walk is not reading the plugin", len(pages)) + } + return pages +} + +// TestRenderedHelpAndPluginPagesNameTheRoles (AC5): every command's rendered +// help, and every plugin page, is free of the retired word. +func TestRenderedHelpAndPluginPagesNameTheRoles(t *testing.T) { + root := NewRootCommand() + var walk func(c *cobra.Command) + n := 0 + walk = func(c *cobra.Command) { + var buf bytes.Buffer + c.SetOut(&buf) + c.SetErr(&buf) + if err := c.Help(); err != nil { + t.Fatalf("%s: help did not render: %v", c.CommandPath(), err) + } + n++ + for i, line := range strings.Split(buf.String(), "\n") { + if retiredRoleWord.MatchString(line) { + t.Errorf("`%s --help` line %d names neither role: %q", c.CommandPath(), i+1, line) + } + } + for _, child := range c.Commands() { + walk(child) + } + } + walk(root) + if n < 20 { + t.Fatalf("rendered %d help pages; the walk is not reading the command tree", n) + } + for _, p := range pluginPages(t) { + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(data), "\n") { + if retiredRoleWord.MatchString(line) { + t.Errorf("%s:%d names neither role: %q", filepath.ToSlash(p), i+1, strings.TrimSpace(line)) + } + } + } +} + +// questionBlock finds a paragraph in which a page tells the agent to put a +// question to a human: an imperative ask (sentence-initial, after "then" or +// "and", or after a bold lead-in) whose object is a person, "them" or the +// question itself; an instruction to relay or present a question; or a +// `--yes` that answers a question in advance, since pre-answering a stop is +// answering it and the paragraph must say whose answer that is; or an act taken +// "on the 's word", which is a stop that waits on that answer. An ask whose +// object is a verb, a binary or another agent ("ask the update verb", "Ask it +// for kill attempts") is not a question to a human. +var questionBlock = regexp.MustCompile(`(?:(?:^|[.!?:;,—]\s+|\*\*\s*|\bthen\s+|\band\s+)(?:Ask|ask)\s+(?:once\b|whether\b|why\b|what\b|first\b|them\b|the (?:user|human|researcher|person|product thinker|technical facilitator)\b|for (?:the|every)\b)|\b(?:[Rr]elay|[Pp]resent) the question\b|` + "`--yes`" + `[^.]*\bin advance\b|\bon the (?:user|human|researcher|person|product thinker|technical facilitator)['\x{2019}]s word\b)`) + +// TestPluginQuestionBlocksNameTheAddressee (AC3): every question a plugin page +// has the agent put to a human names which of the two roles it asks, in the +// paragraph that asks it, and the page's section sets the mode to that role — +// the addressee comes from the mode (itd-2609212130146198), whose vocabulary +// this test reads rather than restating. +func TestPluginQuestionBlocksNameTheAddressee(t *testing.T) { + type role struct{ name, setter string } + var roles []role + for _, s := range mode.States() { + if who := s.Addressee(); who != "" { + roles = append(roles, role{name: who, setter: "mode " + string(s)}) + } + } + if len(roles) != 2 { + t.Fatalf("the mode names %d roles, want the product thinker and the technical facilitator", len(roles)) + } + found := 0 + for _, p := range pluginPages(t) { + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + for _, sec := range pageSections(string(data)) { + // A setter wrapped across two lines is still the setter. + flat := strings.Join(strings.Fields(sec.text), " ") + for _, para := range sec.paragraphs { + if !questionBlock.MatchString(para.text) { + continue + } + found++ + low := strings.ToLower(para.text) + named := false + for _, r := range roles { + if !strings.Contains(low, r.name) { + continue + } + named = true + if !strings.Contains(flat, r.setter) { + t.Errorf("%s:%d asks the %s but its section never sets `abcd %s` first", + filepath.ToSlash(p), para.line, r.name, r.setter) + } + } + if !named { + t.Errorf("%s:%d puts a question to a human without naming the product thinker or the technical facilitator: %q", + filepath.ToSlash(p), para.line, clipQuestion(para.text)) + } + } + } + } + if found < 10 { + t.Fatalf("found %d question blocks; the detector is not reading the pages", found) + } +} + +type pageParagraph struct { + line int + text string +} + +type pageSection struct { + text string + paragraphs []pageParagraph +} + +// pageSections splits a page at its headings, and each section into its +// blank-line paragraphs, skipping fenced code; a paragraph's text is its lines +// joined, so a question that wraps is read whole. +func pageSections(page string) []pageSection { + var out []pageSection + cur := pageSection{} + var para []string + start := 0 + flush := func() { + if len(para) > 0 { + cur.paragraphs = append(cur.paragraphs, pageParagraph{line: start, text: strings.Join(para, " ")}) + para = nil + } + } + fence := false + for i, line := range strings.Split(page, "\n") { + trim := strings.TrimSpace(line) + if !strings.HasPrefix(line, "#") || fence { + cur.text += line + "\n" + } + if strings.HasPrefix(trim, "```") { + flush() + fence = !fence + continue + } + if fence { + continue + } + if strings.HasPrefix(line, "#") { + flush() + out = append(out, cur) + cur = pageSection{} + continue + } + if trim == "" { + flush() + continue + } + if len(para) == 0 { + start = i + 1 + } + para = append(para, trim) + } + flush() + return append(out, cur) +} + +func clipQuestion(s string) string { + if len(s) > 160 { + return s[:160] + "…" + } + return s +}