From 087de1adbac12964197a4796d89dc4b69d1cda15 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:49:21 +0000 Subject: [PATCH 1/7] fix(claude): validate numeric config bounds in artifacts (#287 r3911238592) --- packages/agent-bundle/src/adapters/claude.ts | 53 +++++++++- .../tests/claude-plugin-validation.test.ts | 96 ++++++++++++++++++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 88805d113..5413300d1 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -61,6 +61,8 @@ import { type StandardPluginHostDocument, type TargetAdapter, type TargetArtifactCopy, + type TargetArtifactDocumentIssue, + type TargetArtifactDocumentValidator, type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; @@ -438,6 +440,55 @@ const agentCapabilities = Object.freeze(Object.fromEntries( )); const packageLifecycle = capabilityTable.plugin.packageLifecycle; +const validateClaudePluginSchema = validateJsonSchemaDocument(validatePlugin); + +const numericUserConfigIssues = ( + userConfig: unknown, + instancePath: string, +): readonly TargetArtifactDocumentIssue[] => { + if (!isDataRecord(userConfig)) return Object.freeze([]); + const issues: TargetArtifactDocumentIssue[] = []; + for (const [key, value] of Object.entries(userConfig)) { + if (!isDataRecord(value) || value.type !== 'number') continue; + const min = value.min; + const max = value.max; + const defaultValue = value.default; + if (typeof min === 'number' && typeof max === 'number' && min > max) { + issues.push(Object.freeze({ + instancePath: `${instancePath}/${key}`, + message: 'numeric option minimum must be less than or equal to its maximum', + })); + } + if (typeof defaultValue !== 'number') continue; + if (typeof min === 'number' && defaultValue < min) { + issues.push(Object.freeze({ + instancePath: `${instancePath}/${key}/default`, + message: 'numeric option default must be greater than or equal to its minimum', + })); + } + if (typeof max === 'number' && defaultValue > max) { + issues.push(Object.freeze({ + instancePath: `${instancePath}/${key}/default`, + message: 'numeric option default must be less than or equal to its maximum', + })); + } + } + return Object.freeze(issues); +}; + +const validateClaudePluginDocument: TargetArtifactDocumentValidator = (document) => { + const schemaIssues = validateClaudePluginSchema(document); + if (schemaIssues.length > 0 || !isDataRecord(document)) return schemaIssues; + const issues = [...numericUserConfigIssues(document.userConfig, '/userConfig')]; + if (Array.isArray(document.channels)) { + for (const [index, channel] of document.channels.entries()) { + if (!isDataRecord(channel)) continue; + issues.push(...numericUserConfigIssues(channel.userConfig, `/channels/${String(index)}/userConfig`)); + } + } + return Object.freeze(issues); +}; + export const claudeArtifactValidation = deepFreeze({ documents: [ Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), @@ -455,7 +506,7 @@ export const claudeArtifactValidation = deepFreeze({ Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }), Object.freeze({ name: 'monitors', validate: validateJsonSchemaDocument(validateMonitors) }), - Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }), + Object.freeze({ name: 'plugin', validate: validateClaudePluginDocument }), Object.freeze({ name: 'settings', validate: validateJsonSchemaDocument(validateSettings) }), Object.freeze({ name: 'theme', validate: validateJsonSchemaDocument(validateTheme) }), ], diff --git a/packages/agent-bundle/tests/claude-plugin-validation.test.ts b/packages/agent-bundle/tests/claude-plugin-validation.test.ts index 05590ae99..a340894c2 100644 --- a/packages/agent-bundle/tests/claude-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/claude-plugin-validation.test.ts @@ -1,12 +1,104 @@ -import { dirname, resolve } from 'node:path'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; -import { expect, it } from '@rstest/core'; +import { afterEach, expect, it } from '@rstest/core'; import { validateClaudePlugin, + validateClaudePluginFiles, type ClaudePluginCommandRunner, } from '../src/host-contracts/claude-plugin-validation.ts'; +const fixtureRoots: string[] = []; + +afterEach(async () => { + await Promise.all(fixtureRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const pluginWithNumberOption = async ( + option: Readonly>, + location: 'channel' | 'plugin' = 'plugin', +): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-validation-')); + fixtureRoots.push(root); + const pluginDirectory = join(root, '.claude-plugin'); + await mkdir(pluginDirectory, { recursive: true }); + const userConfig = { + count: { + description: 'Number of items.', + title: 'Count', + type: 'number', + ...option, + }, + }; + await writeFile(join(pluginDirectory, 'plugin.json'), `${JSON.stringify({ + author: { name: 'Fixture' }, + description: 'Fixture plugin.', + name: 'fixture-plugin', + version: '1.0.0', + ...(location === 'plugin' + ? { userConfig } + : { channels: [{ server: 'fixture', userConfig }] }), + }, null, 2)}\n`); + return root; +}; + +it('rejects a numeric userConfig minimum greater than its maximum', async () => { + const pluginDirectory = await pluginWithNumberOption({ max: 5, min: 10 }); + + await expect(validateClaudePluginFiles({ + pluginDirectory, + target: 'claude', + })).resolves.toEqual([expect.objectContaining({ + code: 'AB6012', + message: expect.stringContaining('minimum must be less than or equal to its maximum'), + })]); +}); + +it('rejects a numeric userConfig default below its minimum', async () => { + const pluginDirectory = await pluginWithNumberOption({ default: 4, min: 5 }); + + await expect(validateClaudePluginFiles({ + pluginDirectory, + target: 'claude', + })).resolves.toEqual([expect.objectContaining({ + code: 'AB6012', + message: expect.stringContaining('default must be greater than or equal to its minimum'), + })]); +}); + +it('rejects a numeric channel userConfig default above its maximum', async () => { + const pluginDirectory = await pluginWithNumberOption({ default: 11, max: 10 }, 'channel'); + + await expect(validateClaudePluginFiles({ + pluginDirectory, + target: 'claude', + })).resolves.toEqual([expect.objectContaining({ + code: 'AB6012', + message: expect.stringContaining('default must be less than or equal to its maximum'), + })]); +}); + +it('accepts numeric userConfig defaults within declared bounds', async () => { + const pluginDirectory = await pluginWithNumberOption({ default: 7, max: 10, min: 5 }); + + await expect(validateClaudePluginFiles({ + pluginDirectory, + target: 'claude', + })).resolves.toEqual([]); +}); + +it('handles numeric userConfig declarations with only one bound', async () => { + const minimumOnly = await pluginWithNumberOption({ default: 5, min: 5 }); + const maximumOnly = await pluginWithNumberOption({ default: 10, max: 10 }); + + await expect(Promise.all([ + validateClaudePluginFiles({ pluginDirectory: minimumOnly, target: 'claude' }), + validateClaudePluginFiles({ pluginDirectory: maximumOnly, target: 'claude' }), + ])).resolves.toEqual([[], []]); +}); + const runWith = ( validation: Readonly<{ exitCode: number; stderr?: string; stdout: string }>, ): { readonly calls: unknown[]; readonly run: ClaudePluginCommandRunner } => { From b62876214d781cb1b6a975980ba18accb55c47d4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:55:25 +0000 Subject: [PATCH 2/7] fix(claude): reject marketplace backslash traversal (#314 r3918535243) --- packages/agent-bundle/src/adapters/claude.ts | 12 ++++----- .../adapters/schemas/claude/PROVENANCE.json | 4 +-- .../schemas/claude/marketplace.schema.json | 4 +-- .../tests/adapter-metadata.test.ts | 2 +- .../agent-bundle/tests/host-adapters.test.ts | 25 +++++++++++++++++++ 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 5413300d1..98420a5a2 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -1230,6 +1230,10 @@ const isInternalSubdirectory = (value: string): boolean => !value.startsWith('\\') && !value.split(/[\\/]/u).some((segment) => segment === '.' || segment === '..'); +const isInternalRelativePath = (value: string): boolean => + value === './' || + (value.startsWith('./') && isInternalSubdirectory(value.slice(2))); + const isSafeArchiveUrl = (value: string): boolean => { let parsed: URL; try { @@ -1266,9 +1270,7 @@ const planMarketplacePluginSource = ( pluginRoot: string | undefined, ): ClaudeMarketplaceSourcePlan => { if (typeof declared === 'string') { - const internalRelative = - declared.startsWith('./') && - !declared.split('/').includes('..'); + const internalRelative = isInternalRelativePath(declared); const bareUnderPluginRoot = pluginRoot !== undefined && declared !== '.' && @@ -1809,9 +1811,7 @@ const planClaudeMarketplace = (model: NormalizedPlugin): ClaudeMarketplacePlan = } const value = metadataValue[field]; const pathValid = field !== 'pluginRoot' || - (isNonemptyString(value) && - value.startsWith('./') && - !value.split('/').includes('..')); + (isNonemptyString(value) && isInternalRelativePath(value)); if (!isNonemptyString(value) || !pathValid) { diagnostics.push(marketplaceDiagnostic( `claude.marketplace.metadata.${field}.invalid`, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 0633e0aab..47a549c34 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -17,8 +17,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" }, "marketplace.schema.json": { - "bytes": 14865, - "sha256": "44e105038ced3fceee4cb7ff81c7caad63965e3fd5a42b6db6255da771236b5d", + "bytes": 14889, + "sha256": "7fc9f2762fa0b15ddb7c1319e8a59ce486a83c39baab1b5f848481ee2e3be7eb", "url": "https://code.claude.com/docs/en/plugin-marketplaces" }, "mcp.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json index 776a94e2b..f794ccc92 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json @@ -133,7 +133,7 @@ { "oneOf": [ { - "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./", + "pattern": "^\\./(?!(?:.*[\\\\/])?\\.\\.?(?:[\\\\/]|$))", "type": "string" }, { @@ -302,7 +302,7 @@ "properties": { "description": { "minLength": 1, "type": "string" }, "pluginRoot": { - "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))\\./.+", + "pattern": "^\\./(?!(?:.*[\\\\/])?\\.\\.?(?:[\\\\/]|$)).+", "type": "string" }, "version": { "minLength": 1, "type": "string" } diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 9cd9806fb..0b64d1809 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -114,7 +114,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'marketplace', revision: '2.1.250', - sha256: '44e105038ced3fceee4cb7ff81c7caad63965e3fd5a42b6db6255da771236b5d', + sha256: '7fc9f2762fa0b15ddb7c1319e8a59ce486a83c39baab1b5f848481ee2e3be7eb', }, { name: 'mcp', diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 11573a315..c289687eb 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -726,6 +726,29 @@ it.each([ }); }); +it.each([ + { + code: 'claude.marketplace.plugin.source.relative.invalid', + marketplace: { plugin: { source: './plugin\\..\\outside' } }, + }, + { + code: 'claude.marketplace.metadata.pluginRoot.invalid', + marketplace: { + metadata: { pluginRoot: './plugins\\..\\outside' }, + plugin: { source: 'review-tools' }, + }, + }, +])('rejects backslash traversal in Claude marketplace relative paths %#', ({ code, marketplace }) => { + const model = withClaudeMarketplace(plugin, marketplace); + const plan = createDefaultRegistry().get('claude').plan(model); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + message: expect.stringMatching(/stays? inside the marketplace/u), + })); + expect(writeContents(model, 'claude')['.claude-plugin/marketplace.json']).toBeUndefined(); +}); + it('accepts archive entry authentication only for an archive source', () => { const source = { source: 'archive', @@ -961,6 +984,8 @@ it('pins the full closed Claude marketplace schema with the documented source ma { ...manifest, plugins: [{ ...manifest.plugins[0], unknown: true }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: 'review-tools' }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: './../outside' }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: './plugin\\..\\outside' }] }, + { ...manifest, metadata: { pluginRoot: './plugins\\..\\outside' } }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'github', repo: 'acme/review-tools', extra: true } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://example.test/plugin.zip', sha256: 'bad' } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'command', command: 'plugin-path', mode: 'move' } }] }, From b714dc43a618d45922cfdd0327e78968557f2735 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:58:09 +0000 Subject: [PATCH 3/7] fix(claude): validate authority-only archive hosts (#314 r3918535249) --- .../src/adapters/schemas/claude/PROVENANCE.json | 4 ++-- .../src/adapters/schemas/claude/marketplace.schema.json | 2 +- packages/agent-bundle/tests/adapter-metadata.test.ts | 2 +- packages/agent-bundle/tests/host-adapters.test.ts | 6 ++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index 47a549c34..8d2665ff4 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -17,8 +17,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/plugins" }, "marketplace.schema.json": { - "bytes": 14889, - "sha256": "7fc9f2762fa0b15ddb7c1319e8a59ce486a83c39baab1b5f848481ee2e3be7eb", + "bytes": 14895, + "sha256": "4ffa94e8024966e8080b9d3b338c9612bdfa255802a8491b43f74f90427bc988", "url": "https://code.claude.com/docs/en/plugin-marketplaces" }, "mcp.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json index f794ccc92..fa6e51a2d 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/marketplace.schema.json @@ -157,7 +157,7 @@ "source": { "const": "archive" }, "url": { "format": "uri", - "pattern": "^https://(?!(?:localhost|[^/]+\\.localhost|127(?:\\.[0-9]{1,3}){3}|169\\.254(?:\\.[0-9]{1,3}){2}|metadata(?:\\.google(?:\\.internal)?)?|metadata\\.azure\\.internal|instance-data\\.ec2\\.internal)(?::[0-9]+)?/)", + "pattern": "^https://(?!(?:localhost|[^/]+\\.localhost|127(?:\\.[0-9]{1,3}){3}|169\\.254(?:\\.[0-9]{1,3}){2}|metadata(?:\\.google(?:\\.internal)?)?|metadata\\.azure\\.internal|instance-data\\.ec2\\.internal)(?::[0-9]+)?(?:/|$))", "type": "string" } }, diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 0b64d1809..116aec780 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -114,7 +114,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'marketplace', revision: '2.1.250', - sha256: '7fc9f2762fa0b15ddb7c1319e8a59ce486a83c39baab1b5f848481ee2e3be7eb', + sha256: '4ffa94e8024966e8080b9d3b338c9612bdfa255802a8491b43f74f90427bc988', }, { name: 'mcp', diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index c289687eb..234f3ebad 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -712,6 +712,7 @@ it.each([ url: 'https://artifacts.example.test/review-tools.zip', sha256: 'd'.repeat(64), }, undefined], + [{ source: 'archive', url: 'https://artifacts.example.test' }, undefined], [{ source: 'command', command: 'review-tools plugin-path', timeout: 120, mode: 'copy' }, undefined], [{ source: 'command', command: 'review-tools plugin-path', mode: 'link' }, undefined], ] as const)('emits an authored Claude marketplace plugin source %#', (source, metadata) => { @@ -971,6 +972,7 @@ it('pins the full closed Claude marketplace schema with the documented source ma { source: 'git-subdir', url: 'acme/monorepo', path: 'plugins/review-tools' }, { source: 'npm', package: '@acme/review-tools', version: '~1.2.3', registry: 'https://npm.example.test' }, { source: 'archive', url: 'https://artifacts.example.test/review-tools.zip', sha256: 'c'.repeat(64) }, + { source: 'archive', url: 'https://artifacts.example.test' }, { source: 'command', command: 'review-tools plugin-path', timeout: 60, mode: 'link' }, ]) { expect(validate({ @@ -986,6 +988,10 @@ it('pins the full closed Claude marketplace schema with the documented source ma { ...manifest, plugins: [{ ...manifest.plugins[0], source: './../outside' }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: './plugin\\..\\outside' }] }, { ...manifest, metadata: { pluginRoot: './plugins\\..\\outside' } }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://localhost' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://localhost/plugin.zip' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://metadata' } }] }, + { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://metadata/plugin.zip' } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'github', repo: 'acme/review-tools', extra: true } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'archive', url: 'https://example.test/plugin.zip', sha256: 'bad' } }] }, { ...manifest, plugins: [{ ...manifest.plugins[0], source: { source: 'command', command: 'plugin-path', mode: 'move' } }] }, From 344440e988b8ce25ba4a74e36bd07c7a2e078d55 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:01:34 +0000 Subject: [PATCH 4/7] fix(replay): preserve invocation provenance in renderers (#322 r3919341927) --- .../dev/playground/lifecycle-render-child.ts | 6 ++ .../playground/lifecycle-replay-service.ts | 6 ++ .../tests/lifecycle-replay-service.test.ts | 6 +- .../tests/route-unit/lifecycle-replay.test.ts | 58 ++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts index 5bcce874a..4c5c9dd8b 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-render-child.ts @@ -60,6 +60,12 @@ const render = async (request: LifecycleRenderChildRequest): Promise { '', ].join('\n')), writeProjectFile(root, 'src/events/tool/after.tsx', [ - "import { Agent } from '@agent-bundle/runtime';", + "import { Agent, agent } from '@agent-bundle/runtime';", "import { createElement } from 'react';", 'export default async function AfterTool({ canonical }) {', + ' const context = await agent();', ' return createElement(', ' Agent.Result,', ' null,', " createElement(Agent.Markdown, null, `Observed ${canonical.event} from ${canonical.provenance.host}.`),", - " createElement(Agent.Context, null, 'Lifecycle replay context.'),", + ' createElement(Agent.Context, null, `${context.invocation.operationId}|${context.invocation.surface}`),', ' );', '}', '', @@ -139,7 +143,7 @@ it('replays Claude and Codex PostToolUse through decode, route execution, render ); expect(replay.nativeResponse).toEqual({ hookSpecificOutput: { - additionalContext: 'Lifecycle replay context.', + additionalContext: 'event:tool/after|tool/after', hookEventName: 'PostToolUse', }, }); @@ -189,6 +193,54 @@ it('replays captured prompt/submit and session/end fixtures through native proje } }); +it('preserves replay invocation provenance for an in-process route observing agent context', async () => { + const { graph } = await createFixtureProject(); + const routeModule = { + default: async () => { + const context = await agent(); + return createElement( + Agent.Result, + null, + createElement( + Agent.Context, + null, + `${context.invocation.operationId}|${context.invocation.surface}`, + ), + ); + }, + } satisfies AgentRouteModule; + const service = new LifecycleReplayService({ + prepared: () => ({ graph, targets: ['claude'] }), + loadRouteModule: async () => routeModule, + }); + const native = JSON.parse(await readFile( + new URL('../../../../examples/rsc-agent-runtime/tests/fixtures/events/claude-post-tool-use.json', import.meta.url), + 'utf8', + )) as Record; + + const result = await service.replay({ + binding: { + manifestDigest: graph.digest, + routeId: 'event:tool/after', + target: 'claude', + }, + native, + source: 'fixture', + }); + if ('diagnostics' in result) throw new Error('Expected a lifecycle replay.'); + + expect(result.requestContext.invocation).toMatchObject({ + operationId: 'event:tool/after', + surface: 'tool/after', + }); + expect(result.nativeResponse).toEqual({ + hookSpecificOutput: { + additionalContext: 'event:tool/after|tool/after', + hookEventName: 'PostToolUse', + }, + }); +}); + it('replays the Cursor workspaceOpen starter as an observation with no native response', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-lifecycle-replay-workspace-open-')); roots.push(root); From 4ee9dcad5f6632bd0c88b0ee480c4debad299fa1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:03:27 +0000 Subject: [PATCH 5/7] fix(replay): derive workspace from cursor roots (#322 r3919341940) --- .../src/dev/playground/lifecycle-replay-service.ts | 8 +++++++- .../tests/route-unit/lifecycle-replay.test.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 3bae95cdb..42dd64b7d 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -56,7 +56,13 @@ const replayRequestContext = ( hostContractRevision: string, ): RequestContextProvenance => { const sessionId = nativeText(native, 'session_id') ?? nativeText(native, 'conversation_id'); - const workspaceRoot = nativeText(native, 'cwd'); + const workspaceRoots = native['workspace_roots']; + const firstWorkspaceRoot = Array.isArray(workspaceRoots) && + typeof workspaceRoots[0] === 'string' && + workspaceRoots[0].trim() !== '' + ? workspaceRoots[0] + : undefined; + const workspaceRoot = nativeText(native, 'cwd') ?? firstWorkspaceRoot; return deepFreeze({ actor: { reason: 'not-provided', state: 'unavailable' }, host: { source: 'receipt', state: 'available', value: { name: target } }, diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts index ec2bc687b..2f9865b37 100644 --- a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -285,6 +285,13 @@ it('replays the Cursor workspaceOpen starter as an observation with no native re provenance: { host: 'cursor', nativeEvent: 'workspaceOpen' }, }, nativeInput: target?.fixture?.native, + requestContext: { + workspace: { + source: 'receipt', + state: 'available', + value: { root: '/tmp' }, + }, + }, }); expect((replay as LifecycleReplay).nativeResponse).toBeUndefined(); }); From 9c96988cb57d0c35d5421a657425f0b30451058a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:05:14 +0000 Subject: [PATCH 6/7] fix(dev): invalidate watcher on chmod changes (#329 r3919499846) --- packages/agent-bundle/src/dev/watcher.ts | 2 +- .../agent-bundle/tests/dev-watcher.test.ts | 43 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/dev/watcher.ts b/packages/agent-bundle/src/dev/watcher.ts index 693b23665..96d304297 100644 --- a/packages/agent-bundle/src/dev/watcher.ts +++ b/packages/agent-bundle/src/dev/watcher.ts @@ -36,7 +36,7 @@ const relativePath = (root: string, path: string): string | undefined => { const defaultPathSignature = async (path: string): Promise => { try { const source = await stat(path, { bigint: true }); - return `${source.dev}:${source.ino}:${source.size}:${source.mtimeNs}`; + return `${source.dev}:${source.ino}:${source.size}:${source.mtimeNs}:${source.mode}:${source.ctimeNs}`; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; diff --git a/packages/agent-bundle/tests/dev-watcher.test.ts b/packages/agent-bundle/tests/dev-watcher.test.ts index 0dead382c..ba5f45a5c 100644 --- a/packages/agent-bundle/tests/dev-watcher.test.ts +++ b/packages/agent-bundle/tests/dev-watcher.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, unlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, rm, stat, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -132,6 +132,47 @@ it('drops delayed source events until the path signature changes', async () => { await watcher.close(); }); +it('invalidates a reported file after chmod changes only its executable mode', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-chmod-watcher-')); + const source = join(root, 'script.sh'); + const fake = new FakeWatcher(); + const invalidations: Invalidation[] = []; + await writeFile(source, '#!/bin/sh\nexit 0\n'); + await chmod(source, 0o644); + const watcher = new ProjectWatcher({ + createWatcher: () => fake, + debounceMs: 60_000, + onInvalidation: async (invalidation) => { + invalidations.push(invalidation); + }, + root, + }); + + try { + fake.emit('add', source); + await watcher.flush(); + const before = await stat(source, { bigint: true }); + + await chmod(source, 0o755); + const after = await stat(source, { bigint: true }); + expect(after.size).toBe(before.size); + expect(after.mtimeNs).toBe(before.mtimeNs); + expect(before.mode & 0o111n).toBe(0n); + expect(after.mode & 0o111n).not.toBe(0n); + + fake.emit('change', source); + await watcher.flush(); + + expect(invalidations).toEqual([ + expect.objectContaining({ paths: ['script.sh'] }), + expect.objectContaining({ paths: ['script.sh'] }), + ]); + } finally { + await watcher.close(); + await rm(root, { force: true, recursive: true }); + } +}); + it('waits for the real watcher root before reporting create, change, and delete source inputs', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-real-watcher-')); await mkdir(join(root, 'src'), { recursive: true }); From 4f9e6c6dc7c7d68b9a54de13887be081f60eafd5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 01:06:19 +0000 Subject: [PATCH 7/7] chore: add framework review fixes changeset --- .changeset/framework-review-fixes.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/framework-review-fixes.md diff --git a/.changeset/framework-review-fixes.md b/.changeset/framework-review-fixes.md new file mode 100644 index 000000000..3f8664a2d --- /dev/null +++ b/.changeset/framework-review-fixes.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Harden Claude artifact and marketplace validation, preserve lifecycle replay invocation and workspace provenance, and invalidate development rebuilds after executable-mode changes.