Skip to content

Execute #71's containment on Windows, with the baseline that gives it meaning - #320

Merged
MongLong0214 merged 6 commits into
devfrom
feat-issue-283
Jul 31, 2026
Merged

Execute #71's containment on Windows, with the baseline that gives it meaning#320
MongLong0214 merged 6 commits into
devfrom
feat-issue-283

Conversation

@MongLong0214

@MongLong0214 MongLong0214 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

For #283 (T-1124). Draft, and stopping here by the ticket's own stop condition. The measurement is complete; the assertions behind it do not pass, and nothing in this PR weakens them or claims Windows support.

What was established, on windows-latest

Question T-1124 had to answer Executed result
Does Git for Windows run the hook with its bundled sh? Yes/usr/bin/sh, MINGW64_NT-10.0-26100. The spawnSync /bin/sh ENOENT in doctor is the probe's failure, not the hook's
Does Windows init record the same shape as macOS? Yes — the .mjs bundle and an absolute interpreter
Is a recorded target outside the install root refused? Not established — the baseline fails first, so this never ran
Is an unrecognised target refused? Not established — same
Does a legitimate recorded target still work? No — the commit hangs and never returns
Does PATH resolution find the .cmd shim? Nocommand -v commitlore: NOT found

The findings are recorded in full, with the runner output, in #321.

The short version

commitlore.root = C:\Users\...\commitlore\v9.9.9        <- written by node, win32
dirname+pwd -P  = /c/Users/.../commitlore/v9.9.9/dist   <- read under MSYS sh, POSIX
containment case: NO MATCH -- the stub falls through
test -x node: yes
test -L bin: no
command -v commitlore: NOT found (the shim is commitlore.cmd)
real commit: HUNG -- killed at 90s, the hook never returned

Both of the containment guard's other conditions pass. Only the comparison fails, and it fails for every value including the installer's own bundle — so the guard neither contains nor permits, and control falls through to a walk that a probe on the same runner measured as walk: NON-TERMINATING, stuck at [C:].

doctor's TypeScript mirror reports no problem in that same state, because node:path is path.win32 and handles the pair correctly. It is green exactly when the hook is dead.

Why nothing is fixed here

src/hooks/commit-msg.ts is not in T-1124's Owns, and the ticket's stop condition is explicit: if the attacks fail on Windows, stop, do not weaken the job, do not mark Windows supported, and record the failure as its own issue. That is #321.

docs/COMPATIBILITY.md does not exist on dev (T-1122 / #271 has not merged), so this ticket's one-cell edit has no target. The row is left unflipped and this is the recorded reason — which is also the correct outcome on the evidence: Windows is reachable, not supported.

What the job contains

Four steps appended to install-ps1, no continue-on-error, timeout-minutes: 30:

  1. Measurement, not assertion — which sh, what init recorded, the containment case recomputed verbatim, where a fall-through lands, a scratch-repo probe reporting what a hook actually sees on this platform, and a bounded real commit.
  2. Baseline — a valid record accepted and an invalid one refused. Runs before the attacks because an attack that "passes" when nothing resolves is not containment, it is a hook that refuses everything.
  3. Attack 1 — a recorded commitlore.bin outside the install root, with a positive control proving the witness fires before the attack relies on its absence.
  4. Attack 2 — a commitlore.cmd planted inside the root. Labelled in the source as a regression guard on a loosening a Windows fix would plausibly make, not as evidence that containment holds.

Every commit through the hook is bounded, and a timeout is reported as HUNG — distinct from a refusal — rather than aborting the step.

Correction to an earlier commit message

a57cbaf explains the chained-hook probe's non-invocation as an argv0 with no / leaving hook_dir as .. The scratch-repo probe disproves that: argv0 = .git/hooks/commit-msg and dir-from-argv0 = .git/hooks. The probe's non-invocation has some other cause and is not diagnosed. It is also no longer load-bearing — the measurement no longer uses that slot.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

CommitLore — record lint

Trailers: clean — 6 commits in origin/dev..6333251581585b895a0c227cfe4dbec933c35028
Active constraints: 24 limits · 98 ruled-out · 16 warnings — from 39 records over 6 changed paths

Active constraints for the paths this PR touches

Limits (24)

  • r-winsupported1124 6333251 — repositories that installed the hook before Windows: the commit-msg hook hangs instead of returning, and #71's containment can never match there #321 keep the old stub and must re-run commitlore hooks install; a corrected release does not reach them, and this row's claim is about a working install
  • r-winderive1124 282693b — a diagnostic that retypes the code it describes goes stale silently, because nothing fails when the two drift apart -- it has to be read out of the artifact that ships
  • r-winbound1124 156deed — an unbounded hang is not evidence; it is a job that dies at the runner timeout having printed nothing, so every hook-invoking commit here has to carry its own bound
  • r-winassert1124 616005d — an assertion whose only oracle is an absent side effect cannot distinguish "refused" from "the mechanism never worked", so each one needs a positive control that fires before the attack
  • r-winshell1124 aa68a9a — a GitHub Actions bash step is invoked as bash -eo pipefail, so set -uo pipefail inside the step does not clear -e and any bare command that fails ends the step at that line
  • r-winfix1127 bd6a7fb — the fix has to reconcile the writer in hooks.ts with the reader in the stub; normalising either side alone relocates the mismatch rather than removing it
  • r-winfix1127 bd6a7fb — doctor's mirror is path.win32 and the stub is MSYS sh, so a stub-only fix leaves the mirror green while the hook is dead
  • r-compat1122 e7d8516 — a non-empty guard does not detect deletion; each table's row keys have to be asserted as a set or the statement can silently shrink to one row
  • r-compat1122 e7d8516 — substring comparison hides a narrowing -- ./ is inside ../ and Edit|Write is inside Edit|Write|MultiEdit|NotebookEdit -- so cells are compared as their rendered form
  • r-compat1122 e7d8516 — a sentinel containing \0 makes git treat the file as binary, which costs it diff, blame and log -p permanently
  • r-compat1122 e7d8516 — the plugin path needs bash, because scripts/commitlore-run.sh carries a #!/bin/bash shebang, and no install script checks for it
  • r-muslbullet1126 04ac181 — this ticket owns four bullets and not the tests that read the section around them, so a check that breaks here means a region was taken that was not allocated
  • r-ps1scope282 72f23df — the hook-runtime probe spawns /bin/sh, so it cannot report on a Windows hook at all until T-1124 changes it
  • r-ps1shell282 97735d6 — a step's shell key takes no expression, so a per-host matrix cannot select the interpreter
  • r-t1120nodeinst 14deeb4 — git and node are hard prerequisites now, so a host without them installs nothing and says which one is missing
  • r-f14owner281 f149ff9 — the shipped install.sh still fetches SHA256SUMS at this base, so no document may describe the installer as Node-only until T-1120 changes both together
  • r-instpath119 9e1fce7 — a user who ignores the printed line still gets "not found" on the next command
  • r-7a3e91 cf859e4 — better-sqlite3 stays external because it is native — the bundle degrades to --no-index without it, which only works because r-6f2a08 made that load lazy first
  • r-3b8f52 1f8b4be — the figure is one machine, one run — it establishes the order of magnitude, not a regression baseline anyone should tune against
  • r-0c5d38 aeb54a6 — the suite runs against the source tree, so no test in it can observe what packaging drops
  • r-6f2e58 ea9ae6d — a library test and a binary test cover different failures, and the packaging layer between them is exactly where a working module becomes a broken install
  • r-7e5f02 e5f5e00 — npm installs through an engine mismatch, so the ecosystem's own signal cannot be relied on to stop anything
  • r-3a9d68 6a3fc3b — a test runner reports what ran, and nothing in its summary distinguishes "did not run" from "does not exist"
  • r-8e2d51 ef93c0e — git is the parser, so a git version that folds or bounds trailers differently is a correctness problem for us, not a compatibility footnote

Ruled out (98)

  • r-winsupported1124 6333251 — Flipping the cell on the throwaway verification head | the ticket requires the attacks to pass in a required job in this ticket's own pull request, and a head that exists to be discarded is not that
  • r-winsupported1124 6333251 — Leaving the assertion pinned and editing only the document | the two contradict each other, so the suite would fail; a guard that blocks the change it was guarding for is a defect in the guard
  • r-winsupported1124 6333251 — Widening this ticket to the hook fix so it could be self-contained | the stop condition sent that to T-1127 for good reason, and this ticket's job was to measure and then say the word once measurement allowed it
  • r-winderive1124 282693b — Updating the two hardcoded lines to match the fixed stub | it would be correct today and wrong at the next change, for the same reason it is wrong now
  • r-winderive1124 282693b — Dropping the two diagnostics and relying on the commit alone | the commit says whether the hook worked, not which branch decided it, and that is the reading the next Windows question starts from
  • r-winbound1124 156deed — Fixing the walk in src/hooks/commit-msg.ts as part of this change | the stub is not this ticket's to own, and the ticket says to record a Windows failure as its own issue rather than absorb it
  • r-winbound1124 156deed — Instrumenting the stub to report its internal state | same ownership objection, and the chained-hook slot already reads the same environment without touching it
  • r-winbound1124 156deed — Raising the job timeout and letting the hang run to completion | it never completes, and the assertions behind it would never execute
  • r-winassert1124 616005d — Keeping the extensionless payload for attack 2 | the stub execs a recorded value only inside the .mjs|.js arm, so an extensionless target is refused by a branch that was never going to run it, and the step asserted its own control flow back to itself
  • r-winassert1124 616005d — Calling attack 2 evidence of containment once the payload is a .cmd | it guards a loosening, it does not exercise the install-root check; conflating the two is what would let a green sticker land on an untested branch
  • r-winassert1124 616005d — Keeping the chained-hook slot as the probe | it was never invoked, and a scratch repo with its own hook measures the platform without depending on the stub's argv0 handling
  • r-winassert1124 616005d — A trailing rm for the planted file | it is skipped by every early exit, and the install root is shared by every later step on the runner
  • r-winshell1124 aa68a9a — set +e at the top of each step | it also disables the abort for genuinely unexpected failures, where stopping is the right answer; the || form is scoped to the one command whose failure is a measurement
  • r-winshell1124 aa68a9a — Reading the outcome from the step's own exit code instead | 124 tells you a bound fired somewhere, not which commit, and it discards the probe output that explains why
  • r-winfix1127 bd6a7fb — Amending T-1124 to own the three source files | its stop condition exists for this fork, and voiding it to let a measurement ticket absorb a platform fix teaches that stop conditions are optional when the failure is interesting
  • r-winfix1127 bd6a7fb — Merging Execute #71's containment on Windows, with the baseline that gives it meaning #320's measurement step alone to keep CI green | it preserves the observation and discards the executable definition of done, and the hang would remain
  • r-winfix1127 bd6a7fb — Marking the containment assertions skipped until the fix lands | a skip looks like coverage and encodes surrender; the honest place for a red assertion is a draft pull request, not the mainline
  • r-winfix1127 bd6a7fb — Treating "new installs work" as the fix | a wedged repository cannot commit, so the recovery path is the part that matters to anyone already affected
  • r-compat1122 e7d8516 — Carrying the README's musl reason into the matrix | it cites glibc-only binaries, and there are no binaries; repeating it would document a mechanism that no longer exists
  • r-compat1122 e7d8516 — Reading the gate's removal as musl support | absence of a check is not evidence of working, which is why it was executed on two architectures instead
  • r-compat1122 e7d8516 — Claiming musl as a class from one image | alpine:3.21 is what ran, so other musl distributions are undecided rather than promised
  • r-compat1122 e7d8516 — One prerequisite column covering both install paths | the plugin path checks nothing, so a shared column would imply an enforcement a user does not get
  • r-compat1122 e7d8516 — Asserting the documented install command as its own string | it is <plugin>@<marketplace>, so deriving it from the two manifests makes a rename of either fail here
  • r-muslbullet1126 04ac181 — Replacing the bullet with "musl is supported" | T-1122 owns the compatibility statement, and a second summary in the README is the duplication the ownership map exists to prevent
  • r-muslbullet1126 04ac181 — Removing the Windows bullet beside it | it is still true, test/readme.test.ts uses its exact wording as a mutation oracle, and T-1124 owns that claim
  • r-muslbullet1126 04ac181 — Folding this into T-1122's pull request | that ticket's forbidden scope is one pointer line per README and nothing else; widening it to fix an adjacent owner's debris is how single-writer discipline decays
  • r-muslbullet1126 04ac181 — Merging T-1122 first and cleaning up afterwards | two ordinary merges are not transactional, and the window where dev contradicts itself has no upper bound
  • r-ps1scope282 72f23df — Making the hook probe pass by installing a shim named commitlore with no extension | Windows will not execute it, and inventing a second artifact to satisfy a probe is the wrong direction; the probe is what needs to learn about Windows
  • r-ps1scope282 72f23df — Committing through the hook in this job to prove the hook works | the hook path on Windows is exactly what is unestablished, so the assertion would be testing T-1124's subject in T-1121's job
  • r-ps1scope282 72f23df — Dropping the doctor call so the log stays clean | its output is the evidence T-1124 starts from, and a clean log that hides it would cost the next ticket the finding
  • r-ps1shell282 97735d6 — Duplicating the job once per host | eighty lines twice, kept in step by hand, to assert the same things about two interpreters
  • r-ps1shell282 97735d6 — Running everything through cmd with the host as a variable | every assertion would become a quoted one-liner passed to -Command, which is the least readable form of the most important job here
  • r-ps1shell282 97735d6 — Testing only PowerShell 7 | 5.1 is what a Windows machine has before anyone installs anything, and a 7-only construct is a parse error for the whole file
  • r-instps1121 7bf6ced — Writing the user-scope PATH with SetEnvironmentVariable | two active records on install.sh reject an installer that changes the user's environment behind their back, and the Windows registry spelling of that act is not a different decision
  • r-instps1121 7bf6ced — An extensionless shim matching the shell wrapper's name | Windows will not execute it, and the git hook resolves through recorded config rather than PATH, so the name would buy nothing
  • r-instps1121 7bf6ced — Testing only under PowerShell 7 | 5.1 is the version a Windows machine already has, and a 7-only construct is a parse error for the whole file rather than a failure in one branch
  • r-instps1121 7bf6ced — Requiring a helper for JSON merges the way install.sh needs jq | PowerShell parses and writes JSON natively, so the cannot-merge branch has no counterpart and inventing one would be a copied limitation
  • r-instps1121 7bf6ced — Passing the version through $args so irm | iex could take one | iex gives a piped script no arguments, so the pinned form is documented as a scriptblock invocation and COMMITLORE_VERSION is accepted as well
  • r-nobin284 ab2f08f — Keeping the binary classification arm for the installer's wrapper | the wrapper is a shell script that execs node, so trusting it by name would re-admit any extensionless executable called commitlore while the bundle it runs is the thing with an interpreter to check
  • r-nobin284 ab2f08f — Relaxing the dogfood Evidence check to allow a missing path | it exists to catch a citation nobody can follow, and dropping the check to accommodate a deletion would retire the guarantee instead of correcting the question it asked
  • r-nobin284 ab2f08f — Leaving src/core/paths.ts alone because ADR-0026's inventory did not name it | it was the only remaining code that read files out of a compiled artifact, so leaving it would have left the invariant false while the test asserting it passed
  • r-nobin284 ab2f08f — Keeping /dist/commitlore in gitignore in case a binary returns | an ignore rule for an artifact nothing produces is a claim that something might, and the ADR says otherwise
  • r-wrapfallback 5a78bcc — Adding a node version check to the wrapper now | requirement 7 makes it thin deliberately and the cost lands on the hook hot path, so that trade belongs to an approved ticket rather than a documentation pass
  • r-wrapfallback 5a78bcc — Recording that an older Node breaks the bundle | none was available to run it, and asserting a failure nobody observed is the overclaim this project refuses
  • r-wrapfallback 5a78bcc — Leaving the fallback undocumented because it works | it works by using an unchecked interpreter, and the check it silently drops is one the installer advertises
  • r-t1122measured 1267579 — Marking musl supported because the platform gate is gone | removing an obstacle is not the same as running the thing, and claiming a platform without executing it is the specific error the distribution ADRs record
  • r-t1122measured 1267579 — Listing Node once as a prerequisite for both install paths | only one of them checks it, and a document that implies otherwise tells a user their setup was validated when it was not
  • r-t1122measured 1267579 — Fixing the stale musl bullet here | it is a residual compiled-binary reference, which the ownership map assigns to T-1125, and two tickets editing one region is the defect this file was corrected for
  • r-t1123inv 7472bc1 — Matching config entries by the substring commitlore | it would also match an unrelated server a user named commitlore-something, and removing someone else's entry is the failure this ticket exists to prevent
  • r-t1123inv 7472bc1 — Having uninstall remove the Claude Code plugin cache too | 6948 files keyed by plugin version belong to the CLI that wrote them, and reaching into another tool's cache is not this command's job
  • r-t1123inv 7472bc1 — Leaving the counts out and describing the split in prose | the split is only obviously right once the numbers are beside each other
  • r-containevid 3cc3f22 — Leaving both clauses as written and relying on the issue comments | a ticket read at implementation time is the contract, and a comment on an issue is not where a forbidden-scope clause belongs
  • r-containevid 3cc3f22 — Concluding from this that Windows containment is already safe | the measurement is macOS only, which is the whole reason T-1124 exists, and inferring the platform is the specific error the ADR forbids
  • r-f14postt1120 e427225 — Deleting the install.sh inventory row outright | a struck-through row that says which ticket completed it and what remains is a record; a deleted row leaves a later reader unable to tell whether it was done or forgotten
  • r-f14postt1120 e427225 — Re-anchoring every 8b0c9fa reference to the merged head | some of them are RED justifications about what was true before T-1120 shipped, and rewriting those would make the tickets claim their tests fail against a state that no longer exists
  • r-f14postt1120 e427225 — Leaving T-1121 to re-derive T-1120's contract at implementation time | it is a fact now, and an implementer inferring it can infer it differently, which is how two installers stop implementing one contract
  • r-t1120fatal 0412668 — Reading the remaining failure by inference from the generic advice line | two cycles already went that way, and the informative line was present in the log the whole time
  • r-t1120cijob 1871106 — Keeping the separate staged clone and adding more mount configuration | each addition would be configuration protecting a copy that exists for no reason once the checkout can be cloned directly
  • r-t1120cijob 1871106 — Dropping the container steps and relying on the vitest suite | the suite runs on a machine that has node and git; the point of a bare image is that the prerequisite is genuinely absent, which is the assertion the active record on this file asks for
  • r-t1120clonemsg 55ffb0a — Keeping the guessed message and fixing only the CI mount | the guess would still be printed for every other clone failure, and this one was found only because a container happened to hit it
  • r-t1120clonemsg 55ffb0a — Letting the clone's stderr through to the terminal unfiltered | it interleaves with the installer's own log lines and a piped installer should speak in one voice; the last lines quoted into one named error keep both
  • r-t1120nodeinst 14deeb4 — Hardcoding the release version in install.sh as the default | it would add a bump obligation to every release and a stale default between a tag and the bump; git ls-remote resolves the newest tag with no API token and no rate limit
  • r-t1120nodeinst 14deeb4 — Defaulting to a branch when no tag is given | installing a moving target is what pinning exists to prevent, and the release gate already forbids a dev-referencing one-liner
  • r-t1120nodeinst 14deeb4 — Writing the wrapper with cp over the destination | an in-place overwrite of a file that may be executing is the defect that forced a same-day patch release; the wrapper is written beside the target and renamed
  • r-t1120nodeinst 14deeb4 — Letting post-install verification decide the exit code | that was the other half of the same defect, where a killed --version became the installer's exit status; verification now retries once and reports, and an install that succeeded exits 0
  • r-t1120nodeinst 14deeb4 — Editing the shell profile when the wrapper directory is not on PATH | an active record on this file rejects it, and printing the line is what this script does about PATH
  • r-t1120nodeinst 14deeb4 — A test-only environment hook to force a verification failure | a source repository whose bundle exits non-zero is a real broken release and needs no seam in shipped code
  • r-t1120nodeinst 14deeb4 — Checking for git with command -v alone | a git that cannot execute is as useless here as a missing one, so the check runs git --version and catches both
  • r-f14owner281 f149ff9 — Leaving the README to T-1122 and letting requirement 29 slide | the requirement exists because a README that describes an installer it does not ship beside is the failure this project treats as most serious, and deferring it guarantees a window in which that is exactly what ships
  • r-f14owner281 f149ff9 — Moving requirement 29 out of the removal section into T-1120's own section | the requirement text is cited by number elsewhere, and renumbering to tidy placement would break those citations for a cosmetic gain
  • r-f14owner281 f149ff9 — Splitting the README region between T-1120 and T-1122 by language or by block | two owners on one region is the defect being corrected, and any split reintroduces it in a smaller form
  • r-f14owner281 f149ff9 — Declaring one owner per file and calling ci.yml an exception without enumerating it | an exception a reader has to interpret is the kind that rots; the four jobs are listed so a crude scan and a human reach the same answer
  • r-gateb4replan 5a35062 — Writing the Gate B acceptance rows for these tickets now | a row with no approved ticket behind it is exactly the dangling authority the Gate A matrix was written to stop, so each row lands with its ticket
  • r-gateb4replan 5a35062 — Shipping the compatibility statement before the installers | the document would describe an installer that does not exist, which is the failure mode this project treats as most serious
  • r-gateb4replan 5a35062 — Removing the compiled-binary code in the same wave as the new installers | it would open a window in which neither install path works
  • r-gateb4replan 5a35062 — Treating Windows as supported once install.ps1 lands | reachability through an installer is not a verified containment property, and conflating them would ship a platform where the property is unestablished
  • r-gateb4replan 5a35062 — Reusing the F12 ticket numbers for this work | those numbers name withdrawn tickets whose issues are closed as not planned, and reusing them would make the history unreadable
  • r-instpath119 9e1fce7 — editing the user's shell profile from the installer | printing the line is honest, rewriting .bashrc silently is what makes people distrust curl-to-shell installers, and this project sells verifiability
  • r-instci99a 318b9a2 — pre-installing curl/jq/tar/git in the containers before running install.sh | would hide exactly the gap this work exists to find — Debian ships neither curl nor wget by default, and asserting that failure cleanly is the point of the bare debian:stable-slim step, not an obstacle to route around
  • r-instci99a 318b9a2 — publishing a -musl release target so Alpine could install successfully | a release.yml/build-matrix change, not an install.sh or CI-verification fix; out of scope here (DO NOT: no Docker in the release build matrix) and orthogonal to making the existing failure clear instead of a raw crash
  • r-instci99a 318b9a2 — heuristically detecting musl before download (checking for /lib/ld-musl-*.so.1) | executing the freshly extracted, already checksum-verified binary directly is a stronger, more general check — it catches any reason the binary cannot run on this machine, not only the one musl signature this repository happens to know the path for
  • r-instci99a 318b9a2 — a matrix job splitting debian/alpine | the two images need different assertions (debian: bare failure, then success once curl is added; alpine: one clean failure, nothing more to add) and share one staged release + HTTP server; one job with named, ordered steps keeps that shared setup and reads linearly in the log
  • r-5e91c7 f991389 — leaving pull_request as the only check for develop | a direct push to an integration branch is exactly what nobody reviews
  • r-7a3e91 cf859e4 — inlining spec/SPEC.md and the schema into the bundle | SPEC.md would need a codegen step that itself needs a drift guard, and the package-root walk removes the reason to want it
  • r-7a3e91 cf859e4 — replacing the tsc output with the bundle | test/cli.test.ts, test/hooks.test.ts and test/mcp.test.ts import dist internals by path
  • r-3b8f52 1f8b4be — enabling the large fixture for the whole default suite | generating 100k commits costs ~22s and the other tests in that file gain nothing from it, so it runs as its own step against its own file
  • r-7f31c9 750ab17 — reporting both datasets from one source list | readSources groups by condition and cannot separate repositories, so any second dataset with a commitlore-on arm silently corrupts the headline test
  • r-0c5d38 aeb54a6 — smoke with --version only | it passes with the schema missing, which is the failure worth catching
  • r-0c5d38 aeb54a6 — assert a file list in package.json | it goes stale silently, and installing is the actual question
  • r-6f2e58 ea9ae6d — pin the adoption sha in the workflow | it is the same stale cutoff the dogfooding suite was written to avoid
  • r-6f2e58 ea9ae6d — spawn git per commit to read trailers | one pass with %(trailers:key=…) uses git's parser and does not scale with history
  • r-7e5f02 e5f5e00 — hold Node 20 and downgrade both dependencies | paying maintenance cost to keep supporting an unpatched runtime
  • r-7e5f02 e5f5e00 — floor at 24 | excludes Node 22 users today for no benefit, and 22 outlives v0.1.0
  • r-7e5f02 e5f5e00 — drop engines entirely | the failure moves from install time to runtime, where it is someone else's confusing bug
  • r-3a9d68 6a3fc3b — pin an expected test count | it goes stale on the next file added, and a stale count reads as passing
  • r-3a9d68 6a3fc3b — keep threads and skip the index tests in CI | that is the same hole with the sign flipped, made deliberate
  • r-8e2d51 ef93c0e — pin one git version in CI | it would hide exactly the divergence the matrix exists to surface
  • r-8e2d51 ef93c0e — allow shallow checkout and skip dogfooding | a skipped gate reports the same green as a passing one

Warnings (16)

  • r-instps1121 7bf6ced (claim) — nothing on this machine can execute this file -- there is no PowerShell here, so every claim about its behaviour comes from the Windows runner in this pull request rather than from a local run
  • r-nobin284 ab2f08f (claim) — an extensionless COMMITLORE_BIN is now ignored rather than honoured -- the hook falls through to the recorded install and to PATH, so a caller who pointed it at the wrapper still gets a working hook, by a different route
  • r-wrapfallback 5a78bcc (claim) — the wrapper enforces no Node version at run time -- the installer's Node 22 check holds only until the recorded interpreter path changes
  • r-t1120nodeinst 14deeb4 (claim) — keep install.sh ASCII only -- a non-ASCII character in a string silently terminated /bin/sh here while sh -n accepted the file
  • r-f14owner281 f149ff9 (claim) — T-1120 must land the installer and all four README shell-install regions in one commit -- a two-commit split puts a false README on the branch, which is the window this correction closes
  • r-gateb4replan 5a35062 (claim) — T-1125 must preserve install-root containment for the wrapper case -- a diff that deletes the check together with the compiled-binary arm removes a verified security property
  • r-instci99a 318b9a2 (claim) — the new binary-execution guard's die() message names musl/glibc specifically because that is the only way this repository's own release actually fails to execute today (glibc-only "-gnu" targets, verified against Alpine). If a -musl target is ever published, or the binary can fail to execute for an unrelated reason, this message will misdiagnose it — revisit the wording if that guard ever needs to distinguish causes.
  • r-7f24a9 96d960a (claim) — this file is still NOT validated as a GitHub workflow. actionlint is unavailable and the sandbox cannot reach the API. Conflating a YAML parse with workflow validation broke gitseed's CI earlier today; the first run is the proof
  • r-7f24a9 96d960a (claim) — the same task was told to update the factory skill's Gitflow section and its phase-gate.py, and could not — it was launched rooted at this repository and had no write access to ~/.claude/skills. The gate still checks for a branch named develop and fails both repositories for having dev. My spec error, not the delegate's; it is the next task
  • r-5e91c7 f991389 (claim) — these files are NOT validated as GitHub workflows. actionlint is unavailable and the sandbox cannot reach the API, so only the YAML parse and structural assertions ran. Conflating those two broke gitseed's CI four commits ago; the first GitHub run is the proof
  • r-7a3e91 cf859e4 (claim) — hardcoding ../ counts back to the package root is what broke this — new code reads assets through installedPath(), never through import.meta.url
  • r-7f31c9 750ab17 (claim) — adding a file to README_SOURCES pools it into every aggregate in the block, including the significance test — check the arm names first
  • r-0c5d38 aeb54a6 (claim) — spec/ ships on purpose -- alternative implementations are told the fixtures are the contract, and this step is what keeps that true
  • r-7e5f02 e5f5e00 (claim) — raising the floor again needs an ADR superseding ADR-0010 -- a package.json-only change will pass tests and lie to users
  • r-3a9d68 6a3fc3b (claim) — any native addon added later needs the fork pool -- moving back to threads will silently drop whatever loads it
  • r-8e2d51 ef93c0e (claim) — keep fetch-depth 0 on any job that runs npm test -- the dogfooding suite fails deliberately on a shallow clone, and "fixing" that by relaxing the assertion removes the gate

git log --follow accepts exactly one pathspec, so renames are not followed for 6 paths; query one path at a time to follow its rename chain

withheld the content of 3 record(s) graded blocked: a Limit, Ruled-out, Verified trailers matching an injection pattern is reported, never quoted (SPEC §7)

Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR.

@MongLong0214

Copy link
Copy Markdown
Owner Author

The steps in this PR pass once #321's fix is in front of them

Observed on a throwaway head (#326) that layers this job onto T-1127's fix — PR #327. install-ps1: success, steps 12-15 all green.

real commit: ACCEPTED (exit 0)
baseline ok: the hook runs, accepts a valid record and refuses an invalid one
positive control ok: the witness fires when the payload runs
--- commit exit 1 --- attack 1 refused: the program outside the install root never ran
positive control ok: the planted .cmd fires when it is run
--- commit exit 1 --- attack 2 refused: the unallowed-extension target never ran

The two containment attacks have now executed for the first time. Until this run the baseline failed first and they never ran at all. commit exit 1 on both shows the hook ran and declined rather than the commit failing before it; the positive controls fire first, so an absent witness means not-executed rather than a witness this harness could never observe.

Two lines in the measurement step are now stale, and they read as contradictions

Same log, same run:

containment case: NO MATCH -- the stub falls through
walk: NON-TERMINATING, stuck at [C:]

Both are wrong about the shipped code now, and both are this PR's to fix. They restate the pre-fix logic rather than deriving it: the measurement step hardcodes the old case and the old walk instead of reading them out of commitMsgStub(). So they will keep printing the original defect forever, next to a real commit: ACCEPTED that contradicts them.

That is the same class of mistake this job exists to catch — a check that restates what it claims to measure. T-1127's own test does it the other way and can be copied: it slices the walk out of commitMsgStub() and executes that text, so it cannot pass against a fix made only in a comment.

Suggested before this PR leaves draft: derive both diagnostics from the stub, then rebase onto dev once #327 merges and run the job at that exact head. Only that run — not this throwaway — is the evidence for Minimum GREEN.

… meaning

T-1121 made Windows reachable. Whether the install-root containment holds there
has only ever been established on macOS, and a property verified on one platform
is not a property of the product.

Four steps, in the order that makes the answer readable. The first measures rather
than asserts: which sh git will use, what init recorded for bin/root/node, the
stub's containment case recomputed verbatim, where a fall-through would land, and
a real commit through the real hook. Its output is the evidence the next step's
verdict has to be read against, and it is deliberately not an assertion -- a
measurement that fails the job tells you less than one that prints.

The other three assert. The baseline runs before the attacks because an attack
that passes by nothing resolving at all is not containment, it is a hook that
refuses everything, and only the baseline separates those. It asserts both
directions: a valid record is accepted, an invalid one refused. Then #71's two
attacks against a wrapper target -- a recorded bin outside the install root, and a
recorded target with no recognised extension planted inside it -- each with a
witness file the tampered program writes if it ever runs.

Paths cross two worlds here and the test converts rather than assumes. init
records a win32 path; the stub reads it under MSYS sh, where a witness bash calls
/tmp/ran.log is C:\tmp\ran.log to node. cygpath -w for what git config records,
cygpath -m for what is embedded in JavaScript, and the witness checked on the
bash side of the same file. Mixing the forms would report "the tampered program
never ran" because the check looked in the wrong place -- a false pass this ticket
cannot afford. The witnesses are ESM for the same reason: .mjs is ESM, and a
require() there throws before writing anything.

Record-Id: r-winred1124
Limit: only a windows-latest runner can answer this; Git for Windows' bundled MSYS sh is what runs the hook, and no local macOS or Linux container reproduces its pwd -P output form
Ruled-out: Asserting the two attacks without a baseline | a hook that fails to resolve anything refuses the attacks too, so the attacks alone cannot distinguish containment from a dead hook
Ruled-out: Simulating the Windows path forms in a hermetic POSIX test | it can exercise the case pattern, but pwd -P's MSYS output is the unknown, so the test would assert its own assumption
Ruled-out: Reusing bash-side /tmp paths for the witness files | node on Windows reads /tmp/x as C:\tmp\x, so the check would look somewhere the witness was never written and pass for the wrong reason
Ruled-out: continue-on-error on the attack steps | the ticket forbids it, and a containment assertion that cannot fail the job asserts nothing
Certainty: tentative
Blast: local
Undo: easy
Verified: the workflow parses and install-ps1 now holds fourteen steps with no continue-on-error anywhere in the file; typecheck and build are clean and dist is unchanged by this commit
Unverified: every one of the four new steps, none of which has run on Windows; whether the containment case can match at all there, which is what this commit exists to measure
… hung

The measurement step never finished. Step 11 commits before the hook exists and
took five seconds; step 12 commits after `init` installed it and was still running
seventeen minutes later, so the hang is the hook, and the run had to be cancelled
before it reached the three assertions behind it.

There is exactly one unbounded construct in the stub, and it is reachable from a
fall-through:

  dir=$PWD
  while [ -n "$dir" ]; do
    if [ -x "$dir/node_modules/.bin/commitlore" ]; then exec ...; fi
    dir=${dir%/*}
  done

`${dir%/*}` returns the string unchanged when there is no `/` left in it. From
`/c/Users/x/repo` the walk reaches `/c`, then the empty string, and stops. From a
drive-letter form it reaches `C:` and stays there. Reproduced under /bin/sh
locally: four iterations for the POSIX form, non-terminating for the other.

So the Windows outcome is not the refusal this ticket was written to expect. It is
a commit that never returns, which is worse for a user than either refusing or
accepting, and it is a finding rather than an obstacle -- the stub belongs to
another owner and is not edited here.

What changes is the harness, so the finding is measured instead of inferred:

- every commit that passes through the hook runs under `timeout -k 5 90`, and 124
  is reported as HUNG, distinct from a refusal
- the job carries `timeout-minutes: 30`, so a missed bound costs half an hour
  instead of the runner's six-hour default
- a probe in the stub's own chained-hook slot reports what the hook actually sees
  -- `$0`, `$PWD`, `pwd`, `pwd -P` -- and runs the same walk with a counter, so
  the loop's behaviour is read off the runner rather than argued from the source.
  That slot is a supported entry point, so nothing here edits the stub
- each attack now prints the commit's exit code and the hook's output. "The
  witness is absent" is only containment if the hook ran and declined, and the
  refusal text is what tells those apart
- attack 2 removes its planted file before the verdict, so a failing assertion
  cannot leave it inside the install root every later step shares

Record-Id: r-winbound1124
Limit: an unbounded hang is not evidence; it is a job that dies at the runner timeout having printed nothing, so every hook-invoking commit here has to carry its own bound
Ruled-out: Fixing the walk in src/hooks/commit-msg.ts as part of this change | the stub is not this ticket's to own, and the ticket says to record a Windows failure as its own issue rather than absorb it
Ruled-out: Instrumenting the stub to report its internal state | same ownership objection, and the chained-hook slot already reads the same environment without touching it
Ruled-out: Raising the job timeout and letting the hang run to completion | it never completes, and the assertions behind it would never execute
Certainty: firm
Blast: local
Undo: easy
Verified: the walk's non-termination reproduced under /bin/sh for a drive-letter form and terminating in four iterations for the POSIX form; the workflow parses with fourteen steps, four bounded commits, no unbounded `git commit -m`, and no continue-on-error key; the previous run reached step 12 with steps 1-11 all success
Unverified: the hook's actual $PWD form on the runner, which is what the chained probe exists to report; whether the hang is the walk or something earlier in the fall-through; all four steps, none of which has completed on Windows
The bound worked and the step still lost its verdict. `timeout` fired at 90s, and
the step ended on that line with exit 124 -- before the case that names the
outcome, and before the probe output underneath it.

Actions runs a bash step as `bash --noprofile --norc -eo pipefail`. The step's own
`set -uo pipefail` sets what it names and clears nothing, so the -e it was invoked
with was still in force, and a timeout that fires is a non-zero command. Every
bounded commit now runs as `|| rc=$?`, which is a tested command and not an
abort.

What the run did establish, before it cut itself off, is the whole shape of the
Windows question:

  commitlore.bin  = C:\Users\...\commitlore\v9.9.9\dist\commitlore.mjs
  commitlore.root = C:\Users\...\commitlore\v9.9.9
  dirname+pwd -P  = /c/Users/.../commitlore/v9.9.9/dist
  containment case: NO MATCH -- the stub falls through
  test -x node: yes
  test -L bin: no
  command -v commitlore: NOT found (the shim is commitlore.cmd)

Both of the stub's guards pass and the comparison that is supposed to tell the
installer's own bundle from an attacker's path does not match, because the two
sides are written in different path worlds: init records win32, and `pwd -P` under
Git for Windows' sh answers in POSIX form. Nothing is exec'd, `commitlore` is not
findable by that name because the shim is `.cmd`, and the commit reached the 90s
bound rather than returning. The job's cleanup names what was left behind:
`Terminate orphan process: pid (7764) (sh)`.

That is measured, not inferred. What is still missing is which construct spins,
and the chained-hook probe already ran -- its output was simply never printed.

Record-Id: r-winshell1124
Limit: a GitHub Actions bash step is invoked as `bash -eo pipefail`, so `set -uo pipefail` inside the step does not clear -e and any bare command that fails ends the step at that line
Ruled-out: set +e at the top of each step | it also disables the abort for genuinely unexpected failures, where stopping is the right answer; the || form is scoped to the one command whose failure is a measurement
Ruled-out: Reading the outcome from the step's own exit code instead | 124 tells you a bound fired somewhere, not which commit, and it discards the probe output that explains why
Certainty: firm
Blast: local
Undo: easy
Verified: on windows-latest at the previous head -- the recorded triple above, the containment case not matching, both stub guards passing, commitlore not resolvable by bare name, and the commit killed at the 90s bound with an orphan sh surviving into cleanup; the workflow parses with fourteen steps, four bounded commits, no bare rc=$? after a bound, and no continue-on-error key
Unverified: the probe's report of the hook's own $0/$PWD/pwd -P and the walk's iteration count, which is what the next run exists to print; the three assertions behind the measurement, none of which has executed
…probe

The run answered the ticket's question and, reading its own output back, two of
the three assertions behind it would not have been worth the answer.

The measurement's last line said "the probe never ran: git did not invoke the
hook". The commit hung, so the hook certainly ran; what did not run was the probe.
The stub finds its chained slot with `hook_dir=${0%/*}`, and an argv0 holding no
slash leaves that as `.`. The claim is removed and the measurement now installs
its own commit-msg hook in a scratch repository, which reads Git for Windows
rather than commitlore: argv0, PWD, pwd, pwd -P, uname, the dirname the stub would
compute, and the node_modules walk with a counter on it.

An adversarial review of the assertions found three ways they could report
containment without having tested any:

- The stub consults COMMITLORE_BIN before the recorded path. A value inherited
  from the environment would satisfy the baseline with the recorded install never
  consulted. Each assertion now unsets it, and checks that core.hooksPath is
  unset and that the installed commit-msg hook carries commitlore's marker --
  otherwise "the hook refused" can mean "some other hook refused".
- Attack 1's only oracle was an absent witness file, which is equally consistent
  with a witness this harness could never observe: a path in the wrong world, an
  ESM error, an unwritable directory. It now runs the payload directly first and
  requires the witness to appear, so absence afterwards means not-executed.
- Attack 2 planted an extensionless file. Nothing in the stub can exec a recorded
  value outside the `*.mjs|*.js` arm, so refusing it tested a branch that does not
  exist. The payload is now `commitlore.cmd` -- the extension a Windows fix is
  most likely to add, since the installer's own shim carries it -- which makes the
  step a regression guard on a loosening somebody would plausibly make. It is not
  evidence that containment holds, and the comment says so; attack 1 is.

Also: the planted file is removed by an EXIT trap rather than a trailing rm, so a
failing assertion cannot leave it in the install root every later step shares.

Record-Id: r-winassert1124
Limit: an assertion whose only oracle is an absent side effect cannot distinguish "refused" from "the mechanism never worked", so each one needs a positive control that fires before the attack
Ruled-out: Keeping the extensionless payload for attack 2 | the stub execs a recorded value only inside the .mjs|.js arm, so an extensionless target is refused by a branch that was never going to run it, and the step asserted its own control flow back to itself
Ruled-out: Calling attack 2 evidence of containment once the payload is a .cmd | it guards a loosening, it does not exercise the install-root check; conflating the two is what would let a green sticker land on an untested branch
Ruled-out: Keeping the chained-hook slot as the probe | it was never invoked, and a scratch repo with its own hook measures the platform without depending on the stub's argv0 handling
Ruled-out: A trailing rm for the planted file | it is skipped by every early exit, and the install root is shared by every later step on the runner
Certainty: firm
Blast: local
Undo: easy
Verified: the workflow parses with fourteen steps, five bounded commits, three unset COMMITLORE_BIN, two positive controls and no continue-on-error key; the previous head established on windows-latest that git uses /usr/bin/sh, that init records win32 paths while pwd -P answers in POSIX form, that the containment case does not match, that both stub guards pass, that commitlore is not resolvable by bare name, and that a real commit hangs to the 90s bound
Unverified: the scratch-repo probe and the two positive controls, none of which has run; the three assertions, which the baseline failure has so far kept from executing
The run that proved this job works also showed it lying. With T-1127's fix in
front of these steps the baseline passed, both containment attacks executed for
the first time and both refused -- and the same log still said:

  containment case: NO MATCH -- the stub falls through
  walk: NON-TERMINATING, stuck at [C:]

Neither was true any more. Both were restatements: the step retyped the stub's
comparison inline and the probe retyped its walk, so once the stub changed they
went on reporting the original defect next to a commit that had plainly
succeeded. A measurement that paraphrases what it measures reports on the
paraphrase, which is the failure this job exists to catch, committed by the job.

Both now come from the installed hook. The containment branch is printed as
shipped, with the two paths it compares resolved beside it, and the verdict is
left to the real commit underneath rather than announced ahead of it. The walk is
sliced out of the same file with awk, seeded with the drive-letter PWD the probe
reports, and run under a bound -- so a fix made only in a comment cannot pass it,
and a walk that does not return fails the step instead of printing a stale
verdict.

Rebased onto the fix. These steps have never run against a working hook until
now, and the head that carries both is what the completion evidence has to cite.

Record-Id: r-winderive1124
Limit: a diagnostic that retypes the code it describes goes stale silently, because nothing fails when the two drift apart -- it has to be read out of the artifact that ships
Ruled-out: Updating the two hardcoded lines to match the fixed stub | it would be correct today and wrong at the next change, for the same reason it is wrong now
Ruled-out: Dropping the two diagnostics and relying on the commit alone | the commit says whether the hook worked, not which branch decided it, and that is the reading the next Windows question starts from
Certainty: firm
Blast: local
Undo: easy
Verified: the workflow parses with fourteen steps and no continue-on-error key; neither hardcoded verdict string survives in the file; on the previous head, with the fix in front of these steps, install-ps1 passed with the baseline accepting a valid record and refusing an invalid one, both positive controls firing, and both attacks refused at commit exit 1
Unverified: the two rewritten diagnostics, which have not run; the job at this exact head
The containment attacks executed on windows-latest and refused, with the
legitimate install working in front of them. That was the whole precondition, and
until #321 was fixed it was unreachable: the baseline failed first, so neither
attack had ever run.

  walk: returned
  real commit: ACCEPTED (exit 0)
  baseline ok: the hook runs, accepts a valid record and refuses an invalid one
  positive control ok: the witness fires when the payload runs
  --- commit exit 1 --- attack 1 refused: the program outside the install root never ran
  positive control ok: the planted .cmd fires when it is run
  --- commit exit 1 --- attack 2 refused: the unallowed-extension target never ran

The exit codes are the part worth reading. Both attacks end at commit exit 1,
which is the hook running and declining -- not the commit failing before it -- and
each positive control fires first, so an absent witness means the program did not
run rather than a witness this harness could never have seen. A fix that had
loosened the match to make the legitimate path work would have passed the baseline
and failed exactly here.

Two corrections travel with it.

The parent-path diagnostic printed nothing, because it was nested three quotings
deep inside a command substitution. It is two plain statements now.

And the assertion in test/compatibility-matrix.test.ts that pinned this cell away
from `supported` was mine, written in T-1122 while this ticket was unproven. It
encoded a transient fact as an invariant and made the one cell edit this ticket is
authorised to perform impossible to land. What is actually invariant is that the
row's status rests on #71's containment being established there, so the citation
is what it checks now, in either direction. The ticket's Also-changes clause
records the reassignment rather than leaving a silent overreach.

Record-Id: r-winsupported1124
Limit: repositories that installed the hook before #321 keep the old stub and must re-run `commitlore hooks install`; a corrected release does not reach them, and this row's claim is about a working install
Ruled-out: Flipping the cell on the throwaway verification head | the ticket requires the attacks to pass in a required job in this ticket's own pull request, and a head that exists to be discarded is not that
Ruled-out: Leaving the assertion pinned and editing only the document | the two contradict each other, so the suite would fail; a guard that blocks the change it was guarding for is a defect in the guard
Ruled-out: Widening this ticket to the hook fix so it could be self-contained | the stop condition sent that to T-1127 for good reason, and this ticket's job was to measure and then say the word once measurement allowed it
Certainty: firm
Blast: local
Undo: easy
Verified: install-ps1 green at the previous head of this branch with steps 12-15 all passing on windows-latest -- the walk returning, a real commit accepted, the baseline discriminating both ways, both positive controls firing and both containment attacks refused at commit exit 1; typecheck clean; compatibility-matrix, readme, no-compiled-executable, hooks and hook-target at 138 passed; the workflow parses
Unverified: the two corrections in this commit, which have not run on a runner yet
@MongLong0214
MongLong0214 marked this pull request as ready for review July 31, 2026 15:22
@MongLong0214
MongLong0214 merged commit 51abef8 into dev Jul 31, 2026
8 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.

1 participant