Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel

### Fixed

- `rootline skill install` now verifies staged symlink preimages by the link identity recorded in the plan and backup, so an Agents/Pi link pointing at the Claude destination is not falsely rejected after the same installation replaces Claude first.
- `fix` now detects native YAML timestamp, boolean and integer values in governed string fields, preserves their exact scalar text while quoting them, and reports unsupported `rule: type` mismatches under `type_findings`. Stored repair reports carry optional `from_representation` evidence and reject stale lexeme or representation changes without weakening historical `correct_value` guards. Fix findings remain informational; `validate` remains the corpus-validity command. Closes #196.
- CI ran on crossbeam's default `light` profile, so `Lint`, `Tidy` and `Vulnerability check` reported "skipping" on every run and the test job never used `-race`. `ci.yml` now passes `profile: full`, which is what `CLAUDE.md` already described.
- The lint job could not have passed even once it was enabled: crossbeam pins `lint-version` `v2.10.1`, built with go1.26, while the job resolves Go from `stable` — now 1.27 — so the linter panicked with `file requires newer Go version go1.27`. `ci.yml` now pins `v2.13.1`. Raise it again whenever the runner's stable Go moves ahead of crossbeam's default.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
tipo: adr
estado: accepted
fecha: '2026-08-29'
contexto: 'La instalación procesa Claude antes que Agents y una preimagen Agents puede enlazar a Claude, por lo que el digest desreferenciado cambia debido a una acción anterior de la misma operación.'
decision: 'Verificar una preimagen staged de symlink por su tipo, destino, kind y target lexical exacto contra el plan y el backup; mantener la verificación completa de digest para directorios.'
alternativas: 'Reordenar destinos se descartó porque acopla la seguridad a un orden particular; conservar el digest desreferenciado se descartó porque la operación lo invalida por sí misma.'
consecuencias: 'La aprobación sigue vinculando el digest observado antes de ejecutar, mientras la publicación valida el objeto restaurable real y permite converger cuando un destino soportado enlaza a otro.'
---
# 0008. Verificar preimagenes symlink por identidad del enlace

## Contexto
La instalación procesa Claude antes que Agents y una preimagen Agents puede enlazar a Claude, por lo que el digest desreferenciado cambia debido a una acción anterior de la misma operación.

## Decisión
Verificar una preimagen staged de symlink por su tipo, destino, kind y target lexical exacto contra el plan y el backup; mantener la verificación completa de digest para directorios.

## Alternativas descartadas
Reordenar destinos se descartó porque acopla la seguridad a un orden particular; conservar el digest desreferenciado se descartó porque la operación lo invalida por sí misma.

## Consecuencias
La aprobación sigue vinculando el digest observado antes de ejecutar, mientras la publicación valida el objeto restaurable real y permite converger cuando un destino soportado enlaza a otro.
14 changes: 13 additions & 1 deletion internal/skilldist/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ func (e *installExecutor) publishSymlink(destination DestinationState, source So
outcome.rolledBack = rolledBack
return outcome, coerceOperationError(ErrVerificationFailed, destination.Path, string(destination.ID), err)
}
if !sameDestinationEvidence(destination, stagedState) {
if !sameStagedPreimageEvidence(destination, stagedState, backup) {
rolledBack := e.restoreStaged(destination, stagedPath, backup)
outcome.rolledBack = rolledBack
return outcome, operationError(ErrVerificationFailed, destination.Path, string(destination.ID), fmt.Errorf("staged preimage evidence changed"))
Expand Down Expand Up @@ -926,6 +926,18 @@ func inventoryAfterFailure(destination DestinationState, source Source, sourceCa
return state
}

func sameStagedPreimageEvidence(expected, observed DestinationState, backup Backup) bool {
if expected.Kind == KindCorrectSymlink || expected.Kind == KindDivergentSymlink {
return expected.ID == observed.ID &&
expected.Kind == observed.Kind &&
backup.Destination == expected.ID &&
backup.Kind == expected.Kind &&
filepath.Clean(expected.LexicalTarget) == filepath.Clean(backup.LinkTarget) &&
filepath.Clean(expected.LexicalTarget) == filepath.Clean(observed.LexicalTarget)
}
return sameDestinationEvidence(expected, observed)
}

func sameDestinationEvidence(expected, observed DestinationState) bool {
return expected.ID == observed.ID &&
expected.Kind == observed.Kind &&
Expand Down
26 changes: 26 additions & 0 deletions internal/skilldist/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,32 @@ func TestInstallRequiresExactPlanThenConvergesIdempotently(t *testing.T) {
}
}

func TestInstallConvergesWhenAgentsPreimageTargetsClaudeDestination(t *testing.T) {
fixture := newServiceFixture(t)
mustWriteSkillFile(t, fixture.claudePath(), "SKILL.md", "old")
if err := os.MkdirAll(filepath.Dir(fixture.agentsPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(fixture.claudePath(), fixture.agentsPath()); err != nil {
t.Fatal(err)
}

planned := fixture.service.Install(context.Background(), fixture.repo, "")
if planned.Plan == nil || planned.Plan.Digest == "" {
t.Fatalf("unexpected plan result: %#v", planned)
}

applied := fixture.service.Install(context.Background(), fixture.repo, planned.Plan.Digest)
if applied.Failed() || !applied.Complete || applied.Receipt == nil || !applied.Receipt.Complete {
t.Fatalf("apply result: %#v", applied)
}
assertSymlinkTo(t, fixture.claudePath(), fixture.skillPath())
assertSymlinkTo(t, fixture.agentsPath(), fixture.skillPath())
if len(applied.Backups) != 2 {
t.Fatalf("backups = %#v, want directory and symlink preimages", applied.Backups)
}
}

func TestInstallRejectsStalePreimageApprovalBeforeMutation(t *testing.T) {
fixture := newServiceFixture(t)
mustWriteSkillFile(t, fixture.claudePath(), "SKILL.md", "first")
Expand Down
Loading