Skip to content

install: match an entry's whole command, not a suffix of it - #72

Merged
k4rthiknerella merged 2 commits into
mainfrom
trust/install-identity
Oct 22, 2026
Merged

k4rthiknerella merged 2 commits into
mainfrom
trust/install-identity

Conversation

@k4rthiknerella

Copy link
Copy Markdown
Contributor

Stacked on #61 (intake: one bounded read and one scan of every hook payload); review and merge after it.

What changed

Owner decided whether a hook entry belonged to this install by searching
its command line for " <subcommand> --install " anywhere in the string.
That is a suffix check, and a suffix check has a hole: a longer subcommand
that happens to end the same way as a shorter one would read as the shorter
one's entry -- TestLUI3_ pins the concrete case, a steer post entry
under PostToolUse against the recorder's own post entry on the same
event. Without this fix, the first entry a future role installs under a
subcommand ending in post would have been misread as the recorder's own
PostToolUse entry by both watch and detach.

Owner now splits a command at the end of its first shell word -- the
executable, bare or single-quoted exactly as it is written -- and compares
everything after it exactly against the subcommand, never as a suffix. It
also now accepts a per-event list of older subcommands as equally ours, so a
future rename can refresh an entry in place instead of leaving the old form
behind for detach to miss it. Nothing has been renamed yet, so every list is
empty; TestLUI4_ supplies its own legacy form to prove the mechanism ahead
of a real rename using it. TestLUI3_ needs none -- its case is the
whole-remainder comparison itself, not the legacy lookup.

This is a narrowing, and it has a real consequence: a command this install
did not write itself, but whose tail after the executable is byte-identical
to ours, is no longer recognised. The old suffix search claimed env FOO=1 /path/rashomon post --install <id>, a double-quoted executable, or an
executable followed by two spaces before the subcommand, because each still
ends in the right text. The new whole-remainder check does not, because
none of those is "<word> then exactly one space then the matched form."
Watch will now add a second entry beside a hand-wrapped one instead of
refreshing it in place, and detach will leave the hand-wrapped one behind as
a live hook. Nothing on this branch writes a command in any of those shapes,
so nothing regresses today, but it is a real narrowing of what Owner
recognises, not a no-op refactor.

Apply, Remove, RemoveIf, Present and ForeignOwners are themselves
untouched -- the fix is entirely inside Owner -- so everything else about
watch and detach (refreshing, collapsing duplicates, leaving foreign entries
and emptied event keys alone) is exactly as it was before, for every command
shape Owner still recognises as ours.

What pins it

  • TestLUI3_OwnerDoesNotClaimSteerPostAsPost: a command ending in post but
    reading steer post after its executable is not claimed by the
    PostToolUse entry, whose own subcommand is post; the positive twin in
    the same test confirms a real post entry is still read.
  • TestLUI4_WatchThenDetachLeavesNoResidue: an entry written under a
    (test-supplied) legacy subcommand is recognised, refreshed to the current
    form in place rather than duplicated, and removed by a later detach; a
    foreign entry under the same event survives byte for byte throughout.
  • Both tests were run against the prior implementation first and failed for
    the reason the fix describes, before being run green against it.
  • TestOwner now also covers afterFirstWord's single-quoted branch
    directly: an embedded quote, a space, and Windows backslashes (every
    Windows install goes through this branch, since shellQuote quotes any
    path containing one), plus an unterminated quote resolving to no owner.
    That branch had no case of its own before; a probe that always failed it
    (forcing afterFirstWord's quoted path to return "", false) still left
    go test ./internal/install/... green on the old test file. It also pins
    the narrowing above with a case of its own: a command wrapped in
    env FOO=1 is not recognised.
  • Mutation coverage: LUI-3_install.py (reverting Owner to the suffix
    search), LUI-4_install.py (dropping the legacy-form list), and
    H-5_install.py (new -- resuming the escaped-quote skip one byte short,
    landing inside the escape sequence instead of past it), all three
    confirmed red by sweep.py itself against this file's mutants, not just
    copied out and checked separately. The sweep's static check
    (sweep.py --check) passes across the full 1422-mutant set with every
    anchor occurring exactly once; the other mutants that target files
    outside this change were not re-run as part of it.

What was measured

Nothing performance-sensitive changed; no budgets.json row is touched.

What is left

A role whose subcommand ends in post (the shape TestLUI3_ guards
against) is not part of this change and is not installed by anything on
this branch. Owner's fix and the legacy mechanism are ahead of that work
on purpose, so the first PR that installs one lands on an identity check
that already handles it correctly.

The narrowing noted above -- a hand-wrapped or hand-edited command no longer
read as ours -- is accepted as-is; nothing on this branch writes a command
in one of those shapes. If that ever needs recovering, it is a new decision
about what Owner should treat as identity, not a bug in this fix.

@lutic1 lutic1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k4rthiknerella, here's an early review of this draft at eda07c2. It's in good shape and close to approvable.

  • Older entries still match. The command format is byte-identical across v0.1.0-alpha.1, v1.1.0 and HEAD, and Owner never inspects the executable. Every entry a released version wrote is still recognised, including Homebrew Cellar paths, symlinks, moved binaries and ~/$HOME.
  • Quoting round-trips. I fuzzed 200k random executables through Entry and Owner for all six events, and every one round-tripped.
  • The tests and mutants hold. All three mutants go red.

Before it leaves draft

  1. detach now silently leaves some entries running. These are no longer claimed as ours (reproduced):

    • entries wrapped as env FOO=1 …
    • a double-quoted path containing a space
    • two spaces or a tab after the executable
    • leading or trailing whitespace

    For each, watch adds a second entry, both fire, and you get duplicate records. detach then removes ours and says nothing about the wrapped one. Builds from the windows-native-support branch write "C:\Program Files\…exe" hook --install …, which is no longer matched either. Keep the strict match, but have Remove/Present report any entry that contains --install <this id> without matching, so it isn't missed silently.

  2. Pin the narrowing cases. Two of my throwaway mutants survive every test:

    • TrimLeft(cmd[end+1:], " ") in place of the exactly-one-space check
    • strings.TrimSpace(cmd) in afterFirstWord

    Add TestOwner cases for two spaces, 'x'y post, and leading/trailing space.

Smaller

  • "$HOME/x" post … is claimed, but "/a b/x" post … is not, while the doc says "bare or quoted".
  • The H-5 mutant is misfiled: H-5 is "first run on a clean machine".
  • The LUI-3/4 rows can't be traced anywhere in the repo.
  • TestLUI4_ mutates the package-global legacyForms, which will break if anyone adds t.Parallel.
  • Run the full mutation sweep before merge. CI skipped it.

Generated by Claude Code

lutic1 commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

@k4rthiknerella, since you don't have Claude Code, I ran the repo's live test test/live/l-items.sh against this PR's head (eda07c2). Each run used a real Claude Code 2.1.303 session with its own HOME and store, and main (7b325a0) is the baseline.

  • L-1, a session using Bash, Write and an MCP tool: ✅ All 4 calls were recorded and all 4 transcript ids matched, with 0 missing. Coverage was verified, the same as main.
  • L-2, another tool's PreToolUse hook beside ours: ✅ The other hook fired and we recorded that Bash call (1/1), the same as main.
  • L-3, detach mid-session: coverage unverified, end_recorded false (reasons run_not_closed, transcript_mismatch, execution_mismatch). That's the same end state as main, but it isn't evidence either way.
    • On main, the reasons change from run to run (I ran it 4 times). Claude Code 2.1.303 runs sleep 25 in the background, so the second Bash call lands 0.08–1.1 s later and races detach.
    • l-items.sh needs a fix before L-3 can judge a PR. That's separate from this PR.

This adds to my review above; it doesn't replace it, and the open items there still stand.


Generated by Claude Code

@lutic1 lutic1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k4rthiknerella, I re-checked at b2f4a90. It's a rebase onto the new record/intake, and both commits are byte-identical to eda07c2. Build, vet, -race and --check pass, and Perf gates is red on the base too.

Confirmed fixed: none yet.

Still open, re-reproduced on b2f4a90

  1. Owner still returns no owner for wrapped entries, so detach skips them without a word:

    • env FOO=1 /…/rashomon post --install <id>
    • "/a b/rashomon" post … and "C:\Program Files\…\rashomon.exe" post …
    • two spaces, a tab, or leading or trailing whitespace

    Keep the strict match, but have Remove/Present report any entry that contains --install <this id> and doesn't match.

  2. The narrowing is still unpinned. Add TestOwner cases for two spaces, 'x'y post, and leading and trailing space.

  3. The smaller items are unchanged: the "bare or quoted" doc, the H-5 filing, the LUI-3/4 rows, the global legacyForms, and the sweep.


Generated by Claude Code

Owner read ownership off the tail of a hook's command line: it searched
for " <subcommand> --install " anywhere in the string and took whatever
followed as the install id. That is wrong once two entries can share an
event with subcommands where one is a suffix of the other -- a "post"
entry and a longer one that happens to end the same way would both read
as "post"'s. Owner now splits the command at the end of its first shell
word (the executable, bare or single-quoted) and compares everything
after it exactly against the subcommand, so a longer form is never
mistaken for a shorter one it merely ends with.

This also adds the hook a subcommand rename needs: a per-event list of
older subcommands Owner still recognises as ours, so a release that
renames one can refresh an existing entry in place (watch) and still
remove it (detach) instead of leaving it as residue. No subcommand has
been renamed yet, so every list is empty; the mechanism is proven with a
test-supplied legacy form rather than a real one.
afterFirstWord's single-quoted branch had no test of its own. Every
Windows install runs through it (shellQuote quotes any path with a
backslash), as does any POSIX install under a path with a space or an
embedded quote, so a break there would have shown up as every watch
adding a duplicate entry and every detach removing nothing, with CI
staying green. Add TestOwner cases for an embedded quote, a space, and
Windows backslashes, plus an unterminated quote resolving to no owner.
Add H-5_install.py, a mutant on the escaped-quote skip, confirmed red
by the three new quoted cases.

The whole-remainder match also means a command this install did not
write itself, but whose tail happens to be byte-identical to ours, no
longer reads as ours -- an env-prefixed wrapper, for one. Pin that with
its own TestOwner case so a future change back to a looser match has
to touch this test, not just add one.
@k4rthiknerella
k4rthiknerella changed the base branch from record/intake to main October 22, 2026 16:19

@lutic1 lutic1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@k4rthiknerella, I re-checked at deb31ad. It's a rebase onto main (e453ed4), and git range-diff shows both commits byte-identical to b2f4a90. Build, vet, -race and --check pass. Perf gates (linux) is red only for main's known issues: the six 1/4/8 MiB rows with no linux/amd64 row, the TestRES28p floor refusal, and the post.edit_314k and recap.stop entry rows. Benchgate and RES-29 pass.

Confirmed fixed: none yet.

Still open (code unchanged)

  1. Owner still returns no owner for wrapped entries (internal/install/install.go:241), so detach skips them without a word:

    • env FOO=1 /…/rashomon post --install <id>
    • "/a b/rashomon" post … and "C:\Program Files\…\rashomon.exe" post …
    • two spaces, a tab, or leading or trailing whitespace

    Keep the strict match, but have Remove/Present report any entry that contains --install <this id> and doesn't match.

  2. The narrowing is still unpinned. Add TestOwner cases for two spaces, 'x'y post, and leading and trailing space.

  3. The smaller items are unchanged:

    • the "bare or quoted" doc (install.go:236);
    • the H-5 filing;
    • the LUI-3/4 rows;
    • the global legacyForms (install_test.go:153);
    • the full mutation sweep, which CI still skips.

The description still says "Stacked on #61", but the base is now main and #61 has landed.


Generated by Claude Code

@k4rthiknerella
k4rthiknerella marked this pull request as ready for review October 22, 2026 17:21
@k4rthiknerella
k4rthiknerella merged commit 22d5fa9 into main Oct 22, 2026
12 of 13 checks passed
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.

2 participants