agentcert 0.2.1 → 0.2.3
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 +8 -5
- package/dist/artifact-validation.js +19 -11
- package/dist/cli.js +110 -11
- package/dist/companion-artifacts.js +180 -0
- package/dist/control-plane.js +91 -5
- package/dist/credentials.js +115 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,15 +37,18 @@ Default outputs:
|
|
|
37
37
|
Push the validated evidence bundle into a hosted AgentCert project:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
|
|
41
|
-
export AGENTCERT_PROJECT_ID="your-project-id"
|
|
42
|
-
export AGENTCERT_API_KEY="ac_live_..."
|
|
40
|
+
npx agentcert connect --server https://agentcert-control-plane.onrender.com --project your-project-id
|
|
43
41
|
npx agentcert push --evidence .agentcert/latest/agentcert-evidence.json
|
|
44
42
|
```
|
|
45
43
|
|
|
46
44
|
Add `--push` to `agentcert run` to run locally and upload the resulting bundle
|
|
47
|
-
in one command.
|
|
48
|
-
|
|
45
|
+
in one command. By default, both commands also upload local files referenced by
|
|
46
|
+
the bundle. Reads are confined to `--artifact-root` (the current directory by
|
|
47
|
+
default), path and symlink escapes are rejected, and uploads are capped at 25
|
|
48
|
+
files, 10 MiB per file, and 50 MiB total. Skipped references are reported in
|
|
49
|
+
the CLI and hosted run timeline. Pass `--no-artifacts` to upload only the JSON
|
|
50
|
+
bundle. Project API keys can create runs, record events, and upload evidence,
|
|
51
|
+
but cannot approve their own runtime actions.
|
|
49
52
|
|
|
50
53
|
Review/export helpers:
|
|
51
54
|
|
|
@@ -4,21 +4,24 @@ export async function validateEvidenceArtifacts(input, artifactRoot) {
|
|
|
4
4
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
5
5
|
return { checked: 0, missing: [] };
|
|
6
6
|
}
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const uniquePaths = collectEvidenceArtifactPaths(input, artifactRoot)
|
|
8
|
+
.filter((entry) => !isRemoteArtifactPath(entry.sourcePath));
|
|
9
9
|
const missing = [];
|
|
10
10
|
for (const entry of uniquePaths) {
|
|
11
|
-
const fullPath = isAbsolute(entry.
|
|
11
|
+
const fullPath = isAbsolute(entry.sourcePath) ? entry.sourcePath : resolve(entry.root, entry.sourcePath);
|
|
12
12
|
try {
|
|
13
13
|
await access(fullPath);
|
|
14
14
|
}
|
|
15
15
|
catch {
|
|
16
|
-
missing.push(entry.
|
|
16
|
+
missing.push(entry.sourcePath);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
return { checked: uniquePaths.length, missing };
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
export function collectEvidenceArtifactPaths(input, artifactRoot) {
|
|
22
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
23
|
+
return [];
|
|
24
|
+
const bundle = input;
|
|
22
25
|
const paths = [];
|
|
23
26
|
const productRoots = productArtifactRoots(bundle, artifactRoot);
|
|
24
27
|
collectStringValues(bundle.artifacts, paths, artifactRoot);
|
|
@@ -27,20 +30,25 @@ function collectArtifactPaths(bundle, artifactRoot) {
|
|
|
27
30
|
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
28
31
|
const resultRecord = result;
|
|
29
32
|
collectStringValues(resultRecord.artifacts, paths, artifactRoot);
|
|
33
|
+
const product = typeof resultRecord.product === "string" ? resultRecord.product : undefined;
|
|
34
|
+
collectEvidencePaths(resultRecord.evidence, paths, product ? (productRoots.get(product) ?? artifactRoot) : artifactRoot);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
collectEvidencePaths(bundle.evidence, paths, artifactRoot, productRoots);
|
|
38
|
+
return dedupeArtifactPathEntries(paths);
|
|
39
|
+
}
|
|
40
|
+
function collectEvidencePaths(input, paths, root, productRoots) {
|
|
41
|
+
const evidence = Array.isArray(input) ? input : [];
|
|
33
42
|
for (const item of evidence) {
|
|
34
43
|
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
35
44
|
const itemRecord = item;
|
|
36
45
|
const artifactPath = itemRecord.artifactPath;
|
|
37
46
|
if (typeof artifactPath === "string" && artifactPath.length > 0) {
|
|
38
47
|
const source = typeof itemRecord.source === "string" ? itemRecord.source : undefined;
|
|
39
|
-
paths.push({
|
|
48
|
+
paths.push({ sourcePath: artifactPath, root: source && productRoots ? (productRoots.get(source) ?? root) : root });
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
|
-
return paths;
|
|
44
52
|
}
|
|
45
53
|
function productArtifactRoots(bundle, artifactRoot) {
|
|
46
54
|
const roots = new Map();
|
|
@@ -71,7 +79,7 @@ function collectStringValues(input, paths, root) {
|
|
|
71
79
|
}
|
|
72
80
|
for (const value of Object.values(input)) {
|
|
73
81
|
if (typeof value === "string" && value.length > 0) {
|
|
74
|
-
paths.push({
|
|
82
|
+
paths.push({ sourcePath: value, root });
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
85
|
}
|
|
@@ -79,7 +87,7 @@ function dedupeArtifactPathEntries(entries) {
|
|
|
79
87
|
const seen = new Set();
|
|
80
88
|
const deduped = [];
|
|
81
89
|
for (const entry of entries) {
|
|
82
|
-
const key = `${entry.root}\0${entry.
|
|
90
|
+
const key = `${entry.root}\0${entry.sourcePath}`;
|
|
83
91
|
if (seen.has(key))
|
|
84
92
|
continue;
|
|
85
93
|
seen.add(key);
|
|
@@ -87,6 +95,6 @@ function dedupeArtifactPathEntries(entries) {
|
|
|
87
95
|
}
|
|
88
96
|
return deduped;
|
|
89
97
|
}
|
|
90
|
-
function
|
|
98
|
+
export function isRemoteArtifactPath(value) {
|
|
91
99
|
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
|
|
92
100
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { validateEvidenceArtifacts } from "./artifact-validation.js";
|
|
5
6
|
import { renderAgentCertBadge } from "./badge.js";
|
|
6
7
|
import { buildEvidenceBundle } from "./bundle.js";
|
|
8
|
+
import { collectCompanionArtifacts, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS } from "./companion-artifacts.js";
|
|
7
9
|
import { recordsFromAgentCertResult, evaluateFailureClassifier, renderCorpusSummary, summarizeCorpus, writeReviewedFailureDataset, } from "./corpus.js";
|
|
8
10
|
import { openCorpusStore, parseCorpusStoreKind } from "./corpus-store.js";
|
|
9
|
-
import { pushEvidenceToControlPlane } from "./control-plane.js";
|
|
11
|
+
import { pushEvidenceToControlPlane, verifyControlPlaneConnection } from "./control-plane.js";
|
|
12
|
+
import { DEFAULT_AGENTCERT_SERVER, resolveConnection, saveConnection } from "./credentials.js";
|
|
10
13
|
import { applyFailureReviews, appendFailureReview, createFailureReview, findFailurePattern, parseFailureReviewStatus, parseFailureType, parseReviewConfidence, readFailureReviews, } from "./failure-review.js";
|
|
11
14
|
import { buildMonitorSnapshot, writeMonitorSnapshot } from "./monitor.js";
|
|
12
15
|
import { normalizeMcpBenchResult, normalizeOnegentAuditPacket, normalizeTripwireResult } from "./normalizers.js";
|
|
@@ -82,6 +85,33 @@ Next:
|
|
|
82
85
|
npx agentcert run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict
|
|
83
86
|
`);
|
|
84
87
|
}
|
|
88
|
+
else if (command === "connect") {
|
|
89
|
+
if (readBoolFlag("--help")) {
|
|
90
|
+
process.stdout.write(`Usage:
|
|
91
|
+
agentcert connect --server https://agentcert-control-plane.onrender.com --project <project-id>
|
|
92
|
+
agentcert connect --name staging --server https://staging.example.com --project <project-id>
|
|
93
|
+
|
|
94
|
+
The API key is read from AGENTCERT_API_KEY or requested with hidden input.
|
|
95
|
+
Saved connections are reused by agentcert push and agentcert run --push.
|
|
96
|
+
`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const name = readFlag("--name") ?? "default";
|
|
100
|
+
const server = readFlag("--server") ?? process.env.AGENTCERT_BASE_URL ?? DEFAULT_AGENTCERT_SERVER;
|
|
101
|
+
const projectId = readFlag("--project") ?? process.env.AGENTCERT_PROJECT_ID ?? await promptValue("Project ID: ");
|
|
102
|
+
const apiKey = readFlag("--api-key") ?? process.env.AGENTCERT_API_KEY ?? await promptSecret("Project API key: ");
|
|
103
|
+
const connection = await resolveConnection({ server, projectId, apiKey, env: {} });
|
|
104
|
+
const verified = await verifyControlPlaneConnection({
|
|
105
|
+
baseUrl: connection.server,
|
|
106
|
+
projectId: connection.projectId,
|
|
107
|
+
apiKey: connection.apiKey,
|
|
108
|
+
});
|
|
109
|
+
const path = await saveConnection(name, connection);
|
|
110
|
+
process.stdout.write(`Connected ${JSON.stringify(name)} to ${connection.server}\n`);
|
|
111
|
+
process.stdout.write(`Project: ${verified.projectId} (${verified.runs} runs, ${verified.evidence} evidence objects)\n`);
|
|
112
|
+
process.stdout.write(`Credentials: ${path}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
85
115
|
else if (command === "report") {
|
|
86
116
|
const config = await loadConfig(readFlag("--config"));
|
|
87
117
|
const subject = readFlag("--subject") ?? config?.subject.name ?? "agentcert-subject";
|
|
@@ -481,6 +511,7 @@ else if (command === "validate") {
|
|
|
481
511
|
else {
|
|
482
512
|
process.stdout.write(`Usage:
|
|
483
513
|
agentcert init --subject my-browser-agent
|
|
514
|
+
agentcert connect --server https://agentcert-control-plane.onrender.com --project <project-id>
|
|
484
515
|
agentcert init --out agentcert.config.json --tripwire-config tripwire.yml --force
|
|
485
516
|
agentcert init --subject my-browser-agent --github-action
|
|
486
517
|
agentcert report --mcpbench .mcpbench/latest/results.json --tripwire .tripwire/latest/tripwire-result.json --onegent .onegent/procurement/audit-packet.json --out .agentcert/latest --subject my-agent
|
|
@@ -491,7 +522,7 @@ else {
|
|
|
491
522
|
agentcert monitor build --corpus .agentcert/corpus/corpus.jsonl --out .agentcert/latest/monitor.json --subject my-agent
|
|
492
523
|
agentcert monitor build --store sqlite --sqlite .agentcert/corpus/agentcert.sqlite --out .agentcert/latest/monitor.json --subject my-agent
|
|
493
524
|
agentcert run --profile public-demo
|
|
494
|
-
agentcert push --evidence .agentcert/latest/agentcert-evidence.json --server https://agentcert.example.com --project <project-id>
|
|
525
|
+
agentcert push --evidence .agentcert/latest/agentcert-evidence.json --server https://agentcert.example.com --project <project-id> [--artifact-root .] [--no-artifacts]
|
|
495
526
|
agentcert run --tripwire .tripwire/latest/tripwire-result.json --push
|
|
496
527
|
agentcert run --mcpbench .mcpbench/latest/results.json --tripwire .tripwire/latest/tripwire-result.json --onegent .onegent/procurement/audit-packet.json --out .agentcert/latest --corpus .agentcert/corpus/corpus.jsonl --monitor-out .agentcert/latest/monitor.json --reviewed-dataset-out .agentcert/latest/reviewed-failure-dataset.jsonl
|
|
497
528
|
agentcert release-gate --config agentcert.config.json --strict
|
|
@@ -515,22 +546,90 @@ async function loadConfig(path) {
|
|
|
515
546
|
return (await readJson(path));
|
|
516
547
|
}
|
|
517
548
|
async function pushHostedEvidence(bundle, bytes, fileName) {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
549
|
+
const connection = await resolveConnection({
|
|
550
|
+
name: readFlag("--connection"),
|
|
551
|
+
server: readFlag("--server"),
|
|
552
|
+
projectId: readFlag("--project"),
|
|
553
|
+
apiKey: readFlag("--api-key"),
|
|
554
|
+
});
|
|
555
|
+
const companions = readBoolFlag("--no-artifacts")
|
|
556
|
+
? undefined
|
|
557
|
+
: await collectCompanionArtifacts(bundle, resolve(readFlag("--artifact-root") ?? process.cwd()));
|
|
524
558
|
const result = await pushEvidenceToControlPlane({
|
|
525
|
-
baseUrl,
|
|
526
|
-
projectId,
|
|
527
|
-
apiKey,
|
|
559
|
+
baseUrl: connection.server,
|
|
560
|
+
projectId: connection.projectId,
|
|
561
|
+
apiKey: connection.apiKey,
|
|
528
562
|
bundle,
|
|
529
563
|
evidenceBytes: bytes,
|
|
530
564
|
fileName,
|
|
531
565
|
externalId: readFlag("--external-id"),
|
|
566
|
+
companionArtifacts: companions?.artifacts,
|
|
567
|
+
skippedCompanionArtifacts: companions?.skipped,
|
|
532
568
|
});
|
|
533
569
|
process.stdout.write(`Hosted run: ${result.runId}\nHosted evidence: ${result.evidenceId}\n`);
|
|
570
|
+
if (companions) {
|
|
571
|
+
process.stdout.write(`Hosted companion artifacts: ${result.artifactsUploaded} uploaded, ${result.artifactsSkipped} skipped, ${result.artifactBytesUploaded} bytes.\n`);
|
|
572
|
+
for (const skipped of companions.skipped.slice(0, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS)) {
|
|
573
|
+
process.stderr.write(`Skipped companion artifact ${skipped.sourcePath}: ${skipped.reason}${skipped.detail ? ` (${skipped.detail})` : ""}.\n`);
|
|
574
|
+
}
|
|
575
|
+
if (companions.skipped.length > MAX_REPORTED_COMPANION_ARTIFACT_SKIPS) {
|
|
576
|
+
process.stderr.write(`${companions.skipped.length - MAX_REPORTED_COMPANION_ARTIFACT_SKIPS} additional skipped artifacts omitted.\n`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async function promptValue(label) {
|
|
581
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
582
|
+
throw new Error(`Missing ${label.trim().replace(/:$/, "").toLowerCase()}. Pass it as a flag or environment variable.`);
|
|
583
|
+
}
|
|
584
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
585
|
+
try {
|
|
586
|
+
return (await prompt.question(label)).trim();
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
prompt.close();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async function promptSecret(label) {
|
|
593
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
|
|
594
|
+
throw new Error("Missing project API key. Set AGENTCERT_API_KEY or pass --api-key in a trusted environment.");
|
|
595
|
+
}
|
|
596
|
+
process.stdout.write(label);
|
|
597
|
+
process.stdin.setRawMode(true);
|
|
598
|
+
process.stdin.resume();
|
|
599
|
+
process.stdin.setEncoding("utf8");
|
|
600
|
+
return new Promise((resolveSecret, rejectSecret) => {
|
|
601
|
+
let value = "";
|
|
602
|
+
const cleanup = () => {
|
|
603
|
+
process.stdin.off("data", onData);
|
|
604
|
+
process.stdin.setRawMode?.(false);
|
|
605
|
+
process.stdin.pause();
|
|
606
|
+
process.stdout.write("\n");
|
|
607
|
+
};
|
|
608
|
+
const onData = (chunk) => {
|
|
609
|
+
for (const character of String(chunk)) {
|
|
610
|
+
if (character === "\u0003") {
|
|
611
|
+
cleanup();
|
|
612
|
+
rejectSecret(new Error("AgentCert connection cancelled."));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (character === "\r" || character === "\n") {
|
|
616
|
+
cleanup();
|
|
617
|
+
resolveSecret(value.trim());
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (character === "\u007f" || character === "\b") {
|
|
621
|
+
if (value) {
|
|
622
|
+
value = value.slice(0, -1);
|
|
623
|
+
process.stdout.write("\b \b");
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
value += character;
|
|
628
|
+
process.stdout.write("*");
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
process.stdin.on("data", onData);
|
|
632
|
+
});
|
|
534
633
|
}
|
|
535
634
|
async function readJson(path) {
|
|
536
635
|
const raw = await readFile(resolve(path), "utf8");
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { lstat, open, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { collectEvidenceArtifactPaths, isRemoteArtifactPath, } from "./artifact-validation.js";
|
|
4
|
+
export const DEFAULT_COMPANION_ARTIFACT_LIMITS = {
|
|
5
|
+
maxFiles: 25,
|
|
6
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
7
|
+
maxTotalBytes: 50 * 1024 * 1024,
|
|
8
|
+
};
|
|
9
|
+
export const MAX_REPORTED_COMPANION_ARTIFACT_SKIPS = 50;
|
|
10
|
+
export async function collectCompanionArtifacts(bundle, artifactRoot, limits = DEFAULT_COMPANION_ARTIFACT_LIMITS) {
|
|
11
|
+
validateLimits(limits);
|
|
12
|
+
const root = await realpath(resolve(artifactRoot));
|
|
13
|
+
const artifacts = [];
|
|
14
|
+
const skipped = [];
|
|
15
|
+
const uploadedRealPaths = new Set();
|
|
16
|
+
let totalBytes = 0;
|
|
17
|
+
for (const entry of collectEvidenceArtifactPaths(bundle, root)) {
|
|
18
|
+
if (isRemoteArtifactPath(entry.sourcePath)) {
|
|
19
|
+
skipped.push(skip(entry, "remote_url"));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (artifacts.length >= limits.maxFiles) {
|
|
23
|
+
skipped.push(skip(entry, "file_limit", `maximum ${limits.maxFiles} files`));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const candidate = isAbsolute(entry.sourcePath)
|
|
27
|
+
? resolve(entry.sourcePath)
|
|
28
|
+
: resolve(entry.root, entry.sourcePath);
|
|
29
|
+
if (!isWithinRoot(root, candidate)) {
|
|
30
|
+
skipped.push(skip(entry, "outside_artifact_root"));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let candidateInfo;
|
|
34
|
+
try {
|
|
35
|
+
candidateInfo = await lstat(candidate);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
skipped.push(skip(entry, errorCode(error) === "ENOENT" ? "missing" : "unreadable", errorMessage(error)));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (candidateInfo.isSymbolicLink()) {
|
|
42
|
+
skipped.push(skip(entry, "symlink"));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let resolvedPath;
|
|
46
|
+
try {
|
|
47
|
+
resolvedPath = await realpath(candidate);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
skipped.push(skip(entry, errorCode(error) === "ENOENT" ? "missing" : "unreadable", errorMessage(error)));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!isWithinRoot(root, resolvedPath)) {
|
|
54
|
+
skipped.push(skip(entry, "outside_artifact_root"));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (uploadedRealPaths.has(resolvedPath))
|
|
58
|
+
continue;
|
|
59
|
+
let fileInfo;
|
|
60
|
+
try {
|
|
61
|
+
fileInfo = await stat(resolvedPath);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
skipped.push(skip(entry, errorCode(error) === "ENOENT" ? "missing" : "unreadable", errorMessage(error)));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!fileInfo.isFile()) {
|
|
68
|
+
skipped.push(skip(entry, "not_file"));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (fileInfo.size > limits.maxFileBytes) {
|
|
72
|
+
skipped.push(skip(entry, "file_too_large", `${fileInfo.size} bytes exceeds ${limits.maxFileBytes}`));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (totalBytes + fileInfo.size > limits.maxTotalBytes) {
|
|
76
|
+
skipped.push(skip(entry, "total_size_limit", `maximum ${limits.maxTotalBytes} bytes`));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const remainingBytes = limits.maxTotalBytes - totalBytes;
|
|
80
|
+
let readResult;
|
|
81
|
+
try {
|
|
82
|
+
readResult = await readBounded(resolvedPath, Math.min(limits.maxFileBytes, remainingBytes));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
skipped.push(skip(entry, "unreadable", errorMessage(error)));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (readResult.exceeded) {
|
|
89
|
+
const reason = remainingBytes < limits.maxFileBytes ? "total_size_limit" : "file_too_large";
|
|
90
|
+
skipped.push(skip(entry, reason));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const descriptor = describeArtifact(entry.sourcePath);
|
|
94
|
+
artifacts.push({
|
|
95
|
+
sourcePath: normalizeSourcePath(entry.sourcePath),
|
|
96
|
+
fileName: basename(resolvedPath),
|
|
97
|
+
kind: descriptor.kind,
|
|
98
|
+
contentType: descriptor.contentType,
|
|
99
|
+
bytes: readResult.bytes,
|
|
100
|
+
});
|
|
101
|
+
uploadedRealPaths.add(resolvedPath);
|
|
102
|
+
totalBytes += readResult.bytes.byteLength;
|
|
103
|
+
}
|
|
104
|
+
return { artifacts, skipped, totalBytes, limits: { ...limits } };
|
|
105
|
+
}
|
|
106
|
+
function validateLimits(limits) {
|
|
107
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
108
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
109
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function isWithinRoot(root, candidate) {
|
|
113
|
+
const pathFromRoot = relative(root, candidate);
|
|
114
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== ".." && !isAbsolute(pathFromRoot));
|
|
115
|
+
}
|
|
116
|
+
async function readBounded(path, maxBytes) {
|
|
117
|
+
const handle = await open(path, "r");
|
|
118
|
+
const chunks = [];
|
|
119
|
+
let total = 0;
|
|
120
|
+
try {
|
|
121
|
+
while (total <= maxBytes) {
|
|
122
|
+
const capacity = Math.min(64 * 1024, maxBytes + 1 - total);
|
|
123
|
+
const chunk = Buffer.allocUnsafe(capacity);
|
|
124
|
+
const { bytesRead } = await handle.read(chunk, 0, capacity, null);
|
|
125
|
+
if (bytesRead === 0)
|
|
126
|
+
return { bytes: Buffer.concat(chunks, total), exceeded: false };
|
|
127
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
128
|
+
total += bytesRead;
|
|
129
|
+
}
|
|
130
|
+
return { bytes: new Uint8Array(), exceeded: true };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function describeArtifact(path) {
|
|
137
|
+
const lower = path.toLowerCase();
|
|
138
|
+
const extension = extname(lower);
|
|
139
|
+
if ([".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
|
|
140
|
+
const imageType = extension === ".jpg" ? "jpeg" : extension.slice(1);
|
|
141
|
+
return { kind: "screenshot", contentType: `image/${imageType}` };
|
|
142
|
+
}
|
|
143
|
+
if (extension === ".svg")
|
|
144
|
+
return { kind: "report", contentType: "image/svg+xml" };
|
|
145
|
+
if (extension === ".zip" || lower.includes("trace")) {
|
|
146
|
+
const contentType = extension === ".zip" ? "application/zip" : extension === ".json" ? "application/json" : "application/octet-stream";
|
|
147
|
+
return { kind: "trace", contentType };
|
|
148
|
+
}
|
|
149
|
+
if ([".html", ".htm"].includes(extension) || lower.includes("dom")) {
|
|
150
|
+
const contentType = [".html", ".htm"].includes(extension)
|
|
151
|
+
? "text/html; charset=utf-8"
|
|
152
|
+
: extension === ".json"
|
|
153
|
+
? "application/json"
|
|
154
|
+
: "application/octet-stream";
|
|
155
|
+
return { kind: "dom", contentType };
|
|
156
|
+
}
|
|
157
|
+
if (extension === ".json")
|
|
158
|
+
return { kind: "json", contentType: "application/json" };
|
|
159
|
+
if (extension === ".jsonl")
|
|
160
|
+
return { kind: "json", contentType: "application/x-ndjson" };
|
|
161
|
+
if (extension === ".pdf")
|
|
162
|
+
return { kind: "report", contentType: "application/pdf" };
|
|
163
|
+
if (extension === ".md")
|
|
164
|
+
return { kind: "report", contentType: "text/markdown; charset=utf-8" };
|
|
165
|
+
if (extension === ".txt")
|
|
166
|
+
return { kind: "artifact", contentType: "text/plain; charset=utf-8" };
|
|
167
|
+
return { kind: "artifact", contentType: "application/octet-stream" };
|
|
168
|
+
}
|
|
169
|
+
function normalizeSourcePath(path) {
|
|
170
|
+
return path.replace(/\\/g, "/").slice(0, 1024);
|
|
171
|
+
}
|
|
172
|
+
function skip(entry, reason, detail) {
|
|
173
|
+
return { sourcePath: normalizeSourcePath(entry.sourcePath), reason, ...(detail ? { detail } : {}) };
|
|
174
|
+
}
|
|
175
|
+
function errorCode(error) {
|
|
176
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
177
|
+
}
|
|
178
|
+
function errorMessage(error) {
|
|
179
|
+
return error instanceof Error ? error.message : String(error);
|
|
180
|
+
}
|
package/dist/control-plane.js
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
import { MAX_REPORTED_COMPANION_ARTIFACT_SKIPS, } from "./companion-artifacts.js";
|
|
2
|
+
export class ControlPlaneRequestError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.name = "ControlPlaneRequestError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function verifyControlPlaneConnection(options) {
|
|
11
|
+
const baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
12
|
+
const request = options.fetch ?? fetch;
|
|
13
|
+
try {
|
|
14
|
+
const overview = await requestJson(request, `${baseUrl}/v1/projects/${encodeURIComponent(options.projectId)}/overview`, {
|
|
15
|
+
headers: { authorization: `Bearer ${options.apiKey}` },
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
projectId: overview.projectId,
|
|
19
|
+
runs: overview.summary.runs ?? 0,
|
|
20
|
+
evidence: overview.summary.evidence ?? 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof ControlPlaneRequestError && error.status === 401) {
|
|
25
|
+
throw new Error("AgentCert API key was rejected. Create a new project API key and try again.");
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof ControlPlaneRequestError && error.status === 403) {
|
|
28
|
+
throw new Error(`AgentCert API key cannot access project ${options.projectId}. Check the project ID and key scope.`);
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof ControlPlaneRequestError && error.status === 404) {
|
|
31
|
+
throw new Error(`AgentCert project ${options.projectId} was not found.`);
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
1
36
|
export async function pushEvidenceToControlPlane(options) {
|
|
2
37
|
const baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
3
38
|
if (!baseUrl || !options.projectId || !options.apiKey) {
|
|
@@ -51,6 +86,47 @@ export async function pushEvidenceToControlPlane(options) {
|
|
|
51
86
|
headers: { ...headers, "content-type": "application/json" },
|
|
52
87
|
body: new Uint8Array(options.evidenceBytes).buffer,
|
|
53
88
|
});
|
|
89
|
+
const companionArtifacts = options.companionArtifacts ?? [];
|
|
90
|
+
const skippedArtifacts = options.skippedCompanionArtifacts ?? [];
|
|
91
|
+
let artifactBytesUploaded = 0;
|
|
92
|
+
for (const artifact of companionArtifacts) {
|
|
93
|
+
const artifactQuery = new URLSearchParams({
|
|
94
|
+
fileName: artifact.fileName,
|
|
95
|
+
kind: artifact.kind,
|
|
96
|
+
schemaVersion: options.bundle.schemaVersion,
|
|
97
|
+
runId: run.id,
|
|
98
|
+
sourcePath: artifact.sourcePath,
|
|
99
|
+
});
|
|
100
|
+
await requestJson(request, `${projectUrl}/evidence?${artifactQuery}`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { ...headers, "content-type": artifact.contentType },
|
|
103
|
+
body: new Uint8Array(artifact.bytes).buffer,
|
|
104
|
+
});
|
|
105
|
+
artifactBytesUploaded += artifact.bytes.byteLength;
|
|
106
|
+
}
|
|
107
|
+
if (companionArtifacts.length > 0 || skippedArtifacts.length > 0) {
|
|
108
|
+
await requestJson(request, `${projectUrl}/runs/${encodeURIComponent(run.id)}/events`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
events: [{
|
|
113
|
+
sequence: 1,
|
|
114
|
+
type: "agentcert.companion_artifacts.processed",
|
|
115
|
+
actor: "agentcert-cli",
|
|
116
|
+
occurredAt: options.bundle.generatedAt,
|
|
117
|
+
payload: {
|
|
118
|
+
uploadedCount: companionArtifacts.length,
|
|
119
|
+
skippedCount: skippedArtifacts.length,
|
|
120
|
+
uploadedBytes: artifactBytesUploaded,
|
|
121
|
+
skipped: skippedArtifacts
|
|
122
|
+
.slice(0, MAX_REPORTED_COMPANION_ARTIFACT_SKIPS)
|
|
123
|
+
.map(({ sourcePath, reason }) => ({ sourcePath, reason })),
|
|
124
|
+
skippedDetailsTruncated: skippedArtifacts.length > MAX_REPORTED_COMPANION_ARTIFACT_SKIPS,
|
|
125
|
+
},
|
|
126
|
+
}],
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
54
130
|
const firstDivergence = options.bundle.evidence.find((item) => item.severity === "critical" || item.severity === "high")?.message;
|
|
55
131
|
await requestJson(request, `${projectUrl}/runs/${encodeURIComponent(run.id)}/complete`, {
|
|
56
132
|
method: "POST",
|
|
@@ -64,10 +140,20 @@ export async function pushEvidenceToControlPlane(options) {
|
|
|
64
140
|
metadata: {
|
|
65
141
|
evidenceId: evidence.id,
|
|
66
142
|
evidenceSchemaVersion: options.bundle.schemaVersion,
|
|
143
|
+
companionArtifactsUploaded: companionArtifacts.length,
|
|
144
|
+
companionArtifactsSkipped: skippedArtifacts.length,
|
|
145
|
+
companionArtifactBytesUploaded: artifactBytesUploaded,
|
|
67
146
|
},
|
|
68
147
|
}),
|
|
69
148
|
});
|
|
70
|
-
return {
|
|
149
|
+
return {
|
|
150
|
+
runId: run.id,
|
|
151
|
+
evidenceId: evidence.id,
|
|
152
|
+
externalId,
|
|
153
|
+
artifactsUploaded: companionArtifacts.length,
|
|
154
|
+
artifactsSkipped: skippedArtifacts.length,
|
|
155
|
+
artifactBytesUploaded,
|
|
156
|
+
};
|
|
71
157
|
}
|
|
72
158
|
function hostedRunKind(bundle) {
|
|
73
159
|
const products = new Set(bundle.summary.products);
|
|
@@ -88,7 +174,7 @@ async function requestJson(request, url, init) {
|
|
|
88
174
|
}
|
|
89
175
|
catch (error) {
|
|
90
176
|
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
-
throw new
|
|
177
|
+
throw new ControlPlaneRequestError(`AgentCert control plane request failed: ${message}`);
|
|
92
178
|
}
|
|
93
179
|
const text = await response.text();
|
|
94
180
|
let value = {};
|
|
@@ -98,12 +184,12 @@ async function requestJson(request, url, init) {
|
|
|
98
184
|
}
|
|
99
185
|
catch {
|
|
100
186
|
if (!response.ok)
|
|
101
|
-
throw new
|
|
102
|
-
throw new
|
|
187
|
+
throw new ControlPlaneRequestError(`AgentCert control plane returned HTTP ${response.status}.`, response.status);
|
|
188
|
+
throw new ControlPlaneRequestError("AgentCert control plane returned invalid JSON.", response.status);
|
|
103
189
|
}
|
|
104
190
|
}
|
|
105
191
|
if (!response.ok) {
|
|
106
|
-
throw new
|
|
192
|
+
throw new ControlPlaneRequestError(typeof value.error === "string" ? value.error : `AgentCert control plane returned HTTP ${response.status}.`, response.status);
|
|
107
193
|
}
|
|
108
194
|
return value;
|
|
109
195
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
export const DEFAULT_AGENTCERT_SERVER = "https://agentcert-control-plane.onrender.com";
|
|
6
|
+
export async function saveConnection(name, input, options = {}) {
|
|
7
|
+
const connectionName = validateConnectionName(name);
|
|
8
|
+
const connection = validateConnection(input);
|
|
9
|
+
const path = credentialsPath(options);
|
|
10
|
+
const current = await readCredentialFile(path);
|
|
11
|
+
const next = {
|
|
12
|
+
schemaVersion: "agentcert.credentials.v1",
|
|
13
|
+
defaultConnection: connectionName,
|
|
14
|
+
connections: { ...current?.connections, [connectionName]: connection },
|
|
15
|
+
};
|
|
16
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
17
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
18
|
+
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
19
|
+
await rename(temporaryPath, path);
|
|
20
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
export async function loadConnection(name, options = {}) {
|
|
24
|
+
const file = await readCredentialFile(credentialsPath(options));
|
|
25
|
+
if (!file)
|
|
26
|
+
return undefined;
|
|
27
|
+
const connectionName = name ? validateConnectionName(name) : file.defaultConnection;
|
|
28
|
+
const connection = file.connections[connectionName];
|
|
29
|
+
return connection ? validateConnection(connection) : undefined;
|
|
30
|
+
}
|
|
31
|
+
export async function resolveConnection(options = {}) {
|
|
32
|
+
const env = options.env ?? process.env;
|
|
33
|
+
const stored = options.server && options.projectId && options.apiKey
|
|
34
|
+
? undefined
|
|
35
|
+
: await loadConnection(options.name, options);
|
|
36
|
+
const candidate = {
|
|
37
|
+
server: options.server ?? env.AGENTCERT_BASE_URL ?? stored?.server,
|
|
38
|
+
projectId: options.projectId ?? env.AGENTCERT_PROJECT_ID ?? stored?.projectId,
|
|
39
|
+
apiKey: options.apiKey ?? env.AGENTCERT_API_KEY ?? stored?.apiKey,
|
|
40
|
+
};
|
|
41
|
+
const missing = Object.entries(candidate).filter(([, value]) => !value).map(([key]) => key);
|
|
42
|
+
if (missing.length > 0) {
|
|
43
|
+
throw new Error(`Hosted connection is incomplete (${missing.join(", ")} missing). Run \`npx agentcert connect\` or set AGENTCERT_BASE_URL, AGENTCERT_PROJECT_ID, and AGENTCERT_API_KEY.`);
|
|
44
|
+
}
|
|
45
|
+
return validateConnection(candidate);
|
|
46
|
+
}
|
|
47
|
+
export function credentialsPath(options = {}) {
|
|
48
|
+
const configHome = options.configHome ?? process.env.AGENTCERT_CONFIG_HOME ?? join(homedir(), ".agentcert");
|
|
49
|
+
return join(configHome, "credentials.json");
|
|
50
|
+
}
|
|
51
|
+
function validateConnection(input) {
|
|
52
|
+
const projectId = input.projectId.trim();
|
|
53
|
+
const apiKey = input.apiKey.trim();
|
|
54
|
+
if (!projectId)
|
|
55
|
+
throw new Error("AgentCert project ID is required.");
|
|
56
|
+
if (!apiKey.startsWith("ac_live_"))
|
|
57
|
+
throw new Error("AgentCert API key must start with ac_live_.");
|
|
58
|
+
return { server: normalizeServer(input.server), projectId, apiKey };
|
|
59
|
+
}
|
|
60
|
+
function normalizeServer(value) {
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(value.trim());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error("AgentCert server must be a valid HTTP or HTTPS URL.");
|
|
67
|
+
}
|
|
68
|
+
const loopback = new Set(["localhost", "127.0.0.1", "[::1]"]).has(url.hostname);
|
|
69
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
70
|
+
throw new Error("AgentCert server must use HTTPS. Plain HTTP is allowed only for localhost development.");
|
|
71
|
+
}
|
|
72
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
73
|
+
throw new Error("AgentCert server URL cannot contain credentials, query parameters, or a fragment.");
|
|
74
|
+
}
|
|
75
|
+
return url.toString().replace(/\/$/, "");
|
|
76
|
+
}
|
|
77
|
+
function validateConnectionName(value) {
|
|
78
|
+
const name = value.trim();
|
|
79
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) {
|
|
80
|
+
throw new Error("Connection name must contain 1-64 letters, numbers, dots, underscores, or hyphens.");
|
|
81
|
+
}
|
|
82
|
+
return name;
|
|
83
|
+
}
|
|
84
|
+
async function readCredentialFile(path) {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await readFile(path, "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code === "ENOENT")
|
|
91
|
+
return undefined;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
let value;
|
|
95
|
+
try {
|
|
96
|
+
value = JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error(`AgentCert credentials file is not valid JSON: ${path}`);
|
|
100
|
+
}
|
|
101
|
+
if (!isCredentialFile(value)) {
|
|
102
|
+
throw new Error(`AgentCert credentials file has an unsupported format: ${path}`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
function isCredentialFile(value) {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
108
|
+
return false;
|
|
109
|
+
const file = value;
|
|
110
|
+
return file.schemaVersion === "agentcert.credentials.v1"
|
|
111
|
+
&& typeof file.defaultConnection === "string"
|
|
112
|
+
&& Boolean(file.connections)
|
|
113
|
+
&& typeof file.connections === "object"
|
|
114
|
+
&& !Array.isArray(file.connections);
|
|
115
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./bundle.js";
|
|
|
2
2
|
export * from "./corpus.js";
|
|
3
3
|
export * from "./corpus-store.js";
|
|
4
4
|
export * from "./control-plane.js";
|
|
5
|
+
export * from "./credentials.js";
|
|
5
6
|
export * from "./failure-review.js";
|
|
6
7
|
export * from "./evidence-signing.js";
|
|
7
8
|
export * from "./local-server.js";
|