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
6 changes: 6 additions & 0 deletions .changeset/doctor-static-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": minor
---

Surface pinned static bytes-at-rest validation findings for supplied bundles
and installed Cursor plugins through the read-only Doctor report.
13 changes: 12 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ gate a build, a validation, or a dev rebuild.
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7318` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, and durable-state inventory. |
| `AB7300`–`AB7320` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, and static bytes-at-rest validation. |
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
| `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. |
| `AB8xxx` | Development server configuration. |
Expand Down Expand Up @@ -399,6 +399,17 @@ SQLite lock or shared-memory files.
| `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. Restart it after upgrading Agent Bundle. |
| `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. Inspect or restart the runtime, then rerun Doctor. |

## Read-only Doctor static validation (`AB7319`–`AB7320`)

Doctor reuses the pinned, process-free host document and loader validators.
These checks read installed or supplied bundle bytes only; they never invoke a
host CLI, repair a bundle, or perform a live protocol exchange.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. |
| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract; the loader-recognized entry remains `installed`. | Reinstall an invalid Cursor plugin or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. |

## Development package build (`AB7103`)

`agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ const metadata = Object.freeze({
const evidence = capabilityEvidence(claudeName, metadata);
const distributionPolicy = capabilityTable.plugin.distributionPolicy;

const artifactValidation = deepFreeze({
export const claudeArtifactValidation = deepFreeze({
documents: [
Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }),
Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'lsp' }),
Expand Down Expand Up @@ -3099,7 +3099,7 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({
});

export const claudeAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactValidation: claudeArtifactValidation,
artifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
Expand Down
116 changes: 115 additions & 1 deletion packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { dirname, resolve } from 'node:path';
import { readFile, readdir } from 'node:fs/promises';
import { dirname, join, posix, resolve } from 'node:path';

import { claudeArtifactValidation } from '../adapters/claude.ts';
import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts';
import { freezeDiagnostics } from '../core/diagnostics.ts';
import { isErrno } from '../core/errors.ts';
Expand Down Expand Up @@ -41,6 +43,11 @@ export interface ValidateClaudePluginOptions {
readonly target: string;
}

export interface ValidateClaudePluginFilesOptions {
readonly pluginDirectory: string;
readonly target: string;
}

const runClaudeCommand: ClaudePluginCommandRunner = (request) => runBoundedChildProcess(request, {
labels: { outputLimit: 'output-limit', timedOut: 'timed-out' },
maxOutputBytes: maximumOutputBytes,
Expand Down Expand Up @@ -87,6 +94,113 @@ const issueLines = (output: string): readonly { readonly message: string; readon
return Object.freeze(issues);
};

const matchingDocumentPaths = async (
root: string,
contractPath: string,
): Promise<readonly string[]> => {
const wildcard = contractPath.indexOf('*');
if (wildcard === -1) return Object.freeze([contractPath]);
const directory = posix.dirname(contractPath);
const name = contractPath.slice(directory.length + 1);
const nameWildcard = name.indexOf('*');
const prefix = name.slice(0, nameWildcard);
const suffix = name.slice(nameWildcard + 1);
let entries;
try {
entries = await readdir(join(root, directory), { withFileTypes: true });
} catch (error) {
if (isErrno(error, 'ENOENT')) return Object.freeze([]);
throw error;
}
return Object.freeze(entries
.filter((entry) =>
(entry.isFile() || entry.isSymbolicLink()) &&
entry.name.startsWith(prefix) &&
entry.name.endsWith(suffix) &&
entry.name.length > prefix.length + suffix.length)
.map((entry) => posix.join(directory, entry.name))
.sort((left, right) => left.localeCompare(right)));
};

const localDiagnostic = (
code: 'AB6006' | 'AB6011' | 'AB6012',
message: string,
target: string,
): Diagnostic => Object.freeze({
code,
message,
recovery: 'Repair the generated Claude document so it satisfies the vendored pinned schema, then rebuild.',
severity: 'error',
target,
});

export const validateClaudePluginFiles = async (
options: ValidateClaudePluginFilesOptions,
): Promise<readonly Diagnostic[]> => {
const pluginDirectory = resolve(options.pluginDirectory);
const validators = new Map(
claudeArtifactValidation.schemas.map((schema) => [schema.name, schema.validate]),
);
const diagnostics: Diagnostic[] = [];
for (const contract of claudeArtifactValidation.documents) {
let paths: readonly string[];
try {
paths = await matchingDocumentPaths(pluginDirectory, contract.path);
} catch {
diagnostics.push(localDiagnostic(
'AB6012',
`Claude bundle document pattern ${JSON.stringify(contract.path)} could not be read.`,
options.target,
));
continue;
}
if (paths.length === 0 && contract.required) {
diagnostics.push(localDiagnostic(
'AB6011',
`Required Claude bundle document ${JSON.stringify(contract.path)} is missing.`,
options.target,
));
continue;
}
for (const relativePath of paths) {
let document: unknown;
try {
document = JSON.parse(await readFile(join(pluginDirectory, relativePath), 'utf8')) as unknown;
} catch (error) {
if (isErrno(error, 'ENOENT') && !contract.required) continue;
diagnostics.push(localDiagnostic(
isErrno(error, 'ENOENT') ? 'AB6011' : 'AB6006',
isErrno(error, 'ENOENT')
? `Required Claude bundle document ${JSON.stringify(relativePath)} is missing.`
: `Claude bundle document ${JSON.stringify(relativePath)} is unreadable or not valid JSON.`,
options.target,
));
continue;
}
const validate = validators.get(contract.schema);
if (validate === undefined) continue;
let issues;
try {
issues = validate(document);
} catch {
issues = Object.freeze([Object.freeze({
instancePath: '/',
message: 'schema validation failed',
})]);
}
for (const issue of issues) {
diagnostics.push(localDiagnostic(
'AB6012',
`Claude bundle document ${JSON.stringify(relativePath)} is invalid for schema ` +
`${JSON.stringify(contract.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`,
options.target,
));
}
}
}
return freezeDiagnostics(diagnostics);
};

export const validateClaudePlugin = async (
options: ValidateClaudePluginOptions,
): Promise<ClaudePluginValidationReport> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export interface ValidateCodexPluginOptions {
readonly target: string;
}

export interface ValidateCodexPluginFilesOptions {
readonly pluginDirectory: string;
readonly target: string;
}

interface PinnedDocumentContract {
readonly path: string;
readonly required: boolean;
Expand Down Expand Up @@ -182,6 +187,11 @@ const validatePinnedDocuments = async (
return freezeDiagnostics(diagnostics);
};

export const validateCodexPluginFiles = async (
options: ValidateCodexPluginFilesOptions,
): Promise<readonly Diagnostic[]> =>
validatePinnedDocuments(resolve(options.pluginDirectory), options.target);

const generatedJsonFiles = async (directory: string): Promise<readonly string[]> =>
Object.freeze((await readdir(directory, { recursive: true }))
.filter((path) => path.endsWith('.json'))
Expand Down Expand Up @@ -388,7 +398,7 @@ export const validateCodexPlugin = async (
options.target,
'Use the vendored pinned schema diagnostics until Codex publishes a plugin validation developer tool.',
),
...await validatePinnedDocuments(pluginDirectory, options.target),
...await validateCodexPluginFiles({ pluginDirectory, target: options.target }),
...await schemaGenerationDiagnostics({
cwd,
executable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface ValidateCursorPluginOptions {
readonly target: string;
}

export interface ValidateCursorPluginFilesOptions {
readonly containmentRoot?: string;
readonly pluginDirectory: string;
readonly target: string;
}

interface CursorProbe {
readonly diagnostics: readonly Diagnostic[];
readonly unavailable: boolean;
Expand Down Expand Up @@ -288,20 +294,26 @@ const displayPath = (root: string, path: string): string => relative(root, path)

const symlinkDiagnostics = async (
pluginDirectory: string,
containmentRoot: string,
target: string,
): Promise<readonly Diagnostic[]> => {
let rootRealPath: string;
try {
rootRealPath = await realpath(pluginDirectory);
rootRealPath = await realpath(containmentRoot);
} catch (error) {
if (isErrno(error, 'ENOENT')) return Object.freeze([]);
return freezeDiagnostics([diagnostic(
'AB6028',
'The Cursor bundle directory could not be resolved for symlink containment validation.',
containmentRoot === pluginDirectory
? 'The Cursor bundle directory could not be resolved for symlink containment validation.'
: `Cursor local plugin root ${JSON.stringify(containmentRoot)} could not be resolved for symlink containment validation.`,
'error',
target,
)]);
}
const containmentLabel = containmentRoot === pluginDirectory
? 'the Cursor bundle directory'
: `Cursor local plugin root ${JSON.stringify(containmentRoot)}`;
const diagnostics: Diagnostic[] = [];
const visit = async (directory: string): Promise<void> => {
let entries;
Expand All @@ -325,15 +337,15 @@ const symlinkDiagnostics = async (
if (!isInsideOrEqual(rootRealPath, targetPath)) {
diagnostics.push(diagnostic(
'AB6028',
`${displayPath(pluginDirectory, path)} is a symlink whose real target escapes the Cursor bundle directory.`,
`${displayPath(pluginDirectory, path)} is a symlink whose real target escapes ${containmentLabel}.`,
'error',
target,
));
}
} catch {
diagnostics.push(diagnostic(
'AB6028',
`${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside the Cursor bundle directory.`,
`${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside ${containmentLabel}.`,
'error',
target,
));
Expand Down Expand Up @@ -394,17 +406,47 @@ const tokenDiagnostics = (
return freezeDiagnostics(diagnostics);
};

export const validateCursorPluginSymlinks = async (
options: ValidateCursorPluginFilesOptions,
): Promise<readonly Diagnostic[]> => {
const pluginDirectory = resolve(options.pluginDirectory);
return symlinkDiagnostics(
pluginDirectory,
resolve(options.containmentRoot ?? pluginDirectory),
options.target,
);
};

export const validateCursorPluginFiles = async (
options: ValidateCursorPluginFilesOptions,
): Promise<readonly Diagnostic[]> => {
const pluginDirectory = resolve(options.pluginDirectory);
const [localDocuments, precedence, symlinks] = await Promise.all([
readDocuments(pluginDirectory, options.target),
manifestPrecedenceDiagnostics(pluginDirectory, options.target),
validateCursorPluginSymlinks({
...(options.containmentRoot === undefined ? {} : { containmentRoot: options.containmentRoot }),
pluginDirectory,
target: options.target,
}),
]);
return freezeDiagnostics([
...localDocuments.diagnostics,
...precedence,
...symlinks,
...localDocuments.documents.flatMap((document) => tokenDiagnostics(document, options.target)),
]);
};

export const validateCursorPlugin = async (
options: ValidateCursorPluginOptions,
): Promise<CursorPluginValidationReport> => {
const pluginDirectory = resolve(options.pluginDirectory);
const executable = options.executable ?? 'cursor-agent';
const run = options.run ?? runCursorCommand;
const [probe, localDocuments, precedence, symlinks] = await Promise.all([
const [probe, localDiagnostics] = await Promise.all([
probeCursor(executable, dirname(pluginDirectory), run, options.target),
readDocuments(pluginDirectory, options.target),
manifestPrecedenceDiagnostics(pluginDirectory, options.target),
symlinkDiagnostics(pluginDirectory, options.target),
validateCursorPluginFiles({ pluginDirectory, target: options.target }),
]);
const transparency = diagnostic(
'AB6026',
Expand All @@ -415,10 +457,7 @@ export const validateCursorPlugin = async (
const diagnostics = freezeDiagnostics([
transparency,
...probe.diagnostics,
...localDocuments.diagnostics,
...precedence,
...symlinks,
...localDocuments.documents.flatMap((document) => tokenDiagnostics(document, options.target)),
...localDiagnostics,
]);
const failed = diagnostics.some((entry) => entry.severity === 'error');
const warnings = diagnostics.some((entry) => entry.severity === 'warning');
Expand Down
Loading
Loading