diff --git a/packages/pctr/README.md b/packages/pctr/README.md index 51a0cf7..265b06a 100644 --- a/packages/pctr/README.md +++ b/packages/pctr/README.md @@ -17,6 +17,11 @@ npx @blocksifr/pctr init # discover agents and tools, write pctr.json npx @blocksifr/pctr scan # what consequences can they reach? ``` +If the project has no agents, `init` says so rather than inventing any. To see how a +scan reads, `npx @blocksifr/pctr init --example` writes a worked example — and every +view of it is labelled as made-up data, so it can never be mistaken for findings about +your code. + Or install it: `npm i -g @blocksifr/pctr`, then just `pctr init` and `pctr scan`. No account. No network. Everything stays in `./pctr.json` and `./.pctr`. diff --git a/packages/pctr/action.yml b/packages/pctr/action.yml index e4f50c2..efc613e 100644 --- a/packages/pctr/action.yml +++ b/packages/pctr/action.yml @@ -44,7 +44,9 @@ runs: set -euo pipefail PCTR="npx --yes @blocksifr/pctr@${{ inputs.version }}" - # init is additive: an existing pctr.json is kept and only extended. + # init is additive: an existing pctr.json is kept and only extended. It never + # fabricates agents, so a repository with none produces an empty scan, not a + # false report. $PCTR init > /dev/null $PCTR scan --share pctr-report.md > /dev/null $PCTR scan --json > pctr-scan.json diff --git a/packages/pctr/bin/pctr.mjs b/packages/pctr/bin/pctr.mjs index 03551f9..2955bc0 100755 --- a/packages/pctr/bin/pctr.mjs +++ b/packages/pctr/bin/pctr.mjs @@ -103,27 +103,48 @@ async function main() { case 'init': { const existing = store.readManifest(); const { manifest, notes, discovered } = discover(process.cwd(), { declared: existing }); - // In CI a fabricated example would produce a false report about someone's repo, - // so --no-example keeps an empty scan empty. - if (!manifest.agents.length && !argv.includes('--no-example')) { - manifest.agents = exampleManifest().agents; - manifest.tools = exampleManifest().tools; - manifest.actions = exampleManifest().actions; - notes.push('No agents detected; wrote a worked example you can edit.'); + // Never invent agents in someone's repository. Fabricated findings presented in + // the same format as real ones is the fastest way to lose a reader's trust. + const wantsExample = argv.includes('--example'); + if (!manifest.agents.length && wantsExample) { + Object.assign(manifest, { + example: true, + agents: exampleManifest().agents, + tools: exampleManifest().tools, + actions: exampleManifest().actions + }); + notes.push('Wrote the bundled example. It describes made-up agents, not this project.'); } const path = store.writeManifest(manifest); if (asJson) return out(null, { path, manifest, notes }); console.log(r.heading('pctr initialized')); console.log(`Wrote ${path}`); - console.log(r.field('Agents', String(discovered.agents || manifest.agents.length))); - console.log(r.field('Tools', String(discovered.tools || manifest.tools.length))); + console.log(r.field('Agents found', String(discovered.agents))); + console.log(r.field('Tools found', String(discovered.tools))); for (const n of notes) console.log(r.dim(`- ${n}`)); + + if (!manifest.agents.length) { + console.log(`\n${r.yellow('No agents found in this project.')}`); + console.log('PCTR looks for MCP servers, and for agent and tool declarations in'); + console.log('your source — OpenAI, Claude, LangGraph, CrewAI, AutoGen, AGT.'); + console.log(`\nDeclare them in ${r.bold('pctr.json')}, or see how it works on a worked example:`); + console.log(` ${r.bold('pctr init --example')} ${r.dim('(made-up agents, clearly labelled)')}`); + return 0; + } console.log(`\nNext: ${r.bold('pctr scan')}`); return 0; } case 'scan': { const graph = loadGraph(); + if (graph.manifest.example && !asJson) console.log(r.exampleBanner()); + if (!graph.manifest.agents?.length) { + return out([r.heading('nothing to scan'), + 'pctr.json declares no agents, so there is nothing that can cause a consequence.', + '', `Run ${r.bold('pctr init')} to discover them, or ${r.bold('pctr init --example')} to see`, + 'how a scan reads on a worked example.'].join('\n'), + { summary: summarize(graph), protected: [] }); + } if (flag('share')) { const report = renderReport(graph); const file = flag('share') !== true ? String(flag('share')) : 'pctr-report.md'; @@ -495,7 +516,7 @@ Usage Options --json Machine-readable output --share [file] scan: write the findings as shareable Markdown - --no-example init: don't write a worked example when nothing is found + --example init: write the bundled worked example (made-up agents) --amount Material parameter: amount --records Material parameter: records affected --params '' Any other material parameters diff --git a/packages/pctr/package.json b/packages/pctr/package.json index 89442d9..2e54108 100644 --- a/packages/pctr/package.json +++ b/packages/pctr/package.json @@ -1,6 +1,6 @@ { "name": "@blocksifr/pctr", - "version": "0.1.0", + "version": "0.1.1", "description": "See what your AI agents can cause, route them safely, and prove what happened. Consequence preview, trust routing and signed execution receipts for AI agents.", "type": "module", "license": "Apache-2.0", diff --git a/packages/pctr/src/render.mjs b/packages/pctr/src/render.mjs index d244826..b9c78a6 100644 --- a/packages/pctr/src/render.mjs +++ b/packages/pctr/src/render.mjs @@ -16,6 +16,10 @@ export const field = (label, value, width = 22) => `${label.length >= width ? `${label} ` : label.padEnd(width)}${value}`; export const chain = (ids) => ids.join(`\n${dim(' |')}\n${dim(' v')}\n`); +// Example data must never be mistaken for findings about the reader's own project. +export const exampleBanner = () => + `\n${yellow('EXAMPLE DATA')} ${dim('— these agents are made up. They are not in this project.')}\n${dim('Run `pctr init` against a project with real agents to scan it.')}`; + export function renderScan(graph) { const s = summarize(graph); const out = [heading('PCTR'), 'Scanning your agents...', '']; diff --git a/packages/pctr/src/report.mjs b/packages/pctr/src/report.mjs index 404e588..71a3442 100644 --- a/packages/pctr/src/report.mjs +++ b/packages/pctr/src/report.mjs @@ -25,6 +25,10 @@ export function renderReport(graph, { title = 'PCTR consequence scan', includeBa const out = []; out.push(`## ${summary.bySeverity.CRITICAL ? '🛑' : '🔍'} ${title}`, ''); + if (graph.manifest?.example) { + out.push('> **Example data.** These agents are made up and are not part of this project. ' + + 'This report is showing how a scan reads, not findings about real software.', ''); + } if (includeBadge) out.push(`![PCTR](${badgeUrl(graph)})`, ''); if (!summary.agents) { out.push(`PCTR found no agents to scan here. Declare them in \`pctr.json\` — see the [manifest format](${HOMEPAGE}#pctrjson).`, ''); diff --git a/packages/pctr/tests/report.test.mjs b/packages/pctr/tests/report.test.mjs index 3576366..6a332ac 100644 --- a/packages/pctr/tests/report.test.mjs +++ b/packages/pctr/tests/report.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { buildGraph } from '../src/graph.mjs'; import { renderReport, badgeUrl } from '../src/report.mjs'; -const graph = (overrides = {}) => buildGraph({ +const manifestOf = (overrides = {}) => ({ principal: 'user:test', agents: [{ id: 'support', trust: 0.8, evidenceAgeSeconds: 10, authority: ['customers.read'], tools: ['db'], delegatesTo: ['admin'] }, { id: 'admin', trust: 0.95, evidenceAgeSeconds: 10, authority: ['customers.*'], tools: ['db'] }], @@ -11,6 +11,7 @@ const graph = (overrides = {}) => buildGraph({ actions: [{ id: 'customers.delete', recordsAffected: 1842, connectedWorkflows: 4 }], ...overrides }); +const graph = (overrides = {}) => buildGraph(manifestOf(overrides)); test('the shared report leads with the consequence, not with agent counts', () => { const report = renderReport(graph()); @@ -51,3 +52,11 @@ test('an empty scan says so rather than inventing findings', () => { test('the badge reflects what was actually found', () => { assert.match(badgeUrl(graph()), /1%20critical%20consequence-critical/); }); + +test('example data is never presented as findings about the reader\'s project', () => { + const example = buildGraph(manifestOf({ example: true })); + const report = renderReport(example); + assert.match(report, /\*\*Example data\.\*\* These agents are made up/); + assert.ok(report.indexOf('Example data') < report.indexOf('Highest priority'), + 'the warning must come before anything that reads as a finding'); +});