Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Candidate `artifacts` command validates actual local files with strict capacity,
CSV/JSON contracts, digest binding and cross-file summaries. Reports omit values
and local paths. Shared scans use bounded reads, metadata allowlists, streamed
enumeration, no-follow file opens and snapshot-change checks.
- `report` now requires matching plan digests when both an ActionPlan and an
ApplyReceipt are supplied. Mismatched evidence fails before rendering any
Markdown, JSON, or SARIF output, with an error that does not expose local
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ The command above verifies the included sample Skill. Replace the fixture path w

## What you get

- The local source candidate `artifacts` command independently checks actual
CSV/JSON delivery files, Receipt/file hashes and cross-file count/integer sums.
See [actual artifact checks](docs/artifact-delivery.md); it runs no Skill code.
- `verify` reviews one local Skill for provenance, target compatibility, and changes without running its scripts.
- `scan` inventories local Skills, while `compat` checks their declared features against agent profiles.
- `diff` shows the meaningful changes between two Skill versions before you accept them.
Expand Down
72 changes: 72 additions & 0 deletions docs/artifact-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Actual artifact delivery checks

The candidate source command `artifacts` checks local output files against a
versioned contract. It opens actual bytes; it does not execute a Skill, Replay,
Docker, provider or remote service. This command is not in the published npm0.1.0
package. Existing `test` preflight and Replay event evidence keep their semantics.

## Run a physical check

```bash
npm ci
npm run build
node dist/cli/index.js artifacts \
--contract fixtures/product/order-summary/contract.json \
--path fixtures/product/order-summary/artifacts --format json
```

Use `--delivery /path/to/bundle` instead of `--path` for a SkillTape source bundle
created by `verify --delivery-dir`. The checker validates its layout, exact
Receipt hash, artifact hashes/sizes, and deterministic artifact-set digest before
accepting a complete result. A consistently rewritten manifest is not signed or
authenticated evidence; the report marks provenance as not authenticated.

## Declared requirements

The [synthetic contract](../fixtures/product/order-summary/contract.json) declares:

- capacity ceilings and a complete allowlist of required files;
- exact CSV headers, scalar types, real calendar dates, row limits, unique keys,
optional expected hashes, and rejection of formula-risk text prefixes;
- JSON top-level field types, required values and optional rejection of extras;
- independently recomputed CSV record counts and safe integer sums compared with
declared JSON fields. Integer sums avoid unspecified floating-point tolerances.

`bytes` rules check integrity and capacity without interpreting content. JSON
checks cover declared top-level fields, not full JSON Schema or arbitrary
expressions. Contract files are limited to 64 KiB. Limits must be explicit and
within the shared 10,000-file/16 MiB-file/64 MiB-total workspace ceilings.
Duplicate JSON keys are rejected, including escaped-key aliases in metadata and
payloads. JSON depth is capped at 64 and object keys at 100,000; individual keys
are at most 1,024 characters. CSV rows stop at the declared field count instead
of allocating an array for millions of separators. Manifest order is checked
by UTF-8 path bytes. Directory allowlists use precomputed prefix sets.

The inventory is streamed and checked before payload reads. Extra paths, links,
special files, excess depth/entries and capacity overruns fail. Single-file reads
are bounded and no-follow where supported; metadata and content changes during
inspection fail. This is inspection of a stable caller-selected snapshot, not an
OS sandbox against a same-privilege process actively replacing directory parents.

CSV parsing preserves spaces and supports quoted commas, multiline values,
escaped quotes and CRLF, plus the common LF extension. The format basis is
[RFC 4180](https://www.rfc-editor.org/rfc/rfc4180.html). Text-prefix checks include
ASCII/full-width formula starters and leading controls discussed by
[OWASP CSV Injection](https://community.owasp.org/attacks/CSV_Injection).
The checker rejects risky parsed cells; it neither rewrites files nor guarantees
safe interpretation in every spreadsheet application or locale.

## Reports and exit codes

Exit0 means declared checks passed, exit1 an artifact finding, and exit2 invalid
input/contract. JSON/text reports contain relative paths, digests, sizes, record
counts and stable findings, not raw cells, JSON values, process output or host
absolute paths. Cross-file totals remain internal. Findings identify the file,
declared field and data-record number where appropriate.

The [fixture](../fixtures/product/order-summary/README.md) proves physical file
checks with fictional outputs. The complete cross-project example separately
runs a local summarizer, preserves a SkillTape bundle, passes this checker and
rejects corruption and an internally inconsistent summary in copies. None of
those checks establishes customer-data truth, model/Agent acceptance or a live
production/business integration.
9 changes: 9 additions & 0 deletions fixtures/product/order-summary/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Synthetic order summary artifacts

These fictional outputs exercise actual-file inspection, not a provider or database.
Use the source CLI with `artifacts --contract fixtures/product/order-summary/contract.json
--path fixtures/product/order-summary/artifacts --format json`.

The contract verifies exact headers, valid dates, nonnegative safe integers, unique
days, required JSON fields and schema_version1, then independently compares CSV
record counts and integer sums with the summary. Reports omit cell and JSON values.
3 changes: 3 additions & 0 deletions fixtures/product/order-summary/artifacts/metrics.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
day,orders,total_cents
2026-01-01,2,2000
2026-01-02,1,1600
6 changes: 6 additions & 0 deletions fixtures/product/order-summary/artifacts/summary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"row_count": 2,
"total_orders": 3,
"total_cents": 3600
}
78 changes: 78 additions & 0 deletions fixtures/product/order-summary/contract.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"schema": "skillsync.artifacts/v1",
"limits": {
"max_files": 8,
"max_file_bytes": 1048576,
"max_total_bytes": 8388608
},
"files": [
{
"path": "metrics.csv",
"format": "csv",
"max_rows": 1000,
"unique_by": [
"day"
],
"columns": [
{
"name": "day",
"type": "date"
},
{
"name": "orders",
"type": "integer",
"min": 0
},
{
"name": "total_cents",
"type": "integer",
"min": 0
}
]
},
{
"path": "summary.json",
"format": "json",
"fields": [
{
"name": "schema_version",
"type": "integer",
"equals": 1
},
{
"name": "row_count",
"type": "integer",
"min": 0
},
{
"name": "total_orders",
"type": "integer",
"min": 0
},
{
"name": "total_cents",
"type": "integer",
"min": 0
}
]
}
],
"checks": [
{
"type": "csv_summary",
"csv": "metrics.csv",
"json": "summary.json",
"row_count_field": "row_count",
"sums": [
{
"column": "orders",
"field": "total_orders"
},
{
"column": "total_cents",
"field": "total_cents"
}
]
}
]
}
142 changes: 142 additions & 0 deletions src/cli/commands/artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { createHash } from "node:crypto";
import { lstat, readdir } from "node:fs/promises";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { z } from "zod";

import { parseArtifactContract } from "../../domain/artifact-contract.js";
import { parseStrictJson } from "../../domain/strict-json.js";
import { inspectArtifactContent, type ArtifactFacts, type ArtifactFinding } from "../../domain/artifact-content.js";
import { redactLocalPaths } from "../../reporters/local-paths.js";
import { readWorkspaceFile, scanStagedWorkspace, WorkspaceTreeError } from "../../sandbox/workspace-tree.js";

const hash = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
const sha = z.string().regex(/^[0-9a-f]{64}$/);
const manifestSchema = z.object({ schema: z.literal("skilltape.dev/delivery/v1"),
run_id: sha, skill_hash: sha, receipt_sha256: sha, artifact_set_sha256: sha,
files: z.array(z.object({ path: z.string().min(1).max(1024), bytes: z.number().int().min(0).max(16 * 1024 * 1024), sha256: sha }).strict()).max(10_000),
}).strict();

export type ArtifactsOptions = { contract: string; delivery?: string; path?: string };
export type ArtifactReport = {
schema: "skillsync.artifacts-report/v1"; status: "passed" | "failed"; exitCode: number;
evidence: "physical-files"; execution: "not-run"; provenance: "not-authenticated";
receipt_binding: "verified" | "not-provided" | "failed";
contract_sha256?: string; artifact_set_sha256?: string;
files: Array<{ path: string; bytes: number; sha256: string; rows?: number }>;
findings: ArtifactFinding[];
};

function setHash(files: ArtifactReport["files"]): string {
const digest = createHash("sha256");
for (const file of [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) {
const size = Buffer.alloc(8); size.writeBigUInt64BE(BigInt(file.bytes));
digest.update(file.path); digest.update(Buffer.from([0])); digest.update(size); digest.update(file.sha256);
}
return digest.digest("hex");
}

function readJson(bytes: Uint8Array): unknown {
return parseStrictJson(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
}

export async function runArtifacts(options: ArtifactsOptions): Promise<ArtifactReport> {
const report: ArtifactReport = { schema: "skillsync.artifacts-report/v1", status: "failed", exitCode: 1,
evidence: "physical-files", execution: "not-run", provenance: "not-authenticated",
receipt_binding: options.delivery ? "failed" : "not-provided", files: [], findings: [] };
const issue = (code: string, path?: string, field?: string) => {
if (report.findings.length < 256) report.findings.push({ code, ...(path ? { path } : {}), ...(field ? { field } : {}) });
};
let contract;
try {
if (Boolean(options.delivery) === Boolean(options.path)) throw new Error("select one directory");
const bytes = await readWorkspaceFile(options.contract, 64 * 1024);
contract = parseArtifactContract(parseYaml(new TextDecoder("utf-8", { fatal: true }).decode(bytes), { maxAliasCount: 100, prettyErrors: false, logLevel: "silent" }));
report.contract_sha256 = hash(JSON.stringify(contract));
} catch { issue("artifact.contract-invalid"); report.exitCode = 2; return report; }
let root = options.path ?? join(options.delivery!, "artifacts");
let manifest: z.infer<typeof manifestSchema> | undefined;
let metadataHashes: { receipt: string; manifest: string } | undefined;
if (options.delivery) {
try {
const metadata = await lstat(options.delivery);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("directory invalid");
const entries = await readdir(options.delivery);
if (entries.length !== 3 || !["artifacts", "receipt.json", "delivery.json"].every(name => entries.includes(name))) {
issue("delivery.layout"); return report;
}
const manifestBytes = await readWorkspaceFile(join(options.delivery, "delivery.json"), 1024 * 1024);
const result = manifestSchema.safeParse(readJson(manifestBytes));
if (!result.success) { issue("delivery.manifest-invalid"); return report; }
manifest = result.data;
const expected = new Set(contract.files.map(file => file.path));
if (manifest.files.length !== expected.size || new Set(manifest.files.map(file => file.path)).size !== manifest.files.length ||
manifest.files.some((file, index) => index > 0 && Buffer.compare(Buffer.from(manifest!.files[index - 1].path), Buffer.from(file.path)) >= 0) ||
manifest.files.some(file => !expected.has(file.path))) { issue("delivery.file-set"); return report; }
const receiptBytes = await readWorkspaceFile(join(options.delivery, "receipt.json"), 1024 * 1024);
const receipt = readJson(receiptBytes) as Record<string, unknown>;
if (!receipt || receipt.schema !== "skilltape.dev/receipt/v1" || receipt.status !== "succeeded" ||
receipt.run_id !== manifest.run_id || receipt.skill_hash !== manifest.skill_hash || hash(receiptBytes) !== manifest.receipt_sha256) {
issue("delivery.receipt-binding"); return report;
}
report.receipt_binding = "verified";
metadataHashes = { receipt: hash(receiptBytes), manifest: hash(manifestBytes) };
} catch { issue("delivery.metadata-invalid"); return report; }
root = join(options.delivery, "artifacts");
}
const facts = new Map<string, ArtifactFacts>();
try {
const rules = new Map(contract.files.map(file => [file.path, file]));
const tree = await scanStagedWorkspace(root, {
limits: { maxFiles: contract.limits.max_files, maxFileBytes: contract.limits.max_file_bytes, maxTotalBytes: contract.limits.max_total_bytes },
allowedFiles: new Set(rules.keys()),
inspectFile(path, content, observation) {
const rule = rules.get(path)!;
const sha256 = observation.digest.slice("sha256:".length);
if (rule.sha256 && rule.sha256 !== sha256) issue("artifact.digest-mismatch", path);
const inspected = inspectArtifactContent(rule, content);
facts.set(path, inspected.facts);
for (const finding of inspected.findings) if (report.findings.length < 256) report.findings.push(finding);
report.files.push({ path, bytes: observation.bytes, sha256, ...(inspected.facts.rows !== undefined ? { rows: inspected.facts.rows } : {}) });
},
});
const found = new Set(tree.files.map(file => file.path.slice("workspace/".length)));
for (const file of contract.files) if (!found.has(file.path)) issue("artifact.missing", file.path);
} catch (error) {
report.files = [];
report.findings = [];
issue(error instanceof WorkspaceTreeError && error.code === "workspace.tree-too-large" ? "artifact.capacity" : "artifact.structure-or-snapshot");
if (error instanceof WorkspaceTreeError) report.findings.at(-1)!.message = error.message;
return report;
}
report.files.sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)));
report.artifact_set_sha256 = setHash(report.files);
if (manifest) {
if (manifest.artifact_set_sha256 !== report.artifact_set_sha256) issue("delivery.artifact-set-digest");
for (const file of report.files) {
const declared = manifest.files.find(entry => entry.path === file.path)!;
if (declared.bytes !== file.bytes || declared.sha256 !== file.sha256) issue("delivery.artifact-digest", file.path);
}
try {
const entries = await readdir(options.delivery!);
if (entries.length !== 3 || !["artifacts", "receipt.json", "delivery.json"].every(name => entries.includes(name)) ||
hash(await readWorkspaceFile(join(options.delivery!, "receipt.json"), 1024 * 1024)) !== metadataHashes!.receipt ||
hash(await readWorkspaceFile(join(options.delivery!, "delivery.json"), 1024 * 1024)) !== metadataHashes!.manifest) issue("delivery.changed-during-check");
} catch { issue("delivery.changed-during-check"); }
}
for (const check of contract.checks) {
const csv = facts.get(check.csv), json = facts.get(check.json);
if (!csv || !json) { issue("artifact.comparison-missing", check.csv); continue; }
if (csv.rows !== json.integers.get(check.row_count_field)) issue("artifact.summary-row-count", check.json, check.row_count_field);
for (const sum of check.sums) if (csv.integers.get(sum.column) !== json.integers.get(sum.field)) issue("artifact.summary-sum", check.json, sum.field);
}
if (!report.findings.length) { report.status = "passed"; report.exitCode = 0; }
return report;
}

export function renderArtifacts(report: ArtifactReport, format = "text"): string {
const safe = redactLocalPaths(report);
if (format === "json") return JSON.stringify(safe, null, 2) + "\n";
return [`Artifact validation: ${safe.status}`, `Evidence: physical files; execution: not-run; receipt binding: ${safe.receipt_binding}`,
`Files: ${safe.files.length}`, ...safe.findings.map(finding => `${finding.code}${finding.path ? ` file=${JSON.stringify(finding.path)}` : ""}${finding.field ? ` field=${JSON.stringify(finding.field)}` : ""}${finding.row !== undefined ? ` row=${finding.row}` : ""}${finding.message ? ` ${finding.message}` : ""}`)].join("\n") + "\n";
}
14 changes: 14 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type FixPlanOptions,
} from "./commands/fix.js";
import { ActionPlanApplyError } from "../domain/patch-application.js";
import { runArtifacts, renderArtifacts, type ArtifactsOptions } from "./commands/artifacts.js";
import {
listBehaviorFixtures,
renderBehaviorTest,
Expand Down Expand Up @@ -563,6 +564,19 @@ export function createCli(io: CliIO = defaultCliIO): Command {
}
});

program.command("artifacts")
.description("Inspect actual local delivery artifacts against a bounded contract.")
.requiredOption("--contract <path>", "JSON or YAML artifact contract")
.option("--delivery <path>", "SkillTape delivery bundle directory")
.option("--path <path>", "Standalone artifact directory; choose this or --delivery")
.option("--format <format>", "Output format: text or json", "text")
.action(async (options: ArtifactsOptions & { format?: string }) => {
if (options.format !== "text" && options.format !== "json") throw new Error("artifacts format must be text or json");
const report = await runArtifacts(options);
io.writeOut(renderArtifacts(report, options.format));
if (report.exitCode) io.setExitCode?.(report.exitCode);
});

const runner = program
.command("runner")
.description("Validate a local Runner image contract.");
Expand Down
Loading
Loading