Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions KNOWN-LIMITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
131 changes: 131 additions & 0 deletions test/policy-matcher-stamp-coverage.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
125 changes: 125 additions & 0 deletions test/stamp-reach-coverage.test.js
Original file line number Diff line number Diff line change
@@ -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);
});