Files
openprose__prose/tools/cli/scripts/audit-policy.mjs
Raymond Weitekamp 9856ac5b12 feat(spec): declared ### Skills section with fail-closed compile resolution (#62)
* feat(spec): declared ### Skills section with fail-closed compile resolution

Implements the spec from issue #60. Components declare required harness
skills via a `### Skills` section (colon form, e.g. `document-skills:pdf`).
`prose compile` resolves declared skills against ./skills/, ~/.claude/skills/,
~/.codex/skills/, and ~/.agents/skills/, and fails closed with
`skill_unresolved` before forwarding to the agent harness when any are
missing.

- Spec: skills/open-prose/contract-markdown.md gains a ### Skills row in
  the Canonical Sections table and a ## Skills H2 covering colon naming,
  search order, the BYO-harness invariant, and fail-closed semantics.
- Implementation: tools/cli/src/skills/declared.ts (parser + resolver +
  directory walker + DeclaredSkillsUnresolvedError). Pure functions; no
  I/O beyond readFile / readdir / stat.
- Wiring: tools/cli/src/commands/compile.ts pre-checks declared skills
  before forwarding the compile prompt; fails closed with
  CompileValidationError when any are unresolved. Gated behind the
  existing skillPreflight option for test parity.
- Example: skills/open-prose/examples/declared-skills/ shows the
  document-skills:pdf canonical pattern.
- Tests: 19 new (18 in declared.test.ts covering parser/resolver/walker/
  error formatter; 1 in cli.test.ts asserting compile fails closed before
  the harness is invoked when a declared skill is missing).
- BYO harness: OpenProse never installs harness skills; resolution failure
  is the user's signal to install the named skill themselves.

Resolves #60.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(spec): move skill resolution into compiler program; trim spec per review

Address review feedback on #62:

- contract-markdown.md: drop the "not duplicated in frontmatter" clause —
  the rejected alternative shouldn't propagate into the spec.
- contract-markdown.md: fail-closed clause now mentions only `prose compile`;
  `prose run` enforcement is deferred per the PR description.
- compiler/index.prose.md: add a `skills_resolver` agent that owns the
  search-path order, scope aggregation, BYO invariant, and fail-closed
  semantics. Skill resolution is now a compiler/program-level
  responsibility, not a harness responsibility, so other harnesses running
  the compiler get the same behavior.
- skills/declared.ts: add a header comment pointing at the program-level
  spec; this module is the harness implementation of `skills_resolver`.
- examples/declared-skills/README.md: update wording to reference the
  compiler agent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): make smoke artifacts case-specific

* fix(ci): use deterministic audit policy

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:50:01 -04:00

103 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const cliDir = dirname(scriptDir);
const policyPath = join(cliDir, "audit-policy.json");
const policy = JSON.parse(readFileSync(policyPath, "utf8"));
const audit = spawnSync("npm", ["audit", "--omit=dev", "--json"], {
cwd: cliDir,
encoding: "utf8",
});
if (audit.error) {
throw audit.error;
}
let report;
try {
report = JSON.parse(audit.stdout);
} catch (error) {
process.stderr.write(audit.stderr);
process.stderr.write(audit.stdout);
throw new Error(`Failed to parse npm audit JSON: ${error.message}`);
}
const today = new Date().toISOString().slice(0, 10);
const allowances = new Map(
(policy.allowedAdvisories ?? []).map((entry) => [`${entry.package}:${entry.id}`, entry]),
);
const findings = extractFindings(report);
const failures = [];
const allowed = [];
for (const finding of findings) {
const allowance = allowances.get(`${finding.packageName}:${finding.id}`);
if (!allowance) {
failures.push(`${finding.packageName} ${finding.id} ${finding.severity}: ${finding.title}`);
continue;
}
if (allowance.severity !== finding.severity) {
failures.push(
`${finding.packageName} ${finding.id} severity changed from ${allowance.severity} to ${finding.severity}`,
);
continue;
}
if (allowance.expires < today) {
failures.push(`${finding.packageName} ${finding.id} allowance expired on ${allowance.expires}`);
continue;
}
allowed.push(`${finding.packageName} ${finding.id} allowed until ${allowance.expires}`);
}
if (failures.length > 0) {
process.stderr.write("Production dependency audit failed policy:\n");
for (const failure of failures) {
process.stderr.write(`- ${failure}\n`);
}
process.exitCode = 1;
} else {
const count = findings.length;
process.stdout.write(`Production dependency audit passed policy (${count} advisory finding${count === 1 ? "" : "s"}).\n`);
for (const entry of allowed) {
process.stdout.write(`- ${entry}\n`);
}
}
function extractFindings(report) {
const findings = [];
const seen = new Set();
for (const vulnerability of Object.values(report.vulnerabilities ?? {})) {
for (const via of vulnerability.via ?? []) {
if (!via || typeof via !== "object") continue;
const id = advisoryId(via);
const packageName = via.name ?? vulnerability.name;
const key = `${packageName}:${id}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
id,
packageName,
severity: via.severity ?? vulnerability.severity,
title: via.title ?? "(no title)",
});
}
}
return findings.sort((left, right) => `${left.packageName}:${left.id}`.localeCompare(`${right.packageName}:${right.id}`));
}
function advisoryId(via) {
if (typeof via.url === "string") {
const match = via.url.match(/GHSA-[A-Za-z0-9-]+/);
if (match) return match[0];
}
return String(via.source ?? via.title ?? "unknown-advisory");
}