agentcert 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,7 +64,9 @@ case by declaring the exact reviewed scope:
64
64
  npx agentcert run --config agentcert.config.json --push \
65
65
  --assurance-case "$AGENTCERT_ASSURANCE_CASE_ID" \
66
66
  --assurance-scope agentcert.assurance-scope.json \
67
- --assurance-trigger auto
67
+ --assurance-trigger auto \
68
+ --require-current auto \
69
+ --continuous-health-out .agentcert/canary/generated-kit-health.json
68
70
  ```
69
71
 
70
72
  Validate the scope before CI uses it:
@@ -79,6 +81,10 @@ npx agentcert schema validate \
79
81
  and other GitHub runs as release checks. Authoritative failure or scope drift
80
82
  sets the Hosted contract to `REVALIDATION_REQUIRED`; only an independently
81
83
  issued successor case establishes a new `CURRENT` baseline.
84
+ Release and nightly checks fail unless Hosted returns `CURRENT`. The optional
85
+ health output is redacted and captures the Hosted run/evidence identifiers,
86
+ evidence completeness, freshness transition, and install-to-CURRENT timing for
87
+ external canaries and operational dashboards.
82
88
 
83
89
  Add `--push` to `agentcert run` to run locally and upload the resulting bundle
84
90
  in one command. By default, both commands also upload local files referenced by
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { collectCompanionArtifacts, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS } from
9
9
  import { recordsFromAgentCertResult, evaluateFailureClassifier, renderCorpusSummary, summarizeCorpus, writeReviewedFailureDataset, } from "./corpus.js";
10
10
  import { openCorpusStore, parseCorpusStoreKind } from "./corpus-store.js";
11
11
  import { deleteCorpusRecords, exportGovernedCorpus, governCorpusRecords, parseCorpusConsent } from "./corpus-governance.js";
12
- import { pushEvidenceToControlPlane, verifyControlPlaneConnection } from "./control-plane.js";
12
+ import { pushEvidenceToControlPlane, requireContinuousAssuranceCurrent, verifyControlPlaneConnection } from "./control-plane.js";
13
13
  import { runEvidenceConformance } from "./conformance.js";
14
14
  import { DEFAULT_AGENTCERT_SERVER, resolveConnection, saveConnection } from "./credentials.js";
15
15
  import { applyFailureReviews, appendFailureReview, createFailureReview, findFailurePattern, parseFailureReviewStatus, parseFailureType, parseReviewConfidence, readFailureReviews, } from "./failure-review.js";
@@ -601,6 +601,11 @@ async function pushHostedEvidence(bundle, bytes, fileName) {
601
601
  const companions = readBoolFlag("--no-artifacts")
602
602
  ? undefined
603
603
  : await collectCompanionArtifacts(bundle, resolve(readFlag("--artifact-root") ?? process.cwd()));
604
+ const assurance = await readContinuousAssuranceBinding();
605
+ const healthOut = readFlag("--continuous-health-out");
606
+ const requireCurrent = readBoolFlag("--require-current");
607
+ if (requireCurrent && !assurance)
608
+ throw new Error("--require-current needs --assurance-case and --assurance-scope.");
604
609
  const result = await pushEvidenceToControlPlane({
605
610
  baseUrl: connection.server,
606
611
  projectId: connection.projectId,
@@ -611,7 +616,8 @@ async function pushHostedEvidence(bundle, bytes, fileName) {
611
616
  externalId: readFlag("--external-id"),
612
617
  companionArtifacts: companions?.artifacts,
613
618
  skippedCompanionArtifacts: companions?.skipped,
614
- assurance: await readContinuousAssuranceBinding(),
619
+ assurance,
620
+ verifyContinuousAssurance: Boolean(assurance && (healthOut || requireCurrent)),
615
621
  });
616
622
  process.stdout.write(`Hosted run: ${result.runId}\nHosted evidence: ${result.evidenceId}\n`);
617
623
  if (companions) {
@@ -623,6 +629,17 @@ async function pushHostedEvidence(bundle, bytes, fileName) {
623
629
  process.stderr.write(`${companions.skipped.length - MAX_REPORTED_COMPANION_ARTIFACT_SKIPS} additional skipped artifacts omitted.\n`);
624
630
  }
625
631
  }
632
+ if (result.continuousAssuranceHealth) {
633
+ process.stdout.write(`Hosted assurance: ${result.continuousAssuranceHealth.status} (${result.continuousAssuranceHealth.healthy ? "healthy" : "attention required"})\n`);
634
+ if (healthOut) {
635
+ const outputPath = resolve(healthOut);
636
+ await mkdir(dirname(outputPath), { recursive: true });
637
+ await writeFile(outputPath, `${JSON.stringify(result.continuousAssuranceHealth, null, 2)}\n`, "utf8");
638
+ process.stdout.write(`Continuous assurance health: ${outputPath}\n`);
639
+ }
640
+ if (requireCurrent)
641
+ requireContinuousAssuranceCurrent(result.continuousAssuranceHealth);
642
+ }
626
643
  }
627
644
  async function readContinuousAssuranceBinding() {
628
645
  const caseId = readFlag("--assurance-case");
@@ -37,6 +37,8 @@ Options:
37
37
  --assurance-case <id> Issued assurance case to reconcile
38
38
  --assurance-scope <p> Declared agent/model/prompt/tools/policy/suite scope JSON
39
39
  --assurance-trigger <t> auto, pull_request, release, or nightly (default: auto)
40
+ --require-current Fail unless Hosted confirms authoritative CURRENT and complete evidence
41
+ --continuous-health-out <path> Write the redacted Hosted health contract
40
42
  --artifact-root <dir> Allowed root for companion artifacts (default: current directory)
41
43
  --no-artifacts Upload only the evidence bundle
42
44
  --help, -h Show this help
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { serializeHostedEvidenceBundle } from "./artifact-manifest.js";
2
3
  import { MAX_REPORTED_COMPANION_ARTIFACT_SKIPS, } from "./companion-artifacts.js";
3
4
  export class ControlPlaneRequestError extends Error {
@@ -58,6 +59,8 @@ export async function pushEvidenceToControlPlane(options) {
58
59
  const externalId = options.externalId ?? bundle.runId;
59
60
  const kind = hostedRunKind(bundle);
60
61
  const headers = { authorization: `Bearer ${options.apiKey}` };
62
+ const trace = hostedTrace(options.projectId, externalId);
63
+ const observationEvents = buildHostedObservationEvents(bundle, trace);
61
64
  const run = await requestJson(request, `${projectUrl}/runs`, {
62
65
  method: "POST",
63
66
  headers: { ...headers, "content-type": "application/json" },
@@ -66,6 +69,8 @@ export async function pushEvidenceToControlPlane(options) {
66
69
  kind,
67
70
  schemaVersion: bundle.schemaVersion,
68
71
  startedAt: bundle.generatedAt,
72
+ traceId: trace.traceId,
73
+ rootSpanId: trace.rootSpanId,
69
74
  metadata: {
70
75
  subject: bundle.subject,
71
76
  products: bundle.summary.products,
@@ -77,18 +82,7 @@ export async function pushEvidenceToControlPlane(options) {
77
82
  method: "POST",
78
83
  headers: { ...headers, "content-type": "application/json" },
79
84
  body: JSON.stringify({
80
- events: [{
81
- sequence: 0,
82
- type: "agentcert.evidence.created",
83
- actor: "agentcert-cli",
84
- occurredAt: bundle.generatedAt,
85
- payload: {
86
- verdict: bundle.verdict,
87
- totalEvidence: bundle.summary.totalEvidence,
88
- criticalEvidence: bundle.summary.criticalEvidence,
89
- highEvidence: bundle.summary.highEvidence,
90
- },
91
- }],
85
+ events: observationEvents,
92
86
  }),
93
87
  });
94
88
  const query = new URLSearchParams({
@@ -119,29 +113,32 @@ export async function pushEvidenceToControlPlane(options) {
119
113
  });
120
114
  artifactBytesUploaded += artifact.bytes.byteLength;
121
115
  }
122
- if (companionArtifacts.length > 0 || skippedArtifacts.length > 0) {
123
- await requestJson(request, `${projectUrl}/runs/${encodeURIComponent(run.id)}/events`, {
124
- method: "POST",
125
- headers: { ...headers, "content-type": "application/json" },
126
- body: JSON.stringify({
127
- events: [{
128
- sequence: 1,
129
- type: "agentcert.companion_artifacts.processed",
130
- actor: "agentcert-cli",
131
- occurredAt: bundle.generatedAt,
132
- payload: {
133
- uploadedCount: companionArtifacts.length,
134
- skippedCount: skippedArtifacts.length,
135
- uploadedBytes: artifactBytesUploaded,
136
- skipped: skippedArtifacts
137
- .slice(0, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS)
138
- .map(({ sourcePath, reason }) => ({ sourcePath, reason })),
139
- skippedDetailsTruncated: skippedArtifacts.length > MAX_REPORTED_COMPANION_ARTIFACT_SKIPS,
140
- },
141
- }],
142
- }),
143
- });
144
- }
116
+ await requestJson(request, `${projectUrl}/runs/${encodeURIComponent(run.id)}/events`, {
117
+ method: "POST",
118
+ headers: { ...headers, "content-type": "application/json" },
119
+ body: JSON.stringify({
120
+ events: [{
121
+ sequence: observationEvents.length,
122
+ type: "agentcert.evidence.uploaded",
123
+ actor: "agentcert-cli",
124
+ occurredAt: bundle.generatedAt,
125
+ payload: {
126
+ evidenceId: evidence.id,
127
+ bundleSchemaVersion: bundle.schemaVersion,
128
+ uploadedCount: companionArtifacts.length,
129
+ skippedCount: skippedArtifacts.length,
130
+ uploadedBytes: artifactBytesUploaded,
131
+ skipped: skippedArtifacts
132
+ .slice(0, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS)
133
+ .map(({ sourcePath, reason }) => ({ sourcePath, reason })),
134
+ skippedDetailsTruncated: skippedArtifacts.length > MAX_REPORTED_COMPANION_ARTIFACT_SKIPS,
135
+ },
136
+ traceId: trace.traceId,
137
+ spanId: stableSpanId(`${trace.traceId}:evidence-uploaded`),
138
+ parentSpanId: trace.rootSpanId,
139
+ }],
140
+ }),
141
+ });
145
142
  const firstDivergence = bundle.evidence.find((item) => item.severity === "critical" || item.severity === "high")?.message;
146
143
  await requestJson(request, `${projectUrl}/runs/${encodeURIComponent(run.id)}/complete`, {
147
144
  method: "POST",
@@ -162,6 +159,17 @@ export async function pushEvidenceToControlPlane(options) {
162
159
  },
163
160
  }),
164
161
  });
162
+ const continuousAssuranceHealth = options.assurance && options.verifyContinuousAssurance
163
+ ? await loadContinuousAssuranceHealth({
164
+ baseUrl,
165
+ projectId: options.projectId,
166
+ apiKey: options.apiKey,
167
+ runId: run.id,
168
+ expectedCaseId: options.assurance.caseId,
169
+ expectedScopeFingerprintSha256: scopeFingerprint(options.assurance.scope),
170
+ fetch: request,
171
+ })
172
+ : undefined;
165
173
  return {
166
174
  runId: run.id,
167
175
  evidenceId: evidence.id,
@@ -169,8 +177,67 @@ export async function pushEvidenceToControlPlane(options) {
169
177
  artifactsUploaded: companionArtifacts.length,
170
178
  artifactsSkipped: skippedArtifacts.length,
171
179
  artifactBytesUploaded,
180
+ ...(continuousAssuranceHealth ? { continuousAssuranceHealth } : {}),
181
+ };
182
+ }
183
+ export async function loadContinuousAssuranceHealth(options) {
184
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
185
+ const request = options.fetch ?? fetch;
186
+ const analysis = await requestJson(request, `${baseUrl}/v1/projects/${encodeURIComponent(options.projectId)}/runs/${encodeURIComponent(options.runId)}/analysis`, { headers: { authorization: `Bearer ${options.apiKey}` } });
187
+ const run = object(analysis.run);
188
+ const metadata = object(run.metadata);
189
+ const binding = object(metadata.continuousAssurance);
190
+ const reconciliation = object(binding.reconciliation);
191
+ const completeness = object(analysis.evidenceCompleteness);
192
+ const manifest = object(completeness.reconciliation);
193
+ const runStatus = text(run.status) ?? "unknown";
194
+ const nextStatus = assuranceStatus(reconciliation.nextStatus);
195
+ const evidenceStatus = evidenceCompletenessStatus(completeness.status);
196
+ const diagnostics = [];
197
+ if (!text(binding.caseId))
198
+ diagnostics.push(diagnostic("assurance_binding_missing", "Hosted run has no continuous assurance binding.", "Regenerate the kit from an issued assurance case and rerun the workflow."));
199
+ if (options.expectedCaseId && binding.caseId !== options.expectedCaseId)
200
+ diagnostics.push(diagnostic("assurance_case_mismatch", "Hosted run is bound to a different assurance case.", "Use the unmodified generated workflow and case ID."));
201
+ if (options.expectedScopeFingerprintSha256 && binding.scopeFingerprintSha256 !== options.expectedScopeFingerprintSha256)
202
+ diagnostics.push(diagnostic("scope_fingerprint_mismatch", "Hosted scope fingerprint does not match the generated scope file.", "Restore the generated scope file or start a formal revalidation."));
203
+ if (reconciliation.authoritative !== true)
204
+ diagnostics.push(diagnostic("non_authoritative_trigger", "The Hosted reconciliation is prospective, not authoritative.", "Run the release or nightly workflow; pull requests cannot establish CURRENT."));
205
+ if (reconciliation.outcome !== "current" || nextStatus !== "CURRENT")
206
+ diagnostics.push(diagnostic("assurance_not_current", `Hosted reconciliation ended in ${nextStatus}.`, "Review changed scope components or failed tests, then revalidate before release."));
207
+ if (runStatus !== "passed")
208
+ diagnostics.push(diagnostic("run_not_passed", `Hosted run status is ${runStatus}.`, "Open the run trace, fix the first divergence, and rerun the same generated kit."));
209
+ if (evidenceStatus !== "complete")
210
+ diagnostics.push(diagnostic("evidence_incomplete", `Hosted evidence is ${evidenceStatus}.`, "Upload every declared companion artifact and verify manifest hashes before retrying."));
211
+ return {
212
+ schemaVersion: "agentcert.continuous_assurance_health.v0.1",
213
+ healthy: diagnostics.length === 0,
214
+ status: nextStatus,
215
+ checkedAt: new Date().toISOString(),
216
+ run: { id: text(run.id) ?? options.runId, externalId: text(run.externalId) ?? "unknown", status: runStatus },
217
+ assurance: {
218
+ caseId: text(binding.caseId),
219
+ trigger: assuranceTrigger(binding.trigger),
220
+ scopeFingerprintSha256: text(binding.scopeFingerprintSha256),
221
+ authoritative: boolean(reconciliation.authoritative),
222
+ outcome: text(reconciliation.outcome),
223
+ firstAuthoritativeCurrentAt: text(reconciliation.firstAuthoritativeCurrentAt),
224
+ timeToFirstCurrentMs: finiteNumber(reconciliation.timeToFirstCurrentMs),
225
+ },
226
+ evidence: {
227
+ status: evidenceStatus,
228
+ declared: finiteNumber(manifest.declared) ?? 0,
229
+ matched: finiteNumber(manifest.matched) ?? 0,
230
+ reasons: stringArray(completeness.reasons),
231
+ },
232
+ diagnostics,
172
233
  };
173
234
  }
235
+ export function requireContinuousAssuranceCurrent(health) {
236
+ if (health.healthy)
237
+ return;
238
+ const details = health.diagnostics.map((item) => `${item.code}: ${item.message} ${item.recovery}`).join("\n");
239
+ throw new Error(`Hosted continuous assurance did not reach CURRENT.\n${details}`);
240
+ }
174
241
  function hostedRunKind(bundle) {
175
242
  const products = new Set(bundle.summary.products);
176
243
  if (products.size > 1)
@@ -183,6 +250,135 @@ function hostedRunKind(bundle) {
183
250
  return "runtime";
184
251
  return "custom";
185
252
  }
253
+ function hostedTrace(projectId, externalId) {
254
+ const traceId = createHash("sha256").update(`agentcert:${projectId}:${externalId}`).digest("hex").slice(0, 32);
255
+ return { traceId, rootSpanId: stableSpanId(`${traceId}:root`) };
256
+ }
257
+ function buildHostedObservationEvents(bundle, trace) {
258
+ const events = [{
259
+ sequence: 0,
260
+ type: "agentcert.run.started",
261
+ actor: "agentcert-cli",
262
+ occurredAt: bundle.generatedAt,
263
+ payload: redactedPayload({ subject: bundle.subject, products: bundle.summary.products, schemaVersion: bundle.schemaVersion }),
264
+ traceId: trace.traceId,
265
+ spanId: trace.rootSpanId,
266
+ }];
267
+ for (const [resultIndex, result] of bundle.results.entries()) {
268
+ if (events.length >= 478)
269
+ break;
270
+ const resultSpanId = stableSpanId(`${trace.traceId}:result:${resultIndex}:${result.runId}`);
271
+ events.push({
272
+ sequence: events.length,
273
+ type: "agentcert.product.result",
274
+ actor: result.product,
275
+ occurredAt: result.timestamp || bundle.generatedAt,
276
+ payload: redactedPayload({ product: result.product, phase: result.phase, passed: result.passed, score: result.score, certLevel: result.certLevel, summary: result.summary, sourceRunId: result.runId }),
277
+ traceId: trace.traceId,
278
+ spanId: resultSpanId,
279
+ parentSpanId: trace.rootSpanId,
280
+ });
281
+ for (const [findingIndex, finding] of result.evidence.entries()) {
282
+ if (events.length >= 478)
283
+ break;
284
+ events.push({
285
+ sequence: events.length,
286
+ type: findingEventType(result.product, finding.kind),
287
+ actor: result.product,
288
+ occurredAt: result.timestamp || bundle.generatedAt,
289
+ payload: redactedPayload({
290
+ findingId: finding.id,
291
+ kind: finding.kind,
292
+ severity: finding.severity,
293
+ message: finding.message,
294
+ source: finding.source,
295
+ artifactPath: finding.artifactPath,
296
+ suggestedFix: finding.suggestedFix,
297
+ metadata: finding.metadata,
298
+ passed: !new Set(["critical", "high"]).has(finding.severity),
299
+ }),
300
+ traceId: trace.traceId,
301
+ spanId: stableSpanId(`${trace.traceId}:finding:${resultIndex}:${findingIndex}:${finding.id}`),
302
+ parentSpanId: resultSpanId,
303
+ });
304
+ }
305
+ }
306
+ events.push({
307
+ sequence: events.length,
308
+ type: "agentcert.run.evaluated",
309
+ actor: "agentcert-cli",
310
+ occurredAt: bundle.generatedAt,
311
+ payload: redactedPayload({ verdict: bundle.verdict, summary: bundle.summary, evidenceStrength: bundle.evidenceStrength?.level }),
312
+ traceId: trace.traceId,
313
+ spanId: stableSpanId(`${trace.traceId}:evaluated`),
314
+ parentSpanId: trace.rootSpanId,
315
+ });
316
+ return events;
317
+ }
318
+ function findingEventType(product, kind) {
319
+ if (product === "tripwire-ci")
320
+ return "tripwire.fault.assertion";
321
+ if (product === "mcpbench")
322
+ return /policy|permission|security|violation/i.test(kind) ? "mcpbench.policy.violation" : "mcpbench.assertion";
323
+ if (product === "onegent-runtime") {
324
+ if (/approval/i.test(kind))
325
+ return "onegent.approval.decision";
326
+ if (/outcome|verification/i.test(kind))
327
+ return "onegent.outcome.verification";
328
+ if (/policy|mandate|authorization/i.test(kind))
329
+ return "onegent.policy.decision";
330
+ return "onegent.action.observation";
331
+ }
332
+ return "agentcert.finding";
333
+ }
334
+ function stableSpanId(seed) {
335
+ return createHash("sha256").update(seed).digest("hex").slice(0, 16);
336
+ }
337
+ function redactedPayload(value, depth = 0) {
338
+ if (depth > 4)
339
+ return "[TRUNCATED]";
340
+ if (typeof value === "string")
341
+ return value.length > 1_000 ? `${value.slice(0, 1_000)}...[TRUNCATED]` : value;
342
+ if (typeof value === "number" || typeof value === "boolean" || value === null)
343
+ return value;
344
+ if (Array.isArray(value))
345
+ return value.slice(0, 50).map((item) => redactedPayload(item, depth + 1));
346
+ if (value && typeof value === "object") {
347
+ const result = {};
348
+ for (const [key, item] of Object.entries(value).slice(0, 50)) {
349
+ result[key] = /(authorization|api.?key|token|secret|password|credential)/i.test(key) ? "[REDACTED]" : redactedPayload(item, depth + 1);
350
+ }
351
+ return result;
352
+ }
353
+ return undefined;
354
+ }
355
+ function scopeFingerprint(scope) {
356
+ return createHash("sha256").update(canonicalJson(scope)).digest("hex");
357
+ }
358
+ function canonicalJson(value) {
359
+ if (Array.isArray(value))
360
+ return `[${value.map(canonicalJson).join(",")}]`;
361
+ if (value && typeof value === "object") {
362
+ const source = value;
363
+ return `{${Object.keys(source).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(source[key])}`).join(",")}}`;
364
+ }
365
+ return JSON.stringify(value) ?? "null";
366
+ }
367
+ function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
368
+ function text(value) { return typeof value === "string" && value ? value : undefined; }
369
+ function boolean(value) { return typeof value === "boolean" ? value : undefined; }
370
+ function finiteNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
371
+ function stringArray(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; }
372
+ function assuranceStatus(value) {
373
+ return value === "CURRENT" || value === "REVALIDATION_REQUIRED" || value === "SUSPENDED" || value === "EXPIRED" ? value : "UNKNOWN";
374
+ }
375
+ function evidenceCompletenessStatus(value) {
376
+ return value === "complete" || value === "partial" || value === "rejected" ? value : "unknown";
377
+ }
378
+ function assuranceTrigger(value) {
379
+ return value === "pull_request" || value === "release" || value === "nightly" ? value : undefined;
380
+ }
381
+ function diagnostic(code, message, recovery) { return { code, message, recovery }; }
186
382
  async function requestJson(request, url, init) {
187
383
  let response;
188
384
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcert",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Assurance release gates, runtime evidence, and regression CI for AI agents.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",