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
5 changes: 5 additions & 0 deletions .changeset/findings-wave3-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Address the third-wave review findings across the adapters: Codex artifacts honor `plugin.logo` (interface field + shipped image), the generated fallback prompt is bounded to the pinned 128-code-point limit and authored prompts are counted in code points, the transcribed Codex plugin schema rejects backslash parent traversal in component and interface-asset paths and accepts case-insensitive HTTP(S) schemes, Claude marketplace relative paths allow harmless `.` segments again (only `..` escapes), `permission/request` envelopes accept every `tool_input` shape the pinned schema declares, adapter revisions advance for the promoted event contracts (claude 1.22.0, codex 1.6.0, cursor 1.8.0, plugin 1.21.0), the G5 agents provenance note reconciles with the published parity rows, and the repository-owned capability-table hash pins are removed per the documented hashing policy.
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@
"retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents PreCompact input as trigger plus nullable custom_instructions and allows decision:block with reason, while discarding continue and systemMessage. PostCompact adds compact_summary, has no decision control, and discards continue and systemMessage.",
"2026-09-02: live Claude Code 2.1.257 non-interactive captures recorded PreCompact and PostCompact for manual compaction; scrubbed envelopes are tests/fixtures/events/claude-pre-compact.json and tests/fixtures/events/claude-post-compact.json.",
"The hooks reference states prompt_id requires Claude Code 2.1.196 or later; the pinned 2.1.250 release covers that input field without a version bump.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 deferred the agents component per the G5 narrowing in #107; the plugin.agents capability rows published by #346 are parity evidence only — the G5 gate still prohibits emitting agent components until a later stage admits them.",
"2026-09-01: https://code.claude.com/docs/en/plugins \"Ship default settings with your plugin\": a plugin may include settings.json at the plugin root \"to apply default configuration when the plugin is enabled. Currently, only the `agent` and `subagentStatusLine` keys are supported.\" The file-locations table of https://code.claude.com/docs/en/plugins-reference repeats the same bound: \"Settings | settings.json | Default configuration applied when the plugin is enabled. Only the agent and subagentStatusLine keys are supported\".",
"2026-09-01: The same plugins section fixes precedence and host tolerance: \"Settings from `settings.json` take priority over `settings` declared in `plugin.json`. Unknown keys are silently ignored.\" Agent Bundle tightens the silent ignore into the build error claude.settings.field.unknown, the same way an unknown LSP server field is rejected, so a documented component an author asked for is never dropped without a diagnostic; an empty settings object is rejected too, because it declares no default configuration.",
"2026-09-01: \"Setting `agent` activates one of the plugin's custom agents as the main thread, applying its system prompt, tool restrictions, and model\"; the documented example value \"security-reviewer\" names an agent in the plugin's agents/ directory. The plugin agents/ component remains deferred by the #100 stage-2 G5 gate recorded in merged PR #220, so this compiler emits no agents/ tree: a declared `agent` resolves only when the author ships that agent by other means, such as the prebuilt payload surface, and the compiler emits the claude.settings.agent.deferred warning to keep the dangling-reference risk visible instead of implying an agents component exists.",
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.21.0',
adapterRevision: '1.22.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -1228,7 +1228,8 @@ const isInternalSubdirectory = (value: string): boolean =>
isNonemptyString(value) &&
!value.startsWith('/') &&
!value.startsWith('\\') &&
!value.split(/[\\/]/u).some((segment) => segment === '.' || segment === '..');
// Only parent traversal escapes the marketplace; no-op '.' segments stay in.
!value.split(/[\\/]/u).some((segment) => segment === '..');

const isInternalRelativePath = (value: string): boolean =>
value === './' ||
Expand Down
24 changes: 19 additions & 5 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
type TargetArtifactDocumentValidator,
type TargetArtifactPlan,
} from './types.ts';
import { pluginLogoManifestRef, withPluginLogoEntry } from './plugin-logo.ts';
import { withInstallSurface } from '../install/surface.ts';
import { deepFreeze } from '../core/freeze.ts';

Expand Down Expand Up @@ -156,7 +157,7 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Codex'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.5.0',
adapterRevision: '1.6.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -495,7 +496,8 @@ const planCodexInterface = (
} else {
let valid = true;
for (const [index, item] of items.entries()) {
if (isNonemptyString(item) && item.length <= 128) continue;
// Code points, not UTF-16 units, to match JSON Schema maxLength.
if (isNonemptyString(item) && [...item].length <= 128) continue;
valid = false;
diagnostics.push(errorDiagnostic(
'codex.interface.default-prompt.item.invalid',
Expand Down Expand Up @@ -823,16 +825,22 @@ export const planCodexArtifacts = (
}

const description = model.metadata.description ?? model.metadata.name;
// The generated fallback prompt must stay within the pinned 128-code-point
// defaultPrompt limit even for maximum-length plugin names.
const generatedPrompt = [...`Help me use ${model.metadata.name}.`].slice(0, 128).join('');
const generatedInterface = {
capabilities: [
...(mcp === undefined ? [] : ['mcp']),
...(hookDocument === undefined ? [] : ['hooks']),
...(model.skills.some((skill) => isSelected(skill.targets)) ? ['skills'] : []),
],
defaultPrompt: [`Help me use ${model.metadata.name}.`],
defaultPrompt: [generatedPrompt],
developerName: model.metadata.name,
category: 'Productivity',
displayName: model.metadata.name,
...(model.metadata.logo === undefined
? {}
: { logo: pluginLogoManifestRef(model.metadata.logo.path) }),
longDescription: description,
shortDescription: description,
} satisfies Readonly<Record<string, unknown>>;
Expand Down Expand Up @@ -866,7 +874,7 @@ export const planCodexArtifacts = (
const marketplaceValid = validateMarketplace(marketplace);
diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors));

return withInstallSurface(standardPluginArtifactPlan({
const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: sourceInputs(
...manifestMetadata.sourceInputs,
...interfacePlan.sourceInputs,
Expand Down Expand Up @@ -896,7 +904,13 @@ export const planCodexArtifacts = (
...(options.sharedCopyEntries === undefined ? {} : { sharedCopyEntries: options.sharedCopyEntries }),
pluginRelativePath: codexArtifactPaths.plugin,
targetName,
}), model, targetName === 'plugin' ? 'plugin' : 'codex');
});
// interface.logo references an artifact path, so the referenced image must
// ship; shared-copy suppression leaves emission to the composite's owner.
const plan = options.sharedCopyEntries === false
? basePlan
: Object.freeze({ ...basePlan, entries: withPluginLogoEntry(basePlan.entries, model) });
return withInstallSurface(plan, model, targetName === 'plugin' ? 'plugin' : 'codex');
};

export const codexAdapter: TargetAdapter = Object.freeze({
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const cursorManifest = (
});

const metadata = Object.freeze({
adapterRevision: '1.7.0',
adapterRevision: '1.8.0',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const artifactValidation = deepFreeze({
});

const metadata = Object.freeze({
adapterRevision: '1.20.0',
adapterRevision: '1.21.0',
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`,
// Metadata schemas must exactly match the validation contract: each host's
// documents, with one shared Claude-format hook schema (the pinned Codex
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"Component paths must begin with ./ and must not contain a parent-directory segment so they remain inside the plugin root.",
"Inline mcpServers values must be objects; inline hook documents use the same closed command-hook shape as hooks.schema.json.",
"The closed interface object admits every documented install-surface field; brandColor requires a six-digit hexadecimal value, external links require http(s), asset paths must stay inside the plugin root, and screenshots must be ./assets/-relative PNG paths.",
"The apps pointer is const-locked to ./.app.json, and app.schema.json requires a nonempty apps map whose entries carry exactly one nonempty registered-connection id."
"The apps pointer is const-locked to ./.app.json, and app.schema.json requires a nonempty apps map whose entries carry exactly one nonempty registered-connection id.",
"Component and interface-asset path patterns treat backslashes as separators and reject them outright so Windows-form parent traversal cannot escape the plugin root; HTTP(S) URL scheme patterns are case-insensitive to match WHATWG URL protocol normalization."
]
},
"schemas": {
Expand All @@ -35,8 +36,8 @@
"url": "https://github.com/openai/codex/blob/main/codex-rs/core/config.schema.json"
},
"plugin.schema.json": {
"bytes": 4802,
"sha256": "6d8238718b8d74c59f5519d996c6a4c707ab5ddb02781dfef401333f3ca0b4a5",
"bytes": 4962,
"sha256": "decee14ec76a602701f3c312aee135a0983c1ce95fa89dafc588ebb5c968843b",
"url": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md"
}
},
Expand Down
20 changes: 10 additions & 10 deletions packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"$id": "https://agent-bundle.dev/schemas/codex/0.147.0/plugin.schema.json",
"$defs": {
"componentPath": {
"pattern": "^\\./(?!(?:.*\\/)?\\.\\.(?:\\/|$)).+",
"pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\]+$",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject line terminators in hardened path patterns

The replacement final class matches line terminators, while the negative lookahead's .* does not. Consequently, a component path containing a newline followed by /../../outside passes this schema even though POSIX normalization escapes the plugin root; the same expression is copied into the three interface-asset patterns. Since this schema is the local guard for Codex manifests, exclude line terminators or make the traversal check span every character.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #397 (merged as d25a9c6). plugin.schema.json now excludes line terminators (\n, \r, U+2028, U+2029), other control characters, and backslash-form parent segments in componentPath, composerIcon, logo, logoDark, and screenshots[] so a newline followed by /../ can no longer slip past the traversal guard; PROVENANCE.json sha/bytes re-pinned, Codex adapter revision 1.9.0 / composite plugin adapter 1.24.0, and tests/codex-plugin-validation.test.ts gained negative fixtures with embedded newlines and traversal for every path field.

"type": "string"
},
"hookDocument": {
Expand Down Expand Up @@ -51,13 +51,13 @@
"properties": {
"email": { "format": "email", "type": "string" },
"name": { "minLength": 1, "type": "string" },
"url": { "format": "uri", "pattern": "^https?://", "type": "string" }
"url": { "format": "uri", "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" }
},
"required": ["name"],
"type": "object"
},
"description": { "minLength": 1, "type": "string" },
"homepage": { "format": "uri", "pattern": "^https?://", "type": "string" },
"homepage": { "format": "uri", "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" },
"hooks": {
"oneOf": [
{ "$ref": "#/$defs/componentPath" },
Expand All @@ -80,18 +80,18 @@
"brandColor": { "pattern": "^#[0-9A-Fa-f]{6}$", "type": "string" },
"capabilities": { "items": { "minLength": 1, "pattern": "\\S", "type": "string" }, "type": "array" },
"category": { "minLength": 1, "pattern": "\\S", "type": "string" },
"composerIcon": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" },
"composerIcon": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\]+$", "type": "string" },
"defaultPrompt": { "items": { "maxLength": 128, "minLength": 1, "pattern": "\\S", "type": "string" }, "maxItems": 3, "minItems": 1, "type": "array" },
"developerName": { "minLength": 1, "pattern": "\\S", "type": "string" },
"displayName": { "minLength": 1, "pattern": "\\S", "type": "string" },
"logo": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" },
"logoDark": { "pattern": "^\\./(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "type": "string" },
"logo": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\]+$", "type": "string" },
"logoDark": { "pattern": "^\\./(?!(?:.*[/\\\\])?\\.\\.(?:[/\\\\]|$))[^\\\\]+$", "type": "string" },
"longDescription": { "minLength": 1, "pattern": "\\S", "type": "string" },
"privacyPolicyURL": { "pattern": "^https?://", "type": "string" },
"privacyPolicyURL": { "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" },
"screenshots": { "items": { "pattern": "^\\./assets/(?!.*(?:^|/)\\.\\.(?:/|$)).+\\.png$", "type": "string" }, "type": "array" },
"shortDescription": { "minLength": 1, "pattern": "\\S", "type": "string" },
"termsOfServiceURL": { "pattern": "^https?://", "type": "string" },
"websiteURL": { "pattern": "^https?://", "type": "string" }
"termsOfServiceURL": { "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" },
"websiteURL": { "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" }
},
"required": ["displayName", "shortDescription", "longDescription", "developerName", "category", "capabilities", "defaultPrompt"],
"type": "object"
Expand All @@ -111,7 +111,7 @@
]
},
"name": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" },
"repository": { "format": "uri", "pattern": "^https?://", "type": "string" },
"repository": { "format": "uri", "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://", "type": "string" },
"skills": { "$ref": "#/$defs/componentPath" },
"version": { "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", "type": "string" }
},
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-bundle/src/events/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,10 @@ export const validateNativeEventEnvelope = (
}
if (canonicalEvent === 'permission/request') {
requireNativeString(native, 'tool_name');
if (typeof native.tool_input !== 'object' || native.tool_input === null || Array.isArray(native.tool_input)) {
return nativeEventError('native tool_input must be an object');
// The pinned permission-request input schema declares `"tool_input": true`
// (any JSON value), so presence is required but shape is tool-defined.
if (!Object.hasOwn(native, 'tool_input') || native.tool_input === undefined) {
return nativeEventError('native tool_input is required');
Comment on lines +224 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Claude tool_input validation object-shaped

This relaxed check applies to Claude as well as Codex, although only the pinned Codex schema declares tool_input: true; Claude's PermissionRequest envelope remains object-shaped, like its other tool events. A malformed Claude replay or playground request with null, an array, or a scalar now reaches handlers as if it were a valid host envelope. Gate the any-JSON behavior on target === 'codex' and preserve the object check for Claude.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #397 (merged as d25a9c6). The any-JSON tool_input relaxation in events/projection.ts is now Codex-only (whose pinned schema declares "tool_input": true); Claude permission/request envelopes keep the object-shaped requirement. Covered for both targets in tests/event-project.test.ts and tests/route-unit/event-project.test.ts.

}
requirePermissionMode(native);
if (target === 'codex') {
Expand Down
17 changes: 8 additions & 9 deletions packages/agent-bundle/tests/adapter-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ it('records exact immutable metadata for every built-in target', () => {
],
});
expect(registryMetadata(registry, 'codex')).toEqual({
adapterRevision: '1.5.0',
adapterRevision: '1.6.0',
observedVersion: '0.147.0',
schemas: [
{
Expand All @@ -93,12 +93,12 @@ it('records exact immutable metadata for every built-in target', () => {
{
name: 'plugin',
revision: '0.147.0',
sha256: '6d8238718b8d74c59f5519d996c6a4c707ab5ddb02781dfef401333f3ca0b4a5',
sha256: 'decee14ec76a602701f3c312aee135a0983c1ce95fa89dafc588ebb5c968843b',
},
],
});
expect(registryMetadata(registry, 'claude')).toEqual({
adapterRevision: '1.21.0',
adapterRevision: '1.22.0',
observedVersion: '2.1.250',
schemas: [
{
Expand Down Expand Up @@ -144,7 +144,7 @@ it('records exact immutable metadata for every built-in target', () => {
],
});
expect(registryMetadata(registry, 'cursor')).toEqual({
adapterRevision: '1.7.0',
adapterRevision: '1.8.0',
observedVersion: '2026-08-28',
schemas: [
{
Expand All @@ -169,7 +169,7 @@ it('records exact immutable metadata for every built-in target', () => {
},
],
});
expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.20.0');
expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.21.0');
});

it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => {
Expand Down Expand Up @@ -209,11 +209,10 @@ it('records observed capability versions and rehashes schema snapshots against p
expect(schema.revision).toBe(metadata.observedVersion);
}

if (target === 'claude') {
expect(sha256Hex(capability)).toBe('ad8913270725c3f4abbfbf12bac888092e271cecc9c64adaa114093c9e2e70de');
}
// Repository-owned capability tables are deliberately NOT hash-pinned:
// Git and adapterRevision version them (README "What gets hashed"), and a
// byte pin here forced a digest re-pin on every evidence edit.
if (target === 'cursor') {
expect(sha256Hex(capability)).toBe('d056a5575fb98408e12e9236af356227911aec657f226eb720ff9b6a120c5819');
const pluginSchema = JSON.parse(await readFile(
new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url),
'utf8',
Expand Down
Loading