Skip to content

refresh: audit hooks/mcp/settings projection reads against config layers - #552

Open
NikashPrakash wants to merge 11 commits into
masterfrom
fix/refresh-projection-effective-flags
Open

NikashPrakash wants to merge 11 commits into
masterfrom
fix/refresh-projection-effective-flags

Conversation

@NikashPrakash

Copy link
Copy Markdown

Ground-truth audit of a field report claiming da refresh dropped hook
projections because the projection path consulted the flat repo-local
hooks boolean instead of the layer-resolved effective value — the same
"layered-consumer-left-behind" class as the earlier relevance /
loadFlatSnapshot gap, and framed as completing #535 (which fixed only
the marshal side).

The report does not hold, and the reason is not the one either side expected.

What the audit found

hooks, mcp, and settings are read at projection time neither flat
nor effective — they are not read at all.
The three keys participate in
the layer merge (so da config explain hooks correctly reports an org
layer's true), but no projection path consumes the resolved value. Hook,
MCP, and settings projection is driven entirely by what exists under
~/.agents/{hooks,mcp,settings}/<scope>/, via internal/platform's
resolveHookSpec / resolveScopedFile. Neither internal/platform nor
internal/links loads a manifest at all.

Every non-test read of the three fields:

file:line read decides
commands/internal/lifecycle/install.go:586-588 flat rc.Hooks/MCP/Settings display only — the install --generate --dry-run preview
internal/config/agentsrc.go:1496-1498 pickStringsOrBool / pickBool whether a fresh scan overwrites an author-committed declaration
internal/config/agentsrc.go:1112-1114, :1185-1187 field copy serialization round-trip

There is no Snapshot.Effective.Hooks/MCP/Settings read anywhere, no
ExtraFields["hooks"]-style read (all three are in agentsRCKnown, so
they can never land there), and no StringsOrBool.Contains call outside
tests. The last behavioral consumer was a da status display line removed
in 70f23a3. There is no shell implementation left to hide one.

Verified end to end in a sandboxed HOME/AGENTS_HOME, reproducing the
reported shape: manifest omits hooks, a local extends layer supplies
hooks: true.

$ da config explain hooks
Field:   hooks
Value:   true
Layer stack:
  [1] product-defaults       -> not set
  [2] org:org/base.json      -> true   <- active
  [3] repo-local             -> not set

$ da refresh e2eproj      # → .claude/settings.local.json carries the hook

Same result with hooks:false, hooks:true, or the key absent, with or
without a layer: the projected artifacts are byte-identical. The flag gates
nothing.

What this PR does

No behavior change. Two commits:

  1. TestRunRefresh_ProjectionIgnoresHooksMCPSettingsFlags — drives the real
    runRefresh over five manifest/layer combinations and asserts both halves
    together: the merge precedence (via ResolveLocked, the surface config explain uses) and the projected artifacts (rendered hook command in
    .claude/settings.local.json, .mcp.json, .cursor/settings.json).
    The absent-key + org-layer-true row is the reported shape and is what
    would fail if anyone later wired projection to a flat read.

  2. Corrects the AgentsRC field comment and the three schema descriptions,
    which asserted that an explicit repo-local false "disables the org
    layer's projection." It does not — it wins the merge and changes what
    config explain reports.

Why not just wire the flags in

Honoring the flags would be a behavior change, not a fix. Manifests written
before the pointer migration carry an injected "hooks": false their authors
never wrote (that injection is exactly what #535 stopped). Gating projection
on the resolved value would disable hook projection for every such repo the
next time it refreshes. That needs a deliberate decision plus a migration
story; this PR pins the current contract so the change shows up as a
reviewed diff rather than a silent one.

Same-class flat reads found nearby (reported, not changed)

  • gitignore_projectionscommands/internal/lifecycle/gitignore.go:52,61.
    The one real flat-read behavioral gate at projection time, reached from
    both refresh and install. Deliberate and documented as such; left alone.
  • sources at install — commands/internal/lifecycle/install.go:184 resolves
    flat rc.Sources while skills/agents link from Snapshot.Effective
    (:196). Mitigated by design: linkInstallResources always appends
    config.AgentsHome(), so a layer-supplied skill still resolves from the
    home store. Worth a look, not obviously broken.
  • kg.graph_home (commands/workflow/graph.go:87) and observability.*
    (commands/observability/cmd.go:64,317-326,
    commands/workflow/observability_hook.go:94,171) are flat reads of
    layer-mergeable keys, so an org layer setting them is invisible. Outside
    the projection/lifecycle layer and adjacent to in-flight commands/kg
    work, so untouched here.
  • features has no consumer at all outside config explain --flags.

Verification

  • go test ./internal/... -skip TestConfigLoadSave — pass
  • go test ./commands/... — pass
  • go test ./... -skip TestConfigLoadSave — pass
  • gofmt, go vet ./commands/... ./internal/config/... — clean
  • Sandboxed HOME/AGENTS_HOME E2E as above; no real repo or home touched

A field report claimed refresh dropped hook projections because the
projection path read the flat repo-local boolean instead of the
layer-resolved effective value, ignoring an org layer's hooks:true.

The audit found a different shape: refresh reads hooks/mcp/settings
neither flat nor effective. They participate in the layer merge (so
config explain reports them correctly) but no projection path consumes
the result — hooks, MCP configs, and settings project from whatever
exists under ~/.agents/{hooks,mcp,settings}/<scope>/.

Pin both halves together across five manifest/layer combinations: the
merge precedence the pointer migration exists to protect, and the
projection artifacts that must land regardless. The absent-key +
org-layer-true row is the reported shape and the one that guards
against a flat-read regression.
…ting

The AgentsRC field comment and the three schema descriptions asserted
that an explicit repo-local false disables the org layer's projection.
It does not: it wins the layer merge and changes what config explain
reports, but no projection path consults the resolved value.

State the actual scope, and record why wiring it in is a behavior change
rather than a fix — manifests written before the pointer migration carry
an injected hooks:false their authors never wrote, so honoring it would
disable hook projection for every such repo at once.
master's refresh scoping work replaced runRefresh's string project
filter with the refreshScope struct. Carry the merge through the new
regression test's single call site.
The hooks/mcp/settings design + audit notes sat wedged between field
declarations. That prose is cross-cutting type-level documentation
(layer-merge vs projection semantics), not per-field wire encoding, so
it reads better as part of the AgentsRC doc comment with a short
pointer left at the field group.

Also keeps this change out of the pre-existing agentsRCCore mirror
duplication span, which was charging the comment edit as new
duplicated lines against the new-code quality gate.
…ce plan

LoadEffectiveAgentsRC fixes one read-only, offline access mode for every
consumer that is not already holding a resolved snapshot, so an org/team
layer's keys are visible without turning best-effort paths into lock writers.

ResourceSourcePlan derives the reachable source roots from the per-layer raw
declarations instead of the ordered-replace Effective.Sources, keeping
inherited roots searchable while excluding user-scope and synthesized-home
roots from project resource materialization.
…m the effective config

MaintainManagedGitignore now takes the resolved snapshot install/refresh
already hold, so a layer-supplied gitignore_projections governs the managed
block; an unreadable manifest or unreplayable layer stack still skips rather
than guessing.

Install resolves resource roots from the source plan, which surfaces roots an
org/team layer declared. http/oci declarations reach install for the first
time through those inherited layers; they resolve to a fetched blob rather
than a resource tree, so they are named and skipped non-fatally.
The command surface (login, status, sync, outbox drain) and the workflow
publication hook read the flat repo-local manifest, so a fleet that supplies
the observability block from an org/team layer got 'not configured' and
queued nothing. All of them now route through LoadEffectiveAgentsRC.
commands/workflow/graph.go still reads kg.graph_home flat, so a layer-supplied
KG graph home is invisible to the native namespace commands. Routed as a task
note on graph-backend-adapter-contract/t6-bridge-decommission rather than
widening this change.
goreleaser 2.18.1 fails `goreleaser check` (exit 2) on
homebrew_casks.url.verified, which breaks the GoReleaser validation step on
every branch carrying current master. The url block held nothing else, so the
anchor and its reference go with it.
Split the provenance/eligibility assertions into named helpers (S3776:
cognitive complexity 16 > 15) and rename apiURL's local url copy so it no
longer shadows the predeclared `copy` (S978).
@sonarqubecloud

Copy link
Copy Markdown

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