diff --git a/package-lock.json b/package-lock.json index 35c894b..c36a79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/packages/core/package.json b/packages/core/package.json index 8ea4516..dd9b18d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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 ", "license": "MIT", "repository": { diff --git a/packages/core/schemas/finding-0.1.0.json b/packages/core/schemas/finding-0.1.0.json new file mode 100644 index 0000000..74f2a70 --- /dev/null +++ b/packages/core/schemas/finding-0.1.0.json @@ -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#" +} diff --git a/packages/core/schemas/observation-0.1.0.json b/packages/core/schemas/observation-0.1.0.json new file mode 100644 index 0000000..c805458 --- /dev/null +++ b/packages/core/schemas/observation-0.1.0.json @@ -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#" +} diff --git a/packages/core/scripts/emit-schemas.mts b/packages/core/scripts/emit-schemas.mts new file mode 100644 index 0000000..2e4f6fb --- /dev/null +++ b/packages/core/scripts/emit-schemas.mts @@ -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}`) +} diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts new file mode 100644 index 0000000..10a6190 --- /dev/null +++ b/packages/core/src/contracts.ts @@ -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 = (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 + +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 + +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 diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts new file mode 100644 index 0000000..0034a3a --- /dev/null +++ b/packages/core/src/diagnostics.ts @@ -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 + +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 + +/** + * 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 { + 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 } +} diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts new file mode 100644 index 0000000..d1655f6 --- /dev/null +++ b/packages/core/src/fingerprint.ts @@ -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 = { + 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 = { + 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