diff --git a/.changeset/findings-wave3-adapters.md b/.changeset/findings-wave3-adapters.md new file mode 100644 index 000000000..8cf8939e8 --- /dev/null +++ b/.changeset/findings-wave3-adapters.md @@ -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. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index bf4dc7d20..621b56769 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -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.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 1f01939e4..567ff169d 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -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), }); @@ -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 === './' || diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 55df42b10..4e4b20e11 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -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'; @@ -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), }); @@ -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', @@ -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>; @@ -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, @@ -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({ diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 75845972d..cb6cec4ea 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -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), }); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index ae3bd48d1..080895741 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -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 diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 15be77353..bf719c0a0 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -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": { @@ -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" } }, diff --git a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json index bef8c5248..fc36741fc 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/plugin.schema.json @@ -3,7 +3,7 @@ "$id": "https://agent-bundle.dev/schemas/codex/0.147.0/plugin.schema.json", "$defs": { "componentPath": { - "pattern": "^\\./(?!(?:.*\\/)?\\.\\.(?:\\/|$)).+", + "pattern": "^\\./(?!(?:.*[\\/\\\\])?\\.\\.(?:[\\/\\\\]|$))[^\\\\]+$", "type": "string" }, "hookDocument": { @@ -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" }, @@ -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" @@ -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" } }, diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 5f4b3da01..e658e153f 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -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'); } requirePermissionMode(native); if (target === 'codex') { diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 46f0fcf93..7df65381b 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -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: [ { @@ -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: [ { @@ -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: [ { @@ -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 () => { @@ -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',