diff --git a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts
index fd0d85906..3aeff3e4d 100644
--- a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts
+++ b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts
@@ -27,6 +27,8 @@ function payload(): SecurityAssetControlPlanePayload {
stale_asset_count: 0,
orphan_asset_count: 0,
relationship_count: 40,
+ package_digest_unpinned_count: 0,
+ forbidden_github_supply_chain_asset_count: 0,
automation_coverage_green_percent: 85,
automation_coverage_unknown_count: 0,
compliance_violation_count: 0,
@@ -99,6 +101,18 @@ function payload(): SecurityAssetControlPlanePayload {
accepted_ai_trace_count_24h: 2,
immutable_external_audit_store_evidenced: false,
},
+ supply_chain: {
+ package_asset_count: 12,
+ package_inventory_evidenced: true,
+ package_digest_pinned_count: 12,
+ package_digest_unpinned_count: 0,
+ forbidden_github_supply_chain_asset_count: 0,
+ github_freeze_compliant: true,
+ runtime_image_policy_compliant: true,
+ raw_image_identity_returned: false,
+ registry_api_called: false,
+ github_api_used: false,
+ },
source_health: [
{ source_id: "asset_inventory", status: "ready", reason_code: null },
],
@@ -144,6 +158,15 @@ describe("security asset control plane projection", () => {
boundaries: { ...safe.boundaries, raw_asset_identity_returned: true },
};
expect(isSecurityAssetControlPlanePayload(unsafe)).toBe(false);
+
+ const unsafeSupplyChain = {
+ ...safe,
+ supply_chain: {
+ ...safe.supply_chain,
+ raw_image_identity_returned: true,
+ },
+ };
+ expect(isSecurityAssetControlPlanePayload(unsafeSupplyChain)).toBe(false);
});
it("does not show healthy before same-run closure is proven", () => {
@@ -157,6 +180,20 @@ describe("security asset control plane projection", () => {
expect(securityControlPlaneTone(value)).toBe("critical");
});
+ it("keeps runtime image supply-chain gaps critical", () => {
+ const value = payload();
+ value.ai_automation.same_run_closed_loop_proven = true;
+ expect(securityControlPlaneTone(value)).toBe("healthy");
+
+ value.supply_chain!.package_digest_pinned_count = 0;
+ value.supply_chain!.package_digest_unpinned_count = 12;
+ value.supply_chain!.runtime_image_policy_compliant = false;
+ expect(securityControlPlaneTone(value)).toBe("critical");
+
+ value.supply_chain = undefined;
+ expect(securityControlPlaneTone(value)).toBe("critical");
+ });
+
it("keeps P0 work ahead of P1 regardless of API order", () => {
const items = topSecurityControlPlaneWorkItems(payload(), 2);
expect(items.map((item) => item.work_item_id)).toEqual([
diff --git a/apps/web/src/lib/security-asset-control-plane.ts b/apps/web/src/lib/security-asset-control-plane.ts
index 41ba011e1..3630688e7 100644
--- a/apps/web/src/lib/security-asset-control-plane.ts
+++ b/apps/web/src/lib/security-asset-control-plane.ts
@@ -14,6 +14,8 @@ export type SecurityControlPlaneSummary = {
stale_asset_count: number;
orphan_asset_count: number;
relationship_count: number;
+ package_digest_unpinned_count?: number;
+ forbidden_github_supply_chain_asset_count?: number;
automation_coverage_green_percent: number;
automation_coverage_unknown_count: number;
compliance_violation_count: number;
@@ -69,6 +71,19 @@ export type SecurityControlPlaneWorkItem = {
next_action: string;
};
+export type SecurityControlPlaneSupplyChain = {
+ package_asset_count: number;
+ package_inventory_evidenced: boolean;
+ package_digest_pinned_count: number;
+ package_digest_unpinned_count: number;
+ forbidden_github_supply_chain_asset_count: number;
+ github_freeze_compliant: boolean;
+ runtime_image_policy_compliant: boolean;
+ raw_image_identity_returned: false;
+ registry_api_called: false;
+ github_api_used: false;
+};
+
export type SecurityAssetControlPlanePayload = {
schema_version: "iwooos_security_asset_control_plane_v1";
generated_at: string;
@@ -140,6 +155,7 @@ export type SecurityAssetControlPlanePayload = {
accepted_ai_trace_count_24h: number;
immutable_external_audit_store_evidenced: boolean;
};
+ supply_chain?: SecurityControlPlaneSupplyChain;
source_health: Array<{
source_id: string;
status: "ready" | "unavailable";
@@ -178,6 +194,24 @@ export function isSecurityAssetControlPlanePayload(
if (!isRecord(value.ai_automation) || !Array.isArray(value.ai_automation.stages)) {
return false;
}
+ if (value.supply_chain !== undefined) {
+ if (!isRecord(value.supply_chain)) return false;
+ if (
+ typeof value.supply_chain.package_asset_count !== "number" ||
+ typeof value.supply_chain.package_inventory_evidenced !== "boolean" ||
+ typeof value.supply_chain.package_digest_pinned_count !== "number" ||
+ typeof value.supply_chain.package_digest_unpinned_count !== "number" ||
+ typeof value.supply_chain.forbidden_github_supply_chain_asset_count !==
+ "number" ||
+ typeof value.supply_chain.github_freeze_compliant !== "boolean" ||
+ typeof value.supply_chain.runtime_image_policy_compliant !== "boolean" ||
+ value.supply_chain.raw_image_identity_returned !== false ||
+ value.supply_chain.registry_api_called !== false ||
+ value.supply_chain.github_api_used !== false
+ ) {
+ return false;
+ }
+ }
return (
typeof value.summary.managed_asset_count === "number" &&
typeof value.summary.source_count === "number" &&
@@ -202,6 +236,15 @@ export function securityControlPlaneTone(
payload: SecurityAssetControlPlanePayload
): SecurityControlPlaneTone {
if (payload.status !== "ready" || !payload.discovery.fresh) return "critical";
+ if (
+ !payload.supply_chain ||
+ !payload.supply_chain.package_inventory_evidenced ||
+ payload.supply_chain.package_digest_unpinned_count > 0 ||
+ payload.supply_chain.forbidden_github_supply_chain_asset_count > 0 ||
+ !payload.supply_chain.runtime_image_policy_compliant
+ ) {
+ return "critical";
+ }
if (
payload.summary.compliance_violation_count > 0 ||
payload.summary.nist_controlled_percent < 50 ||