From 0862eafc741fcfe65a1018ed59195d55691d5457 Mon Sep 17 00:00:00 2001 From: Jacob Hoehler Date: Sat, 29 Aug 2026 11:59:12 -0400 Subject: [PATCH 1/2] feat(check): gate managed output content on declared redactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan every managed output for absolute home directories and for strings a marketplace declares under `redactions:`. The scan rides on the byte read `checkManagedOutput` already performs for the drift comparison, so copied passthrough resources are covered at no extra I/O — until now nothing read those bytes for anything but equality. Redactions are literal strings rather than patterns: a declaration names a vocabulary, and a regex invites an author to encode matching logic the compiler then has to defend against. The one patterned class that generalizes across every repository is built in. A gate on `check`, never on `compile` (ndr:tfee0d): compilation stays total, and whether a tree is publishable is a judgement about a finished tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LxNdWk6ZUzq6uWFtd94yWD --- CLAUDE.md | 25 +++++++++++ src/check.ts | 64 +++++++++++++++++++++++++-- src/cli.ts | 8 +++- src/compiler.ts | 6 +++ src/definitions.ts | 15 +++++++ tests/check.test.ts | 90 ++++++++++++++++++++++++++++++++++++++ tests/materializer.test.ts | 2 +- 7 files changed, 205 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80dd821..8fa1a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,6 +305,31 @@ root** — the directory holding `MARKETPLACE.yaml` — beside the usual drift/absence under the publication id with a `/…` path, and `--claude-native` validates the marketplace root as a second plugin root. +## Output content checks + +`check` reads every managed output's bytes for the drift comparison already, so +a content gate rides on that same read — covering **copied passthrough +resources**, not only generated documents: + +- `unsafe-output-content` — the output contains an absolute home directory + (`/Users//`, `/home//`), or a string listed in the marketplace's + optional `redactions:` block. Redactions are **literal strings, not patterns**: + a declaration names a vocabulary, and a regex invites an author to encode + matching logic the compiler then has to defend against. The patterned class + that generalizes across every repository (the home directory) is built in. + Binary outputs are skipped — a file with no text has nothing to leak in it. + +This is a gate on `check`, never on `compile`: compilation stays total, and +whether a tree is publishable is a judgement about a finished tree (ndr:tfee0d). +A leak that reaches disk under `--out` has not been published; one that survives +`check` is about to be. + +Deliberately **not** ported from the repo-local scanner this replaces: the +repo-wide sweep over every git-tracked file. Neither is a runtime +failure on either harness, and turning one repository's house style into every +consumer's problem is not the compiler's job. A publishing repo keeps that as +its own pre-push hook — agentforge only ever sees files a publication declares. + ## Authoring keys `PACKAGE.yaml` may declare `authoring-keys: [, …]` — a flat diff --git a/src/check.ts b/src/check.ts index a3049ce..0d3773d 100644 --- a/src/check.ts +++ b/src/check.ts @@ -19,6 +19,7 @@ export type MarketplaceCheckIssueCode = | 'package-identity-mismatch' | 'package-version-mismatch' | 'invalid-artifact-frontmatter' + | 'unsafe-output-content' | 'unsafe-output-entry'; export interface MarketplaceCheckIssue { @@ -61,7 +62,7 @@ export function checkMarketplace( const issues: MarketplaceCheckIssue[] = []; for (const output of plan.outputs) { - issues.push(...checkManagedOutput(output, publicationAnchor(outputRoot))); + issues.push(...checkManagedOutput(output, publicationAnchor(outputRoot), plan.redactions)); } for (const path of actualPaths) { @@ -85,7 +86,7 @@ export function checkMarketplace( publicationId: output.provenance.publicationId, path: rootDisplayPath(output.destination), }); - issues.push(...checkManagedOutput(output, marketplaceRootAnchor(output))); + issues.push(...checkManagedOutput(output, marketplaceRootAnchor(output), plan.redactions)); } issues.sort( @@ -136,7 +137,11 @@ function marketplaceRootAnchor(output: RootAnchoredOutput): OutputAnchor { }; } -function checkManagedOutput(output: DesiredOutput, anchor: OutputAnchor): MarketplaceCheckIssue[] { +function checkManagedOutput( + output: DesiredOutput, + anchor: OutputAnchor, + redactions: readonly string[], +): MarketplaceCheckIssue[] { const path = anchor.displayPath(output.destination); const actualPath = join(anchor.baseDirectory, ...output.destination.split('/')); @@ -174,6 +179,59 @@ function checkManagedOutput(output: DesiredOutput, anchor: OutputAnchor): Market } const nativeIssue = validateNativeDocument(output, path, actualBytes); if (nativeIssue) issues.push(nativeIssue); + issues.push(...scanOutputContent(output, path, actualBytes, redactions)); + return issues; +} + +// Absolute home directories, the one leak class that generalizes across every +// repository: a compiler that interpolated a source path into a manifest ships +// the author's username to whoever installs the plugin. Both spellings, because +// the same publication is compiled on macOS and on Linux CI. +const ABSOLUTE_HOME_PATH = /\/(?:Users|home)\/[A-Za-z0-9._-]+\//; + +// Scans what a managed output actually contains. Runs against the bytes already +// read for the drift comparison, so a copied resource costs no extra read than +// the one `checkManagedOutput` performs regardless — which is why this covers +// passthrough resources and not only generated documents. +// +// A gate on `check` rather than on `compile`: compilation stays total, and what +// is publishable is a judgement about a finished tree (ndr:tfee0d). A leak that +// reaches disk under `--out` has not been published; one that survives `check` +// is about to be. +function scanOutputContent( + output: DesiredOutput, + path: string, + bytes: Buffer, + redactions: readonly string[], +): MarketplaceCheckIssue[] { + // Binary payloads — an icon, a compiled helper — have no text to scan, and + // decoding them produces replacement characters that match nothing useful. + if (bytes.includes(0)) return []; + const content = bytes.toString('utf8'); + + const issues: MarketplaceCheckIssue[] = []; + const home = ABSOLUTE_HOME_PATH.exec(content); + if (home) { + issues.push( + issueFor( + output, + 'unsafe-output-content', + `managed output contains an absolute home directory ${JSON.stringify(home[0])}`, + path, + ), + ); + } + for (const redaction of redactions) { + if (!content.includes(redaction)) continue; + issues.push( + issueFor( + output, + 'unsafe-output-content', + `managed output contains the declared redaction ${JSON.stringify(redaction)}`, + path, + ), + ); + } return issues; } diff --git a/src/cli.ts b/src/cli.ts index c1dbb65..b0f22f4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -173,7 +173,13 @@ const compileSelectedMarketplace = ( diagnostics.push(...plan.diagnostics); } - return { marketplaceId: loaded.definition.id, outputs, diagnostics, rootOutputs }; + return { + marketplaceId: loaded.definition.id, + outputs, + diagnostics, + rootOutputs, + redactions: loaded.definition.redactions ?? [], + }; }; program diff --git a/src/compiler.ts b/src/compiler.ts index 453882f..79d02a2 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -141,6 +141,11 @@ export interface CompilationPlan { // optional list makes every consumer restate the default, and one that // forgets silently drops the second anchor. rootOutputs: readonly RootAnchoredOutput[]; + // The marketplace's declared redactions, carried on the plan rather than + // passed alongside it. `check` derives every judgement it makes from the plan + // (ndr:tfee0d), and a gate handed in as a separate optional argument is a gate + // a caller can forget — the same reasoning that keeps `rootOutputs` here. + redactions: readonly string[]; } export interface CompileMarketplaceOptions { @@ -220,6 +225,7 @@ export function compileMarketplace( outputs: resolvedOutputs, diagnostics, rootOutputs: buildRootOutputs(loaded, resolvedOutputs, options.outputRoot), + redactions: loaded.definition.redactions ?? [], }; } diff --git a/src/definitions.ts b/src/definitions.ts index a8d1783..cb58351 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -185,6 +185,20 @@ const Enrollment = z.discriminatedUnion('mode', [ // against the nested root and must keep working. const RootManifest = z.boolean(); +// A literal string that must never appear in compiled output — a machine path +// fragment, an internal hostname, the name of a private vault. Declared rather +// than inferred, on the `authoring-keys` / `documents` precedent (ndr:4nshwv): +// which strings are sensitive is a property of the repository publishing them, +// and a compiler that guessed would either miss the ones that matter or fail a +// build over a word that merely looks private. +// +// Literal, not a pattern, on purpose. A declaration names a vocabulary; a regex +// invites an author to encode matching logic the compiler then has to defend +// against (catastrophic backtracking, silent over-match). The patterned classes +// that generalize across every repository — an absolute home directory — are +// built into the check layer instead, where they can be tested once. +const Redaction = z.string().min(1); + const Publication = z.strictObject({ id: Slug, target: TargetName, @@ -203,6 +217,7 @@ export const CanonicalMarketplace = z defaults: MarketplaceDefaults, packages: z.array(z.string().min(1)).min(1), publications: z.array(Publication).min(1), + redactions: z.array(Redaction).min(1).optional(), }) .superRefine((definition, context) => { reportDuplicates(definition.packages, (pattern) => pattern, context, 'package pattern', [ diff --git a/tests/check.test.ts b/tests/check.test.ts index b4549c4..b32d272 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -304,6 +304,93 @@ describe('marketplace check', () => { message: 'managed output must be a regular file contained by its publication root', }); }); + + // The copied half is the point: a passthrough resource is never parsed by the + // compiler, so a leak inside one is invisible to every other check. + test('reports an absolute home directory in a copied resource', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, 'see /Users/someone/Projects/notes.md for details\n'); + const plan = fixturePlan(source); + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toContainEqual({ + code: 'unsafe-output-content', + publicationId: 'claude', + packageId: 'example', + path: 'claude/packages/example/source.txt', + message: 'managed output contains an absolute home directory "/Users/someone/"', + }); + }); + + test('reports an absolute home directory in a generated document', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, 'copied\n'); + const plan = fixturePlan(source); + const [generatedOutput, ...rest] = plan.outputs; + if (!generatedOutput || generatedOutput.kind !== 'generated') throw new Error('fixture drift'); + const leaking: CompilationPlan = { + ...plan, + outputs: [{ ...generatedOutput, content: '{"root":"/home/someone/src"}\n' }, ...rest], + }; + materializeCompilation(leaking, outputRoot); + + const result = checkMarketplace(leaking, outputRoot); + + expect(result.issues).toContainEqual({ + code: 'unsafe-output-content', + publicationId: 'claude', + path: 'claude/generated.json', + message: 'managed output contains an absolute home directory "/home/someone/"', + }); + }); + + test('reports a declared redaction', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, 'filed under Loose Ends for later\n'); + const plan = { ...fixturePlan(source), redactions: ['Loose Ends'] }; + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toContainEqual({ + code: 'unsafe-output-content', + publicationId: 'claude', + packageId: 'example', + path: 'claude/packages/example/source.txt', + message: 'managed output contains the declared redaction "Loose Ends"', + }); + }); + + test('leaves an undeclared sensitive-looking string alone', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, 'filed under Loose Ends for later\n'); + const plan = fixturePlan(source); + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toEqual([]); + }); + + // A binary payload decodes to replacement characters, which match nothing + // useful; scanning it would only produce noise on a file with no text in it. + test('skips a binary payload', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, Buffer.from([0x00, 0x01, 0x02, 0xff])); + const plan = { ...fixturePlan(source), redactions: ['Loose Ends'] }; + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toEqual([]); + }); }); function fixturePlan(sourcePath: string): CompilationPlan { @@ -311,6 +398,7 @@ function fixturePlan(sourcePath: string): CompilationPlan { marketplaceId: 'fixture', diagnostics: [], rootOutputs: [], + redactions: [], outputs: [ { kind: 'generated', @@ -342,6 +430,7 @@ function claudePlan(): CompilationPlan { marketplaceId: 'fixture', diagnostics: [], rootOutputs: [], + redactions: [], outputs: [ generated( 'claude/.claude-plugin/marketplace.json', @@ -370,6 +459,7 @@ function codexPlan(): CompilationPlan { marketplaceId: 'fixture', diagnostics: [], rootOutputs: [], + redactions: [], outputs: [ { kind: 'generated', diff --git a/tests/materializer.test.ts b/tests/materializer.test.ts index f6d204c..23cf1c0 100644 --- a/tests/materializer.test.ts +++ b/tests/materializer.test.ts @@ -140,7 +140,7 @@ describe('marketplace materialization', () => { }); function plan(outputs: readonly DesiredOutput[]): CompilationPlan { - return { marketplaceId: 'fixture', outputs, diagnostics: [], rootOutputs: [] }; + return { marketplaceId: 'fixture', outputs, diagnostics: [], rootOutputs: [], redactions: [] }; } function generated(destination: string, content: string): DesiredOutput { From 293f4e295cbc8dde9878edd77e9aec05768915a5 Mon Sep 17 00:00:00 2001 From: Jacob Hoehler Date: Sat, 29 Aug 2026 12:05:46 -0400 Subject: [PATCH 2/2] feat(check): report a managed .json output that does not parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate any `.json` output that is not one of the native documents — a hook configuration, a package's own settings file, anything a publication ships verbatim. Both harnesses parse these at load time, so a file that does not parse is one the runtime rejects. Narrower than the repo-local linter this replaces, which also failed an empty markdown file and warned on a short one. Neither is a runtime failure on either harness, and turning one repository's house style into every consumer's build error is the overreach ndr:17dhph rejected for strict target schemas. Two tests pin the tolerance. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LxNdWk6ZUzq6uWFtd94yWD --- CLAUDE.md | 18 ++++++++++----- src/check.ts | 30 +++++++++++++++++++++++++ tests/check.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8fa1a2c..84b4097 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,7 +308,7 @@ root** — the directory holding `MARKETPLACE.yaml` — beside the usual ## Output content checks `check` reads every managed output's bytes for the drift comparison already, so -a content gate rides on that same read — covering **copied passthrough +two content gates ride on that same read — covering **copied passthrough resources**, not only generated documents: - `unsafe-output-content` — the output contains an absolute home directory @@ -318,17 +318,23 @@ resources**, not only generated documents: matching logic the compiler then has to defend against. The patterned class that generalizes across every repository (the home directory) is built in. Binary outputs are skipped — a file with no text has nothing to leak in it. +- `invalid-output-document` — a `.json` output that is not one of the native + documents (which have their own `invalid-native-document` code) fails to parse. + Both harnesses parse these at load time, so a file that does not parse is one + the runtime rejects. -This is a gate on `check`, never on `compile`: compilation stays total, and +Both are gates on `check`, never on `compile`: compilation stays total, and whether a tree is publishable is a judgement about a finished tree (ndr:tfee0d). A leak that reaches disk under `--out` has not been published; one that survives `check` is about to be. -Deliberately **not** ported from the repo-local scanner this replaces: the -repo-wide sweep over every git-tracked file. Neither is a runtime +Deliberately **not** ported from the repo-local linter these replace: an empty +or short markdown file, and an allowed-extension list. Neither is a runtime failure on either harness, and turning one repository's house style into every -consumer's problem is not the compiler's job. A publishing repo keeps that as -its own pre-push hook — agentforge only ever sees files a publication declares. +consumer's build error is the overreach ndr:17dhph rejected for strict target +schemas. A publishing repo that wants those keeps them as its own pre-push hook, +alongside the repo-wide secret scan that cannot move here — agentforge only ever +sees files a publication declares. ## Authoring keys diff --git a/src/check.ts b/src/check.ts index 0d3773d..3fd4b2e 100644 --- a/src/check.ts +++ b/src/check.ts @@ -19,6 +19,7 @@ export type MarketplaceCheckIssueCode = | 'package-identity-mismatch' | 'package-version-mismatch' | 'invalid-artifact-frontmatter' + | 'invalid-output-document' | 'unsafe-output-content' | 'unsafe-output-entry'; @@ -179,10 +180,39 @@ function checkManagedOutput( } const nativeIssue = validateNativeDocument(output, path, actualBytes); if (nativeIssue) issues.push(nativeIssue); + else { + const jsonIssue = validateOutputJson(output, path, actualBytes); + if (jsonIssue) issues.push(jsonIssue); + } issues.push(...scanOutputContent(output, path, actualBytes, redactions)); return issues; } +// A `.json` output that is not one of the native documents above: a hook +// configuration, a package's own settings file, anything a publication ships +// verbatim. Malformed JSON here is a real defect rather than a style opinion — +// the harness parses these at load time, so a file that does not parse is one +// the runtime will reject. +// +// Deliberately narrower than the linter this replaces, which also failed an +// empty markdown file and warned on a short one. Neither is a runtime failure +// on either harness, and turning one repository's house style into every +// consumer's build error is the same overreach `ndr:17dhph` rejected for strict +// target schemas. +function validateOutputJson( + output: DesiredOutput, + path: string, + actualBytes: Buffer, +): MarketplaceCheckIssue | undefined { + if (!output.destination.endsWith('.json')) return undefined; + try { + JSON.parse(actualBytes.toString('utf8')); + return undefined; + } catch { + return issueFor(output, 'invalid-output-document', 'managed output is not valid JSON', path); + } +} + // Absolute home directories, the one leak class that generalizes across every // repository: a compiler that interpolated a source path into a manifest ships // the author's username to whoever installs the plugin. Both spellings, because diff --git a/tests/check.test.ts b/tests/check.test.ts index b32d272..c0979ac 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -391,8 +391,62 @@ describe('marketplace check', () => { expect(result.issues).toEqual([]); }); + + test('reports a copied .json resource that does not parse', () => { + const source = join(temporaryRoot, 'settings.json'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, '{"unterminated": true\n'); + const plan = jsonResourcePlan(source); + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toContainEqual({ + code: 'invalid-output-document', + publicationId: 'claude', + packageId: 'example', + path: 'claude/packages/example/settings.json', + message: 'managed output is not valid JSON', + }); + }); + + test('accepts a copied .json resource that parses', () => { + const source = join(temporaryRoot, 'settings.json'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, '{"ok": true}\n'); + const plan = jsonResourcePlan(source); + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toEqual([]); + }); + + // The linter this replaces failed an empty markdown file. Neither harness + // does, so neither does check. + test('accepts an empty copied markdown resource', () => { + const source = join(temporaryRoot, 'source.txt'); + const outputRoot = join(temporaryRoot, 'output'); + writeFileSync(source, ''); + const plan = fixturePlan(source); + materializeCompilation(plan, outputRoot); + + const result = checkMarketplace(plan, outputRoot); + + expect(result.issues).toEqual([]); + }); }); +function jsonResourcePlan(sourcePath: string): CompilationPlan { + const base = fixturePlan(sourcePath); + const [generatedOutput, copied] = base.outputs; + if (!generatedOutput || !copied || copied.kind !== 'copy') throw new Error('fixture drift'); + return { + ...base, + outputs: [generatedOutput, { ...copied, destination: 'claude/packages/example/settings.json' }], + }; +} + function fixturePlan(sourcePath: string): CompilationPlan { return { marketplaceId: 'fixture',