From 3734f6cb3aff468a2b0aab9b3797795a41025cae Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 15 Sep 2026 01:42:13 -0400 Subject: [PATCH] feat: add passive TCAS shadow receipts --- docs/design/agent-proximity.md | 80 +++- scripts/lib/agent-proximity/shadow-trail.js | 366 ++++++++++++++++++ .../lib/agent-proximity-shadow-trail.test.js | 233 +++++++++++ 3 files changed, 659 insertions(+), 20 deletions(-) create mode 100644 scripts/lib/agent-proximity/shadow-trail.js create mode 100644 tests/lib/agent-proximity-shadow-trail.test.js diff --git a/docs/design/agent-proximity.md b/docs/design/agent-proximity.md index c9d3e2b96..4542936fc 100644 --- a/docs/design/agent-proximity.md +++ b/docs/design/agent-proximity.md @@ -1,8 +1,9 @@ -# Agent-space distance metric & collision avoidance (Layer 4) +# Agent-space proximity and advisory prototypes (Layer 4) -> Status: v0 implemented in `scripts/lib/agent-proximity/`. This is the moat -> layer of ECC 2.0 — *spatial deconfliction for multiple agents (and humans) -> working the same codebase*, modeled on aircraft collision avoidance (TCAS). +> Status: v0 proximity scoring exists in `scripts/lib/agent-proximity/`. +> A separate v1 shadow trail records bounded advisories and declared responses. +> Current scores and drawings have no calibrated conflict probability or live +> conflict-reduction result. ## The analogy @@ -28,16 +29,16 @@ also declare an **intent set** *I_a* of files it is about to touch (look-ahead). ## 2. Collision is multi-channel (noisy-OR) -Two agents can collide through several independent channels. Each channel *i* -yields a collision probability *r_i ∈ [0,1]*; we combine them as the probability -of colliding through **at least one** channel: +Several channels contribute bounded heuristic scores *r_i ∈ [0,1]*. The shipped +noisy-OR-shaped combination is: ``` R(a,b) = 1 − Π_i ( 1 − ω_i · r_i ) (2) ``` -with channel weights *ω_i ∈ [0,1]*. The reported **distance** is the dual -*D(a,b) = 1 − R(a,b)*. +with channel weights *ω_i ∈ [0,1]*. The reported **distance score** is the dual +*D(a,b) = 1 − R(a,b)*. Correlated channels and unvalidated weights mean *R* is +not a calibrated probability, and *D* need not satisfy metric axioms. ### Channel 1 — edit overlap *r_overlap* @@ -119,10 +120,9 @@ rate near *τ_TA* can pre-emptively escalate before the protected zone is entere Each file gets a coordinate via a **space-filling embedding of its path** (files sharing a long directory prefix share most of their coordinate), then pulled toward its dependency neighbours by one averaging step. An agent sits at the -recency-weighted centroid of its files' coordinates. The result: `‖v_a − v_b‖` -tracks the collision risk *R*, so a **3D "where are the agents" view** renders -agents as moving points in a file-cloud — you literally watch them crawl toward -each other, see the advisory line light up, and watch one steer away. +recency-weighted centroid of its files' coordinates. The resulting **3D display** +illustrates path and dependency proximity. Its Euclidean separation has not +been shown to preserve the risk score or predict collisions. `scanAirspace(agents, graph)` returns, in one pass: the non-clear `advisories` (what the trigger layer acts on), the 3D `positions` and `fileCoordinates` (what @@ -133,18 +133,58 @@ the renderer draws), and pairwise `links` with risk (the edges to color). - **Inputs** come from the session/work state: each running session's worktree diff gives its working set *W_a*; the dependency graph is built from the repo (`buildDependencyGraph`). -- **Triggers**: the control-pane tick calls `scanAirspace`; a Traffic Advisory - injects a "transmit intent" message between the two agents' sessions; a - Resolution Advisory tells the lower-priority agent to steer (re-target to a - different file/subtree) — the first concrete realization of *just-in-time - multi-agent (and multi-human) deconfliction*. +- **Triggers**: control-pane prototype code can derive messages from + `scanAirspace`. Delivery, acknowledgement, scope authorization and actual + conflict resolution are separate evidence. The shadow trail below calls no + sink, hook or steer path. - **Board**: advisories surface on the kanban as proximity warnings, extending the agent/human JIT assignment layer already in the control pane. +## Passive shadow receipt trail (v1) + +`scripts/lib/agent-proximity/shadow-trail.js` accepts two supplied, versioned +observations with task/repository/base/scope identity, read/write path sets, +completeness and expiry. It computes a local advisory using the shipped +three-channel scorer. An exact write/write or write/read path overlap takes +precedence over a low score. Incomplete, expired, future or incomparable critical +observations produce `unknown`; an absent advisory means only that no advisory +was found **within the complete, comparable supplied scope and supplied dependency +graph**. The optional graph defaults to empty and may be supplied anew for +re-observation; a quiet advisory says nothing about unsupplied dependencies. + +Call `createShadowTrail`, then `appendAcknowledgement` for both observed agents, +`appendScopeRevision` for one externally declared revision and its new footprint, +and `appendReobservation` with a fresh matching pair. `summarizeShadowTrail` +reports the latest scoped advisory, its own advisory-bound ACKs, separate +historical acknowledgers and `outcome: unadjudicated`. Both original observations +must still be current when the scope-change declaration is recorded. +`verifyShadowTrail` checks the bounded local receipt chain after JSON persistence. +Each receipt hash binds the trail ID, event ID, time, predecessor hash and +canonical payload. The verifier rejects noncanonical persisted payload field +and path order before comparing hashes. Append results copy earlier receipts, +so changing an earlier returned object cannot change a later returned trail. +Returned data can still be mutated; verification detects inconsistent local +content. A party able to +rewrite and rehash the entire trail can forge a consistent chain. These hashes +do not establish trusted identity, permission, delivery, whole-log immutability +or an independently verified result. + +The module issues no grants and sends no messages. The scope-change receipt's +`authorizationRef` is a caller declaration and is labelled `declared-only`; +the actual authority owner must validate it before any real work changes. It +records path metadata locally, so a caller must apply its own publication policy. +It accepts no memory summary or required-state payload. Approximate summaries +belong to the separately owned memory envelope; exact task constraints and +authority remain outside this heuristic score. The trail is limited to one +two-agent change, so it cannot grant coherent ownership across a three-agent +conflict component. + ## Roadmap -- v0 (done): tree + overlap + dependency channels, noisy-OR risk, TCAS advisories, - priority/steer, 3D embedding, full test coverage. +- v0 (source present): tree + overlap + dependency channels, noisy-OR-shaped + risk score, advisory/priority prototype and display embedding. +- v1 shadow (source present): passive two-agent observation/advisory/ACK/scope + revision/re-observation receipts; no active steering or measured benefit. - v1: call-graph & symbol read/write channels; intent look-ahead; closure-rate escalation wired to live session diffs. - v2: cross-machine airspace over Tailscale (teammate agents enter the same diff --git a/scripts/lib/agent-proximity/shadow-trail.js b/scripts/lib/agent-proximity/shadow-trail.js new file mode 100644 index 000000000..69b471aed --- /dev/null +++ b/scripts/lib/agent-proximity/shadow-trail.js @@ -0,0 +1,366 @@ +'use strict'; + +// Passive, local event trail. Receipt hashes detect accidental changes to these +// bytes; they do not authenticate a producer, authorize a scope or prove delivery. +const crypto = require('node:crypto'); +const { collisionRisk, DEFAULTS } = require('./distance'); + +const VERSION = 1; +const ZERO_HASH = '0'.repeat(64); +const MAX_RECEIPTS = 16; +function invalid() { throw new Error('Invalid shadow coordination input.'); } +function object(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) || + ![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid(); + const keys = Reflect.ownKeys(value); + if (keys.length > 64) invalid(); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (typeof key !== 'string' || !Object.hasOwn(value, key) || + !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid(); + } + return value; +} +function shape(value, allowed, required = allowed) { + const row = object(value); + if (Reflect.ownKeys(row).some(key => !allowed.includes(key)) || + required.some(key => !Object.hasOwn(row, key))) invalid(); + return row; +} +function array(value, max) { + if (!Array.isArray(value) || value.length > max || + Object.getPrototypeOf(value) !== Array.prototype) invalid(); + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1 || !keys.includes('length')) invalid(); + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid(); + } + return value; +} +function identifier(value) { + if (typeof value !== 'string' || value.length > 120 || + !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) || + ['__proto__', 'constructor', 'prototype'].includes(value)) invalid(); + return value; +} +function timestamp(value) { + if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) || + !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) invalid(); + return value; +} +function path(value) { + if (typeof value !== 'string' || !value || value.length > 256 || + value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.includes('\\') || + value.split('/').some(part => !part || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(part)) || + [...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) invalid(); + return value; +} +function paths(value, max = 32) { + array(value, max); + const result = value.map(path); + if (new Set(result).size !== result.length) invalid(); + return result.sort(); +} +function observation(value) { + const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision', 'scopeRevision', + 'footprint', 'readPaths', 'writePaths', 'observedAt', 'expiresAt']); + if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) || + !Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1 || + !['complete', 'incomplete'].includes(row.footprint)) invalid(); + const observedAt = timestamp(row.observedAt); + const expiresAt = timestamp(row.expiresAt); + if (Date.parse(expiresAt) <= Date.parse(observedAt)) invalid(); + return { agentId: identifier(row.agentId), taskId: identifier(row.taskId), + repoId: identifier(row.repoId), baseRevision: row.baseRevision, + scopeRevision: row.scopeRevision, footprint: row.footprint, + readPaths: paths(row.readPaths), writePaths: paths(row.writePaths), observedAt, expiresAt }; +} +function pair(value) { + array(value, 2); + if (value.length !== 2) invalid(); + const result = value.map(observation).sort((a, b) => a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0); + if (result[0].agentId === result[1].agentId) invalid(); + return result; +} +function graph(value = { adjacency: {} }) { + const row = shape(value, ['adjacency']); + const adjacency = object(row.adjacency); + const entries = Object.entries(adjacency).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + if (entries.length > 64) invalid(); + const normalized = Object.create(null); + let edgeCount = 0; + for (const [name, edges] of entries) { + edgeCount += Array.isArray(edges) ? edges.length : 257; + if (edgeCount > 256) invalid(); + normalized[path(name)] = paths(edges, 16); + } + return { adjacency: normalized }; +} +function sameTask(a, b) { + return a.agentId === b.agentId && a.taskId === b.taskId && + a.repoId === b.repoId && a.baseRevision === b.baseRevision; +} +function intersection(a, b) { return a.some(item => b.includes(item)); } +function samePaths(a, b) { return a.length === b.length && a.every((item, index) => item === b[index]); } +function directHazard(a, b) { + if (intersection(a.writePaths, b.writePaths)) return 'write-write'; + if (intersection(a.writePaths, b.readPaths) || intersection(b.writePaths, a.readPaths)) return 'write-read'; + return null; +} +function advisory(observations, dependencyGraph, at) { + const [a, b] = observations; + const hazard = directHazard(a, b); + const aAll = [...new Set([...a.readPaths, ...a.writePaths])].sort(); + const bAll = [...new Set([...b.readPaths, ...b.writePaths])].sort(); + const first = collisionRisk({ files: a.writePaths.map(name => ({ path: name })) }, + { files: bAll.map(name => ({ path: name })) }, dependencyGraph).risk; + const second = collisionRisk({ files: b.writePaths.map(name => ({ path: name })) }, + { files: aAll.map(name => ({ path: name })) }, dependencyGraph).risk; + const risk = Math.max(first, second); + let reason = null; + if (a.repoId !== b.repoId || a.baseRevision !== b.baseRevision) reason = 'incomparable-context'; + else if (observations.some(item => Date.parse(item.observedAt) > Date.parse(at) || + Date.parse(item.expiresAt) <= Date.parse(at))) reason = 'stale-or-future-observation'; + else if (observations.some(item => item.footprint === 'incomplete')) reason = 'incomplete-footprint'; + const state = reason ? 'unknown' : hazard || risk >= DEFAULTS.thresholds.ta + ? 'advisory' : 'no-advisory-in-scope'; + return { state, reason, hazard: reason === 'incomparable-context' ? null : hazard, + heuristicRisk: risk, scoreKind: 'uncalibrated-three-channel-heuristic', + authority: 'observation-only', outcome: 'unadjudicated' }; +} +function encode(value) { + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + const items = []; + for (let index = 0; index < value.length; index += 1) items.push(encode(value[index])); + return `[${items.join(',')}]`; + } + if (!value || typeof value !== 'object') invalid(); + const fields = []; + for (const key of Object.keys(value)) fields.push(`${JSON.stringify(key)}:${encode(value[key])}`); + return `{${fields.join(',')}}`; +} +function digest(trailId, receipt, payload = receipt.payload) { + const encoded = encode([VERSION, trailId, receipt.id, receipt.kind, receipt.at, + receipt.previousHash, payload]); + return crypto.createHash('sha256').update('ecc.tcas.shadow.v1\n').update(encoded).digest('hex'); +} +function newReceipt(trail, id, kind, at, payload) { + if (trail.receipts.length >= MAX_RECEIPTS) invalid(); + const previousHash = trail.receipts.at(-1)?.hash || ZERO_HASH; + const row = { version: VERSION, id: identifier(id), kind, at: timestamp(at), previousHash, payload }; + return { ...row, hash: digest(trail.trailId, row) }; +} +function extend(trail, id, kind, at, payload) { + verifyShadowTrail(trail); + const copied = copyTrail(trail); + const next = { ...copied, receipts: [...copied.receipts, newReceipt(copied, id, kind, at, payload)] }; + verifyShadowTrail(next); + return next; +} +function normalizePayload(kind, value) { + if (kind === 'observation' || kind === 'reobservation') { + const row = shape(value, ['observations', 'graph']); + return { observations: pair(row.observations), graph: graph(row.graph) }; + } + if (kind === 'advisory') { + const row = shape(value, ['state', 'reason', 'hazard', 'heuristicRisk', 'scoreKind', 'authority', 'outcome']); + if (!['unknown', 'advisory', 'no-advisory-in-scope'].includes(row.state) || + ![null, 'incomparable-context', 'stale-or-future-observation', + 'incomplete-footprint'].includes(row.reason) || + ![null, 'write-write', 'write-read'].includes(row.hazard) || + !Number.isFinite(row.heuristicRisk) || row.heuristicRisk < 0 || row.heuristicRisk > 1 || + row.scoreKind !== 'uncalibrated-three-channel-heuristic' || + row.authority !== 'observation-only' || row.outcome !== 'unadjudicated') invalid(); + return { state: row.state, reason: row.reason, hazard: row.hazard, + heuristicRisk: row.heuristicRisk, scoreKind: row.scoreKind, + authority: row.authority, outcome: row.outcome }; + } + if (kind === 'acknowledgement') { + const row = shape(value, ['agentId', 'scopeRevision', 'advisoryId']); + if (!Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1) invalid(); + return { agentId: identifier(row.agentId), scopeRevision: row.scopeRevision, + advisoryId: identifier(row.advisoryId) }; + } + if (kind === 'scope-revision') { + const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision', + 'previousScopeRevision', 'newScopeRevision', 'newReadPaths', 'newWritePaths', + 'authorizationRef', 'authority']); + if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) || + !Number.isSafeInteger(row.previousScopeRevision) || row.previousScopeRevision < 1 || + !Number.isSafeInteger(row.newScopeRevision) || row.newScopeRevision <= row.previousScopeRevision || + row.authority !== 'declared-only') invalid(); + return { agentId: identifier(row.agentId), taskId: identifier(row.taskId), + repoId: identifier(row.repoId), baseRevision: row.baseRevision, + previousScopeRevision: row.previousScopeRevision, newScopeRevision: row.newScopeRevision, + newReadPaths: paths(row.newReadPaths), newWritePaths: paths(row.newWritePaths), + authorizationRef: identifier(row.authorizationRef), authority: row.authority }; + } + invalid(); +} +function copyTrail(trail) { + return { version: VERSION, trailId: trail.trailId, + receipts: trail.receipts.map(receipt => ({ version: receipt.version, + id: receipt.id, kind: receipt.kind, at: receipt.at, + previousHash: receipt.previousHash, payload: normalizePayload(receipt.kind, receipt.payload), + hash: receipt.hash })) }; +} +function transition(state, receipt) { + const payload = normalizePayload(receipt.kind, receipt.payload); + if (receipt.kind === 'observation') { + if (state.observations) invalid(); + return { ...state, observations: payload.observations, graph: payload.graph, awaitingAdvisory: true }; + } + if (receipt.kind === 'advisory') { + if (!state.awaitingAdvisory || !state.observations) invalid(); + const expected = advisory(state.observations, state.graph, receipt.at); + if (encode(payload) !== encode(expected)) invalid(); + return { ...state, latestAdvisory: expected, advisoryId: receipt.id, + awaitingAdvisory: false, acknowledgements: [] }; + } + if (state.awaitingAdvisory || !state.latestAdvisory) invalid(); + if (receipt.kind === 'acknowledgement') { + if (state.latestAdvisory.state !== 'advisory' || state.scopeChange || state.reobserved || + payload.advisoryId !== state.advisoryId || + state.acknowledgements.includes(payload.agentId)) invalid(); + const member = state.observations.find(item => item.agentId === payload.agentId); + if (!member || member.scopeRevision !== payload.scopeRevision || + Date.parse(member.expiresAt) <= Date.parse(receipt.at)) invalid(); + return { ...state, acknowledgements: [...state.acknowledgements, payload.agentId], + acknowledgedBy: [...state.acknowledgedBy, payload.agentId] }; + } + if (receipt.kind === 'scope-revision') { + if (state.latestAdvisory.state !== 'advisory' || state.scopeChange || + state.reobserved || state.acknowledgements.length !== 2) invalid(); + const member = state.observations.find(item => item.agentId === payload.agentId); + if (!member || !sameTask(member, payload) || + member.scopeRevision !== payload.previousScopeRevision || + state.observations.some(item => Date.parse(item.expiresAt) <= Date.parse(receipt.at) || + Date.parse(item.observedAt) > Date.parse(receipt.at))) invalid(); + return { ...state, scopeChange: { ...payload, at: receipt.at } }; + } + if (receipt.kind === 'reobservation') { + if (!state.scopeChange || state.reobserved) invalid(); + for (const member of payload.observations) { + const old = state.observations.find(item => item.agentId === member.agentId); + if (!old || !sameTask(old, member) || + member.scopeRevision !== (member.agentId === state.scopeChange.agentId + ? state.scopeChange.newScopeRevision : old.scopeRevision) || + !samePaths(member.readPaths, member.agentId === state.scopeChange.agentId + ? state.scopeChange.newReadPaths : old.readPaths) || + !samePaths(member.writePaths, member.agentId === state.scopeChange.agentId + ? state.scopeChange.newWritePaths : old.writePaths) || + Date.parse(member.observedAt) < Date.parse(state.scopeChange.at)) invalid(); + } + return { ...state, observations: payload.observations, graph: payload.graph, + reobserved: true, awaitingAdvisory: true }; + } + invalid(); +} +function inspect(trail) { + const row = shape(trail, ['version', 'trailId', 'receipts']); + if (row.version !== VERSION) invalid(); + array(row.receipts, MAX_RECEIPTS); + if (row.receipts.length < 2) invalid(); + identifier(row.trailId); + let previousHash = ZERO_HASH; + let previousAt = -Infinity; + let state = { observations: null, awaitingAdvisory: false, + acknowledgements: [], acknowledgedBy: [] }; + const ids = new Set(); + for (const receipt of row.receipts) { + shape(receipt, ['version', 'id', 'kind', 'at', 'previousHash', 'payload', 'hash']); + const id = identifier(receipt.id); + const at = timestamp(receipt.at); + const payload = normalizePayload(receipt.kind, receipt.payload); + if (encode(receipt.payload) !== encode(payload)) invalid(); + if (receipt.version !== VERSION || ids.has(id) || Date.parse(at) < previousAt || + typeof receipt.previousHash !== 'string' || typeof receipt.hash !== 'string' || + !/^[a-f0-9]{64}$/.test(receipt.previousHash) || + !/^[a-f0-9]{64}$/.test(receipt.hash) || + receipt.previousHash !== previousHash || receipt.hash !== digest(row.trailId, receipt, payload)) invalid(); + state = transition(state, { ...receipt, payload }); + ids.add(id); + previousAt = Date.parse(at); + previousHash = receipt.hash; + } + if (state.awaitingAdvisory) invalid(); + return state; +} +function guard(fn) { + try { return fn(); } catch { invalid(); } +} +function verifyShadowTrail(trail) { return guard(() => { inspect(trail); return true; }); } +function createShadowTrail(input) { + const row = shape(input, ['trailId', 'observations', 'at', 'graph'], + ['trailId', 'observations', 'at']); + const trailId = identifier(row.trailId); + const at = timestamp(row.at); + const observations = pair(row.observations); + const dependencyGraph = graph(row.graph); + const blank = { version: VERSION, trailId, receipts: [] }; + const first = newReceipt(blank, `${trailId}:observation-0`, 'observation', at, + { observations, graph: dependencyGraph }); + const partial = { ...blank, receipts: [first] }; + const second = newReceipt(partial, `${trailId}:advisory-0`, 'advisory', at, + advisory(observations, dependencyGraph, at)); + const result = { ...blank, receipts: [first, second] }; + verifyShadowTrail(result); + return result; +} +function appendAcknowledgement(trail, input) { + const row = shape(input, ['id', 'agentId', 'scopeRevision', 'at']); + const state = inspect(trail); + return extend(trail, row.id, 'acknowledgement', row.at, + { agentId: row.agentId, scopeRevision: row.scopeRevision, advisoryId: state.advisoryId }); +} +function appendScopeRevision(trail, input) { + const row = shape(input, ['id', 'agentId', 'previousScopeRevision', + 'newScopeRevision', 'newReadPaths', 'newWritePaths', 'authorizationRef', 'at']); + const state = inspect(trail); + const member = state.observations.find(item => item.agentId === row.agentId); + if (!member) invalid(); + return extend(trail, row.id, 'scope-revision', row.at, + { agentId: row.agentId, taskId: member.taskId, repoId: member.repoId, + baseRevision: member.baseRevision, previousScopeRevision: row.previousScopeRevision, + newScopeRevision: row.newScopeRevision, newReadPaths: paths(row.newReadPaths), + newWritePaths: paths(row.newWritePaths), authorizationRef: row.authorizationRef, + authority: 'declared-only' }); +} +function appendReobservation(trail, input) { + const row = shape(input, ['id', 'observations', 'at', 'graph'], ['id', 'observations', 'at']); + const state = inspect(trail); + const at = timestamp(row.at); + const observations = pair(row.observations); + const dependencyGraph = graph(row.graph === undefined ? state.graph : row.graph); + if (trail.receipts.length > MAX_RECEIPTS - 2) invalid(); + const copied = copyTrail(trail); + const first = newReceipt(copied, row.id, 'reobservation', at, { observations, graph: dependencyGraph }); + const partial = { ...copied, receipts: [...copied.receipts, first] }; + const second = newReceipt(partial, `${row.id}:advisory`, 'advisory', at, + advisory(observations, dependencyGraph, at)); + const result = { ...copied, receipts: [...partial.receipts, second] }; + verifyShadowTrail(result); + return result; +} +function summarizeShadowTrail(trail) { + const state = inspect(trail); + return { version: VERSION, mode: 'shadow-read-only', trailId: trail.trailId, + receiptCount: trail.receipts.length, latestAdvisory: { ...state.latestAdvisory }, + latestAdvisoryId: state.advisoryId, + latestAdvisoryAcknowledgedBy: [...state.acknowledgements].sort(), + historicalAcknowledgedBy: [...new Set(state.acknowledgedBy)].sort(), + scopeChange: state.scopeChange ? { ...state.scopeChange } : null, + outcome: 'unadjudicated' }; +} +module.exports = { + createShadowTrail: input => guard(() => createShadowTrail(input)), + appendAcknowledgement: (trail, input) => guard(() => appendAcknowledgement(trail, input)), + appendScopeRevision: (trail, input) => guard(() => appendScopeRevision(trail, input)), + appendReobservation: (trail, input) => guard(() => appendReobservation(trail, input)), + verifyShadowTrail, + summarizeShadowTrail: trail => guard(() => summarizeShadowTrail(trail)) +}; diff --git a/tests/lib/agent-proximity-shadow-trail.test.js b/tests/lib/agent-proximity-shadow-trail.test.js new file mode 100644 index 000000000..d428a107f --- /dev/null +++ b/tests/lib/agent-proximity-shadow-trail.test.js @@ -0,0 +1,233 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + createShadowTrail, appendAcknowledgement, appendScopeRevision, + appendReobservation, verifyShadowTrail, summarizeShadowTrail +} = require('../../scripts/lib/agent-proximity/shadow-trail'); + +const at = '2026-09-15T12:00:00.000Z'; +const later = '2026-09-15T12:01:00.000Z'; +const latest = '2026-09-15T12:02:00.000Z'; +const baseRevision = 'a'.repeat(40); + +function observation(agentId, overrides = {}) { + return { + agentId, taskId: `task-${agentId}`, repoId: 'ecc', baseRevision, + scopeRevision: 1, footprint: 'complete', readPaths: [], + writePaths: ['src/shared.js'], observedAt: '2026-09-15T11:59:00.000Z', + expiresAt: '2026-09-15T12:05:00.000Z', ...overrides + }; +} +function start(a = observation('a'), b = observation('b'), options = {}) { + return createShadowTrail({ trailId: 'shadow-1', observations: [a, b], at, ...options }); +} +function bothAcknowledge(trail) { + const first = appendAcknowledgement(trail, { id: 'ack-a', agentId: 'a', scopeRevision: 1, at: later }); + return appendAcknowledgement(first, { id: 'ack-b', agentId: 'b', scopeRevision: 1, at: later }); +} + +test('records a copy-on-append passive observation → advisory → ACK → scope revision → re-observation chain', () => { + const initial = start(); + assert.equal(summarizeShadowTrail(initial).latestAdvisory.state, 'advisory'); + assert.equal(summarizeShadowTrail(initial).latestAdvisory.hazard, 'write-write'); + const acknowledged = bothAcknowledge(initial); + assert.deepEqual(summarizeShadowTrail(acknowledged).latestAdvisoryAcknowledgedBy, ['a', 'b']); + const revised = appendScopeRevision(acknowledged, { + id: 'scope-b2', agentId: 'b', previousScopeRevision: 1, + newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'], + authorizationRef: 'declared-contract-42', at: later + }); + const complete = appendReobservation(revised, { + id: 'reobserve-1', at: latest, + observations: [observation('a', { writePaths: ['src/shared.js'], observedAt: later }), + observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })] + }); + const summary = summarizeShadowTrail(complete); + assert.equal(summary.mode, 'shadow-read-only'); + assert.equal(summary.latestAdvisory.state, 'no-advisory-in-scope'); + assert.equal(summary.outcome, 'unadjudicated'); + assert.equal(summary.scopeChange.authority, 'declared-only'); + assert.deepEqual(summary.historicalAcknowledgedBy, ['a', 'b']); + assert.deepEqual(summary.latestAdvisoryAcknowledgedBy, []); + assert.equal(verifyShadowTrail(JSON.parse(JSON.stringify(complete))), true); + assert.equal(initial.receipts.length, 2); + assert.equal(acknowledged.receipts.length, 4); + assert.equal(complete.receipts.length, 7); + for (let i = 1; i < complete.receipts.length; i += 1) { + assert.equal(complete.receipts[i].previousHash, complete.receipts[i - 1].hash); + } + assert.ok(!JSON.stringify(complete).includes('CANARY_SECRET')); +}); + +test('read/read is quiet, write/read is a direct hazard, and dependency proximity reuses shipped scorer', () => { + const quiet = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }), + observation('b', { readPaths: ['src/shared.js'], writePaths: [] })); + assert.equal(summarizeShadowTrail(quiet).latestAdvisory.state, 'no-advisory-in-scope'); + const hazard = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }), + observation('b', { readPaths: [], writePaths: ['src/shared.js'] })); + assert.equal(summarizeShadowTrail(hazard).latestAdvisory.hazard, 'write-read'); + const graph = { adjacency: { 'apps/web/page.js': ['packages/core/util.js'], 'packages/core/util.js': [] } }; + const coupled = start(observation('a', { writePaths: ['apps/web/page.js'] }), + observation('b', { writePaths: ['packages/core/util.js'] }), { graph }); + assert.equal(summarizeShadowTrail(coupled).latestAdvisory.state, 'advisory'); + assert.equal(summarizeShadowTrail(coupled).latestAdvisory.hazard, null); +}); + +test('known overlap cannot be clear, while incomplete, stale and incomparable critical data remain unknown', () => { + const partialHazard = start(observation('a', { footprint: 'incomplete' }), observation('b')); + assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.state, 'unknown'); + assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.hazard, 'write-write'); + const partialQuiet = start(observation('a', { footprint: 'incomplete', writePaths: [] }), + observation('b', { writePaths: ['docs/guide.md'] })); + assert.equal(summarizeShadowTrail(partialQuiet).latestAdvisory.state, 'unknown'); + const stale = start(observation('a', { expiresAt: '2026-09-15T11:59:30.000Z' }), observation('b')); + assert.equal(summarizeShadowTrail(stale).latestAdvisory.state, 'unknown'); + const foreignBase = start(observation('a'), observation('b', { baseRevision: 'b'.repeat(40) })); + assert.equal(summarizeShadowTrail(foreignBase).latestAdvisory.state, 'unknown'); +}); + +test('stale or crossed ACK, unacknowledged scope change and mismatched re-observation fail closed', () => { + const initial = start(); + assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'a', scopeRevision: 2, at: later }), /Invalid shadow/); + assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'c', scopeRevision: 1, at: later }), /Invalid shadow/); + assert.throws(() => appendScopeRevision(initial, { id: 'premature', agentId: 'b', previousScopeRevision: 1, + newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'], + authorizationRef: 'evidence-1', at: later }), /Invalid shadow/); + const ack = bothAcknowledge(initial); + assert.throws(() => appendAcknowledgement(ack, { id: 'third-ack', agentId: 'a', scopeRevision: 1, at: later }), /Invalid shadow/); + const revised = appendScopeRevision(ack, { id: 'scope-b2', agentId: 'b', previousScopeRevision: 1, + newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'], + authorizationRef: 'evidence-1', at: later }); + assert.throws(() => appendReobservation(revised, { id: 'wrong-scope', at: latest, + observations: [observation('a'), observation('b')] }), /Invalid shadow/); + assert.throws(() => appendReobservation(revised, { id: 'foreign-task', at: latest, + observations: [observation('a'), observation('b', { scopeRevision: 2, taskId: 'other-task' })] }), /Invalid shadow/); + assert.throws(() => appendReobservation(revised, { id: 'different-declaration', at: latest, + observations: [observation('a'), observation('b', { scopeRevision: 2, + writePaths: ['src/not-declared.js'], observedAt: later })] }), /Invalid shadow/); + assert.throws(() => appendReobservation(revised, { id: 'changed-other-agent', at: latest, + observations: [observation('a', { writePaths: ['src/changed-without-revision.js'], observedAt: later }), + observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })] }), /Invalid shadow/); +}); + +test('unknown critical footprints cannot be used as an acknowledged clear-scoped resolution', () => { + const unknown = start(observation('a', { footprint: 'incomplete', writePaths: [] }), observation('b')); + assert.throws(() => appendAcknowledgement(unknown, { id: 'ack-unknown', agentId: 'a', + scopeRevision: 1, at: later }), /Invalid shadow/); +}); + +test('receipt hashes bind the trail identity and reject cross-trail relabeling', () => { + const relabeled = JSON.parse(JSON.stringify(start())); + relabeled.trailId = 'different-shadow-trail'; + assert.throws(() => verifyShadowTrail(relabeled), /Invalid shadow/); +}); + +test('a persisted receipt with reordered path bytes is noncanonical and rejected', () => { + const trail = start(observation('a', { writePaths: ['src/a.js', 'src/b.js'] }), + observation('b', { writePaths: ['src/b.js'] })); + const reordered = JSON.parse(JSON.stringify(trail)); + reordered.receipts[0].payload.observations[0].writePaths.reverse(); + assert.throws(() => verifyShadowTrail(reordered), /Invalid shadow/); +}); + +test('dependency graph insertion order canonicalizes to the same receipt content', () => { + const a = start(observation('a'), observation('b'), { graph: { adjacency: { + 'src/z.js': ['src/a.js'], 'src/a.js': [] + } } }); + const b = start(observation('a'), observation('b'), { graph: { adjacency: { + 'src/a.js': [], 'src/z.js': ['src/a.js'] + } } }); + assert.equal(a.receipts[0].hash, b.receipts[0].hash); +}); + +test('append results keep independent copies of earlier receipt payloads', () => { + const initial = start(); + const appended = appendAcknowledgement(initial, { + id: 'copy-ack', agentId: 'a', scopeRevision: 1, at: later + }); + initial.receipts[0].payload.observations[0].writePaths[0] = 'src/changed.js'; + assert.throws(() => verifyShadowTrail(initial), /Invalid shadow/); + assert.equal(verifyShadowTrail(appended), true); +}); + +test('verification rejects nested serialization hooks, sparse and accessor arrays before execution', () => { + const original = start(); + let callbackRuns = 0; + const hooked = JSON.parse(JSON.stringify(original)); + hooked.receipts[0].payload.observations[0].writePaths.toJSON = () => { + callbackRuns += 1; + return ['src/shared.js']; + }; + assert.throws(() => verifyShadowTrail(hooked), /Invalid shadow/); + assert.equal(callbackRuns, 0); + + const accessor = JSON.parse(JSON.stringify(original)); + Object.defineProperty(accessor.receipts[0].payload.observations, '0', { + enumerable: true, get() { callbackRuns += 1; return observation('a'); } + }); + assert.throws(() => verifyShadowTrail(accessor), /Invalid shadow/); + assert.equal(callbackRuns, 0); + + const sparse = JSON.parse(JSON.stringify(original)); + delete sparse.receipts[0].payload.observations[0].writePaths[0]; + assert.throws(() => verifyShadowTrail(sparse), /Invalid shadow/); +}); + +test('hash fields reject coercible objects without invoking their conversion hooks', () => { + const trail = JSON.parse(JSON.stringify(start())); + let callbackRuns = 0; + trail.receipts[0].hash = { toString() { callbackRuns += 1; return '0'.repeat(64); } }; + assert.throws(() => verifyShadowTrail(trail), /Invalid shadow/); + assert.equal(callbackRuns, 0); +}); + +test('receipt verification never invokes inherited JSON serialization hooks', () => { + const persisted = JSON.parse(JSON.stringify(start())); + let callbackRuns = 0; + Object.defineProperty(Array.prototype, 'toJSON', { configurable: true, + value() { callbackRuns += 1; return []; } }); + try { + assert.equal(verifyShadowTrail(persisted), true); + assert.equal(callbackRuns, 0); + } finally { + delete Array.prototype.toJSON; + } +}); + +test('exotic verification failures are generic and do not echo private text', () => { + const trail = start(); + const receipts = new Proxy(trail.receipts, { + getPrototypeOf() { throw new Error('CANARY_SECRET'); } + }); + assert.throws(() => verifyShadowTrail({ ...trail, receipts }), error => + /Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET')); +}); + +test('scope revision requires both acknowledged observations to remain current', () => { + const initial = start(observation('a'), observation('b', { + expiresAt: '2026-09-15T12:01:30.000Z' + })); + const acknowledged = bothAcknowledge(initial); + assert.throws(() => appendScopeRevision(acknowledged, { + id: 'scope-after-peer-expiry', agentId: 'a', previousScopeRevision: 1, + newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'], + authorizationRef: 'declared-contract-42', at: latest + }), /Invalid shadow/); +}); + +test('tampering, duplicate IDs, unknown fields and unbounded input cannot be accepted or echoed', () => { + const initial = start(); + const tampered = JSON.parse(JSON.stringify(initial)); + tampered.receipts[0].payload.observations[0].writePaths = ['CANARY_SECRET']; + assert.throws(() => verifyShadowTrail(tampered), /Invalid shadow/); + const poisoned = observation('a', { summary: 'CANARY_SECRET' }); + assert.throws(() => start(poisoned, observation('b')), error => + /Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET')); + assert.throws(() => start(observation('a', { writePaths: ['../escape'] }), observation('b')), /Invalid shadow/); + assert.throws(() => start(observation('a', { writePaths: Array(33).fill('src/a.js') }), observation('b')), /Invalid shadow/); + assert.throws(() => start(observation('a'), observation('b'), { graph: { adjacency: { '__proto__': ['src/a.js'] } } }), /Invalid shadow/); + assert.throws(() => appendAcknowledgement(initial, { id: initial.receipts[0].id, agentId: 'a', + scopeRevision: 1, at: later }), /Invalid shadow/); +});