From 94bbc9a8dce8145535fef30833ee43388ba114cb Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:45:44 -0500 Subject: [PATCH 1/2] limit 63: the matcher version stamp hashes the entry points, not the deciders Every gate, warn, grant and egress receipt carries a matcher version, and it is the field a reader uses to ask whether two decisions were made by the same rules. It hashes thirteen top-level functions plus the two rule tables. Function.toString returns a function's own source and nothing it calls, so the helpers that actually decide self-mod are outside it: the protected-path list, both self-mod matchers, the path normalizer, the segment matcher, the brace expander and the prose strippers. The dispatcher that IS hashed only names the matchers it calls. Change the protected list or the way a path is folded before matching, and the gate stops a different set of actions while the stamp stays byte-identical. The silence runs the wrong way: a matcher weakened between two runs keeps stamping the old, stronger version. Enforcement is unaffected; the trace misdescribes it, which is why this is filed as a witness defect. Measured, not read off the source. The new test asserts the absence directly against the hashed inputs, with controls asserting the hashed bodies are present so the block cannot pass vacuously. Stamp on this build: matcher/1 95291ff6385151ca. The stamp was introduced 2026-08-09 (b1b7bf8); the protected list gained an entry on 2026-08-23 and the version did not move. The repair is core and is not attempted here: name the helpers in parts and bump the schema marker to matcher/2, since the hashing method changes and a value moving on its own would be indistinguishable from a rule edit. Suite 962 pass / 0 fail (8 new). Co-Authored-By: Claude Opus 5 (1M context) --- KNOWN-LIMITS.md | 59 ++++++++++ test/policy-matcher-stamp-coverage.test.js | 131 +++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 test/policy-matcher-stamp-coverage.test.js diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index bf3f1da..ffe26a7 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -2074,3 +2074,62 @@ reviewed sitting rather than in this entry. behavior with the plain spellings beside it as controls. **When the matcher is fixed, that file fails.** The repair is to invert its assertions and amend this entry in the same change, never to delete the block. + +## 63. The matcher version stamp hashes the rule entry points, not the code that decides + +Found 2026-09-02, by asking what `matcherVersionHash()` actually reads rather than +what its comment says it reads. + +Every `gated-action`, `policy-warn`, grant and egress receipt carries a matcher +version. It is the field a reader uses to answer the only question that makes two +receipts comparable: **were these decided by the same rules?** The function's own +docstring calls it the "content hash of the matcher logic in force right now." + +**It hashes thirteen top-level functions plus `RULE_TABLE` and `RULE_INFO`.** +`Function.prototype.toString()` returns a function's own source and nothing it +calls, so a helper is covered only if the `parts` array names it. The self-mod +deciders are not named: `selfModFragmentsForBase` (the protected-path list +itself), `isSelfModEdit`, `selfModCommandHit`, `normalizePath`, +`pathContainsFragment`, `expandBraces`, `stripHeredocBodies`, `stripMessageArgs`. +`isSelfMod` IS hashed and is a three-line dispatcher: it names the two matchers +and contains neither. + +**Measured, not argued.** `test/policy-matcher-stamp-coverage.test.js` asserts the +absence directly against the hashed inputs, with controls asserting the hashed +bodies are present so the block cannot pass vacuously. On the build this entry was +written against, the stamp is `matcher/1 95291ff6385151ca`. + +**What it costs.** Add a directory to the protected list, change how a path is +folded before it is matched, or widen the brace expander, and the gate stops a +different set of actions while the stamp stays byte-identical. Two receipts either +side of that change agree on the matcher version and disagree on the behavior. +**The failure runs the wrong way on purpose-built silence:** a matcher WEAKENED +between two runs keeps stamping the old, stronger version, so the record's own +account of why an action was allowed is wrong in the permissive direction. This is +a witness defect rather than an enforcement one, which is what makes it worth its +own entry: the gate still gates correctly, and the trace misdescribes it. + +A shipped change demonstrates it. The stamp was introduced 2026-08-09 (commit +b1b7bf8, "Observer versioning: matcher hash and canonical params digest"). The +protected-path list gained an entry on 2026-08-23, two weeks later, which changed +what an unsigned Edit could touch. That list is not inside the hashed text, so a +receipt written before that date and one written after carry the same matcher +version and cannot be told apart by it. + +**The repair, and why it is not done here.** Add the helpers to `parts` and bump +`MATCHER_SCHEMA` to `matcher/2` (the hashing METHOD changes, which is precisely +what that marker exists to record; the value changing on its own would otherwise +be indistinguishable from a rule edit). Historical receipts keep `matcher/1` and +stay honest about what they meant. That edit is `src/policy` and therefore +non-delegable core, so it queues for a signing sitting rather than riding along +with the disclosure. + +**Residual after the repair, stated now.** A hash over function source is still a +hash over THIS module. Behavior that reaches the decision from outside it, such as +`src/policy/git-context.js` resolving a push target, would remain unstamped. The +honest ceiling is "the rules in this file", and the docstring should say that +instead of "the matcher logic", which is what invited the gap in the first place. + +**Related.** Limit 62 is the same file being wrong about paths; this is the record +being wrong about limit 62. A stamp that does not move when 62 is fixed is how a +reader would fail to notice the fix landed. diff --git a/test/policy-matcher-stamp-coverage.test.js b/test/policy-matcher-stamp-coverage.test.js new file mode 100644 index 0000000..d9ebfd7 --- /dev/null +++ b/test/policy-matcher-stamp-coverage.test.js @@ -0,0 +1,131 @@ +/** + * test/policy-matcher-stamp-coverage.test.js + * + * TRIPWIRE, not a regression test. Found 2026-09-02 (KNOWN-LIMITS 63). + * + * `matcherVersionHash()` documents itself as the "content hash of the matcher + * logic in force right now", and every gate, warn, grant and egress receipt + * carries it. It is the field a reader uses to answer "were these two + * decisions made by the same rules?". + * + * It hashes the source text of THIRTEEN TOP-LEVEL FUNCTIONS plus RULE_TABLE + * and RULE_INFO (src/policy/index.js, the `parts` array). `Function.toString()` + * returns only that function's own source, so a helper is included only if it + * is named in the array itself. The self-mod deciders are not: + * `selfModFragmentsForBase` (the protected-path list), `isSelfModEdit`, + * `selfModCommandHit`, `normalizePath`, `pathContainsFragment`, + * `expandBraces`, `stripHeredocBodies`. `isSelfMod` IS hashed, but its body + * is a three-line dispatcher that only NAMES the two matchers it calls. + * + * THE CONSEQUENCE. Adding a directory to the protected list, or changing how + * a path is normalized before it is matched, changes what the gate stops and + * leaves the stamp byte-identical. Two receipts written either side of that + * change agree on the matcher version and disagree on the behavior, and the + * record cannot tell you which one you got. The failure is silent in the + * direction that matters: a matcher WEAKENED between two runs still stamps + * the old, stronger version. + * + * WHAT THESE ASSERTIONS SAY. They assert the CURRENT, DEFECTIVE behavior: the + * decider text is ABSENT from the hashed inputs today. The controls beside + * them assert that the hashed functions' own bodies ARE present, so this block + * cannot pass by the export surface dying or a rename emptying the strings. + * + * WHEN SOMEONE FIXES THE STAMP, THIS FILE FAILS. That is the point. The repair + * is to add the helpers to `parts`, bump MATCHER_SCHEMA (the hashing METHOD + * changes, which is exactly what that marker is for), invert the assertions + * below, and amend KNOWN-LIMITS 63 in the same change. Do NOT delete the + * block: a confession that can be closed by deleting its evidence is not a + * confession. + * + * The fix is `src/policy` and therefore non-delegable core. It is not + * attempted here; it queues for a signing sitting. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + isSelfMod, + isModeChange, + isPushForce, + isPushProtected, + isPublish, + isEgressOther, + isDestructive, + isScopeEscalation, + matcherVersionHash, + MATCHER_SCHEMA +} from '../src/policy/index.js'; + +/** + * The hashed inputs this test can reach. + * + * `parts` in matcherVersionHash() also names five values this module does not + * export (isScopeEscalationEdit, isPersistenceArtifactPath, isOpaqueExec, + * extensionlessLocalFileKind, EXPLICIT_LOCAL_PATH) plus the two rule tables. + * None of them is a self-mod decider, so the subset below is sufficient to + * prove ABSENCE: a string missing from the whole hashed text is missing from + * this subset too, and a string found here would be present in the full text. + * The asymmetry runs the safe way for what is being asserted. + */ +const hashedText = [ + isSelfMod, isModeChange, isPushForce, isPushProtected, + isPublish, isEgressOther, isDestructive, isScopeEscalation +].map(fn => fn.toString()).join(' '); + +describe('matcher version stamp coverage (KNOWN-LIMITS 63)', () => { + it('CONTROL: the hashed functions own bodies are in the hashed text', () => { + // If a rename or a bad import empties `hashedText`, these fail first and + // the absence assertions below cannot pass vacuously. + assert.ok(hashedText.length > 1000, 'hashed text is implausibly short'); + assert.ok(hashedText.includes('isSelfModCommand'), 'isSelfMod body missing'); + assert.ok(hashedText.includes('git\\s+push'), 'push matcher body missing'); + assert.ok(hashedText.includes('usesAuthedRemoteClient'), 'egress body missing'); + }); + + it('CONTROL: the stamp is a stable 16-hex digest under the declared schema', () => { + assert.equal(MATCHER_SCHEMA, 'matcher/1'); + const a = matcherVersionHash(); + const b = matcherVersionHash(); + assert.match(a, /^[0-9a-f]{16}$/); + assert.equal(a, b, 'the stamp must be stable within a process'); + }); + + // --- the defect: the deciders are outside the hash (invert when fixed) --- + + it('the protected-path list is NOT in the hashed text (invert when fixed)', () => { + // Three live entries of selfModFragmentsForBase(). Adding or removing any + // of them changes what an Edit is allowed to touch. + assert.equal(hashedText.includes('src/chain/'), false); + assert.equal(hashedText.includes('src/limits/'), false); + assert.equal(hashedText.includes('src/charter/'), false); + }); + + it('the fragment builder is NOT in the hashed text (invert when fixed)', () => { + assert.equal(hashedText.includes('selfModFragmentsForBase'), false); + }); + + it('the Edit-path matcher body is NOT in the hashed text (invert when fixed)', () => { + // isSelfMod NAMES isSelfModEdit at its call site, which is why the name + // appears; the body that decides is the bin regex, and that is absent. + assert.equal(hashedText.includes('bin\\/[^/]+\\.(js'), false); + }); + + it('the command-path matcher body is NOT in the hashed text (invert when fixed)', () => { + assert.equal(hashedText.includes('refsLotorHome'), false); + assert.equal(hashedText.includes('approval-nonces'), false); + }); + + it('the path normalizer is NOT in the hashed text (invert when fixed)', () => { + // How a path is folded before matching decides whether a spelling gates + // at all (KNOWN-LIMITS 62). Changing it leaves the stamp identical. + assert.equal(hashedText.includes('function normalizePath'), false); + assert.equal(hashedText.includes('function pathContainsFragment'), false); + }); + + it('the brace expander and prose stripper are NOT in the hashed text (invert when fixed)', () => { + assert.equal(hashedText.includes('function expandBraces'), false); + assert.equal(hashedText.includes('function stripHeredocBodies'), false); + assert.equal(hashedText.includes('function stripMessageArgs'), false); + }); +}); From e60aa1e95008c2be8941b8c7b3281ae64c467365 Mon Sep 17 00:00:00 2001 From: githubscum <65476409+githubscum@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:43:28 -0500 Subject: [PATCH 2/2] limit 64: the whole-tree fingerprint is wired to the reader, not the record Following limit 63's stated residual ("behavior that reaches the decision from outside the policy module would remain unstamped") to where it leads, and finding that the instrument which closes it is already built. Two code identities exist. matcherVersionHash() covers named functions in src/policy/index.js and is on every receipt. computeSourceDigest() covers all 50 .js files under src/ and bin/ - including src/gate, src/grant, src/chain, src/store and bin/hook-pre-tool-use.js - and is attached only to MCP tool responses, which are discarded when the call returns. So the ephemeral artifact carries the strong identity and the permanent one carries the weak one. Change the gate or the hooks and every receipt after the change is byte-comparable with every receipt before it. Measured: build digest 47ed7876d2652e68 over 50 files / 522,651 bytes; matcher stamp matcher/1 95291ff6385151ca; exactly two consumers of the digest in the tree, neither of which writes to the chain. Ships KNOWN-LIMITS 64 plus a 4-assertion test. Every absence claim is paired with a control (the same file IS asserted to carry matcherHash, and the MCP server IS asserted to carry the build stamp) so the block cannot pass vacuously. Suite 966/966, up from 962. The repair touches src/gate and bin/hook-* and therefore queues for a signing sitting rather than riding along with this disclosure. Co-Authored-By: Claude Opus 5 (1M context) --- KNOWN-LIMITS.md | 66 ++++++++++++++++ test/stamp-reach-coverage.test.js | 125 ++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/stamp-reach-coverage.test.js diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index ffe26a7..3f0324e 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -2133,3 +2133,69 @@ instead of "the matcher logic", which is what invited the gap in the first place **Related.** Limit 62 is the same file being wrong about paths; this is the record being wrong about limit 62. A stamp that does not move when 62 is fixed is how a reader would fail to notice the fix landed. + +## 64. The whole-tree fingerprint exists, and it is wired to the reader instead of the record + +Found 2026-09-02, following limit 63's own stated residual to the place it leads, +and finding the fix already built and pointed the wrong way. + +This repository computes **two** code identities, and they cover different things. + +| Stamp | Covers | Reaches | +|---|---|---| +| `matcherVersionHash()` | named functions in `src/policy/index.js` (and per limit 63, not all of them) | **every receipt**: `gated-action`, `policy-warn`, grant, egress, session-start | +| `computeSourceDigest()` | **every `.js` file under `src/` and `bin/`** | MCP tool responses only, as `_lotorBuild` | + +**Measured, not argued.** `test/stamp-reach-coverage.test.js` asserts all of it +against the tree. On the build this entry was written against: the build digest is +`47ed7876d2652e68`, over **50 files / 522,651 bytes**; the matcher stamp is +`matcher/1 95291ff6385151ca`. The digest's file set contains `src/gate/index.js`, +`src/grant/check.js`, `src/chain/index.js`, `src/store/index.js` and +`bin/hook-pre-tool-use.js` — every module that decides whether an action is +allowed. The matcher stamp contains none of them. And the digest has exactly two +consumers in the whole tree, `src/mcp/build-identity.js` and `src/mcp/server.js`, +neither of which writes to the chain. + +**What it costs.** Change the gate, the grant checker, the chain writer, the store, +or the pre-tool-use hook, and every receipt written after the change is +byte-comparable with every receipt written before it. `matcherHash` is unmoved, +because none of that code is in the policy module. A reader asking the question +receipts exist to answer — *were these two decided by the same code?* — is told yes, +and the honest answer is unknown. The MCP reader is told the truth in the same +minute, on a response that is discarded when the call returns. + +**Why this is its own entry rather than limit 63's residual.** Limit 63 names the +gap ("behavior that reaches the decision from outside it would remain unstamped") +and treats it as an accepted ceiling. It is not a ceiling. **The instrument that +closes it is already in this repository, already tested, already computing the +right value on every MCP call.** The defect is not a missing capability, it is a +wire going to the wrong consumer, and that is a different and much cheaper thing to +fix. Limit 63's repair (widening `parts`) does not touch this and should not be +read as covering it. + +**The asymmetry is the sharpest part.** The ephemeral artifact carries the strong +identity. The permanent artifact carries the weak one. That is exactly backwards +for a project whose thesis is that the record outlives the reader, and it is the +same shape as limit 41's original incident: the chain was intact and signed the +whole time, and what could not be trusted was the account of which code produced +the answer. + +**The repair, and why it is not done here.** Carry the source digest (short form +plus full, per limit 50) onto `session-open` at minimum, where it costs one field +per session rather than one per action, and let per-action receipts inherit it by +session id. That edit touches `src/gate` and `bin/hook-*` and is therefore +non-delegable core, so it queues for a signing sitting rather than riding along +with this disclosure. + +**Residual after that repair, stated now.** A digest over `src/` and `bin/` still +misses `node_modules` (a dependency upgrade moves nothing), anything loaded by +absolute path from outside the repository, and every non-`.js` input: policy files, +settings, the chain itself. All 50 source files are `.js` today, so the extension +filter has no live hole; a future `.mjs` or `.cjs` under either directory would be +unstamped and nothing would say so. And a digest detects without explaining, which +is limit 53 again. + +**Related.** Limit 41 shipped this digest to stop a stale reader misreporting a fix +as a defect; it did that, and stopped at the reader. Limit 53 is why the digest +cannot say what changed. Limit 63 is the narrow stamp being narrower than it claims; +this is the wide one not being anywhere it matters. diff --git a/test/stamp-reach-coverage.test.js b/test/stamp-reach-coverage.test.js new file mode 100644 index 0000000..b05487d --- /dev/null +++ b/test/stamp-reach-coverage.test.js @@ -0,0 +1,125 @@ +/** + * test/stamp-reach-coverage.test.js + * + * KNOWN-LIMITS 64. Two version stamps exist in this repository and they cover + * different things: + * + * matcherHash hashes named functions in src/policy/index.js. It is the + * field every gated-action, policy-warn, grant and egress + * receipt carries, so it is the ONLY code identity that + * reaches the permanent record. + * + * sourceDigest hashes every .js file under src/ and bin/, which includes + * src/gate, src/grant, src/chain, src/store and the hooks. + * It is attached to MCP tool responses and nowhere else. + * + * The broad fingerprint exists, is tested, and is wired to the ephemeral + * consumer. The narrow one is wired to the durable one. A change to the code + * that actually decides whether an action is allowed moves nothing a future + * reader of the chain can see. + * + * These assertions are written so the absence claims cannot pass vacuously: + * every "X does not reference the build digest" is paired with a control + * asserting that something else does, and that X references the matcher hash. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { computeSourceDigest } from '../src/mcp/build-identity.js'; +import { matcherVersionHash, MATCHER_SCHEMA } from '../src/policy/index.js'; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8'); + +/** Independently enumerate the .js files under the source dirs. */ +function jsUnder(dir, acc = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return acc; + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name === '.git') continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) jsUnder(full, acc); + else if (e.isFile() && e.name.endsWith('.js')) { + acc.push(path.relative(ROOT, full).split(path.sep).join('/')); + } + } + return acc; +} + +/** + * The modules that decide whether an action is allowed. None of them is + * src/policy/index.js, which is the point: the matcher hash covers that one + * file and these are the rest of the decision. + */ +const DECIDERS = [ + 'src/gate/index.js', + 'src/grant/check.js', + 'src/chain/index.js', + 'src/store/index.js', + 'bin/hook-pre-tool-use.js' +]; + +test('the build digest covers every decider, including the ones outside the policy module', () => { + const covered = new Set([ + ...jsUnder(path.join(ROOT, 'src')), + ...jsUnder(path.join(ROOT, 'bin')) + ]); + + for (const rel of DECIDERS) { + assert.ok(covered.has(rel), `${rel} should be inside the build digest's file set`); + } + // Control: the file set is the one computeSourceDigest actually hashed. + const d = computeSourceDigest(ROOT); + assert.equal(d.fileCount, covered.size, 'fileCount should equal the independently enumerated set'); + assert.equal(typeof d.digest, 'string'); +}); + +test('the matcher hash is the only code identity that reaches a receipt', () => { + // Every module that writes a receipt names matcherVersionHash... + for (const rel of ['src/gate/index.js', 'bin/hook-pre-tool-use.js', 'bin/hook-post-tool-use.js']) { + const src = read(rel); + assert.match(src, /matcherVersionHash/, `${rel} should stamp receipts with the matcher hash (control)`); + // ...and none of them names the whole-tree digest. + assert.doesNotMatch( + src, + /computeSourceDigest|captureBuildIdentity|sourceDigest/, + `${rel} does not carry the build digest onto the record — this is limit 64` + ); + } +}); + +test('the whole-tree digest is consumed only by the MCP response path', () => { + // Control: the consumer that does exist. + assert.match( + read('src/mcp/server.js'), + /captureBuildIdentity|_lotorBuild/, + 'the MCP server should attach the build stamp (control)' + ); + // And it is the only non-test consumer in the tree. + const all = [...jsUnder(path.join(ROOT, 'src')), ...jsUnder(path.join(ROOT, 'bin'))]; + const consumers = all.filter((rel) => + /computeSourceDigest|captureBuildIdentity/.test(read(rel)) + ); + assert.deepEqual( + consumers.sort(), + ['src/mcp/build-identity.js', 'src/mcp/server.js'], + 'the build digest should have exactly one consumer outside its own module' + ); +}); + +test('the two stamps are different values over different inputs', () => { + const build = computeSourceDigest(ROOT).digest; + const matcher = matcherVersionHash(); + assert.notEqual(build, matcher); + assert.equal(MATCHER_SCHEMA, 'matcher/1'); + // Stable across calls, so a receipt comparison is meaningful at all. + assert.equal(matcherVersionHash(), matcher); +});