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
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,37 @@ root** — the directory holding `MARKETPLACE.yaml` — beside the usual
drift/absence under the publication id with a `<root>/…` 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
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
(`/Users/<name>/`, `/home/<name>/`), 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.
- `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.

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 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 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

`PACKAGE.yaml` may declare `authoring-keys: [<frontmatter key>, …]` — a flat
Expand Down
94 changes: 91 additions & 3 deletions src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type MarketplaceCheckIssueCode =
| 'package-identity-mismatch'
| 'package-version-mismatch'
| 'invalid-artifact-frontmatter'
| 'invalid-output-document'
| 'unsafe-output-content'
| 'unsafe-output-entry';

export interface MarketplaceCheckIssue {
Expand Down Expand Up @@ -61,7 +63,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) {
Expand All @@ -85,7 +87,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(
Expand Down Expand Up @@ -136,7 +138,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('/'));

Expand Down Expand Up @@ -174,6 +180,88 @@ function checkManagedOutput(output: DesiredOutput, anchor: OutputAnchor): Market
}
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
// 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;
}

Expand Down
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -220,6 +225,7 @@ export function compileMarketplace(
outputs: resolvedOutputs,
diagnostics,
rootOutputs: buildRootOutputs(loaded, resolvedOutputs, options.outputRoot),
redactions: loaded.definition.redactions ?? [],
};
}

Expand Down
15 changes: 15 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', [
Expand Down
Loading