| { | ||
| "type": "skillpress.guidance_impact.v1", | ||
| "schema_version": 1, | ||
| "ok": true, | ||
| "producer": { | ||
| "id": "skillpress" | ||
| }, | ||
| "repository": { | ||
| "id": "attebury/skillpress" | ||
| }, | ||
| "candidate": { | ||
| "base_sha": "1111111111111111111111111111111111111111", | ||
| "head_sha": "2222222222222222222222222222222222222222" | ||
| }, | ||
| "capability_contracts": [ | ||
| { | ||
| "capability_id": "skillpress.capability_contracts", | ||
| "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | ||
| } | ||
| ], | ||
| "source_artifacts": [ | ||
| { | ||
| "artifact_id": "skillpress-repo-skill", | ||
| "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | ||
| } | ||
| ], | ||
| "scenario_results": [ | ||
| { | ||
| "id": "candidate-impact-no-drift", | ||
| "status": "pass" | ||
| } | ||
| ], | ||
| "authority_role": "advisory", | ||
| "authority": { | ||
| "role": "advisory", | ||
| "owns": [ | ||
| "guidance_impact" | ||
| ], | ||
| "not_authority_for": [ | ||
| "merge", | ||
| "release", | ||
| "closeout", | ||
| "software_correctness" | ||
| ] | ||
| }, | ||
| "limitations": [ | ||
| { | ||
| "code": "semantic_review_not_included" | ||
| } | ||
| ], | ||
| "evidence_refs": [ | ||
| "evidence/skillpress-impact.json" | ||
| ], | ||
| "evidence_digest": "sha256:7b71d544d3ba96e3b771dca6637dd49c9ddca7a348de37067ed1973a8a13e81d" | ||
| } |
| { | ||
| "type": "verigram.semantic_guidance_review.v1", | ||
| "schema_version": 1, | ||
| "ok": true, | ||
| "producer": { | ||
| "id": "verigram" | ||
| }, | ||
| "repository": { | ||
| "id": "attebury/skillpress" | ||
| }, | ||
| "candidate": { | ||
| "base_sha": "1111111111111111111111111111111111111111", | ||
| "head_sha": "2222222222222222222222222222222222222222" | ||
| }, | ||
| "parent_evidence": [ | ||
| { | ||
| "type": "skillpress.guidance_impact.v1", | ||
| "digest": "sha256:7b71d544d3ba96e3b771dca6637dd49c9ddca7a348de37067ed1973a8a13e81d" | ||
| } | ||
| ], | ||
| "capability_contracts": [ | ||
| { | ||
| "capability_id": "skillpress.capability_contracts", | ||
| "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | ||
| } | ||
| ], | ||
| "source_artifacts": [ | ||
| { | ||
| "artifact_id": "skillpress-repo-skill", | ||
| "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | ||
| } | ||
| ], | ||
| "reviewer": { | ||
| "id": "reviewer-1" | ||
| }, | ||
| "disposition": "approved", | ||
| "authority_role": "validator", | ||
| "authority": { | ||
| "role": "validator", | ||
| "owns": [ | ||
| "semantic_guidance_review" | ||
| ], | ||
| "not_authority_for": [ | ||
| "merge", | ||
| "release", | ||
| "closeout", | ||
| "software_correctness" | ||
| ] | ||
| }, | ||
| "limitations": [ | ||
| { | ||
| "code": "implementation_correctness_not_reviewed" | ||
| } | ||
| ], | ||
| "evidence_refs": [ | ||
| "evidence/verigram-semantic-review.json" | ||
| ], | ||
| "evidence_digest": "sha256:c376dc709796cb5600428354e21018dd67d6deaf3da6037c481bd90eebc55763" | ||
| } |
| { | ||
| "type": "skillpress.sync_receipt.v1", | ||
| "schema_version": 1, | ||
| "ok": true, | ||
| "producer": { | ||
| "id": "skillpress" | ||
| }, | ||
| "repository": { | ||
| "id": "attebury/skillpress" | ||
| }, | ||
| "merged_source": { | ||
| "commit_sha": "3333333333333333333333333333333333333333" | ||
| }, | ||
| "parent_evidence": [ | ||
| { | ||
| "type": "skillpress.guidance_impact.v1", | ||
| "digest": "sha256:7b71d544d3ba96e3b771dca6637dd49c9ddca7a348de37067ed1973a8a13e81d" | ||
| } | ||
| ], | ||
| "source_artifacts": [ | ||
| { | ||
| "artifact_id": "skillpress-repo-skill", | ||
| "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | ||
| } | ||
| ], | ||
| "target_sets": { | ||
| "configured": [ | ||
| { | ||
| "id": "codex", | ||
| "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" | ||
| }, | ||
| { | ||
| "id": "agents", | ||
| "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" | ||
| } | ||
| ], | ||
| "attempted": [ | ||
| { | ||
| "id": "codex", | ||
| "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" | ||
| }, | ||
| { | ||
| "id": "agents", | ||
| "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" | ||
| } | ||
| ], | ||
| "verified": [ | ||
| { | ||
| "id": "codex", | ||
| "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" | ||
| }, | ||
| { | ||
| "id": "agents", | ||
| "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" | ||
| } | ||
| ] | ||
| }, | ||
| "authority_role": "advisory", | ||
| "authority": { | ||
| "role": "advisory", | ||
| "owns": [ | ||
| "provider_readback" | ||
| ], | ||
| "not_authority_for": [ | ||
| "merge", | ||
| "release", | ||
| "closeout", | ||
| "software_correctness" | ||
| ] | ||
| }, | ||
| "limitations": [ | ||
| { | ||
| "code": "provider_semantics_not_reviewed" | ||
| } | ||
| ], | ||
| "evidence_refs": [ | ||
| "evidence/skillpress-sync.json" | ||
| ], | ||
| "evidence_digest": "sha256:42bffceae0ae7974fa4b163de131a4f89004200e76beaecbe9b081b0b30e3bc7" | ||
| } |
+145
| /** | ||
| * Fail-closed CLI JSON emit helpers. | ||
| * | ||
| * A common CLI failure mode: `console.log(JSON.stringify(packet)); process.exit(0)` | ||
| * races the async flush of stdout when stdout is a pipe (not a TTY). The | ||
| * process can exit 0 while emitting truncated, unparseable JSON, silently | ||
| * dropping trailing fields such as `validity` or `not_authority_for`. Exit | ||
| * status alone is then insufficient evidence that the emitted packet is | ||
| * intact. | ||
| * | ||
| * `emitJsonPacket` avoids the race by only advancing past the write once the | ||
| * stream's write callback confirms the data was handed off, and only ever | ||
| * calling a hard `process.exit` from inside that callback (never before). By | ||
| * default it does not call `process.exit` at all: it sets `process.exitCode` | ||
| * and resolves, letting Node exit naturally once the event loop drains, | ||
| * which is the safest way to guarantee the write is flushed first. | ||
| */ | ||
| export const CLI_EMIT_ERROR_CODES = Object.freeze({ | ||
| INVALID_ARGS: 'invalid_args', | ||
| PACKET_NOT_SERIALIZABLE: 'packet_not_serializable', | ||
| PACKET_TOO_LARGE: 'packet_too_large', | ||
| WRITE_FAILED: 'write_failed', | ||
| }); | ||
| function cliEmitError(code, message, details = null) { | ||
| const error = new Error(message); | ||
| error.code = code; | ||
| if (details && typeof details === 'object') { | ||
| error.details = details; | ||
| } | ||
| return error; | ||
| } | ||
| /** | ||
| * Serialize `packet` as a single JSON line and write it to `stream`, | ||
| * resolving only after the write is confirmed flushed by the stream. | ||
| * | ||
| * @param {*} packet - JSON-serializable value to emit. | ||
| * @param {object} [options] | ||
| * @param {number} [options.exitCode=0] - Value assigned to `process.exitCode` | ||
| * once the write is confirmed. Assigning `process.exitCode` (rather than | ||
| * forcing `process.exit`) lets Node exit naturally with this code. | ||
| * @param {number|null} [options.maxBytes=null] - Optional maximum byte length | ||
| * (UTF-8, including the trailing newline) for the serialized packet. When | ||
| * exceeded, throws synchronously with code `packet_too_large` before any | ||
| * write is attempted. | ||
| * @param {NodeJS.WritableStream} [options.stream=process.stdout] - Stream to | ||
| * write to; injectable for tests. | ||
| * @param {((exitCode: number) => void)|null} [options.exit=null] - Optional | ||
| * hard-exit hook (e.g. `process.exit`), invoked only after the write | ||
| * callback confirms the flush. When omitted, no forced exit is triggered. | ||
| * @returns {Promise<{ok: true, bytes_written: number, exit_code: number}>} | ||
| */ | ||
| export function emitJsonPacket(packet, options = {}) { | ||
| const { | ||
| exitCode = 0, | ||
| maxBytes = null, | ||
| stream = process.stdout, | ||
| exit = null, | ||
| } = options; | ||
| if (!Number.isInteger(exitCode) || exitCode < 0) { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.INVALID_ARGS, | ||
| 'emitJsonPacket requires a non-negative integer "exitCode" option', | ||
| ); | ||
| } | ||
| if (maxBytes !== null && (!Number.isInteger(maxBytes) || maxBytes < 0)) { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.INVALID_ARGS, | ||
| 'emitJsonPacket "maxBytes" option must be a non-negative integer or null', | ||
| ); | ||
| } | ||
| if (exit !== null && typeof exit !== 'function') { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.INVALID_ARGS, | ||
| 'emitJsonPacket "exit" option must be a function or null', | ||
| ); | ||
| } | ||
| if (!stream || typeof stream.write !== 'function') { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.INVALID_ARGS, | ||
| 'emitJsonPacket "stream" option must be a writable stream', | ||
| ); | ||
| } | ||
| let serialized; | ||
| try { | ||
| serialized = JSON.stringify(packet); | ||
| } catch (cause) { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.PACKET_NOT_SERIALIZABLE, | ||
| `emitJsonPacket requires a JSON-serializable packet: ${cause.message}`, | ||
| ); | ||
| } | ||
| if (serialized === undefined) { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.PACKET_NOT_SERIALIZABLE, | ||
| 'emitJsonPacket requires a JSON-serializable packet (received a value that serializes to undefined)', | ||
| ); | ||
| } | ||
| const line = `${serialized}\n`; | ||
| const byteLength = Buffer.byteLength(line, 'utf8'); | ||
| if (maxBytes !== null && byteLength > maxBytes) { | ||
| throw cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.PACKET_TOO_LARGE, | ||
| `Serialized packet is ${byteLength} bytes, exceeding maxBytes limit of ${maxBytes}`, | ||
| { byteLength, maxBytes }, | ||
| ); | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| const onWriteSettled = (err) => { | ||
| if (err) { | ||
| reject(cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.WRITE_FAILED, | ||
| `emitJsonPacket failed to write packet to stream: ${err.message}`, | ||
| { cause: err.message }, | ||
| )); | ||
| return; | ||
| } | ||
| process.exitCode = exitCode; | ||
| const result = { ok: true, bytes_written: byteLength, exit_code: exitCode }; | ||
| resolve(result); | ||
| if (typeof exit === 'function') { | ||
| exit(exitCode); | ||
| } | ||
| }; | ||
| try { | ||
| stream.write(line, 'utf8', onWriteSettled); | ||
| } catch (err) { | ||
| reject(cliEmitError( | ||
| CLI_EMIT_ERROR_CODES.WRITE_FAILED, | ||
| `emitJsonPacket failed to write packet to stream: ${err.message}`, | ||
| { cause: err.message }, | ||
| )); | ||
| } | ||
| }); | ||
| } |
| import { existsSync, readFileSync, readdirSync } from 'node:fs'; | ||
| import { join, relative } from 'node:path'; | ||
| export const DEPENDENCY_PIN_ERROR_CODES = Object.freeze({ | ||
| INVALID_ARGS: 'invalid_args', | ||
| LOCKFILE_MISSING: 'lockfile_missing', | ||
| LOCKFILE_UNPARSEABLE: 'lockfile_unparseable', | ||
| PACKAGE_NOT_IN_LOCKFILE: 'package_not_in_lockfile', | ||
| PACKAGE_NOT_INSTALLED: 'package_not_installed', | ||
| INSTALLED_VERSION_MISMATCH: 'installed_version_mismatch', | ||
| MULTIPLE_INSTALLED_VERSIONS: 'multiple_installed_versions', | ||
| }); | ||
| function pinError(code, message, details = null) { | ||
| const error = new Error(message); | ||
| error.code = code; | ||
| if (details && typeof details === 'object') { | ||
| error.details = details; | ||
| } | ||
| return error; | ||
| } | ||
| function assertNonEmptyString(value, label) { | ||
| if (typeof value !== 'string' || value.trim().length === 0) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INVALID_ARGS, | ||
| `${label} must be a non-empty string`, | ||
| ); | ||
| } | ||
| } | ||
| function readJsonFile(filePath, errorCode, label) { | ||
| if (!existsSync(filePath)) { | ||
| throw pinError(errorCode, `${label} not found: ${filePath}`); | ||
| } | ||
| try { | ||
| return JSON.parse(readFileSync(filePath, 'utf8')); | ||
| } catch (cause) { | ||
| const error = pinError(errorCode, `${label} is not valid JSON: ${filePath}`); | ||
| error.cause = cause; | ||
| throw error; | ||
| } | ||
| } | ||
| function lockfileKeyForPackage(packageName) { | ||
| return `node_modules/${packageName}`; | ||
| } | ||
| function readPackageVersion(packageDir) { | ||
| const packageJsonPath = join(packageDir, 'package.json'); | ||
| const packageJson = readJsonFile( | ||
| packageJsonPath, | ||
| DEPENDENCY_PIN_ERROR_CODES.INSTALLED_VERSION_MISMATCH, | ||
| 'package.json', | ||
| ); | ||
| if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INSTALLED_VERSION_MISMATCH, | ||
| `package.json at ${packageDir} is missing a version`, | ||
| ); | ||
| } | ||
| return packageJson.version; | ||
| } | ||
| function collectInstalledInstances(packageRoot, packageName) { | ||
| const instances = []; | ||
| function walkModulesDir(modulesDir) { | ||
| if (!existsSync(modulesDir)) return; | ||
| for (const entry of readdirSync(modulesDir, { withFileTypes: true })) { | ||
| if (!entry.isDirectory() || entry.name === '.bin') continue; | ||
| if (entry.name.startsWith('@')) { | ||
| const scopeDir = join(modulesDir, entry.name); | ||
| for (const scopedEntry of readdirSync(scopeDir, { withFileTypes: true })) { | ||
| if (!scopedEntry.isDirectory()) continue; | ||
| const packageDir = join(scopeDir, scopedEntry.name); | ||
| const fullName = `${entry.name}/${scopedEntry.name}`; | ||
| if (fullName === packageName) { | ||
| instances.push({ | ||
| packageName: fullName, | ||
| version: readPackageVersion(packageDir), | ||
| lockPath: relative(packageRoot, packageDir).split('\\').join('/'), | ||
| }); | ||
| } | ||
| walkModulesDir(join(packageDir, 'node_modules')); | ||
| } | ||
| continue; | ||
| } | ||
| const packageDir = join(modulesDir, entry.name); | ||
| if (entry.name === packageName) { | ||
| instances.push({ | ||
| packageName: entry.name, | ||
| version: readPackageVersion(packageDir), | ||
| lockPath: relative(packageRoot, packageDir).split('\\').join('/'), | ||
| }); | ||
| } | ||
| walkModulesDir(join(packageDir, 'node_modules')); | ||
| } | ||
| } | ||
| walkModulesDir(join(packageRoot, 'node_modules')); | ||
| return instances; | ||
| } | ||
| function getLockfilePackageVersion(lockfile, packageName) { | ||
| const lockKey = lockfileKeyForPackage(packageName); | ||
| const entry = lockfile.packages?.[lockKey]; | ||
| if (!entry || typeof entry.version !== 'string' || entry.version.length === 0) { | ||
| return null; | ||
| } | ||
| return entry.version; | ||
| } | ||
| function assertPackagePins({ | ||
| packageRoot, | ||
| packageName, | ||
| lockfile, | ||
| }) { | ||
| const expectedVersion = getLockfilePackageVersion(lockfile, packageName); | ||
| if (!expectedVersion) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.PACKAGE_NOT_IN_LOCKFILE, | ||
| `Security package "${packageName}" is missing from package-lock.json`, | ||
| { package: packageName, lockPath: lockfileKeyForPackage(packageName) }, | ||
| ); | ||
| } | ||
| const instances = collectInstalledInstances(packageRoot, packageName); | ||
| if (instances.length === 0) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.PACKAGE_NOT_INSTALLED, | ||
| `Security package "${packageName}" is not installed under node_modules`, | ||
| { package: packageName, expectedVersion }, | ||
| ); | ||
| } | ||
| const installedVersions = [...new Set(instances.map((instance) => instance.version))]; | ||
| if (installedVersions.length > 1) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.MULTIPLE_INSTALLED_VERSIONS, | ||
| `Security package "${packageName}" has multiple installed versions: ${installedVersions.join(', ')}`, | ||
| { | ||
| package: packageName, | ||
| expectedVersion, | ||
| installedVersions, | ||
| instances, | ||
| }, | ||
| ); | ||
| } | ||
| const [installedVersion] = installedVersions; | ||
| if (installedVersion !== expectedVersion) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INSTALLED_VERSION_MISMATCH, | ||
| `Security package "${packageName}" is installed as ${installedVersion}, but package-lock.json pins ${expectedVersion}`, | ||
| { | ||
| package: packageName, | ||
| expectedVersion, | ||
| installedVersion, | ||
| instances, | ||
| }, | ||
| ); | ||
| } | ||
| for (const instance of instances) { | ||
| const lockEntry = lockfile.packages?.[instance.lockPath]; | ||
| if (!lockEntry?.version) continue; | ||
| if (lockEntry.version !== instance.version) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INSTALLED_VERSION_MISMATCH, | ||
| `Security package "${packageName}" at ${instance.lockPath} is installed as ${instance.version}, but package-lock.json pins ${lockEntry.version}`, | ||
| { | ||
| package: packageName, | ||
| lockPath: instance.lockPath, | ||
| expectedVersion: lockEntry.version, | ||
| installedVersion: instance.version, | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
| return { | ||
| package: packageName, | ||
| expectedVersion, | ||
| installedVersion, | ||
| instances, | ||
| }; | ||
| } | ||
| export function assertInstalledSecurityPins({ | ||
| packageRoot, | ||
| packages, | ||
| } = {}) { | ||
| assertNonEmptyString(packageRoot, 'packageRoot'); | ||
| if (!Array.isArray(packages) || packages.length === 0) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INVALID_ARGS, | ||
| 'packages must be a non-empty array of package names', | ||
| ); | ||
| } | ||
| const normalizedPackages = packages.map((packageName, index) => { | ||
| if (typeof packageName !== 'string' || packageName.trim().length === 0) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.INVALID_ARGS, | ||
| `packages[${index}] must be a non-empty string`, | ||
| ); | ||
| } | ||
| return packageName.trim(); | ||
| }); | ||
| const lockfile = readJsonFile( | ||
| join(packageRoot, 'package-lock.json'), | ||
| DEPENDENCY_PIN_ERROR_CODES.LOCKFILE_MISSING, | ||
| 'package-lock.json', | ||
| ); | ||
| if (!lockfile || typeof lockfile !== 'object' || !lockfile.packages) { | ||
| throw pinError( | ||
| DEPENDENCY_PIN_ERROR_CODES.LOCKFILE_UNPARSEABLE, | ||
| 'package-lock.json must contain a packages object', | ||
| ); | ||
| } | ||
| const results = normalizedPackages.map((packageName) => assertPackagePins({ | ||
| packageRoot, | ||
| packageName, | ||
| lockfile, | ||
| })); | ||
| return { | ||
| ok: true, | ||
| packageRoot, | ||
| packages: results, | ||
| }; | ||
| } |
+5
-3
| { | ||
| "name": "attepack", | ||
| "version": "0.1.1", | ||
| "version": "0.2.1", | ||
| "description": "Shared library for packet envelopes, schema contracts, and error packet normalization across the Forest tool ecosystem", | ||
@@ -19,3 +19,5 @@ "type": "module", | ||
| "./diagnostics": "./src/diagnostics.js", | ||
| "./evidence-protocol": "./src/evidence-protocol.js" | ||
| "./cli-emit": "./src/cli-emit.js", | ||
| "./evidence-protocol": "./src/evidence-protocol.js", | ||
| "./dependency-pins": "./src/dependency-pins.js" | ||
| }, | ||
@@ -26,3 +28,3 @@ "scripts": { | ||
| "dependencies": { | ||
| "atteguard": "0.2.0" | ||
| "atteguard": "0.2.1" | ||
| }, | ||
@@ -29,0 +31,0 @@ "keywords": [ |
+184
-2
@@ -15,3 +15,9 @@ # Attepack | ||
| | `attepack/envelope` | Enforces packet envelope validation (`type`, `schema_version`, `observed_at`, `ok`) | | ||
| | `attepack/errors` | Known error codes plus `buildFormattedError` for stable `{ code, message, details? }` shapes | | ||
| | `attepack/sanitize` | Redacts sensitive text from error messages and allowlists error detail fields | | ||
| | `attepack/receipts` | Builds and validates mutation receipts bound to SHA-256 digests | | ||
| | `attepack/diagnostics` | Builds diagnostic events and validates fail-closed sink command argv | | ||
| | `attepack/cli-emit` | Fail-closed CLI JSON emit helper: flush-before-exit and a typed too-large error | | ||
| | `attepack/evidence-protocol` | Validates neutral producer-native evidence packet conformance without product-specific schemas | | ||
| | `attepack/dependency-pins` | Asserts installed security packages match `package-lock.json` across nested `node_modules` | | ||
@@ -37,2 +43,57 @@ ## Install | ||
| Error formatting: | ||
| ```js | ||
| import { ATTEPACK_ERROR_CODES, buildFormattedError } from 'attepack/errors'; | ||
| const error = buildFormattedError( | ||
| ATTEPACK_ERROR_CODES.INVALID_ARGS, | ||
| 'Missing required field', | ||
| { field: 'schema_version' } | ||
| ); | ||
| // { code: 'invalid_args', message: 'Missing required field', details: { field: 'schema_version' } } | ||
| ``` | ||
| Message and details sanitization: | ||
| ```js | ||
| import { sanitizeErrorMessage, sanitizeErrorDetails } from 'attepack/sanitize'; | ||
| const message = sanitizeErrorMessage('failed under a local home path'); | ||
| const details = sanitizeErrorDetails({ | ||
| field: 'credential_field', | ||
| reason: 'rejected', | ||
| value: 'redacted-before-call' | ||
| }); | ||
| ``` | ||
| Mutation receipts: | ||
| ```js | ||
| import { buildMutationReceipt } from 'attepack/receipts'; | ||
| const digest = 'a'.repeat(64); | ||
| const receipt = buildMutationReceipt({ | ||
| mutationKind: 'cr_merged', | ||
| identityDigest: digest, | ||
| intentDigest: digest, | ||
| providerId: 'gitea-api', | ||
| remoteName: 'origin', | ||
| repoId: 'example/tool' | ||
| }); | ||
| ``` | ||
| Diagnostic events: | ||
| ```js | ||
| import { buildDiagnosticEvent } from 'attepack/diagnostics'; | ||
| const event = buildDiagnosticEvent({ | ||
| tool: 'example-tool', | ||
| command: 'doctor', | ||
| failureClass: 'config_invalid', | ||
| details: { field: 'baseUrl' } | ||
| }); | ||
| ``` | ||
| ## Neutral Evidence Protocol | ||
@@ -56,3 +117,124 @@ | ||
| Attepack rejects legacy producer-section fields by default. To reject coupling | ||
| to a downstream product namespace, pass `forbiddenProductNamespaces`. | ||
| Attepack rejects legacy producer-section fields and the `atteway` product | ||
| namespace by default. Override with `forbiddenProductNamespaces` when a caller | ||
| needs a different forbid-list. | ||
| The same module also owns neutral, product-independent evidence binding | ||
| mechanics for guidance-lifecycle packets. Producers can use these primitives to | ||
| bind packets to public repository identity, exact candidate base/head SHAs, | ||
| merged source commits, capability-contract digests, source-artifact digests, | ||
| parent evidence digests, multi-target receipt summaries, safe relative | ||
| evidence references, and explicit authority limits without creating | ||
| Skillpress-, Verigram-, Waylane-, or Atteway-specific envelope forks. | ||
| ```js | ||
| import { | ||
| calculateEvidenceDigest, | ||
| validateNeutralEvidencePacket, | ||
| } from 'attepack/evidence-protocol'; | ||
| const impact = { | ||
| type: 'skillpress.guidance_impact.v1', | ||
| schema_version: 1, | ||
| ok: true, | ||
| producer: { id: 'skillpress' }, | ||
| repository: { id: 'attebury/skillpress' }, | ||
| candidate: { | ||
| base_sha: '1111111111111111111111111111111111111111', | ||
| head_sha: '2222222222222222222222222222222222222222', | ||
| }, | ||
| capability_contracts: [{ | ||
| capability_id: 'skillpress.capability_contracts', | ||
| digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', | ||
| }], | ||
| source_artifacts: [{ | ||
| artifact_id: 'skillpress-repo-skill', | ||
| digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', | ||
| }], | ||
| authority: { | ||
| role: 'advisory', | ||
| owns: ['guidance_impact'], | ||
| not_authority_for: ['merge', 'release', 'closeout', 'software_correctness'], | ||
| }, | ||
| limitations: [{ code: 'semantic_review_not_included' }], | ||
| evidence_refs: ['evidence/skillpress-impact.json'], | ||
| }; | ||
| impact.evidence_digest = calculateEvidenceDigest(impact); | ||
| validateNeutralEvidencePacket(impact, { bindingProfile: 'candidate' }); | ||
| ``` | ||
| Use `bindingProfile: 'candidate'` for pre-merge candidate evidence, | ||
| `bindingProfile: 'semantic_review'` for candidate-bound review receipts that | ||
| chain to parent evidence, and `bindingProfile: 'merged_source'` for | ||
| merged-commit receipts with configured/attempted/verified target-set summaries. | ||
| The digest is deterministic canonical JSON with sorted object keys and bounded | ||
| arrays, strings, objects, and depth. | ||
| ## Security dependency pins | ||
| Install with `npm ci`, then assert the installed tree matches the lockfile for | ||
| security-sensitive packages such as `atteguard` and `attepack`: | ||
| ```js | ||
| import { assertInstalledSecurityPins } from 'attepack/dependency-pins'; | ||
| assertInstalledSecurityPins({ | ||
| packageRoot: process.cwd(), | ||
| packages: ['atteguard', 'attepack'], | ||
| }); | ||
| ``` | ||
| Use this in CI gates and `doctor` checks so a stale or nested duplicate install | ||
| cannot pass while the lockfile documents a different version. | ||
| ## Fail-closed CLI JSON emit | ||
| `console.log(JSON.stringify(packet)); process.exit(0)` can race the async | ||
| flush of `process.stdout` when stdout is a pipe, letting a CLI exit `0` while | ||
| emitting truncated, unparseable JSON. `emitJsonPacket` closes that race: it | ||
| only resolves (and only ever calls an injected exit hook) after the | ||
| stream's write callback confirms the data was flushed. | ||
| ```js | ||
| import { emitJsonPacket } from 'attepack/cli-emit'; | ||
| await emitJsonPacket( | ||
| { type: 'wayline.verify.v1', ok: true, validity: 'authoritative' }, | ||
| { exitCode: 0 }, | ||
| ); | ||
| // process.exitCode is now set to 0; Node exits naturally once the packet | ||
| // is confirmed flushed, so exit status is only ever reported after the | ||
| // write completes. | ||
| ``` | ||
| Pass `maxBytes` to fail closed on oversized packets instead of emitting a | ||
| partial payload; this throws synchronously with a typed error before any | ||
| write is attempted: | ||
| ```js | ||
| import { emitJsonPacket, CLI_EMIT_ERROR_CODES } from 'attepack/cli-emit'; | ||
| try { | ||
| await emitJsonPacket(packet, { maxBytes: 1_000_000 }); | ||
| } catch (error) { | ||
| if (error.code === CLI_EMIT_ERROR_CODES.PACKET_TOO_LARGE) { | ||
| // error.details.byteLength / error.details.maxBytes | ||
| } | ||
| } | ||
| ``` | ||
| By default `emitJsonPacket` never forces `process.exit`; it sets | ||
| `process.exitCode` after the flush is confirmed and lets Node exit | ||
| naturally, which is sufficient to guarantee ordering. Callers that need a | ||
| hard exit (e.g. to sidestep lingering handles) can opt in with an `exit` | ||
| hook, which is still only invoked after the flush callback fires: | ||
| ```js | ||
| await emitJsonPacket(packet, { exitCode: 1, exit: process.exit }); | ||
| ``` | ||
| Note that exit status alone is not proof that stdout was not truncated by | ||
| something downstream of this helper (e.g. a consumer closing the pipe | ||
| early); `emitJsonPacket` only guarantees ordering between the write and the | ||
| exit signal it controls. |
+459
-1
@@ -0,1 +1,2 @@ | ||
| import crypto from 'node:crypto'; | ||
| import { validatePacketEnvelope } from './envelope.js'; | ||
@@ -5,2 +6,3 @@ | ||
| export const NEUTRAL_EVIDENCE_CONFORMANCE_PACKET_TYPE = 'neutral.evidence_packet_conformance.v1'; | ||
| export const NEUTRAL_EVIDENCE_DIGEST_PREFIX = 'sha256:'; | ||
| export const NEUTRAL_EVIDENCE_AUTHORITY_ROLES = Object.freeze([ | ||
@@ -13,3 +15,8 @@ 'authoritative', | ||
| ]); | ||
| export const DEFAULT_FORBIDDEN_PRODUCT_NAMESPACES = Object.freeze([]); | ||
| export const NEUTRAL_EVIDENCE_BINDING_PROFILES = Object.freeze([ | ||
| 'candidate', | ||
| 'merged_source', | ||
| 'semantic_review', | ||
| ]); | ||
| export const DEFAULT_FORBIDDEN_PRODUCT_NAMESPACES = Object.freeze(['atteway']); | ||
| export const LEGACY_PRODUCER_SECTION_FIELDS = Object.freeze([ | ||
@@ -21,5 +28,27 @@ 'section_id', | ||
| 'producer_sections', | ||
| 'atteway_section', | ||
| ]); | ||
| const PRODUCT_COUPLING_FIELD_PATTERN = /(?:^|_)(?:type|schema|section|corpus|golden|contract|fixture|packet)(?:_|$)/i; | ||
| const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/; | ||
| const GIT_SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i; | ||
| const PUBLIC_REPO_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i; | ||
| const SAFE_ID = /^[a-z][a-z0-9._:-]{0,127}$/; | ||
| const SAFE_REASON = /^[a-z][a-z0-9_:-]{0,127}$/; | ||
| const SAFE_REF = /^[a-z0-9][a-z0-9._/-]{0,255}$/i; | ||
| const MAX_CANONICAL_DEPTH = 32; | ||
| const MAX_CANONICAL_ARRAY_ITEMS = 128; | ||
| const MAX_CANONICAL_OBJECT_KEYS = 128; | ||
| const MAX_CANONICAL_STRING_BYTES = 4096; | ||
| const MAX_CANONICAL_BYTES = 262144; | ||
| const MAX_DIGEST_REF_ITEMS = 64; | ||
| const MAX_TARGET_SET_ITEMS = 64; | ||
| const AUTHORITY_FORBIDDEN_CLAIMS = Object.freeze([ | ||
| 'merge_authority', | ||
| 'closeout_authority', | ||
| 'release_authority', | ||
| 'software_correctness', | ||
| 'work_judgment', | ||
| 'forge_truth', | ||
| ]); | ||
@@ -42,2 +71,6 @@ function isObject(value) { | ||
| function add(issues, code, path, message) { | ||
| issues.push(issue(code, path, message)); | ||
| } | ||
| function namespacePattern(namespace) { | ||
@@ -86,2 +119,412 @@ const escaped = String(namespace).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| function canonicalizationError(code, message, path = '$') { | ||
| const error = new TypeError(message); | ||
| error.code = code; | ||
| error.path = path; | ||
| return error; | ||
| } | ||
| function canonicalize(value, path = '$', depth = 0, seen = new Set()) { | ||
| if (depth > MAX_CANONICAL_DEPTH) { | ||
| throw canonicalizationError('neutral_canonical_depth_exceeded', 'Neutral evidence value exceeds maximum canonicalization depth.', path); | ||
| } | ||
| if (value === null) return 'null'; | ||
| if (typeof value === 'boolean') return value ? 'true' : 'false'; | ||
| if (typeof value === 'number') { | ||
| if (!Number.isFinite(value)) { | ||
| throw canonicalizationError('neutral_canonical_nonfinite_number', 'Neutral evidence numbers must be finite.', path); | ||
| } | ||
| return JSON.stringify(value); | ||
| } | ||
| if (typeof value === 'string') { | ||
| if (Buffer.byteLength(value, 'utf8') > MAX_CANONICAL_STRING_BYTES) { | ||
| throw canonicalizationError('neutral_canonical_string_oversized', 'Neutral evidence string exceeds maximum canonicalization size.', path); | ||
| } | ||
| return JSON.stringify(value); | ||
| } | ||
| if (Array.isArray(value)) { | ||
| if (value.length > MAX_CANONICAL_ARRAY_ITEMS) { | ||
| throw canonicalizationError('neutral_canonical_array_oversized', 'Neutral evidence array exceeds maximum canonicalization size.', path); | ||
| } | ||
| return `[${value.map((item, index) => canonicalize(item, `${path}[${index}]`, depth + 1, seen)).join(',')}]`; | ||
| } | ||
| if (isObject(value)) { | ||
| if (seen.has(value)) { | ||
| throw canonicalizationError('neutral_canonical_circular_reference', 'Neutral evidence value contains a circular reference.', path); | ||
| } | ||
| seen.add(value); | ||
| try { | ||
| const keys = Object.keys(value).sort(); | ||
| if (keys.length > MAX_CANONICAL_OBJECT_KEYS) { | ||
| throw canonicalizationError('neutral_canonical_object_oversized', 'Neutral evidence object exceeds maximum canonicalization size.', path); | ||
| } | ||
| const parts = keys.map((key) => { | ||
| if (value[key] === undefined) { | ||
| throw canonicalizationError('neutral_canonical_undefined', 'Neutral evidence objects must not contain undefined values.', `${path}.${key}`); | ||
| } | ||
| return `${JSON.stringify(key)}:${canonicalize(value[key], `${path}.${key}`, depth + 1, seen)}`; | ||
| }); | ||
| return `{${parts.join(',')}}`; | ||
| } finally { | ||
| seen.delete(value); | ||
| } | ||
| } | ||
| throw canonicalizationError('neutral_canonical_unsupported_type', 'Neutral evidence value contains an unsupported type.', path); | ||
| } | ||
| function rootDigestInput(packet) { | ||
| if (!isObject(packet)) return packet; | ||
| const copy = {}; | ||
| for (const key of Object.keys(packet)) { | ||
| if (key === 'evidence_digest') continue; | ||
| copy[key] = packet[key]; | ||
| } | ||
| return copy; | ||
| } | ||
| export function canonicalizeNeutralJson(value) { | ||
| const canonical = canonicalize(value); | ||
| if (Buffer.byteLength(canonical, 'utf8') > MAX_CANONICAL_BYTES) { | ||
| throw canonicalizationError('neutral_canonical_packet_oversized', 'Neutral evidence canonical JSON exceeds maximum byte size.', '$'); | ||
| } | ||
| return canonical; | ||
| } | ||
| export function calculateEvidenceDigest(value, options = {}) { | ||
| const input = options.omitRootEvidenceDigest === false ? value : rootDigestInput(value); | ||
| const canonical = canonicalizeNeutralJson(input); | ||
| return `${NEUTRAL_EVIDENCE_DIGEST_PREFIX}${crypto.createHash('sha256').update(canonical).digest('hex')}`; | ||
| } | ||
| export function isSha256Digest(value) { | ||
| return typeof value === 'string' && SHA256_DIGEST.test(value); | ||
| } | ||
| export function isPublicRepositoryId(value) { | ||
| return typeof value === 'string' && PUBLIC_REPO_ID.test(value); | ||
| } | ||
| export function isGitCommitSha(value) { | ||
| return typeof value === 'string' && GIT_SHA.test(value); | ||
| } | ||
| export function isSafeEvidenceRef(value) { | ||
| if (typeof value !== 'string' || !SAFE_REF.test(value)) return false; | ||
| if (value.startsWith('/') || value.includes('..') || value.includes('//') || value.includes('~')) return false; | ||
| if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false; | ||
| return true; | ||
| } | ||
| function validateRepository(packet, issues, options) { | ||
| const repoId = packet?.repository?.id ?? packet?.repo_id ?? null; | ||
| if (options.requireRepository && repoId == null) { | ||
| add(issues, 'repository_id_missing', '$.repository.id', 'Neutral evidence must declare a stable public repository identity.'); | ||
| return; | ||
| } | ||
| if (repoId != null && !isPublicRepositoryId(repoId)) { | ||
| add(issues, 'repository_id_invalid', '$.repository.id', 'Repository identity must be a bounded public owner/repo id.'); | ||
| } | ||
| if (options.expectedRepositoryId && repoId !== options.expectedRepositoryId) { | ||
| add(issues, 'repository_id_mismatch', '$.repository.id', 'Repository identity does not match expected target.'); | ||
| } | ||
| } | ||
| function validateCandidate(packet, issues, options) { | ||
| const candidate = packet?.candidate; | ||
| if (options.requireCandidate && !isObject(candidate)) { | ||
| add(issues, 'candidate_binding_missing', '$.candidate', 'Candidate evidence must bind exact base and head SHAs.'); | ||
| return; | ||
| } | ||
| if (!isObject(candidate)) return; | ||
| if (!isGitCommitSha(candidate.base_sha)) { | ||
| add(issues, 'candidate_base_sha_invalid', '$.candidate.base_sha', 'Candidate base_sha must be a full Git SHA.'); | ||
| } | ||
| if (!isGitCommitSha(candidate.head_sha)) { | ||
| add(issues, 'candidate_head_sha_invalid', '$.candidate.head_sha', 'Candidate head_sha must be a full Git SHA.'); | ||
| } | ||
| if (candidate.base_sha === candidate.head_sha) { | ||
| add(issues, 'candidate_sha_ambiguous', '$.candidate', 'Candidate base_sha and head_sha must be distinct.'); | ||
| } | ||
| if (options.expectedBaseSha && candidate.base_sha !== options.expectedBaseSha) { | ||
| add(issues, 'candidate_base_sha_mismatch', '$.candidate.base_sha', 'Candidate base_sha does not match expected target.'); | ||
| } | ||
| if (options.expectedHeadSha && candidate.head_sha !== options.expectedHeadSha) { | ||
| add(issues, 'candidate_head_sha_mismatch', '$.candidate.head_sha', 'Candidate head_sha does not match expected target.'); | ||
| } | ||
| } | ||
| function validateMergedSource(packet, issues, options) { | ||
| const merged = packet?.merged_source; | ||
| if (options.requireMergedSource && !isObject(merged)) { | ||
| add(issues, 'merged_source_binding_missing', '$.merged_source', 'Merged-source evidence must bind an exact source commit.'); | ||
| return; | ||
| } | ||
| if (!isObject(merged)) return; | ||
| if (!isGitCommitSha(merged.commit_sha)) { | ||
| add(issues, 'merged_source_commit_sha_invalid', '$.merged_source.commit_sha', 'Merged-source commit_sha must be a full Git SHA.'); | ||
| } | ||
| if (options.expectedCommitSha && merged.commit_sha !== options.expectedCommitSha) { | ||
| add(issues, 'merged_source_commit_sha_mismatch', '$.merged_source.commit_sha', 'Merged-source commit_sha does not match expected target.'); | ||
| } | ||
| } | ||
| function validateDigestRefs(values, field, idField, digestField, issues) { | ||
| if (values == null) return; | ||
| if (!Array.isArray(values)) { | ||
| add(issues, `${field}_invalid`, `$.${field}`, `${field} must be an array.`); | ||
| return; | ||
| } | ||
| if (values.length > MAX_DIGEST_REF_ITEMS) { | ||
| add(issues, `${field}_oversized`, `$.${field}`, `${field} exceeds the maximum item count.`); | ||
| return; | ||
| } | ||
| const seen = new Set(); | ||
| values.forEach((item, index) => { | ||
| const path = `$.${field}[${index}]`; | ||
| if (!isObject(item)) { | ||
| add(issues, `${field}_item_invalid`, path, `${field} entries must be objects.`); | ||
| return; | ||
| } | ||
| const id = item[idField]; | ||
| const digest = item[digestField]; | ||
| if (typeof id !== 'string' || !SAFE_ID.test(id)) { | ||
| add(issues, `${field}_id_invalid`, `${path}.${idField}`, `${field} entry id must be a bounded safe identifier.`); | ||
| } | ||
| if (!isSha256Digest(digest)) { | ||
| add(issues, `${field}_digest_invalid`, `${path}.${digestField}`, `${field} entry digest must be sha256:<64 hex>`); | ||
| } | ||
| const key = `${id}:${digest}`; | ||
| if (seen.has(key)) { | ||
| add(issues, `${field}_duplicate`, path, `${field} entries must be unambiguous.`); | ||
| } | ||
| seen.add(key); | ||
| }); | ||
| } | ||
| function validateParentEvidence(packet, issues, options) { | ||
| const parentEvidence = packet?.parent_evidence ?? null; | ||
| const inputEvidence = packet?.input_evidence ?? null; | ||
| if (options.requireParentEvidence && parentEvidence == null && inputEvidence == null) { | ||
| add(issues, 'parent_evidence_missing', '$.parent_evidence', 'Evidence chaining requires parent_evidence digest references.'); | ||
| return; | ||
| } | ||
| validateDigestRefs(parentEvidence, 'parent_evidence', 'type', 'digest', issues); | ||
| validateDigestRefs(inputEvidence, 'input_evidence', 'type', 'digest', issues); | ||
| if (options.expectedParentDigest) { | ||
| const values = [ | ||
| ...(Array.isArray(parentEvidence) ? parentEvidence : []), | ||
| ...(Array.isArray(inputEvidence) ? inputEvidence : []), | ||
| ]; | ||
| const found = values.some((item) => item?.digest === options.expectedParentDigest); | ||
| if (!found) { | ||
| add(issues, 'parent_evidence_digest_mismatch', '$.parent_evidence', 'Expected parent evidence digest was not present.'); | ||
| } | ||
| } | ||
| } | ||
| function validateTargetSetArray(values, field, issues) { | ||
| if (!Array.isArray(values)) { | ||
| add(issues, 'target_set_invalid', `$.target_sets.${field}`, 'Target-set summaries must be arrays.'); | ||
| return; | ||
| } | ||
| if (values.length > MAX_TARGET_SET_ITEMS) { | ||
| add(issues, 'target_set_oversized', `$.target_sets.${field}`, 'Target-set summary exceeds the maximum item count.'); | ||
| return; | ||
| } | ||
| const seen = new Set(); | ||
| values.forEach((item, index) => { | ||
| const path = `$.target_sets.${field}[${index}]`; | ||
| const id = typeof item === 'string' ? item : item?.id; | ||
| if (typeof id !== 'string' || !SAFE_ID.test(id)) { | ||
| add(issues, 'target_set_id_invalid', path, 'Target-set ids must be bounded safe identifiers.'); | ||
| return; | ||
| } | ||
| if (seen.has(id)) { | ||
| add(issues, 'target_set_duplicate', path, 'Target-set ids must be unique within each set.'); | ||
| } | ||
| seen.add(id); | ||
| if (isObject(item) && item.digest !== undefined && !isSha256Digest(item.digest)) { | ||
| add(issues, 'target_set_digest_invalid', `${path}.digest`, 'Target-set digests must be sha256:<64 hex> when present.'); | ||
| } | ||
| }); | ||
| } | ||
| function validateTargetSets(packet, issues, options) { | ||
| const targetSets = packet?.target_sets; | ||
| if (options.requireTargetSets && !isObject(targetSets)) { | ||
| add(issues, 'target_sets_missing', '$.target_sets', 'Multi-target receipts must summarize configured, attempted, and verified target sets.'); | ||
| return; | ||
| } | ||
| if (!isObject(targetSets)) return; | ||
| for (const field of ['configured', 'attempted', 'verified']) { | ||
| validateTargetSetArray(targetSets[field], field, issues); | ||
| } | ||
| if (Array.isArray(targetSets.verified) && Array.isArray(targetSets.configured)) { | ||
| const configured = new Set(targetSets.configured.map((item) => (typeof item === 'string' ? item : item?.id))); | ||
| for (const item of targetSets.verified) { | ||
| const id = typeof item === 'string' ? item : item?.id; | ||
| if (id && !configured.has(id)) { | ||
| add(issues, 'target_set_verified_not_configured', '$.target_sets.verified', 'Verified targets must be part of the configured target set.'); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| function validateEvidenceRefs(packet, issues) { | ||
| if (packet?.evidence_refs == null) return; | ||
| if (!Array.isArray(packet.evidence_refs)) { | ||
| add(issues, 'evidence_refs_invalid', '$.evidence_refs', 'Evidence refs must be an array.'); | ||
| return; | ||
| } | ||
| if (packet.evidence_refs.length > MAX_DIGEST_REF_ITEMS) { | ||
| add(issues, 'evidence_refs_oversized', '$.evidence_refs', 'Evidence refs exceed maximum item count.'); | ||
| return; | ||
| } | ||
| packet.evidence_refs.forEach((ref, index) => { | ||
| if (!isSafeEvidenceRef(ref)) { | ||
| add(issues, 'evidence_ref_unsafe', `$.evidence_refs[${index}]`, 'Evidence refs must be bounded safe relative references.'); | ||
| } | ||
| }); | ||
| } | ||
| function validateAuthorityDeclarations(packet, issues, options) { | ||
| const authority = packet?.authority; | ||
| if (options.requireAuthority && !isObject(authority)) { | ||
| add(issues, 'authority_declaration_missing', '$.authority', 'Neutral evidence must declare authority and limits.'); | ||
| return; | ||
| } | ||
| if (!isObject(authority)) return; | ||
| const notAuthorityFor = authority.not_authority_for ?? packet.not_authority_for ?? null; | ||
| if (!Array.isArray(notAuthorityFor) || notAuthorityFor.length === 0) { | ||
| add(issues, 'authority_limitations_missing', '$.authority.not_authority_for', 'Evidence authority declarations must include not_authority_for limits.'); | ||
| } else { | ||
| notAuthorityFor.forEach((value, index) => { | ||
| if (typeof value !== 'string' || !SAFE_REASON.test(value)) { | ||
| add(issues, 'authority_limitation_invalid', `$.authority.not_authority_for[${index}]`, 'Authority limitations must be bounded safe tokens.'); | ||
| } | ||
| }); | ||
| } | ||
| const claims = [ | ||
| ...(Array.isArray(authority.owns) ? authority.owns : []), | ||
| ...(Array.isArray(authority.grants) ? authority.grants : []), | ||
| ]; | ||
| claims.forEach((claim, index) => { | ||
| if (typeof claim !== 'string' || !SAFE_REASON.test(claim)) { | ||
| add(issues, 'authority_claim_invalid', `$.authority.owns[${index}]`, 'Authority claims must be bounded safe tokens.'); | ||
| return; | ||
| } | ||
| if (AUTHORITY_FORBIDDEN_CLAIMS.includes(claim)) { | ||
| add(issues, 'authority_claim_escalation', `$.authority.owns[${index}]`, `Authority claim "${claim}" exceeds neutral evidence ownership.`); | ||
| } | ||
| }); | ||
| } | ||
| function validateLimitations(packet, issues) { | ||
| if (packet?.limitations == null) return; | ||
| if (!Array.isArray(packet.limitations)) { | ||
| add(issues, 'limitations_invalid', '$.limitations', 'Limitations must be an array.'); | ||
| return; | ||
| } | ||
| if (packet.limitations.length > MAX_DIGEST_REF_ITEMS) { | ||
| add(issues, 'limitations_oversized', '$.limitations', 'Limitations exceed maximum item count.'); | ||
| return; | ||
| } | ||
| packet.limitations.forEach((limitation, index) => { | ||
| const path = `$.limitations[${index}]`; | ||
| const code = typeof limitation === 'string' ? limitation : limitation?.code; | ||
| if (typeof code !== 'string' || !SAFE_REASON.test(code)) { | ||
| add(issues, 'limitation_code_invalid', path, 'Limitation codes must be bounded safe tokens.'); | ||
| } | ||
| }); | ||
| } | ||
| function validateEvidenceDigest(packet, issues, options) { | ||
| if (options.requireEvidenceDigest && packet?.evidence_digest == null) { | ||
| add(issues, 'evidence_digest_missing', '$.evidence_digest', 'Evidence packets must include a deterministic evidence_digest when required.'); | ||
| return; | ||
| } | ||
| if (packet?.evidence_digest == null) return; | ||
| if (!isSha256Digest(packet.evidence_digest)) { | ||
| add(issues, 'evidence_digest_invalid', '$.evidence_digest', 'evidence_digest must be sha256:<64 hex>.'); | ||
| return; | ||
| } | ||
| let calculated; | ||
| try { | ||
| calculated = calculateEvidenceDigest(packet); | ||
| } catch (error) { | ||
| add(issues, error.code ?? 'evidence_digest_calculation_failed', error.path ?? '$', error.message); | ||
| return; | ||
| } | ||
| if (packet.evidence_digest !== calculated) { | ||
| add(issues, 'evidence_digest_mismatch', '$.evidence_digest', 'evidence_digest does not match deterministic canonical packet digest.'); | ||
| } | ||
| } | ||
| function requirementsForProfile(profile) { | ||
| if (profile == null) return {}; | ||
| if (!NEUTRAL_EVIDENCE_BINDING_PROFILES.includes(profile)) { | ||
| return { invalidProfile: true }; | ||
| } | ||
| if (profile === 'candidate') { | ||
| return { | ||
| requireRepository: true, | ||
| requireCandidate: true, | ||
| requireAuthority: true, | ||
| requireEvidenceDigest: true, | ||
| }; | ||
| } | ||
| if (profile === 'merged_source') { | ||
| return { | ||
| requireRepository: true, | ||
| requireMergedSource: true, | ||
| requireAuthority: true, | ||
| requireEvidenceDigest: true, | ||
| requireTargetSets: true, | ||
| }; | ||
| } | ||
| return { | ||
| requireRepository: true, | ||
| requireCandidate: true, | ||
| requireAuthority: true, | ||
| requireEvidenceDigest: true, | ||
| requireParentEvidence: true, | ||
| }; | ||
| } | ||
| function collectBindingIssues(packet, options) { | ||
| const issues = []; | ||
| const profileRequirements = requirementsForProfile(options.bindingProfile); | ||
| if (profileRequirements.invalidProfile) { | ||
| add(issues, 'binding_profile_invalid', '$', 'Neutral evidence binding profile is not supported.'); | ||
| return issues; | ||
| } | ||
| const checks = { | ||
| ...profileRequirements, | ||
| requireRepository: options.requireRepository === true || profileRequirements.requireRepository === true, | ||
| requireCandidate: options.requireCandidate === true || profileRequirements.requireCandidate === true, | ||
| requireMergedSource: options.requireMergedSource === true || profileRequirements.requireMergedSource === true, | ||
| requireParentEvidence: options.requireParentEvidence === true || profileRequirements.requireParentEvidence === true, | ||
| requireTargetSets: options.requireTargetSets === true || profileRequirements.requireTargetSets === true, | ||
| requireAuthority: options.requireAuthority === true || profileRequirements.requireAuthority === true, | ||
| requireEvidenceDigest: options.requireEvidenceDigest === true || profileRequirements.requireEvidenceDigest === true, | ||
| expectedRepositoryId: options.expectedRepositoryId, | ||
| expectedBaseSha: options.expectedBaseSha, | ||
| expectedHeadSha: options.expectedHeadSha, | ||
| expectedCommitSha: options.expectedCommitSha, | ||
| expectedParentDigest: options.expectedParentDigest, | ||
| }; | ||
| validateRepository(packet, issues, checks); | ||
| validateCandidate(packet, issues, checks); | ||
| validateMergedSource(packet, issues, checks); | ||
| validateDigestRefs(packet?.capability_contracts, 'capability_contracts', 'capability_id', 'digest', issues); | ||
| validateDigestRefs(packet?.source_artifacts, 'source_artifacts', 'artifact_id', 'digest', issues); | ||
| validateParentEvidence(packet, issues, checks); | ||
| validateTargetSets(packet, issues, checks); | ||
| validateEvidenceRefs(packet, issues); | ||
| validateAuthorityDeclarations(packet, issues, checks); | ||
| validateLimitations(packet, issues); | ||
| validateEvidenceDigest(packet, issues, checks); | ||
| return issues; | ||
| } | ||
| function collectEnvelopeIssues(packet, options) { | ||
@@ -120,2 +563,15 @@ const issues = []; | ||
| forbiddenProductNamespaces: Array.from(forbiddenProductNamespaces), | ||
| bindingProfile: options.bindingProfile ?? null, | ||
| requireRepository: options.requireRepository === true, | ||
| requireCandidate: options.requireCandidate === true, | ||
| requireMergedSource: options.requireMergedSource === true, | ||
| requireParentEvidence: options.requireParentEvidence === true, | ||
| requireTargetSets: options.requireTargetSets === true, | ||
| requireAuthority: options.requireAuthority === true, | ||
| requireEvidenceDigest: options.requireEvidenceDigest === true, | ||
| expectedRepositoryId: options.expectedRepositoryId, | ||
| expectedBaseSha: options.expectedBaseSha, | ||
| expectedHeadSha: options.expectedHeadSha, | ||
| expectedCommitSha: options.expectedCommitSha, | ||
| expectedParentDigest: options.expectedParentDigest, | ||
| }; | ||
@@ -133,2 +589,3 @@ } | ||
| issues.push(...collectProductCouplingIssues(packet, normalized.forbiddenProductNamespaces)); | ||
| issues.push(...collectBindingIssues(packet, normalized)); | ||
| } | ||
@@ -143,2 +600,3 @@ | ||
| forbidden_product_namespaces: normalized.forbiddenProductNamespaces, | ||
| ...(normalized.bindingProfile ? { binding_profile: normalized.bindingProfile } : {}), | ||
| }, | ||
@@ -145,0 +603,0 @@ producer: normalized.producerId ? { id: normalized.producerId } : null, |
+18
-0
@@ -40,7 +40,25 @@ export { | ||
| export { | ||
| DEPENDENCY_PIN_ERROR_CODES, | ||
| assertInstalledSecurityPins, | ||
| } from './dependency-pins.js'; | ||
| export { | ||
| CLI_EMIT_ERROR_CODES, | ||
| emitJsonPacket, | ||
| } from './cli-emit.js'; | ||
| export { | ||
| NEUTRAL_EVIDENCE_PROTOCOL_SCHEMA_VERSION, | ||
| NEUTRAL_EVIDENCE_CONFORMANCE_PACKET_TYPE, | ||
| NEUTRAL_EVIDENCE_DIGEST_PREFIX, | ||
| NEUTRAL_EVIDENCE_AUTHORITY_ROLES, | ||
| NEUTRAL_EVIDENCE_BINDING_PROFILES, | ||
| DEFAULT_FORBIDDEN_PRODUCT_NAMESPACES, | ||
| LEGACY_PRODUCER_SECTION_FIELDS, | ||
| canonicalizeNeutralJson, | ||
| calculateEvidenceDigest, | ||
| isSha256Digest, | ||
| isPublicRepositoryId, | ||
| isGitCommitSha, | ||
| isSafeEvidenceRef, | ||
| evaluateNeutralEvidencePacket, | ||
@@ -47,0 +65,0 @@ validateNeutralEvidencePacket, |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
68664
175.24%16
45.45%1643
152.77%238
325%4
100%+ Added
- Removed
Updated