diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index bf3f1da..3f0324e 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -2074,3 +2074,128 @@ 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. + +## 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/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); + }); +}); 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); +});