Skip to content

feat: repo identity — one canonical block, every surface held to it (itd-102) - #158

Merged
REPPL merged 15 commits into
mainfrom
feat/itd-102-repo-identity
Jul 28, 2026
Merged

feat: repo identity — one canonical block, every surface held to it (itd-102)#158
REPPL merged 15 commits into
mainfrom
feat/itd-102-repo-identity

Conversation

@REPPL

@REPPL REPPL commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Implements itd-102 (repo identity) per spc-19 — the spec derived from the 2026-07-27 Grill Settlements. abcd asks a repo for its identity once and holds every surface to it.

What this delivers

  • internal/core/positioning/ — a new package (deliberately not folded into internal/core/identity/, which is the git commit-author gate; the two share only the English word). Parses the canonical identity block (fixed - **Title:** / **Tagline:** / **Pitch:** bullets, title+tagline required, pitch optional, multi-line accepted), and runs a positioning check over the three default surfaces — README strapline, plugin manifest description, AGENTS.md opening — comparing each to the block.
  • .abcd/positioning.json records the block location and the surface registry (config-driven, not hardcoded to one manifest format); per-repo config upgrades the warn-tier finding to blocker; additional surfaces are registrable, none silently.
  • The check runs on every abcd audit. A drifted surface yields a warn finding naming the exact file, line, drifted text, and the canonical line it should carry — with iss-143's real three-variant tagline drift as the fixture corpus.
  • abcd identity render prints the proposed corrected surface as a unified diff to stdout and writes nothing (asserted by test; no flag can make it write). abcd identity init scaffolds the block + pointer during onboarding; the prepare skill gains the detect-existing-block-first interview step.
  • abcd-cli's own config points at the brief's existing "Identity (canonical)" section unchanged (AC 4); abcd audit here is clean on the identity family.

Settlements honoured

  • Identity home is a parseable markdown block; config records only its location (markdown stays the single source of truth).
  • Warn-tier by default, per-repo upgradeable; autonomous surface rewriting is permanently out — re-render is always a human-adopted diff.

Reviews

  • Ruthless: 2 FIX-FIRST fixed (a CRLF sanitiser that voided the round-trip so identity render emitted an unappliable patch; an init path that appended a duplicate heading on every retry), final SHIP. One residual below the bar — mixed line-ending files emit a CR on LF-only lines; the failure is loud (git apply refuses), now documented on the unifiedDiff comment rather than silently fixed.
  • Security (config-supplied paths/patterns run against arbitrary audited repos): initial BLOCK — a hostile cloned repo committing a symlinked ancestor directory could make abcd audit read and print files from outside the repo root (~/.ssh, ~/.aws/credentials). Fixed by routing all four positioning reads, the init write path (also found escaping), and the render re-read through os.Root (the repo's readLifeboatFile containment pattern); lexical ValidRelPath is no longer load-bearing. Symlinked-ancestor regression tests (absolute + relative shapes) added. Re-review reproduced the pre-fix exploit and confirmed it refuses post-fix on macOS and Linux: final APPROVE.

Known gaps, recorded not hidden

  • iss-149: positioning read caps are per-file, not per-run — a large surface registry is a self-inflicted memory bound (operator's own repo only).
  • iss-150: a registry may name a surface under .git/; it stays inside the containment root and reaches only the operator's own stdout.

Reuse

validRelPath was promoted from lifeboat/pack.go to fsutil.ValidRelPath in a behaviour-preserving commit rather than copied a third time.

Gates: make preflight, docs lint and record-lint all clean (0 blockers).

REPPL added 15 commits July 28, 2026 02:26
The 2026-07-27 grill settled the design; the spec records the
settlements plus the mechanism (fixed-bullet markdown identity
block, config records only its location, warn-tier positioning
check with re-render as a proposed diff, iss-143 as acceptance
corpus). Fourth iss-80 mint collision: renumbered to spc-19.

Assisted-by: Claude:claude-fable-5
The lexical repo-relative-path guard lived unexported inside the lifeboat
packer, but it is the general primitive any caller needs before joining a
path that arrived as data onto a trusted root. Move it to the canonical
path home (internal/fsutil) so a second caller extends the primitive
rather than copying it; lifeboat's validRelPath now delegates.

Behaviour-preserving: the implementation is unchanged, and the exported
form gains its own table test.

Assisted-by: Claude:claude-opus-5
A repository's positioning fragments silently: the README strapline, the
manifest description, and the conventions file's opening are edited at
different moments until three surfaces tell three stories (iss-143). This
adds the core primitive that stops that.

internal/core/positioning parses one canonical markdown identity block —
the fixed `- **Title:** / **Tagline:** / **Pitch:**` bullets under a
recorded heading, title and tagline required, a wrapped pitch joined —
and compares every registered surface against it. Markdown stays the
single source of truth; .abcd/positioning.json records only where the
block lives, how loudly the family reports, and which surfaces render
from it.

The registry is data: a surface names candidate files (first present
wins, so one entry covers several manifest formats), a locator (a
capture-group regexp or a top-level JSON field), and the block fields it
must carry. Comparison normalises markup, dashes, wrapping, and case, so
a bolded or line-wrapped tagline is not drift while a reworded one is.
Every registry value is validated before use, and every file read goes
through the guarded primitive.

The iss-143 three-variant drift is the acceptance corpus, one test per
variant plus all three together.

Assisted-by: Claude:claude-opus-5
Drift is found, never fixed behind the maintainer's back. Propose runs
the positioning check and, for every surface that drifted, renders what
the surface would say if it derived from the identity block — as a
unified diff naming the exact removed line and the canonical one that
replaces it.

It writes nothing, and there is no flag that makes it: a test snapshots
every file's content and mtime around the call and fails on any change.
A surface the locator could not find yields no hunk rather than a patch
against a line that was never located, and a manifest replacement is
re-escaped so the proposed JSON still parses. A further test applies
every proposal and re-runs the check, so an adopted proposal is proven
to leave the repo clean.

Assisted-by: Claude:claude-opus-5
The audit verb gains an identity-positioning rule: it holds every
registered surface to the repo's canonical identity block and names the
file, the exact drifted line, and the canonical line it should carry.

Warn-tier by default — it highlights, it never gates and never rewrites;
a repo that wants a hard gate sets "severity": "blocker" in its registry
and every finding in the family becomes an error. The rule is Where-gated
on a committed .abcd/positioning.json, so a repo that has not adopted the
check is skipped rather than failed.

A registry that cannot be loaded, and a block that cannot be read, are
findings of this family rather than a silent pass or an abort that takes
the other rules down with it. Every error path is path-free: a test drives
each one and fails on an absolute repo root in a finding (iss-81).

Assisted-by: Claude:claude-opus-5
`abcd identity` renders the canonical block and every registered
surface's verdict; `abcd identity render` prints the proposed correction
for each drifted surface as a unified diff; `abcd identity init` records
the block and the pointer that says where it lives.

render writes nothing, and the tests hold it there: a tree snapshot
(content and mtime) around the call, and a check that --write/--apply/
--fix/--in-place/--force are all rejected. init adopts an existing block
rather than re-interviewing over it, refuses to repoint a registry that
is already committed, and re-running it on an adopted repo is a no-op.

Bare `identity` is a status render and exits 0 even when it reports
drift; the gate is `abcd audit` and its tri-state exit.

Assisted-by: Claude:claude-opus-5
`abcd identity render` passed its whole unified diff through the
single-line sanitiser, which masks newlines so an injected line break
cannot forge report lines. On a diff that is the wrong trade: every
newline became a '?' and the patch arrived as one unreadable smear that
no tool could apply.

termsafe gains SanitizeBlock — the same masking, applied per line with
the newlines preserved — and the render path uses it. Bare carriage
returns are still masked, so a line cannot be overwritten in place. The
surface test now asserts the markers open their own lines.

Assisted-by: Claude:claude-opus-5
.abcd/positioning.json registers abcd's canon — the brief product
chapter's "Identity (canonical)" section, unchanged — and the three
surfaces that render from it: the README strapline, the plugin manifest
description, and the AGENTS.md opening. All three come out clean.

Adds the /abcd:identity plugin command (bare / render / init, with the
interview wording and the detect-before-asking rule), its brief surface
page and registry row, the identity-positioning row in the audit rule
table, the prepare flow's identity step, the regenerated CLI reference
and surface snapshot, and a CHANGELOG entry.

Assisted-by: Claude:claude-opus-5
The proposal rendered a hunk no tool would apply in three cases, all
found by pointing `git apply --check` at it:

- a file ending in a newline was split into N+1 lines, so the hunk
  carried a phantom trailing context line that is not in the file;
- a file NOT ending in a newline got no no-newline marker, which every
  patch tool requires;
- a CRLF file got an LF patch, which cannot match its bytes.

Line endings now round-trip (the diff and the proposed content both
carry CRLF back when the file uses it), the marker is emitted against
the trailing context line or against both sides of the change, and a
locator whose paragraph terminator is end-of-file no longer swallows the
file's final newline into the replaced span — which also panicked the
renderer by indexing past the last line.

The regression test drives five shapes through `git apply --check`.

Assisted-by: Claude:claude-opus-5
The diff test only asked whether `git apply` accepted the hunk. The
patch and the SurfaceDiff.Proposed field are two renderings of the same
change, so it now applies the patch for real and compares the resulting
file to Proposed byte for byte — if they ever disagree, one of them is
lying to the maintainer. A trailing-blank-line shape joins the cases.

Assisted-by: Claude:claude-opus-5
Two review findings, both reproduced before the fix.

SanitizeBlock dropped the carriage return from every line, so the core's
CRLF round-trip was voided at the front door: `abcd identity render`
printed an LF patch that no tool would apply to a CRLF surface, while
the same repo's --json output carried a correct one. The CR of a CRLF
pair is committed by the newline that follows it and can overprint
nothing, so it now survives; every bare carriage return — including one
inside a run before a newline, and one trailing the final line — is
still masked. Asserted at the CLI boundary, which is where the guarantee
has to hold; the core test could not see this.

`identity init` routed a heading with incomplete bullets into the
scaffold branch, but writeBlock only appends: the append left a second
copy of the heading, the parser still read the broken first one, Init
failed, no registry was written, and every retry appended again. Only an
absent file or heading is fixable by appending, so an incomplete block
is now refused before any write, naming the file and the missing bullet.

Also backs the finding-message truncation off to a rune boundary: the
byte cut split a multi-byte rune and %q rendered the remains as byte
escapes in the report.

Assisted-by: Claude:claude-opus-5
The positioning check resolved every configured path with
filepath.Join(root, rel) behind fsutil.ValidRelPath. That guard is LEXICAL —
it rejects ".." and absolute paths and can say nothing about symlinks — and
fsutil.ReadGuarded's O_NOFOLLOW guards only the LEAF. An audited repo that
COMMITS a symlinked directory (git mode 120000: `etclink -> /etc`, or
`home -> ../../..`) and names a file through it therefore had its read resolve
straight out of the repository. A surface's content is quoted verbatim into
the Report, so `abcd audit` printed an outside file into its finding and
`abcd identity --json` emitted up to 1 MiB of it whole; SSH keys, cloud
credentials, and host tokens are all reachable from a relative-ancestor link.

Containment now rests on os.Root, the pattern the lifeboat embark path already
uses, so the OS enforces it against every ancestor component rather than a
string check that cannot see one. All four sites route through a root opened
per operation: the surface read (check), the canonical block read (block), the
registry read (config), and onboarding's block and registry writes (init) —
plus the proposal's re-read, which carries surface bytes into a diff.
ValidRelPath stays as the cheap first reject; it is no longer load-bearing.

fsutil gains the contained counterparts of its three primitives, so the
one-canonical-primitive invariant holds and no caller hand-rolls containment:
ReadGuardedInRoot keeps every leaf guarantee ReadGuarded gives (symlinked leaf
refused, non-regular refused, O_NONBLOCK, the byte cap enforced on both the
fstat size and the bytes read) and adds os.SameFile to close the lstat-to-open
swap; WriteFileAtomicInRoot and WriteFileAtomicPreserveModeInRoot keep the
write, fchmod, fsync, rename, parent-fsync ordering with an unpredictable temp
name created inside the root.

A refusal is never an absence: an escaping path is not an os.IsNotExist error,
so the loop that skips absent candidates fails closed on it, and the new
ErrSurfaceUnreadable sentinel names the refusal to a caller.

The regression test builds the attack in both real shapes — an absolute target
and a relative ancestor — and asserts that no byte from outside the root
reaches a Report, a Block, a Config, a proposal, or an error message, and that
onboarding writes nothing outside. Before the change it reproduces the leak at
all four sites.

Assisted-by: Claude:claude-opus-5
One CRLF anywhere in a surface file makes every emitted hunk line carry a
carriage return, so a file with mixed endings gets a diff whose LF-only lines
gain a CR they do not have on disk. Recording it rather than handling it: the
failure is loud, not silent — the context lines stop matching and `git apply`
refuses the patch, so nothing is ever written on the strength of a wrong diff.

Assisted-by: Claude:claude-opus-5
Both sit beside the escape the containment fix closed and neither is one:
an unbounded surface count in a committed registry (a per-run memory bound,
not a boundary crossing), and a registry naming a surface under .git/, which
stays inside the containment root and reaches only the operator's own stdout.
Recorded rather than fixed so the containment change stays one purpose.

Assisted-by: Claude:claude-opus-5
…ntity

# Conflicts:
#	.abcd/development/brief/04-surfaces/README.md
#	.abcd/development/release/surface.json
#	.abcd/work/DECISIONS.md
#	docs/reference/cli/commands.md
#	internal/surface/cli/cli.go
@REPPL
REPPL enabled auto-merge July 28, 2026 07:39
@REPPL
REPPL merged commit 3036c19 into main Jul 28, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-102-repo-identity branch July 28, 2026 07:42
@REPPL REPPL mentioned this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant