From 8729f2667a75daa4216f65cd95e633138ce99a38 Mon Sep 17 00:00:00 2001 From: repsecure Date: Wed, 5 Aug 2026 17:20:17 -0500 Subject: [PATCH] audit: never advance the chain past a record that was not written emit() advanced chainIndex and previousHash before the sinks ran and swallowed whatever they threw. A record that never reached disk still moved the chain on, so the next record carried an index jump and a broken link: the shape of a DELETED record. `agentwall verify` reported a full partition with the same findings as the corpus forgery b3-record-removed, while the process stayed up and said nothing. The chain now advances only after a durable sink accepts the record, so the file stays contiguous across a loss and neither the index nor the link reads as an edit. The refused payload goes to stderr under `agentwall_audit_dropped` with no integrity block, /health carries the counters, and the first append that succeeds afterwards writes a gap declaration record that both verifiers report as the non-fatal chain-gap-declared. A declaration never excuses an index gap, a link break, or a hash mismatch. Two failures found alongside it. A short write left a fragment with no terminator that the next append fused onto, so a full disk destroyed a record that WAS written on top of the one that was not; the sink now rolls its own partial write back. And a stream redirected to a regular file reports a failed writeSync as an 'error' event rather than an exception, so the per-sink try/catch never saw it and an unhandled event killed the process on the next tick, taking egress gating down with it. Also: remove capability tickets, which /evaluate minted and nothing ever presented back, and cover the decision precedence conflict that no test reached. --- CHANGELOG.md | 29 +++ README.md | 7 + docs/audit-format.md | 38 ++++ scripts/security-regression.js | 2 +- src/audit/chain.ts | 11 + src/audit/file-sink.ts | 98 ++++++++- src/audit/logger.ts | 274 +++++++++++++++++++++--- src/routes/health.ts | 11 + src/routes/policy.ts | 3 - src/runtime/capabilities.ts | 95 --------- src/server.ts | 4 +- src/types.ts | 29 --- tests/audit-durability.test.ts | 360 ++++++++++++++++++++++++++++++++ tests/capability-ticket.test.ts | 156 -------------- tests/policy-precedence.test.ts | 95 +++++++++ verifier/chain.go | 15 ++ verifier/codes.go | 7 + 17 files changed, 910 insertions(+), 324 deletions(-) delete mode 100644 src/runtime/capabilities.ts create mode 100644 tests/audit-durability.test.ts delete mode 100644 tests/capability-ticket.test.ts create mode 100644 tests/policy-precedence.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 836ec8f..f80c757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,35 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht as a submission failure rather than writing it as a proof. A broken or hostile answer therefore leaves a recorded gap instead of a file that a later verify reports as corrupt evidence. +### Removed +- Capability tickets. `/evaluate` minted an HMAC-signed ticket, `capabilityTicket`, and nothing in + the product ever presented one back: the verifier function had exactly one caller, its own test. + The signing key was generated per process, so a ticket could not be checked by anything but the + process that issued it and did not survive a restart. A signed token nobody checks reads to a + reviewer as an authorization control and is not one, so the minting is gone rather than given an + endpoint no client calls. `PolicyEvaluationResponse` no longer carries the field. + +### Fixed +- Audit records lost to a failing sink no longer read as tampering. The chain state advanced before + the sinks ran and the sink error was swallowed, so a record that never reached disk still moved + the chain on, and the next record carried the index jump and broken link of a DELETED record. + `agentwall verify` reported the same findings for a full partition as for the corpus forgery + `b3-record-removed`, while the process stayed up and said nothing. The chain now advances only + after a durable sink accepts the record, so the file stays contiguous across a loss; the refused + record goes to stderr under `agentwall_audit_dropped` without an integrity block; and the first + append that succeeds afterwards writes a gap declaration record, which both verifiers report as + the non-fatal `chain-gap-declared`. `/health` carries the drop counters. See + [The gap declaration record](docs/audit-format.md#the-gap-declaration-record). +- The audit file sink rolls back an append that ran out of space part way through. A short write + left a fragment with no terminator, and the next append fused onto that line, so a full disk + destroyed a record that had been written on top of the one that had not. +- A failed console write no longer terminates the service. With stdout or stderr redirected to a + regular file, node backs the stream with a synchronous writer whose failure Writable turns into + an `'error'` event rather than an exception, so the per-sink try/catch never saw it and an + unhandled event killed the process on the next tick. A partition full enough to stop the audit + file therefore took down the thing gating egress, on the record after the first one it could not + write. + ## [0.2.0] - 2026-08-05 The first tagged release. It freezes the on-disk evidence format and makes that format diff --git a/README.md b/README.md index cd41d46..4d2a89d 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,13 @@ and a foreign key exits 1. Full detail, including the conformance corpus and what verification does NOT prove, is in [docs/verification.md](docs/verification.md). +A storage failure is deliberately not reported that way. When a record cannot be written, the +chain does not advance past it, so the file stays contiguous and nothing in it reads as a removed +record; the refused record goes to stderr under `agentwall_audit_dropped`, `/health` counts it, and +the first append that succeeds afterwards writes a record declaring how many were lost. `verify` +surfaces that as `chain-gap-declared` without failing the layer. A full partition and a deletion +have to look different, or the alert for one gets ignored because of the other. + ## Limits Stated plainly, because a security tool that oversells itself is worse than no tool. diff --git a/docs/audit-format.md b/docs/audit-format.md index 4261175..6b85f36 100644 --- a/docs/audit-format.md +++ b/docs/audit-format.md @@ -311,6 +311,36 @@ Records that share a `chainIndex` inside one file are a distinct diagnosis from altered record. Many records but few distinct indexes is the signature of two processes each keeping their own chain state and appending to one file, not of an edit. +### The gap declaration record + +A writer that produces a record and cannot store it has a choice about what to leave behind. +Writing the next record anyway puts an index jump and a broken link into the file, which is +byte for byte the shape of a record someone deleted; an operator reading that report is sent +hunting a tamperer through a log nobody touched. So a conforming writer MUST NOT advance the +chain past a record it did not store. The next record it does store takes the index and the +`previousHash` the lost one would have taken, and the file stays contiguous. + +Contiguity alone makes the loss invisible, so a writer SHOULD then record what happened. A gap +declaration is an ordinary record in every respect: it occupies its own index, it links, and +its hash is computed exactly as any other. It is recognisable by two members: + +| Member | Value | +| --- | --- | +| `action` | `"audit:chain-gap"` | +| `metadata.droppedRecords` | Decimal count of records that were produced and not stored, as a string | + +A verifier MAY report such a record, and the bundled implementations do, as a non-fatal +finding named `chain-gap-declared`. Two rules bound what it means: + +- A verifier MUST NOT let a declaration excuse anything. An index gap, a link break, and a + hash mismatch are judged the same whether or not a declaration is present. The record is the + writer's account of a hole, not a licence to have one, and treating it as a licence would + hand an attacker a member to add to a rewritten file. +- A verifier MUST NOT treat the absence of a declaration as proof that nothing was lost. The + declaration can only be written once storage accepts writes again, so a process that dies + during the outage never writes one. This is the completeness limit the format already has, + not a new one. + ### Worked example: two records chained The record from the worked example above is followed on the next line by: @@ -747,6 +777,12 @@ reports otherwise is wrong. - **Completeness of capture.** Every hash and every signature is computed over records that exist. Nothing here can show that an action which was never written down did not happen. An anchor proves records were not altered afterwards; it does not prove the log is complete. +- **That a gap was declared.** A writer keeps the chain contiguous across records it could not + store, so a storage outage leaves no linkage failure to find. The + [gap declaration record](#the-gap-declaration-record) is the only in-band trace, and it can + be written only once storage recovers. A file that ends where the disk filled, or a process + that died before recovery, carries no trace at all. This is the completeness limit above, + stated for the case an operator is most likely to meet. - **Authorship, without a pinned key.** A checkpoint signature verified against the key the checkpoint itself carries proves only internal consistency. Anyone who can write the file can generate a key, sign their version, and produce a set of records that verifies @@ -790,6 +826,8 @@ A verifier written from this document is conforming when all of the following ho - It reports `chained`, `linked`, and `anchored` separately, and reports counts of pending, confirmed, and failed anchors. - It distinguishes a torn final line from other parse failures. +- It judges an index gap, a link break, and a hash mismatch identically whether or not the + file contains a gap declaration record. - It resolves a relative manifest `path` against the manifest's directory, never against its own working directory. - It checks every manifest entry against the segment it names, reports both a missing segment diff --git a/scripts/security-regression.js b/scripts/security-regression.js index abec909..7c44318 100644 --- a/scripts/security-regression.js +++ b/scripts/security-regression.js @@ -25,7 +25,7 @@ const securityGroups = [ tests: [ 'tests/approval-webhook.test.ts', 'tests/audit-chain.test.ts', - 'tests/capability-ticket.test.ts', + 'tests/audit-durability.test.ts', ], }, { diff --git a/src/audit/chain.ts b/src/audit/chain.ts index 1f271d5..c30ae2b 100644 --- a/src/audit/chain.ts +++ b/src/audit/chain.ts @@ -16,6 +16,17 @@ const HASH_STATUS = "chained-local"; // a tampered record. const CANON = "cu1"; +/** + * The `action` that marks a record as the writer's declaration of a hole. + * + * A record carrying it is an ordinary chained record in every respect. It exists because a + * writer that could not store a record has no other way to say so inside the evidence: the + * chain stays contiguous across the loss, so nothing in the linkage reveals that anything + * happened. Named here rather than in the logger because the writer and the chain walk both + * need the same string, and a copy in each would drift. + */ +export const AUDIT_CHAIN_GAP_ACTION = "audit:chain-gap"; + type AuditPayloadValue = string | number | boolean | null | AuditPayloadValue[] | { [key: string]: AuditPayloadValue }; function emitCanonical(value: AuditPayloadValue): string { diff --git a/src/audit/file-sink.ts b/src/audit/file-sink.ts index d59580f..b6739d4 100644 --- a/src/audit/file-sink.ts +++ b/src/audit/file-sink.ts @@ -5,14 +5,16 @@ import { writeFileSync, existsSync, fstatSync, + ftruncateSync, mkdirSync, openSync, readFileSync, readSync, + statSync, } from "fs"; import { dirname } from "path"; import { AuditEvent } from "../types"; -import { AuditChainState, findDuplicateKey } from "./chain"; +import { AUDIT_CHAIN_GAP_ACTION, AuditChainState, findDuplicateKey } from "./chain"; /** * Durable JSONL sink for the audit chain. @@ -173,16 +175,86 @@ export function claimWriter( }); } -/** One audit record per line, nothing else in the file. */ -export function createFileSink(path: string): (event: AuditEvent) => void { +/** + * Undo an append that ran out of space part way through. + * + * A write that fails with ENOSPC can still have moved bytes: the kernel copies what fits and + * reports the shortfall, leaving a headless fragment with no terminator. The next append + * lands on that same line and fuses the two into one unparseable record, so a full disk + * destroys a record that WAS written on top of the one that was not. + * + * Only ever removes bytes this sink just wrote. `mark` is the length after the last append + * that completed and claimWriter guarantees no other process appends between the two, so the + * bytes past it are ours. Anything that does not match that picture is left alone, because a + * truncate aimed at the wrong offset deletes records instead of a fragment. + */ +function discardPartialAppend(path: string, mark: number): boolean { + let fd: number | undefined; + try { + fd = openSync(path, "r+"); + if (fstatSync(fd).size <= mark) return false; // nothing of ours landed + if (mark > 0) { + const boundary = Buffer.alloc(1); + readSync(fd, boundary, 0, 1, mark - 1); + if (boundary[0] !== 0x0a) return false; // the mark is not a record boundary + } + ftruncateSync(fd, mark); + return true; + } catch { + return false; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + /* ignore */ + } + } + } +} + +/** + * One audit record per line, nothing else in the file. + * + * Throws when the record did not reach the file. The caller decides what a refusal means for + * the chain; swallowing it here is what let the chain advance past records that were never + * stored. + * + * `append` is injectable for the same reason the lock probe is: the failure that matters here + * is a short write on a full filesystem, which a test process cannot stage on demand, and it + * is the case that decides whether a record that WAS written survives the one that was not. + */ +export function createFileSink( + path: string, + append: (target: string, data: string) => void = appendFileSync, +): (event: AuditEvent) => void { mkdirSync(dirname(path), { recursive: true }); claimWriter(path); + // Length as of the last append that completed. Tracked rather than measured per record + // because the repair needs it BEFORE the write, and a stat on every record would put a + // syscall on the proxy's per-request path to serve an error case that almost never runs. + let committed = existsSync(path) ? statSync(path).size : 0; return (event: AuditEvent) => { - // Flag "a" opens O_APPEND, so the kernel makes the seek-to-end and the write a - // single atomic operation against the file offset. That is the guarantee that keeps - // records from interleaving, not PIPE_BUF, which governs pipes and is only 4096 on - // Linux, well under a typical record carrying full detections. - appendFileSync(path, JSON.stringify(event) + "\n", { encoding: "utf8" }); + // Handed over as a string, not a Buffer this function built. appendFileSync converts + // internally either way, and doing it here measures about 5 microseconds per record + // slower, which the proxy pays on every request. + const line = JSON.stringify(event) + "\n"; + try { + // Flag "a" opens O_APPEND, so the kernel makes the seek-to-end and the write a + // single atomic operation against the file offset. That is the guarantee that keeps + // records from interleaving, not PIPE_BUF, which governs pipes and is only 4096 on + // Linux, well under a typical record carrying full detections. + append(path, line); + } catch (err) { + const discarded = discardPartialAppend(path, committed); + throw new Error( + `audit append to ${path} failed: ${(err as Error).message}` + + (discarded ? "; the partial write was rolled back" : ""), + ); + } + // Bytes, not characters: a record carrying non-ASCII metadata occupies more of the file + // than its length, and a mark short of the real end would leave a fragment behind. + committed += Buffer.byteLength(line, "utf8"); }; } @@ -385,6 +457,16 @@ export function verifyChainFile( if (rehash(ev) !== integ.hash) { problems.push(`line ${i + 1}: hash mismatch, record altered after write`); } + if (ev.action === AUDIT_CHAIN_GAP_ACTION) { + // The writer's own statement, inside the chain, that records it produced could not be + // stored. Surfaced because the chain is contiguous across such a loss and would + // otherwise pass in silence. It is NOT a licence: the index and link checks above ran + // first and still stand, so a marker cannot be used to excuse a removed record. + notes.push( + `line ${i + 1}: chain-gap-declared, the writer recorded that ` + + `${ev.metadata?.droppedRecords ?? "an unstated number of"} record(s) could not be written here`, + ); + } expectedIndex = integ.chainIndex + 1; expectedPrev = integ.hash; }); diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 10f3174..aee1cef 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -1,10 +1,39 @@ import { randomUUID } from "crypto"; -import { chainAuditEvent, AuditChainState } from "./chain"; +import { AUDIT_CHAIN_GAP_ACTION, chainAuditEvent, AuditChainState } from "./chain"; import { AuditEvent, AgentContext, PolicyResult } from "../types"; export type AuditSink = (event: AuditEvent) => void; -const sinks: AuditSink[] = []; +/** + * Whether a sink's stream IS the evidence, or only watches it go past. + * + * The distinction decides what a write failure means. A file sink's bytes are what `verify` + * walks, so a record it could not take is a record that does not exist and the chain must not + * link across it. A stdout sink is a console view; a line missing there costs visibility, not + * evidence, and must not be able to stall the chain. + */ +export interface AuditSinkOptions { + durable?: boolean; +} + +interface RegisteredSink { + fn: AuditSink; + durable: boolean; +} + +const sinks: RegisteredSink[] = []; + +// Keep a failed console write from killing the service. +// +// When stdout or stderr is a regular file, node backs it with a SyncWriteStream whose +// writeSync throws inside _write. Writable turns that into an 'error' event rather than +// letting it out of write(), so the try/catch around each sink call below never sees it, and +// an unhandled 'error' event terminates the process on the next tick. The partition that +// stopped the audit file accepting records is the same partition an operator redirected +// stdout to, so the console copy of a record would take down the thing that gates egress. +// Both writes are best-effort views; the chain and the drop counters are the record. +process.stdout.on("error", () => {}); +process.stderr.on("error", () => {}); function initialChainState(): AuditChainState { return { @@ -15,9 +44,47 @@ function initialChainState(): AuditChainState { let auditChainState = initialChainState(); -export function registerAuditSink(sink: AuditSink): void { - if (!sinks.includes(sink)) { - sinks.push(sink); +/** + * Records the durable sinks refused, waiting to be declared inside the chain. + * + * Held in memory only. Persisting it would need a write to the storage that is already + * refusing writes, so a process that dies during an outage loses the declaration and the + * loss survives only in the stderr copies. The contiguity guarantee does not depend on it: + * a restart resumes from the last record actually on disk. + */ +interface PendingGap { + count: number; + firstFailureAt: string; + lastFailureAt: string; + /** What the durable sink said when the outage began, which is the diagnosis worth keeping. */ + reason: string; +} + +let pendingGap: PendingGap | null = null; +let droppedTotal = 0; + +export interface AuditDropStats { + /** Records no durable sink would take, since the last reset. */ + dropped: number; + /** Of those, the ones no marker in the chain declares yet. */ + undeclared: number; + /** When the current run of failures began, or null when nothing is outstanding. */ + since: string | null; + /** Why the current run of failures began, or null when nothing is outstanding. */ + reason: string | null; +} + +/** + * Register a sink. + * + * Pass `durable` for a sink whose stream is verified as a chain. The logger assumes at most + * one of those, matching the single-writer lock the file sink takes: two durable sinks that + * disagree about a record cannot both stay contiguous, and this treats any durable failure as + * the record not having been recorded at all. + */ +export function registerAuditSink(sink: AuditSink, options: AuditSinkOptions = {}): void { + if (!sinks.some((registered) => registered.fn === sink)) { + sinks.push({ fn: sink, durable: options.durable === true }); } } @@ -29,48 +96,193 @@ export function seedAuditChain(state: AuditChainState): void { auditChainState = { ...state }; } +/** + * Return the logger to its boot state. + * + * Sinks go too. A test that builds the server twice would otherwise leave two file sinks + * registered against one path, and every subsequent record would be appended twice under a + * single chain index, which reads back as the two-writer interleave the lock exists to stop. + */ export function resetAuditChain(): void { auditChainState = initialChainState(); + pendingGap = null; + droppedTotal = 0; + sinks.length = 0; +} + +export function auditDropStats(): AuditDropStats { + return { + dropped: droppedTotal, + undeclared: pendingGap?.count ?? 0, + since: pendingGap?.firstFailureAt ?? null, + reason: pendingGap?.reason ?? null, + }; } export function emit(ctx: AgentContext, result: PolicyResult): AuditEvent { - const event = chainAuditEvent( - { - id: randomUUID(), - timestamp: new Date().toISOString(), - agentId: ctx.agentId, - sessionId: ctx.sessionId, - plane: ctx.plane, - action: ctx.action, - decision: result.decision, - riskLevel: result.riskLevel, - matchedRules: result.matchedRules, - reasons: result.reasons, - requiresApproval: result.requiresApproval, - highRiskFlow: result.highRiskFlow, - detections: result.detections, - metadata: ctx.metadata, - actor: ctx.actor, - provenance: ctx.provenance, - flow: ctx.flow, - }, - auditChainState - ); + // Any earlier loss is declared before this event joins the chain, so the marker sits where + // the missing records were rather than after the ones that replaced them. + declarePendingGap(); + + const payload: Omit = { + id: randomUUID(), + timestamp: new Date().toISOString(), + agentId: ctx.agentId, + sessionId: ctx.sessionId, + plane: ctx.plane, + action: ctx.action, + decision: result.decision, + riskLevel: result.riskLevel, + matchedRules: result.matchedRules, + reasons: result.reasons, + requiresApproval: result.requiresApproval, + highRiskFlow: result.highRiskFlow, + detections: result.detections, + metadata: ctx.metadata, + actor: ctx.actor, + provenance: ctx.provenance, + flow: ctx.flow, + }; + const event = chainAuditEvent(payload, auditChainState); + + // The chain advances only after the record is on the evidence stream. Advancing first, as + // this did, leaves the next record pointing at a predecessor that a failed append never + // wrote: the file then carries the index jump and broken link of a DELETED record, which is + // the signature of the exact attack this product exists to detect. A full disk must not be + // reportable as tampering. + const failure = deliverDurable(event); + if (failure === null) { + commit(event); + } else { + dropRecord(payload, failure); + } + + return event; +} +/** + * Offer a record to every durable sink, returning why it was refused, or null on success. + * + * Errors are caught per sink rather than allowed out: emit() is called on the request path + * and a storage fault must not become a failed policy decision. The list stays unallocated + * until something actually fails, because this runs once per proxied request and the + * succeeding path should cost nothing it does not use. + */ +function deliverDurable(event: AuditEvent): string | null { + let failures: string[] | null = null; + for (const sink of sinks) { + if (!sink.durable) continue; + try { + sink.fn(event); + } catch (err) { + (failures ??= []).push(err instanceof Error ? err.message : String(err)); + } + } + return failures === null ? null : failures.join("; "); +} + +/** + * Accept a record into the chain and show it to the observers. + * + * Observers run only after the evidence stream took the record, so what a console shows is + * what the chain contains. A line printed for a record that was never stored would invite an + * operator to believe an unwritten decision is on file. + */ +function commit(event: AuditEvent): void { auditChainState = { chainIndex: event.integrity.chainIndex + 1, previousHash: event.integrity.hash, }; - for (const sink of sinks) { + if (sink.durable) continue; try { - sink(event); + sink.fn(event); } catch { - // sinks must not throw + // An observer must not break the request it is watching. } } +} - return event; +/** + * Account for a record no durable sink would take. + * + * The chain is deliberately left where it was, so the next record reuses this index and links + * to the same predecessor: the file stays contiguous and verification reports neither a gap + * nor a broken link for something that was never written. + * + * The payload still goes to stderr, without an integrity block, because losing the content + * silently is the other half of the failure. Stripping the integrity block is the point: the + * index it was chained at now belongs to a different record, so publishing it would hand a + * reader a hash that verifies against nothing. + */ +function dropRecord(payload: Omit, reason: string): void { + const at = new Date().toISOString(); + droppedTotal++; + pendingGap = + pendingGap === null + ? { count: 1, firstFailureAt: at, lastFailureAt: at, reason } + : { ...pendingGap, count: pendingGap.count + 1, lastFailureAt: at }; + + // A different key from the stdout sink's `agentwall_audit` so no collector can file an + // unchained record alongside chained ones. + // + // Guarded, because the thing that filled the partition fills stderr too. When fd 2 is a + // regular file Node backs it with a synchronous writer that throws ENOSPC straight back out + // of write(), and emit() is called inline by the route handlers and the proxy. Letting that + // escape would turn a full disk into a 500 on every request, which is a worse outage than + // the silent one this whole change exists to end. The counters and the gap declaration are + // the record if this line cannot be printed. + try { + process.stderr.write( + JSON.stringify({ agentwall_audit_dropped: { at, reason, record: payload } }) + "\n", + ); + } catch { + // Nowhere left to say it. auditDropStats() still counts it. + } +} + +/** + * Write the marker that says records were produced and could not be stored. + * + * Best effort by construction: while the storage is still refusing, nothing can be written, + * so the count keeps accumulating and the marker lands on the first append that succeeds. + */ +function declarePendingGap(): void { + if (pendingGap === null) return; + + const marker = chainAuditEvent(gapPayload(pendingGap), auditChainState); + if (deliverDurable(marker) !== null) { + // Still refusing. Not counted as another drop: this record stands in for losses that are + // already counted, and counting it would inflate the number it reports. + return; + } + commit(marker); + pendingGap = null; +} + +function gapPayload(gap: PendingGap): Omit { + return { + id: randomUUID(), + timestamp: new Date().toISOString(), + agentId: "agentwall", + plane: "governance", + action: AUDIT_CHAIN_GAP_ACTION, + // Nothing was permitted here. Every other decision value reads as an outcome a policy + // produced, and a marker counted as an allow would credit an outage with approvals. + decision: "deny", + riskLevel: "critical", + matchedRules: [], + reasons: [`${gap.count} audit record(s) could not be written and are absent from this chain`], + requiresApproval: false, + highRiskFlow: false, + detections: [], + metadata: { + droppedRecords: String(gap.count), + firstFailureAt: gap.firstFailureAt, + lastFailureAt: gap.lastFailureAt, + reason: gap.reason, + }, + }; } export function stdoutSink(event: AuditEvent): void { diff --git a/src/routes/health.ts b/src/routes/health.ts index ab01bb4..573d2d5 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,12 +1,23 @@ import { FastifyInstance } from "fastify"; +import { auditDropStats } from "../audit/logger"; export async function healthRoutes(app: FastifyInstance): Promise { app.get("/health", async (_req, reply) => { + // A process whose audit records are not reaching storage has lost the property this + // product sells, and the chain itself cannot say so: it stays contiguous across the loss + // by design. The counters are reported here because that is the only live surface an + // operator polls. `status` deliberately stays "ok": the container healthcheck restarts + // on anything else, and restarting does not empty a full disk. + const audit = auditDropStats(); return reply.send({ status: "ok", service: "agentwall", version: "0.1.0", timestamp: new Date().toISOString(), + auditDropped: audit.dropped, + auditUndeclaredDrops: audit.undeclared, + auditDropSince: audit.since, + auditDropReason: audit.reason, }); }); diff --git a/src/routes/policy.ts b/src/routes/policy.ts index 3ec75ff..1dad9b8 100644 --- a/src/routes/policy.ts +++ b/src/routes/policy.ts @@ -4,7 +4,6 @@ import { AgentContextSchema, PolicyEvaluationResponse } from "../types"; import { emit } from "../audit/logger"; import { RuntimeState } from "../dashboard/state"; import { detectionCatalog } from "../policy/detections"; -import { issueCapabilityTicket } from "../runtime/capabilities"; import { RuntimeFloodGuard } from "../runtime/floodguard"; import { DecisionTraceExporter } from "../telemetry/otel"; @@ -94,7 +93,6 @@ export async function policyRoutes( const result = engine.evaluate(ctx); const auditEvent = emit(ctx, result); - const capabilityTicket = issueCapabilityTicket(ctx, result); runtime.recordAuditEvent(auditEvent); const response: PolicyEvaluationResponse = { @@ -106,7 +104,6 @@ export async function policyRoutes( highRiskFlow: result.highRiskFlow, detections: result.detections, auditEventId: auditEvent.id, - capabilityTicket, }; await telemetry.export({ diff --git a/src/runtime/capabilities.ts b/src/runtime/capabilities.ts deleted file mode 100644 index 5ca741e..0000000 --- a/src/runtime/capabilities.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto"; -import { AgentContext, CapabilityTicket, CapabilityTicketConstraints, PolicyResult } from "../types"; - -const capabilitySecret = randomBytes(32); - -export const CAPABILITY_TICKET_TTL_MS = 5 * 60 * 1000; - -function stableSerialize(value: unknown): string { - if (value === null) { - return "null"; - } - - if (Array.isArray(value)) { - return `[${value.map((entry) => stableSerialize(entry)).join(",")}]`; - } - - if (typeof value === "object") { - const entries = Object.entries(value as Record) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - - return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`).join(",")}}`; - } - - return JSON.stringify(value); -} - -function signCapabilityTicket(ticket: Omit): string { - return createHmac("sha256", capabilitySecret).update(stableSerialize(ticket)).digest("hex"); -} - -function deriveConstraints(context: AgentContext): CapabilityTicketConstraints { - const flow = context.flow - ? { - direction: context.flow.direction, - labels: context.flow.labels ? [...context.flow.labels].sort() : undefined, - target: context.flow.target, - highRisk: context.flow.highRisk, - crossesBoundary: context.flow.crossesBoundary, - } - : undefined; - - return { - payloadKeys: Object.keys(context.payload).sort(), - flow, - }; -} - -export function shouldIssueCapabilityTicket(result: PolicyResult): boolean { - return result.decision === "allow" && !result.requiresApproval; -} - -export function issueCapabilityTicket( - context: AgentContext, - result: PolicyResult, - options?: { now?: Date; ttlMs?: number } -): CapabilityTicket | undefined { - if (!shouldIssueCapabilityTicket(result)) { - return undefined; - } - - const issuedAt = options?.now ?? new Date(); - const expiresAt = new Date(issuedAt.getTime() + (options?.ttlMs ?? CAPABILITY_TICKET_TTL_MS)); - const unsignedTicket: Omit = { - id: randomUUID(), - issuedAt: issuedAt.toISOString(), - expiresAt: expiresAt.toISOString(), - decision: result.decision, - riskLevel: result.riskLevel, - agentId: context.agentId, - sessionId: context.sessionId, - plane: context.plane, - action: context.action, - actor: context.actor, - constraints: deriveConstraints(context), - }; - - return { - ...unsignedTicket, - signature: signCapabilityTicket(unsignedTicket), - }; -} - -export function verifyCapabilityTicketSignature(ticket: CapabilityTicket): boolean { - const { signature, ...unsignedTicket } = ticket; - const expected = signCapabilityTicket(unsignedTicket); - - const providedBuffer = Buffer.from(signature, "utf8"); - const expectedBuffer = Buffer.from(expected, "utf8"); - if (providedBuffer.length !== expectedBuffer.length) { - return false; - } - - return timingSafeEqual(providedBuffer, expectedBuffer); -} diff --git a/src/server.ts b/src/server.ts index 76c5bdb..3daa879 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,7 +52,9 @@ export async function buildServer(config: AgentwallConfig): Promise line.trim() !== "") + .map((line) => JSON.parse(line) as AuditEvent); +} + +/** + * A durable sink that writes through to a real file until `refuse` is set, then throws the + * way appendFileSync throws on a full partition. + */ +function refusableFileSink(auditPath: string): { refuse: (on: boolean) => void } { + const write = createFileSink(auditPath); + let refusing = false; + registerAuditSink( + (event) => { + if (refusing) throw new Error(`audit append to ${auditPath} failed: ENOSPC: no space left on device`); + write(event); + }, + { durable: true }, + ); + return { refuse: (on: boolean) => (refusing = on) }; +} + +beforeEach(() => { + resetAuditChain(); +}); + +afterEach(() => { + resetAuditChain(); + jest.restoreAllMocks(); + while (tempDirs.length) { + fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe("audit chain durability under a refusing sink", () => { + it("keeps the file contiguous across records the durable sink refused", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + + emit(ctx("before"), allowed); + sink.refuse(true); + emit(ctx("lost-1"), allowed); + emit(ctx("lost-2"), allowed); + sink.refuse(false); + emit(ctx("after"), allowed); + + const verification = verifyChainFile(auditPath, rehashAuditEvent); + // The whole point: nothing here may read as an edit or a removal. + expect(verification.problems).toEqual([]); + expect(verification.ok).toBe(true); + + const written = recordsOf(auditPath); + expect(written.map((r) => r.action)).toEqual(["before", AUDIT_CHAIN_GAP_ACTION, "after"]); + expect(written.map((r) => r.integrity.chainIndex)).toEqual([0, 1, 2]); + expect(written[1].integrity.previousHash).toBe(written[0].integrity.hash); + expect(written[2].integrity.previousHash).toBe(written[1].integrity.hash); + }); + + it("declares how many records were lost, and reports it without failing the layer", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + + emit(ctx("before"), allowed); + sink.refuse(true); + emit(ctx("lost-1"), allowed); + emit(ctx("lost-2"), allowed); + emit(ctx("lost-3"), allowed); + sink.refuse(false); + emit(ctx("after"), allowed); + + const marker = recordsOf(auditPath)[1]; + expect(marker.action).toBe(AUDIT_CHAIN_GAP_ACTION); + expect(marker.metadata?.droppedRecords).toBe("3"); + expect(marker.metadata?.reason).toContain("ENOSPC"); + expect(marker.decision).toBe("deny"); + + const verification = verifyChainFile(auditPath, rehashAuditEvent); + expect(verification.problems).toEqual([]); + expect(verification.notes).toHaveLength(1); + expect(verification.notes[0]).toContain("chain-gap-declared"); + expect(verification.notes[0]).toContain("3 record(s)"); + }); + + it("counts one gap declaration per outage, not per recovery", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + + sink.refuse(true); + emit(ctx("lost-1"), allowed); + sink.refuse(false); + emit(ctx("recovered-1"), allowed); + sink.refuse(true); + emit(ctx("lost-2"), allowed); + sink.refuse(false); + emit(ctx("recovered-2"), allowed); + + expect(recordsOf(auditPath).map((r) => r.action)).toEqual([ + AUDIT_CHAIN_GAP_ACTION, + "recovered-1", + AUDIT_CHAIN_GAP_ACTION, + "recovered-2", + ]); + expect(verifyChainFile(auditPath, rehashAuditEvent).problems).toEqual([]); + }); + + it("writes no declaration while storage is still refusing", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + + emit(ctx("before"), allowed); + sink.refuse(true); + emit(ctx("lost-1"), allowed); + emit(ctx("lost-2"), allowed); + + // Nothing can be written to a partition that is refusing writes, marker included. + expect(recordsOf(auditPath).map((r) => r.action)).toEqual(["before"]); + expect(auditDropStats()).toMatchObject({ dropped: 2, undeclared: 2 }); + expect(auditDropStats().reason).toContain("ENOSPC"); + }); + + it("puts the refused record on stderr without an integrity block", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + const lines: string[] = []; + jest.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }); + + sink.refuse(true); + emit(ctx("lost-1"), allowed); + + expect(lines).toHaveLength(1); + const dropped = JSON.parse(lines[0]) as { + agentwall_audit_dropped: { reason: string; record: Record }; + }; + expect(dropped.agentwall_audit_dropped.record.action).toBe("lost-1"); + // An index it was chained at now belongs to a different record, so publishing the block + // would hand a reader a hash that verifies against nothing. + expect(dropped.agentwall_audit_dropped.record.integrity).toBeUndefined(); + expect(dropped.agentwall_audit_dropped.reason).toContain("ENOSPC"); + }); + + it("survives stderr being as full as the audit file", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + // With fd 2 on a regular file, Node backs process.stderr with a synchronous writer that + // throws ENOSPC straight back out of write(). emit() is called inline by the route + // handlers, so an escape here turns a full partition into a 500 on every request. + jest.spyOn(process.stderr, "write").mockImplementation(() => { + const err = new Error("ENOSPC: no space left on device, write") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + }); + + sink.refuse(true); + expect(() => emit(ctx("lost-1"), allowed)).not.toThrow(); + expect(() => emit(ctx("lost-2"), allowed)).not.toThrow(); + + expect(auditDropStats()).toMatchObject({ dropped: 2, undeclared: 2 }); + + // Recovery still declares the loss, so the only thing given up is the console copy. + sink.refuse(false); + jest.restoreAllMocks(); + emit(ctx("after"), allowed); + expect(recordsOf(auditPath)[0].metadata?.droppedRecords).toBe("2"); + }); + + it("does not let a failed console write terminate the process", () => { + // A stream backed by a regular file reports a failed writeSync as an 'error' event + // instead of throwing out of write(), so the try/catch around each sink never sees it. + // An 'error' event with no listener is rethrown by EventEmitter and kills the process, + // which on a full partition means the console copy of a record takes down egress + // gating. Emitting one here is exactly what node does in that case. + expect(() => process.stderr.emit("error", new Error("ENOSPC: no space left on device"))).not.toThrow(); + expect(() => process.stdout.emit("error", new Error("ENOSPC: no space left on device"))).not.toThrow(); + }); + + it("shows an observer only the records the evidence stream accepted", () => { + const auditPath = tempAuditPath(); + const sink = refusableFileSink(auditPath); + const observed: string[] = []; + registerAuditSink((event) => observed.push(event.action)); + + emit(ctx("before"), allowed); + sink.refuse(true); + emit(ctx("lost-1"), allowed); + sink.refuse(false); + emit(ctx("after"), allowed); + + expect(observed).toEqual(["before", AUDIT_CHAIN_GAP_ACTION, "after"]); + }); + + it("lets the chain advance when only an observer fails", () => { + const auditPath = tempAuditPath(); + refusableFileSink(auditPath); + registerAuditSink(() => { + throw new Error("console gone"); + }); + + emit(ctx("one"), allowed); + emit(ctx("two"), allowed); + + expect(recordsOf(auditPath).map((r) => r.integrity.chainIndex)).toEqual([0, 1]); + expect(verifyChainFile(auditPath, rehashAuditEvent).problems).toEqual([]); + expect(auditDropStats().dropped).toBe(0); + }); + + it("advances the chain normally when no sink is durable", () => { + const observed: number[] = []; + registerAuditSink((event) => observed.push(event.integrity.chainIndex)); + + emit(ctx("one"), allowed); + emit(ctx("two"), allowed); + + expect(observed).toEqual([0, 1]); + expect(auditDropStats().dropped).toBe(0); + }); +}); + +describe("partial append rollback", () => { + it("removes bytes a short write left behind so the next record parses", () => { + const auditPath = tempAuditPath(); + // ENOSPC on a partly-filled page: the kernel copies what fits and reports the shortfall. + let shortWrite = false; + const write = createFileSink(auditPath, (target, data) => { + if (!shortWrite) { + fs.appendFileSync(target, data); + return; + } + fs.appendFileSync(target, data.slice(0, 40)); + const err = new Error("ENOSPC: no space left on device, write") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + }); + + const first = chainAuditEvent( + { ...ctx("first"), id: "a", timestamp: "2026-01-01T00:00:00.000Z", ...allowed } as unknown as Omit< + AuditEvent, + "integrity" + >, + { chainIndex: 0, previousHash: null }, + ); + write(first); + const goodLength = fs.statSync(auditPath).size; + + const second = chainAuditEvent( + { ...ctx("second"), id: "b", timestamp: "2026-01-01T00:00:01.000Z", ...allowed } as unknown as Omit< + AuditEvent, + "integrity" + >, + { chainIndex: 1, previousHash: first.integrity.hash }, + ); + shortWrite = true; + expect(() => write(second)).toThrow(/rolled back/); + + // Without the rollback the fragment stays and the next append fuses onto its line, + // destroying a record that WAS written on top of the one that was not. + expect(fs.statSync(auditPath).size).toBe(goodLength); + + shortWrite = false; + write(second); + expect(recordsOf(auditPath).map((r) => r.action)).toEqual(["first", "second"]); + expect(verifyChainFile(auditPath, rehashAuditEvent).problems).toEqual([]); + }); +}); + +describe("a declaration is not a licence", () => { + it("still reports an index gap that follows a gap declaration", () => { + const auditPath = tempAuditPath(); + const first = chainAuditEvent( + { + id: "gap", + timestamp: "2026-01-01T00:00:00.000Z", + agentId: "agentwall", + plane: "governance", + action: AUDIT_CHAIN_GAP_ACTION, + decision: "deny", + riskLevel: "critical", + matchedRules: [], + reasons: ["2 audit record(s) could not be written and are absent from this chain"], + requiresApproval: false, + highRiskFlow: false, + detections: [], + metadata: { droppedRecords: "2" }, + }, + { chainIndex: 0, previousHash: null }, + ); + // A record removed after the declaration, which the declaration must not absolve. + const jumped = chainAuditEvent( + { ...ctx("later"), id: "c", timestamp: "2026-01-01T00:00:02.000Z", ...allowed } as unknown as Omit< + AuditEvent, + "integrity" + >, + { chainIndex: 5, previousHash: first.integrity.hash }, + ); + fs.writeFileSync(auditPath, `${JSON.stringify(first)}\n${JSON.stringify(jumped)}\n`); + + const verification = verifyChainFile(auditPath, rehashAuditEvent); + expect(verification.ok).toBe(false); + expect(verification.problems.join(" ")).toContain("gap or silent restart"); + expect(verification.notes.join(" ")).toContain("chain-gap-declared"); + }); +}); diff --git a/tests/capability-ticket.test.ts b/tests/capability-ticket.test.ts deleted file mode 100644 index dc5ad76..0000000 --- a/tests/capability-ticket.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@jest/globals"; -import { resetAuditChain } from "../src/audit/logger"; -import { AgentwallConfig } from "../src/config"; -import { buildServer } from "../src/server"; -import { verifyCapabilityTicketSignature } from "../src/runtime/capabilities"; - -const config: AgentwallConfig = { - port: 3018, - host: "127.0.0.1", - logLevel: "silent", - dashboard: {}, - approval: { - mode: "auto", - timeoutMs: 30_000, - backend: "memory", - }, - policy: { - defaultDecision: "deny", - }, - dlp: { - enabled: true, - redactSecrets: true, - }, - egress: { - enabled: true, - defaultDeny: true, - allowPrivateRanges: false, - allowedHosts: ["api.openai.com"], - allowedSchemes: ["https"], - allowedPorts: [443], - }, - manifestIntegrity: { - enabled: true, - }, - watchdog: { - enabled: true, - staleAfterMs: 15_000, - timeoutMs: 30_000, - killSwitchMode: "deny_all", - }, -}; - -describe("Capability tickets", () => { - let server: Awaited>; - - beforeEach(async () => { - resetAuditChain(); - server = await buildServer(config); - }); - - afterEach(async () => { - await server.app.close(); - resetAuditChain(); - }); - - it("issues a signed short-lived ticket for allowed executable requests", async () => { - const response = await server.app.inject({ - method: "POST", - url: "/evaluate", - payload: { - agentId: "agent-capability", - sessionId: "session-capability", - plane: "tool", - action: "write_file", - actor: { - userId: "user-123", - roleIds: ["operator"], - }, - payload: { - path: "/tmp/report.txt", - content: "hello", - }, - flow: { - direction: "internal", - labels: ["destructive_action"], - highRisk: true, - }, - }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.decision).toBe("allow"); - expect(body.requiresApproval).toBe(false); - expect(body.capabilityTicket).toBeDefined(); - expect(verifyCapabilityTicketSignature(body.capabilityTicket)).toBe(true); - expect(Date.parse(body.capabilityTicket.expiresAt)).toBeGreaterThan(Date.now()); - expect(Date.parse(body.capabilityTicket.expiresAt)).toBeGreaterThan(Date.parse(body.capabilityTicket.issuedAt)); - expect(body.capabilityTicket).toEqual( - expect.objectContaining({ - decision: "allow", - riskLevel: "medium", - agentId: "agent-capability", - sessionId: "session-capability", - plane: "tool", - action: "write_file", - actor: { - userId: "user-123", - roleIds: ["operator"], - }, - constraints: { - payloadKeys: ["content", "path"], - flow: { - direction: "internal", - labels: ["destructive_action"], - highRisk: true, - }, - }, - signature: expect.any(String), - }) - ); - }); - - it("does not issue a ticket for denied requests", async () => { - const response = await server.app.inject({ - method: "POST", - url: "/evaluate", - payload: { - agentId: "agent-denied", - sessionId: "session-denied", - plane: "network", - action: "http_request", - payload: { - url: "http://127.0.0.1:8080/admin", - }, - }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.decision).toBe("deny"); - expect(body.capabilityTicket).toBeUndefined(); - }); - - it("does not issue an active ticket when the request still requires approval", async () => { - const response = await server.app.inject({ - method: "POST", - url: "/evaluate", - payload: { - agentId: "agent-approval", - sessionId: "session-approval", - plane: "tool", - action: "bash_exec", - payload: { - command: "id", - }, - }, - }); - - expect(response.statusCode).toBe(200); - const body = response.json(); - expect(body.decision).toBe("approve"); - expect(body.requiresApproval).toBe(true); - expect(body.capabilityTicket).toBeUndefined(); - }); -}); diff --git a/tests/policy-precedence.test.ts b/tests/policy-precedence.test.ts new file mode 100644 index 0000000..aa5c438 --- /dev/null +++ b/tests/policy-precedence.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@jest/globals"; +import { PolicyEngine } from "../src/policy/engine"; +import { AgentContext, Decision, PolicyRule, RiskLevel } from "../src/types"; + +/** + * What happens when rules of different decisions match the same action. + * + * The engine reduces every match down to one decision by taking the most restrictive, and + * that reduction is the whole safety property: a deny rule an operator wrote is worthless if + * a broader allow rule that also matches can outvote it. Nothing covered the conflict, so a + * reordered DECISION_ORDER table or a reduce that kept the first match instead of the + * strictest would have shipped with every existing test green. + */ + +const ORDER: Decision[] = ["allow", "redact", "approve", "deny"]; + +function rule(decision: Decision, riskLevel: RiskLevel = "low"): PolicyRule { + return { + id: `conflict:${decision}`, + description: `matches everything and votes ${decision}`, + plane: "all", + match: () => true, + decision, + riskLevel, + reason: `${decision} rule matched`, + }; +} + +const ctx: AgentContext = { + agentId: "precedence-agent", + sessionId: "precedence-session", + plane: "tool", + action: "write_file", + payload: {}, +} as AgentContext; + +/** Evaluate with both orderings, so a result cannot come from rule order. */ +function decide(rules: PolicyRule[]): Decision[] { + return [ + new PolicyEngine(rules, "allow").evaluate(ctx).decision, + new PolicyEngine([...rules].reverse(), "allow").evaluate(ctx).decision, + ]; +} + +describe("decision precedence when rules conflict", () => { + it("lets deny beat allow", () => { + expect(decide([rule("allow"), rule("deny")])).toEqual(["deny", "deny"]); + }); + + it("lets approve beat redact", () => { + expect(decide([rule("redact"), rule("approve")])).toEqual(["approve", "approve"]); + }); + + it("lets redact beat allow", () => { + expect(decide([rule("allow"), rule("redact")])).toEqual(["redact", "redact"]); + }); + + it("lets deny beat approve", () => { + expect(decide([rule("approve"), rule("deny")])).toEqual(["deny", "deny"]); + }); + + it("takes the strictest decision when every kind matches at once", () => { + expect(decide(ORDER.map((decision) => rule(decision)))).toEqual(["deny", "deny"]); + }); + + it("holds the whole ordering pairwise", () => { + for (let i = 0; i < ORDER.length; i++) { + for (let j = i + 1; j < ORDER.length; j++) { + expect(decide([rule(ORDER[i]), rule(ORDER[j])])).toEqual([ORDER[j], ORDER[j]]); + } + } + }); + + it("reports every matched rule, not just the one that won", () => { + const result = new PolicyEngine([rule("allow"), rule("deny")], "allow").evaluate(ctx); + expect(result.matchedRules).toEqual(["conflict:allow", "conflict:deny"]); + expect(result.reasons).toEqual(["allow rule matched", "deny rule matched"]); + }); + + it("requires approval only when approve is the decision that survived", () => { + expect(new PolicyEngine([rule("redact"), rule("approve")], "allow").evaluate(ctx).requiresApproval).toBe(true); + // A deny outranks the approve rule, so the request is refused rather than queued for a + // human: routing it to an approver would offer someone a button that grants a denied action. + expect(new PolicyEngine([rule("approve"), rule("deny")], "allow").evaluate(ctx).requiresApproval).toBe(false); + }); + + it("takes the highest risk level independently of the winning decision", () => { + // Risk and decision reduce separately: a low-risk deny must not drag the reported risk + // down from a critical allow that also matched, or an operator triaging by severity + // never sees the finding. + const result = new PolicyEngine([rule("allow", "critical"), rule("deny", "low")], "allow").evaluate(ctx); + expect(result.decision).toBe("deny"); + expect(result.riskLevel).toBe("critical"); + }); +}); diff --git a/verifier/chain.go b/verifier/chain.go index e063927..0415a0e 100644 --- a/verifier/chain.go +++ b/verifier/chain.go @@ -22,6 +22,11 @@ import ( // problem per record; without a cap a hostile file could make the report itself a memory sink. const problemCap = 200 +// chainGapAction is the action a writer puts on the record that declares it could not store +// records it produced. The value is part of the evidence format, not of this program, so it +// matches the writer's constant exactly; the two are held together by the format spec. +const chainGapAction = "audit:chain-gap" + type chainResult struct { records int problems []problem @@ -115,6 +120,16 @@ func verifyChainFile(path string) chainResult { } } + if action, ok := v.field("action"); ok && action.kind == kindString && action.str == chainGapAction { + dropped := "an unstated number of" + if meta, ok := v.field("metadata"); ok && meta.kind == kindObject { + if n, ok := meta.field("droppedRecords"); ok && n.kind == kindString { + dropped = n.str + } + } + problems = append(problems, problem{code: codeChainGapDeclared, text: fmt.Sprintf("%s line %d: the writer recorded that %s record(s) could not be written here", base, lineNo, dropped), fatal: false}) + } + have = true prevIndex = integ.chainIndex prevHash = integ.hash diff --git a/verifier/codes.go b/verifier/codes.go index 7c10385..8451b84 100644 --- a/verifier/codes.go +++ b/verifier/codes.go @@ -41,4 +41,11 @@ const ( // signing is expected and is not a mismatch: the committed prefix is searched for within the // current file. codeLiveTailMismatch = "live-tail-mismatch" + // codeChainGapDeclared reports a record in which the writer states that records it + // produced could not be stored. It is not fatal: the chain is contiguous across such a + // loss by construction, so there is no linkage failure to report, and the only thing the + // evidence can offer is the writer's own account of the hole. It is surfaced because a + // silent hole is indistinguishable from nothing having happened. It never excuses an + // index gap or a link break; those are judged before this and stay fatal. + codeChainGapDeclared = "chain-gap-declared" )