diff --git a/apps/ai/src/advisory-runtime.ts b/apps/ai/src/advisory-runtime.ts index 469c35d..08222c8 100644 --- a/apps/ai/src/advisory-runtime.ts +++ b/apps/ai/src/advisory-runtime.ts @@ -1,135 +1,70 @@ -import type { AiAdvisoryRequest, AiInferenceRequest, AiInferenceResponse } from "@aegisai/shared"; - -import { createDeterministicFallbackProvider, createModelGateway } from "./model-gateway"; - -export interface AiAdvisoryRuntimeResponse { - detectorSignals: string[]; - plannerSteps: string[]; - confidence: number; - modelVersion: string; -} - -const FORBIDDEN_INPUT_KEYS = [ - "accessToken", - "refreshToken", - "tokenValue", - "secretValue", - "sourceArchive", - "fullRepository", - "rawScannerPayload", - "policyOverride", - "findingOverride", - "enforcementAction", - "blockRequested", - "waiverApplied", - "staleSuppressed" -]; - -export async function handleAiAdvisoryRequest(request: Request): Promise { +import type { + AiInferenceRequest, + AiInferenceResponse +} from '@aegisai/shared'; + +import { + AiInferenceValidationError, + createDeterministicFallbackProvider, + createModelGateway +} from './model-gateway'; + +export async function handleAiAdvisoryRequest( + request: Request +): Promise { const url = new URL(request.url); - if (url.pathname === "/health") { - return jsonResponse({ status: "ok", service: "ai-runtime" }, 200); + if (url.pathname === '/health') { + return jsonResponse({ status: 'ok', service: 'ai-runtime' }, 200); } - - if (url.pathname !== "/ai/advisories") { - return jsonResponse({ error: "AI advisory route was not found." }, 404); + if (url.pathname !== '/ai/advisories') { + return jsonResponse( + { error: 'AI advisory route was not found.' }, + 404 + ); } - - if (request.method !== "POST") { - return jsonResponse({ error: "AI advisory route only accepts POST." }, 405); + if (request.method !== 'POST') { + return jsonResponse( + { error: 'AI advisory route only accepts POST.' }, + 405 + ); } try { const body = (await request.json()) as unknown; - - if (isAiInferenceRequestLike(body)) { - const gateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, - fallbackProvider: createDeterministicFallbackProvider() - }); - - return jsonResponse(await gateway.infer(body), 200); + if (!isAiInferenceRequestLike(body)) { + throw new AiInferenceValidationError( + 'AI advisory runtime requires a T043 reduced-reference handoff.', + 'FORBIDDEN_INPUT_CLASS' + ); } - - const advisoryRequest = parseReducedAdvisoryRequest(body); - - return jsonResponse(createDetectorPlannerAdvisory(advisoryRequest), 200); + const gateway = createModelGateway({ + config: { + providerId: 'deterministic', + model: 'detector-planner-fallback', + version: body.modelVersion, + allowFallback: true + }, + fallbackProvider: createDeterministicFallbackProvider() + }); + return jsonResponse(await gateway.infer(body), 200); } catch (error) { return jsonResponse( { - error: error instanceof Error ? error.message : "AI advisory request is malformed." + error: 'AI advisory request was rejected.', + reasonCode: + error instanceof AiInferenceValidationError + ? error.rejectionReason + : 'MALFORMED_REQUEST' }, 400 ); } } -export function createDetectorPlannerAdvisory(input: AiAdvisoryRequest): AiAdvisoryRuntimeResponse { - return { - detectorSignals: [ - "SCANNER_CONFIRMED", - `SEVERITY_${input.normalizedFinding.severity}`, - `PROVENANCE_${input.normalizedFinding.scannerProvenance}`, - "MODEL_SERVICE_BOUNDARY_REDUCED_INPUT" - ], - plannerSteps: plannerStepsFor(input), - confidence: confidenceFor(input), - modelVersion: input.modelVersion - }; -} - -function parseReducedAdvisoryRequest(input: unknown): AiAdvisoryRequest { - if (!isRecord(input)) { - throw new Error("AI advisory request must be an object."); - } - - const serialized = JSON.stringify(input); - - for (const forbiddenKey of FORBIDDEN_INPUT_KEYS) { - if (new RegExp(forbiddenKey, "i").test(serialized)) { - throw new Error("AI advisory request contains forbidden sensitive or authority content."); - } - } - - const evidence = input.evidence; - const normalizedFinding = input.normalizedFinding; - - if (!isRecord(evidence) || evidence.redacted !== true) { - throw new Error("AI advisory request must use redacted evidence."); - } - - if (!isRecord(normalizedFinding)) { - throw new Error("AI advisory request must include a normalized finding."); - } - - if ( - typeof input.tenantId !== "string" || - typeof input.scanRequestId !== "string" || - typeof input.findingId !== "string" || - typeof input.modelVersion !== "string" - ) { - throw new Error("AI advisory request is missing required identifiers."); - } - - if ( - typeof normalizedFinding.severity !== "string" || - typeof normalizedFinding.scannerProvenance !== "string" || - typeof normalizedFinding.title !== "string" || - typeof normalizedFinding.filePath !== "string" - ) { - throw new Error("AI advisory request normalized finding is incomplete."); - } - - return input as unknown as AiAdvisoryRequest; -} - -function isAiInferenceRequestLike(input: unknown): input is AiInferenceRequest { +function isAiInferenceRequestLike( + input: unknown +): input is AiInferenceRequest { return ( isRecord(input) && isRecord(input.reducedEvidence) && @@ -138,39 +73,16 @@ function isAiInferenceRequestLike(input: unknown): input is AiInferenceRequest { ); } -function plannerStepsFor(input: AiAdvisoryRequest): string[] { - const steps = ["Review normalized scanner evidence before remediation planning."]; - - if (input.normalizedFinding.severity === "CRITICAL" || input.normalizedFinding.severity === "HIGH") { - steps.push("Prioritize owner review before merging affected changes."); - } - - steps.push("Keep remediation, policy, and merge decisions outside the AI Plane."); - - return steps; -} - -function confidenceFor(input: AiAdvisoryRequest): number { - if (input.normalizedFinding.severity === "CRITICAL") { - return 0.82; - } - - if (input.normalizedFinding.severity === "HIGH") { - return 0.74; - } - - return 0.61; -} - -function jsonResponse(body: Record | AiAdvisoryRuntimeResponse | AiInferenceResponse, status: number): Response { +function jsonResponse( + body: Record | AiInferenceResponse, + status: number +): Response { return new Response(JSON.stringify(body), { status, - headers: { - "content-type": "application/json" - } + headers: { 'content-type': 'application/json' } }); } -function isRecord(input: unknown): input is Record { - return Boolean(input) && typeof input === "object" && !Array.isArray(input); +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/apps/ai/src/index.ts b/apps/ai/src/index.ts index c00353c..7b896d9 100644 --- a/apps/ai/src/index.ts +++ b/apps/ai/src/index.ts @@ -1,7 +1,5 @@ export { - createDetectorPlannerAdvisory, - handleAiAdvisoryRequest, - type AiAdvisoryRuntimeResponse + handleAiAdvisoryRequest } from "./advisory-runtime"; export { createDeterministicFallbackProvider, diff --git a/apps/ai/src/model-gateway.ts b/apps/ai/src/model-gateway.ts index 81f9e28..c49c54b 100644 --- a/apps/ai/src/model-gateway.ts +++ b/apps/ai/src/model-gateway.ts @@ -41,7 +41,7 @@ export function validateModelGatewayConfig(config: ModelGatewayConfig): ModelGat throw new Error("Model gateway model is required."); } - if (config.version.trim().length === 0) { + if (!isRuntimeModelVersion(config.version)) { throw new Error("Model gateway version is required."); } @@ -57,6 +57,12 @@ export function createModelGateway(options: ModelGatewayOptions): ModelGateway { try { validateAiInferenceRequest(request); + if (request.modelVersion !== config.version) { + throw new AiInferenceValidationError( + "AI inference request model version does not match the selected gateway.", + "FORBIDDEN_INPUT_CLASS" + ); + } } catch (error) { emitAuditEvent(options, request, "ai_inference.rejected", { rejectionReason: rejectionReasonFor(error) @@ -90,7 +96,7 @@ export function createModelGateway(options: ModelGatewayOptions): ModelGateway { request, config, startedAt, - error instanceof Error ? error.message : "provider failed" + "PROVIDER_REQUEST_FAILED" ); } } @@ -103,7 +109,13 @@ export function createModelGateway(options: ModelGatewayOptions): ModelGateway { throw new Error("Model gateway provider is not configured and fallback is disabled."); } - return runFallbackProvider(options, request, config, startedAt, "provider not configured"); + return runFallbackProvider( + options, + request, + config, + startedAt, + "PROVIDER_NOT_CONFIGURED" + ); } }; } @@ -137,15 +149,42 @@ async function runFallbackProvider( } export function validateAiInferenceRequest(request: AiInferenceRequest): AiInferenceRequest { - if (request.tenantId.trim().length === 0) { + if ( + !isRecord(request as unknown) || + !hasExactKeys(request as unknown as Record, [ + "tenantId", + "scanRequestId", + "canonicalScanKey", + "requestId", + "modelVersion", + "reducedEvidence", + "requestedCapabilities", + "runtimePolicy", + "createdAt" + ]) + ) { + throw new AiInferenceValidationError( + "AI inference request must use the exact T043 reduced-reference contract.", + "FORBIDDEN_INPUT_CLASS" + ); + } + + if (typeof request.tenantId !== "string" || request.tenantId.trim().length === 0) { throw new AiInferenceValidationError("AI inference request requires tenant attribution.", "MISSING_TENANT_ATTRIBUTION"); } - if (request.scanRequestId.trim().length === 0) { + if (typeof request.scanRequestId !== "string" || request.scanRequestId.trim().length === 0) { throw new AiInferenceValidationError("AI inference request requires scan attribution.", "MISSING_SCAN_ATTRIBUTION"); } - if (request.reducedEvidence.redactionState !== "redacted" && request.reducedEvidence.redactionState !== "reduced") { + if (!isRuntimeModelVersion(request.modelVersion)) { + throw new AiInferenceValidationError( + "AI inference request requires a bounded model version.", + "FORBIDDEN_INPUT_CLASS" + ); + } + + if (!isRecord(request.reducedEvidence as unknown) || request.reducedEvidence.redactionState !== "reduced") { throw new AiInferenceValidationError( "AI inference request must remain inside the reduced evidence boundary.", "UNREDACTED_EVIDENCE" @@ -159,9 +198,212 @@ export function validateAiInferenceRequest(request: AiInferenceRequest): AiInfer ); } + if (!isT043ReducedReferenceRequest(request)) { + throw new AiInferenceValidationError( + "AI inference request must use the exact T043 reduced-reference contract.", + "FORBIDDEN_INPUT_CLASS" + ); + } + return request; } +const T043_METADATA_KEYS = [ + "handoffVersion", + "handoffDigest", + "requestDigest", + "repositoryBindingId", + "attemptId", + "occurrenceId", + "normalizedFindingId", + "findingFingerprint", + "capability", + "severity", + "confidence", + "scanner", + "ruleSemanticId", + "ruleRevision", + "location", + "cweIds", + "cveIds", + "accessDecisionId", + "accessDecisionDigest", + "reducedEvidenceRef", + "redactedProjectionDigest", + "fragmentCount", + "payloadExpiresAt", + "retrievalAllowed", + "toolsAllowed", + "policyAuthority", + "publicationAuthority", + "lifecycleMutationAuthority", + "scmWriteAuthority", + "advisoryOnly" +] as const; + +function isT043ReducedReferenceRequest( + request: AiInferenceRequest +): boolean { + const evidence = request.reducedEvidence; + if ( + !isRecord(evidence as unknown) || + !isRecord(evidence.metadata) || + !Array.isArray(evidence.findingIds) || + !Array.isArray(evidence.scannerNames) || + !Array.isArray(evidence.snippets) || + !Array.isArray(request.requestedCapabilities) || + !isRecord(request.runtimePolicy as unknown) + ) { + return false; + } + const metadata = evidence.metadata; + const createdAt = Date.parse(request.createdAt); + const expiresAt = Date.parse(String(metadata.payloadExpiresAt)); + const requestSuffix = String(metadata.requestDigest).replace( + /^sha256:/u, + "" + ); + return ( + hasExactKeys(evidence as unknown as Record, [ + "findingIds", + "scannerNames", + "evidencePackId", + "summary", + "snippets", + "metadata", + "redactionState" + ]) && + hasExactKeys(metadata, T043_METADATA_KEYS) && + hasExactKeys(request.runtimePolicy as unknown as Record, [ + "allowFallback", + "maxLatencyMs" + ]) && + isBoundedRuntimeText(request.tenantId, 512) && + isBoundedRuntimeText(request.scanRequestId, 512) && + request.canonicalScanKey === [ + request.tenantId, + metadata.repositoryBindingId, + request.scanRequestId, + metadata.attemptId, + metadata.accessDecisionDigest, + request.modelVersion + ].join(":") && + request.requestId === `sast-ai-request://${requestSuffix}` && + /^sast-ai-request:\/\/[a-f0-9]{64}$/u.test(request.requestId) && + /^sha256:[a-f0-9]{64}$/u.test(String(metadata.handoffDigest)) && + /^sha256:[a-f0-9]{64}$/u.test(String(metadata.requestDigest)) && + /^sha256:[a-f0-9]{64}$/u.test(String(metadata.findingFingerprint)) && + /^sha256:[a-f0-9]{64}$/u.test(String(metadata.accessDecisionDigest)) && + /^sha256:[a-f0-9]{64}$/u.test(String(metadata.redactedProjectionDigest)) && + metadata.handoffVersion === "sast-ai-advisory-handoff-v1" && + /^sast-evidence-access:\/\/[a-f0-9]{64}$/u.test(String(metadata.accessDecisionId)) && + /^sast-reduced-evidence:\/\/[a-f0-9]{64}$/u.test(String(metadata.reducedEvidenceRef)) && + /^sast-evidence-pack:\/\/[a-f0-9]{64}$/u.test(evidence.evidencePackId) && + /^finding-occurrence:\/\/[a-f0-9]{64}$/u.test(String(metadata.occurrenceId)) && + isBoundedRuntimeText(metadata.repositoryBindingId, 512) && + isBoundedRuntimeText(metadata.attemptId, 512) && + isBoundedRuntimeText(metadata.normalizedFindingId, 512) && + isBoundedRuntimeText(metadata.ruleSemanticId, 512) && + isBoundedRuntimeText(metadata.ruleRevision, 512) && + isBoundedRuntimeText(metadata.location, 1024) && + isCommaSeparatedIdentifiers(metadata.cweIds) && + isCommaSeparatedIdentifiers(metadata.cveIds) && + [ + "SAST", + "DEPENDENCY_VULNERABILITY", + "SECRET_DETECTION", + "IAC_MISCONFIGURATION" + ].includes(String(metadata.capability)) && + evidence.findingIds.length === 1 && + evidence.findingIds[0] === metadata.normalizedFindingId && + evidence.scannerNames.length === 1 && + evidence.scannerNames[0] === metadata.scanner && + evidence.snippets.length === 0 && + typeof evidence.summary === "string" && + evidence.summary.length > 0 && + evidence.summary.length <= 1024 && + (metadata.scanner === "OPENGREP" || metadata.scanner === "TRIVY") && + ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"].includes(String(metadata.severity)) && + ["HIGH", "MEDIUM", "LOW", "UNKNOWN"].includes(String(metadata.confidence)) && + Number.isInteger(metadata.fragmentCount) && + Number(metadata.fragmentCount) >= 1 && + Number(metadata.fragmentCount) <= 5 && + metadata.retrievalAllowed === false && + metadata.toolsAllowed === false && + metadata.policyAuthority === false && + metadata.publicationAuthority === false && + metadata.lifecycleMutationAuthority === false && + metadata.scmWriteAuthority === false && + metadata.advisoryOnly === true && + request.requestedCapabilities.length === 2 && + request.requestedCapabilities[0] === "detector" && + request.requestedCapabilities[1] === "planner" && + request.runtimePolicy.allowFallback === true && + Number.isFinite(request.runtimePolicy.maxLatencyMs) && + request.runtimePolicy.maxLatencyMs > 0 && + request.runtimePolicy.maxLatencyMs <= 30_000 && + Number.isFinite(createdAt) && + Number.isFinite(expiresAt) && + new Date(createdAt).toISOString() === request.createdAt && + new Date(expiresAt).toISOString() === metadata.payloadExpiresAt && + createdAt < expiresAt && + Date.now() < expiresAt && + expiresAt - createdAt <= 24 * 60 * 60 * 1000 + ); +} + +function isBoundedRuntimeText( + value: unknown, + maximumLength: number +): value is string { + return typeof value === "string" && + value.length > 0 && + value.length <= maximumLength && + value.trim() === value && + !hasAsciiControl(value); +} + +function isRuntimeModelVersion(value: unknown): value is string { + return isBoundedRuntimeText(value, 128) && + /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,127}$/u.test(value); +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function isCommaSeparatedIdentifiers(value: unknown): boolean { + if (typeof value !== "string") return false; + if (value === "") return true; + const identifiers = value.split(","); + return identifiers.length <= 32 && + identifiers.every((identifier) => + /^[A-Z0-9][A-Z0-9._:-]{0,127}$/u.test(identifier) + ) && + new Set(identifiers).size === identifiers.length && + identifiers.every((identifier, index) => + index === 0 || String(identifiers[index - 1]) < identifier + ); +} + +function hasExactKeys( + value: Record, + expected: readonly string[] +): boolean { + const actual = Object.keys(value).sort(); + const ordered = [...expected].sort(); + return actual.length === ordered.length && + actual.every((key, index) => key === ordered[index]); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + function hasForbiddenEvidenceKey(input: unknown): boolean { if (input === null || typeof input !== "object") { return false; @@ -229,7 +471,7 @@ export function createDeterministicFallbackProvider(): AiModelProvider { }; } -class AiInferenceValidationError extends Error { +export class AiInferenceValidationError extends Error { constructor( message: string, readonly rejectionReason: AiInferenceRejectionReason diff --git a/apps/ai/test/advisory-runtime.test.ts b/apps/ai/test/advisory-runtime.test.ts index 1d0945a..d917bfa 100644 --- a/apps/ai/test/advisory-runtime.test.ts +++ b/apps/ai/test/advisory-runtime.test.ts @@ -1,178 +1,133 @@ -import assert from "node:assert/strict"; -import test from "node:test"; +import assert from 'node:assert/strict'; +import test from 'node:test'; -import { handleAiAdvisoryRequest } from "../src/advisory-runtime"; - -import type { AiAdvisoryRequest, AiInferenceRequest } from "@aegisai/shared"; - -const baseRequest: AiAdvisoryRequest = { - tenantId: "tenant_ai_service", - scanRequestId: "scan_request_1", - findingId: "finding_1", - normalizedFinding: { - id: "finding_1", - tenantId: "tenant_ai_service", - scanRequestId: "scan_request_1", - scannerRunId: "scanner_run_1", - title: "SQL injection sink", - severity: "HIGH", - scannerProvenance: "OPENGREP", - filePath: "src/user.controller.ts", - lineStart: 42, - status: "OPEN" - }, - evidence: { - id: "evidence_1", - tenantId: "tenant_ai_service", - scanRequestId: "scan_request_1", - classification: "SHORT_LIVED_EVIDENCE", - objectKey: "tenant_ai_service/scan_request_1/evidence/evidence_1.json", - expiresAt: "2026-04-19T00:00:00.000Z", - byteSize: 512, - redacted: true - }, - modelVersion: "detector-planner-runtime-v1" -}; +import { handleAiAdvisoryRequest } from '../src/advisory-runtime'; +import { t043InferenceRequest } from './t043-inference.fixture'; function advisoryHttpRequest(body: unknown): Request { - return new Request("http://127.0.0.1:8000/ai/advisories", { - method: "POST", - headers: { - "content-type": "application/json" - }, + return new Request('http://127.0.0.1:8000/ai/advisories', { + method: 'POST', + headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); } -test("POST /ai/advisories returns advisory-only detector and planner output", async () => { - const response = await handleAiAdvisoryRequest(advisoryHttpRequest(baseRequest)); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.deepEqual(Object.keys(body).sort(), [ - "confidence", - "detectorSignals", - "modelVersion", - "plannerSteps" - ]); - assert.deepEqual(body.detectorSignals, [ - "SCANNER_CONFIRMED", - "SEVERITY_HIGH", - "PROVENANCE_OPENGREP", - "MODEL_SERVICE_BOUNDARY_REDUCED_INPUT" - ]); - assert.equal(body.modelVersion, "detector-planner-runtime-v1"); - assert.equal(typeof body.confidence, "number"); - assert.ok(body.confidence >= 0); - assert.ok(body.confidence <= 1); -}); - -test("POST /ai/advisories accepts model gateway inference requests", async () => { - const inferenceRequest: AiInferenceRequest = { - tenantId: "tenant_ai_service", - scanRequestId: "scan_request_1", - canonicalScanKey: "tenant_ai_service:scan_request_1:finding_1:AI_ADVISORY:detector-planner-runtime-v1", - requestId: "ai_inference_scan_request_1_finding_1", - reducedEvidence: { - findingIds: ["finding_1"], - scannerNames: ["OPENGREP"], - evidencePackId: "evidence_1", - summary: "SQL injection sink (HIGH)", - snippets: [ - { - label: "finding-location", - redactedText: "src/user.controller.ts:42" - } - ], - metadata: { - severity: "HIGH", - scannerProvenance: "OPENGREP" - }, - redactionState: "redacted" - }, - requestedCapabilities: ["detector", "planner"], - runtimePolicy: { - allowFallback: true, - maxLatencyMs: 2500 - }, - createdAt: "2026-05-26T00:00:00.000Z" - }; - - const response = await handleAiAdvisoryRequest(advisoryHttpRequest(inferenceRequest)); +test('POST /ai/advisories accepts only the T043 reduced-reference request', async () => { + const request = t043InferenceRequest(); + const response = await handleAiAdvisoryRequest( + advisoryHttpRequest(request) + ); const body = await response.json(); assert.equal(response.status, 200); - assert.equal(body.requestId, "ai_inference_scan_request_1_finding_1"); - assert.equal(body.tenantId, "tenant_ai_service"); - assert.equal(body.scanRequestId, "scan_request_1"); + assert.equal(body.requestId, request.requestId); + assert.equal(body.tenantId, request.tenantId); + assert.equal(body.scanRequestId, request.scanRequestId); assert.equal(body.advisoryOnly, true); assert.equal(body.fallback.used, true); - assert.equal(body.modelMetadata.provider, "deterministic"); + assert.equal(body.modelMetadata.version, request.modelVersion); assert.deepEqual(body.detectorAdvisories[0].signals, [ - "FALLBACK_DETERMINISTIC", - "EVIDENCE_REDACTED", - "SCANNERS_OPENGREP" + 'FALLBACK_DETERMINISTIC', + 'EVIDENCE_REDUCED', + 'SCANNERS_OPENGREP' ]); - assert.equal(body.plannerAdvisories[0].action, "Review normalized scanner evidence with the owning team."); }); -test("GET /health returns the AI runtime health status", async () => { - const response = await handleAiAdvisoryRequest(new Request("http://127.0.0.1:8000/health")); - const body = await response.json(); +test('GET /health returns the AI runtime health status', async () => { + const response = await handleAiAdvisoryRequest( + new Request('http://127.0.0.1:8000/health') + ); assert.equal(response.status, 200); - assert.deepEqual(body, { - service: "ai-runtime", - status: "ok" + assert.deepEqual(await response.json(), { + service: 'ai-runtime', + status: 'ok' }); }); -test("POST /ai/advisories rejects unredacted evidence", async () => { +test('POST /ai/advisories rejects the legacy direct finding payload', async () => { const response = await handleAiAdvisoryRequest( advisoryHttpRequest({ - ...baseRequest, - evidence: { - ...baseRequest.evidence, - redacted: false - } + tenantId: 'tenant-ai-runtime', + scanRequestId: 'scan-ai-runtime', + normalizedFinding: { title: 'caller supplied' }, + evidence: { redacted: true }, + modelVersion: 'legacy-v1' }) ); const body = await response.json(); assert.equal(response.status, 400); - assert.match(body.error, /redacted evidence/i); + assert.deepEqual(body, { + error: 'AI advisory request was rejected.', + reasonCode: 'FORBIDDEN_INPUT_CLASS' + }); }); -test("POST /ai/advisories rejects credentials, repositories, source archives, and raw scanner payloads", async () => { - const forbiddenInputs = [ - { accessToken: "ghs_secret" }, - { fullRepository: "entire repository contents" }, - { sourceArchive: "base64-zip" }, - { rawScannerPayload: { raw: true } } +test('POST /ai/advisories rejects unknown fields and authority escalation', async () => { + const cases = [ + { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + metadata: { + ...t043InferenceRequest().reducedEvidence.metadata, + accessToken: 'secret' + } + } + }, + { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + metadata: { + ...t043InferenceRequest().reducedEvidence.metadata, + toolsAllowed: true + } + } + } ]; - for (const forbiddenInput of forbiddenInputs) { + for (const input of cases) { const response = await handleAiAdvisoryRequest( - advisoryHttpRequest({ - ...baseRequest, - ...forbiddenInput - }) + advisoryHttpRequest(input) ); - assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'AI advisory request was rejected.', + reasonCode: 'FORBIDDEN_INPUT_CLASS' + }); } }); -test("POST /ai/advisories never returns policy authority or finding override fields", async () => { - const response = await handleAiAdvisoryRequest(advisoryHttpRequest(baseRequest)); +test('POST /ai/advisories returns a stable error for malformed JSON', async () => { + const response = await handleAiAdvisoryRequest( + new Request('http://127.0.0.1:8000/ai/advisories', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{' + }) + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'AI advisory request was rejected.', + reasonCode: 'MALFORMED_REQUEST' + }); +}); + +test('POST /ai/advisories never returns secrets or decision authority', async () => { + const response = await handleAiAdvisoryRequest( + advisoryHttpRequest(t043InferenceRequest()) + ); const serialized = JSON.stringify(await response.json()); assert.doesNotMatch( serialized, - /accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|rawScannerPayload/i + /accessToken|refreshToken|secretValue|sourceArchive|rawScannerPayload/i ); assert.doesNotMatch( serialized, - /policyOverride|findingOverride|enforcementAction|blockRequested|waiverApplied|staleSuppressed/i + /policyOverride|findingOverride|enforcementAction|blockRequested/i ); }); diff --git a/apps/ai/test/model-gateway.test.ts b/apps/ai/test/model-gateway.test.ts index 7d0e104..bd4a0dc 100644 --- a/apps/ai/test/model-gateway.test.ts +++ b/apps/ai/test/model-gateway.test.ts @@ -1,519 +1,322 @@ -import assert from "node:assert/strict"; -import test from "node:test"; +import assert from 'node:assert/strict'; +import test from 'node:test'; import { + AiInferenceValidationError, createDeterministicFallbackProvider, createModelGateway, validateAiInferenceRequest, validateModelGatewayConfig -} from "../src/model-gateway"; - -import type { AiInferenceRequest, AiInferenceResponse } from "@aegisai/shared"; - -const inferenceRequest: AiInferenceRequest = { - tenantId: "tenant_model_gateway", - scanRequestId: "scan_request_1", - canonicalScanKey: "tenant_model_gateway:repo_1:FAST:main:abc123:policy_v1:scanner_v1", - requestId: "ai_request_1", - reducedEvidence: { - findingIds: ["finding_1"], - scannerNames: ["OPENGREP"], - evidencePackId: "evidence_1", - summary: "Reduced scanner evidence for a SQL injection sink.", - snippets: [ - { - label: "sink", - language: "ts", - redactedText: "db.query(REDACTED)" - } - ], - metadata: { - severity: "HIGH" - }, - redactionState: "redacted" - }, - requestedCapabilities: ["detector", "planner"], - runtimePolicy: { - allowFallback: true, - maxLatencyMs: 1000 - }, - createdAt: "2026-05-26T00:00:00.000Z" +} from '../src/model-gateway'; +import { t043InferenceRequest } from './t043-inference.fixture'; + +import type { + AiInferenceAuditEvent, + AiInferenceResponse +} from '@aegisai/shared'; + +const fallbackConfig = { + providerId: 'deterministic', + model: 'detector-planner-fallback', + version: 'detector-planner-runtime-v1', + allowFallback: true }; -test("model gateway returns deterministic fallback advisories when no provider is configured", async () => { +test('model gateway returns deterministic output from the T043 reference', async () => { + const request = t043InferenceRequest(); const gateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, + config: fallbackConfig, fallbackProvider: createDeterministicFallbackProvider() }); - const response = await gateway.infer(inferenceRequest); + const response = await gateway.infer(request); + assert.equal(response.requestId, request.requestId); assert.equal(response.advisoryOnly, true); assert.equal(response.fallback.used, true); - assert.equal(response.modelMetadata.provider, "deterministic"); - assert.equal(response.modelMetadata.model, "detector-planner-fallback"); - assert.equal(response.detectorAdvisories[0]?.findingId, "finding_1"); - assert.equal(response.plannerAdvisories[0]?.priority, "high"); + assert.equal(response.detectorAdvisories[0]?.findingId, + 'normalized-finding-ai-runtime'); + assert.deepEqual(response.detectorAdvisories[0]?.signals, [ + 'FALLBACK_DETERMINISTIC', + 'EVIDENCE_REDUCED', + 'SCANNERS_OPENGREP' + ]); }); -test("model gateway returns provider output without fallback when provider succeeds", async () => { +test('model gateway returns provider output without fallback', async () => { + const request = t043InferenceRequest(); const providerResponse: AiInferenceResponse = { - requestId: inferenceRequest.requestId, - tenantId: inferenceRequest.tenantId, - scanRequestId: inferenceRequest.scanRequestId, + requestId: request.requestId, + tenantId: request.tenantId, + scanRequestId: request.scanRequestId, advisoryOnly: true, detectorAdvisories: [ { - findingId: "finding_1", + findingId: request.reducedEvidence.findingIds[0]!, confidence: 0.91, - rationale: "Provider confirmed scanner context.", - signals: ["PROVIDER_CONFIRMED"] - } - ], - plannerAdvisories: [ - { - findingId: "finding_1", - action: "Schedule owner review.", - rationale: "High confidence advisory.", - priority: "high" + rationale: 'Provider confirmed normalized metadata.', + signals: ['PROVIDER_CONFIRMED'] } ], + plannerAdvisories: [], modelMetadata: { - provider: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26" - }, - fallback: { - used: false + provider: 'configured-provider', + model: 'prod-detector-planner', + version: request.modelVersion }, + fallback: { used: false }, latencyMs: 12, - createdAt: "2026-05-26T00:00:01.000Z" + createdAt: '2026-08-11T04:00:01.000Z' }; - const gateway = createModelGateway({ config: { - providerId: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26", + providerId: 'configured-provider', + model: 'prod-detector-planner', + version: request.modelVersion, allowFallback: true }, - provider: { - infer: async () => providerResponse - }, + provider: { infer: async () => providerResponse }, fallbackProvider: createDeterministicFallbackProvider() }); - const response = await gateway.infer(inferenceRequest); + const response = await gateway.infer(request); assert.equal(response.fallback.used, false); - assert.equal(response.detectorAdvisories[0]?.signals[0], "PROVIDER_CONFIRMED"); + assert.equal(response.detectorAdvisories[0]?.signals[0], + 'PROVIDER_CONFIRMED'); }); -test("model gateway uses fallback when provider fails and runtime policy allows fallback", async () => { +test('model gateway falls back after a provider failure', async () => { const gateway = createModelGateway({ config: { - providerId: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26", - allowFallback: true + ...fallbackConfig, + providerId: 'configured-provider' }, provider: { infer: async () => { - throw new Error("provider unavailable"); + throw new Error('provider unavailable'); } }, fallbackProvider: createDeterministicFallbackProvider() }); - const response = await gateway.infer(inferenceRequest); + const response = await gateway.infer(t043InferenceRequest()); assert.equal(response.fallback.used, true); - assert.match(response.fallback.reason ?? "", /provider unavailable/i); -}); - -test("model gateway configuration guardrails reject incomplete provider metadata", () => { - assert.throws( - () => - validateModelGatewayConfig({ - providerId: "", - model: "prod-detector-planner", - version: "2026-05-26", - allowFallback: true - }), - /provider/i - ); - - assert.throws( - () => - validateModelGatewayConfig({ - providerId: "configured-provider", - model: "", - version: "2026-05-26", - allowFallback: true - }), - /model/i + assert.equal(response.fallback.reason, 'PROVIDER_REQUEST_FAILED'); + assert.doesNotMatch( + JSON.stringify(response), + /provider unavailable/u ); }); -test("model gateway responses remain advisory-only without authority fields", async () => { +test('model gateway rejects model-version drift before provider execution', async () => { + let providerCalls = 0; const gateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true + config: { ...fallbackConfig, version: 'different-model-version' }, + provider: { + infer: async () => { + providerCalls += 1; + throw new Error('provider must not be called'); + } }, fallbackProvider: createDeterministicFallbackProvider() }); - const serialized = JSON.stringify(await gateway.infer(inferenceRequest)); + await assert.rejects( + () => gateway.infer(t043InferenceRequest()), + /model version/i + ); + assert.equal(providerCalls, 0); +}); - assert.doesNotMatch( - serialized, - /accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|rawScannerPayload/i +test('model gateway configuration rejects incomplete provider metadata', () => { + assert.throws( + () => validateModelGatewayConfig({ + ...fallbackConfig, + providerId: '' + }), + /provider/i ); - assert.doesNotMatch( - serialized, - /policyOverride|findingOverride|enforcementAction|blockRequested|waiverApplied|staleSuppressed/i + assert.throws( + () => validateModelGatewayConfig({ + ...fallbackConfig, + model: '' + }), + /model/i ); }); -test("model gateway rejects requests outside the reduced evidence boundary and audits rejections", async () => { - const auditEvents: unknown[] = []; - const gateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, - fallbackProvider: createDeterministicFallbackProvider(), - auditSink: (event) => { - auditEvents.push(event); - } - }); +test('request validation enforces the exact reduced-reference boundary', () => { + const request = t043InferenceRequest(); + assert.equal(validateAiInferenceRequest(request), request); - for (const forbiddenRequest of [ + const invalid: Array<{ + candidate: unknown; + reason: AiInferenceValidationError['rejectionReason']; + }> = [ { - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - redactionState: "raw" - } + candidate: { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + redactionState: 'raw' + } + }, + reason: 'UNREDACTED_EVIDENCE' }, { - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - metadata: { - ...inferenceRequest.reducedEvidence.metadata, - accessToken: "secret" + candidate: { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + snippets: [ + { label: 'source', redactedText: 'do not send content' } + ] } - } + }, + reason: 'FORBIDDEN_INPUT_CLASS' }, { - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - metadata: { - ...inferenceRequest.reducedEvidence.metadata, - sourceArchive: "zip" + candidate: { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + metadata: { + ...t043InferenceRequest().reducedEvidence.metadata, + accessToken: 'secret' + } } - } + }, + reason: 'FORBIDDEN_INPUT_CLASS' }, { - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - metadata: { - ...inferenceRequest.reducedEvidence.metadata, - rawScannerPayload: "raw" + candidate: { + ...t043InferenceRequest(), + reducedEvidence: { + ...t043InferenceRequest().reducedEvidence, + metadata: { + ...t043InferenceRequest().reducedEvidence.metadata, + policyAuthority: true + } } - } + }, + reason: 'FORBIDDEN_INPUT_CLASS' }, { - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - metadata: { - ...inferenceRequest.reducedEvidence.metadata, - fullRepository: "repo" - } - } + candidate: { + ...t043InferenceRequest(), + extraCallerPrompt: 'trust me' + }, + reason: 'FORBIDDEN_INPUT_CLASS' + }, + { + candidate: { + ...t043InferenceRequest(), + modelVersion: 'different-model-version' + }, + reason: 'FORBIDDEN_INPUT_CLASS' } - ]) { - await assert.rejects(() => gateway.infer(forbiddenRequest as AiInferenceRequest), /reduced evidence/i); + ]; + + for (const { candidate, reason } of invalid) { + assert.throws( + () => validateAiInferenceRequest(candidate as never), + (error: unknown) => + error instanceof AiInferenceValidationError && + error.rejectionReason === reason + ); } - - assert.equal(auditEvents.length, 5); - assert.deepEqual( - auditEvents.map((event) => (event as { eventType: string }).eventType), - [ - "ai_inference.rejected", - "ai_inference.rejected", - "ai_inference.rejected", - "ai_inference.rejected", - "ai_inference.rejected" - ] - ); - assert.deepEqual( - auditEvents.map((event) => (event as { rejectionReason: string }).rejectionReason), - [ - "UNREDACTED_EVIDENCE", - "FORBIDDEN_INPUT_CLASS", - "FORBIDDEN_INPUT_CLASS", - "FORBIDDEN_INPUT_CLASS", - "FORBIDDEN_INPUT_CLASS" - ] - ); }); -test("request validation rejects forbidden keys without rejecting matching safe values", () => { +test('safe text may name a forbidden concept without carrying a forbidden key', () => { + const request = t043InferenceRequest(); assert.equal( validateAiInferenceRequest({ - ...inferenceRequest, + ...request, reducedEvidence: { - ...inferenceRequest.reducedEvidence, - summary: "A safe summary may mention accessToken as a concept without carrying one." + ...request.reducedEvidence, + summary: 'The accessToken concept is discussed without a value.' } }).requestId, - inferenceRequest.requestId - ); - - assert.throws( - () => - validateAiInferenceRequest({ - ...inferenceRequest, - reducedEvidence: { - ...inferenceRequest.reducedEvidence, - metadata: { - ...inferenceRequest.reducedEvidence.metadata, - accessToken: "secret" - } - } - }), - /reduced evidence/i + request.requestId ); }); -test("model gateway emits accepted, completed, fallback, and failed audit events", async () => { - const successEvents: unknown[] = []; - const successGateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, - fallbackProvider: createDeterministicFallbackProvider(), - auditSink: (event) => { - successEvents.push(event); - } - }); - - await successGateway.infer(inferenceRequest); - - assert.deepEqual( - successEvents.map((event) => (event as { eventType: string }).eventType), - ["ai_inference.requested", "ai_inference.fallback_completed"] - ); - - const providerEvents: unknown[] = []; - const providerGateway = createModelGateway({ - config: { - providerId: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26", - allowFallback: false - }, - provider: { - infer: async () => ({ - requestId: inferenceRequest.requestId, - tenantId: inferenceRequest.tenantId, - scanRequestId: inferenceRequest.scanRequestId, - advisoryOnly: true, - detectorAdvisories: [], - plannerAdvisories: [], - modelMetadata: { - provider: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26" - }, - fallback: { - used: false - }, - latencyMs: 1, - createdAt: "2026-05-26T00:00:01.000Z" - }) - }, +test('model gateway audits accepted, fallback, rejected, and failed outcomes', async () => { + const events: AiInferenceAuditEvent[] = []; + const fallbackGateway = createModelGateway({ + config: fallbackConfig, fallbackProvider: createDeterministicFallbackProvider(), auditSink: (event) => { - providerEvents.push(event); + events.push(event); } }); + await fallbackGateway.infer(t043InferenceRequest()); - await providerGateway.infer({ - ...inferenceRequest, - runtimePolicy: { - ...inferenceRequest.runtimePolicy, - allowFallback: false - } - }); + const rejected = t043InferenceRequest(); + rejected.reducedEvidence.redactionState = 'redacted'; + await assert.rejects(() => fallbackGateway.infer(rejected), + /reduced evidence/i); - assert.deepEqual( - providerEvents.map((event) => (event as { eventType: string }).eventType), - ["ai_inference.requested", "ai_inference.completed"] - ); - - const failedEvents: unknown[] = []; const failedGateway = createModelGateway({ - config: { - providerId: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26", - allowFallback: false - }, - provider: { - infer: async () => { - throw new Error("provider failed"); - } - }, + config: { ...fallbackConfig, allowFallback: false }, fallbackProvider: createDeterministicFallbackProvider(), auditSink: (event) => { - failedEvents.push(event); + events.push(event); } }); - await assert.rejects( - () => - failedGateway.infer({ - ...inferenceRequest, - runtimePolicy: { - ...inferenceRequest.runtimePolicy, - allowFallback: false - } - }), - /provider failed/i - ); - - assert.deepEqual( - failedEvents.map((event) => (event as { eventType: string }).eventType), - ["ai_inference.requested", "ai_inference.failed"] - ); -}); - -test("model gateway emits failed audit events when fallback provider fails", async () => { - const providerFailureEvents: unknown[] = []; - const providerFailureGateway = createModelGateway({ - config: { - providerId: "configured-provider", - model: "prod-detector-planner", - version: "2026-05-26", - allowFallback: true - }, - provider: { - infer: async () => { - throw new Error("provider unavailable"); - } - }, - fallbackProvider: { - infer: async () => { - throw new Error("fallback unavailable"); - } - }, - auditSink: (event) => { - providerFailureEvents.push(event); - } - }); - - await assert.rejects(() => providerFailureGateway.infer(inferenceRequest), /fallback unavailable/i); - - assert.deepEqual( - providerFailureEvents.map((event) => (event as { eventType: string }).eventType), - ["ai_inference.requested", "ai_inference.failed"] + () => failedGateway.infer(t043InferenceRequest()), + /fallback is disabled/i ); - const missingProviderEvents: unknown[] = []; - const missingProviderGateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, - fallbackProvider: { - infer: async () => { - throw new Error("fallback unavailable"); - } - }, - auditSink: (event) => { - missingProviderEvents.push(event); - } - }); - - await assert.rejects(() => missingProviderGateway.infer(inferenceRequest), /fallback unavailable/i); - - assert.deepEqual( - missingProviderEvents.map((event) => (event as { eventType: string }).eventType), - ["ai_inference.requested", "ai_inference.failed"] - ); + assert.deepEqual(events.map((event) => event.eventType), [ + 'ai_inference.requested', + 'ai_inference.fallback_completed', + 'ai_inference.rejected', + 'ai_inference.requested', + 'ai_inference.failed' + ]); + assert.equal(events[2]?.rejectionReason, 'UNREDACTED_EVIDENCE'); }); -test("model gateway does not let audit sink failures abort inference", async () => { - const throwingAuditGateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true +test('audit sink failures never abort inference', async () => { + for (const auditSink of [ + () => { + throw new Error('audit sink failed'); }, - fallbackProvider: createDeterministicFallbackProvider(), - auditSink: () => { - throw new Error("audit sink failed"); + async () => { + throw new Error('audit sink rejected'); } - }); - - const throwingAuditResponse = await throwingAuditGateway.infer(inferenceRequest); - assert.equal(throwingAuditResponse.fallback.used, true); - - const rejectingAuditGateway = createModelGateway({ - config: { - providerId: "deterministic", - model: "detector-planner-fallback", - version: "v1", - allowFallback: true - }, - fallbackProvider: createDeterministicFallbackProvider(), - auditSink: async () => { - throw new Error("audit sink rejected"); - } - }); - - const rejectingAuditResponse = await rejectingAuditGateway.infer(inferenceRequest); - assert.equal(rejectingAuditResponse.fallback.used, true); + ]) { + const gateway = createModelGateway({ + config: fallbackConfig, + fallbackProvider: createDeterministicFallbackProvider(), + auditSink + }); + const response = await gateway.infer(t043InferenceRequest()); + assert.equal(response.fallback.used, true); + } }); -test("request validation requires tenant and scan attribution", () => { +test('request validation requires tenant and scan attribution', () => { assert.throws( - () => - validateAiInferenceRequest({ - ...inferenceRequest, - tenantId: "" - }), - /tenant/i + () => validateAiInferenceRequest({ + ...t043InferenceRequest(), + tenantId: '' + }), + (error: unknown) => + error instanceof AiInferenceValidationError && + error.rejectionReason === 'MISSING_TENANT_ATTRIBUTION' ); - assert.throws( - () => - validateAiInferenceRequest({ - ...inferenceRequest, - scanRequestId: "" - }), - /scan/i + () => validateAiInferenceRequest({ + ...t043InferenceRequest(), + scanRequestId: '' + }), + (error: unknown) => + error instanceof AiInferenceValidationError && + error.rejectionReason === 'MISSING_SCAN_ATTRIBUTION' ); }); diff --git a/apps/ai/test/t043-inference.fixture.ts b/apps/ai/test/t043-inference.fixture.ts new file mode 100644 index 0000000..1408cdb --- /dev/null +++ b/apps/ai/test/t043-inference.fixture.ts @@ -0,0 +1,73 @@ +import type { AiInferenceRequest } from '@aegisai/shared'; + +const REQUEST_DIGEST = `sha256:${'a'.repeat(64)}`; +const MODEL_VERSION = 'detector-planner-runtime-v1'; + +export function t043InferenceRequest( + referenceTime = Date.now() +): AiInferenceRequest { + const createdAt = new Date(referenceTime).toISOString(); + const payloadExpiresAt = new Date( + referenceTime + 60 * 60 * 1000 + ).toISOString(); + return { + tenantId: 'tenant-ai-runtime', + scanRequestId: 'scan-ai-runtime', + canonicalScanKey: [ + 'tenant-ai-runtime', + 'repository-ai-runtime', + 'scan-ai-runtime', + 'attempt-ai-runtime', + `sha256:${'d'.repeat(64)}`, + MODEL_VERSION + ].join(':'), + requestId: `sast-ai-request://${'a'.repeat(64)}`, + modelVersion: MODEL_VERSION, + reducedEvidence: { + findingIds: ['normalized-finding-ai-runtime'], + scannerNames: ['OPENGREP'], + evidencePackId: `sast-evidence-pack://${'e'.repeat(64)}`, + summary: 'Unsafe deserialization (HIGH)', + snippets: [], + metadata: { + handoffVersion: 'sast-ai-advisory-handoff-v1', + handoffDigest: `sha256:${'b'.repeat(64)}`, + requestDigest: REQUEST_DIGEST, + repositoryBindingId: 'repository-ai-runtime', + attemptId: 'attempt-ai-runtime', + occurrenceId: `finding-occurrence://${'f'.repeat(64)}`, + normalizedFindingId: 'normalized-finding-ai-runtime', + findingFingerprint: `sha256:${'c'.repeat(64)}`, + capability: 'SAST', + severity: 'HIGH', + confidence: 'HIGH', + scanner: 'OPENGREP', + ruleSemanticId: 'java.unsafe-deserialization', + ruleRevision: '1.0.0', + location: 'src/App.java:42', + cweIds: 'CWE-502', + cveIds: '', + accessDecisionId: `sast-evidence-access://${'7'.repeat(64)}`, + accessDecisionDigest: `sha256:${'d'.repeat(64)}`, + reducedEvidenceRef: `sast-reduced-evidence://${'8'.repeat(64)}`, + redactedProjectionDigest: `sha256:${'9'.repeat(64)}`, + fragmentCount: 1, + payloadExpiresAt, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true + }, + redactionState: 'reduced' + }, + requestedCapabilities: ['detector', 'planner'], + runtimePolicy: { + allowFallback: true, + maxLatencyMs: 2500 + }, + createdAt + }; +} diff --git a/apps/api/prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql b/apps/api/prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql new file mode 100644 index 0000000..da9f638 --- /dev/null +++ b/apps/api/prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql @@ -0,0 +1,160 @@ +-- T043 accepts only a T042 AI access decision plus its durable normalized +-- occurrence. The request payload is never persisted; this ledger retains +-- canonical references, digests, expiry, and explicit zero downstream authority. +ALTER TABLE "AiAdvisoryMetadata" + ADD COLUMN "sastHandoffId" TEXT; + +CREATE TABLE "SastAiAdvisoryHandoff" ( + "id" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "advisoryId" TEXT NOT NULL, + "accessDecisionId" TEXT NOT NULL, + "accessDecisionDigest" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "normalizedFindingId" TEXT NOT NULL, + "scannerRunId" TEXT NOT NULL, + "evidencePackId" TEXT NOT NULL, + "findingFingerprint" TEXT NOT NULL, + "modelVersion" TEXT NOT NULL, + "payloadExpiresAt" TIMESTAMP(3) NOT NULL, + "requestDigest" TEXT NOT NULL, + "handoffDigest" TEXT NOT NULL, + "normalizedFindingAllowed" BOOLEAN NOT NULL DEFAULT true, + "reducedEvidenceReferenceAllowed" BOOLEAN NOT NULL DEFAULT true, + "aiPayloadAllowed" BOOLEAN NOT NULL DEFAULT true, + "aiProviderCallAllowed" BOOLEAN NOT NULL DEFAULT true, + "advisoryOnly" BOOLEAN NOT NULL DEFAULT true, + "callerFindingAccepted" BOOLEAN NOT NULL DEFAULT false, + "callerEvidenceAccepted" BOOLEAN NOT NULL DEFAULT false, + "callerPromptAccepted" BOOLEAN NOT NULL DEFAULT false, + "requestPayloadStored" BOOLEAN NOT NULL DEFAULT false, + "rawSourceStored" BOOLEAN NOT NULL DEFAULT false, + "secretValueStored" BOOLEAN NOT NULL DEFAULT false, + "evidenceFragmentStored" BOOLEAN NOT NULL DEFAULT false, + "retrievalAttempted" BOOLEAN NOT NULL DEFAULT false, + "toolsInvoked" BOOLEAN NOT NULL DEFAULT false, + "retrievalAllowed" BOOLEAN NOT NULL DEFAULT false, + "toolsAllowed" BOOLEAN NOT NULL DEFAULT false, + "policyAuthority" BOOLEAN NOT NULL DEFAULT false, + "publicationAuthority" BOOLEAN NOT NULL DEFAULT false, + "lifecycleMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "scmWriteAuthority" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastAiAdvisoryHandoff_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastAiAdvisoryHandoff_contract_check" CHECK ( + "id" ~ '^sast-ai-handoff://[a-f0-9]{64}$' + AND "requestId" ~ '^sast-ai-request://[a-f0-9]{64}$' + AND "advisoryId" ~ '^sast-ai-advisory://[a-f0-9]{64}$' + AND "accessDecisionId" ~ '^sast-evidence-access://[a-f0-9]{64}$' + AND "accessDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "findingFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "requestDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "handoffDigest" ~ '^sha256:[a-f0-9]{64}$' + AND octet_length("modelVersion") BETWEEN 1 AND 128 + AND "modelVersion" ~ '^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,127}$' + AND "createdAt" < "payloadExpiresAt" + AND "payloadExpiresAt" <= "createdAt" + INTERVAL '24 hours' + AND "normalizedFindingAllowed" IS TRUE + AND "reducedEvidenceReferenceAllowed" IS TRUE + AND "aiPayloadAllowed" IS TRUE + AND "aiProviderCallAllowed" IS TRUE + AND "advisoryOnly" IS TRUE + AND "callerFindingAccepted" IS FALSE + AND "callerEvidenceAccepted" IS FALSE + AND "callerPromptAccepted" IS FALSE + AND "requestPayloadStored" IS FALSE + AND "rawSourceStored" IS FALSE + AND "secretValueStored" IS FALSE + AND "evidenceFragmentStored" IS FALSE + AND "retrievalAttempted" IS FALSE + AND "toolsInvoked" IS FALSE + AND "retrievalAllowed" IS FALSE + AND "toolsAllowed" IS FALSE + AND "policyAuthority" IS FALSE + AND "publicationAuthority" IS FALSE + AND "lifecycleMutationAuthority" IS FALSE + AND "scmWriteAuthority" IS FALSE + ) +); + +CREATE UNIQUE INDEX "SastAiAdvisoryHandoff_requestId_key" + ON "SastAiAdvisoryHandoff"("requestId"); +CREATE UNIQUE INDEX "SastAiAdvisoryHandoff_advisoryId_key" + ON "SastAiAdvisoryHandoff"("advisoryId"); +CREATE UNIQUE INDEX "SastAiAdvisoryHandoff_requestDigest_key" + ON "SastAiAdvisoryHandoff"("requestDigest"); +CREATE UNIQUE INDEX "SastAiAdvisoryHandoff_handoffDigest_key" + ON "SastAiAdvisoryHandoff"("handoffDigest"); +CREATE UNIQUE INDEX "SastAiAdvisoryHandoff_tenant_scope_key" + ON "SastAiAdvisoryHandoff"("id", "tenantId"); +CREATE INDEX "SastAiAdvisoryHandoff_scan_idx" + ON "SastAiAdvisoryHandoff"( + "tenantId", + "repositoryBindingId", + "scanRequestId", + "createdAt" + ); +CREATE INDEX "SastAiAdvisoryHandoff_access_scope_idx" + ON "SastAiAdvisoryHandoff"( + "accessDecisionId", + "accessDecisionDigest", + "tenantId", + "repositoryBindingId", + "scanRequestId", + "attemptId", + "occurrenceId", + "evidencePackId", + "findingFingerprint", + "createdAt" + ); +CREATE INDEX "SastAiAdvisoryHandoff_finding_scope_idx" + ON "SastAiAdvisoryHandoff"( + "normalizedFindingId", + "tenantId", + "scanRequestId", + "scannerRunId" + ); +CREATE INDEX "SastAiAdvisoryHandoff_payloadExpiresAt_idx" + ON "SastAiAdvisoryHandoff"("payloadExpiresAt"); + +ALTER TABLE "SastAiAdvisoryHandoff" + ADD CONSTRAINT "SastAiAdvisoryHandoff_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "SastAiAdvisoryHandoff" + ADD CONSTRAINT "SastAiAdvisoryHandoff_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "SastAiAdvisoryHandoff" + ADD CONSTRAINT "SastAiAdvisoryHandoff_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE RESTRICT ON UPDATE CASCADE; +CREATE FUNCTION "reject_sast_ai_advisory_handoff_update"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'SAST AI advisory handoff ledgers are immutable' + USING ERRCODE = '55000'; + RETURN OLD; +END; +$$; + +CREATE TRIGGER "SastAiAdvisoryHandoff_immutable_update" + BEFORE UPDATE ON "SastAiAdvisoryHandoff" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_ai_advisory_handoff_update"(); + +CREATE TRIGGER "SastAiAdvisoryHandoff_immutable_delete" + BEFORE DELETE ON "SastAiAdvisoryHandoff" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_ai_advisory_handoff_update"(); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1e7b1ef..5e47380 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -382,6 +382,7 @@ model Tenant { sastRetryDecisions SastScanRetryDecision[] sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] + sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] users User[] } @@ -440,6 +441,7 @@ model RepositoryBinding { sastRetryDecisions SastScanRetryDecision[] sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] + sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -488,6 +490,7 @@ model ScanRequest { sastRetryDecisions SastScanRetryDecision[] sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] + sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -841,12 +844,13 @@ model NormalizedFinding { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) - policyDecisions PolicyDecision[] - suppressions Suppression[] - sastOccurrence SastFindingOccurrence? + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) + policyDecisions PolicyDecision[] + suppressions Suppression[] + sastOccurrence SastFindingOccurrence? + sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] // Installed concurrently by the mandatory online-schema step so legacy // rows remain deployable while T037 metadata is introduced. @@ -978,6 +982,7 @@ model SastFindingOccurrence { targetCorrelationEdges SastFindingCorrelationEdge[] @relation("SastFindingCorrelationTargetOccurrence") correlationProvenances SastFindingCorrelationProvenance[] evidenceBuildDecisions SastEvidenceBuildDecision[] + aiAdvisoryHandoffs SastAiAdvisoryHandoff[] @@unique([observationBatchId, ordinal], map: "SastFindingOccurrence_batch_ordinal_key") @@unique([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastFindingOccurrence_normalized_scope_key") @@ -1572,13 +1577,15 @@ model SastEvidenceAccessDecision { decidedAt DateTime createdAt DateTime @default(now()) - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_repository_scope_fkey") - scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastEvidenceAccessDecision_scan_scope_fkey") - buildDecision SastEvidenceBuildDecision @relation(fields: [buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceAccessDecision_build_scope_fkey") - deletionSchedule SastEvidenceDeletionSchedule @relation(fields: [deletionScheduleId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_schedule_scope_fkey") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastEvidenceAccessDecision_scan_scope_fkey") + buildDecision SastEvidenceBuildDecision @relation(fields: [buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceAccessDecision_build_scope_fkey") + deletionSchedule SastEvidenceDeletionSchedule @relation(fields: [deletionScheduleId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_schedule_scope_fkey") + aiAdvisoryHandoffs SastAiAdvisoryHandoff[] @@unique([id, tenantId], map: "SastEvidenceAccessDecision_tenant_scope_key") + @@unique([id, decisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, decidedAt], map: "SastEvidenceAccessDecision_ai_scope_key") @@index([tenantId, repositoryBindingId, evidencePackId, purpose], map: "SastEvidenceAccessDecision_lookup_idx") @@index([evidenceExpiresAt], map: "SastEvidenceAccessDecision_expiresAt_idx") @@index([aiPayloadExpiresAt], map: "SastEvidenceAccessDecision_aiPayloadExpiresAt_idx") @@ -1661,6 +1668,62 @@ model SastEvidenceDeletionProof { @@index([buildDecisionId], map: "SastEvidenceDeletionProof_buildDecisionId_idx") } +model SastAiAdvisoryHandoff { + id String @id + requestId String @unique + advisoryId String @unique + accessDecisionId String + accessDecisionDigest String + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + normalizedFindingId String + scannerRunId String + evidencePackId String + findingFingerprint String + modelVersion String + payloadExpiresAt DateTime + requestDigest String @unique + handoffDigest String @unique + normalizedFindingAllowed Boolean @default(true) + reducedEvidenceReferenceAllowed Boolean @default(true) + aiPayloadAllowed Boolean @default(true) + aiProviderCallAllowed Boolean @default(true) + advisoryOnly Boolean @default(true) + callerFindingAccepted Boolean @default(false) + callerEvidenceAccepted Boolean @default(false) + callerPromptAccepted Boolean @default(false) + requestPayloadStored Boolean @default(false) + rawSourceStored Boolean @default(false) + secretValueStored Boolean @default(false) + evidenceFragmentStored Boolean @default(false) + retrievalAttempted Boolean @default(false) + toolsInvoked Boolean @default(false) + retrievalAllowed Boolean @default(false) + toolsAllowed Boolean @default(false) + policyAuthority Boolean @default(false) + publicationAuthority Boolean @default(false) + lifecycleMutationAuthority Boolean @default(false) + scmWriteAuthority Boolean @default(false) + createdAt DateTime + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_scan_scope_fkey") + occurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_occurrence_scope_fkey") + normalizedFinding NormalizedFinding @relation(fields: [normalizedFindingId, tenantId, scanRequestId, scannerRunId], references: [id, tenantId, scanRequestId, scannerRunId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_finding_scope_fkey") + accessDecision SastEvidenceAccessDecision @relation(fields: [accessDecisionId, accessDecisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, createdAt], references: [id, decisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, decidedAt], onDelete: Restrict, map: "SastAiAdvisoryHandoff_access_scope_fkey") + advisoryMetadata AiAdvisoryMetadata? + + @@unique([id, tenantId], map: "SastAiAdvisoryHandoff_tenant_scope_key") + @@index([tenantId, repositoryBindingId, scanRequestId, createdAt], map: "SastAiAdvisoryHandoff_scan_idx") + @@index([accessDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId], map: "SastAiAdvisoryHandoff_access_scope_idx") + @@index([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastAiAdvisoryHandoff_finding_scope_idx") + @@index([payloadExpiresAt], map: "SastAiAdvisoryHandoff_payloadExpiresAt_idx") +} + model SastFindingCorrelationEdge { id String @id correlationBatchId String @@ -1781,6 +1844,7 @@ model PolicyDecision { model AiAdvisoryMetadata { id String @id + sastHandoffId String? @unique tenantId String scanRequestId String findingId String @@ -1797,8 +1861,9 @@ model AiAdvisoryMetadata { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + sastHandoff SastAiAdvisoryHandoff? @relation(fields: [sastHandoffId], references: [id], onDelete: Restrict, map: "AiAdvisoryMetadata_sastHandoffId_fkey") @@index([tenantId]) @@index([scanRequestId]) diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index a96d54d..28daf3c 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -99,6 +99,18 @@ const indexes = [ create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastFindingOccurrence_correlation_scope_key" ON "SastFindingOccurrence"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId")' }, + { + name: 'SastEvidenceAccessDecision_ai_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastEvidenceAccessDecision_ai_scope_key" ON "SastEvidenceAccessDecision"("id", "decisionDigest", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "evidencePackId", "findingFingerprint", "decidedAt")' + }, + { + name: 'AiAdvisoryMetadata_sastHandoffId_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AiAdvisoryMetadata_sastHandoffId_key" ON "AiAdvisoryMetadata"("sastHandoffId")' + }, { name: 'SastArtifactDispositionDecision_coverage_scope_key', unique: true, @@ -609,6 +621,34 @@ const constraints = [ definition: 'FOREIGN KEY ("occurrenceId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") REFERENCES "SastFindingOccurrence"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") ON DELETE CASCADE ON UPDATE CASCADE' }, + { + table: 'SastAiAdvisoryHandoff', + name: 'SastAiAdvisoryHandoff_occurrence_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("occurrenceId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") REFERENCES "SastFindingOccurrence"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") ON DELETE RESTRICT ON UPDATE CASCADE' + }, + { + table: 'SastAiAdvisoryHandoff', + name: 'SastAiAdvisoryHandoff_finding_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("normalizedFindingId", "tenantId", "scanRequestId", "scannerRunId") REFERENCES "NormalizedFinding"("id", "tenantId", "scanRequestId", "scannerRunId") ON DELETE RESTRICT ON UPDATE CASCADE' + }, + { + table: 'SastAiAdvisoryHandoff', + name: 'SastAiAdvisoryHandoff_access_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("accessDecisionId", "accessDecisionDigest", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "evidencePackId", "findingFingerprint", "createdAt") REFERENCES "SastEvidenceAccessDecision"("id", "decisionDigest", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "evidencePackId", "findingFingerprint", "decidedAt") ON DELETE RESTRICT ON UPDATE CASCADE' + }, + { + table: 'AiAdvisoryMetadata', + name: 'AiAdvisoryMetadata_sastHandoffId_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("sastHandoffId") REFERENCES "SastAiAdvisoryHandoff"("id") ON DELETE RESTRICT ON UPDATE CASCADE' + }, { table: 'SastFindingCorrelationEdge', name: 'SastFindingCorrelationEdge_source_occurrence_scope_fkey', diff --git a/apps/api/src/ai-plane/ai-advisory-runtime.client.ts b/apps/api/src/ai-plane/ai-advisory-runtime.client.ts index 50c69a3..afdf855 100644 --- a/apps/api/src/ai-plane/ai-advisory-runtime.client.ts +++ b/apps/api/src/ai-plane/ai-advisory-runtime.client.ts @@ -1,183 +1,313 @@ -import { BadGatewayException, Injectable } from "@nestjs/common"; -import axios from "axios"; +import { + isSastAiAdvisoryHandoffShapeValid, + type AiInferenceRequest, + type AiInferenceResponse, + type SastAiAdvisoryHandoff +} from '@aegisai/shared'; +import { BadGatewayException, Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; -import { ConfigService } from "../config/config.service"; - -import type { AiAdvisoryRequest, AiInferenceRequest, AiInferenceResponse } from '@aegisai/shared'; +import { ConfigService } from '../config/config.service'; const FORBIDDEN_RUNTIME_RESPONSE_KEYS = [ - "accessToken", - "refreshToken", - "tokenValue", - "secretValue", - "sourceArchive", - "fullRepository", - "rawScannerPayload", - "policyOverride", - "findingOverride", - "enforcementAction", - "blockRequested", - "waiverApplied", - "staleSuppressed" + 'accessToken', + 'refreshToken', + 'tokenValue', + 'secretValue', + 'sourceArchive', + 'fullRepository', + 'rawScannerPayload', + 'policyOverride', + 'findingOverride', + 'enforcementAction', + 'blockRequested', + 'waiverApplied', + 'staleSuppressed' ]; +const MAX_RUNTIME_ADVISORIES = 32; +const MAX_RUNTIME_SIGNALS = 32; +const MAX_RUNTIME_TEXT_BYTES = 2048; +const MAX_RUNTIME_METADATA_BYTES = 128; +const MAX_RUNTIME_SCAN_DEPTH = 12; +const MAX_RUNTIME_SCAN_COLLECTION = 64; +const MAX_RUNTIME_LATENCY_MILLISECONDS = 30_000; @Injectable() export class AiAdvisoryRuntimeClient { constructor(private readonly config: ConfigService) {} - async createAdvisory(input: AiAdvisoryRequest): Promise { + async createAdvisory( + handoff: Readonly + ): Promise { + if (!isSastAiAdvisoryHandoffShapeValid(handoff, digest)) { + throw new BadGatewayException( + 'AI advisory handoff is malformed.' + ); + } try { - const response = await axios.post(this.runtimeUrl(), this.toInferenceRequest(input), { - timeout: this.config.get("AI_ADVISORY_TIMEOUT_MS") - }); - - return this.parseRuntimeOutput(response.data); + const timeoutMs = this.timeoutMs(); + const response = await axios.post( + this.runtimeUrl(), + this.toInferenceRequest(handoff, timeoutMs), + { timeout: timeoutMs } + ); + return this.parseRuntimeOutput(response.data, handoff); } catch (error) { - if (error instanceof BadGatewayException) { - throw error; - } - - throw new BadGatewayException("AI advisory runtime request failed."); + if (error instanceof BadGatewayException) throw error; + throw new BadGatewayException( + 'AI advisory runtime request failed.' + ); } } - private toInferenceRequest(input: AiAdvisoryRequest): AiInferenceRequest { - const maxLatencyMs = Number(this.config.get("AI_ADVISORY_TIMEOUT_MS")); - const location = - input.normalizedFinding.lineEnd && input.normalizedFinding.lineEnd !== input.normalizedFinding.lineStart - ? `${input.normalizedFinding.filePath}:${input.normalizedFinding.lineStart}-${input.normalizedFinding.lineEnd}` - : `${input.normalizedFinding.filePath}:${input.normalizedFinding.lineStart}`; - + private toInferenceRequest( + handoff: Readonly, + maxLatencyMs: number + ): AiInferenceRequest { + const finding = handoff.normalizedFinding; + const reference = handoff.reducedEvidenceReference; return { - tenantId: input.tenantId, - scanRequestId: input.scanRequestId, + tenantId: handoff.tenantId, + scanRequestId: handoff.scanRequestId, canonicalScanKey: [ - input.tenantId, - input.scanRequestId, - input.findingId, - "AI_ADVISORY", - input.modelVersion - ].join(":"), - requestId: `ai_inference_${input.scanRequestId}_${input.findingId}`, + handoff.tenantId, + handoff.repositoryBindingId, + handoff.scanRequestId, + handoff.attemptId, + handoff.accessDecisionDigest, + handoff.modelVersion + ].join(':'), + requestId: handoff.requestId, + modelVersion: handoff.modelVersion, reducedEvidence: { - findingIds: [input.findingId], - scannerNames: [input.normalizedFinding.scannerProvenance], - evidencePackId: input.evidence.id, - summary: `${input.normalizedFinding.title} (${input.normalizedFinding.severity})`, - snippets: [ - { - label: "finding-location", - redactedText: location - } - ], + findingIds: [finding.normalizedFindingId], + scannerNames: [finding.scanner], + evidencePackId: handoff.evidencePackId, + summary: `${finding.title} (${finding.severity})`, + snippets: [], metadata: { - severity: input.normalizedFinding.severity, - scannerProvenance: input.normalizedFinding.scannerProvenance, - findingStatus: input.normalizedFinding.status, - evidenceByteSize: input.evidence.byteSize, - evidenceExpiresAt: input.evidence.expiresAt + handoffVersion: handoff.version, + handoffDigest: handoff.handoffDigest, + requestDigest: handoff.requestDigest, + repositoryBindingId: handoff.repositoryBindingId, + attemptId: handoff.attemptId, + occurrenceId: finding.occurrenceId, + normalizedFindingId: finding.normalizedFindingId, + findingFingerprint: finding.findingFingerprint, + capability: finding.capability, + severity: finding.severity, + confidence: finding.confidence, + scanner: finding.scanner, + ruleSemanticId: finding.ruleSemanticId, + ruleRevision: finding.ruleRevision, + location: locationReference(finding.location), + cweIds: finding.cweIds.join(','), + cveIds: finding.cveIds.join(','), + accessDecisionId: handoff.accessDecisionId, + accessDecisionDigest: handoff.accessDecisionDigest, + reducedEvidenceRef: reference.reducedEvidenceRef, + redactedProjectionDigest: + reference.redactedProjectionDigest, + fragmentCount: reference.fragmentCount, + payloadExpiresAt: handoff.payloadExpiresAt, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true }, - redactionState: input.evidence.redacted ? "redacted" : "reduced" + redactionState: 'reduced' }, - requestedCapabilities: ["detector", "planner"], + requestedCapabilities: ['detector', 'planner'], runtimePolicy: { allowFallback: true, - maxLatencyMs: Number.isFinite(maxLatencyMs) ? maxLatencyMs : 2500 + maxLatencyMs }, - createdAt: new Date().toISOString() + createdAt: handoff.createdAt }; } - private parseRuntimeOutput(input: unknown): AiInferenceResponse { - if (!input || typeof input !== "object" || Array.isArray(input)) { - throw new BadGatewayException("AI advisory runtime response must be an object."); + private parseRuntimeOutput( + input: unknown, + handoff: Readonly + ): AiInferenceResponse { + if (!isRecord(input)) { + throw new BadGatewayException( + 'AI advisory runtime response must be an object.' + ); } - if (hasForbiddenRuntimeResponseKey(input)) { - throw new BadGatewayException("AI advisory runtime response contains forbidden authority or sensitive content."); + throw new BadGatewayException( + 'AI advisory runtime response contains forbidden authority or sensitive content.' + ); } - const candidate = input as Partial; - + const findingId = + handoff.normalizedFinding.normalizedFindingId; if ( - typeof candidate.requestId !== "string" || - typeof candidate.tenantId !== "string" || - typeof candidate.scanRequestId !== "string" || + candidate.requestId !== handoff.requestId || + candidate.tenantId !== handoff.tenantId || + candidate.scanRequestId !== handoff.scanRequestId || candidate.advisoryOnly !== true || !Array.isArray(candidate.detectorAdvisories) || - !candidate.detectorAdvisories.every(isDetectorAdvisory) || + candidate.detectorAdvisories.length > MAX_RUNTIME_ADVISORIES || + !candidate.detectorAdvisories.every( + (advisory) => + isDetectorAdvisory(advisory) && + advisory.findingId === findingId + ) || !Array.isArray(candidate.plannerAdvisories) || - !candidate.plannerAdvisories.every(isPlannerAdvisory) || - !candidate.modelMetadata || - typeof candidate.modelMetadata.provider !== "string" || - typeof candidate.modelMetadata.model !== "string" || - typeof candidate.modelMetadata.version !== "string" || - !candidate.fallback || - typeof candidate.fallback.used !== "boolean" || - (candidate.fallback.reason !== undefined && typeof candidate.fallback.reason !== "string") || - typeof candidate.latencyMs !== "number" || - typeof candidate.createdAt !== "string" + candidate.plannerAdvisories.length > MAX_RUNTIME_ADVISORIES || + !candidate.plannerAdvisories.every( + (advisory) => + isPlannerAdvisory(advisory) && + (advisory.findingId === undefined || + advisory.findingId === findingId) + ) || + !isRecord(candidate.modelMetadata) || + !isBoundedRuntimeResponseText( + candidate.modelMetadata.provider, + MAX_RUNTIME_METADATA_BYTES + ) || + !isBoundedRuntimeResponseText( + candidate.modelMetadata.model, + MAX_RUNTIME_METADATA_BYTES + ) || + typeof candidate.modelMetadata.version !== 'string' || + candidate.modelMetadata.version !== handoff.modelVersion || + !isRecord(candidate.fallback) || + typeof candidate.fallback.used !== 'boolean' || + (candidate.fallback.reason !== undefined && + !isBoundedRuntimeResponseText( + candidate.fallback.reason, + MAX_RUNTIME_TEXT_BYTES + )) || + typeof candidate.latencyMs !== 'number' || + !Number.isFinite(candidate.latencyMs) || + candidate.latencyMs < 0 || + candidate.latencyMs > MAX_RUNTIME_LATENCY_MILLISECONDS || + typeof candidate.createdAt !== 'string' || + !Number.isFinite(Date.parse(candidate.createdAt)) ) { - throw new BadGatewayException("AI advisory runtime response is malformed."); + throw new BadGatewayException( + 'AI advisory runtime response is malformed.' + ); } - return candidate as AiInferenceResponse; } + private timeoutMs(): number { + const configured = Number( + this.config.get('AI_ADVISORY_TIMEOUT_MS') + ); + return Number.isFinite(configured) && + configured > 0 && + configured <= MAX_RUNTIME_LATENCY_MILLISECONDS + ? configured + : 2500; + } + private runtimeUrl(): string { - return `${this.config.get("AI_SERVER_URL").replace(/\/$/, "")}/ai/advisories`; + return `${this.config.get('AI_SERVER_URL').replace(/\/$/, '')}/ai/advisories`; } } -function isDetectorAdvisory(input: unknown): boolean { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return false; +function locationReference( + location: Readonly< + SastAiAdvisoryHandoff['normalizedFinding']['location'] + > +): string { + if (location.kind === 'UNKNOWN') { + return `UNKNOWN:${location.reasonCode}`; } + const lines = + location.lineEnd && location.lineEnd !== location.lineStart + ? `${location.lineStart}-${location.lineEnd}` + : String(location.lineStart); + return `${location.normalizedPath}:${lines}`; +} - const candidate = input as Record; - +function isDetectorAdvisory( + input: unknown +): input is AiInferenceResponse['detectorAdvisories'][number] { + if (!isRecord(input)) return false; return ( - typeof candidate.findingId === "string" && - typeof candidate.confidence === "number" && - candidate.confidence >= 0 && - candidate.confidence <= 1 && - typeof candidate.rationale === "string" && - Array.isArray(candidate.signals) && - candidate.signals.every((signal) => typeof signal === "string") + typeof input.findingId === 'string' && + typeof input.confidence === 'number' && + input.confidence >= 0 && + input.confidence <= 1 && + isBoundedRuntimeResponseText( + input.rationale, + MAX_RUNTIME_TEXT_BYTES + ) && + Array.isArray(input.signals) && + input.signals.length <= MAX_RUNTIME_SIGNALS && + input.signals.every((signal) => + isBoundedRuntimeResponseText(signal, MAX_RUNTIME_TEXT_BYTES) + ) ); } -function isPlannerAdvisory(input: unknown): boolean { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return false; - } - - const candidate = input as Record; - +function isPlannerAdvisory( + input: unknown +): input is AiInferenceResponse['plannerAdvisories'][number] { + if (!isRecord(input)) return false; return ( - (candidate.findingId === undefined || typeof candidate.findingId === "string") && - typeof candidate.action === "string" && - typeof candidate.rationale === "string" && - (candidate.priority === "low" || candidate.priority === "medium" || candidate.priority === "high") + (input.findingId === undefined || + typeof input.findingId === 'string') && + isBoundedRuntimeResponseText( + input.action, + MAX_RUNTIME_TEXT_BYTES + ) && + isBoundedRuntimeResponseText( + input.rationale, + MAX_RUNTIME_TEXT_BYTES + ) && + (input.priority === 'low' || + input.priority === 'medium' || + input.priority === 'high') ); } -function hasForbiddenRuntimeResponseKey(input: unknown): boolean { - if (input === null || typeof input !== "object") { - return false; - } - +function hasForbiddenRuntimeResponseKey( + input: unknown, + depth = 0 +): boolean { + if (depth > MAX_RUNTIME_SCAN_DEPTH) return true; + if (input === null || typeof input !== 'object') return false; if (Array.isArray(input)) { - return input.some((item) => hasForbiddenRuntimeResponseKey(item)); + return input.length > MAX_RUNTIME_SCAN_COLLECTION || + input.some((item) => + hasForbiddenRuntimeResponseKey(item, depth + 1) + ); } - - return Object.entries(input as Record).some( - ([key, value]) => isForbiddenRuntimeResponseKey(key) || hasForbiddenRuntimeResponseKey(value) + const entries = Object.entries(input as Record); + return entries.length > MAX_RUNTIME_SCAN_COLLECTION || entries.some( + ([key, value]) => + FORBIDDEN_RUNTIME_RESPONSE_KEYS.some( + (forbidden) => + forbidden.toLowerCase() === key.toLowerCase() + ) || hasForbiddenRuntimeResponseKey(value, depth + 1) ); } -function isForbiddenRuntimeResponseKey(key: string): boolean { - return FORBIDDEN_RUNTIME_RESPONSE_KEYS.some( - (forbiddenKey) => forbiddenKey.toLowerCase() === key.toLowerCase() - ); +function isBoundedRuntimeResponseText( + value: unknown, + maximumBytes: number +): value is string { + return typeof value === 'string' && + value.length > 0 && + Buffer.byteLength(value, 'utf8') <= maximumBytes; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; } diff --git a/apps/api/src/ai-plane/ai-advisory.controller.ts b/apps/api/src/ai-plane/ai-advisory.controller.ts index 6bac03f..147eeba 100644 --- a/apps/api/src/ai-plane/ai-advisory.controller.ts +++ b/apps/api/src/ai-plane/ai-advisory.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; -import type { AiAdvisoryRequest } from '@aegisai/shared'; +import type { SastAiAdvisoryIntent } from '@aegisai/shared'; import { CurrentTenant } from '../auth/decorators/current-tenant.decorator'; import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../common/security/internal-service.guard'; @@ -12,7 +12,7 @@ export class AiAdvisoryController { @Post() @UseGuards(InternalServiceGuard) - create(@Body() body: AiAdvisoryRequest) { + create(@Body() body: SastAiAdvisoryIntent) { return this.aiAdvisoryService.createAdvisory(body); } diff --git a/apps/api/src/ai-plane/ai-advisory.service.ts b/apps/api/src/ai-plane/ai-advisory.service.ts index 68b5292..65ef623 100644 --- a/apps/api/src/ai-plane/ai-advisory.service.ts +++ b/apps/api/src/ai-plane/ai-advisory.service.ts @@ -1,18 +1,27 @@ -import { BadRequestException, Injectable, NotFoundException, Optional } from "@nestjs/common"; -import { randomUUID } from "node:crypto"; - -import type { - AiAdvisoryRequest, - AiAdvisoryResult, - AiDetectorAdvisory, - AiInferenceFallback, - AiInferenceResponse, - AiModelMetadata, - AiPlannerAdvisory +import { + buildSastAiAdvisoryHandoff, + isSastAiAdvisoryIntentShapeValid, + type AiAdvisoryResult, + type AiDetectorAdvisory, + type AiInferenceFallback, + type AiInferenceResponse, + type AiModelMetadata, + type AiPlannerAdvisory, + type SastAiAdvisoryHandoff, + type SastAiAdvisoryIntent } from '@aegisai/shared'; -import { ConfigService } from "../config/config.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { AiAdvisoryRuntimeClient } from "./ai-advisory-runtime.client"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException +} from '@nestjs/common'; +import { createHash } from 'node:crypto'; + +import { ConfigService } from '../config/config.service'; +import { SastEvidenceAccessService } from '../scan-plane/sast-evidence-access.service'; +import { AiAdvisoryRuntimeClient } from './ai-advisory-runtime.client'; +import { SastAiAdvisoryStore } from './sast-ai-advisory.store'; interface AiAdvisoryRuntimeProjection { detectorSignals: string[]; @@ -25,200 +34,243 @@ interface AiAdvisoryRuntimeProjection { fallback?: AiInferenceFallback; } -interface AiAdvisoryMetadataRecord { - id: string; - tenantId: string; - scanRequestId: string; - findingId: string; - modelVersion: string; - advisoryOnly: boolean; - redactedEvidenceOnly: boolean; - detectorSignals: unknown; - plannerSteps: unknown; - confidence: number; - detectorAdvisories?: unknown; - plannerAdvisories?: unknown; - modelMetadata?: unknown; - fallback?: unknown; - createdAt: Date | string; -} - -interface AiAdvisoryMetadataDelegate { - create(input: { data: Record }): Promise; - findFirst(input: { where: { id: string; tenantId: string } }): Promise; -} - -const FORBIDDEN_AI_INPUT_KEYS = [ - "accessToken", - "refreshToken", - "tokenValue", - "secretValue", - "sourceArchive", - "fullRepository", - "rawScannerPayload", - "policyOverride", - "findingOverride" -]; +type AdvisoryClock = () => string; @Injectable() export class AiAdvisoryService { - private readonly advisories: AiAdvisoryResult[] = []; - private advisorySequence = 0; + private readonly logger = new Logger(AiAdvisoryService.name); constructor( - private readonly config?: ConfigService, - private readonly runtimeClient?: AiAdvisoryRuntimeClient, - @Optional() private readonly prisma?: PrismaService + private readonly config: ConfigService, + private readonly runtimeClient: AiAdvisoryRuntimeClient, + private readonly evidenceAccess: SastEvidenceAccessService, + private readonly store: SastAiAdvisoryStore ) {} - async createAdvisory(input: AiAdvisoryRequest): Promise { - this.assertReducedInput(input); - const runtimeOutput = await this.resolveRuntimeOutput(input); - const persistentStore = this.persistentStore(); + async createAdvisory( + input: SastAiAdvisoryIntent, + clock: AdvisoryClock = () => new Date().toISOString() + ): Promise { + if (!isSastAiAdvisoryIntentShapeValid(input)) { + throw new BadRequestException( + 'AI advisory intent must contain only durable scope identifiers.' + ); + } - const advisory: AiAdvisoryResult = { - id: persistentStore ? randomUUID() : `ai_advisory_${++this.advisorySequence}`, + const startedAt = readClock(clock); + if (!startedAt) throw unavailable(); + + const scope = { tenantId: input.tenantId, - scanRequestId: input.scanRequestId, - findingId: input.findingId, - modelVersion: runtimeOutput.modelVersion, - advisoryOnly: true, - redactedEvidenceOnly: true, - detectorSignals: runtimeOutput.detectorSignals, - plannerSteps: runtimeOutput.plannerSteps, - confidence: runtimeOutput.confidence, - detectorAdvisories: runtimeOutput.detectorAdvisories, - plannerAdvisories: runtimeOutput.plannerAdvisories, - modelMetadata: runtimeOutput.modelMetadata, - fallback: runtimeOutput.fallback, - createdAt: new Date().toISOString() + repositoryBindingId: input.repositoryBindingId, + evidencePackId: input.evidencePackId }; + const firstAccess = await this.classify(scope, clock); + if (firstAccess.outcome !== 'ALLOWED') throw unavailable(); - if (persistentStore) { - return this.toAdvisoryResult( - await persistentStore.create({ - data: { - id: advisory.id, - tenantId: advisory.tenantId, - scanRequestId: advisory.scanRequestId, - findingId: advisory.findingId, - modelVersion: advisory.modelVersion, - advisoryOnly: advisory.advisoryOnly, - redactedEvidenceOnly: advisory.redactedEvidenceOnly, - detectorSignals: advisory.detectorSignals, - plannerSteps: advisory.plannerSteps, - confidence: advisory.confidence, - detectorAdvisories: advisory.detectorAdvisories, - plannerAdvisories: advisory.plannerAdvisories, - modelMetadata: advisory.modelMetadata, - fallback: advisory.fallback - } - }) - ); + const normalizedFinding = await this.loadFinding( + firstAccess.decision + ); + if (!normalizedFinding) throw unavailable(); + + const reboundAt = readClock(clock); + if ( + !reboundAt || + Date.parse(reboundAt) < Date.parse(startedAt) + ) { + throw unavailable(); + } + const finalAccess = await this.classify(scope, clock); + if ( + finalAccess.outcome !== 'ALLOWED' || + !sameAccess(firstAccess, finalAccess) || + finalAccess.reducedEvidenceReference === null + ) { + throw unavailable(); } + const reducedEvidenceReference = + finalAccess.reducedEvidenceReference; - this.advisories.push(advisory); + const createdAt = readClock(clock); + if ( + !createdAt || + Date.parse(createdAt) < Date.parse(reboundAt) || + Date.parse(createdAt) >= + Date.parse(reducedEvidenceReference.payloadExpiresAt) + ) { + throw unavailable(); + } + const handoff = buildSastAiAdvisoryHandoff({ + decision: finalAccess.decision, + reducedEvidenceReference, + normalizedFinding, + modelVersion: input.modelVersion, + createdAt, + digestCanonical: digest + }); + if (!handoff) throw unavailable(); + + const { persisted, existing } = + await this.persistHandoffAndRead(handoff); + if (existing) return existing; + + const invokedAt = readClock(clock); + if ( + !invokedAt || + Date.parse(invokedAt) < Date.parse(createdAt) || + Date.parse(invokedAt) >= Date.parse(handoff.payloadExpiresAt) + ) { + throw unavailable(); + } - return advisory; - } + const runtimeOutput = await this.resolveRuntimeOutput( + persisted.handoff + ); + const completedAt = readClock(clock); + if ( + !completedAt || + Date.parse(completedAt) < Date.parse(invokedAt) || + Date.parse(completedAt) >= Date.parse(handoff.payloadExpiresAt) + ) { + throw unavailable(); + } - async getAdvisory(tenantId: string, advisoryId: string): Promise { - const persistentStore = this.persistentStore(); - if (persistentStore) { - const advisory = await persistentStore.findFirst({ - where: { - id: advisoryId, - tenantId + try { + return await this.store.persistAdvisory({ + handoff: persisted.handoff, + advisory: { + id: handoff.advisoryId, + sastHandoffId: handoff.handoffId, + requestDigest: handoff.requestDigest, + tenantId: handoff.tenantId, + scanRequestId: handoff.scanRequestId, + findingId: handoff.normalizedFinding.normalizedFindingId, + modelVersion: runtimeOutput.modelVersion, + advisoryOnly: true, + redactedEvidenceOnly: true, + detectorSignals: runtimeOutput.detectorSignals, + plannerSteps: runtimeOutput.plannerSteps, + confidence: runtimeOutput.confidence, + detectorAdvisories: runtimeOutput.detectorAdvisories, + plannerAdvisories: runtimeOutput.plannerAdvisories, + modelMetadata: runtimeOutput.modelMetadata, + fallback: runtimeOutput.fallback, + createdAt: completedAt } }); - - if (advisory) { - return this.toAdvisoryResult(advisory); - } + } catch (error) { + this.logFailure('persistence', error, handoff.handoffId); + throw unavailable(); } + } - const advisory = this.advisories.find( - (candidate) => candidate.id === advisoryId && candidate.tenantId === tenantId - ); - + async getAdvisory( + tenantId: string, + advisoryId: string + ): Promise { + const advisory = await this.store.loadAdvisory({ + tenantId, + advisoryId + }); if (!advisory) { - throw new NotFoundException("AI advisory was not found for tenant."); + throw new NotFoundException( + 'AI advisory was not found for tenant.' + ); } - return advisory; } - private persistentStore(): AiAdvisoryMetadataDelegate | undefined { - const candidate = this.prisma as unknown as { aiAdvisoryMetadata?: AiAdvisoryMetadataDelegate } | undefined; - - if ( - candidate?.aiAdvisoryMetadata && - typeof candidate.aiAdvisoryMetadata.create === "function" && - typeof candidate.aiAdvisoryMetadata.findFirst === "function" - ) { - return candidate.aiAdvisoryMetadata; + private async classify( + scope: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + }, + clock: AdvisoryClock + ) { + try { + return await this.evidenceAccess.classifyForAi(scope, clock); + } catch (error) { + this.logFailure('access classification', error); + throw unavailable(); } - - return undefined; } - private toAdvisoryResult(record: AiAdvisoryMetadataRecord): AiAdvisoryResult { - return { - id: record.id, - tenantId: record.tenantId, - scanRequestId: record.scanRequestId, - findingId: record.findingId, - modelVersion: record.modelVersion, - advisoryOnly: true, - redactedEvidenceOnly: true, - detectorSignals: toStringArray(record.detectorSignals), - plannerSteps: toStringArray(record.plannerSteps), - confidence: record.confidence, - detectorAdvisories: toDetectorAdvisories(record.detectorAdvisories), - plannerAdvisories: toPlannerAdvisories(record.plannerAdvisories), - modelMetadata: toModelMetadata(record.modelMetadata), - fallback: toFallback(record.fallback), - createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt - }; + private async loadFinding( + decision: Parameters< + SastAiAdvisoryStore['loadNormalizedFinding'] + >[0] + ) { + try { + return await this.store.loadNormalizedFinding(decision); + } catch (error) { + this.logFailure('finding load', error); + throw unavailable(); + } } - private assertReducedInput(input: AiAdvisoryRequest): void { - if (!input.evidence.redacted) { - throw new BadRequestException("AI advisory input must use redacted evidence."); + private async resolveRuntimeOutput( + handoff: Readonly + ): Promise { + if (this.config.get('USE_INTERNAL_AI') === 'true') { + return this.projectInferenceResponse( + await this.runtimeClient.createAdvisory(handoff) + ); } - const serialized = JSON.stringify(input); - - for (const forbiddenKey of FORBIDDEN_AI_INPUT_KEYS) { - if (new RegExp(forbiddenKey, "i").test(serialized)) { - throw new BadRequestException("AI advisory input contains forbidden sensitive content."); - } - } + return { + detectorSignals: this.detectorSignalsFor(handoff), + plannerSteps: this.plannerStepsFor(handoff), + confidence: this.confidenceFor(handoff), + modelVersion: handoff.modelVersion + }; } - private async resolveRuntimeOutput(input: AiAdvisoryRequest): Promise { - if (this.config?.get("USE_INTERNAL_AI") === "true") { - if (!this.runtimeClient) { - throw new BadRequestException("AI advisory runtime client is not configured."); - } - - return this.projectInferenceResponse(await this.runtimeClient.createAdvisory(input)); + private async persistHandoffAndRead( + handoff: Readonly + ) { + try { + const persisted = await this.store.persistHandoff(handoff); + const existing = await this.store.loadAdvisory({ + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId + }); + return { persisted, existing }; + } catch (error) { + this.logFailure('handoff persistence', error, handoff.handoffId); + throw unavailable(); } + } - return { - detectorSignals: this.detectorSignalsFor(input), - plannerSteps: this.plannerStepsFor(input), - confidence: this.confidenceFor(input), - modelVersion: input.modelVersion - }; + private logFailure( + stage: string, + error: unknown, + handoffId?: string + ): void { + const category = safeErrorCategory(error); + const handoff = handoffId === undefined ? '' : ` [${handoffId}]`; + this.logger.error( + `AI advisory ${stage} failed${handoff} (${category}).` + ); } - private projectInferenceResponse(response: AiInferenceResponse): AiAdvisoryRuntimeProjection { + private projectInferenceResponse( + response: AiInferenceResponse + ): AiAdvisoryRuntimeProjection { return { - detectorSignals: Array.from(new Set(response.detectorAdvisories.flatMap((advisory) => advisory.signals))), - plannerSteps: response.plannerAdvisories.map((advisory) => advisory.action), + detectorSignals: Array.from( + new Set( + response.detectorAdvisories.flatMap( + (advisory) => advisory.signals + ) + ) + ), + plannerSteps: response.plannerAdvisories.map( + (advisory) => advisory.action + ), confidence: response.detectorAdvisories.reduce( - (highestConfidence, advisory) => Math.max(highestConfidence, advisory.confidence), + (highest, advisory) => + Math.max(highest, advisory.confidence), 0 ), modelVersion: response.modelMetadata.version, @@ -229,125 +281,104 @@ export class AiAdvisoryService { }; } - private detectorSignalsFor(input: AiAdvisoryRequest): string[] { + private detectorSignalsFor( + handoff: Readonly + ): string[] { return [ - "SCANNER_CONFIRMED", - `SEVERITY_${input.normalizedFinding.severity}`, - `PROVENANCE_${input.normalizedFinding.scannerProvenance}` + 'SCANNER_CONFIRMED', + `SEVERITY_${handoff.normalizedFinding.severity}`, + `PROVENANCE_${handoff.normalizedFinding.scanner}`, + 'T043_REDUCED_REFERENCE_ONLY' ]; } - private plannerStepsFor(input: AiAdvisoryRequest): string[] { - const steps = ["Review scanner evidence before remediation."]; - - if (input.normalizedFinding.severity === "CRITICAL" || input.normalizedFinding.severity === "HIGH") { - steps.push("Prioritize owner review before merging affected changes."); + private plannerStepsFor( + handoff: Readonly + ): string[] { + const steps = [ + 'Review normalized scanner evidence before remediation.' + ]; + if ( + handoff.normalizedFinding.severity === 'CRITICAL' || + handoff.normalizedFinding.severity === 'HIGH' + ) { + steps.push( + 'Prioritize owner review before merging affected changes.' + ); } - - steps.push("Apply remediation outside the AI advisory boundary."); - + steps.push( + 'Keep remediation, policy, and merge decisions outside the AI Plane.' + ); return steps; } - private confidenceFor(input: AiAdvisoryRequest): number { - if (input.normalizedFinding.severity === "CRITICAL") { - return 0.82; - } - - if (input.normalizedFinding.severity === "HIGH") { - return 0.74; - } - + private confidenceFor( + handoff: Readonly + ): number { + if (handoff.normalizedFinding.severity === 'CRITICAL') return 0.82; + if (handoff.normalizedFinding.severity === 'HIGH') return 0.74; return 0.61; } } -function toStringArray(input: unknown): string[] { - return Array.isArray(input) ? input.filter((item): item is string => typeof item === "string") : []; -} - -function toDetectorAdvisories(input: unknown): AiDetectorAdvisory[] | undefined { - if (!Array.isArray(input)) { - return undefined; - } - - return input.filter(isDetectorAdvisory); -} - -function toPlannerAdvisories(input: unknown): AiPlannerAdvisory[] | undefined { - if (!Array.isArray(input)) { - return undefined; - } - - return input.filter(isPlannerAdvisory); -} - -function toModelMetadata(input: unknown): AiModelMetadata | undefined { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return undefined; - } - - const candidate = input as Record; - +function sameAccess( + left: Awaited< + ReturnType + > & { outcome: 'ALLOWED' }, + right: Awaited< + ReturnType + > & { outcome: 'ALLOWED' } +): boolean { if ( - typeof candidate.provider === "string" && - typeof candidate.model === "string" && - typeof candidate.version === "string" + left.reducedEvidenceReference === null || + right.reducedEvidenceReference === null ) { - return { - provider: candidate.provider, - model: candidate.model, - version: candidate.version - }; - } - - return undefined; -} - -function toFallback(input: unknown): AiInferenceFallback | undefined { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return undefined; - } - - const candidate = input as Record; - - if (typeof candidate.used !== "boolean") { - return undefined; - } - - return { - used: candidate.used, - reason: typeof candidate.reason === "string" ? candidate.reason : undefined - }; -} - -function isDetectorAdvisory(input: unknown): input is AiDetectorAdvisory { - if (!input || typeof input !== "object" || Array.isArray(input)) { return false; } - - const candidate = input as Record; - return ( - typeof candidate.findingId === "string" && - typeof candidate.confidence === "number" && - typeof candidate.rationale === "string" && - Array.isArray(candidate.signals) && - candidate.signals.every((signal) => typeof signal === "string") + left.decision.accessDecisionId === + right.decision.accessDecisionId && + left.decision.decisionDigest === + right.decision.decisionDigest && + left.reducedEvidenceReference.reducedEvidenceRef === + right.reducedEvidenceReference.reducedEvidenceRef && + left.reducedEvidenceReference.redactedProjectionDigest === + right.reducedEvidenceReference.redactedProjectionDigest && + left.reducedEvidenceReference.payloadExpiresAt === + right.reducedEvidenceReference.payloadExpiresAt ); } -function isPlannerAdvisory(input: unknown): input is AiPlannerAdvisory { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return false; +function safeErrorCategory(error: unknown): string { + if (!(error instanceof Error)) return 'UnknownError'; + return [ + 'Error', + 'TypeError', + 'PrismaClientKnownRequestError', + 'PrismaClientUnknownRequestError', + 'PrismaClientInitializationError' + ].includes(error.name) + ? error.name + : 'UnknownError'; +} + +function readClock(clock: AdvisoryClock): string | null { + try { + const value = clock(); + return typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ? value + : null; + } catch { + return null; } +} - const candidate = input as Record; +function unavailable(): NotFoundException { + return new NotFoundException('AI advisory source is unavailable.'); +} - return ( - (candidate.findingId === undefined || typeof candidate.findingId === "string") && - typeof candidate.action === "string" && - typeof candidate.rationale === "string" && - (candidate.priority === "low" || candidate.priority === "medium" || candidate.priority === "high") - ); +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; } diff --git a/apps/api/src/ai-plane/ai-plane.module.ts b/apps/api/src/ai-plane/ai-plane.module.ts index 7bc1d0d..ab95aab 100644 --- a/apps/api/src/ai-plane/ai-plane.module.ts +++ b/apps/api/src/ai-plane/ai-plane.module.ts @@ -5,11 +5,22 @@ import { AiAdvisoryRuntimeClient } from "./ai-advisory-runtime.client"; import { AiAdvisoryService } from "./ai-advisory.service"; import { ConfigModule } from "../config/config.module"; import { PrismaModule } from "../prisma/prisma.module"; +import { ScanPlaneModule } from '../scan-plane/scan-plane.module'; +import { PrismaSastAiAdvisoryStore } from './prisma-sast-ai-advisory.store'; +import { SastAiAdvisoryStore } from './sast-ai-advisory.store'; @Module({ - imports: [ConfigModule, PrismaModule], + imports: [ConfigModule, PrismaModule, ScanPlaneModule], controllers: [AiAdvisoryController], - providers: [AiAdvisoryService, AiAdvisoryRuntimeClient], + providers: [ + AiAdvisoryService, + AiAdvisoryRuntimeClient, + PrismaSastAiAdvisoryStore, + { + provide: SastAiAdvisoryStore, + useExisting: PrismaSastAiAdvisoryStore + } + ], exports: [AiAdvisoryService] }) export class AiPlaneModule {} diff --git a/apps/api/src/ai-plane/prisma-sast-ai-advisory.store.ts b/apps/api/src/ai-plane/prisma-sast-ai-advisory.store.ts new file mode 100644 index 0000000..70159d8 --- /dev/null +++ b/apps/api/src/ai-plane/prisma-sast-ai-advisory.store.ts @@ -0,0 +1,672 @@ +import { createHash } from 'node:crypto'; + +import { + isSastAiAdvisoryHandoffShapeValid, + isSastEvidenceAccessDecisionShapeValid, + isSastSecretRedactedFindingCandidateShapeValid, + type AiAdvisoryResult, + type SastAiAdvisoryHandoff, + type SastAiAdvisoryNormalizedFinding, + type SastEvidenceAccessDecision, + type SastFindingLocation, + type SastSecretRedactedFindingCandidate +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + SastAiAdvisoryPersistenceError, + SastAiAdvisoryStore, + type PersistedSastAiAdvisoryHandoff +} from './sast-ai-advisory.store'; + +const SERIALIZABLE_RETRIES = 3; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 10_000; + +@Injectable() +export class PrismaSastAiAdvisoryStore extends SastAiAdvisoryStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async loadNormalizedFinding( + decision: Readonly + ): Promise { + if (!isSastEvidenceAccessDecisionShapeValid(decision, digest)) { + return null; + } + + const row = await this.prisma.sastEvidenceAccessDecision.findUnique({ + where: { + id_tenantId: { + id: decision.accessDecisionId, + tenantId: decision.scope.tenantId + } + }, + select: { + id: true, + tenantId: true, + repositoryBindingId: true, + scanRequestId: true, + attemptId: true, + occurrenceId: true, + evidencePackId: true, + findingFingerprint: true, + purpose: true, + outcome: true, + classification: true, + decisionDigest: true, + decision: true, + buildDecision: { + select: { + normalizedFindingId: true, + findingOccurrence: { + select: { + id: true, + tenantId: true, + repositoryBindingId: true, + scanRequestId: true, + attemptId: true, + scannerRunId: true, + normalizedFindingId: true, + capability: true, + stableFingerprint: true, + sourceFinding: true, + normalizedFinding: { + select: { + id: true, + tenantId: true, + scanRequestId: true, + scannerRunId: true, + title: true, + severity: true, + scannerProvenance: true, + filePath: true, + lineStart: true, + lineEnd: true, + sastCapability: true, + sastStableFingerprint: true + } + } + } + } + } + } + } + }); + + if (!row || !isStoredDecisionBound(row, decision)) { + return null; + } + + const occurrence = row.buildDecision.findingOccurrence; + const finding = occurrence.normalizedFinding; + const source = occurrence.sourceFinding; + if ( + !isSastSecretRedactedFindingCandidateShapeValid(source, digest) || + !isSourceBound(source, row, occurrence, finding, decision) + ) { + return null; + } + + return { + normalizedFindingId: finding.id, + occurrenceId: occurrence.id, + tenantId: occurrence.tenantId, + repositoryBindingId: occurrence.repositoryBindingId, + scanRequestId: occurrence.scanRequestId, + attemptId: occurrence.attemptId, + scannerRunId: occurrence.scannerRunId, + findingFingerprint: + occurrence.stableFingerprint as `sha256:${string}`, + capability: occurrence.capability, + title: source.title, + severity: source.severity, + confidence: source.confidence, + cweIds: [...source.cweIds], + cveIds: [...source.cveIds], + location: projectLocation(source.location), + scanner: source.provenance.scanner, + ruleSemanticId: source.identityMaterial.ruleSemanticId, + ruleRevision: source.provenance.ruleRevision, + secretRedactionApplied: true + }; + } + + async persistHandoff( + handoff: Readonly + ): Promise { + if (!isSastAiAdvisoryHandoffShapeValid(handoff, digest)) { + throw new SastAiAdvisoryPersistenceError('OUTPUT_INVALID'); + } + + try { + return await this.runSerializable(async (tx) => { + const existing = await tx.sastAiAdvisoryHandoff.findUnique({ + where: { id: handoff.handoffId } + }); + if (existing) return replayHandoff(existing, handoff); + + const created = await tx.sastAiAdvisoryHandoff.create({ + data: { + id: handoff.handoffId, + requestId: handoff.requestId, + advisoryId: handoff.advisoryId, + accessDecisionId: handoff.accessDecisionId, + accessDecisionDigest: handoff.accessDecisionDigest, + tenantId: handoff.tenantId, + repositoryBindingId: handoff.repositoryBindingId, + scanRequestId: handoff.scanRequestId, + attemptId: handoff.attemptId, + occurrenceId: handoff.normalizedFinding.occurrenceId, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + scannerRunId: handoff.normalizedFinding.scannerRunId, + evidencePackId: handoff.evidencePackId, + findingFingerprint: + handoff.normalizedFinding.findingFingerprint, + modelVersion: handoff.modelVersion, + payloadExpiresAt: new Date(handoff.payloadExpiresAt), + requestDigest: handoff.requestDigest, + handoffDigest: handoff.handoffDigest, + normalizedFindingAllowed: true, + reducedEvidenceReferenceAllowed: true, + aiPayloadAllowed: true, + aiProviderCallAllowed: true, + advisoryOnly: true, + callerFindingAccepted: false, + callerEvidenceAccepted: false, + callerPromptAccepted: false, + requestPayloadStored: false, + rawSourceStored: false, + secretValueStored: false, + evidenceFragmentStored: false, + retrievalAttempted: false, + toolsInvoked: false, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + createdAt: new Date(handoff.createdAt) + } + }); + return replayHandoff(created, handoff, false); + }); + } catch (error) { + if (!isUniqueConflict(error)) throw error; + const existing = + await this.prisma.sastAiAdvisoryHandoff.findUnique({ + where: { id: handoff.handoffId } + }); + if (!existing) { + throw new SastAiAdvisoryPersistenceError('REPLAY_CONFLICT'); + } + return replayHandoff(existing, handoff); + } + } + + async loadAdvisory(input: { + tenantId: string; + advisoryId: string; + }): Promise { + const row = await this.prisma.aiAdvisoryMetadata.findFirst({ + where: { + id: input.advisoryId, + tenantId: input.tenantId + }, + include: { + sastHandoff: { + select: { requestDigest: true } + } + } + }); + return row ? advisoryFromRow(row) : null; + } + + async persistAdvisory(input: { + handoff: Readonly; + advisory: Readonly; + }): Promise { + if (!isAdvisoryBound(input.handoff, input.advisory)) { + throw new SastAiAdvisoryPersistenceError('OUTPUT_INVALID'); + } + + try { + return await this.runSerializable(async (tx) => { + const existing = await tx.aiAdvisoryMetadata.findUnique({ + where: { id: input.advisory.id }, + include: { + sastHandoff: { + select: { requestDigest: true } + } + } + }); + if (existing) { + return replayAdvisory(existing, input); + } + const created = await tx.aiAdvisoryMetadata.create({ + data: { + id: input.advisory.id, + sastHandoffId: input.handoff.handoffId, + tenantId: input.advisory.tenantId, + scanRequestId: input.advisory.scanRequestId, + findingId: input.advisory.findingId, + modelVersion: input.advisory.modelVersion, + advisoryOnly: true, + redactedEvidenceOnly: true, + detectorSignals: input.advisory.detectorSignals, + plannerSteps: input.advisory.plannerSteps, + confidence: input.advisory.confidence, + detectorAdvisories: + input.advisory.detectorAdvisories === undefined + ? Prisma.JsonNull + : (input.advisory.detectorAdvisories as unknown as Prisma.InputJsonValue), + plannerAdvisories: + input.advisory.plannerAdvisories === undefined + ? Prisma.JsonNull + : (input.advisory.plannerAdvisories as unknown as Prisma.InputJsonValue), + modelMetadata: + input.advisory.modelMetadata === undefined + ? Prisma.JsonNull + : (input.advisory.modelMetadata as unknown as Prisma.InputJsonValue), + fallback: + input.advisory.fallback === undefined + ? Prisma.JsonNull + : (input.advisory.fallback as unknown as Prisma.InputJsonValue), + createdAt: new Date(input.advisory.createdAt) + }, + include: { + sastHandoff: { + select: { requestDigest: true } + } + } + }); + return advisoryFromRow(created); + }); + } catch (error) { + if (!isUniqueConflict(error)) throw error; + const existing = await this.loadAdvisory({ + tenantId: input.handoff.tenantId, + advisoryId: input.handoff.advisoryId + }); + if (!existing || !isAdvisoryBound(input.handoff, existing)) { + throw new SastAiAdvisoryPersistenceError('REPLAY_CONFLICT'); + } + return existing; + } + } + + private async runSerializable( + operation: (tx: Prisma.TransactionClient) => Promise + ): Promise { + for (let attempt = 1; attempt <= SERIALIZABLE_RETRIES; attempt += 1) { + try { + return await this.prisma.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: SERIALIZABLE_TIMEOUT_MILLISECONDS, + timeout: SERIALIZABLE_TIMEOUT_MILLISECONDS + }); + } catch (error) { + if (!isSerializableConflict(error) || attempt === SERIALIZABLE_RETRIES) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, 20 * attempt + Math.floor(Math.random() * 20)) + ); + } + } + throw new SastAiAdvisoryPersistenceError('REPLAY_CONFLICT'); + } +} + +function isStoredDecisionBound( + row: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + evidencePackId: string; + findingFingerprint: string; + purpose: string; + outcome: string; + classification: string; + decisionDigest: string; + decision: unknown; + }, + decision: Readonly +): boolean { + if (!isSastEvidenceAccessDecisionShapeValid(row.decision, digest)) { + return false; + } + const stored = row.decision; + return ( + stored.accessDecisionId === row.id && + stored.decisionDigest === row.decisionDigest && + stored.decisionDigest === decision.decisionDigest && + row.id === decision.accessDecisionId && + row.decisionDigest === decision.decisionDigest && + row.tenantId === decision.scope.tenantId && + row.repositoryBindingId === decision.scope.repositoryBindingId && + row.scanRequestId === decision.scope.scanRequestId && + row.attemptId === decision.scope.attemptId && + row.occurrenceId === decision.scope.occurrenceId && + row.evidencePackId === decision.scope.evidencePackId && + row.findingFingerprint === decision.scope.findingFingerprint && + row.purpose === 'AI_ADVISORY' && + row.outcome === 'ALLOWED' && + row.classification === 'AI_REDUCED_REFERENCE_SAFE' + ); +} + +function isSourceBound( + source: Readonly, + row: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + findingFingerprint: string; + buildDecision: { normalizedFindingId: string }; + }, + occurrence: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + normalizedFindingId: string; + capability: string; + stableFingerprint: string; + }, + finding: { + id: string; + tenantId: string; + scanRequestId: string; + scannerRunId: string; + title: string; + severity: string; + scannerProvenance: string; + filePath: string | null; + lineStart: number | null; + lineEnd: number | null; + sastCapability: string | null; + sastStableFingerprint: string | null; + }, + decision: Readonly +): boolean { + const locationMatches = + source.location.kind === 'FILE' + ? finding.filePath === source.location.normalizedPath && + finding.lineStart === source.location.lineStart && + finding.lineEnd === (source.location.lineEnd ?? null) + : finding.filePath === null && + finding.lineStart === null && + finding.lineEnd === null; + return ( + occurrence.id === row.occurrenceId && + occurrence.id === decision.scope.occurrenceId && + occurrence.tenantId === row.tenantId && + occurrence.repositoryBindingId === row.repositoryBindingId && + occurrence.scanRequestId === row.scanRequestId && + occurrence.attemptId === row.attemptId && + occurrence.normalizedFindingId === row.buildDecision.normalizedFindingId && + occurrence.stableFingerprint === row.findingFingerprint && + finding.id === occurrence.normalizedFindingId && + finding.tenantId === occurrence.tenantId && + finding.scanRequestId === occurrence.scanRequestId && + finding.scannerRunId === occurrence.scannerRunId && + finding.title === source.title && + finding.severity === source.severity && + finding.scannerProvenance === source.provenance.scanner && + finding.sastCapability === occurrence.capability && + finding.sastCapability === source.capability && + finding.sastStableFingerprint === occurrence.stableFingerprint && + source.tenantId === occurrence.tenantId && + source.repositoryBindingId === occurrence.repositoryBindingId && + source.scanRequestId === occurrence.scanRequestId && + source.attemptId === occurrence.attemptId && + source.scannerRunId === occurrence.scannerRunId && + locationMatches + ); +} + +function projectLocation( + location: Readonly +): SastFindingLocation { + if (location.kind === 'UNKNOWN') { + return { kind: 'UNKNOWN', reasonCode: location.reasonCode }; + } + return { + kind: 'FILE', + normalizedPath: location.normalizedPath, + lineStart: location.lineStart, + ...(location.lineEnd === undefined + ? {} + : { lineEnd: location.lineEnd }), + ...(location.columnStart === undefined + ? {} + : { columnStart: location.columnStart }), + ...(location.columnEnd === undefined + ? {} + : { columnEnd: location.columnEnd }) + }; +} + +function replayHandoff( + row: { + id: string; + requestId: string; + advisoryId: string; + accessDecisionId: string; + accessDecisionDigest: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + normalizedFindingId: string; + scannerRunId: string; + evidencePackId: string; + findingFingerprint: string; + modelVersion: string; + payloadExpiresAt: Date; + requestDigest: string; + handoffDigest: string; + normalizedFindingAllowed: boolean; + reducedEvidenceReferenceAllowed: boolean; + aiPayloadAllowed: boolean; + aiProviderCallAllowed: boolean; + advisoryOnly: boolean; + callerFindingAccepted: boolean; + callerEvidenceAccepted: boolean; + callerPromptAccepted: boolean; + requestPayloadStored: boolean; + rawSourceStored: boolean; + secretValueStored: boolean; + evidenceFragmentStored: boolean; + retrievalAttempted: boolean; + toolsInvoked: boolean; + retrievalAllowed: boolean; + toolsAllowed: boolean; + policyAuthority: boolean; + publicationAuthority: boolean; + lifecycleMutationAuthority: boolean; + scmWriteAuthority: boolean; + createdAt: Date; + }, + expected: Readonly, + replayed = true +): PersistedSastAiAdvisoryHandoff { + if ( + row.id !== expected.handoffId || + row.requestId !== expected.requestId || + row.advisoryId !== expected.advisoryId || + row.accessDecisionId !== expected.accessDecisionId || + row.accessDecisionDigest !== expected.accessDecisionDigest || + row.tenantId !== expected.tenantId || + row.repositoryBindingId !== expected.repositoryBindingId || + row.scanRequestId !== expected.scanRequestId || + row.attemptId !== expected.attemptId || + row.occurrenceId !== expected.normalizedFinding.occurrenceId || + row.normalizedFindingId !== + expected.normalizedFinding.normalizedFindingId || + row.scannerRunId !== expected.normalizedFinding.scannerRunId || + row.evidencePackId !== expected.evidencePackId || + row.findingFingerprint !== + expected.normalizedFinding.findingFingerprint || + row.modelVersion !== expected.modelVersion || + !isSameInstant(row.payloadExpiresAt, expected.payloadExpiresAt) || + row.requestDigest !== expected.requestDigest || + row.handoffDigest !== expected.handoffDigest || + row.normalizedFindingAllowed !== true || + row.reducedEvidenceReferenceAllowed !== true || + row.aiPayloadAllowed !== true || + row.aiProviderCallAllowed !== true || + row.advisoryOnly !== true || + row.callerFindingAccepted !== false || + row.callerEvidenceAccepted !== false || + row.callerPromptAccepted !== false || + row.requestPayloadStored !== false || + row.rawSourceStored !== false || + row.secretValueStored !== false || + row.evidenceFragmentStored !== false || + row.retrievalAttempted !== false || + row.toolsInvoked !== false || + row.retrievalAllowed !== false || + row.toolsAllowed !== false || + row.policyAuthority !== false || + row.publicationAuthority !== false || + row.lifecycleMutationAuthority !== false || + row.scmWriteAuthority !== false || + !isSameInstant(row.createdAt, expected.createdAt) + ) { + throw new SastAiAdvisoryPersistenceError('REPLAY_CONFLICT'); + } + return { handoff: expected, replayed }; +} + +function replayAdvisory( + row: Parameters[0], + expected: { + handoff: Readonly; + advisory: Readonly; + } +): AiAdvisoryResult { + const advisory = advisoryFromRow(row); + if ( + !isAdvisoryBound(expected.handoff, advisory) || + advisory.modelVersion !== expected.advisory.modelVersion + ) { + throw new SastAiAdvisoryPersistenceError('REPLAY_CONFLICT'); + } + return advisory; +} + +function isAdvisoryBound( + handoff: Readonly, + advisory: Readonly +): boolean { + return ( + advisory.id === handoff.advisoryId && + advisory.sastHandoffId === handoff.handoffId && + advisory.requestDigest === handoff.requestDigest && + advisory.tenantId === handoff.tenantId && + advisory.scanRequestId === handoff.scanRequestId && + advisory.findingId === handoff.normalizedFinding.normalizedFindingId && + advisory.advisoryOnly === true && + advisory.redactedEvidenceOnly === true + ); +} + +function advisoryFromRow(row: { + id: string; + sastHandoffId: string | null; + tenantId: string; + scanRequestId: string; + findingId: string; + modelVersion: string; + detectorSignals: unknown; + plannerSteps: unknown; + confidence: number; + detectorAdvisories: unknown; + plannerAdvisories: unknown; + modelMetadata: unknown; + fallback: unknown; + createdAt: Date; + sastHandoff?: { requestDigest: string } | null; +}): AiAdvisoryResult { + return { + id: row.id, + ...(row.sastHandoffId + ? { sastHandoffId: row.sastHandoffId } + : {}), + ...(row.sastHandoff?.requestDigest && + /^sha256:[a-f0-9]{64}$/u.test(row.sastHandoff.requestDigest) + ? { + requestDigest: + row.sastHandoff.requestDigest as `sha256:${string}` + } + : {}), + tenantId: row.tenantId, + scanRequestId: row.scanRequestId, + findingId: row.findingId, + modelVersion: row.modelVersion, + advisoryOnly: true, + redactedEvidenceOnly: true, + detectorSignals: stringArray(row.detectorSignals), + plannerSteps: stringArray(row.plannerSteps), + confidence: row.confidence, + detectorAdvisories: arrayOrUndefined(row.detectorAdvisories) as + AiAdvisoryResult['detectorAdvisories'], + plannerAdvisories: arrayOrUndefined(row.plannerAdvisories) as + AiAdvisoryResult['plannerAdvisories'], + modelMetadata: objectOrUndefined(row.modelMetadata) as + AiAdvisoryResult['modelMetadata'], + fallback: objectOrUndefined(row.fallback) as + AiAdvisoryResult['fallback'], + createdAt: row.createdAt.toISOString() + }; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function arrayOrUndefined(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function objectOrUndefined(value: unknown): object | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : undefined; +} + +function isSameInstant(actual: Date, expected: string): boolean { + const expectedMilliseconds = Date.parse(expected); + return Number.isFinite(expectedMilliseconds) && + actual.getTime() === expectedMilliseconds; +} + +function isSerializableConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034'; +} + +function isUniqueConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002'; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} diff --git a/apps/api/src/ai-plane/sast-ai-advisory.store.ts b/apps/api/src/ai-plane/sast-ai-advisory.store.ts new file mode 100644 index 0000000..7e59887 --- /dev/null +++ b/apps/api/src/ai-plane/sast-ai-advisory.store.ts @@ -0,0 +1,44 @@ +import type { + AiAdvisoryResult, + SastAiAdvisoryHandoff, + SastAiAdvisoryNormalizedFinding, + SastEvidenceAccessDecision +} from '@aegisai/shared'; + +export interface PersistedSastAiAdvisoryHandoff { + handoff: SastAiAdvisoryHandoff; + replayed: boolean; +} + +export class SastAiAdvisoryPersistenceError extends Error { + constructor( + readonly reason: + | 'CONTEXT_DRIFT' + | 'OUTPUT_INVALID' + | 'REPLAY_CONFLICT' + ) { + super('The SAST AI advisory handoff conflicts with durable state.'); + this.name = 'SastAiAdvisoryPersistenceError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export abstract class SastAiAdvisoryStore { + abstract loadNormalizedFinding( + decision: Readonly + ): Promise; + + abstract persistHandoff( + handoff: Readonly + ): Promise; + + abstract loadAdvisory(input: { + tenantId: string; + advisoryId: string; + }): Promise; + + abstract persistAdvisory(input: { + handoff: Readonly; + advisory: Readonly; + }): Promise; +} diff --git a/apps/api/test/ai-plane/ai-advisory-runtime.client.e2e-spec.ts b/apps/api/test/ai-plane/ai-advisory-runtime.client.e2e-spec.ts index e548873..3565292 100644 --- a/apps/api/test/ai-plane/ai-advisory-runtime.client.e2e-spec.ts +++ b/apps/api/test/ai-plane/ai-advisory-runtime.client.e2e-spec.ts @@ -1,164 +1,242 @@ -import axios from "axios"; +import type { AiInferenceResponse } from '@aegisai/shared'; +import axios from 'axios'; -import { AiAdvisoryRuntimeClient } from "../../src/ai-plane/ai-advisory-runtime.client"; - -import type { AiAdvisoryRequest, AiInferenceResponse } from "../../../../packages/shared/src"; - -jest.mock("axios"); +import { AiAdvisoryRuntimeClient } from '../../src/ai-plane/ai-advisory-runtime.client'; +import { aiHandoff } from '../support/sast-ai-advisory-fixture'; +jest.mock('axios'); const mockedAxios = jest.mocked(axios); -describe("AiAdvisoryRuntimeClient", () => { - const request: AiAdvisoryRequest = { - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - findingId: "finding_1", - normalizedFinding: { - id: "finding_1", - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - scannerRunId: "scanner_run_1", - title: "Unsafe deserialization", - severity: "HIGH", - scannerProvenance: "OPENGREP", - filePath: "src/App.java", - lineStart: 42, - status: "OPEN" - }, - evidence: { - id: "evidence_1", - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - classification: "SHORT_LIVED_EVIDENCE", - objectKey: "tenant_runtime/scan_request_1/evidence/evidence_1.json", - expiresAt: "2026-04-19T00:00:00.000Z", - byteSize: 512, - redacted: true - }, - modelVersion: "detector-planner-runtime-v1" - }; +describe('AiAdvisoryRuntimeClient T043 boundary', () => { + beforeEach(() => mockedAxios.post.mockReset()); - beforeEach(() => { - mockedAxios.post.mockReset(); - }); + it('sends only normalized metadata and an opaque reduced reference', async () => { + const handoff = aiHandoff(); + const runtimeResponse = responseFor(handoff.requestId); + mockedAxios.post.mockResolvedValueOnce({ data: runtimeResponse }); + const client = new AiAdvisoryRuntimeClient(runtimeConfig()); - it("sends reduced inference requests and accepts the model gateway response shape", async () => { - const runtimeResponse: AiInferenceResponse = { - requestId: "ai_request_1", - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - advisoryOnly: true, - detectorAdvisories: [ - { - findingId: "finding_1", - confidence: 0.91, - rationale: "Model gateway mapped reduced evidence to a detector advisory.", - signals: ["SCANNER_CONFIRMED", "MODEL_TRIAGED"] - } - ], - plannerAdvisories: [ - { - findingId: "finding_1", - action: "Review scanner evidence before remediation.", - rationale: "Planner advisory generated from reduced evidence.", - priority: "high" - } - ], - modelMetadata: { - provider: "deterministic", - model: "detector-planner-runtime", - version: "2026-05-26" - }, - fallback: { - used: true, - reason: "provider not configured" - }, - latencyMs: 13, - createdAt: "2026-05-26T00:00:00.000Z" + const result = await client.createAdvisory(handoff); + const inferenceRequest = mockedAxios.post.mock.calls[0]?.[1] as { + canonicalScanKey: string; + modelVersion: string; + reducedEvidence: { + snippets: unknown[]; + metadata: Record; + }; }; - mockedAxios.post.mockResolvedValueOnce({ - data: runtimeResponse - }); - const client = new AiAdvisoryRuntimeClient({ - get: jest.fn((key: string) => { - const values: Record = { - AI_SERVER_URL: "https://ai-runtime.example", - AI_ADVISORY_TIMEOUT_MS: 2500 - }; - - return values[key]; - }) - } as never); - - const result = await client.createAdvisory(request); - const inferenceRequest = mockedAxios.post.mock.calls[0]?.[1] as Record; expect(mockedAxios.post).toHaveBeenCalledWith( - "https://ai-runtime.example/ai/advisories", + 'https://ai-runtime.example/ai/advisories', expect.objectContaining({ - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - requestId: expect.any(String), - canonicalScanKey: expect.any(String), + tenantId: 'tenant-ai', + scanRequestId: 'scan-ai', + requestId: handoff.requestId, + modelVersion: handoff.modelVersion, reducedEvidence: expect.objectContaining({ - findingIds: ["finding_1"], - evidencePackId: "evidence_1", - scannerNames: ["OPENGREP"], - redactionState: "redacted" + findingIds: ['normalized-finding-ai'], + evidencePackId: handoff.evidencePackId, + scannerNames: ['OPENGREP'], + snippets: [], + redactionState: 'reduced', + metadata: expect.objectContaining({ + handoffVersion: 'sast-ai-advisory-handoff-v1', + handoffDigest: handoff.handoffDigest, + requestDigest: handoff.requestDigest, + normalizedFindingId: 'normalized-finding-ai', + reducedEvidenceRef: + handoff.reducedEvidenceReference.reducedEvidenceRef, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true + }) }), - requestedCapabilities: ["detector", "planner"], - runtimePolicy: { - allowFallback: true, - maxLatencyMs: 2500 - } + requestedCapabilities: ['detector', 'planner'], + runtimePolicy: { allowFallback: true, maxLatencyMs: 2500 } }), - expect.objectContaining({ - timeout: 2500 - }) + { timeout: 2500 } + ); + expect(inferenceRequest.reducedEvidence.snippets).toEqual([]); + expect(inferenceRequest.canonicalScanKey).toBe( + [ + handoff.tenantId, + handoff.repositoryBindingId, + handoff.scanRequestId, + handoff.attemptId, + handoff.accessDecisionDigest, + handoff.modelVersion + ].join(':') + ); + expect(inferenceRequest.modelVersion).toBe(handoff.modelVersion); + expect(inferenceRequest.reducedEvidence.metadata).toMatchObject({ + cweIds: 'CWE-502,CWE-79', + cveIds: 'CVE-2025-0001,CVE-2026-0002' + }); + expect(inferenceRequest.reducedEvidence.metadata).not.toHaveProperty( + 'redactedContent' ); - expect(inferenceRequest).not.toHaveProperty("normalizedFinding"); - expect(inferenceRequest).not.toHaveProperty("evidence"); expect(result).toEqual(runtimeResponse); - expect(JSON.stringify({ calls: mockedAxios.post.mock.calls, result })).not.toMatch( - /accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|rawScannerPayload/i + expect(JSON.stringify(mockedAxios.post.mock.calls)).not.toMatch( + /accessToken|secretValue|sourceArchive|fullRepository|rawScannerPayload|redactedContent/i ); }); - it("rejects runtime responses that attempt to override findings or policy", async () => { + it('rejects authority-bearing or cross-request runtime responses', async () => { + const handoff = aiHandoff(); + const client = new AiAdvisoryRuntimeClient(runtimeConfig()); + mockedAxios.post.mockResolvedValueOnce({ + data: { ...responseFor(handoff.requestId), enforcementAction: 'BLOCK' } + }); + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response contains forbidden authority or sensitive content.' + ); + + mockedAxios.post.mockResolvedValueOnce({ + data: responseFor('sast-ai-request://' + 'f'.repeat(64)) + }); + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response is malformed.' + ); + mockedAxios.post.mockResolvedValueOnce({ data: { - requestId: "ai_request_1", - tenantId: "tenant_runtime", - scanRequestId: "scan_request_1", - advisoryOnly: true, - detectorAdvisories: [], - plannerAdvisories: [], + ...responseFor(handoff.requestId), modelMetadata: { - provider: "deterministic", - model: "detector-planner-runtime", - version: "2026-05-26" - }, - fallback: { - used: false - }, - latencyMs: 1, - createdAt: "2026-05-26T00:00:00.000Z", - enforcementAction: "BLOCK" + ...responseFor(handoff.requestId).modelMetadata, + version: 'different-model-version' + } } }); - const client = new AiAdvisoryRuntimeClient({ - get: jest.fn((key: string) => { - const values: Record = { - AI_SERVER_URL: "https://ai-runtime.example", - AI_ADVISORY_TIMEOUT_MS: 2500 - }; - - return values[key]; - }) - } as never); - - await expect(client.createAdvisory(request)).rejects.toThrow( - "AI advisory runtime response contains forbidden authority or sensitive content." + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response is malformed.' + ); + }); + + it('rejects a tampered handoff before any provider request', async () => { + const handoff = aiHandoff(); + const client = new AiAdvisoryRuntimeClient(runtimeConfig()); + await expect( + client.createAdvisory({ + ...handoff, + authority: { ...handoff.authority, toolsAllowed: true } + } as never) + ).rejects.toThrow('AI advisory handoff is malformed.'); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('uses one bounded timeout value for transport and runtime policy', async () => { + const handoff = aiHandoff(); + mockedAxios.post.mockResolvedValueOnce({ + data: responseFor(handoff.requestId) + }); + const client = new AiAdvisoryRuntimeClient(runtimeConfig(-1)); + + await expect(client.createAdvisory(handoff)).resolves.toEqual( + responseFor(handoff.requestId) + ); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://ai-runtime.example/ai/advisories', + expect.objectContaining({ + runtimePolicy: { allowFallback: true, maxLatencyMs: 2500 } + }), + { timeout: 2500 } + ); + }); + + it('rejects oversized or excessively nested runtime output', async () => { + const handoff = aiHandoff(); + const client = new AiAdvisoryRuntimeClient(runtimeConfig()); + const response = responseFor(handoff.requestId); + + mockedAxios.post.mockResolvedValueOnce({ + data: { + ...response, + detectorAdvisories: [ + { + ...response.detectorAdvisories[0], + rationale: 'x'.repeat(2049) + } + ] + } + }); + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response is malformed.' + ); + + mockedAxios.post.mockResolvedValueOnce({ + data: { + ...response, + detectorAdvisories: Array.from( + { length: 33 }, + () => response.detectorAdvisories[0] + ) + } + }); + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response is malformed.' + ); + + mockedAxios.post.mockResolvedValueOnce({ + data: { ...response, diagnostics: nestedValue(14) } + }); + await expect(client.createAdvisory(handoff)).rejects.toThrow( + 'AI advisory runtime response contains forbidden authority or sensitive content.' ); }); }); + +function responseFor(requestId: string): AiInferenceResponse { + return { + requestId, + tenantId: 'tenant-ai', + scanRequestId: 'scan-ai', + advisoryOnly: true, + detectorAdvisories: [ + { + findingId: 'normalized-finding-ai', + confidence: 0.91, + rationale: 'Model gateway mapped the reduced reference.', + signals: ['SCANNER_CONFIRMED', 'MODEL_TRIAGED'] + } + ], + plannerAdvisories: [ + { + findingId: 'normalized-finding-ai', + action: 'Review normalized scanner evidence.', + rationale: 'Advisory planning remains non-authoritative.', + priority: 'high' + } + ], + modelMetadata: { + provider: 'deterministic', + model: 'detector-planner-runtime', + version: 'detector-planner-runtime-v1' + }, + fallback: { used: true, reason: 'provider not configured' }, + latencyMs: 13, + createdAt: '2026-08-11T04:00:00.450Z' + }; +} + +function runtimeConfig(timeout: number | string = 2500) { + return { + get: jest.fn((key: string) => { + const values: Record = { + AI_SERVER_URL: 'https://ai-runtime.example', + AI_ADVISORY_TIMEOUT_MS: timeout + }; + return values[key]; + }) + } as never; +} + +function nestedValue(depth: number): unknown { + let value: unknown = 'bounded'; + for (let index = 0; index < depth; index += 1) { + value = { next: value }; + } + return value; +} diff --git a/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts b/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts index 9ba4b14..20d5daf 100644 --- a/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts +++ b/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts @@ -1,32 +1,40 @@ -import { INestApplication } from "@nestjs/common"; -import { Test } from "@nestjs/testing"; -import request from "supertest"; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; + import { SessionAuthGuard } from '../../src/auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../../src/common/security/internal-service.guard'; -import { TestInternalServiceGuard, TestSessionAuthGuard } from '../support/security-guards'; +import { + TestInternalServiceGuard, + TestSessionAuthGuard +} from '../support/security-guards'; +import { aiAdvisoryIntent } from '../support/sast-ai-advisory-fixture'; -describe("AI advisory API (e2e)", () => { +describe('AI advisory API T043 boundary (e2e)', () => { let app: INestApplication; beforeAll(async () => { - process.env.NODE_ENV = "test"; - process.env.PORT = "3000"; - process.env.DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/aegisai"; - process.env.REDIS_URL = "redis://localhost:6379"; - process.env.SESSION_SECRET = "test-session-secret-value-at-least-32"; - process.env.CSRF_SECRET = "test-csrf-secret-value-at-least-32"; - process.env.GITHUB_CLIENT_ID = "github-client-id"; - process.env.GITHUB_CLIENT_SECRET = "github-client-secret"; - process.env.GITLAB_CLIENT_ID = "gitlab-client-id"; - process.env.GITLAB_CLIENT_SECRET = "gitlab-client-secret"; - process.env.APP_URL = "http://localhost:3000"; - process.env.FRONTEND_URL = "http://localhost:5173"; + process.env.NODE_ENV = 'test'; + process.env.PORT = '3000'; + process.env.DATABASE_URL = + 'postgresql://postgres:postgres@localhost:5432/aegisai'; + process.env.REDIS_URL = 'redis://localhost:6379'; + process.env.SESSION_SECRET = + 'test-session-secret-value-at-least-32'; + process.env.CSRF_SECRET = + 'test-csrf-secret-value-at-least-32'; + process.env.GITHUB_CLIENT_ID = 'github-client-id'; + process.env.GITHUB_CLIENT_SECRET = 'github-client-secret'; + process.env.GITLAB_CLIENT_ID = 'gitlab-client-id'; + process.env.GITLAB_CLIENT_SECRET = 'gitlab-client-secret'; + process.env.APP_URL = 'http://localhost:3000'; + process.env.FRONTEND_URL = 'http://localhost:5173'; process.env.TOKEN_ENCRYPTION_KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; const [{ AppModule }, { PrismaService }] = await Promise.all([ - import("../../src/app.module"), - import("../../src/prisma/prisma.service") + import('../../src/app.module'), + import('../../src/prisma/prisma.service') ]); const moduleRef = await Test.createTestingModule({ @@ -47,78 +55,41 @@ describe("AI advisory API (e2e)", () => { .compile(); app = moduleRef.createNestApplication(); - app.setGlobalPrefix("api"); - + app.setGlobalPrefix('api'); await app.init(); }); afterAll(async () => { - if (app) { - await app.close(); - } + await app?.close(); }); - const dataOf = (body: { data?: T } | T): T => { - if (body && typeof body === "object" && "data" in body) { - return (body as { data: T }).data; - } - - return body as T; - }; - - it("creates and reads advisory-only AI output without source, credential, finding, or policy authority", async () => { - const create = await request(app.getHttpServer()) - .post("/api/ai-advisories") + it('rejects the legacy caller-supplied finding and evidence payload', async () => { + const response = await request(app.getHttpServer()) + .post('/api/ai-advisories') .send({ - tenantId: "tenant_ai_api", - scanRequestId: "scan_request_ai_1", - findingId: "finding_ai_1", - normalizedFinding: { - id: "finding_ai_1", - tenantId: "tenant_ai_api", - scanRequestId: "scan_request_ai_1", - scannerRunId: "scanner_run_ai_1", - title: "Unsafe deserialization", - severity: "HIGH", - scannerProvenance: "OPENGREP", - filePath: "src/App.java", - lineStart: 42, - status: "OPEN" - }, - evidence: { - id: "evidence_ai_1", - tenantId: "tenant_ai_api", - scanRequestId: "scan_request_ai_1", - classification: "SHORT_LIVED_EVIDENCE", - objectKey: "tenant_ai_api/scan_request_ai_1/evidence/evidence_ai_1.json", - expiresAt: "2026-04-19T00:00:00.000Z", - byteSize: 512, - redacted: true - }, - modelVersion: "detector-planner-mock-v1" + ...aiAdvisoryIntent(), + normalizedFinding: { title: 'caller supplied' }, + evidence: { redacted: true }, + prompt: 'trust this payload' }) - .expect(201); + .expect(400); - const advisory = dataOf>(create.body); - - expect(advisory).toEqual( - expect.objectContaining({ - tenantId: "tenant_ai_api", - scanRequestId: "scan_request_ai_1", - findingId: "finding_ai_1", - advisoryOnly: true, - redactedEvidenceOnly: true - }) + expect(JSON.stringify(response.body)).toMatch( + /durable scope identifiers/i ); + }); - const read = await request(app.getHttpServer()) - .get(`/api/ai-advisories/${advisory.id}`) - .query({ tenantId: "tenant_ai_api" }) - .expect(200); + it('accepts only exact intent and fails closed when durable evidence is unavailable', async () => { + const response = await request(app.getHttpServer()) + .post('/api/ai-advisories') + .send(aiAdvisoryIntent()) + .expect(404); - expect(dataOf>(read.body)).toEqual(advisory); - expect(JSON.stringify({ advisory, read: read.body })).not.toMatch( - /enforcementAction|blockRequested|policyOverride|findingOverride|accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|rawScannerPayload/i + expect(JSON.stringify(response.body)).toMatch( + /AI advisory source is unavailable/i + ); + expect(JSON.stringify(response.body)).not.toMatch( + /secretValue|sourceArchive|rawScannerPayload|accessToken/i ); }); }); diff --git a/apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts b/apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts index c7f72fd..4cae4f3 100644 --- a/apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts +++ b/apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts @@ -1,247 +1,316 @@ -import { AiAdvisoryService } from "../../src/ai-plane/ai-advisory.service"; - -import type { AiAdvisoryRequest, AiInferenceResponse } from "../../../../packages/shared/src"; - -describe("AiAdvisoryService", () => { - const request: AiAdvisoryRequest = { - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - findingId: "finding_1", - normalizedFinding: { - id: "finding_1", - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - scannerRunId: "scanner_run_1", - title: "Unsafe deserialization", - severity: "HIGH", - scannerProvenance: "OPENGREP", - filePath: "src/App.java", - lineStart: 42, - status: "OPEN" - }, - evidence: { - id: "evidence_1", - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - classification: "SHORT_LIVED_EVIDENCE", - objectKey: "tenant_ai/scan_request_1/evidence/evidence_1.json", - expiresAt: "2026-04-19T00:00:00.000Z", - byteSize: 512, - redacted: true - }, - modelVersion: "detector-planner-mock-v1" - }; +import type { + AiAdvisoryResult, + AiInferenceResponse, + SastAiAdvisoryHandoff +} from '@aegisai/shared'; +import { Logger } from '@nestjs/common'; + +import { AiAdvisoryService } from '../../src/ai-plane/ai-advisory.service'; +import { + aiAdvisoryIntent, + aiNormalizedFinding, + allowedAiAccess +} from '../support/sast-ai-advisory-fixture'; + +describe('AiAdvisoryService T043 handoff', () => { + afterEach(() => jest.restoreAllMocks()); - it("creates advisory-only detector planner output from normalized findings and redacted evidence", async () => { - const service = new AiAdvisoryService({ - get: jest.fn().mockReturnValue("false") - } as never); + it('derives an advisory from durable scope and never accepts caller payloads', async () => { + const access = allowedAiAccess(); + const evidenceAccess = { + classifyForAi: jest.fn().mockResolvedValue(access) + }; + const store = memoryStore(access.decision); + const runtime = { createAdvisory: jest.fn() }; + const service = new AiAdvisoryService( + config(false), + runtime as never, + evidenceAccess as never, + store as never + ); - const advisory = await service.createAdvisory(request); + const advisory = await service.createAdvisory( + aiAdvisoryIntent(), + clock() + ); + expect(evidenceAccess.classifyForAi).toHaveBeenCalledTimes(2); + expect(evidenceAccess.classifyForAi).toHaveBeenNthCalledWith( + 1, + { + tenantId: 'tenant-ai', + repositoryBindingId: 'repository-ai', + evidencePackId: aiAdvisoryIntent().evidencePackId + }, + expect.any(Function) + ); + expect(store.loadNormalizedFinding).toHaveBeenCalledWith( + access.decision + ); + expect(store.persistHandoff).toHaveBeenCalledWith( + expect.objectContaining({ + version: 'sast-ai-advisory-handoff-v1', + tenantId: 'tenant-ai', + evidencePackId: aiAdvisoryIntent().evidencePackId, + authority: expect.objectContaining({ + aiProviderCallAllowed: true, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false + }), + audit: expect.objectContaining({ + callerFindingAccepted: false, + callerEvidenceAccepted: false, + requestPayloadStored: false + }) + }) + ); + expect(runtime.createAdvisory).not.toHaveBeenCalled(); expect(advisory).toEqual( expect.objectContaining({ - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - findingId: "finding_1", - modelVersion: "detector-planner-mock-v1", + id: expect.stringMatching(/^sast-ai-advisory:\/\//u), + sastHandoffId: expect.stringMatching( + /^sast-ai-handoff:\/\//u + ), + tenantId: 'tenant-ai', + scanRequestId: 'scan-ai', + findingId: 'normalized-finding-ai', advisoryOnly: true, redactedEvidenceOnly: true, - confidence: expect.any(Number) + detectorSignals: expect.arrayContaining([ + 'T043_REDUCED_REFERENCE_ONLY' + ]) }) ); - expect(advisory.detectorSignals).toEqual(expect.arrayContaining(["SCANNER_CONFIRMED"])); - expect(advisory.plannerSteps).toEqual(expect.arrayContaining(["Review scanner evidence before remediation."])); - expect(JSON.stringify(advisory)).not.toMatch( - /enforcementAction|blockRequested|policyOverride|findingOverride|accessToken|refreshToken|tokenValue|secretValue|fullRepository|sourceArchive|rawScannerPayload/i - ); + expect(JSON.stringify({ advisory, calls: store.persistHandoff.mock.calls })) + .not.toMatch( + /sourceArchive|fullRepository|rawScannerPayload|redactedContent|policyOverride|findingOverride|"secretValue"\s*:/i + ); }); - it("rejects unredacted evidence and forbidden repository or credential payloads", async () => { - const service = new AiAdvisoryService({ - get: jest.fn().mockReturnValue("false") - } as never); + it('rejects the legacy caller-supplied finding and evidence shape before access', async () => { + const evidenceAccess = { classifyForAi: jest.fn() }; + const access = allowedAiAccess(); + const store = memoryStore(access.decision); + const service = new AiAdvisoryService( + config(false), + { createAdvisory: jest.fn() } as never, + evidenceAccess as never, + store as never + ); await expect( service.createAdvisory({ - ...request, - evidence: { - ...request.evidence, - redacted: false - } - }) - ).rejects.toThrow("AI advisory input must use redacted evidence."); + ...aiAdvisoryIntent(), + normalizedFinding: { title: 'caller supplied' }, + evidence: { redacted: true } + } as never) + ).rejects.toThrow( + 'AI advisory intent must contain only durable scope identifiers.' + ); + expect(evidenceAccess.classifyForAi).not.toHaveBeenCalled(); + expect(store.persistHandoff).not.toHaveBeenCalled(); + }); + + it('fails closed on durable finding drift or a changed final access decision', async () => { + const access = allowedAiAccess(); + const denied = { + outcome: 'DENIED' as const, + reasonCode: 'EVIDENCE_ACCESS_DELETION_PENDING' as const, + decision: access.decision, + replayed: false, + dashboardEvidence: null, + reducedEvidenceReference: null + }; + const evidenceAccess = { + classifyForAi: jest + .fn() + .mockResolvedValueOnce(access) + .mockResolvedValueOnce(denied) + }; + const store = memoryStore(access.decision); + const service = new AiAdvisoryService( + config(false), + { createAdvisory: jest.fn() } as never, + evidenceAccess as never, + store as never + ); await expect( - service.createAdvisory({ - ...request, - normalizedFinding: { - ...request.normalizedFinding, - title: "fullRepository payload was supplied" - } - }) - ).rejects.toThrow("AI advisory input contains forbidden sensitive content."); + service.createAdvisory(aiAdvisoryIntent(), clock()) + ).rejects.toThrow('AI advisory source is unavailable.'); + expect(store.persistHandoff).not.toHaveBeenCalled(); + + const missingStore = memoryStore(access.decision); + missingStore.loadNormalizedFinding.mockResolvedValueOnce(null); + const missingService = new AiAdvisoryService( + config(false), + { createAdvisory: jest.fn() } as never, + { classifyForAi: jest.fn().mockResolvedValue(access) } as never, + missingStore as never + ); + await expect( + missingService.createAdvisory(aiAdvisoryIntent(), clock()) + ).rejects.toThrow('AI advisory source is unavailable.'); + expect(missingStore.persistHandoff).not.toHaveBeenCalled(); + }); + + it('fails closed on handoff or result persistence conflicts', async () => { + const logger = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const access = allowedAiAccess(); + const runtime = { createAdvisory: jest.fn() }; + const handoffStore = memoryStore(access.decision); + handoffStore.persistHandoff.mockRejectedValueOnce( + new Error('handoff conflict') + ); + const handoffService = new AiAdvisoryService( + config(false), + runtime as never, + { classifyForAi: jest.fn().mockResolvedValue(access) } as never, + handoffStore as never + ); + + await expect( + handoffService.createAdvisory(aiAdvisoryIntent(), clock()) + ).rejects.toThrow('AI advisory source is unavailable.'); + expect(runtime.createAdvisory).not.toHaveBeenCalled(); + + const resultStore = memoryStore(access.decision); + resultStore.persistAdvisory.mockRejectedValueOnce( + new Error('result conflict') + ); + const resultService = new AiAdvisoryService( + config(false), + runtime as never, + { classifyForAi: jest.fn().mockResolvedValue(access) } as never, + resultStore as never + ); + await expect( + resultService.createAdvisory(aiAdvisoryIntent(), clock()) + ).rejects.toThrow('AI advisory source is unavailable.'); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('AI advisory handoff persistence failed') + ); + expect(logger).toHaveBeenCalledWith( + expect.stringContaining('AI advisory persistence failed') + ); + expect(JSON.stringify(logger.mock.calls)).not.toMatch( + /handoff conflict|result conflict/u + ); }); - it("uses runtime detector planner output when internal AI runtime is enabled", async () => { + it('sends only the canonical handoff to the internal runtime and replays stored results', async () => { + const access = allowedAiAccess(); const runtimeResponse: AiInferenceResponse = { - requestId: "ai_request_1", - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", + requestId: 'ignored-by-service-mock', + tenantId: 'tenant-ai', + scanRequestId: 'scan-ai', advisoryOnly: true, detectorAdvisories: [ { - findingId: "finding_1", + findingId: 'normalized-finding-ai', confidence: 0.83, - rationale: "Runtime detector advisory.", - signals: ["SCANNER_CONFIRMED", "MODEL_TRIAGED"] + rationale: 'Runtime detector advisory.', + signals: ['SCANNER_CONFIRMED', 'MODEL_TRIAGED'] } ], plannerAdvisories: [ { - findingId: "finding_1", - action: "Review scanner evidence before remediation.", - rationale: "Runtime planner advisory.", - priority: "high" + findingId: 'normalized-finding-ai', + action: 'Review normalized evidence.', + rationale: 'Runtime planner advisory.', + priority: 'high' } ], modelMetadata: { - provider: "deterministic", - model: "detector-planner-runtime", - version: "2026-05-26" - }, - fallback: { - used: true, - reason: "provider not configured" + provider: 'deterministic', + model: 'detector-planner-runtime', + version: '2026-08-11' }, + fallback: { used: true, reason: 'provider not configured' }, latencyMs: 11, - createdAt: "2026-05-26T00:00:00.000Z" + createdAt: '2026-08-11T04:00:00.450Z' }; const runtime = { createAdvisory: jest.fn().mockResolvedValue(runtimeResponse) }; + const store = memoryStore(access.decision); const service = new AiAdvisoryService( - { - get: jest.fn((key: string) => (key === "USE_INTERNAL_AI" ? "true" : undefined)) - } as never, - runtime as never + config(true), + runtime as never, + { classifyForAi: jest.fn().mockResolvedValue(access) } as never, + store as never ); - const advisory = await service.createAdvisory({ - ...request, - modelVersion: "detector-planner-runtime-v1" - }); - + const advisory = await service.createAdvisory( + aiAdvisoryIntent(), + clock() + ); expect(runtime.createAdvisory).toHaveBeenCalledWith( expect.objectContaining({ - tenantId: "tenant_ai", - findingId: "finding_1", - evidence: expect.objectContaining({ - redacted: true + normalizedFinding: expect.objectContaining({ + normalizedFindingId: 'normalized-finding-ai' + }), + reducedEvidenceReference: expect.objectContaining({ + retrievalAllowed: false, + toolsAllowed: false }) }) ); - expect(advisory).toEqual( - expect.objectContaining({ - modelVersion: "2026-05-26", - detectorSignals: ["SCANNER_CONFIRMED", "MODEL_TRIAGED"], - plannerSteps: ["Review scanner evidence before remediation."], - confidence: 0.83, - advisoryOnly: true, - redactedEvidenceOnly: true, - detectorAdvisories: runtimeResponse.detectorAdvisories, - plannerAdvisories: runtimeResponse.plannerAdvisories, - modelMetadata: runtimeResponse.modelMetadata, - fallback: runtimeResponse.fallback - }) - ); - expect(JSON.stringify(advisory)).not.toMatch( - /enforcementAction|blockRequested|policyOverride|findingOverride|waiverApplied|staleSuppressed/i + expect(advisory.modelVersion).toBe('2026-08-11'); + expect(advisory.detectorSignals).toEqual([ + 'SCANNER_CONFIRMED', + 'MODEL_TRIAGED' + ]); + + store.loadAdvisory.mockResolvedValueOnce(advisory); + runtime.createAdvisory.mockClear(); + const replayed = await service.createAdvisory( + aiAdvisoryIntent(), + clock() ); + expect(replayed).toEqual(advisory); + expect(runtime.createAdvisory).not.toHaveBeenCalled(); }); +}); - it("persists advisory metadata without granting finding or policy authority", async () => { - const createdAt = new Date("2026-05-26T00:00:00.000Z"); - const prisma = { - aiAdvisoryMetadata: { - create: jest.fn(async ({ data }) => ({ - ...data, - createdAt, - updatedAt: createdAt - })), - findFirst: jest.fn(async ({ where }) => ({ - id: where.id, - tenantId: where.tenantId, - scanRequestId: "scan_request_1", - findingId: "finding_1", - modelVersion: "detector-planner-mock-v1", - advisoryOnly: true, - redactedEvidenceOnly: true, - detectorSignals: ["SCANNER_CONFIRMED", "SEVERITY_HIGH", "PROVENANCE_OPENGREP"], - plannerSteps: [ - "Review scanner evidence before remediation.", - "Prioritize owner review before merging affected changes.", - "Apply remediation outside the AI advisory boundary." - ], - confidence: 0.74, - detectorAdvisories: null, - plannerAdvisories: null, - modelMetadata: null, - fallback: null, - createdAt, - updatedAt: createdAt - })) - } - }; - const service = new AiAdvisoryService( - { - get: jest.fn().mockReturnValue("false") - } as never, - undefined, - prisma as never - ); - - const advisory = await service.createAdvisory(request); - - expect(advisory.id).not.toBe("ai_advisory_1"); - expect(advisory.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); - expect(prisma.aiAdvisoryMetadata.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - id: advisory.id, - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - findingId: "finding_1", - modelVersion: "detector-planner-mock-v1", - advisoryOnly: true, - redactedEvidenceOnly: true, - confidence: 0.74 - }) - }); - expect(JSON.stringify(prisma.aiAdvisoryMetadata.create.mock.calls)).not.toMatch( - /enforcementAction|blockRequested|policyOverride|findingOverride|waiverApplied|staleSuppressed/i - ); +function memoryStore(decision: ReturnType['decision']) { + let persisted: SastAiAdvisoryHandoff | null = null; + return { + loadNormalizedFinding: jest + .fn() + .mockResolvedValue(aiNormalizedFinding(decision)), + persistHandoff: jest.fn(async (handoff: SastAiAdvisoryHandoff) => { + const replayed = persisted !== null; + persisted = handoff; + return { handoff, replayed }; + }), + loadAdvisory: jest.fn, [unknown]>() + .mockResolvedValue(null), + persistAdvisory: jest.fn(async ({ advisory }: { advisory: AiAdvisoryResult }) => advisory) + }; +} - const stored = await service.getAdvisory("tenant_ai", advisory.id); +function config(enabled: boolean) { + return { + get: jest.fn((key: string) => + key === 'USE_INTERNAL_AI' ? String(enabled) : undefined + ) + } as never; +} - expect(prisma.aiAdvisoryMetadata.findFirst).toHaveBeenCalledWith({ - where: { - id: advisory.id, - tenantId: "tenant_ai" - } - }); - expect(stored).toEqual( - expect.objectContaining({ - id: advisory.id, - tenantId: "tenant_ai", - scanRequestId: "scan_request_1", - findingId: "finding_1", - advisoryOnly: true, - redactedEvidenceOnly: true, - detectorSignals: ["SCANNER_CONFIRMED", "SEVERITY_HIGH", "PROVENANCE_OPENGREP"] - }) - ); - expect(JSON.stringify(stored)).not.toMatch( - /enforcementAction|blockRequested|policyOverride|findingOverride|waiverApplied|staleSuppressed/i - ); - }); -}); +function clock() { + const values = [ + '2026-08-11T04:00:00.100Z', + '2026-08-11T04:00:00.200Z', + '2026-08-11T04:00:00.300Z', + '2026-08-11T04:00:00.400Z', + '2026-08-11T04:00:00.500Z' + ]; + let index = 0; + return () => values[Math.min(index++, values.length - 1)] as string; +} diff --git a/apps/api/test/ai-plane/sast-ai-advisory-persistence.e2e-spec.ts b/apps/api/test/ai-plane/sast-ai-advisory-persistence.e2e-spec.ts new file mode 100644 index 0000000..63abbec --- /dev/null +++ b/apps/api/test/ai-plane/sast-ai-advisory-persistence.e2e-spec.ts @@ -0,0 +1,201 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { readScanPlaneExports } from '../support/scan-plane-module-source'; +import { PrismaSastAiAdvisoryStore } from '../../src/ai-plane/prisma-sast-ai-advisory.store'; +import { aiHandoff } from '../support/sast-ai-advisory-fixture'; + +describe('SAST AI advisory handoff persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql' + ); + const onlineSchema = read( + 'scripts/apply-online-sast-runtime-schema.mjs' + ); + const store = read( + 'src/ai-plane/prisma-sast-ai-advisory.store.ts' + ); + const service = read('src/ai-plane/ai-advisory.service.ts'); + const runtime = read( + 'src/ai-plane/ai-advisory-runtime.client.ts' + ); + const controller = read( + 'src/ai-plane/ai-advisory.controller.ts' + ); + const aiModule = read('src/ai-plane/ai-plane.module.ts'); + const scanModule = read('src/scan-plane/scan-plane.module.ts'); + + it('adds an immutable reference-only handoff ledger', () => { + expect(schema).toContain('model SastAiAdvisoryHandoff {'); + expect(migration).toContain( + 'CREATE TABLE "SastAiAdvisoryHandoff"' + ); + expect(migration).toContain( + 'SastAiAdvisoryHandoff_immutable_update' + ); + expect(migration).toContain( + 'SastAiAdvisoryHandoff_immutable_delete' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryHandoff_access_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryHandoff_occurrence_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryHandoff_finding_scope_fkey' + ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastEvidenceAccessDecision_ai_scope_key"' + ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AiAdvisoryMetadata_sastHandoffId_key"' + ); + expect(onlineSchema).toContain( + 'AiAdvisoryMetadata_sastHandoffId_fkey' + ); + expect(migration).not.toMatch( + /SastEvidenceAccessDecision_ai_scope_key|SastAiAdvisoryHandoff_(?:access|occurrence|finding)_scope_fkey/u + ); + expect(migration).not.toMatch( + /AiAdvisoryMetadata_sastHandoffId_(?:key|fkey)/u + ); + expect(migration).toContain( + '"payloadExpiresAt" TIMESTAMP(3) NOT NULL' + ); + expect(migration).toContain('"createdAt" TIMESTAMP(3) NOT NULL'); + expect(migration).not.toContain('"handoff" JSONB'); + expect(schema).not.toMatch( + /model SastAiAdvisoryHandoff \{[\s\S]*?\n\s+handoff\s+Json/u + ); + }); + + it('persists only scope references, digests, expiry, and fixed authority bits', () => { + for (const value of [ + '"requestPayloadStored" IS FALSE', + '"rawSourceStored" IS FALSE', + '"secretValueStored" IS FALSE', + '"evidenceFragmentStored" IS FALSE', + '"retrievalAttempted" IS FALSE', + '"toolsInvoked" IS FALSE', + '"retrievalAllowed" IS FALSE', + '"toolsAllowed" IS FALSE', + '"policyAuthority" IS FALSE', + '"publicationAuthority" IS FALSE', + '"lifecycleMutationAuthority" IS FALSE', + '"scmWriteAuthority" IS FALSE' + ]) { + expect(migration).toContain(value); + } + expect(store).not.toContain( + 'handoff: handoff as unknown as Prisma.InputJsonValue' + ); + expect(store).toContain('requestDigest: handoff.requestDigest'); + expect(store).toContain('handoffDigest: handoff.handoffDigest'); + expect(store).toContain('requestPayloadStored: false'); + }); + + it('rebinds T042 access to the durable T037 normalized source', () => { + expect(store).toContain( + 'isSastEvidenceAccessDecisionShapeValid' + ); + expect(store).toContain( + 'isSastSecretRedactedFindingCandidateShapeValid' + ); + expect(store).toContain('isStoredDecisionBound'); + expect(store).toContain('isSourceBound'); + expect(store).toContain('id_tenantId'); + expect(store).toContain('isSameInstant'); + expect(store).toContain( + 'Prisma.TransactionIsolationLevel.Serializable' + ); + expect(service).toContain('classifyForAi'); + expect(service.match(/this\.classify\(scope, clock\)/gu)).toHaveLength(2); + }); + + it('sends no fragment content, retrieval, tools, or decision authority to AI', () => { + expect(runtime).toContain('snippets: []'); + expect(runtime).toContain("redactionState: 'reduced'"); + expect(runtime).toContain('modelVersion: handoff.modelVersion'); + expect(runtime).toContain('retrievalAllowed: false'); + expect(runtime).toContain('toolsAllowed: false'); + expect(runtime).toContain('policyAuthority: false'); + expect(runtime).toContain('publicationAuthority: false'); + expect(runtime).toContain('lifecycleMutationAuthority: false'); + expect(runtime).toContain('scmWriteAuthority: false'); + expect(runtime).not.toContain('redactedContent'); + }); + + it('keeps the boundary narrow and caller intent exact', () => { + expect(controller).toContain('SastAiAdvisoryIntent'); + expect(aiModule).toContain('ScanPlaneModule'); + expect(aiModule).toContain('SastAiAdvisoryStore'); + const exportsBlock = readScanPlaneExports(scanModule); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); + }); + + it('persists exact replay metadata without serializing the handoff body', async () => { + const rows = new Map>(); + const handoffModel = { + findUnique: jest.fn(({ where }: { where: { id: string } }) => + Promise.resolve(rows.get(where.id) ?? null) + ), + create: jest.fn(({ data }: { data: Record }) => { + rows.set(String(data.id), data); + return Promise.resolve(data); + }) + }; + const transaction = { sastAiAdvisoryHandoff: handoffModel }; + const prisma = { + sastAiAdvisoryHandoff: handoffModel, + $transaction: jest.fn( + (operation: (tx: typeof transaction) => Promise) => + operation(transaction) + ) + }; + const persistence = new PrismaSastAiAdvisoryStore(prisma as never); + const handoff = aiHandoff(); + + await expect(persistence.persistHandoff(handoff)).resolves.toEqual({ + handoff, + replayed: false + }); + const stored = handoffModel.create.mock.calls[0]?.[0].data; + expect(stored).not.toHaveProperty('handoff'); + expect(stored).toMatchObject({ + id: handoff.handoffId, + requestDigest: handoff.requestDigest, + handoffDigest: handoff.handoffDigest, + requestPayloadStored: false, + rawSourceStored: false, + secretValueStored: false, + evidenceFragmentStored: false, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false + }); + await expect(persistence.persistHandoff(handoff)).resolves.toEqual({ + handoff, + replayed: true + }); + + const exactRow = rows.get(handoff.handoffId); + expect(exactRow).toBeDefined(); + rows.set(handoff.handoffId, { + ...exactRow!, + requestDigest: `sha256:${'f'.repeat(64)}` + }); + await expect(persistence.persistHandoff(handoff)).rejects.toMatchObject({ + reason: 'REPLAY_CONFLICT' + }); + }); +}); + +function read(relativePath: string): string { + return readFileSync(resolve(__dirname, '../..', relativePath), 'utf8'); +} diff --git a/apps/api/test/support/sast-ai-advisory-fixture.ts b/apps/api/test/support/sast-ai-advisory-fixture.ts new file mode 100644 index 0000000..e30f94d --- /dev/null +++ b/apps/api/test/support/sast-ai-advisory-fixture.ts @@ -0,0 +1,164 @@ +import { createHash } from 'node:crypto'; + +import { + buildSastAiAdvisoryHandoff, + buildSastEvidenceAccessDecision, + buildSastEvidenceDeletionSchedule, + type SastAiAdvisoryHandoff, + type SastAiAdvisoryIntent, + type SastAiAdvisoryNormalizedFinding, + type SastEvidenceAccessDecision, + type SastReducedEvidenceReference +} from '@aegisai/shared'; + +export const AI_FIXTURE_DECIDED_AT = + '2026-08-11T04:00:00.000Z'; +export const AI_FIXTURE_PAYLOAD_EXPIRES_AT = + '2026-08-12T04:00:00.000Z'; +export const AI_FIXTURE_EVIDENCE_EXPIRES_AT = + '2026-08-18T03:40:00.000Z'; + +export function aiAdvisoryIntent(): SastAiAdvisoryIntent { + return { + tenantId: 'tenant-ai', + repositoryBindingId: 'repository-ai', + evidencePackId: contractId('sast-evidence-pack', 'pack-ai'), + modelVersion: 'detector-planner-runtime-v1' + }; +} + +export function aiAccessDecision(): SastEvidenceAccessDecision { + const intent = aiAdvisoryIntent(); + const schedule = buildSastEvidenceDeletionSchedule({ + scope: { + tenantId: intent.tenantId, + repositoryBindingId: intent.repositoryBindingId, + scanRequestId: 'scan-ai', + attemptId: 'attempt-ai', + occurrenceId: contractId('finding-occurrence', 'occurrence-ai'), + buildDecisionId: contractId('sast-evidence-build', 'build-ai'), + evidencePackId: intent.evidencePackId, + findingFingerprint: digest('finding-ai'), + profileId: 'JAVA_FAST_V1', + profileDigest: + 'sha256:19743211685c76ac7c63cb8c829823c45bf458da3aee5dac4f5eaba2b44bbe74', + freshnessDecisionId: contractId('sast-freshness', 'fresh-ai'), + freshnessDecisionDigest: digest('fresh-ai'), + coverageDecisionId: contractId('sast-coverage', 'coverage-ai'), + coverageDecisionDigest: digest('coverage-ai'), + sourcePackDigest: digest('pack-ai') + }, + scheduledAt: '2026-08-11T03:40:00.000Z', + deleteAfter: AI_FIXTURE_EVIDENCE_EXPIRES_AT, + digestCanonical: digest + }); + return buildSastEvidenceAccessDecision({ + purpose: 'AI_ADVISORY', + scope: schedule.scope, + schedule, + secretRegistryVersion: 'platform-secret-registry-v1', + outcome: 'ALLOWED', + reasonCodes: [], + redactedProjectionDigest: digest('projection-ai'), + redactedFragmentCount: 1, + redactedTotalBytes: 32, + redactionCount: 0, + evidenceExpiresAt: AI_FIXTURE_EVIDENCE_EXPIRES_AT, + decidedAt: AI_FIXTURE_DECIDED_AT, + digestCanonical: digest + }); +} + +export function aiReducedReference( + decision: SastEvidenceAccessDecision = aiAccessDecision() +): SastReducedEvidenceReference { + if ( + !decision.reducedEvidenceRef || + !decision.redactedProjectionDigest || + !decision.aiPayloadExpiresAt + ) { + throw new Error('AI access decision fixture is incomplete.'); + } + return { + version: 'sast-reduced-evidence-reference-v1', + reducedEvidenceRef: decision.reducedEvidenceRef, + accessDecisionId: decision.accessDecisionId, + accessDecisionDigest: decision.decisionDigest, + evidencePackId: decision.scope.evidencePackId, + findingFingerprint: decision.scope.findingFingerprint, + redactedProjectionDigest: decision.redactedProjectionDigest, + fragmentCount: decision.redactedFragmentCount, + payloadExpiresAt: decision.aiPayloadExpiresAt, + aiPayloadCreated: false, + aiProviderCalled: false, + retrievalAllowed: false, + toolsAllowed: false, + advisoryOnly: true + }; +} + +export function aiNormalizedFinding( + decision: SastEvidenceAccessDecision = aiAccessDecision() +): SastAiAdvisoryNormalizedFinding { + return { + normalizedFindingId: 'normalized-finding-ai', + occurrenceId: decision.scope.occurrenceId, + tenantId: decision.scope.tenantId, + repositoryBindingId: decision.scope.repositoryBindingId, + scanRequestId: decision.scope.scanRequestId, + attemptId: decision.scope.attemptId, + scannerRunId: 'scanner-run-ai', + findingFingerprint: decision.scope.findingFingerprint, + capability: 'SAST', + title: 'Unsafe deserialization', + severity: 'HIGH', + confidence: 'HIGH', + cweIds: ['CWE-502', 'CWE-79'], + cveIds: ['CVE-2025-0001', 'CVE-2026-0002'], + location: { + kind: 'FILE', + normalizedPath: 'src/App.java', + lineStart: 42, + lineEnd: 42 + }, + scanner: 'OPENGREP', + ruleSemanticId: 'java.unsafe-deserialization', + ruleRevision: '1.0.0', + secretRedactionApplied: true + }; +} + +export function aiHandoff( + createdAt = '2026-08-11T04:00:01.000Z' +): SastAiAdvisoryHandoff { + const decision = aiAccessDecision(); + const handoff = buildSastAiAdvisoryHandoff({ + decision, + reducedEvidenceReference: aiReducedReference(decision), + normalizedFinding: aiNormalizedFinding(decision), + modelVersion: aiAdvisoryIntent().modelVersion, + createdAt, + digestCanonical: digest + }); + if (!handoff) throw new Error('AI handoff fixture is invalid.'); + return handoff; +} + +export function allowedAiAccess() { + const decision = aiAccessDecision(); + return { + outcome: 'ALLOWED' as const, + decision, + replayed: false, + dashboardEvidence: null, + reducedEvidenceReference: aiReducedReference(decision) + }; +} + +export function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +function contractId(prefix: string, seed: string): string { + return `${prefix}://${createHash('sha256').update(seed, 'utf8').digest('hex')}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c928fab..37a155a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -24,6 +24,7 @@ export * from './types/sast-scan-coverage'; export * from './types/sast-scan-freshness'; export * from './types/sast-accepted-evidence'; export * from './types/sast-evidence-access'; +export * from './types/sast-ai-advisory-handoff'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/ai-inference-runtime.ts b/packages/shared/src/types/ai-inference-runtime.ts index 95b1995..d5803de 100644 --- a/packages/shared/src/types/ai-inference-runtime.ts +++ b/packages/shared/src/types/ai-inference-runtime.ts @@ -47,6 +47,7 @@ export interface AiInferenceRequest { scanRequestId: string; canonicalScanKey: string; requestId: string; + modelVersion: string; reducedEvidence: ReducedEvidence; requestedCapabilities: AiInferenceCapability[]; runtimePolicy: AiRuntimePolicy; diff --git a/packages/shared/src/types/production-architecture.ts b/packages/shared/src/types/production-architecture.ts index a0aa8b1..af5a67e 100644 --- a/packages/shared/src/types/production-architecture.ts +++ b/packages/shared/src/types/production-architecture.ts @@ -419,6 +419,8 @@ export interface AiAdvisoryRequest { export interface AiAdvisoryResult { id: string; + sastHandoffId?: string; + requestDigest?: `sha256:${string}`; tenantId: string; scanRequestId: string; findingId: string; diff --git a/packages/shared/src/types/sast-ai-advisory-handoff.ts b/packages/shared/src/types/sast-ai-advisory-handoff.ts new file mode 100644 index 0000000..febe616 --- /dev/null +++ b/packages/shared/src/types/sast-ai-advisory-handoff.ts @@ -0,0 +1,650 @@ +import { + isSastEvidenceAccessDecisionShapeValid, + isSastReducedEvidenceReferenceShapeValid, + type SastEvidenceAccessDecision, + type SastReducedEvidenceReference +} from './sast-evidence-access'; +import type { + SastCapability, + SastFindingLocation +} from './sast-runtime'; + +export const SAST_AI_ADVISORY_HANDOFF_VERSION = + 'sast-ai-advisory-handoff-v1' as const; + +export const SAST_AI_ADVISORY_HANDOFF_LIMITS = Object.freeze({ + modelVersionBytes: 128, + titleBytes: 512, + identifierBytes: 512, + maximumCweIds: 32, + maximumCveIds: 32 +}); + +const CONTRACT_ID_PATTERNS = { + 'sast-ai-handoff': /^sast-ai-handoff:\/\/[a-f0-9]{64}$/u, + 'sast-ai-request': /^sast-ai-request:\/\/[a-f0-9]{64}$/u, + 'sast-ai-advisory': /^sast-ai-advisory:\/\/[a-f0-9]{64}$/u, + 'sast-reduced-evidence': /^sast-reduced-evidence:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-access': /^sast-evidence-access:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-pack': /^sast-evidence-pack:\/\/[a-f0-9]{64}$/u +} as const; + +export interface SastAiAdvisoryIntent { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + modelVersion: string; +} + +export interface SastAiAdvisoryNormalizedFinding { + normalizedFindingId: string; + occurrenceId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + findingFingerprint: `sha256:${string}`; + capability: Exclude; + title: string; + severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO'; + confidence: 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; + cweIds: string[]; + cveIds: string[]; + location: SastFindingLocation; + scanner: 'OPENGREP' | 'TRIVY'; + ruleSemanticId: string; + ruleRevision: string; + secretRedactionApplied: true; +} + +export interface SastAiAdvisoryHandoffAuthority { + normalizedFindingAllowed: true; + reducedEvidenceReferenceAllowed: true; + aiPayloadAllowed: true; + aiProviderCallAllowed: true; + retrievalAllowed: false; + toolsAllowed: false; + policyAuthority: false; + publicationAuthority: false; + lifecycleMutationAuthority: false; + scmWriteAuthority: false; + advisoryOnly: true; +} + +export interface SastAiAdvisoryHandoffAudit { + callerFindingAccepted: false; + callerEvidenceAccepted: false; + callerPromptAccepted: false; + rawSourceStored: false; + secretValueStored: false; + evidenceFragmentStored: false; + requestPayloadStored: false; + retrievalAttempted: false; + toolsInvoked: false; +} + +export interface SastAiAdvisoryHandoff { + version: typeof SAST_AI_ADVISORY_HANDOFF_VERSION; + handoffId: string; + requestId: string; + advisoryId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + modelVersion: string; + normalizedFinding: SastAiAdvisoryNormalizedFinding; + reducedEvidenceReference: SastReducedEvidenceReference; + accessDecisionId: string; + accessDecisionDigest: `sha256:${string}`; + evidencePackId: string; + payloadExpiresAt: string; + authority: SastAiAdvisoryHandoffAuthority; + audit: SastAiAdvisoryHandoffAudit; + createdAt: string; + requestDigest: `sha256:${string}`; + handoffDigest: `sha256:${string}`; +} + +export type SastAiAdvisoryHandoffCore = Omit< + SastAiAdvisoryHandoff, + 'handoffDigest' +>; + +export type SastAiAdvisoryCanonicalDigester = ( + canonicalValue: string +) => `sha256:${string}`; + +const AUTHORITY: SastAiAdvisoryHandoffAuthority = Object.freeze({ + normalizedFindingAllowed: true, + reducedEvidenceReferenceAllowed: true, + aiPayloadAllowed: true, + aiProviderCallAllowed: true, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true +}); + +const AUDIT: SastAiAdvisoryHandoffAudit = Object.freeze({ + callerFindingAccepted: false, + callerEvidenceAccepted: false, + callerPromptAccepted: false, + rawSourceStored: false, + secretValueStored: false, + evidenceFragmentStored: false, + requestPayloadStored: false, + retrievalAttempted: false, + toolsInvoked: false +}); + +export function canonicalizeSastAiAdvisoryRequest(value: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + modelVersion: string; + normalizedFinding: Readonly; + reducedEvidenceReference: Readonly; + accessDecisionId: string; + accessDecisionDigest: `sha256:${string}`; + evidencePackId: string; + payloadExpiresAt: string; + createdAt: string; +}): string { + return stableJson(value); +} + +export function canonicalizeSastAiAdvisoryHandoff( + value: Readonly +): string { + return stableJson(value); +} + +export function buildSastAiAdvisoryHandoff(input: { + decision: Readonly; + reducedEvidenceReference: Readonly; + normalizedFinding: Readonly; + modelVersion: string; + createdAt: string; + digestCanonical: SastAiAdvisoryCanonicalDigester; +}): SastAiAdvisoryHandoff | null { + if ( + !isSastEvidenceAccessDecisionShapeValid( + input.decision, + input.digestCanonical + ) || + input.decision.purpose !== 'AI_ADVISORY' || + input.decision.outcome !== 'ALLOWED' || + input.decision.classification !== 'AI_REDUCED_REFERENCE_SAFE' || + !input.decision.authority.reducedEvidenceReferenceAllowed || + input.decision.authority.aiPayloadAllowed || + input.decision.authority.aiProviderCallAllowed || + !isSastReducedEvidenceReferenceShapeValid( + input.reducedEvidenceReference + ) || + !isSastAiAdvisoryNormalizedFindingShapeValid( + input.normalizedFinding + ) || + !isBoundedModelVersion(input.modelVersion) || + !isIsoInstant(input.createdAt) + ) { + return null; + } + + const scope = input.decision.scope; + const finding = input.normalizedFinding; + const reference = input.reducedEvidenceReference; + const createdAt = Date.parse(input.createdAt); + const payloadExpiresAt = Date.parse(reference.payloadExpiresAt); + const evidenceExpiresAt = Date.parse( + input.decision.evidenceExpiresAt + ); + + if ( + finding.tenantId !== scope.tenantId || + finding.repositoryBindingId !== scope.repositoryBindingId || + finding.scanRequestId !== scope.scanRequestId || + finding.attemptId !== scope.attemptId || + finding.occurrenceId !== scope.occurrenceId || + finding.findingFingerprint !== scope.findingFingerprint || + reference.accessDecisionId !== input.decision.accessDecisionId || + reference.accessDecisionDigest !== input.decision.decisionDigest || + reference.evidencePackId !== scope.evidencePackId || + reference.findingFingerprint !== scope.findingFingerprint || + reference.reducedEvidenceRef !== input.decision.reducedEvidenceRef || + reference.redactedProjectionDigest !== + input.decision.redactedProjectionDigest || + reference.payloadExpiresAt !== input.decision.aiPayloadExpiresAt || + !Number.isFinite(createdAt) || + !Number.isFinite(payloadExpiresAt) || + !Number.isFinite(evidenceExpiresAt) || + createdAt < Date.parse(input.decision.decidedAt) || + createdAt >= payloadExpiresAt || + payloadExpiresAt > evidenceExpiresAt + ) { + return null; + } + + // Bind the durable handoff timestamp to the immutable access decision rather + // than the caller's invocation clock. Exact retries therefore derive the + // same request, handoff, and advisory identifiers while the invocation clock + // is still used above to enforce expiry. + const requestCore = { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + modelVersion: input.modelVersion, + normalizedFinding: cloneFinding(finding), + reducedEvidenceReference: { ...reference }, + accessDecisionId: input.decision.accessDecisionId, + accessDecisionDigest: input.decision.decisionDigest, + evidencePackId: scope.evidencePackId, + payloadExpiresAt: reference.payloadExpiresAt, + createdAt: input.decision.decidedAt + }; + const requestDigest = input.digestCanonical( + canonicalizeSastAiAdvisoryRequest(requestCore) + ); + const suffix = stripDigest(requestDigest); + if (!suffix) return null; + + const core: SastAiAdvisoryHandoffCore = { + version: SAST_AI_ADVISORY_HANDOFF_VERSION, + handoffId: `sast-ai-handoff://${suffix}`, + requestId: `sast-ai-request://${suffix}`, + advisoryId: `sast-ai-advisory://${suffix}`, + ...requestCore, + authority: { ...AUTHORITY }, + audit: { ...AUDIT }, + requestDigest + }; + const handoff: SastAiAdvisoryHandoff = { + ...core, + handoffDigest: input.digestCanonical( + canonicalizeSastAiAdvisoryHandoff(core) + ) + }; + return isSastAiAdvisoryHandoffShapeValid( + handoff, + input.digestCanonical + ) + ? handoff + : null; +} + +export function isSastAiAdvisoryIntentShapeValid( + value: unknown +): value is SastAiAdvisoryIntent { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'evidencePackId', + 'modelVersion' + ]) && + isBoundedReference(value.tenantId) && + isBoundedReference(value.repositoryBindingId) && + isBoundedReference(value.evidencePackId) && + isBoundedModelVersion(value.modelVersion) + ); +} + +export function isSastAiAdvisoryNormalizedFindingShapeValid( + value: unknown +): value is SastAiAdvisoryNormalizedFinding { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'normalizedFindingId', + 'occurrenceId', + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'scannerRunId', + 'findingFingerprint', + 'capability', + 'title', + 'severity', + 'confidence', + 'cweIds', + 'cveIds', + 'location', + 'scanner', + 'ruleSemanticId', + 'ruleRevision', + 'secretRedactionApplied' + ]) || + ![ + value.normalizedFindingId, + value.occurrenceId, + value.tenantId, + value.repositoryBindingId, + value.scanRequestId, + value.attemptId, + value.scannerRunId, + value.ruleSemanticId, + value.ruleRevision + ].every(isBoundedReference) || + !isSha256Digest(value.findingFingerprint) || + ![ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION' + ].includes(String(value.capability)) || + !isBoundedText(value.title, SAST_AI_ADVISORY_HANDOFF_LIMITS.titleBytes) || + hasAsciiControl(value.title) || + !['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].includes( + String(value.severity) + ) || + !['HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'].includes( + String(value.confidence) + ) || + (value.scanner !== 'OPENGREP' && value.scanner !== 'TRIVY') || + (value.scanner === 'OPENGREP') !== (value.capability === 'SAST') || + value.secretRedactionApplied !== true || + !isBoundedIdentifierArray( + value.cweIds, + SAST_AI_ADVISORY_HANDOFF_LIMITS.maximumCweIds + ) || + !isBoundedIdentifierArray( + value.cveIds, + SAST_AI_ADVISORY_HANDOFF_LIMITS.maximumCveIds + ) || + !isFindingLocationValid(value.location) + ) { + return false; + } + return true; +} + +export function isSastAiAdvisoryHandoffShapeValid( + value: unknown, + digestCanonical: SastAiAdvisoryCanonicalDigester +): value is SastAiAdvisoryHandoff { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'handoffId', + 'requestId', + 'advisoryId', + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'modelVersion', + 'normalizedFinding', + 'reducedEvidenceReference', + 'accessDecisionId', + 'accessDecisionDigest', + 'evidencePackId', + 'payloadExpiresAt', + 'authority', + 'audit', + 'createdAt', + 'requestDigest', + 'handoffDigest' + ]) || + value.version !== SAST_AI_ADVISORY_HANDOFF_VERSION || + !isContractId(value.handoffId, 'sast-ai-handoff') || + !isContractId(value.requestId, 'sast-ai-request') || + !isContractId(value.advisoryId, 'sast-ai-advisory') || + !isSastAiAdvisoryNormalizedFindingShapeValid( + value.normalizedFinding + ) || + !isSastReducedEvidenceReferenceShapeValid( + value.reducedEvidenceReference + ) || + !isBoundedReference(value.tenantId) || + !isBoundedReference(value.repositoryBindingId) || + !isBoundedReference(value.scanRequestId) || + !isBoundedReference(value.attemptId) || + !isBoundedModelVersion(value.modelVersion) || + !isBoundedReference(value.accessDecisionId) || + !isSha256Digest(value.accessDecisionDigest) || + !isBoundedReference(value.evidencePackId) || + !isIsoInstant(value.payloadExpiresAt) || + !isIsoInstant(value.createdAt) || + !isSha256Digest(value.requestDigest) || + !isSha256Digest(value.handoffDigest) || + !isExactAuthority(value.authority) || + !isExactAudit(value.audit) + ) { + return false; + } + + const core = { ...value } as Record; + delete core.handoffDigest; + const requestCore = { + tenantId: value.tenantId, + repositoryBindingId: value.repositoryBindingId, + scanRequestId: value.scanRequestId, + attemptId: value.attemptId, + modelVersion: value.modelVersion, + normalizedFinding: value.normalizedFinding, + reducedEvidenceReference: value.reducedEvidenceReference, + accessDecisionId: value.accessDecisionId, + accessDecisionDigest: value.accessDecisionDigest, + evidencePackId: value.evidencePackId, + payloadExpiresAt: value.payloadExpiresAt, + createdAt: value.createdAt + }; + const suffix = stripDigest(value.requestDigest); + const createdAt = Date.parse(value.createdAt); + const payloadExpiresAt = Date.parse(value.payloadExpiresAt); + return ( + value.tenantId === value.normalizedFinding.tenantId && + value.repositoryBindingId === + value.normalizedFinding.repositoryBindingId && + value.scanRequestId === value.normalizedFinding.scanRequestId && + value.attemptId === value.normalizedFinding.attemptId && + value.accessDecisionId === + value.reducedEvidenceReference.accessDecisionId && + value.accessDecisionDigest === + value.reducedEvidenceReference.accessDecisionDigest && + value.evidencePackId === + value.reducedEvidenceReference.evidencePackId && + value.normalizedFinding.findingFingerprint === + value.reducedEvidenceReference.findingFingerprint && + value.payloadExpiresAt === + value.reducedEvidenceReference.payloadExpiresAt && + createdAt < payloadExpiresAt && + payloadExpiresAt - createdAt <= 24 * 60 * 60 * 1000 && + value.handoffId === `sast-ai-handoff://${suffix}` && + value.requestId === `sast-ai-request://${suffix}` && + value.advisoryId === `sast-ai-advisory://${suffix}` && + digestCanonical(canonicalizeSastAiAdvisoryRequest(requestCore)) === + value.requestDigest && + digestCanonical( + canonicalizeSastAiAdvisoryHandoff( + core as unknown as SastAiAdvisoryHandoffCore + ) + ) === value.handoffDigest + ); +} + +function cloneFinding( + value: Readonly +): SastAiAdvisoryNormalizedFinding { + return { + ...value, + cweIds: [...value.cweIds], + cveIds: [...value.cveIds], + location: { ...value.location } + }; +} + +function isExactAuthority( + value: unknown +): value is SastAiAdvisoryHandoffAuthority { + return isRecord(value) && + hasExactKeys(value, Object.keys(AUTHORITY)) && + Object.entries(AUTHORITY).every(([key, expected]) => + value[key] === expected + ); +} + +function isExactAudit( + value: unknown +): value is SastAiAdvisoryHandoffAudit { + return isRecord(value) && + hasExactKeys(value, Object.keys(AUDIT)) && + Object.entries(AUDIT).every(([key, expected]) => + value[key] === expected + ); +} + +function isFindingLocationValid(value: unknown): value is SastFindingLocation { + if (!isRecord(value)) return false; + if (value.kind === 'UNKNOWN') { + return ( + hasExactKeys(value, ['kind', 'reasonCode']) && + (value.reasonCode === 'SCANNER_LOCATION_OMITTED' || + value.reasonCode === 'LOCATION_NOT_MAPPABLE') + ); + } + if (value.kind !== 'FILE') return false; + const allowed = [ + 'kind', + 'normalizedPath', + 'lineStart', + 'lineEnd', + 'columnStart', + 'columnEnd' + ]; + return ( + Object.keys(value).every((key) => allowed.includes(key)) && + typeof value.normalizedPath === 'string' && + value.normalizedPath.length > 0 && + value.normalizedPath.length <= 1024 && + !value.normalizedPath.includes('\\') && + !value.normalizedPath.split('/').includes('..') && + isPositiveInteger(value.lineStart) && + (value.lineEnd === undefined || + (isPositiveInteger(value.lineEnd) && + Number(value.lineEnd) >= Number(value.lineStart))) && + (value.columnStart === undefined || + isPositiveInteger(value.columnStart)) && + (value.columnEnd === undefined || + (value.columnStart !== undefined && + isPositiveInteger(value.columnEnd) && + (value.lineEnd !== undefined && + value.lineEnd !== value.lineStart + ? true + : Number(value.columnEnd) >= Number(value.columnStart)))) + ); +} + +function isBoundedIdentifierArray( + value: unknown, + maximum: number +): value is string[] { + return Array.isArray(value) && + value.length <= maximum && + value.every((item) => + typeof item === 'string' && + /^[A-Z0-9][A-Z0-9._:-]{0,127}$/u.test(item) + ) && + new Set(value).size === value.length && + value.every((item, index) => + index === 0 || String(value[index - 1]) < item + ); +} + +function isBoundedModelVersion(value: unknown): value is string { + return typeof value === 'string' && + /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,127}$/u.test(value) && + utf8Length(value) <= SAST_AI_ADVISORY_HANDOFF_LIMITS.modelVersionBytes; +} + +function isBoundedReference(value: unknown): value is string { + return isBoundedText( + value, + SAST_AI_ADVISORY_HANDOFF_LIMITS.identifierBytes + ) && + value.trim() === value && + !hasAsciiControl(value); +} + +function isBoundedText(value: unknown, maximumBytes: number): value is string { + return typeof value === 'string' && + value.length > 0 && + !value.includes('\u0000') && + utf8Length(value) <= maximumBytes; +} + +function isContractId( + value: unknown, + prefix: keyof typeof CONTRACT_ID_PATTERNS +): value is string { + return typeof value === 'string' && + CONTRACT_ID_PATTERNS[prefix].test(value); +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/u.test(value); +} + +function stripDigest(value: string): string | null { + return isSha256Digest(value) ? value.slice('sha256:'.length) : null; +} + +function isIsoInstant(value: unknown): value is string { + return typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value; +} + +function isPositiveInteger(value: unknown): boolean { + return Number.isInteger(value) && Number(value) > 0; +} + +function hasExactKeys( + value: Record, + expected: readonly string[] +): boolean { + const actual = Object.keys(value).sort(); + const ordered = [...expected].sort(); + return actual.length === ordered.length && + actual.every((key, index) => key === ordered[index]); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function utf8Length(value: string): number { + return new TextEncoder().encode(value).length; +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; +} diff --git a/packages/shared/src/types/sast-evidence-access.ts b/packages/shared/src/types/sast-evidence-access.ts index ac2b0ec..10616b5 100644 --- a/packages/shared/src/types/sast-evidence-access.ts +++ b/packages/shared/src/types/sast-evidence-access.ts @@ -462,6 +462,47 @@ export function isSastEvidenceAccessScopeValid( ); } +export function isSastReducedEvidenceReferenceShapeValid( + value: unknown +): value is SastReducedEvidenceReference { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'version', + 'reducedEvidenceRef', + 'accessDecisionId', + 'accessDecisionDigest', + 'evidencePackId', + 'findingFingerprint', + 'redactedProjectionDigest', + 'fragmentCount', + 'payloadExpiresAt', + 'aiPayloadCreated', + 'aiProviderCalled', + 'retrievalAllowed', + 'toolsAllowed', + 'advisoryOnly' + ]) && + value.version === SAST_REDUCED_EVIDENCE_REFERENCE_VERSION && + isContractId(value.reducedEvidenceRef, 'sast-reduced-evidence') && + isContractId(value.accessDecisionId, 'sast-evidence-access') && + isSha256Digest(value.accessDecisionDigest) && + isContractId(value.evidencePackId, 'sast-evidence-pack') && + isSha256Digest(value.findingFingerprint) && + isSha256Digest(value.redactedProjectionDigest) && + Number.isSafeInteger(value.fragmentCount) && + Number(value.fragmentCount) >= 1 && + Number(value.fragmentCount) <= + SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentCount && + isCanonicalTimestamp(value.payloadExpiresAt) && + value.aiPayloadCreated === false && + value.aiProviderCalled === false && + value.retrievalAllowed === false && + value.toolsAllowed === false && + value.advisoryOnly === true + ); +} + export function isSastEvidenceDeletionScheduleShapeValid( value: unknown, digestCanonical?: SastEvidenceAccessCanonicalDigester diff --git a/packages/shared/test/sast-evidence-access.test.mjs b/packages/shared/test/sast-evidence-access.test.mjs index c63fa91..ba5ac61 100644 --- a/packages/shared/test/sast-evidence-access.test.mjs +++ b/packages/shared/test/sast-evidence-access.test.mjs @@ -4,10 +4,13 @@ import test from 'node:test'; import { SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS, + buildSastAiAdvisoryHandoff, buildSastEvidenceAccessDecision, buildSastEvidenceDeletionProof, buildSastEvidenceDeletionSchedule, isSafeNormalizedPath, + isSastAiAdvisoryHandoffShapeValid, + isSastAiAdvisoryIntentShapeValid, isSastEvidenceAccessDecisionShapeValid, isSastEvidenceDeletionProofShapeValid, isSastEvidenceDeletionScheduleShapeValid, @@ -197,6 +200,137 @@ test('dashboard path classification rejects traversal and repository metadata', assert.equal(isSafeNormalizedPath('C:\\repo\\secret.env'), false); }); +test('T043 handoff binds normalized findings to an opaque reduced reference', () => { + const decision = accessDecision( + deletionSchedule(), + 'AI_ADVISORY' + ); + const reference = reducedReference(decision); + const normalizedFinding = advisoryFinding(decision); + const handoff = buildSastAiAdvisoryHandoff({ + decision, + reducedEvidenceReference: reference, + normalizedFinding, + modelVersion: 'detector-planner-runtime-v1', + createdAt: '2026-08-10T05:00:01.000Z', + digestCanonical: digest + }); + + assert.ok(handoff); + assert.equal( + isSastAiAdvisoryHandoffShapeValid(handoff, digest), + true + ); + assert.equal(handoff.createdAt, decision.decidedAt); + assert.deepEqual(handoff.authority, { + normalizedFindingAllowed: true, + reducedEvidenceReferenceAllowed: true, + aiPayloadAllowed: true, + aiProviderCallAllowed: true, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true + }); + assert.equal(handoff.audit.requestPayloadStored, false); + assert.equal(handoff.audit.rawSourceStored, false); + assert.equal(handoff.audit.evidenceFragmentStored, false); +}); + +test('T043 retries are deterministic and reject caller fields or authority widening', () => { + const decision = accessDecision( + deletionSchedule(), + 'AI_ADVISORY' + ); + const input = { + decision, + reducedEvidenceReference: reducedReference(decision), + normalizedFinding: advisoryFinding(decision), + modelVersion: 'detector-planner-runtime-v1', + digestCanonical: digest + }; + const first = buildSastAiAdvisoryHandoff({ + ...input, + createdAt: '2026-08-10T05:00:01.000Z' + }); + const retry = buildSastAiAdvisoryHandoff({ + ...input, + createdAt: '2026-08-10T05:00:02.000Z' + }); + + assert.ok(first); + assert.deepEqual(retry, first); + assert.equal( + isSastAiAdvisoryHandoffShapeValid({ + ...first, + authority: { ...first.authority, toolsAllowed: true } + }, digest), + false + ); + assert.equal( + isSastAiAdvisoryHandoffShapeValid({ + ...first, + requestDigest: digest('tampered-request') + }, digest), + false + ); + assert.equal( + isSastAiAdvisoryHandoffShapeValid({ + ...first, + handoffDigest: digest('tampered-handoff') + }, digest), + false + ); + assert.equal(buildSastAiAdvisoryHandoff({ + ...input, + createdAt: decision.aiPayloadExpiresAt + }), null); + assert.equal(buildSastAiAdvisoryHandoff({ + ...input, + normalizedFinding: { + ...input.normalizedFinding, + title: 'Unsafe\ndeserialization' + }, + createdAt: '2026-08-10T05:00:01.000Z' + }), null); + + const withUndefinedLocation = buildSastAiAdvisoryHandoff({ + ...input, + normalizedFinding: { + ...input.normalizedFinding, + location: { + ...input.normalizedFinding.location, + lineEnd: undefined + } + }, + createdAt: '2026-08-10T05:00:01.000Z' + }); + assert.ok(withUndefinedLocation); + const roundTripped = JSON.parse(JSON.stringify(withUndefinedLocation)); + assert.equal( + isSastAiAdvisoryHandoffShapeValid(roundTripped, digest), + true + ); + assert.equal(roundTripped.requestDigest, withUndefinedLocation.requestDigest); + assert.equal(roundTripped.handoffDigest, withUndefinedLocation.handoffDigest); + assert.equal(isSastAiAdvisoryIntentShapeValid({ + tenantId: decision.scope.tenantId, + repositoryBindingId: decision.scope.repositoryBindingId, + evidencePackId: decision.scope.evidencePackId, + modelVersion: 'detector-planner-runtime-v1' + }), true); + assert.equal(isSastAiAdvisoryIntentShapeValid({ + tenantId: decision.scope.tenantId, + repositoryBindingId: decision.scope.repositoryBindingId, + evidencePackId: decision.scope.evidencePackId, + modelVersion: 'detector-planner-runtime-v1', + normalizedFinding: { title: 'caller supplied' } + }), false); +}); + function accessDecision(schedule, purpose) { return buildSastEvidenceAccessDecision({ purpose, @@ -215,6 +349,54 @@ function accessDecision(schedule, purpose) { }); } +function reducedReference(decision) { + return { + version: 'sast-reduced-evidence-reference-v1', + reducedEvidenceRef: decision.reducedEvidenceRef, + accessDecisionId: decision.accessDecisionId, + accessDecisionDigest: decision.decisionDigest, + evidencePackId: decision.scope.evidencePackId, + findingFingerprint: decision.scope.findingFingerprint, + redactedProjectionDigest: decision.redactedProjectionDigest, + fragmentCount: decision.redactedFragmentCount, + payloadExpiresAt: decision.aiPayloadExpiresAt, + aiPayloadCreated: false, + aiProviderCalled: false, + retrievalAllowed: false, + toolsAllowed: false, + advisoryOnly: true + }; +} + +function advisoryFinding(decision) { + return { + normalizedFindingId: 'normalized-finding-1', + occurrenceId: decision.scope.occurrenceId, + tenantId: decision.scope.tenantId, + repositoryBindingId: decision.scope.repositoryBindingId, + scanRequestId: decision.scope.scanRequestId, + attemptId: decision.scope.attemptId, + scannerRunId: 'scanner-run-1', + findingFingerprint: decision.scope.findingFingerprint, + capability: 'SAST', + title: 'Unsafe deserialization', + severity: 'HIGH', + confidence: 'HIGH', + cweIds: ['CWE-502', 'CWE-79'], + cveIds: ['CVE-2025-0001', 'CVE-2026-0002'], + location: { + kind: 'FILE', + normalizedPath: 'src/App.java', + lineStart: 42, + lineEnd: 42 + }, + scanner: 'OPENGREP', + ruleSemanticId: 'java.unsafe-deserialization', + ruleRevision: '1.0.0', + secretRedactionApplied: true + }; +} + function deletionSchedule() { return buildSastEvidenceDeletionSchedule({ scope: { diff --git a/specs/003-production-ai-inference-runtime/contracts/ai-inference-runtime.md b/specs/003-production-ai-inference-runtime/contracts/ai-inference-runtime.md index 359c219..d5633f2 100644 --- a/specs/003-production-ai-inference-runtime/contracts/ai-inference-runtime.md +++ b/specs/003-production-ai-inference-runtime/contracts/ai-inference-runtime.md @@ -26,6 +26,7 @@ export interface AiInferenceRequest { scanRequestId: string; canonicalScanKey: string; requestId: string; + modelVersion: string; reducedEvidence: ReducedEvidence; requestedCapabilities: Array<'detector' | 'planner'>; runtimePolicy: { diff --git a/specs/003-production-ai-inference-runtime/data-model.md b/specs/003-production-ai-inference-runtime/data-model.md index 8525c4a..cb76dcd 100644 --- a/specs/003-production-ai-inference-runtime/data-model.md +++ b/specs/003-production-ai-inference-runtime/data-model.md @@ -8,6 +8,7 @@ - `scanRequestId` - `canonicalScanKey` - `requestId` +- `modelVersion` - `reducedEvidence` - `requestedCapabilities` - `runtimePolicy` diff --git a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md index 1599f21..8d80978 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1209,7 +1209,7 @@ content enters the access ledger, logs, audit, or error response. An allowed dashboard decision returns only a second-pass-redacted projection after session tenant and repository binding authorization. A denied or cross-scope request uses a generic not-found response. An allowed AI decision returns only a -`sast-evidence-reduced://` reference whose eligibility expires in at most 24 hours and +`sast-reduced-evidence://` reference whose eligibility expires in at most 24 hours and never later than pack expiry. T042 performs no AI provider request, stores no request payload, and grants no retrieval, tools, policy, publication, lifecycle, or SCM action. T041 safe flags and null classification/deletion fields remain unchanged. @@ -1242,8 +1242,49 @@ lost. An exceptional authorized hard purge must revoke access, finish provider d live content, retain/export the required external audit record, delete the proof ledger first, and only then remove the tenant or another cascading parent. -AI receives finding metadata and reduced evidence references only after a second redaction -pass. AI never receives the result-ingress artifact reference. +### Advisory AI handoff gate v1 + +`sast-ai-advisory-handoff-v1` accepts only an exact intent containing `tenantId`, +`repositoryBindingId`, `evidencePackId`, and `modelVersion`. It obtains a T042 `AI_ADVISORY` +decision, reloads that ledger and the exact T037 occurrence, secret-redacted source finding, +and normalized-finding row, then obtains the same access decision again. Any missing, changed, +cross-scope, expired, or non-monotonic state produces one generic unavailable result. + +The canonical request contains the normalized finding projection and the opaque +`sast-reduced-evidence://` reference only. Its creation time is the immutable T042 +decision time, so exact retries derive identical `sast-ai-request`, `sast-ai-handoff`, and +`sast-ai-advisory` IDs. Serializable persistence permits only exact replay. The immutable ledger +stores relationship IDs, digests, model version, expiry, and explicit booleans; it stores no +handoff/request JSON, title/path, prompt, source, secret, evidence fragment, redacted content, +or provider payload. + +The internal AI request contains the handoff-bound model version, normalized metadata, one +opaque reference, and `snippets=[]`. The model version is part of the canonical runtime key, +selects the gateway configuration, and must equal the returned model metadata. The request +carries the canonical T035/T037 `cweIds` and `cveIds` in strict ascending, duplicate-free +order without runtime normalization; malformed or reordered identifier sets are rejected at +the shared handoff boundary rather than silently repaired. The request +carries no result-ingress artifact reference and grants no retrieval, tools, policy, +publication, lifecycle mutation, or SCM write authority. The runtime rejects unknown keys, +legacy caller-supplied finding/evidence shapes, content-bearing snippets, model or correlation +drift, expired references, and authority widening before provider execution. T043 records +advisory output only; T044 separately proves that output cannot acquire authoritative finding +or policy effects. + +The API consumer treats provider output as hostile input. It accepts at most 32 detector and +32 planner advisories, at most 32 bounded signals per detector advisory, 2,048 UTF-8 bytes per +rationale/action/signal/fallback reason, 128 UTF-8 bytes per provider/model identifier, 30,000 +milliseconds of reported latency, and a forbidden-key scan bounded to depth 12 and 64 entries +per collection. Oversized, excessively nested, cross-request, model-drifted, authority-bearing, +or sensitive output is rejected before persistence. Client-visible runtime rejection bodies +use stable error and reason codes and never echo provider or parser exception messages. + +Normal tenant or repository offboarding soft-revokes access while retaining the immutable, +digest-only handoff and advisory audit chain under the tenant tombstone. An exceptional hard +purge requires an authorized, externally audited database-maintenance procedure: revoke access, +export the required audit record, remove `AiAdvisoryMetadata` children, bypass the immutable +delete fence only for the identified handoff rows, and then remove parent scope. The restrictive +foreign keys intentionally prevent an ordinary cascade from erasing this ledger. ## Cleanup Contract diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index 744c65b..b31f4c6 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -785,11 +785,35 @@ decisions cannot be inferred from a successful scan or accepted T041 pack. codes, counts, second-pass redaction reference, projection digest, and decision digest/time - dashboard-safe content is returned transiently after the persisted decision and second clock check; it is never stored in the decision, logs, or audit -- AI decisions contain only a `sast-evidence-reduced://` reference and an expiry no +- AI decisions contain only a `sast-reduced-evidence://` reference and an expiry no later than 24 hours or the pack expiry, whichever comes first; no AI payload is persisted - all policy, publication, lifecycle, SCM, provider-call, retrieval, and tool authority remains false; T041 `dashboardSafe`, `aiSafe`, and null reference fields are never updated +### SastAiAdvisoryHandoff + +- deterministic `sast-ai-handoff://`, `sast-ai-request://`, and + `sast-ai-advisory://` identities bind one model version to the exact T042 AI access + decision, T037 occurrence, durable normalized finding, and reduced-evidence reference +- tenant, repository, scan, attempt, occurrence, normalized-finding, scanner-run, evidence-pack, + fingerprint, access-decision, request, and handoff digests are enforced by composite foreign + keys and exact-replay-only serializable persistence +- `payloadExpiresAt` is inherited from T042; the canonical creation timestamp is the immutable + access-decision timestamp so later valid retries reproduce byte-identical identities +- the ledger stores no JSON handoff/request body, title, path, prompt, raw source, secret value, + evidence fragment, or redacted content. It retains only relationship references, digests, + model version, expiry, and explicit audit/authority booleans +- normalized-finding, reduced-reference, payload, and provider-call handoff authority are true; + retrieval, tools, policy, publication, lifecycle mutation, and SCM write authority are false. + Caller finding/evidence/prompt acceptance and every content-storage audit bit are false +- `AiAdvisoryMetadata.sastHandoffId` is nullable only for legacy rows and unique for T043 output; + new results must rebind to the exact handoff and request digest. Parent deletion is restricted + so the immutable audit chain cannot be silently cascaded away +- normal tenant/repository offboarding retains this digest-only chain beneath a soft-revoked + tenant tombstone. Exceptional hard purge is an authorized, externally audited maintenance + flow that deletes advisory metadata before temporarily bypassing the immutable handoff fence; + ordinary application roles cannot perform that operation + ### SastEvidenceDeletionSchedule - deterministic `sast-evidence-deletion://` schedule and diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index d457421..695ba85 100644 --- a/specs/006-production-sast-runtime-design/plan.md +++ b/specs/006-production-sast-runtime-design/plan.md @@ -15,7 +15,7 @@ Issue #276 is an explicitly reclassified adjacent bootstrap, not a new productio Its `ontology/` Neo4j and MITRE CWE work remains local dev/demo data tooling with no Scan, AI, policy, finding, evidence, publication, SCM, tenant, or deployment authority. Work on that bootstrap did not advance or satisfy T040; the formal 006 sequence has since completed -T040, T041, and T042 independently and now proceeds to T043. +T040, T041, T042, and T043 independently and now proceeds to T044. ## Target Boundaries @@ -137,7 +137,13 @@ at-most-24-hour eligibility window. The T041 pack flags remain unchanged. Expiry leased and fenced; a verified provider receipt is required before content deletion and an immutable proof, while the T041 build decision remains retained. The default secret registry and deletion provider authorities fail closed. Only `SastEvidenceAccessService` crosses the -module boundary; T043 advisory AI consumption is the next gate. +Scan Plane module boundary. T043 now accepts only tenant/repository/evidence-pack/model intent, +classifies access before and after rebinding the exact T037 occurrence and normalized finding, +and derives deterministic `sast-ai-advisory-handoff-v1` identities. Its immutable ledger stores +only scope references, digests, expiry, and fixed authority/audit bits; request payload, source, +secret values, and evidence fragments are absent. The AI runtime receives normalized metadata +plus one opaque reduced-evidence reference, an empty snippets array, and zero retrieval, tool, +policy, publication, lifecycle, or SCM authority. T044 output-authority proof is the next gate. ### Slice 6 - Coverage, Failure, Policy, and Evidence @@ -169,6 +175,7 @@ gates. Produce a machine-readable go/no-go record. Hand live cluster/microVM rol - `NormalizedSastFinding`, provenance, occurrence, and correlation - `ScannerCoverageRecord` and `SastCoverageDecision` - `SastEvidencePolicy` and evidence pack reference +- `SastAiAdvisoryIntent`, `SastAiAdvisoryHandoff`, and reference-only advisory ledger - `RuleBundleDescriptor`, promotion evidence, tenant policy, and kill switch - `SastFailureDecision` and sandbox destruction evidence - `SastQualityMeasurements` and immutable go/no-go record diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index 495b217..f5b6a0c 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -318,6 +318,18 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl recovered after finalization failure create zero false proofs, duplicate rows, overdue content, or restored content. A context-drifted claim is fenced and quarantined after three validations and cannot starve a later due schedule. +- 100% T043 source-binding invariant: every advisory handoff is derived from two matching T042 + AI classifications around one exact durable T037 occurrence/source/normalized-finding rebind. + Caller finding, evidence, prompt, path, digest, expiry, unknown field, cross-scope identifier, + changed decision, clock rollback, or late access creates zero handoffs and provider calls. +- 100% T043 reference-only invariant: the immutable ledger stores only scope relationships, + digests, model version, expiry, and fixed authority/audit bits. Stored request/handoff JSON, + source, secret, evidence fragment, prompt, retrieval payload, and redacted content equal zero. + Exact valid retries reproduce one request, handoff, advisory, and result row. +- 100% T043 AI-boundary invariant: runtime requests contain one normalized metadata projection, + one opaque reduced-evidence reference, and zero snippets. Retrieval, tools, policy, + publication, lifecycle mutation, and SCM write authority are false in every request; legacy + direct finding/evidence requests, correlation drift, expiry, and authority widening are denied. ## Canary and Continuous Production Gates diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index adb3e2b..5f13431 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -507,6 +507,17 @@ portion of Phase 6: audit/proof ledgers remain durable. An original receipt from an exact deterministic retry succeeds when it remains deadline/observation/lease bounded; a changed receipt cannot mutate the result. +- T043 accepts only exact advisory intent (`tenantId`, `repositoryBindingId`, `evidencePackId`, + and `modelVersion`). It classifies T042 AI access twice around a durable T037 occurrence, + source-finding, and normalized-finding rebind; any scope, digest, access, clock, or expiry drift + returns the same generic unavailable result. +- `sast-ai-advisory-handoff-v1` derives deterministic request, handoff, and advisory identities + from the immutable T042 decision timestamp. Its ledger stores only scope references, digests, + expiry, model version, and fixed audit/authority bits. It stores no request/handoff JSON, + title/path, source, secret, fragment, prompt, or provider payload. +- The internal AI runtime receives one normalized metadata projection and one opaque reduced + reference with `snippets=[]`. Retrieval, tools, policy, publication, lifecycle mutation, and + SCM write authority remain false; the legacy caller-supplied finding/evidence route is denied. This checkpoint proves the provider-facing execution contract but does not claim that the provider microVM platform is live. The non-production opaque credential issuer and test @@ -519,8 +530,9 @@ T038 authority-aware cross-tool correlation, T039 fail-closed scanner/capability T040 stale-scan denial and bounded infrastructure-only retry, T041 bounded accepted-finding evidence with reconstruction-risk checks, and T042 purpose-bound dashboard/AI classification, second-pass secret redaction, seven-day expiry enforcement, and deletion proof are complete; -T043 normalized-finding and reduced-evidence-reference delivery to the advisory AI Plane is -therefore the next implementation task. +T043 normalized-finding plus reduced-reference advisory handoff is also complete; T044 is the +next implementation task and proves AI cannot create, suppress, waive, resolve, or override +finding/policy authority. Live deployment eligibility still requires the 005 rollout and the remaining 006 gates. diff --git a/specs/006-production-sast-runtime-design/research.md b/specs/006-production-sast-runtime-design/research.md index 2da6b3c..a500757 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -537,3 +537,29 @@ returning fragments before the second clock check, deriving AI eligibility from access, retaining an AI payload for T043, treating a deletion request as deletion proof, deleting before receipt validation, allowing an unfenced worker to finalize, or erasing the T041 decision with the content. + +## Decision 25: Derive an Expiring Reference-Only Advisory Handoff from Durable State + +**Decision**: T043 accepts only tenant, repository-binding, evidence-pack, and model-version +intent. It calls the T042 AI classifier, reloads and validates the access ledger plus the exact +T037 occurrence/source and normalized-finding row, then calls the classifier again before +deriving `sast-ai-advisory-handoff-v1`. The access decision timestamp, rather than invocation +time, is canonical so every still-valid exact retry derives the same request, handoff, and +advisory identities. + +The persistence ledger stores relationship references, digests, model version, expiry, and +fixed booleans only. The handoff/request JSON, title, path, source, secret values, fragments, +prompt, and provider request are not persisted there. The internal AI runtime receives the +normalized metadata and opaque reduced reference with `snippets=[]`; retrieval, tools, policy, +publication, lifecycle mutation, and SCM write authority are fixed false. Legacy direct +finding/evidence requests and unknown fields are rejected. + +**Rationale**: A caller-safe reduced reference still does not prove which durable finding is +being described, and a valid decision can expire or drift while the occurrence is loaded. +Double classification plus exact durable rebinding closes that race. Reference-only persistence +keeps replay auditable without retaining a second copy of sensitive or expiring model input. + +**Rejected**: Trusting caller-normalized findings, forwarding snippets or redacted fragment +content, persisting a full handoff JSON, deriving retry identity from wall-clock invocation +time, accepting dashboard-purpose authority, enabling model retrieval/tools, or treating an AI +response as finding, policy, publication, lifecycle, or SCM authority. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index fdca245..5cffb03 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -374,6 +374,20 @@ incomplete, stale, quarantined, or security-blocked scan. MUST NOT exceed 24 hours. - **FR-051**: AI eligibility requires complete non-stale coverage, an approved profile, reduced evidence, and tenant/repository opt-in. +- **FR-051a**: T043 MUST accept only tenant, repository binding, evidence-pack, and model-version + intent. It MUST independently obtain T042 `AI_ADVISORY` access before and after reloading the + exact T037 occurrence, source finding, and normalized-finding row. Caller-provided findings, + evidence, prompts, paths, digests, expiry, or authority MUST be rejected before access. +- **FR-051b**: The canonical `sast-ai-advisory-handoff-v1` MUST contain only the rebound + normalized finding and the T042 opaque reduced-evidence reference. Its immutable database + ledger MUST store only scope references, digests, expiry, model version, and fixed audit/ + authority booleans; it MUST NOT persist the handoff/request body, raw source, secret values, + access-time fragments, or prompt text. +- **FR-051c**: The AI runtime request MUST carry no snippets or retrievable content and MUST set + retrieval, tools, policy, publication, lifecycle mutation, and SCM write authority false. + Tenant/scan/finding/request correlation and payload expiry MUST be validated, and an exact + retry MUST derive the same request, handoff, and advisory identities. Drift, expiry, clock + rollback, unknown fields, or authority widening MUST fail closed. - **FR-052**: AI output MUST remain advisory and MUST NOT create, suppress, waive, resolve, re-severity, or block a deterministic finding. diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 858f051..8e06c99 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -71,7 +71,7 @@ - [x] T041 Build bounded accepted-finding evidence with reconstruction-risk checks - [x] T042 Enforce dashboard/AI classification, secret redaction, seven-day expiry, and deletion proof -- [ ] T043 Send only normalized findings and reduced evidence references to the advisory AI Plane +- [x] T043 Send only normalized findings and reduced evidence references to the advisory AI Plane - [ ] T044 Prove AI cannot create, suppress, waive, resolve, or override authoritative findings/policy ## Phase 9: Rule Governance Runtime diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index 7abfff8..356a9f5 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -87,6 +87,7 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Evidence-purpose confusion | Dashboard consent or one stale decision is reused to construct an AI payload | Separate immutable dashboard/AI decisions, complete T041 chain rebind, access-time redaction/classification, and explicit zero provider/tool authority | Purpose swap, opt-in, registry drift, unsafe identifier, cross-tenant, and replay fixtures deny | | Evidence expiry race | A reader returns content while expiry/deletion is claimed or after the final clock check | Check retention before read and after the final awaited confirmation, confirm unchanged schedule/claim/proof, and deny from claim onward | Expiry-before/during/final-confirmation read, late-reader, deletion-race, and clock-rollback fixtures return no content | | False deletion proof or overdue content | A worker marks evidence deleted without provider removal, rejects the original receipt after a finalization retry, or lets polling/batch caps or one corrupt claim create a retention backlog | Deterministic operation, leased owner/token fence, default-unavailable provider, deadline-aware startup/earliest-due scheduling, saturated zero-delay continuation, context-drift quarantine, exact receipt replay, delete-then immutable proof | Unavailable provider, changed/original receipt, stale token, concurrent claim/finalize, drifted-head queue, deadline wakeup, and exact replay corpus | +| AI handoff forgery or payload smuggling | A caller supplies a finding, prompt, fragment, stale access reference, or authority bit and causes it to reach the model | Exact four-field intent, double T042 classification, durable T037 source rebind, canonical expiring handoff, empty snippets, exact runtime keys, and fixed zero downstream authority | Legacy/extra-field, cross-scope, drift, expiry, correlation, snippet, secret-key, and authority-widening fixtures deny before provider use | | AI prompt injection | Evidence text instructs model | Evidence is untrusted data, bounded/redacted, no retrieval/tools/SCM | Advisory label and output schema validation | | Sandbox persistence | Compromise survives next scan | No worker/workspace reuse; new microVM per attempt | Destruction evidence and lag alert | | Operator credential leak | Deployment secrets enter repo/config | 005 reference-only credential handoff | Secret scanning and deployment audit | @@ -158,6 +159,9 @@ The following must always remain true: 18. Dashboard and AI evidence access are separately classified after access-time redaction and durable rebinding. Expiry or a deletion claim revokes both; content is deleted only after a fenced provider receipt and the retained canonical proof cannot restore access. +19. Advisory AI input is derived only from the exact T042/T037 durable chain. Its ledger is + reference-only, its runtime request contains no snippets or retrievable content, and it + grants no policy, publication, lifecycle, finding, tool, retrieval, or SCM authority. ## Required Security Test Corpus @@ -178,6 +182,13 @@ The following must always remain true: identifier, cross-tenant/repository, before/during-read expiry, late-reader, concurrent classification/deletion, unavailable-provider, stale-fence, changed-receipt, exact-replay, and clock-rollback fixtures +- T043 caller-supplied finding/evidence/prompt and unknown-field rejection; T042 decision or + reduced-reference drift; cross-tenant/repository/scan/occurrence/fingerprint rebinding; + expired and non-monotonic clocks; changed model version; exact retry; snippets/content; + forbidden secret keys; request/tenant/scan/finding correlation drift; retrieval, tool, + policy, publication, lifecycle, and SCM authority widening; duplicate/reordered CWE/CVE sets; + oversized advisory/signal/text output; excessive response depth/breadth; latency overflow; + provider-error reflection; and unauthorized immutable-ledger purge - CycloneDX schema/tool/version/source-component rebinding, metadata-tool component count smuggling, vulnerability/VEX and nested/file component extensions, duplicate or mismatched PURL/BOM references, invalid CPE part/field/quoting/wildcard/language forms, diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 8d1a959..4244810 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -47,6 +47,7 @@ const files = { sharedSastAcceptedEvidenceTest: new URL('../../packages/shared/test/sast-accepted-evidence.test.mjs', import.meta.url), sharedSastEvidenceAccess: new URL('../../packages/shared/src/types/sast-evidence-access.ts', import.meta.url), sharedSastEvidenceAccessTest: new URL('../../packages/shared/test/sast-evidence-access.test.mjs', import.meta.url), + sharedSastAiAdvisoryHandoff: new URL('../../packages/shared/src/types/sast-ai-advisory-handoff.ts', import.meta.url), apiSastPlanner: new URL('../../apps/api/src/control-plane/sast-scan-planner.service.ts', import.meta.url), apiSastQueueAdmission: new URL('../../apps/api/src/control-plane/sast-queue-admission.service.ts', import.meta.url), apiSastPlanningController: new URL('../../apps/api/src/control-plane/sast-planning.controller.ts', import.meta.url), @@ -100,6 +101,15 @@ const files = { apiDashboardEvidenceController: new URL('../../apps/api/src/dashboard/dashboard-evidence.controller.ts', import.meta.url), apiSastEvidenceAccessTest: new URL('../../apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts', import.meta.url), apiSastEvidenceAccessPersistenceTest: new URL('../../apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts', import.meta.url), + apiAiAdvisoryService: new URL('../../apps/api/src/ai-plane/ai-advisory.service.ts', import.meta.url), + apiAiAdvisoryStore: new URL('../../apps/api/src/ai-plane/prisma-sast-ai-advisory.store.ts', import.meta.url), + apiAiAdvisoryRuntime: new URL('../../apps/api/src/ai-plane/ai-advisory-runtime.client.ts', import.meta.url), + apiAiAdvisoryController: new URL('../../apps/api/src/ai-plane/ai-advisory.controller.ts', import.meta.url), + apiAiAdvisoryModule: new URL('../../apps/api/src/ai-plane/ai-plane.module.ts', import.meta.url), + apiAiAdvisoryServiceTest: new URL('../../apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts', import.meta.url), + apiAiAdvisoryPersistenceTest: new URL('../../apps/api/test/ai-plane/sast-ai-advisory-persistence.e2e-spec.ts', import.meta.url), + aiAdvisoryRuntime: new URL('../../apps/ai/src/advisory-runtime.ts', import.meta.url), + aiModelGateway: new URL('../../apps/ai/src/model-gateway.ts', import.meta.url), apiPrismaSchema: new URL('../../apps/api/prisma/schema.prisma', import.meta.url), apiOnlineSastRuntimeSchema: new URL('../../apps/api/scripts/apply-online-sast-runtime-schema.mjs', import.meta.url), apiSastFindingLineageMigration: new URL('../../apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql', import.meta.url), @@ -108,6 +118,7 @@ const files = { apiSastScanFreshnessMigration: new URL('../../apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql', import.meta.url), apiSastAcceptedEvidenceMigration: new URL('../../apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql', import.meta.url), apiSastEvidenceAccessMigration: new URL('../../apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql', import.meta.url), + apiSastAiAdvisoryMigration: new URL('../../apps/api/prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql', import.meta.url), apiScanPlaneModule: new URL('../../apps/api/src/scan-plane/scan-plane.module.ts', import.meta.url), completedDeploymentQuickstart: new URL('../../specs/005-production-deployment-operations/quickstart.md', import.meta.url), completedDeploymentTasks: new URL('../../specs/005-production-deployment-operations/tasks.md', import.meta.url), @@ -1409,7 +1420,7 @@ test('SAST T041 builds bounded accepted-finding evidence and rejects reconstruct assert.match(dataModel, /SastAcceptedEvidencePack/); assert.match( plan, - /T040, T041, and T042 independently and now proceeds to T043/ + /T040, T041, T042, and T043 independently and now proceeds to T044/ ); assert.match(spec, /FR-046a/); assert.match( @@ -1600,6 +1611,162 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(qualityGates, /100% T042 deletion-proof invariant/); }); +test('SAST T043 sends only a durable normalized finding and opaque AI reference', () => { + const shared = readNormalizedText( + files.sharedSastAiAdvisoryHandoff + ); + const sharedTest = readNormalizedText( + files.sharedSastEvidenceAccessTest + ); + const sharedIndex = readNormalizedText(files.sharedIndex); + const service = readNormalizedText(files.apiAiAdvisoryService); + const store = readNormalizedText(files.apiAiAdvisoryStore); + const runtime = readNormalizedText(files.apiAiAdvisoryRuntime); + const controller = readNormalizedText( + files.apiAiAdvisoryController + ); + const aiModule = readNormalizedText(files.apiAiAdvisoryModule); + const serviceTest = readNormalizedText( + files.apiAiAdvisoryServiceTest + ); + const persistenceTest = readNormalizedText( + files.apiAiAdvisoryPersistenceTest + ); + const aiRuntime = readNormalizedText(files.aiAdvisoryRuntime); + const modelGateway = readNormalizedText(files.aiModelGateway); + const schema = readNormalizedText(files.apiPrismaSchema); + const migration = readNormalizedText( + files.apiSastAiAdvisoryMigration + ); + const onlineSchema = readNormalizedText( + files.apiOnlineSastRuntimeSchema + ); + const tasks = readNormalizedText(files.tasks); + const quickstart = readNormalizedText(files.quickstart); + const contract = readNormalizedText(files.contract); + const dataModel = readNormalizedText(files.dataModel); + const plan = readNormalizedText(files.plan); + const spec = readNormalizedText(files.spec); + const research = readNormalizedText(files.research); + const threatModel = readNormalizedText(files.threatModel); + const qualityGates = readNormalizedText(files.qualityGates); + + assert.match(shared, /sast-ai-advisory-handoff-v1/); + assert.match(shared, /isSastAiAdvisoryIntentShapeValid/); + assert.match(shared, /createdAt: input\.decision\.decidedAt/); + assert.match(shared, /isSastReducedEvidenceReferenceShapeValid/); + assert.match(shared, /hasAsciiControl\(value\.title\)/); + assert.match(shared, /record\[key\] !== undefined/); + assert.match(sharedIndex, /sast-ai-advisory-handoff/); + assert.match( + sharedTest, + /T043 retries are deterministic and reject caller fields or authority widening/ + ); + + assert.match(service, /isSastAiAdvisoryIntentShapeValid/); + assert.equal( + service.match(/this\.classify\(scope, clock\)/gu)?.length, + 2 + ); + assert.match(store, /isSastEvidenceAccessDecisionShapeValid/); + assert.match( + store, + /isSastSecretRedactedFindingCandidateShapeValid/ + ); + assert.match( + store, + /Prisma\.TransactionIsolationLevel\.Serializable/ + ); + assert.match(store, /id_tenantId/); + assert.match(store, /isSameInstant/); + assert.match(service, /safeErrorCategory/); + assert.doesNotMatch( + store, + /handoff:\s*handoff as unknown as Prisma\.InputJsonValue/u + ); + assert.match(runtime, /snippets: \[\]/); + assert.match(runtime, /modelVersion: handoff\.modelVersion/); + assert.match(runtime, /candidate\.modelMetadata\.version !== handoff\.modelVersion/); + assert.match(runtime, /MAX_RUNTIME_ADVISORIES/); + assert.match(runtime, /MAX_RUNTIME_SCAN_DEPTH/); + assert.match(runtime, /retrievalAllowed: false/); + assert.match(runtime, /toolsAllowed: false/); + assert.doesNotMatch(runtime, /redactedContent/); + assert.match(controller, /SastAiAdvisoryIntent/); + assert.match(aiModule, /ScanPlaneModule/); + assert.match( + serviceTest, + /never accepts caller payloads/ + ); + assert.match( + persistenceTest, + /immutable reference-only handoff ledger/ + ); + assert.match( + aiRuntime, + /requires a T043 reduced-reference handoff/ + ); + assert.match(aiRuntime, /version: body\.modelVersion/); + assert.match(modelGateway, /T043_METADATA_KEYS/); + assert.match(modelGateway, /request\.modelVersion !== config\.version/); + assert.match(modelGateway, /evidence\.snippets\.length === 0/); + + assert.match(schema, /model SastAiAdvisoryHandoff \{/); + assert.match(migration, /CREATE TABLE "SastAiAdvisoryHandoff"/); + assert.match( + onlineSchema, + /SastAiAdvisoryHandoff_access_scope_fkey/ + ); + assert.match( + onlineSchema, + /CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastEvidenceAccessDecision_ai_scope_key"/ + ); + assert.match( + onlineSchema, + /CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AiAdvisoryMetadata_sastHandoffId_key"/ + ); + assert.match( + onlineSchema, + /AiAdvisoryMetadata_sastHandoffId_fkey/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryHandoff_occurrence_scope_fkey/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryHandoff_finding_scope_fkey/ + ); + assert.doesNotMatch( + migration, + /SastEvidenceAccessDecision_ai_scope_key|SastAiAdvisoryHandoff_(?:occurrence|finding|access)_scope_fkey|AiAdvisoryMetadata_sastHandoffId_(?:key|fkey)/ + ); + assert.match( + migration, + /SastAiAdvisoryHandoff_immutable_update/ + ); + assert.doesNotMatch(migration, /"handoff" JSONB/); + + assert.match(tasks, /- \[x\] T043\b/); + assert.match( + quickstart, + /T043 normalized-finding plus reduced-reference advisory handoff is also complete;[\s\S]{0,80}T044 is the[\s\S]{0,80}next implementation task/ + ); + assert.match(contract, /Advisory AI handoff gate v1/); + assert.match(dataModel, /### SastAiAdvisoryHandoff/); + assert.match( + plan, + /T040, T041, T042, and T043 independently and now proceeds to T044/ + ); + assert.match(spec, /FR-051a/); + assert.match( + research, + /Decision 25: Derive an Expiring Reference-Only Advisory Handoff from Durable State/ + ); + assert.match(threatModel, /AI handoff forgery or payload smuggling/); + assert.match(qualityGates, /100% T043 reference-only invariant/); +}); + test('SAST design completion gate stays synchronized between quickstart and CI', () => { const readme = readNormalizedText(files.readme); const ci = readNormalizedText(files.ci); diff --git a/test/github-actions/ontology.test.mjs b/test/github-actions/ontology.test.mjs index 521bc37..ba1469b 100644 --- a/test/github-actions/ontology.test.mjs +++ b/test/github-actions/ontology.test.mjs @@ -68,7 +68,10 @@ test('ontology importer applies bounded hostile-input validation', () => { assert.match(loader, /m\.cwe = row\.cwe/); assert.match(loader, /--dry-run/); assert.match(readme, /dev\/demo data bootstrap only/); - assert.match(readme, /does not replace[\s\S]*006-production-sast-runtime-design/); + assert.match( + readme, + /does not replace[\s\S]{0,320}006-production-sast-runtime-design/ + ); }); test('active 006 spec explicitly reclassifies only the bounded issue 276 bootstrap', () => { @@ -78,11 +81,11 @@ test('active 006 spec explicitly reclassifies only the bounded issue 276 bootstr assert.match(spec, /Explicitly Reclassified Adjacent Bootstrap: Issue #276/); assert.match(spec, /MUST NOT receive Scan Plane, AI Plane, policy/); - assert.match(spec, /does not[\s\S]*advance or satisfy T040/); + assert.match(spec, /does not[\s\S]{0,240}advance or satisfy T040/); assert.match(plan, /Issue #276 is an explicitly reclassified adjacent bootstrap/); assert.match( plan, - /did not advance or satisfy T040[\s\S]*completed[\s\S]*T042[\s\S]*proceeds to T043/ + /did not advance or satisfy T040[\s\S]{0,240}completed[\s\S]{0,120}T043[\s\S]{0,120}proceeds to T044/ ); assert.match(tasks, /Approved Adjacent Bootstrap \(Does Not Advance 006\)/); assert.match(tasks, /Keep T040 as the next formal active-milestone task/);