agentic-scorecard 0.1.0 → 0.2.0
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/AGENT_PROMPT.md +60 -31
- package/FEEDBACK.md +53 -0
- package/README.md +96 -37
- package/benchmark/v0.2/adapter-contract.md +25 -0
- package/benchmark/v0.2/agent-evidence-schema.json +52 -0
- package/benchmark/v0.2/attestation-schema.json +26 -0
- package/benchmark/v0.2/benchmark.yaml +127 -0
- package/benchmark/v0.2/controls/context.yaml +51 -0
- package/benchmark/v0.2/controls/environment.yaml +70 -0
- package/benchmark/v0.2/controls/governance.yaml +60 -0
- package/benchmark/v0.2/controls/learning.yaml +48 -0
- package/benchmark/v0.2/controls/observability.yaml +76 -0
- package/benchmark/v0.2/controls/resilience.yaml +60 -0
- package/benchmark/v0.2/controls/security.yaml +71 -0
- package/benchmark/v0.2/controls/specification.yaml +62 -0
- package/benchmark/v0.2/controls/testing.yaml +60 -0
- package/benchmark/v0.2/controls/tooling.yaml +58 -0
- package/benchmark/v0.2/report-schema.json +47 -0
- package/benchmark/v0.2/scoring-policy.md +71 -0
- package/dist/cli.js +607 -102
- package/package.json +5 -3
- package/templates/agent-evidence.yaml +11 -0
- package/templates/attestations.yaml +4 -4
- package/templates/baseline.csv +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { appendFile, mkdir, readFile as readFile3, writeFile } from "fs/promises";
|
|
5
|
-
import { dirname as dirname2, join as join2, resolve as
|
|
5
|
+
import { dirname as dirname2, join as join2, resolve as resolve4 } from "path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { stringify } from "yaml";
|
|
8
8
|
|
|
@@ -29,31 +29,49 @@ var dimensionIds = [
|
|
|
29
29
|
];
|
|
30
30
|
var DimensionIdSchema = z.enum(dimensionIds);
|
|
31
31
|
var LevelSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]);
|
|
32
|
+
var EvidenceScopeSchema = z.enum(["repository", "platform", "organization", "outcome"]);
|
|
33
|
+
var AssessmentScopeSchema = z.enum(["tracked", "workspace"]);
|
|
32
34
|
var PathAnySchema = z.object({
|
|
33
35
|
type: z.literal("path_any"),
|
|
34
|
-
|
|
36
|
+
scope: z.literal("repository").default("repository"),
|
|
37
|
+
patterns: z.array(z.string().min(1)).min(1),
|
|
38
|
+
min_bytes: z.number().int().positive().default(1)
|
|
35
39
|
});
|
|
36
40
|
var PathAllSchema = z.object({
|
|
37
41
|
type: z.literal("path_all"),
|
|
38
|
-
|
|
42
|
+
scope: z.literal("repository").default("repository"),
|
|
43
|
+
patterns: z.array(z.string().min(1)).min(1),
|
|
44
|
+
min_bytes: z.number().int().positive().default(1)
|
|
39
45
|
});
|
|
40
46
|
var ContentAnySchema = z.object({
|
|
41
47
|
type: z.literal("content_any"),
|
|
48
|
+
scope: z.literal("repository").default("repository"),
|
|
42
49
|
files: z.array(z.string().min(1)).min(1),
|
|
43
50
|
needles: z.array(z.string().min(1)).min(1)
|
|
44
51
|
});
|
|
45
52
|
var ContentAllSchema = z.object({
|
|
46
53
|
type: z.literal("content_all"),
|
|
54
|
+
scope: z.literal("repository").default("repository"),
|
|
47
55
|
files: z.array(z.string().min(1)).min(1),
|
|
48
56
|
needles: z.array(z.string().min(1)).min(1)
|
|
49
57
|
});
|
|
58
|
+
var ContentTermsSchema = z.object({
|
|
59
|
+
type: z.literal("content_terms"),
|
|
60
|
+
scope: z.literal("repository").default("repository"),
|
|
61
|
+
files: z.array(z.string().min(1)).min(1),
|
|
62
|
+
terms: z.array(z.string().min(1)).min(1),
|
|
63
|
+
min_terms: z.number().int().positive(),
|
|
64
|
+
required_any_terms: z.array(z.string().min(1)).min(1).optional()
|
|
65
|
+
});
|
|
50
66
|
var MaxBytesSchema = z.object({
|
|
51
67
|
type: z.literal("max_bytes"),
|
|
68
|
+
scope: z.literal("repository").default("repository"),
|
|
52
69
|
patterns: z.array(z.string().min(1)).min(1),
|
|
53
70
|
max_bytes: z.number().int().positive()
|
|
54
71
|
});
|
|
55
72
|
var ManualSchema = z.object({
|
|
56
73
|
type: z.literal("manual"),
|
|
74
|
+
scope: z.enum(["platform", "organization", "outcome"]).default("organization"),
|
|
57
75
|
prompt: z.string().min(1)
|
|
58
76
|
});
|
|
59
77
|
var EvidenceCheckSchema = z.discriminatedUnion("type", [
|
|
@@ -61,6 +79,7 @@ var EvidenceCheckSchema = z.discriminatedUnion("type", [
|
|
|
61
79
|
PathAllSchema,
|
|
62
80
|
ContentAnySchema,
|
|
63
81
|
ContentAllSchema,
|
|
82
|
+
ContentTermsSchema,
|
|
64
83
|
MaxBytesSchema,
|
|
65
84
|
ManualSchema
|
|
66
85
|
]);
|
|
@@ -73,13 +92,21 @@ var RawControlSchema = z.object({
|
|
|
73
92
|
evidence: z.array(EvidenceCheckSchema).min(1),
|
|
74
93
|
remediation: z.string().min(1),
|
|
75
94
|
references: z.array(z.string().min(1)).default([]),
|
|
76
|
-
allow_attestation: z.boolean().default(
|
|
77
|
-
allow_not_applicable: z.boolean().default(false)
|
|
95
|
+
allow_attestation: z.boolean().default(false),
|
|
96
|
+
allow_not_applicable: z.boolean().default(false),
|
|
97
|
+
allow_agent_evidence: z.boolean().default(false)
|
|
98
|
+
});
|
|
99
|
+
var LegacyRawControlSchema = RawControlSchema.extend({
|
|
100
|
+
allow_attestation: z.boolean().default(true)
|
|
78
101
|
});
|
|
79
102
|
var ControlFileSchema = z.object({
|
|
80
103
|
dimension: DimensionIdSchema,
|
|
81
104
|
controls: z.array(RawControlSchema).min(1)
|
|
82
105
|
});
|
|
106
|
+
var LegacyControlFileSchema = z.object({
|
|
107
|
+
dimension: DimensionIdSchema,
|
|
108
|
+
controls: z.array(LegacyRawControlSchema).min(1)
|
|
109
|
+
});
|
|
83
110
|
var DimensionSchema = z.object({
|
|
84
111
|
id: DimensionIdSchema,
|
|
85
112
|
title: z.string().min(1),
|
|
@@ -116,16 +143,53 @@ var AttestationSchema = z.object({
|
|
|
116
143
|
evidence: z.string().min(1),
|
|
117
144
|
owner: z.string().min(1),
|
|
118
145
|
reviewed_at: z.string().date(),
|
|
146
|
+
expires_at: z.string().date()
|
|
147
|
+
});
|
|
148
|
+
var LegacyAttestationSchema = AttestationSchema.extend({
|
|
119
149
|
expires_at: z.string().date().nullable().default(null)
|
|
120
150
|
});
|
|
121
151
|
var AttestationFileSchema = z.object({
|
|
122
152
|
benchmark_version: z.string().min(1),
|
|
123
153
|
attestations: z.record(z.string(), AttestationSchema).default({})
|
|
124
154
|
});
|
|
155
|
+
var LegacyAttestationFileSchema = z.object({
|
|
156
|
+
benchmark_version: z.string().min(1),
|
|
157
|
+
attestations: z.record(z.string(), LegacyAttestationSchema).default({})
|
|
158
|
+
});
|
|
159
|
+
var AgentEvidenceClaimSchema = z.object({
|
|
160
|
+
status: z.enum(["met", "not_met", "unknown"]),
|
|
161
|
+
scope: z.enum(["platform", "organization", "outcome"]),
|
|
162
|
+
summary: z.string().min(1),
|
|
163
|
+
references: z.array(z.string().min(1)).min(1),
|
|
164
|
+
collected_at: z.string().datetime(),
|
|
165
|
+
expires_at: z.string().datetime(),
|
|
166
|
+
error: z.string().min(1).nullable().default(null)
|
|
167
|
+
}).strict().superRefine((claim, context) => {
|
|
168
|
+
if (claim.error && claim.status !== "unknown") {
|
|
169
|
+
context.addIssue({
|
|
170
|
+
code: z.ZodIssueCode.custom,
|
|
171
|
+
message: "A claim with an error must have unknown status",
|
|
172
|
+
path: ["status"]
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
var AgentEvidenceFileSchema = z.object({
|
|
177
|
+
schema_version: z.literal("0.2.0"),
|
|
178
|
+
benchmark_version: z.literal("0.2.0"),
|
|
179
|
+
target: z.object({
|
|
180
|
+
repository: z.string().min(1),
|
|
181
|
+
git_head: z.string().min(1).nullable()
|
|
182
|
+
}).strict(),
|
|
183
|
+
collector: z.object({
|
|
184
|
+
name: z.string().min(1),
|
|
185
|
+
version: z.string().min(1)
|
|
186
|
+
}).strict(),
|
|
187
|
+
claims: z.record(z.string().regex(/^ADRB-[A-Z]{3}-\d{3}$/), AgentEvidenceClaimSchema)
|
|
188
|
+
}).strict();
|
|
125
189
|
|
|
126
190
|
// src/load.ts
|
|
127
191
|
var packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
128
|
-
var defaultBenchmarkRoot = join(packageRoot, "benchmark", "v0.
|
|
192
|
+
var defaultBenchmarkRoot = join(packageRoot, "benchmark", "v0.2");
|
|
129
193
|
async function readYaml(path) {
|
|
130
194
|
return parse(await readFile(path, "utf8"));
|
|
131
195
|
}
|
|
@@ -134,7 +198,7 @@ async function loadBenchmark(root = defaultBenchmarkRoot) {
|
|
|
134
198
|
const controlPaths = await fg("controls/*.yaml", { cwd: root, absolute: true, onlyFiles: true });
|
|
135
199
|
const controls = [];
|
|
136
200
|
for (const path of controlPaths.sort()) {
|
|
137
|
-
const file = ControlFileSchema.parse(await readYaml(path));
|
|
201
|
+
const file = benchmark.version === "0.1.0" ? LegacyControlFileSchema.parse(await readYaml(path)) : ControlFileSchema.parse(await readYaml(path));
|
|
138
202
|
controls.push(...file.controls.map((control) => ({ ...control, dimension: file.dimension })));
|
|
139
203
|
}
|
|
140
204
|
validateCatalog(benchmark, controls);
|
|
@@ -142,7 +206,8 @@ async function loadBenchmark(root = defaultBenchmarkRoot) {
|
|
|
142
206
|
}
|
|
143
207
|
async function loadAttestations(path, benchmarkVersion) {
|
|
144
208
|
try {
|
|
145
|
-
const
|
|
209
|
+
const rawFile = await readYaml(path);
|
|
210
|
+
const file = benchmarkVersion === "0.1.0" ? LegacyAttestationFileSchema.parse(rawFile) : AttestationFileSchema.parse(rawFile);
|
|
146
211
|
if (file.benchmark_version !== benchmarkVersion) {
|
|
147
212
|
throw new Error(
|
|
148
213
|
`Attestation benchmark version ${file.benchmark_version} does not match ${benchmarkVersion}`
|
|
@@ -154,11 +219,33 @@ async function loadAttestations(path, benchmarkVersion) {
|
|
|
154
219
|
throw error;
|
|
155
220
|
}
|
|
156
221
|
}
|
|
222
|
+
async function loadAgentEvidence(path) {
|
|
223
|
+
try {
|
|
224
|
+
return AgentEvidenceFileSchema.parse(await readYaml(path));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error.code === "ENOENT") return null;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
157
230
|
function validateCatalog(benchmark, controls) {
|
|
158
231
|
const ids = /* @__PURE__ */ new Set();
|
|
159
232
|
for (const control of controls) {
|
|
160
233
|
if (ids.has(control.id)) throw new Error(`Duplicate control id: ${control.id}`);
|
|
161
234
|
ids.add(control.id);
|
|
235
|
+
if (benchmark.version === "0.2.0" && control.evidence.some(({ type }) => type === "content_any" || type === "content_all")) {
|
|
236
|
+
throw new Error(`${control.id} uses a legacy broad content collector in benchmark v0.2.0`);
|
|
237
|
+
}
|
|
238
|
+
if (control.allow_agent_evidence && !control.evidence.some(({ type }) => type === "manual")) {
|
|
239
|
+
throw new Error(`${control.id} allows agent evidence without an external evidence check`);
|
|
240
|
+
}
|
|
241
|
+
if (benchmark.version === "0.2.0" && control.allow_attestation && !control.evidence.some(({ type }) => type === "manual")) {
|
|
242
|
+
throw new Error(`${control.id} allows attestation for repository-detected evidence`);
|
|
243
|
+
}
|
|
244
|
+
for (const check of control.evidence) {
|
|
245
|
+
if (check.type === "content_terms" && check.min_terms > check.terms.length) {
|
|
246
|
+
throw new Error(`${control.id} requires more content terms than it defines`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
162
249
|
}
|
|
163
250
|
const benchmarkDimensions = benchmark.dimensions.map(({ id }) => id);
|
|
164
251
|
if (benchmarkDimensions.join(",") !== dimensionIds.join(",")) {
|
|
@@ -181,18 +268,88 @@ var statusIcon = {
|
|
|
181
268
|
unknown: "UNKNOWN",
|
|
182
269
|
not_applicable: "N/A"
|
|
183
270
|
};
|
|
271
|
+
function controlScope(control) {
|
|
272
|
+
return control.evidence.find(({ scope }) => scope !== "repository")?.scope ?? "repository";
|
|
273
|
+
}
|
|
274
|
+
function safeText(value) {
|
|
275
|
+
return value.replace(/\s+/g, " ").replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
276
|
+
}
|
|
277
|
+
function sentence(value) {
|
|
278
|
+
const safe = safeText(value);
|
|
279
|
+
return /[.!?]$/.test(safe) ? safe : `${safe}.`;
|
|
280
|
+
}
|
|
281
|
+
function references(values) {
|
|
282
|
+
return values.map((value) => `\`${value.replaceAll("`", "'").replace(/\s+/g, " ")}\``).join(", ");
|
|
283
|
+
}
|
|
284
|
+
function evidenceLines(control) {
|
|
285
|
+
const lines = control.evidence.flatMap((evidence) => {
|
|
286
|
+
const evidenceReferences = evidence.references.length > 0 ? ` References: ${references(evidence.references)}.` : "";
|
|
287
|
+
return `- **${evidence.scope}/${evidence.type}:** ${evidence.status} \u2014 ${sentence(evidence.summary)}${evidenceReferences}`;
|
|
288
|
+
});
|
|
289
|
+
if (control.agent_evidence) {
|
|
290
|
+
lines.push(
|
|
291
|
+
`- **Agent-collected:** ${control.agent_evidence.status} \u2014 ${sentence(control.agent_evidence.summary)} References: ${references(control.agent_evidence.references)}.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (control.attestation) {
|
|
295
|
+
lines.push(
|
|
296
|
+
`- **Human-attested:** ${control.attestation.status} \u2014 ${references([control.attestation.evidence])} (owner: ${safeText(control.attestation.owner)}; reviewed: ${control.attestation.reviewed_at}).`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (control.agent_evidence && control.attestation && control.agent_evidence.status !== "unknown" && control.attestation.status !== "unknown" && control.agent_evidence.status !== control.attestation.status) {
|
|
300
|
+
lines.push(
|
|
301
|
+
"- **Conflict:** agent-collected and human-attested statuses disagree; fail closed."
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return lines;
|
|
305
|
+
}
|
|
306
|
+
function appendControlDetails(lines, heading, controls) {
|
|
307
|
+
lines.push("", `## ${heading}`, "");
|
|
308
|
+
if (controls.length === 0) {
|
|
309
|
+
lines.push("None.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
for (const control of controls) {
|
|
313
|
+
lines.push(
|
|
314
|
+
`### ${statusIcon[control.status]} ${control.id} \u2014 ${control.title}`,
|
|
315
|
+
"",
|
|
316
|
+
`**Risk:** ${control.risk}`,
|
|
317
|
+
"",
|
|
318
|
+
`**Improve:** ${control.remediation}`,
|
|
319
|
+
"",
|
|
320
|
+
`Evidence confidence: ${control.confidence}.`,
|
|
321
|
+
"",
|
|
322
|
+
...evidenceLines(control),
|
|
323
|
+
""
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
184
327
|
function toMarkdown(report) {
|
|
185
328
|
const target = report.profiles.find(({ id }) => id === report.target.profile);
|
|
329
|
+
const established = report.controls.filter(
|
|
330
|
+
({ status }) => status === "met" || status === "not_applicable"
|
|
331
|
+
);
|
|
332
|
+
const unresolved = report.controls.filter(
|
|
333
|
+
({ status }) => status === "not_met" || status === "unknown"
|
|
334
|
+
);
|
|
335
|
+
const repositoryGaps = unresolved.filter((control) => controlScope(control) === "repository");
|
|
336
|
+
const externalControls = unresolved.filter(
|
|
337
|
+
(control) => ["platform", "organization"].includes(controlScope(control))
|
|
338
|
+
);
|
|
339
|
+
const outcomeControls = unresolved.filter((control) => controlScope(control) === "outcome");
|
|
186
340
|
const lines = [
|
|
187
341
|
"# Agentic Development Readiness Assessment",
|
|
188
342
|
"",
|
|
189
343
|
`- Benchmark: ${report.benchmark.id} v${report.benchmark.version}`,
|
|
190
344
|
`- Repository: \`${report.target.repository}\``,
|
|
345
|
+
`- Assessment scope: **${report.target.scope}**`,
|
|
346
|
+
`- Git commit: ${report.target.git_head ? `\`${report.target.git_head}\`` : "unavailable"}`,
|
|
347
|
+
`- Working tree dirty: ${report.target.working_tree_dirty === null ? "unknown" : String(report.target.working_tree_dirty)}`,
|
|
191
348
|
`- Assessed: ${report.assessed_at}`,
|
|
192
349
|
`- Score: **${report.score.total}/${report.score.maximum} (${report.score.percentage}%)**`,
|
|
193
350
|
`- Highest readiness profile: **${report.readiness.highest_profile ?? "none"}**`,
|
|
194
351
|
`- Target \`${report.target.profile}\`: **${report.readiness.target_passed ? "PASS" : "FAIL"}**`,
|
|
195
|
-
`- Evidence: ${report.evidence_summary.
|
|
352
|
+
`- Evidence: ${report.evidence_summary.repository_detected} repository-detected, ${report.evidence_summary.agent_collected} agent-collected, ${report.evidence_summary.attested} human-attested, ${report.evidence_summary.unmet} unmet, ${report.evidence_summary.unknown} unknown`,
|
|
196
353
|
"",
|
|
197
354
|
"## Dimensions",
|
|
198
355
|
"",
|
|
@@ -200,7 +357,9 @@ function toMarkdown(report) {
|
|
|
200
357
|
"| --- | ---: | ---: |",
|
|
201
358
|
...report.dimensions.map(
|
|
202
359
|
({ title, score, controls_met: met, controls_total: total }) => `| ${title} | ${score}/4 | ${met}/${total} |`
|
|
203
|
-
)
|
|
360
|
+
),
|
|
361
|
+
"",
|
|
362
|
+
"> A dimension earns only consecutive levels. Controls met above the first gap remain visible but do not increase its score."
|
|
204
363
|
];
|
|
205
364
|
if (target && !target.passed) {
|
|
206
365
|
lines.push("", "## Target-profile blockers", "");
|
|
@@ -210,27 +369,22 @@ function toMarkdown(report) {
|
|
|
210
369
|
);
|
|
211
370
|
}
|
|
212
371
|
}
|
|
213
|
-
lines.push("", "##
|
|
214
|
-
|
|
215
|
-
({ status }) => status === "not_met" || status === "unknown"
|
|
216
|
-
);
|
|
217
|
-
if (actionControls.length === 0) {
|
|
372
|
+
lines.push("", "## Established controls", "");
|
|
373
|
+
if (established.length === 0) {
|
|
218
374
|
lines.push("None.");
|
|
219
375
|
} else {
|
|
220
|
-
|
|
376
|
+
lines.push("| Control | Level | Evidence scope | Confidence |", "| --- | ---: | --- | --- |");
|
|
377
|
+
for (const control of established) {
|
|
221
378
|
lines.push(
|
|
222
|
-
|
|
223
|
-
"",
|
|
224
|
-
`**Risk:** ${control.risk}`,
|
|
225
|
-
"",
|
|
226
|
-
`**Improve:** ${control.remediation}`,
|
|
227
|
-
"",
|
|
228
|
-
`Evidence confidence: ${control.confidence}.`,
|
|
229
|
-
""
|
|
379
|
+
`| ${control.id} \u2014 ${control.title.replaceAll("|", "\\|")} | ${control.level} | ${controlScope(control)} | ${control.confidence} |`
|
|
230
380
|
);
|
|
231
381
|
}
|
|
232
382
|
}
|
|
383
|
+
appendControlDetails(lines, "Repository evidence gaps", repositoryGaps);
|
|
384
|
+
appendControlDetails(lines, "External controls not established", externalControls);
|
|
385
|
+
appendControlDetails(lines, "Outcome evidence not established", outcomeControls);
|
|
233
386
|
lines.push(
|
|
387
|
+
"",
|
|
234
388
|
"## Limitations",
|
|
235
389
|
"",
|
|
236
390
|
...report.limitations.map((limitation) => `- ${limitation}`),
|
|
@@ -239,143 +393,332 @@ function toMarkdown(report) {
|
|
|
239
393
|
return lines.join("\n");
|
|
240
394
|
}
|
|
241
395
|
|
|
396
|
+
// src/repository.ts
|
|
397
|
+
import { execFile } from "child_process";
|
|
398
|
+
import { realpath } from "fs/promises";
|
|
399
|
+
import { isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
400
|
+
import { promisify } from "util";
|
|
401
|
+
var execFileAsync = promisify(execFile);
|
|
402
|
+
var generatedEvidenceIgnores = [
|
|
403
|
+
"**/.git/**",
|
|
404
|
+
"**/node_modules/**",
|
|
405
|
+
"**/dist/**",
|
|
406
|
+
"**/coverage/**",
|
|
407
|
+
"**/.agentic/reports/**",
|
|
408
|
+
"**/.agentic/report*.json",
|
|
409
|
+
"**/.agentic/agentic-readiness*.json",
|
|
410
|
+
"**/.agentic/agentic-readiness*.md",
|
|
411
|
+
"**/.agentic/attestations.*",
|
|
412
|
+
"**/.agentic/agent-evidence.*",
|
|
413
|
+
"**/.agentic/evidence-request.*"
|
|
414
|
+
];
|
|
415
|
+
function relativePathWithin(root, path) {
|
|
416
|
+
const candidate = relative(root, path);
|
|
417
|
+
if (candidate === "") return candidate;
|
|
418
|
+
if (candidate === ".." || candidate.startsWith(`..${sep}`) || isAbsolute(candidate)) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
return candidate;
|
|
422
|
+
}
|
|
423
|
+
function normalizeExcludedPath(path, requestedRoot, canonicalRoot) {
|
|
424
|
+
const absolute = isAbsolute(path) ? resolve2(path) : resolve2(requestedRoot, path);
|
|
425
|
+
return relativePathWithin(requestedRoot, absolute) ?? relativePathWithin(canonicalRoot, absolute);
|
|
426
|
+
}
|
|
427
|
+
function sanitizeRemote(remote) {
|
|
428
|
+
if (!remote) return null;
|
|
429
|
+
try {
|
|
430
|
+
const url = new URL(remote);
|
|
431
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
432
|
+
url.username = "";
|
|
433
|
+
url.password = "";
|
|
434
|
+
} else if (url.password) {
|
|
435
|
+
url.password = "";
|
|
436
|
+
}
|
|
437
|
+
return url.toString();
|
|
438
|
+
} catch {
|
|
439
|
+
return remote;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async function git(repo, args) {
|
|
443
|
+
try {
|
|
444
|
+
const { stdout } = await execFileAsync("git", ["-C", repo, ...args], {
|
|
445
|
+
encoding: "utf8",
|
|
446
|
+
maxBuffer: 1e7
|
|
447
|
+
});
|
|
448
|
+
return stdout;
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function createRepositoryContext(repository, scope, excludedPaths = []) {
|
|
454
|
+
const requestedRoot = resolve2(repository);
|
|
455
|
+
const root = await realpath(requestedRoot);
|
|
456
|
+
const [headOutput, remoteOutput, statusOutput] = await Promise.all([
|
|
457
|
+
git(root, ["rev-parse", "HEAD"]),
|
|
458
|
+
git(root, ["config", "--get", "remote.origin.url"]),
|
|
459
|
+
git(root, ["status", "--porcelain"])
|
|
460
|
+
]);
|
|
461
|
+
let includedPaths = null;
|
|
462
|
+
if (scope === "tracked") {
|
|
463
|
+
const tracked = await git(root, ["ls-files", "-z", "--cached"]);
|
|
464
|
+
if (tracked === null) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
"Tracked assessment requires a Git worktree. Use --scope workspace for a provisional filesystem assessment."
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
includedPaths = new Set(tracked.split("\0").filter((path) => path.length > 0));
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
metadata: {
|
|
473
|
+
root,
|
|
474
|
+
scope,
|
|
475
|
+
git_head: headOutput?.trim() || null,
|
|
476
|
+
git_remote: sanitizeRemote(remoteOutput?.trim() || null),
|
|
477
|
+
working_tree_dirty: statusOutput === null ? null : statusOutput.length > 0
|
|
478
|
+
},
|
|
479
|
+
includedPaths,
|
|
480
|
+
excludedPaths: new Set(
|
|
481
|
+
excludedPaths.flatMap((path) => {
|
|
482
|
+
const normalized = normalizeExcludedPath(path, requestedRoot, root);
|
|
483
|
+
return normalized === null ? [] : [normalized];
|
|
484
|
+
})
|
|
485
|
+
)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function repositoryEvidenceTarget(metadata) {
|
|
489
|
+
return {
|
|
490
|
+
repository: metadata.git_remote ?? metadata.root,
|
|
491
|
+
git_head: metadata.git_head
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
242
495
|
// src/evidence.ts
|
|
243
|
-
import { lstat, readFile as readFile2, realpath, stat } from "fs/promises";
|
|
244
|
-
import {
|
|
496
|
+
import { lstat, readFile as readFile2, realpath as realpath2, stat } from "fs/promises";
|
|
497
|
+
import { resolve as resolve3, sep as sep2 } from "path";
|
|
245
498
|
import fg2 from "fast-glob";
|
|
246
|
-
var ignored = ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/coverage/**"];
|
|
247
499
|
var maxContentFileBytes = 512e3;
|
|
248
500
|
var maxContentFiles = 250;
|
|
249
501
|
var maxContentTotalBytes = 5e6;
|
|
250
|
-
async function matches(
|
|
251
|
-
|
|
252
|
-
cwd:
|
|
502
|
+
async function matches(context, patterns) {
|
|
503
|
+
const found = await fg2(patterns, {
|
|
504
|
+
cwd: context.metadata.root,
|
|
253
505
|
dot: true,
|
|
254
|
-
onlyFiles:
|
|
506
|
+
onlyFiles: true,
|
|
255
507
|
unique: true,
|
|
256
508
|
followSymbolicLinks: false,
|
|
257
|
-
ignore:
|
|
258
|
-
})
|
|
509
|
+
ignore: generatedEvidenceIgnores
|
|
510
|
+
});
|
|
511
|
+
return found.filter((path) => context.includedPaths === null || context.includedPaths.has(path)).filter((path) => !context.excludedPaths.has(path)).sort();
|
|
259
512
|
}
|
|
260
|
-
async function
|
|
261
|
-
|
|
513
|
+
async function safeFileSize(root, path) {
|
|
514
|
+
try {
|
|
515
|
+
const requestedPath = resolve3(root, path);
|
|
516
|
+
if ((await lstat(requestedPath)).isSymbolicLink()) return null;
|
|
517
|
+
const canonicalPath = await realpath2(requestedPath);
|
|
518
|
+
if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${sep2}`)) return null;
|
|
519
|
+
const metadata = await stat(canonicalPath);
|
|
520
|
+
return metadata.isFile() ? metadata.size : null;
|
|
521
|
+
} catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async function nonEmptyMatches(context, patterns, minBytes) {
|
|
526
|
+
const found = await matches(context, patterns);
|
|
527
|
+
const qualifying = await Promise.all(
|
|
528
|
+
found.map(async (path) => ({ path, size: await safeFileSize(context.metadata.root, path) }))
|
|
529
|
+
);
|
|
530
|
+
return qualifying.filter(({ size }) => size !== null && size >= minBytes).map(({ path }) => path);
|
|
531
|
+
}
|
|
532
|
+
async function evaluatePathAny(context, check) {
|
|
533
|
+
const found = await nonEmptyMatches(context, check.patterns, check.min_bytes);
|
|
262
534
|
return result(
|
|
263
535
|
check.type,
|
|
536
|
+
check.scope,
|
|
264
537
|
found.length > 0 ? "met" : "not_met",
|
|
265
|
-
`${found.length} matching
|
|
538
|
+
`${found.length} safe, non-empty matching file(s)`,
|
|
266
539
|
found
|
|
267
540
|
);
|
|
268
541
|
}
|
|
269
|
-
async function evaluatePathAll(
|
|
270
|
-
const groups = await Promise.all(
|
|
542
|
+
async function evaluatePathAll(context, check) {
|
|
543
|
+
const groups = await Promise.all(
|
|
544
|
+
check.patterns.map(async (pattern) => nonEmptyMatches(context, [pattern], check.min_bytes))
|
|
545
|
+
);
|
|
271
546
|
const missing = check.patterns.filter((_, index) => groups[index]?.length === 0);
|
|
272
547
|
const found = [...new Set(groups.flat())].sort();
|
|
273
548
|
return result(
|
|
274
549
|
check.type,
|
|
550
|
+
check.scope,
|
|
275
551
|
missing.length === 0 ? "met" : "not_met",
|
|
276
|
-
missing.length === 0 ? "Every required
|
|
552
|
+
missing.length === 0 ? "Every required pattern matched a safe, non-empty file" : `Missing non-empty patterns: ${missing.join(", ")}`,
|
|
277
553
|
found
|
|
278
554
|
);
|
|
279
555
|
}
|
|
280
|
-
async function readSearchableFiles(
|
|
281
|
-
const root =
|
|
282
|
-
const paths = (await matches(
|
|
556
|
+
async function readSearchableFiles(context, patterns) {
|
|
557
|
+
const root = context.metadata.root;
|
|
558
|
+
const paths = (await matches(context, patterns)).slice(0, maxContentFiles);
|
|
283
559
|
const files = [];
|
|
284
560
|
let totalBytes = 0;
|
|
285
561
|
for (const path of paths) {
|
|
286
562
|
try {
|
|
287
|
-
const requestedPath =
|
|
563
|
+
const requestedPath = resolve3(root, path);
|
|
288
564
|
if ((await lstat(requestedPath)).isSymbolicLink()) continue;
|
|
289
|
-
const canonicalPath = await
|
|
290
|
-
if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${
|
|
565
|
+
const canonicalPath = await realpath2(requestedPath);
|
|
566
|
+
if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${sep2}`)) continue;
|
|
291
567
|
const metadata = await stat(canonicalPath);
|
|
292
|
-
if (!metadata.isFile() || metadata.size > maxContentFileBytes || totalBytes + metadata.size > maxContentTotalBytes) {
|
|
568
|
+
if (!metadata.isFile() || metadata.size === 0 || metadata.size > maxContentFileBytes || totalBytes + metadata.size > maxContentTotalBytes) {
|
|
293
569
|
continue;
|
|
294
570
|
}
|
|
571
|
+
const text = (await readFile2(canonicalPath, "utf8")).toLowerCase();
|
|
572
|
+
if (isGeneratedAssessment(text)) continue;
|
|
295
573
|
totalBytes += metadata.size;
|
|
296
|
-
files.push({ path, text
|
|
574
|
+
files.push({ path, text });
|
|
297
575
|
} catch {
|
|
298
576
|
}
|
|
299
577
|
}
|
|
300
578
|
return files;
|
|
301
579
|
}
|
|
302
|
-
|
|
303
|
-
const
|
|
580
|
+
function isGeneratedAssessment(text) {
|
|
581
|
+
const normalized = text.trimStart();
|
|
582
|
+
if (normalized.startsWith("# agentic development readiness assessment")) return true;
|
|
583
|
+
if (!normalized.startsWith("{")) return false;
|
|
584
|
+
try {
|
|
585
|
+
const candidate = JSON.parse(normalized);
|
|
586
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
|
587
|
+
const report = candidate;
|
|
588
|
+
const benchmark = report.benchmark;
|
|
589
|
+
return Boolean(benchmark) && typeof benchmark === "object" && !Array.isArray(benchmark) && benchmark.id === "agentic-development-readiness" && typeof report.assessed_at === "string" && Array.isArray(report.controls);
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function evaluateLegacyContent(context, check) {
|
|
595
|
+
const files = await readSearchableFiles(context, check.files);
|
|
596
|
+
const matchingFiles = files.filter(
|
|
597
|
+
({ text }) => check.needles.some((needle) => text.includes(needle.toLowerCase()))
|
|
598
|
+
);
|
|
304
599
|
const matchedNeedles = check.needles.filter(
|
|
305
600
|
(needle) => files.some(({ text }) => text.includes(needle.toLowerCase()))
|
|
306
601
|
);
|
|
307
602
|
const passed = check.type === "content_any" ? matchedNeedles.length > 0 : matchedNeedles.length === check.needles.length;
|
|
308
603
|
return result(
|
|
309
604
|
check.type,
|
|
605
|
+
check.scope,
|
|
310
606
|
passed ? "met" : "not_met",
|
|
311
|
-
`Matched ${matchedNeedles.length}/${check.needles.length}
|
|
312
|
-
|
|
607
|
+
`Matched ${matchedNeedles.length}/${check.needles.length} term(s) across ${files.length} candidate file(s)`,
|
|
608
|
+
matchingFiles.map(({ path }) => path)
|
|
313
609
|
);
|
|
314
610
|
}
|
|
315
|
-
async function
|
|
316
|
-
const
|
|
317
|
-
const
|
|
611
|
+
async function evaluateContentTerms(context, check) {
|
|
612
|
+
const files = await readSearchableFiles(context, check.files);
|
|
613
|
+
const matchesByFile = files.map(({ path, text }) => ({
|
|
614
|
+
path,
|
|
615
|
+
matched: check.terms.filter((term) => containsTerm(text, term)).length,
|
|
616
|
+
requiredMatched: check.required_any_terms?.filter((term) => containsTerm(text, term)).length ?? 0
|
|
617
|
+
}));
|
|
618
|
+
const qualifying = matchesByFile.filter(
|
|
619
|
+
({ matched, requiredMatched }) => matched >= check.min_terms && (check.required_any_terms === void 0 || requiredMatched > 0)
|
|
620
|
+
);
|
|
621
|
+
const strongest = matchesByFile.reduce((maximum, file) => Math.max(maximum, file.matched), 0);
|
|
622
|
+
const strongestRequired = matchesByFile.reduce(
|
|
623
|
+
(maximum, file) => Math.max(maximum, file.requiredMatched),
|
|
624
|
+
0
|
|
625
|
+
);
|
|
626
|
+
const requiredSummary = check.required_any_terms ? `; strongest required match ${strongestRequired}/${check.required_any_terms.length}` : "";
|
|
627
|
+
return result(
|
|
628
|
+
check.type,
|
|
629
|
+
check.scope,
|
|
630
|
+
qualifying.length > 0 ? "met" : "not_met",
|
|
631
|
+
`${qualifying.length} qualifying file(s); strongest co-located match ${strongest}/${check.terms.length} term(s)${requiredSummary} across ${files.length} candidate file(s); threshold ${check.min_terms}`,
|
|
632
|
+
qualifying.map(({ path }) => path)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
function containsTerm(text, term) {
|
|
636
|
+
const pattern = term.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s_-]+");
|
|
637
|
+
return new RegExp(`(^|[^a-z0-9])${pattern}(?=$|[^a-z0-9])`, "i").test(text);
|
|
638
|
+
}
|
|
639
|
+
async function evaluateMaxBytes(context, check) {
|
|
640
|
+
const paths = await matches(context, check.patterns);
|
|
318
641
|
let total = 0;
|
|
319
|
-
|
|
642
|
+
const inspected = [];
|
|
320
643
|
for (const path of paths) {
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
const passed = inspected > 0 && total <= check.max_bytes;
|
|
644
|
+
const size = await safeFileSize(context.metadata.root, path);
|
|
645
|
+
if (size === null) continue;
|
|
646
|
+
total += size;
|
|
647
|
+
inspected.push(path);
|
|
648
|
+
}
|
|
649
|
+
const passed = inspected.length > 0 && total <= check.max_bytes;
|
|
329
650
|
return result(
|
|
330
651
|
check.type,
|
|
652
|
+
check.scope,
|
|
331
653
|
passed ? "met" : "not_met",
|
|
332
|
-
`${total} byte(s) across ${inspected} safe matching file(s); maximum ${check.max_bytes}`,
|
|
333
|
-
|
|
654
|
+
`${total} byte(s) across ${inspected.length} safe matching file(s); maximum ${check.max_bytes}`,
|
|
655
|
+
inspected
|
|
334
656
|
);
|
|
335
657
|
}
|
|
336
|
-
function result(type, status, summary,
|
|
337
|
-
return { type, status, summary, references };
|
|
658
|
+
function result(type, scope, status, summary, references2) {
|
|
659
|
+
return { type, scope, status, summary, references: references2 };
|
|
338
660
|
}
|
|
339
|
-
async function evaluateCheck(
|
|
661
|
+
async function evaluateCheck(context, check) {
|
|
340
662
|
switch (check.type) {
|
|
341
663
|
case "path_any":
|
|
342
|
-
return evaluatePathAny(
|
|
664
|
+
return evaluatePathAny(context, check);
|
|
343
665
|
case "path_all":
|
|
344
|
-
return evaluatePathAll(
|
|
666
|
+
return evaluatePathAll(context, check);
|
|
345
667
|
case "content_any":
|
|
346
668
|
case "content_all":
|
|
347
|
-
return
|
|
669
|
+
return evaluateLegacyContent(context, check);
|
|
670
|
+
case "content_terms":
|
|
671
|
+
return evaluateContentTerms(context, check);
|
|
348
672
|
case "max_bytes":
|
|
349
|
-
return evaluateMaxBytes(
|
|
673
|
+
return evaluateMaxBytes(context, check);
|
|
350
674
|
case "manual":
|
|
351
|
-
return result("manual", "unknown", check.prompt, []);
|
|
675
|
+
return result("manual", check.scope, "unknown", check.prompt, []);
|
|
352
676
|
}
|
|
353
677
|
}
|
|
354
678
|
function activeAttestation(control, attestations, now) {
|
|
679
|
+
if (!control.allow_attestation) return null;
|
|
355
680
|
const attestation = attestations?.attestations[control.id];
|
|
356
681
|
if (!attestation) return null;
|
|
357
682
|
if (attestation.expires_at && new Date(attestation.expires_at) < now) return null;
|
|
358
683
|
if (attestation.status === "not_applicable" && !control.allow_not_applicable) return null;
|
|
359
684
|
return attestation;
|
|
360
685
|
}
|
|
361
|
-
|
|
686
|
+
function activeAgentEvidence(control, claim, now) {
|
|
687
|
+
if (!claim || !control.allow_agent_evidence) return null;
|
|
688
|
+
if (new Date(claim.expires_at) < now) return null;
|
|
689
|
+
return claim;
|
|
690
|
+
}
|
|
691
|
+
async function evaluateControl(context, control, attestations, agentClaim, now = /* @__PURE__ */ new Date()) {
|
|
362
692
|
const evidence = await Promise.all(
|
|
363
|
-
control.evidence.map(async (check) => evaluateCheck(
|
|
693
|
+
control.evidence.map(async (check) => evaluateCheck(context, check))
|
|
364
694
|
);
|
|
365
695
|
const attestation = activeAttestation(control, attestations, now);
|
|
696
|
+
const agentEvidence = activeAgentEvidence(control, agentClaim, now);
|
|
366
697
|
const checksPassed = evidence.every(({ status: status2 }) => status2 === "met");
|
|
367
698
|
const hasManualCheck = control.evidence.some(({ type }) => type === "manual");
|
|
368
699
|
let status = checksPassed ? "met" : "not_met";
|
|
369
|
-
let confidence = checksPassed && !hasManualCheck ? "
|
|
370
|
-
|
|
700
|
+
let confidence = checksPassed && !hasManualCheck ? "repository-detected" : "none";
|
|
701
|
+
const attestationStatus = attestation?.status === "unknown" ? null : attestation?.status ?? null;
|
|
702
|
+
const hasExternalConflict = agentEvidence !== null && agentEvidence.status !== "unknown" && attestationStatus !== null && agentEvidence.status !== attestationStatus;
|
|
703
|
+
if (checksPassed && !hasManualCheck) {
|
|
704
|
+
} else if (hasExternalConflict) {
|
|
705
|
+
status = "unknown";
|
|
706
|
+
confidence = "none";
|
|
707
|
+
} else if (agentEvidence && agentEvidence.status !== "unknown") {
|
|
708
|
+
status = agentEvidence.status;
|
|
709
|
+
confidence = "agent-collected";
|
|
710
|
+
} else if (attestation?.status === "not_applicable") {
|
|
371
711
|
status = "not_applicable";
|
|
372
712
|
confidence = "attested";
|
|
373
|
-
} else if (attestation?.status === "met"
|
|
713
|
+
} else if (attestation?.status === "met") {
|
|
374
714
|
status = "met";
|
|
375
|
-
confidence =
|
|
715
|
+
confidence = "attested";
|
|
376
716
|
} else if (attestation?.status === "not_met" || attestation?.status === "unknown") {
|
|
377
717
|
status = attestation.status;
|
|
378
718
|
confidence = "attested";
|
|
719
|
+
} else if (agentEvidence?.status === "unknown") {
|
|
720
|
+
status = "unknown";
|
|
721
|
+
confidence = "agent-collected";
|
|
379
722
|
} else if (evidence.some(({ status: checkStatus }) => checkStatus === "unknown")) {
|
|
380
723
|
status = "unknown";
|
|
381
724
|
}
|
|
@@ -389,6 +732,7 @@ async function evaluateControl(repo, control, attestations, now = /* @__PURE__ *
|
|
|
389
732
|
status,
|
|
390
733
|
confidence,
|
|
391
734
|
evidence,
|
|
735
|
+
agent_evidence: agentEvidence,
|
|
392
736
|
attestation,
|
|
393
737
|
remediation: control.remediation
|
|
394
738
|
};
|
|
@@ -429,15 +773,27 @@ function assessProfiles(benchmark, dimensions, controls) {
|
|
|
429
773
|
return { id: profile.id, title: profile.title, passed: blockers.length === 0, blockers };
|
|
430
774
|
});
|
|
431
775
|
}
|
|
432
|
-
async function assess(repo, benchmark, catalog, profileId,
|
|
776
|
+
async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
433
777
|
const profile = benchmark.readiness_profiles.find(({ id }) => id === profileId);
|
|
434
778
|
if (!profile) {
|
|
435
779
|
throw new Error(
|
|
436
780
|
`Unknown profile ${profileId}. Choose: ${benchmark.readiness_profiles.map(({ id }) => id).join(", ")}`
|
|
437
781
|
);
|
|
438
782
|
}
|
|
783
|
+
const scope = options.scope ?? "tracked";
|
|
784
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
785
|
+
const context = await createRepositoryContext(repo, scope, options.excludedPaths);
|
|
786
|
+
validateAgentEvidence(benchmark, catalog, context, options.agentEvidence ?? null, now);
|
|
439
787
|
const controls = await Promise.all(
|
|
440
|
-
catalog.map(
|
|
788
|
+
catalog.map(
|
|
789
|
+
async (control) => evaluateControl(
|
|
790
|
+
context,
|
|
791
|
+
control,
|
|
792
|
+
options.attestations ?? null,
|
|
793
|
+
options.agentEvidence?.claims[control.id] ?? null,
|
|
794
|
+
now
|
|
795
|
+
)
|
|
796
|
+
)
|
|
441
797
|
);
|
|
442
798
|
const dimensions = benchmark.dimensions.map(({ id, title }) => {
|
|
443
799
|
const dimensionControls = controls.filter((control) => control.dimension === id);
|
|
@@ -454,37 +810,90 @@ async function assess(repo, benchmark, catalog, profileId, attestations, now = /
|
|
|
454
810
|
const highestProfile = [...profiles].reverse().find(({ passed }) => passed)?.id ?? null;
|
|
455
811
|
const targetPassed = profiles.find(({ id }) => id === profileId)?.passed ?? false;
|
|
456
812
|
return {
|
|
457
|
-
schema_version: "0.
|
|
813
|
+
schema_version: "0.2.0",
|
|
458
814
|
benchmark: { id: benchmark.id, version: benchmark.version },
|
|
459
|
-
target: {
|
|
815
|
+
target: {
|
|
816
|
+
repository: repo,
|
|
817
|
+
profile: profileId,
|
|
818
|
+
scope,
|
|
819
|
+
git_head: context.metadata.git_head,
|
|
820
|
+
git_remote: context.metadata.git_remote,
|
|
821
|
+
working_tree_dirty: context.metadata.working_tree_dirty
|
|
822
|
+
},
|
|
460
823
|
assessed_at: now.toISOString(),
|
|
461
824
|
score: { total, maximum: 40, percentage: Math.round(total / 40 * 100) },
|
|
462
825
|
evidence_summary: {
|
|
463
|
-
|
|
464
|
-
({ confidence, status }) => confidence === "
|
|
826
|
+
repository_detected: controls.filter(
|
|
827
|
+
({ confidence, status }) => confidence === "repository-detected" && status === "met"
|
|
828
|
+
).length,
|
|
829
|
+
agent_collected: controls.filter(
|
|
830
|
+
({ confidence, status }) => confidence === "agent-collected" && status === "met"
|
|
465
831
|
).length,
|
|
466
832
|
attested: controls.filter(
|
|
467
833
|
({ confidence, status }) => confidence === "attested" && status === "met"
|
|
468
834
|
).length,
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
).length
|
|
835
|
+
unmet: controls.filter(({ status }) => status === "not_met").length,
|
|
836
|
+
unknown: controls.filter(({ status }) => status === "unknown").length
|
|
472
837
|
},
|
|
473
838
|
dimensions,
|
|
474
839
|
controls,
|
|
475
840
|
profiles,
|
|
476
841
|
readiness: { highest_profile: highestProfile, target_passed: targetPassed },
|
|
477
842
|
limitations: [
|
|
478
|
-
"
|
|
479
|
-
"
|
|
843
|
+
scope === "tracked" ? "Tracked mode considers only Git-tracked paths, using current working-tree contents; uncommitted edits to tracked files can affect the result." : "Workspace mode includes untracked local files and is provisional; do not compare it directly with tracked-mode reports.",
|
|
844
|
+
"Repository-detected evidence proves a qualifying artifact match, not consistent practice or external enforcement.",
|
|
845
|
+
"Agent-collected evidence and human attestations are reported separately and are not independently verified.",
|
|
480
846
|
"This assessment does not grant production access, deployment authority, or certification."
|
|
481
847
|
]
|
|
482
848
|
};
|
|
483
849
|
}
|
|
850
|
+
function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
851
|
+
if (!evidence) return;
|
|
852
|
+
if (evidence.benchmark_version !== benchmark.version) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`Agent evidence benchmark ${evidence.benchmark_version} does not match ${benchmark.version}`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
const expectedTarget = repositoryEvidenceTarget(context.metadata);
|
|
858
|
+
if (evidence.target.repository !== expectedTarget.repository) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`Agent evidence target ${evidence.target.repository} does not match ${expectedTarget.repository}`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (evidence.target.git_head !== expectedTarget.git_head) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`Agent evidence commit ${evidence.target.git_head ?? "unavailable"} does not match ${expectedTarget.git_head ?? "an unavailable Git commit"}`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
const controls = new Map(catalog.map((control) => [control.id, control]));
|
|
869
|
+
if (Object.keys(evidence.claims).length > 0 && (evidence.collector.name.startsWith("TODO") || evidence.collector.version.startsWith("TODO"))) {
|
|
870
|
+
throw new Error("Agent evidence with claims must identify the collector name and version");
|
|
871
|
+
}
|
|
872
|
+
for (const [controlId, claim] of Object.entries(evidence.claims)) {
|
|
873
|
+
const control = controls.get(controlId);
|
|
874
|
+
if (!control) throw new Error(`Agent evidence references unknown control ${controlId}`);
|
|
875
|
+
if (!control.allow_agent_evidence) {
|
|
876
|
+
throw new Error(`${controlId} does not permit agent-collected evidence`);
|
|
877
|
+
}
|
|
878
|
+
const allowedScopes = control.evidence.filter(({ type }) => type === "manual").map(({ scope: evidenceScope }) => evidenceScope);
|
|
879
|
+
if (!allowedScopes.includes(claim.scope)) {
|
|
880
|
+
throw new Error(`${controlId} does not accept ${claim.scope} evidence`);
|
|
881
|
+
}
|
|
882
|
+
if (new Date(claim.collected_at) > new Date(claim.expires_at)) {
|
|
883
|
+
throw new Error(`${controlId} expires before it was collected`);
|
|
884
|
+
}
|
|
885
|
+
if (new Date(claim.collected_at) > now) {
|
|
886
|
+
throw new Error(`${controlId} has a future collection timestamp`);
|
|
887
|
+
}
|
|
888
|
+
if (claim.summary.startsWith("TODO") || claim.references.some((reference) => reference.startsWith("TODO"))) {
|
|
889
|
+
throw new Error(`${controlId} contains unresolved TODO evidence`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
484
893
|
|
|
485
894
|
// src/cli.ts
|
|
486
895
|
var program = new Command();
|
|
487
|
-
program.name("agentic-scorecard").description("Evidence-backed readiness assessment for agentic software development harnesses").version("0.
|
|
896
|
+
program.name("agentic-scorecard").description("Evidence-backed readiness assessment for agentic software development harnesses").version("0.2.0");
|
|
488
897
|
program.command("validate").description("Validate the bundled benchmark catalog").action(async () => {
|
|
489
898
|
const { benchmark, controls } = await loadBenchmark();
|
|
490
899
|
process.stdout.write(
|
|
@@ -510,7 +919,7 @@ ${control.evidence.map((check) => `- ${stringify(check).trim().replaceAll("\n",
|
|
|
510
919
|
);
|
|
511
920
|
});
|
|
512
921
|
program.command("init").argument("[repository]", "repository to initialize", ".").option("--force", "replace an existing attestation file", false).description("Create a manual-attestation template").action(async (repository, options) => {
|
|
513
|
-
const repo =
|
|
922
|
+
const repo = resolve4(repository);
|
|
514
923
|
const path = join2(repo, ".agentic", "attestations.yaml");
|
|
515
924
|
if (!options.force) {
|
|
516
925
|
try {
|
|
@@ -521,6 +930,9 @@ program.command("init").argument("[repository]", "repository to initialize", "."
|
|
|
521
930
|
}
|
|
522
931
|
}
|
|
523
932
|
const { benchmark, controls } = await loadBenchmark();
|
|
933
|
+
const reviewedAt = /* @__PURE__ */ new Date();
|
|
934
|
+
const expiresAt = new Date(reviewedAt);
|
|
935
|
+
expiresAt.setDate(expiresAt.getDate() + 90);
|
|
524
936
|
const attestations = Object.fromEntries(
|
|
525
937
|
controls.filter((control) => control.evidence.some(({ type }) => type === "manual")).map((control) => {
|
|
526
938
|
const manualCheck = control.evidence.find(
|
|
@@ -532,8 +944,8 @@ program.command("init").argument("[repository]", "repository to initialize", "."
|
|
|
532
944
|
status: "unknown",
|
|
533
945
|
evidence: `TODO: ${manualCheck?.prompt ?? control.outcome}`,
|
|
534
946
|
owner: "TODO",
|
|
535
|
-
reviewed_at:
|
|
536
|
-
expires_at:
|
|
947
|
+
reviewed_at: reviewedAt.toISOString().slice(0, 10),
|
|
948
|
+
expires_at: expiresAt.toISOString().slice(0, 10)
|
|
537
949
|
}
|
|
538
950
|
];
|
|
539
951
|
})
|
|
@@ -541,30 +953,123 @@ program.command("init").argument("[repository]", "repository to initialize", "."
|
|
|
541
953
|
await mkdir(dirname2(path), { recursive: true });
|
|
542
954
|
await writeFile(
|
|
543
955
|
path,
|
|
544
|
-
`# Claims are
|
|
956
|
+
`# Claims are visibly human-attested. Link durable evidence; do not paste secrets.
|
|
545
957
|
${stringify({ benchmark_version: benchmark.version, attestations })}`,
|
|
546
958
|
"utf8"
|
|
547
959
|
);
|
|
548
960
|
process.stdout.write(`Created ${path}
|
|
549
961
|
`);
|
|
550
962
|
});
|
|
551
|
-
program.command("
|
|
963
|
+
program.command("init-evidence").argument("[repository]", "repository to prepare external evidence for", ".").option("--output <path>", "agent evidence bundle path").option("--request-output <path>", "human-readable evidence request path").option("--force", "replace an existing agent evidence bundle", false).description("Create a target-bound template for agent-collected external evidence").action(
|
|
964
|
+
async (repository, options) => {
|
|
965
|
+
const repo = resolve4(repository);
|
|
966
|
+
const path = resolve4(options.output ?? join2(repo, ".agentic", "agent-evidence.yaml"));
|
|
967
|
+
const requestPath = resolve4(
|
|
968
|
+
options.requestOutput ?? join2(repo, ".agentic", "evidence-request.md")
|
|
969
|
+
);
|
|
970
|
+
if (path === requestPath) {
|
|
971
|
+
throw new Error("Agent evidence bundle and request paths must be different");
|
|
972
|
+
}
|
|
973
|
+
if (!options.force) {
|
|
974
|
+
for (const candidate of [path, requestPath]) {
|
|
975
|
+
try {
|
|
976
|
+
await readFile3(candidate, "utf8");
|
|
977
|
+
throw new Error(`${candidate} already exists; use --force to replace it`);
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error.code !== "ENOENT") throw error;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const { benchmark, controls } = await loadBenchmark();
|
|
984
|
+
const context = await createRepositoryContext(repo, "tracked").catch((error) => {
|
|
985
|
+
if (error instanceof Error && error.message.startsWith("Tracked assessment requires a Git worktree")) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
"init-evidence requires a Git worktree with a commit so the bundle can be target-bound."
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
throw error;
|
|
991
|
+
});
|
|
992
|
+
if (!context.metadata.git_head) {
|
|
993
|
+
throw new Error(
|
|
994
|
+
"init-evidence requires a Git commit so the bundle can be target-bound. Commit the assessed state and try again."
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
const eligibleControls = controls.filter(({ allow_agent_evidence: allowed }) => allowed);
|
|
998
|
+
const bundle = {
|
|
999
|
+
schema_version: "0.2.0",
|
|
1000
|
+
benchmark_version: benchmark.version,
|
|
1001
|
+
target: repositoryEvidenceTarget(context.metadata),
|
|
1002
|
+
collector: { name: "TODO: agent or adapter name", version: "TODO" },
|
|
1003
|
+
claims: {}
|
|
1004
|
+
};
|
|
1005
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
1006
|
+
await writeFile(
|
|
1007
|
+
path,
|
|
1008
|
+
`# Use authorized read-only tools. Do not paste secrets or raw sensitive content.
|
|
1009
|
+
${stringify(bundle)}`,
|
|
1010
|
+
"utf8"
|
|
1011
|
+
);
|
|
1012
|
+
await mkdir(dirname2(requestPath), { recursive: true });
|
|
1013
|
+
await writeFile(
|
|
1014
|
+
requestPath,
|
|
1015
|
+
[
|
|
1016
|
+
"# ADRB v0.2 external evidence request",
|
|
1017
|
+
"",
|
|
1018
|
+
`- Repository: ${bundle.target.repository}`,
|
|
1019
|
+
`- Git commit: ${bundle.target.git_head ?? "unavailable"}`,
|
|
1020
|
+
`- Benchmark: ${benchmark.version}`,
|
|
1021
|
+
"",
|
|
1022
|
+
"Obtain authorization before accessing connected systems. Use read-only, least-privileged tools. Add only attempted claims to the bundle; errors remain `unknown`. Never paste secrets or raw sensitive content.",
|
|
1023
|
+
"",
|
|
1024
|
+
...eligibleControls.flatMap((control) => {
|
|
1025
|
+
const manualCheck = control.evidence.find(
|
|
1026
|
+
(check) => check.type === "manual"
|
|
1027
|
+
);
|
|
1028
|
+
return [
|
|
1029
|
+
`## ${control.id} \u2014 ${control.title}`,
|
|
1030
|
+
"",
|
|
1031
|
+
`- Scope: ${manualCheck?.scope ?? "organization"}`,
|
|
1032
|
+
`- Request: ${manualCheck?.prompt ?? control.outcome}`,
|
|
1033
|
+
`- Risk: ${control.risk}`,
|
|
1034
|
+
""
|
|
1035
|
+
];
|
|
1036
|
+
})
|
|
1037
|
+
].join("\n"),
|
|
1038
|
+
"utf8"
|
|
1039
|
+
);
|
|
1040
|
+
process.stdout.write(`Created ${path}
|
|
1041
|
+
Created ${requestPath}
|
|
1042
|
+
`);
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
program.command("assess").argument("[repository]", "repository to assess", ".").option("--profile <profile>", "target autonomy profile", "pr-creation").option("--format <format>", "json or markdown", "markdown").option("--output <path>", "write the report to a file").option("--attestations <path>", "manual attestation file").option("--agent-evidence <path>", "agent-collected external evidence bundle").option("--scope <scope>", "tracked or workspace", "tracked").option("--enforce", "exit non-zero when the target profile fails", false).option("--github-output", "append summary values to $GITHUB_OUTPUT", false).description("Assess a repository using local, read-only evidence collection").action(
|
|
552
1046
|
async (repository, options) => {
|
|
553
1047
|
if (!["json", "markdown"].includes(options.format)) {
|
|
554
1048
|
throw new Error("--format must be json or markdown");
|
|
555
1049
|
}
|
|
556
|
-
|
|
1050
|
+
if (!["tracked", "workspace"].includes(options.scope)) {
|
|
1051
|
+
throw new Error("--scope must be tracked or workspace");
|
|
1052
|
+
}
|
|
1053
|
+
const repo = resolve4(repository);
|
|
557
1054
|
const { benchmark, controls } = await loadBenchmark();
|
|
558
|
-
const attestationPath =
|
|
1055
|
+
const attestationPath = resolve4(
|
|
559
1056
|
options.attestations ?? join2(repo, ".agentic", "attestations.yaml")
|
|
560
1057
|
);
|
|
561
1058
|
const attestations = await loadAttestations(attestationPath, benchmark.version);
|
|
562
|
-
const
|
|
1059
|
+
const agentEvidencePath = resolve4(
|
|
1060
|
+
options.agentEvidence ?? join2(repo, ".agentic", "agent-evidence.yaml")
|
|
1061
|
+
);
|
|
1062
|
+
const agentEvidence = await loadAgentEvidence(agentEvidencePath);
|
|
1063
|
+
const reportPath = options.output ? resolve4(options.output) : null;
|
|
1064
|
+
const report = await assess(repo, benchmark, controls, options.profile, {
|
|
1065
|
+
scope: options.scope,
|
|
1066
|
+
attestations,
|
|
1067
|
+
agentEvidence,
|
|
1068
|
+
excludedPaths: [attestationPath, agentEvidencePath, ...reportPath ? [reportPath] : []]
|
|
1069
|
+
});
|
|
563
1070
|
const output = options.format === "json" ? `${JSON.stringify(report, null, 2)}
|
|
564
1071
|
` : toMarkdown(report);
|
|
565
|
-
|
|
566
|
-
if (options.output) {
|
|
567
|
-
reportPath = resolve3(options.output);
|
|
1072
|
+
if (reportPath) {
|
|
568
1073
|
await mkdir(dirname2(reportPath), { recursive: true });
|
|
569
1074
|
await writeFile(reportPath, output, "utf8");
|
|
570
1075
|
process.stdout.write(`Wrote ${reportPath}
|