Skip to content

Add capability-scoped secret allowlists - #68

Merged
kentcdodds merged 8 commits into
mainfrom
cursor/secrets-access-control-for-capabilities-53bc
Mar 27, 2026
Merged

kentcdodds merged 8 commits into
mainfrom
cursor/secrets-access-control-for-capabilities-53bc

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Mar 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add per-secret allowed_capabilities storage and normalization alongside existing host policy
  • enforce capability-scoped secret access for x-kody-secret inputs while leaving fetch-time host approval unchanged
  • expose and edit allowed capability names through secret metadata APIs, MCP surfaces, and the account secrets UI
  • fix account secrets editor submission so repeated allowed hosts/capabilities are collected from the form at save time
  • clarify capability-access denial guidance so the model is told to help the user add the denied capability to the secret's allowed capabilities and retry
  • add focused node and MCP E2E coverage for allow/deny behavior and metadata serialization

Testing

  • npm run test -- packages/worker/src/mcp/secrets/errors.node.test.ts packages/worker/src/mcp/executor.node.test.ts packages/worker/src/mcp/run-codemode-registry.node.test.ts
  • npm run test:mcp -- packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added "Allowed capabilities" for secrets so admins can restrict which capabilities may directly resolve secret placeholders; UI and API now display and let admins edit this setting.
  • Documentation

    • Clarified enforcement rules and guidance: allowed-capabilities semantics, admin-only approval, and that saving/updating a secret does not grant capability access.
  • Behavior

    • Secret resolution now enforces capability allowlists at execute time.
  • Tests

    • Added and updated unit and E2E tests for capability-based secret access and error guidance.

@coderabbitai

coderabbitai Bot commented Mar 27, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 01aba7ac-6f23-401b-94d6-29a554da5ec6

📥 Commits

Reviewing files that changed from the base of the PR and between 5b8c478 and 913e37f.

📒 Files selected for processing (1)
  • docs/agents/adding-capabilities.md

📝 Walkthrough

Walkthrough

This PR adds an allowed_capabilities allowlist to secret metadata and enforces capability-level secret resolution across docs, DB schema, service/repo, MCP runtime, executor error handling, client UI, API handlers, and tests.

Changes

Cohort / File(s) Summary
Documentation
docs/agents/adding-capabilities.md, docs/agents/secret-host-approval.md
Documented allowed_capabilities policy, enforcement rule (capability name must be listed to resolve placeholders), admin-UI-only approval, and guidance updates.
DB Migration & Types
packages/worker/migrations/0010-secret-allowed-capabilities.sql, packages/worker/src/mcp/secrets/types.ts, packages/worker/src/mcp/capabilities/secrets/shared.ts
Added allowed_capabilities column; updated types and Zod schema to include allowed_capabilities: string[].
Service & Repo
packages/worker/src/mcp/secrets/service.ts, packages/worker/src/mcp/secrets/repo.ts
Persist, parse, and return allowed_capabilities/allowedCapabilities; added setSecretAllowedCapabilities; updated queries, upsert, and mapping.
Helpers
packages/worker/src/mcp/secrets/allowed-capabilities.ts
New helpers: normalizeAllowedCapabilities, parseAllowedCapabilities, stringifyAllowedCapabilities.
MCP Runtime / Resolver
packages/worker/src/mcp/run-codemode-registry.ts, packages/worker/src/mcp/run-codemode-registry.node.test.ts
Secret resolver now receives capabilityName and rejects resolution when secret’s allowlist does not include the capability; tests updated/added.
Executor & Errors
packages/worker/src/mcp/executor.ts, packages/worker/src/mcp/executor.node.test.ts, packages/worker/src/mcp/secrets/errors.ts, packages/worker/src/mcp/secrets/errors.node.test.ts
Added capability-access-denied message creation/parsing, new ExecutionErrorDetails variant, and tests for parsing/formatting.
Generated UI / Client
packages/worker/client/mcp-apps/generated-ui-shell.ts, packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts, packages/worker/client/routes/account-secrets.tsx
Expose allowed_capabilities in generated UI API and client models; added client-side normalization, form parsing, UI for allowed-capabilities, and validation.
API Handlers
packages/worker/src/app/handlers/account-secrets.ts, packages/worker/src/mcp/generated-ui-api.ts
Handlers parse/persist allowedCapabilities and include allowed_capabilities in generated UI responses.
MCP Docs / Tools
packages/worker/src/mcp/index.ts, packages/worker/src/mcp/tools/execute.ts
Updated embedded MCP docs to state allowed-capabilities metadata and capability-name-based resolution constraint.
Tests & E2E
packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts, packages/worker/src/mcp/secrets/account-secret-save-body.node.test.ts, packages/worker/src/mcp/run-codemode-registry.node.test.ts
Updated expected secret objects to include allowed_capabilities and added tests for request parsing and capability-denied behavior.
Minor
packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
Whitespace-only removal of trailing blank line.

Sequence Diagram

sequenceDiagram
    participant Capability as Capability Handler
    participant Resolver as Secret Resolver
    participant Service as Secret Service
    participant DB as Database
    participant Error as Error Handler

    Capability->>Resolver: request secret (capabilityName, secretRef)
    Resolver->>Service: resolveSecret(secretRef, capabilityName)
    Service->>DB: SELECT secret_entries WHERE name=...
    DB-->>Service: { value, allowed_capabilities }
    alt allowed_capabilities not empty
        Service->>Service: check capabilityName in allowed_capabilities
        alt not present
            Service->>Error: createCapabilitySecretAccessDeniedMessage(secret, capability)
            Error-->>Service: Access Denied Error
            Service-->>Resolver: reject with access-denied error
            Resolver-->>Capability: throw "not allowed for capability"
        else present
            Service-->>Resolver: return secret value
            Resolver-->>Capability: deliver secret value
        end
    else allowed_capabilities empty
        Service-->>Resolver: return secret value (unrestricted)
        Resolver-->>Capability: deliver secret value
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰
I hopped through schemas, tiny and spry,
Tucked capabilities in secrets sly—
Now only named friends may sip the brew,
A twirl, a thump—accesses few,
Carrot cheer for guarded secrets true!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add capability-scoped secret allowlists' directly and clearly describes the main change: introducing capability-scoped allowlists for secrets, which aligns with the PR's primary objective of adding per-secret allowed_capabilities storage and enforcement.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/secrets-access-control-for-capabilities-53bc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

secretName: string,
capabilityName: string,
) {
return `Secret "${secretName}" is not allowed for capability "${capabilityName}".`

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.

@cursoragent do we have special handling for this error so the model knows what it can do to help the user? Add that capability as an allowed capability to the secret, like we do when it's used with fetch? If we don't, then please add that.

@cursor cursor Bot Mar 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The branch is cleanly pushed now and the PR body is updated with the new guidance change. I’ve finished the requested Git flow and I’m summarizing exactly what changed and how I verified it.Summary

  • Tightened capability-access-denied handling so the model now gets concrete, structured guidance to help the user add the denied capability to the secret’s allowed capabilities and retry, instead of only generic “edit secret policy” wording.
  • Fetched, rebased the branch onto main, resolved the rebase conflicts against upstream changes, and force-pushed the rebased branch with lease as requested.
  • Updated focused node coverage for the new capability-denial guidance.

Testing

  • ✅ npm run test -- packages/worker/src/mcp/secrets/errors.node.test.ts packages/worker/src/mcp/executor.node.test.ts packages/worker/src/mcp/run-codemode-registry.node.test.ts
    I ran focused node tests because this follow-up only changed the capability-denial parsing/guidance path and its structured error output.
  • ✅ git fetch origin main && git rebase origin/main
    I rebased onto main and resolved the real conflicts that surfaced in upstream files, then continued the rebase successfully.
  • ✅ git push --force-with-lease origin cursor/secrets-access-control-for-capabilities-53bc
    I force-pushed with lease after the rebase to update the branch safely.
  • ⚠️ Manual UI retest was not rerun in this follow-up because the requested change was to the model-facing error guidance path, not the account-secrets UI itself; the earlier UI persistence investigation remained separate and was not part of this specific fix.
Open in Web Open in Cursor 

kentcdodds and others added 5 commits March 27, 2026 22:31
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
Remix host prop diff skips updates when input value is null/undefined, so a
row could keep stale text while editor state held null/undefined. JSON.stringify
then sent null for that slot and the API dropped it, persisting only one
capability. Read repeated text inputs from the form at submit and normalize
the same way as the server; coerce list payloads when hydrating from JSON.

Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
@cursor
cursor Bot force-pushed the cursor/secrets-access-control-for-capabilities-53bc branch from 448c026 to 2aefef7 Compare March 27, 2026 22:33
@kentcdodds
kentcdodds marked this pull request as ready for review March 27, 2026 22:38
@github-actions

github-actions Bot commented Mar 27, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-68.kentcdodds.workers.dev

Worker: kody-pr-68
D1: kody-pr-68-db
KV: kody-pr-68-oauth-kv

Mocks:

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Wrong property name breaks app-scoped capability allowlist saves
    • Renamed the capability save input to use storageContext/StorageContext and updated the caller so app-scoped secrets resolve the correct bucket.
Preview (f090c90fae)
diff --git a/docs/agents/adding-capabilities.md b/docs/agents/adding-capabilities.md
--- a/docs/agents/adding-capabilities.md
+++ b/docs/agents/adding-capabilities.md
@@ -104,6 +104,11 @@
 elsewhere, and secret-bearing capability inputs still require an authenticated
 user.
 
+When a secret has an `allowed_capabilities` policy, Kody also checks that the
+current capability name is explicitly listed before resolving the placeholder.
+An empty `allowed_capabilities` list means the secret is unrestricted for
+capability-input use.
+
 Use this for capabilities that need to accept a secret value as an argument but
 are not themselves the host-approval boundary. Good fits include:
 
@@ -284,6 +289,8 @@
 
 - annotate only the exact credential fields, not the whole object
 - prefer this for local persistence or device-side credential flows
+- tell users which capability names should be added to a secret's
+  `allowed_capabilities` policy when a workflow depends on restricted secrets
 - avoid using it for generic remote API wrappers where fetch-time host approval
   should remain the enforcement point
 
@@ -295,8 +302,8 @@
   ranked results
 - use `execute` to confirm the capability runs correctly
 
-Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real
-MCP contract.
+Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real MCP
+contract.
 
 Registry invariants (duplicate capability names, domain/capability mismatches,
 duplicate domain registration) are covered in

diff --git a/docs/agents/secret-host-approval.md b/docs/agents/secret-host-approval.md
--- a/docs/agents/secret-host-approval.md
+++ b/docs/agents/secret-host-approval.md
@@ -5,18 +5,19 @@
 
 ## Rule
 
-Allowed outbound hosts for a secret are privileged policy, not normal secret
+Allowed outbound hosts for a secret and allowed capabilities for direct
+capability-input secret resolution are privileged policy, not normal secret
 metadata.
 
-That policy must not be created, widened, or modified by:
+Those policies must not be created, widened, or modified by:
 
 - MCP tools
 - execute-time sandboxed code
 - generated UI code
 - capability handlers that serve agent-driven secret creation or update flows
 
-Allowed outbound hosts may only be changed through the authenticated account
-admin UI approval flow.
+Allowed outbound hosts and allowed capabilities may only be changed through the
+authenticated account admin UI.
 
 In this repo, that means the user must approve host access through the account
 secrets experience, such as `/account/secrets` and the focused approval route at
@@ -29,7 +30,7 @@
 UI.
 
 Saving or updating a secret value does not authorize sending that secret to any
-host.
+host or passing it into any capability.
 
 If an outbound request uses a placeholder such as `{{secret:name}}` and the
 target host is not already approved for that secret, the correct behavior is:
@@ -42,16 +43,22 @@
 ## What agents must not do
 
 Do not design or document any MCP capability, generated UI helper, or client
-library that allows agent-controlled writes to a secret's allowed hosts.
+library that allows agent-controlled writes to a secret's allowed hosts or
+allowed capabilities.
 
 Specifically, do not:
 
 - add `allowed_hosts` or equivalent fields to MCP-facing secret create/update
   inputs
+- add `allowed_capabilities` or equivalent fields to MCP-facing secret
+  create/update inputs
 - imply that a generated UI can self-authorize a host just because it can save a
   secret
+- imply that execute-time capability calls can widen which capabilities may
+  consume a secret
 - imply that execute-time code can widen egress permissions
-- treat host approval as ordinary secret metadata editing
+- treat host approval or capability allowlists as ordinary secret metadata
+  editing
 
 If a workflow would be smoother by auto-approving a host, the fix should be
 better guidance, helper APIs, or UX around the approval flow, not a new write
@@ -62,7 +69,10 @@
 When writing capability descriptions or agent-facing docs:
 
 - say explicitly that secret save/update does not grant outbound use
+- say explicitly that secret save/update does not grant capability access
 - say explicitly that only the authenticated account admin UI can approve hosts
+- say explicitly that only the authenticated account admin UI can restrict which
+  capabilities may consume a secret directly
 - tell agents to inspect secret metadata before making a secret-bearing request
 - tell agents to surface the approval link and stop on deny
 
@@ -84,6 +94,9 @@
 for example to store credentials on a local connector or pass them into a
 device-local action.
 
+If a secret has an allowed-capabilities policy, Kody enforces that allowlist by
+capability name before resolving the placeholder for the handler.
+
 Use it narrowly:
 
 - mark only the exact sensitive fields
@@ -105,11 +118,13 @@
 - save those values as secrets
 - save and read back non-secret values for public configuration
 - inspect secret metadata, including current allowed hosts
+- inspect secret metadata, including current allowed capabilities
 - present approval links returned from blocked requests
 
 Generated UIs may not:
 
 - set allowed hosts directly
+- set allowed capabilities directly
 - bypass the admin approval route
 - silently retry secret-bearing requests after a deny
 - use `executeCode(...)` as a general string interpolation mechanism for

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
@@ -215,7 +215,6 @@
 		errorMessage: 'Saved app not found for this user.',
 	})
 })
-
 test('buildLocalMessageLogRuntimeSource logs sendMessage locally outside hosted contexts', () => {
 	const originalWindow = globalThis.window
 	const originalDocument = globalThis.document

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -22,6 +22,7 @@
 	description: string
 	app_id: string | null
 	allowed_hosts: Array<string>
+	allowed_capabilities: Array<string>
 	created_at: string
 	updated_at: string
 	ttl_ms: number | null
@@ -772,6 +773,10 @@
 				(secret.app_id == null || typeof secret.app_id === 'string') &&
 				Array.isArray(secret.allowed_hosts) &&
 				secret.allowed_hosts.every((host) => typeof host === 'string') &&
+				Array.isArray(secret.allowed_capabilities) &&
+				secret.allowed_capabilities.every(
+					(capability) => typeof capability === 'string',
+				) &&
 				typeof secret.created_at === 'string' &&
 				typeof secret.updated_at === 'string' &&
 				(secret.ttl_ms == null ||

diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -42,6 +42,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -68,6 +69,7 @@
 	description: string
 	value: string
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 }
 
 type SelectionState = {
@@ -108,10 +110,56 @@
 		description: '',
 		value: '',
 		allowedHosts: [''],
+		allowedCapabilities: [''],
 	}
 }
 
+function coerceStringRows(list: Array<unknown>): Array<string> {
+	return list.filter((item): item is string => typeof item === 'string')
+}
+
+/** Matches server `normalizeAllowedHosts` in `#mcp/secrets/allowed-hosts.ts`. */
+function clientNormalizeAllowedHosts(hosts: Array<string>): Array<string> {
+	return Array.from(
+		new Set(
+			hosts
+				.map((host) => host.trim().toLowerCase())
+				.filter((host) => host.length > 0),
+		),
+	).sort()
+}
+
+/** Matches server `normalizeAllowedCapabilities` in `#mcp/secrets/allowed-capabilities.ts`. */
+function clientNormalizeAllowedCapabilities(
+	capabilities: Array<string>,
+): Array<string> {
+	return Array.from(
+		new Set(
+			capabilities
+				.map((value) => value.trim())
+				.filter((value) => value.length > 0),
+		),
+	).sort((left, right) => left.localeCompare(right))
+}
+
+function collectRepeatedTextRows(
+	form: HTMLFormElement,
+	listName: 'allowed-hosts' | 'allowed-capabilities',
+): Array<string> {
+	const root = form.querySelector(`[data-repeat-list="${listName}"]`)
+	if (!root) return []
+	const out: Array<string> = []
+	for (const row of root.children) {
+		if (!(row instanceof HTMLElement)) continue
+		const input = row.querySelector('input[type="text"]')
+		if (input instanceof HTMLInputElement) out.push(input.value)
+	}
+	return out
+}
+
 function createEditorStateFromSecret(secret: SecretDetail): EditorState {
+	const allowedHosts = coerceStringRows(secret.allowedHosts)
+	const allowedCapabilities = coerceStringRows(secret.allowedCapabilities)
 	return {
 		currentId: secret.id,
 		name: secret.name,
@@ -119,7 +167,9 @@
 		appId: secret.appId ?? '',
 		description: secret.description,
 		value: secret.value,
-		allowedHosts: secret.allowedHosts.length > 0 ? secret.allowedHosts : [''],
+		allowedHosts: allowedHosts.length > 0 ? allowedHosts : [''],
+		allowedCapabilities:
+			allowedCapabilities.length > 0 ? allowedCapabilities : [''],
 	}
 }
 
@@ -255,6 +305,7 @@
 			secret.appTitle ?? '',
 			secret.scope,
 			...secret.allowedHosts,
+			...secret.allowedCapabilities,
 		]
 			.join(' ')
 			.toLowerCase()
@@ -435,11 +486,19 @@
 		event.preventDefault()
 		if (saveState !== 'idle') return
 
+		const form = event.currentTarget as HTMLFormElement
+
 		saveState = 'saving'
 		message = null
 		handle.update()
 
 		try {
+			const allowedHosts = clientNormalizeAllowedHosts(
+				collectRepeatedTextRows(form, 'allowed-hosts'),
+			)
+			const allowedCapabilities = clientNormalizeAllowedCapabilities(
+				collectRepeatedTextRows(form, 'allowed-capabilities'),
+			)
 			const response = await fetch(accountSecretsApiPath, {
 				method: 'POST',
 				headers: {
@@ -455,7 +514,8 @@
 					appId: editorState.scope === 'app' ? editorState.appId : null,
 					description: editorState.description,
 					value: editorState.value,
-					allowedHosts: editorState.allowedHosts,
+					allowedHosts,
+					allowedCapabilities,
 				}),
 			})
 			if (response.status === 401) {
@@ -569,6 +629,37 @@
 		handle.update()
 	}
 
+	function updateAllowedCapability(index: number, value: string) {
+		editorState = {
+			...editorState,
+			allowedCapabilities: editorState.allowedCapabilities.map(
+				(capabilityName, capabilityIndex) =>
+					capabilityIndex === index ? value : capabilityName,
+			),
+		}
+		handle.update()
+	}
+
+	function addAllowedCapability() {
+		editorState = {
+			...editorState,
+			allowedCapabilities: [...editorState.allowedCapabilities, ''],
+		}
+		handle.update()
+	}
+
+	function removeAllowedCapability(index: number) {
+		const nextCapabilities = editorState.allowedCapabilities.filter(
+			(_capabilityName, capabilityIndex) => capabilityIndex !== index,
+		)
+		editorState = {
+			...editorState,
+			allowedCapabilities:
+				nextCapabilities.length > 0 ? nextCapabilities : [''],
+		}
+		handle.update()
+	}
+
 	return () => {
 		const currentHref = getCurrentHref()
 		const selection = getSelectionState(currentHref)
@@ -1181,10 +1272,13 @@
 											a secret can be used.
 										</p>
 									</div>
-									<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-hosts"
+									>
 										{editorState.allowedHosts.map((host, index) => (
 											<div
-												key={`${index}-${host}`}
+												key={index}
 												css={{
 													display: 'grid',
 													gridTemplateColumns: 'minmax(0, 1fr) auto',
@@ -1195,9 +1289,9 @@
 												}}
 											>
 												<input
-													type="text"
-													value={host}
-													placeholder="api.example.com"
+														type="text"
+														value={typeof host === 'string' ? host : ''}
+														placeholder="api.example.com"
 													on={{
 														input: (event) =>
 															updateAllowedHost(
@@ -1228,6 +1322,71 @@
 									</div>
 								</div>
 
+								<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div css={{ display: 'grid', gap: spacing.xs }}>
+										<span css={fieldLabelCss}>Allowed capabilities</span>
+										<p css={{ margin: 0, color: colors.textMuted }}>
+											Leave this empty to allow any capability with an
+											<code> x-kody-secret </code>
+											input to resolve this secret.
+										</p>
+									</div>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-capabilities"
+									>
+										{editorState.allowedCapabilities.map(
+											(capabilityName, index) => (
+												<div
+													key={index}
+													css={{
+														display: 'grid',
+														gridTemplateColumns: 'minmax(0, 1fr) auto',
+														gap: spacing.sm,
+														[mq.mobile]: {
+															gridTemplateColumns: '1fr',
+														},
+													}}
+												>
+													<input
+														type="text"
+														value={
+															typeof capabilityName === 'string'
+																? capabilityName
+																: ''
+														}
+														placeholder="home_lutron_set_credentials"
+														on={{
+															input: (event) =>
+																updateAllowedCapability(
+																	index,
+																	event.currentTarget.value,
+																),
+														}}
+														css={inputCss}
+													/>
+													<button
+														type="button"
+														on={{ click: () => removeAllowedCapability(index) }}
+														css={secondaryButtonCss}
+													>
+														Remove
+													</button>
+												</div>
+											),
+										)}
+									</div>
+									<div>
+										<button
+											type="button"
+											on={{ click: addAllowedCapability }}
+											css={secondaryButtonCss}
+										>
+											Add capability
+										</button>
+									</div>
+								</div>
+
 								{selectedSecret ? (
 									<div
 										css={{

diff --git a/packages/worker/migrations/0010-secret-allowed-capabilities.sql b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
@@ -1,0 +1,2 @@
+ALTER TABLE secret_entries
+ADD COLUMN allowed_capabilities TEXT NOT NULL DEFAULT '[]';

diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -16,11 +16,13 @@
 	listSecrets,
 	resolveSecret,
 	saveSecret,
+	setSecretAllowedCapabilities,
 	setSecretAllowedHosts,
 } from '#mcp/secrets/service.ts'
 import { type SecretScope } from '#mcp/secrets/types.ts'
 import { listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts'
 import { type routes } from '#app/routes.ts'
+import { normalizeAllowedCapabilities } from '#mcp/secrets/allowed-capabilities.ts'
 import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts'
 
 type AccountEditableSecretScope = Extract<SecretScope, 'app' | 'user'>
@@ -39,6 +41,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -335,6 +338,7 @@
 		description: string
 		appId: string | null
 		allowedHosts: Array<string>
+		allowedCapabilities: Array<string>
 		createdAt: string
 		updatedAt: string
 		ttlMs: number | null
@@ -358,6 +362,7 @@
 		appId: secret.appId,
 		appTitle: secret.appId ? (appTitles.get(secret.appId) ?? null) : null,
 		allowedHosts: secret.allowedHosts,
+		allowedCapabilities: secret.allowedCapabilities,
 		createdAt: secret.createdAt,
 		updatedAt: secret.updatedAt,
 		ttlMs: secret.ttlMs,
@@ -446,6 +451,9 @@
 	const allowedHosts = normalizeAllowedHosts(
 		readStringArray(input.body, 'allowedHosts'),
 	)
+	const allowedCapabilities = normalizeAllowedCapabilities(
+		readStringArray(input.body, 'allowedCapabilities'),
+	)
 
 	if (!name) {
 		return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400)
@@ -523,6 +531,17 @@
 				appId,
 			}),
 		})
+		await setSecretAllowedCapabilities({
+			env: input.env,
+			userId: input.user.mcpUser.userId,
+			name,
+			scope,
+			allowedCapabilities,
+			storageContext: getSecretContextForAccountSecret({
+				scope,
+				appId,
+			}),
+		})
 
 		if (currentSecret && currentSecret.id !== nextId) {
 			await deleteSecret({

diff --git a/packages/worker/src/app/saved-ui-hosted-html.ts b/packages/worker/src/app/saved-ui-hosted-html.ts
--- a/packages/worker/src/app/saved-ui-hosted-html.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.ts
@@ -469,6 +469,10 @@
 			(secret.app_id == null || typeof secret.app_id === 'string') &&
 			Array.isArray(secret.allowed_hosts) &&
 			secret.allowed_hosts.every((host) => typeof host === 'string') &&
+			Array.isArray(secret.allowed_capabilities) &&
+			secret.allowed_capabilities.every(
+				(capability) => typeof capability === 'string',
+			) &&
 			typeof secret.created_at === 'string' &&
 			typeof secret.updated_at === 'string' &&
 			(secret.ttl_ms == null ||

diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
--- a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
@@ -12,7 +12,7 @@
 	{
 		name: 'secret_list',
 		description:
-			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts, from the sandbox.',
+			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts and allowed capabilities, from the sandbox.',
 		keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'],
 		readOnly: true,
 		idempotent: true,
@@ -46,6 +46,7 @@
 					description: secret.description,
 					app_id: secret.appId,
 					allowed_hosts: secret.allowedHosts,
+					allowed_capabilities: secret.allowedCapabilities,
 					created_at: secret.createdAt,
 					updated_at: secret.updatedAt,
 					ttl_ms: secret.ttlMs,

diff --git a/packages/worker/src/mcp/capabilities/secrets/shared.ts b/packages/worker/src/mcp/capabilities/secrets/shared.ts
--- a/packages/worker/src/mcp/capabilities/secrets/shared.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/shared.ts
@@ -7,6 +7,7 @@
 	description: z.string(),
 	app_id: z.string().nullable(),
 	allowed_hosts: z.array(z.string()),
+	allowed_capabilities: z.array(z.string()),
 	created_at: z.string(),
 	updated_at: z.string(),
 	ttl_ms: z.number().int().nonnegative().nullable(),

diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -1,0 +1,54 @@
+import { expect, test } from 'vitest'
+import {
+	createCapabilitySecretAccessDeniedMessage,
+	createMissingSecretMessage,
+} from '#mcp/secrets/errors.ts'
+import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
+
+test('getExecutionErrorDetails returns concrete guidance for capability access denial', () => {
+	const error = new Error(
+		createCapabilitySecretAccessDeniedMessage(
+			'cloudflareToken',
+			'cloudflare_rest',
+		),
+	)
+
+	expect(getExecutionErrorDetails(error)).toEqual({
+		kind: 'secret_capability_access_required',
+		message:
+			'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
+		nextStep:
+			"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+		secretNames: ['cloudflareToken'],
+		capabilityName: 'cloudflare_rest',
+		suggestedAction: {
+			type: 'edit_secret_policy',
+			policyField: 'allowed_capabilities',
+		},
+	})
+})
+
+test('formatExecutionOutput includes capability access next step', () => {
+	const result = {
+		error: new Error(
+			createCapabilitySecretAccessDeniedMessage(
+				'cloudflareToken',
+				'cloudflare_rest',
+			),
+		),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		"Next step: Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+	)
+})
+
+test('formatExecutionOutput keeps missing secret guidance intact', () => {
+	const result = {
+		error: new Error(createMissingSecretMessage('missingToken')),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		'Open a generated UI so the user can provide and save this secret',
+	)
+})

diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,6 +4,7 @@
 import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
 import {
 	isSecretAuthRequiredMessage,
+	parseCapabilityAccessRequiredMessage,
 	parseHostApprovalRequiredMessage,
 	parseMissingSecretMessage,
 } from '#mcp/secrets/errors.ts'
@@ -45,6 +46,17 @@
 			}
 	  }
 	| {
+			kind: 'secret_capability_access_required'
+			message: string
+			nextStep: string
+			secretNames: Array<string>
+			capabilityName: string
+			suggestedAction: {
+				type: 'edit_secret_policy'
+				policyField: 'allowed_capabilities'
+			}
+	  }
+	| {
 			kind: 'secret_required'
 			message: string
 			nextStep: string
@@ -84,6 +96,22 @@
 		}
 	}
 
+	const capabilityAccessDetails = parseCapabilityAccessRequiredMessage(message)
+	if (capabilityAccessDetails) {
+		return {
+			kind: 'secret_capability_access_required',
+			message,
+			nextStep:
+				"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+			secretNames: [capabilityAccessDetails.secretName],
+			capabilityName: capabilityAccessDetails.capabilityName,
+			suggestedAction: {
+				type: 'edit_secret_policy',
+				policyField: 'allowed_capabilities',
+			},
+		}
+	}
+
 	const missingSecretDetails = parseMissingSecretMessage(message)
 	if (missingSecretDetails) {
 		return {

diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts
--- a/packages/worker/src/mcp/generated-ui-api.ts
+++ b/packages/worker/src/mcp/generated-ui-api.ts
@@ -299,6 +299,7 @@
 						description: secret.description,
 						app_id: secret.appId,
 						allowed_hosts: secret.allowedHosts,
+						allowed_capabilities: secret.allowedCapabilities,
 						created_at: secret.createdAt,
 						updated_at: secret.updatedAt,
 						ttl_ms: secret.ttlMs,
@@ -338,6 +339,7 @@
 						description: saved.description,
 						app_id: saved.appId,
 						allowed_hosts: saved.allowedHosts,
+						allowed_capabilities: saved.allowedCapabilities,
 						created_at: saved.createdAt,
 						updated_at: saved.updatedAt,
 						ttl_ms: saved.ttlMs,

diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts
--- a/packages/worker/src/mcp/index.ts
+++ b/packages/worker/src/mcp/index.ts
@@ -80,10 +80,10 @@
 - Each capability call returns that capability's raw structured result value.
 - When chaining calls, read fields from the previous result using its outputSchema.
 - Chain multiple calls, use conditionals, and return structured results.
-- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, and allowed hosts from the sandbox.
+- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, allowed hosts, and allowed capabilities from the sandbox.
 - Use \`await codemode.value_get({ name })\` or \`await codemode.value_list({ scope })\` for readable non-secret configuration that generated UI code should be able to store and read back later.
 - Use normal \`fetch(...)\` for outbound HTTP. To inject a stored secret, place a placeholder such as \`{{secret:cloudflareToken}}\` or \`{{secret:cloudflareToken|scope=user}}\` in the URL, headers, or request body; the host resolves it server-side and blocks unapproved destinations.
-- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value.
+- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value. If that secret has an allowed-capabilities policy, the current capability name must be on the allowlist.
 - Secret placeholders are not general-purpose string interpolation. Do not use \`execute\` to build a string or object that merely returns \`{{secret:...}}\`; those placeholders only resolve in secret-aware fetch paths or capability inputs that explicitly opt into \`x-kody-secret\`.
 - Saving or updating a secret does not authorize sending it anywhere. If a fetch fails because a host is not approved for that secret, ask the user whether to open the approval link and approve that host in the web app.
 - Secrets are intentionally not readable or updatable through \`codemode\`. Never ask the user to paste a secret into chat; use generated UI flows such as \`saveSecret(...)\` when the user needs to provide or rotate a value, and use \`codemode.secret_delete(...)\` only when removing a stored secret reference.

diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -1337,6 +1337,7 @@
 		description: 'Session-only verification code',
 		app_id: null,
 		allowed_hosts: [],
+		allowed_capabilities: [],
 		created_at: expect.any(String),
 		updated_at: expect.any(String),
 		ttl_ms: expect.any(Number),
@@ -1401,6 +1402,7 @@
 			description: 'App-scoped Cloudflare deployment token',
 			app_id: appId,
 			allowed_hosts: [],
+			allowed_capabilities: [],
 			created_at: expect.any(String),
 			updated_at: expect.any(String),
 			ttl_ms: null,

diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
--- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
@@ -1,6 +1,7 @@
-import { expect, test } from 'vitest'
+import { expect, test, vi } from 'vitest'
 import { createMcpCallerContext } from '#mcp/context.ts'
 import { buildCodemodeFns } from './run-codemode-registry.ts'
+import * as secretService from '#mcp/secrets/service.ts'
 
 test('buildCodemodeFns resolves annotated home capability secret placeholders', async () => {
 	let toolArguments: Record<string, unknown> | null = null
@@ -91,3 +92,89 @@
 		password: 'lutronPassword-resolved',
 	})
 })
+
+test('buildCodemodeFns denies capability secret placeholders for disallowed capabilities', async () => {
+	const resolveSecretSpy = vi
+		.spyOn(secretService, 'resolveSecret')
+		.mockResolvedValue({
+			found: true,
+			value: 'lutronUsername-resolved',
+			scope: 'user',
+			allowedHosts: [],
+			allowedCapabilities: ['some_other_capability'],
+		})
+	const env = {
+		HOME_CONNECTOR_SESSION: {
+			idFromName(name: string) {
+				return name
+			},
+			get() {
+				return {
+					async fetch(input: string | URL | Request) {
+						const url = new URL(
+							typeof input === 'string'
+								? input
+								: input instanceof URL
+									? input.toString()
+									: input.url,
+						)
+						if (url.pathname.endsWith('/snapshot')) {
+							return Response.json({
+								connectorId: 'default',
+								connectedAt: '2026-03-27T00:00:00.000Z',
+								lastSeenAt: '2026-03-27T00:00:01.000Z',
+								tools: [
+									{
+										name: 'lutron_set_credentials',
+										title: 'Set Lutron Credentials',
+										description: 'Store Lutron credentials.',
+										inputSchema: {
+											type: 'object',
+											properties: {
+												username: {
+													type: 'string',
+													'x-kody-secret': true,
+												},
+											},
+											required: ['username'],
+										},
+									},
+								],
+							})
+						}
+
+						throw new Error(`Unexpected fetch to ${url.pathname}`)
+					},
+				}
+			},
... diff truncated: showing 800 of 1362 lines

Comment thread packages/worker/src/mcp/secrets/service.ts Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/worker/src/mcp/secrets/account-secret-save-body.node.test.ts (1)

10-14: Test the shared parser instead of a copied helper.

This local readStringArray() duplicates the handler implementation in packages/worker/src/app/handlers/account-secrets.ts, so the test can stay green while the real parser drifts. Moving it to a tiny shared utility, or importing the production implementation, would make this cover the actual save-path behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/secrets/account-secret-save-body.node.test.ts` around
lines 10 - 14, The test duplicates the production parser by defining
readStringArray locally; replace that duplication by importing the canonical
parser from the production code (the implementation used by account-secrets.ts)
or extract it to a tiny shared utility and import it into both the handler and
this test; specifically remove the local readStringArray function in
account-secret-save-body.node.test.ts and instead import the parser used by
packages/worker/src/app/handlers/account-secrets.ts (or move that parser into a
shared module and update both account-secrets.ts and this test to import it) so
the test validates the actual save-path behavior.
packages/worker/client/routes/account-secrets.tsx (1)

1325-1388: Consider validating capability names in the editor.

This allowlist is matched by exact capability name, but the UI is a free-form text box, so typos or case mismatches will save cleanly and only fail later at runtime. An autocomplete from the registry, or at least an inline “unknown capability” warning, would make this much harder to misconfigure.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 23-26: The override for resolveSecretValue currently bypasses
allowed_capabilities because callers can supply options.resolveSecretValue and
never check resolved.allowedCapabilities; update run-codemode-registry so the
capability allowlist check is performed before calling any external override:
move the policy validation that inspects resolved.allowedCapabilities into the
top-level secret-resolution flow (or wrap it in a helper invoked by
createCapabilityInputSecretResolver before delegating), and if you must keep
options.resolveSecretValue as an override make it private/test-only or ensure
the public override signature enforces capabilityName-based checks (i.e., always
validate resolved.allowedCapabilities for the given capabilityName before
returning a secret).

In `@packages/worker/src/mcp/secrets/errors.ts`:
- Around line 22-27: The error message builder
createCapabilitySecretAccessDeniedMessage embeds raw secret and capability names
in quotes which breaks parsing for names containing quotes; update
createCapabilitySecretAccessDeniedMessage (and the similar function at lines
46-53) to encode or serialize secretName and capabilityName in a round-trippable
way (e.g., JSON.stringify or another escaping method) so
parseCapabilityAccessRequiredMessage can reliably parse them back; ensure the
corresponding parser parseCapabilityAccessRequiredMessage and any callers like
getExecutionErrorDetails expect and decode that serialized form.

---

Nitpick comments:
In `@packages/worker/src/mcp/secrets/account-secret-save-body.node.test.ts`:
- Around line 10-14: The test duplicates the production parser by defining
readStringArray locally; replace that duplication by importing the canonical
parser from the production code (the implementation used by account-secrets.ts)
or extract it to a tiny shared utility and import it into both the handler and
this test; specifically remove the local readStringArray function in
account-secret-save-body.node.test.ts and instead import the parser used by
packages/worker/src/app/handlers/account-secrets.ts (or move that parser into a
shared module and update both account-secrets.ts and this test to import it) so
the test validates the actual save-path behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cc8f1685-8754-45da-afdf-8933c939f8ac

📥 Commits

Reviewing files that changed from the base of the PR and between 5ffa6b6 and 2aefef7.

📒 Files selected for processing (25)
  • docs/agents/adding-capabilities.md
  • docs/agents/secret-host-approval.md
  • packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/client/routes/account-secrets.tsx
  • packages/worker/migrations/0010-secret-allowed-capabilities.sql
  • packages/worker/src/app/handlers/account-secrets.ts
  • packages/worker/src/app/saved-ui-hosted-html.ts
  • packages/worker/src/mcp/capabilities/secrets/secret-list.ts
  • packages/worker/src/mcp/capabilities/secrets/shared.ts
  • packages/worker/src/mcp/executor.node.test.ts
  • packages/worker/src/mcp/executor.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/index.ts
  • packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
  • packages/worker/src/mcp/run-codemode-registry.node.test.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/secrets/account-secret-save-body.node.test.ts
  • packages/worker/src/mcp/secrets/allowed-capabilities.ts
  • packages/worker/src/mcp/secrets/errors.node.test.ts
  • packages/worker/src/mcp/secrets/errors.ts
  • packages/worker/src/mcp/secrets/repo.ts
  • packages/worker/src/mcp/secrets/service.ts
  • packages/worker/src/mcp/secrets/types.ts
  • packages/worker/src/mcp/tools/execute.ts
💤 Files with no reviewable changes (1)
  • packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts

Comment thread packages/worker/src/mcp/run-codemode-registry.ts
Comment on lines +22 to +27
export function createCapabilitySecretAccessDeniedMessage(
secretName: string,
capabilityName: string,
) {
return `Secret "${secretName}" is not allowed for capability "${capabilityName}". If this capability should be able to use the secret, ask the user whether to add "${capabilityName}" to the secret's allowed capabilities in the account secrets UI, then retry after they approve that policy change.`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Quoted secret names won't round-trip through this error format.

parseCapabilityAccessRequiredMessage() relies on the same raw quotes that createCapabilitySecretAccessDeniedMessage() embeds, but secret names are not constrained here. A secret like foo"bar will produce an unparseable message, and getExecutionErrorDetails() will fall back to null instead of surfacing the remediation path.

Also applies to: 46-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/secrets/errors.ts` around lines 22 - 27, The error
message builder createCapabilitySecretAccessDeniedMessage embeds raw secret and
capability names in quotes which breaks parsing for names containing quotes;
update createCapabilitySecretAccessDeniedMessage (and the similar function at
lines 46-53) to encode or serialize secretName and capabilityName in a
round-trippable way (e.g., JSON.stringify or another escaping method) so
parseCapabilityAccessRequiredMessage can reliably parse them back; ensure the
corresponding parser parseCapabilityAccessRequiredMessage and any callers like
getExecutionErrorDetails expect and decode that serialized form.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/src/mcp/secrets/service.ts (1)

102-110: ⚠️ Potential issue | 🔴 Critical

Don’t round-trip allowed_capabilities through stale entry snapshots.

saveSecret/updateSecret now copy existingEntry.allowed_capabilities, and setSecretAllowedCapabilities writes ...existingEntry. If those requests overlap, the last writer can silently reapply an old capability allowlist or old secret payload, which can undo a freshly tightened restriction. Please switch these mutations to column-scoped updates or add an updated_at/version precondition.

Also applies to: 253-263, 542-550

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/secrets/service.ts` around lines 102 - 110, The
current mutations (saveSecret/updateSecret/setSecretAllowedCapabilities) are
copying fields from existingEntry (e.g., allowed_capabilities, encrypted_value)
which can cause stale overwrites when requests overlap; change these operations
to perform column-scoped updates (only set allowed_capabilities in
setSecretAllowedCapabilities, only set encrypted_value/payload in
saveSecret/updateSecret) instead of passing the entire existingEntry into
upsertSecretEntry, or implement an optimistic-precondition using an updated_at
or version field checked in the update (fail the write if the row
version/updated_at has changed). Locate and modify the callers that currently
pass existingEntry (references: saveSecret, updateSecret,
setSecretAllowedCapabilities and the upsertSecretEntry usage) so they either
call a new column-specific update method or include a version/updated_at check
before writing.
🧹 Nitpick comments (1)
packages/worker/src/mcp/secrets/service.ts (1)

459-509: Consider extracting the shared allowlist update flow.

setSecretAllowedHosts and setSecretAllowedCapabilities now duplicate the same bucket lookup, entry lookup, timestamping, upsert, and metadata shaping. A small shared helper would reduce future drift when this response shape changes again.

Also applies to: 512-563

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/secrets/service.ts` around lines 459 - 509, Both
setSecretAllowedHosts and setSecretAllowedCapabilities duplicate bucket lookup,
entry fetch, timestamping, upsert, and metadata shaping; extract that shared
flow into a single helper (e.g., updateSecretEntry) that accepts
input.env.APP_DB, userId, scope, storageContext, name, and a rowUpdater callback
that receives existingEntry and now and returns the updated row; inside the
helper call getExistingBucketForScope, getSecretEntry, compute now, call the
rowUpdater to produce the upsert row, run upsertSecretEntry, and return the
metadata by calling toSecretMetadata (so callers like setSecretAllowedHosts use
stringifyAllowedHosts in their rowUpdater and setSecretAllowedCapabilities uses
parseAllowedCapabilities) to eliminate duplication and keep behavior identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/worker/src/mcp/secrets/service.ts`:
- Around line 102-110: The current mutations
(saveSecret/updateSecret/setSecretAllowedCapabilities) are copying fields from
existingEntry (e.g., allowed_capabilities, encrypted_value) which can cause
stale overwrites when requests overlap; change these operations to perform
column-scoped updates (only set allowed_capabilities in
setSecretAllowedCapabilities, only set encrypted_value/payload in
saveSecret/updateSecret) instead of passing the entire existingEntry into
upsertSecretEntry, or implement an optimistic-precondition using an updated_at
or version field checked in the update (fail the write if the row
version/updated_at has changed). Locate and modify the callers that currently
pass existingEntry (references: saveSecret, updateSecret,
setSecretAllowedCapabilities and the upsertSecretEntry usage) so they either
call a new column-specific update method or include a version/updated_at check
before writing.

---

Nitpick comments:
In `@packages/worker/src/mcp/secrets/service.ts`:
- Around line 459-509: Both setSecretAllowedHosts and
setSecretAllowedCapabilities duplicate bucket lookup, entry fetch, timestamping,
upsert, and metadata shaping; extract that shared flow into a single helper
(e.g., updateSecretEntry) that accepts input.env.APP_DB, userId, scope,
storageContext, name, and a rowUpdater callback that receives existingEntry and
now and returns the updated row; inside the helper call
getExistingBucketForScope, getSecretEntry, compute now, call the rowUpdater to
produce the upsert row, run upsertSecretEntry, and return the metadata by
calling toSecretMetadata (so callers like setSecretAllowedHosts use
stringifyAllowedHosts in their rowUpdater and setSecretAllowedCapabilities uses
parseAllowedCapabilities) to eliminate duplication and keep behavior identical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87644d87-bd08-4de4-a707-1a840f4a6f9c

📥 Commits

Reviewing files that changed from the base of the PR and between 2aefef7 and f090c90.

📒 Files selected for processing (2)
  • packages/worker/src/app/handlers/account-secrets.ts
  • packages/worker/src/mcp/secrets/service.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/worker/src/app/handlers/account-secrets.ts

Comment on lines +1329 to +1331
Leave this empty to allow any capability with an
<code> x-kody-secret </code>
input to resolve this secret.

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.

@cursoragent I think this is the wrong way to go about this. We should not assume every capability is allowed when empty. It should be like the allowed_hosts where a host MUST be in the list to be allowed.

Also, double-check linting. We've got a warning.

@cursor cursor Bot Mar 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Wrong property name breaks app-scoped capability allowlist saves
    • Renamed the capability save input to use storageContext/StorageContext and updated the caller so app-scoped secrets resolve the correct bucket.

Create PR

Preview (5b8c478cd2)
diff --git a/docs/agents/adding-capabilities.md b/docs/agents/adding-capabilities.md
--- a/docs/agents/adding-capabilities.md
+++ b/docs/agents/adding-capabilities.md
@@ -104,6 +104,11 @@
 elsewhere, and secret-bearing capability inputs still require an authenticated
 user.
 
+When a secret has an `allowed_capabilities` policy, Kody also checks that the
+current capability name is explicitly listed before resolving the placeholder.
+An empty `allowed_capabilities` list means the secret is unrestricted for
+capability-input use.
+
 Use this for capabilities that need to accept a secret value as an argument but
 are not themselves the host-approval boundary. Good fits include:
 
@@ -284,6 +289,8 @@
 
 - annotate only the exact credential fields, not the whole object
 - prefer this for local persistence or device-side credential flows
+- tell users which capability names should be added to a secret's
+  `allowed_capabilities` policy when a workflow depends on restricted secrets
 - avoid using it for generic remote API wrappers where fetch-time host approval
   should remain the enforcement point
 
@@ -295,8 +302,8 @@
   ranked results
 - use `execute` to confirm the capability runs correctly
 
-Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real
-MCP contract.
+Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real MCP
+contract.
 
 Registry invariants (duplicate capability names, domain/capability mismatches,
 duplicate domain registration) are covered in

diff --git a/docs/agents/secret-host-approval.md b/docs/agents/secret-host-approval.md
--- a/docs/agents/secret-host-approval.md
+++ b/docs/agents/secret-host-approval.md
@@ -5,18 +5,19 @@
 
 ## Rule
 
-Allowed outbound hosts for a secret are privileged policy, not normal secret
+Allowed outbound hosts for a secret and allowed capabilities for direct
+capability-input secret resolution are privileged policy, not normal secret
 metadata.
 
-That policy must not be created, widened, or modified by:
+Those policies must not be created, widened, or modified by:
 
 - MCP tools
 - execute-time sandboxed code
 - generated UI code
 - capability handlers that serve agent-driven secret creation or update flows
 
-Allowed outbound hosts may only be changed through the authenticated account
-admin UI approval flow.
+Allowed outbound hosts and allowed capabilities may only be changed through the
+authenticated account admin UI.
 
 In this repo, that means the user must approve host access through the account
 secrets experience, such as `/account/secrets` and the focused approval route at
@@ -29,7 +30,7 @@
 UI.
 
 Saving or updating a secret value does not authorize sending that secret to any
-host.
+host or passing it into any capability.
 
 If an outbound request uses a placeholder such as `{{secret:name}}` and the
 target host is not already approved for that secret, the correct behavior is:
@@ -42,16 +43,22 @@
 ## What agents must not do
 
 Do not design or document any MCP capability, generated UI helper, or client
-library that allows agent-controlled writes to a secret's allowed hosts.
+library that allows agent-controlled writes to a secret's allowed hosts or
+allowed capabilities.
 
 Specifically, do not:
 
 - add `allowed_hosts` or equivalent fields to MCP-facing secret create/update
   inputs
+- add `allowed_capabilities` or equivalent fields to MCP-facing secret
+  create/update inputs
 - imply that a generated UI can self-authorize a host just because it can save a
   secret
+- imply that execute-time capability calls can widen which capabilities may
+  consume a secret
 - imply that execute-time code can widen egress permissions
-- treat host approval as ordinary secret metadata editing
+- treat host approval or capability allowlists as ordinary secret metadata
+  editing
 
 If a workflow would be smoother by auto-approving a host, the fix should be
 better guidance, helper APIs, or UX around the approval flow, not a new write
@@ -62,7 +69,10 @@
 When writing capability descriptions or agent-facing docs:
 
 - say explicitly that secret save/update does not grant outbound use
+- say explicitly that secret save/update does not grant capability access
 - say explicitly that only the authenticated account admin UI can approve hosts
+- say explicitly that only the authenticated account admin UI can restrict which
+  capabilities may consume a secret directly
 - tell agents to inspect secret metadata before making a secret-bearing request
 - tell agents to surface the approval link and stop on deny
 
@@ -84,6 +94,9 @@
 for example to store credentials on a local connector or pass them into a
 device-local action.
 
+If a secret has an allowed-capabilities policy, Kody enforces that allowlist by
+capability name before resolving the placeholder for the handler.
+
 Use it narrowly:
 
 - mark only the exact sensitive fields
@@ -105,11 +118,13 @@
 - save those values as secrets
 - save and read back non-secret values for public configuration
 - inspect secret metadata, including current allowed hosts
+- inspect secret metadata, including current allowed capabilities
 - present approval links returned from blocked requests
 
 Generated UIs may not:
 
 - set allowed hosts directly
+- set allowed capabilities directly
 - bypass the admin approval route
 - silently retry secret-bearing requests after a deny
 - use `executeCode(...)` as a general string interpolation mechanism for

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
@@ -215,7 +215,6 @@
 		errorMessage: 'Saved app not found for this user.',
 	})
 })
-
 test('buildLocalMessageLogRuntimeSource logs sendMessage locally outside hosted contexts', () => {
 	const originalWindow = globalThis.window
 	const originalDocument = globalThis.document

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -22,6 +22,7 @@
 	description: string
 	app_id: string | null
 	allowed_hosts: Array<string>
+	allowed_capabilities: Array<string>
 	created_at: string
 	updated_at: string
 	ttl_ms: number | null
@@ -772,6 +773,10 @@
 				(secret.app_id == null || typeof secret.app_id === 'string') &&
 				Array.isArray(secret.allowed_hosts) &&
 				secret.allowed_hosts.every((host) => typeof host === 'string') &&
+				Array.isArray(secret.allowed_capabilities) &&
+				secret.allowed_capabilities.every(
+					(capability) => typeof capability === 'string',
+				) &&
 				typeof secret.created_at === 'string' &&
 				typeof secret.updated_at === 'string' &&
 				(secret.ttl_ms == null ||

diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -42,6 +42,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -68,6 +69,7 @@
 	description: string
 	value: string
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 }
 
 type SelectionState = {
@@ -108,10 +110,56 @@
 		description: '',
 		value: '',
 		allowedHosts: [''],
+		allowedCapabilities: [''],
 	}
 }
 
+function coerceStringRows(list: Array<unknown>): Array<string> {
+	return list.filter((item): item is string => typeof item === 'string')
+}
+
+/** Matches server `normalizeAllowedHosts` in `#mcp/secrets/allowed-hosts.ts`. */
+function clientNormalizeAllowedHosts(hosts: Array<string>): Array<string> {
+	return Array.from(
+		new Set(
+			hosts
+				.map((host) => host.trim().toLowerCase())
+				.filter((host) => host.length > 0),
+		),
+	).sort()
+}
+
+/** Matches server `normalizeAllowedCapabilities` in `#mcp/secrets/allowed-capabilities.ts`. */
+function clientNormalizeAllowedCapabilities(
+	capabilities: Array<string>,
+): Array<string> {
+	return Array.from(
+		new Set(
+			capabilities
+				.map((value) => value.trim())
+				.filter((value) => value.length > 0),
+		),
+	).sort((left, right) => left.localeCompare(right))
+}
+
+function collectRepeatedTextRows(
+	form: HTMLFormElement,
+	listName: 'allowed-hosts' | 'allowed-capabilities',
+): Array<string> {
+	const root = form.querySelector(`[data-repeat-list="${listName}"]`)
+	if (!root) return []
+	const out: Array<string> = []
+	for (const row of root.children) {
+		if (!(row instanceof HTMLElement)) continue
+		const input = row.querySelector('input[type="text"]')
+		if (input instanceof HTMLInputElement) out.push(input.value)
+	}
+	return out
+}
+
 function createEditorStateFromSecret(secret: SecretDetail): EditorState {
+	const allowedHosts = coerceStringRows(secret.allowedHosts)
+	const allowedCapabilities = coerceStringRows(secret.allowedCapabilities)
 	return {
 		currentId: secret.id,
 		name: secret.name,
@@ -119,7 +167,9 @@
 		appId: secret.appId ?? '',
 		description: secret.description,
 		value: secret.value,
-		allowedHosts: secret.allowedHosts.length > 0 ? secret.allowedHosts : [''],
+		allowedHosts: allowedHosts.length > 0 ? allowedHosts : [''],
+		allowedCapabilities:
+			allowedCapabilities.length > 0 ? allowedCapabilities : [''],
 	}
 }
 
@@ -255,6 +305,7 @@
 			secret.appTitle ?? '',
 			secret.scope,
 			...secret.allowedHosts,
+			...secret.allowedCapabilities,
 		]
 			.join(' ')
 			.toLowerCase()
@@ -435,11 +486,19 @@
 		event.preventDefault()
 		if (saveState !== 'idle') return
 
+		const form = event.currentTarget as HTMLFormElement
+
 		saveState = 'saving'
 		message = null
 		handle.update()
 
 		try {
+			const allowedHosts = clientNormalizeAllowedHosts(
+				collectRepeatedTextRows(form, 'allowed-hosts'),
+			)
+			const allowedCapabilities = clientNormalizeAllowedCapabilities(
+				collectRepeatedTextRows(form, 'allowed-capabilities'),
+			)
 			const response = await fetch(accountSecretsApiPath, {
 				method: 'POST',
 				headers: {
@@ -455,7 +514,8 @@
 					appId: editorState.scope === 'app' ? editorState.appId : null,
 					description: editorState.description,
 					value: editorState.value,
-					allowedHosts: editorState.allowedHosts,
+					allowedHosts,
+					allowedCapabilities,
 				}),
 			})
 			if (response.status === 401) {
@@ -569,6 +629,37 @@
 		handle.update()
 	}
 
+	function updateAllowedCapability(index: number, value: string) {
+		editorState = {
+			...editorState,
+			allowedCapabilities: editorState.allowedCapabilities.map(
+				(capabilityName, capabilityIndex) =>
+					capabilityIndex === index ? value : capabilityName,
+			),
+		}
+		handle.update()
+	}
+
+	function addAllowedCapability() {
+		editorState = {
+			...editorState,
+			allowedCapabilities: [...editorState.allowedCapabilities, ''],
+		}
+		handle.update()
+	}
+
+	function removeAllowedCapability(index: number) {
+		const nextCapabilities = editorState.allowedCapabilities.filter(
+			(_capabilityName, capabilityIndex) => capabilityIndex !== index,
+		)
+		editorState = {
+			...editorState,
+			allowedCapabilities:
+				nextCapabilities.length > 0 ? nextCapabilities : [''],
+		}
+		handle.update()
+	}
+
 	return () => {
 		const currentHref = getCurrentHref()
 		const selection = getSelectionState(currentHref)
@@ -1181,10 +1272,13 @@
 											a secret can be used.
 										</p>
 									</div>
-									<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-hosts"
+									>
 										{editorState.allowedHosts.map((host, index) => (
 											<div
-												key={`${index}-${host}`}
+												key={index}
 												css={{
 													display: 'grid',
 													gridTemplateColumns: 'minmax(0, 1fr) auto',
@@ -1195,9 +1289,9 @@
 												}}
 											>
 												<input
-													type="text"
-													value={host}
-													placeholder="api.example.com"
+														type="text"
+														value={typeof host === 'string' ? host : ''}
+														placeholder="api.example.com"
 													on={{
 														input: (event) =>
 															updateAllowedHost(
@@ -1228,6 +1322,72 @@
 									</div>
 								</div>
 
+								<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div css={{ display: 'grid', gap: spacing.xs }}>
+										<span css={fieldLabelCss}>Allowed capabilities</span>
+										<p css={{ margin: 0, color: colors.textMuted }}>
+											Only capabilities listed here can resolve this secret
+											when used with an
+											<code> x-kody-secret </code>
+											input.
+										</p>
+									</div>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-capabilities"
+									>
+										{editorState.allowedCapabilities.map(
+											(capabilityName, index) => (
+												<div
+													key={index}
+													css={{
+														display: 'grid',
+														gridTemplateColumns: 'minmax(0, 1fr) auto',
+														gap: spacing.sm,
+														[mq.mobile]: {
+															gridTemplateColumns: '1fr',
+														},
+													}}
+												>
+													<input
+														type="text"
+														value={
+															typeof capabilityName === 'string'
+																? capabilityName
+																: ''
+														}
+														placeholder="home_lutron_set_credentials"
+														on={{
+															input: (event) =>
+																updateAllowedCapability(
+																	index,
+																	event.currentTarget.value,
+																),
+														}}
+														css={inputCss}
+													/>
+													<button
+														type="button"
+														on={{ click: () => removeAllowedCapability(index) }}
+														css={secondaryButtonCss}
+													>
+														Remove
+													</button>
+												</div>
+											),
+										)}
+									</div>
+									<div>
+										<button
+											type="button"
+											on={{ click: addAllowedCapability }}
+											css={secondaryButtonCss}
+										>
+											Add capability
+										</button>
+									</div>
+								</div>
+
 								{selectedSecret ? (
 									<div
 										css={{

diff --git a/packages/worker/migrations/0010-secret-allowed-capabilities.sql b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
@@ -1,0 +1,2 @@
+ALTER TABLE secret_entries
+ADD COLUMN allowed_capabilities TEXT NOT NULL DEFAULT '[]';

diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -16,11 +16,13 @@
 	listSecrets,
 	resolveSecret,
 	saveSecret,
+	setSecretAllowedCapabilities,
 	setSecretAllowedHosts,
 } from '#mcp/secrets/service.ts'
 import { type SecretScope } from '#mcp/secrets/types.ts'
 import { listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts'
 import { type routes } from '#app/routes.ts'
+import { normalizeAllowedCapabilities } from '#mcp/secrets/allowed-capabilities.ts'
 import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts'
 
 type AccountEditableSecretScope = Extract<SecretScope, 'app' | 'user'>
@@ -39,6 +41,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -335,6 +338,7 @@
 		description: string
 		appId: string | null
 		allowedHosts: Array<string>
+		allowedCapabilities: Array<string>
 		createdAt: string
 		updatedAt: string
 		ttlMs: number | null
@@ -358,6 +362,7 @@
 		appId: secret.appId,
 		appTitle: secret.appId ? (appTitles.get(secret.appId) ?? null) : null,
 		allowedHosts: secret.allowedHosts,
+		allowedCapabilities: secret.allowedCapabilities,
 		createdAt: secret.createdAt,
 		updatedAt: secret.updatedAt,
 		ttlMs: secret.ttlMs,
@@ -446,6 +451,9 @@
 	const allowedHosts = normalizeAllowedHosts(
 		readStringArray(input.body, 'allowedHosts'),
 	)
+	const allowedCapabilities = normalizeAllowedCapabilities(
+		readStringArray(input.body, 'allowedCapabilities'),
+	)
 
 	if (!name) {
 		return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400)
@@ -523,6 +531,17 @@
 				appId,
 			}),
 		})
+		await setSecretAllowedCapabilities({
+			env: input.env,
+			userId: input.user.mcpUser.userId,
+			name,
+			scope,
+			allowedCapabilities,
+			storageContext: getSecretContextForAccountSecret({
+				scope,
+				appId,
+			}),
+		})
 
 		if (currentSecret && currentSecret.id !== nextId) {
 			await deleteSecret({

diff --git a/packages/worker/src/app/saved-ui-hosted-html.ts b/packages/worker/src/app/saved-ui-hosted-html.ts
--- a/packages/worker/src/app/saved-ui-hosted-html.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.ts
@@ -469,6 +469,10 @@
 			(secret.app_id == null || typeof secret.app_id === 'string') &&
 			Array.isArray(secret.allowed_hosts) &&
 			secret.allowed_hosts.every((host) => typeof host === 'string') &&
+			Array.isArray(secret.allowed_capabilities) &&
+			secret.allowed_capabilities.every(
+				(capability) => typeof capability === 'string',
+			) &&
 			typeof secret.created_at === 'string' &&
 			typeof secret.updated_at === 'string' &&
 			(secret.ttl_ms == null ||

diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
--- a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
@@ -12,7 +12,7 @@
 	{
 		name: 'secret_list',
 		description:
-			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts, from the sandbox.',
+			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts and allowed capabilities, from the sandbox.',
 		keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'],
 		readOnly: true,
 		idempotent: true,
@@ -46,6 +46,7 @@
 					description: secret.description,
 					app_id: secret.appId,
 					allowed_hosts: secret.allowedHosts,
+					allowed_capabilities: secret.allowedCapabilities,
 					created_at: secret.createdAt,
 					updated_at: secret.updatedAt,
 					ttl_ms: secret.ttlMs,

diff --git a/packages/worker/src/mcp/capabilities/secrets/shared.ts b/packages/worker/src/mcp/capabilities/secrets/shared.ts
--- a/packages/worker/src/mcp/capabilities/secrets/shared.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/shared.ts
@@ -7,6 +7,7 @@
 	description: z.string(),
 	app_id: z.string().nullable(),
 	allowed_hosts: z.array(z.string()),
+	allowed_capabilities: z.array(z.string()),
 	created_at: z.string(),
 	updated_at: z.string(),
 	ttl_ms: z.number().int().nonnegative().nullable(),

diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -1,0 +1,54 @@
+import { expect, test } from 'vitest'
+import {
+	createCapabilitySecretAccessDeniedMessage,
+	createMissingSecretMessage,
+} from '#mcp/secrets/errors.ts'
+import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
+
+test('getExecutionErrorDetails returns concrete guidance for capability access denial', () => {
+	const error = new Error(
+		createCapabilitySecretAccessDeniedMessage(
+			'cloudflareToken',
+			'cloudflare_rest',
+		),
+	)
+
+	expect(getExecutionErrorDetails(error)).toEqual({
+		kind: 'secret_capability_access_required',
+		message:
+			'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
+		nextStep:
+			"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+		secretNames: ['cloudflareToken'],
+		capabilityName: 'cloudflare_rest',
+		suggestedAction: {
+			type: 'edit_secret_policy',
+			policyField: 'allowed_capabilities',
+		},
+	})
+})
+
+test('formatExecutionOutput includes capability access next step', () => {
+	const result = {
+		error: new Error(
+			createCapabilitySecretAccessDeniedMessage(
+				'cloudflareToken',
+				'cloudflare_rest',
+			),
+		),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		"Next step: Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+	)
+})
+
+test('formatExecutionOutput keeps missing secret guidance intact', () => {
+	const result = {
+		error: new Error(createMissingSecretMessage('missingToken')),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		'Open a generated UI so the user can provide and save this secret',
+	)
+})

diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,6 +4,7 @@
 import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
 import {
 	isSecretAuthRequiredMessage,
+	parseCapabilityAccessRequiredMessage,
 	parseHostApprovalRequiredMessage,
 	parseMissingSecretMessage,
 } from '#mcp/secrets/errors.ts'
@@ -45,6 +46,17 @@
 			}
 	  }
 	| {
+			kind: 'secret_capability_access_required'
+			message: string
+			nextStep: string
+			secretNames: Array<string>
+			capabilityName: string
+			suggestedAction: {
+				type: 'edit_secret_policy'
+				policyField: 'allowed_capabilities'
+			}
+	  }
+	| {
 			kind: 'secret_required'
 			message: string
 			nextStep: string
@@ -84,6 +96,22 @@
 		}
 	}
 
+	const capabilityAccessDetails = parseCapabilityAccessRequiredMessage(message)
+	if (capabilityAccessDetails) {
+		return {
+			kind: 'secret_capability_access_required',
+			message,
+			nextStep:
+				"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+			secretNames: [capabilityAccessDetails.secretName],
+			capabilityName: capabilityAccessDetails.capabilityName,
+			suggestedAction: {
+				type: 'edit_secret_policy',
+				policyField: 'allowed_capabilities',
+			},
+		}
+	}
+
 	const missingSecretDetails = parseMissingSecretMessage(message)
 	if (missingSecretDetails) {
 		return {

diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts
--- a/packages/worker/src/mcp/generated-ui-api.ts
+++ b/packages/worker/src/mcp/generated-ui-api.ts
@@ -299,6 +299,7 @@
 						description: secret.description,
 						app_id: secret.appId,
 						allowed_hosts: secret.allowedHosts,
+						allowed_capabilities: secret.allowedCapabilities,
 						created_at: secret.createdAt,
 						updated_at: secret.updatedAt,
 						ttl_ms: secret.ttlMs,
@@ -338,6 +339,7 @@
 						description: saved.description,
 						app_id: saved.appId,
 						allowed_hosts: saved.allowedHosts,
+						allowed_capabilities: saved.allowedCapabilities,
 						created_at: saved.createdAt,
 						updated_at: saved.updatedAt,
 						ttl_ms: saved.ttlMs,

diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts
--- a/packages/worker/src/mcp/index.ts
+++ b/packages/worker/src/mcp/index.ts
@@ -80,10 +80,10 @@
 - Each capability call returns that capability's raw structured result value.
 - When chaining calls, read fields from the previous result using its outputSchema.
 - Chain multiple calls, use conditionals, and return structured results.
-- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, and allowed hosts from the sandbox.
+- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, allowed hosts, and allowed capabilities from the sandbox.
 - Use \`await codemode.value_get({ name })\` or \`await codemode.value_list({ scope })\` for readable non-secret configuration that generated UI code should be able to store and read back later.
 - Use normal \`fetch(...)\` for outbound HTTP. To inject a stored secret, place a placeholder such as \`{{secret:cloudflareToken}}\` or \`{{secret:cloudflareToken|scope=user}}\` in the URL, headers, or request body; the host resolves it server-side and blocks unapproved destinations.
-- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value.
+- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value. If that secret has an allowed-capabilities policy, the current capability name must be on the allowlist.
 - Secret placeholders are not general-purpose string interpolation. Do not use \`execute\` to build a string or object that merely returns \`{{secret:...}}\`; those placeholders only resolve in secret-aware fetch paths or capability inputs that explicitly opt into \`x-kody-secret\`.
 - Saving or updating a secret does not authorize sending it anywhere. If a fetch fails because a host is not approved for that secret, ask the user whether to open the approval link and approve that host in the web app.
 - Secrets are intentionally not readable or updatable through \`codemode\`. Never ask the user to paste a secret into chat; use generated UI flows such as \`saveSecret(...)\` when the user needs to provide or rotate a value, and use \`codemode.secret_delete(...)\` only when removing a stored secret reference.

diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -1337,6 +1337,7 @@
 		description: 'Session-only verification code',
 		app_id: null,
 		allowed_hosts: [],
+		allowed_capabilities: [],
 		created_at: expect.any(String),
 		updated_at: expect.any(String),
 		ttl_ms: expect.any(Number),
@@ -1401,6 +1402,7 @@
 			description: 'App-scoped Cloudflare deployment token',
 			app_id: appId,
 			allowed_hosts: [],
+			allowed_capabilities: [],
 			created_at: expect.any(String),
 			updated_at: expect.any(String),
 			ttl_ms: null,

diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
--- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
@@ -1,6 +1,7 @@
-import { expect, test } from 'vitest'
+import { expect, test, vi } from 'vitest'
 import { createMcpCallerContext } from '#mcp/context.ts'
 import { buildCodemodeFns } from './run-codemode-registry.ts'
+import * as secretService from '#mcp/secrets/service.ts'
 
 test('buildCodemodeFns resolves annotated home capability secret placeholders', async () => {
 	let toolArguments: Record<string, unknown> | null = null
@@ -72,10 +73,12 @@
 		env,
 		createMcpCallerContext({
 			baseUrl: 'https://heykody.dev',
+			user: { userId: 'user-123' },
 			homeConnectorId: 'default',
 		}),
 		{
-			resolveSecretValue: async (secret) => `${secret.name}-resolved`,
+			resolveSecretValue: async (secret, capabilityName) =>
+				`${secret.name}-${capabilityName}-resolved`,
 		},
 	)
 
@@ -87,7 +90,93 @@
 
 	expect(toolArguments).toEqual({
 		processorId: 'lutron-192-168-0-41',
-		username: 'lutronUsername-resolved',
-		password: 'lutronPassword-resolved',
+		username: 'lutronUsername-home_lutron_set_credentials-resolved',
+		password: 'lutronPassword-home_lutron_set_credentials-resolved',
 	})
 })
+
+test('buildCodemodeFns denies capability secret placeholders for disallowed capabilities', async () => {
+	const resolveSecretSpy = vi
+		.spyOn(secretService, 'resolveSecret')
+		.mockResolvedValue({
+			found: true,
+			value: 'lutronUsername-resolved',
+			scope: 'user',
+			allowedHosts: [],
+			allowedCapabilities: ['some_other_capability'],
+		})
+	const env = {
+		HOME_CONNECTOR_SESSION: {
+			idFromName(name: string) {
+				return name
+			},
+			get() {
+				return {
+					async fetch(input: string | URL | Request) {
+						const url = new URL(
+							typeof input === 'string'
+								? input
+								: input instanceof URL
+									? input.toString()
+									: input.url,
+						)
+						if (url.pathname.endsWith('/snapshot')) {
+							return Response.json({
+								connectorId: 'default',
+								connectedAt: '2026-03-27T00:00:00.000Z',
+								lastSeenAt: '2026-03-27T00:00:01.000Z',
+								tools: [
+									{
+										name: 'lutron_set_credentials',
... diff truncated: showing 800 of 1379 lines
Open in Web Open in Cursor 

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread docs/agents/adding-capabilities.md Outdated
Comment on lines +109 to +110
An empty `allowed_capabilities` list means the secret is unrestricted for
capability-input use.

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.

@cursoragent I think this is now inaccurate. An empty list should definitely mean it is completely restricted.

@cursor cursor Bot Mar 27, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Wrong property name breaks app-scoped capability allowlist saves
    • Renamed the capability save input to use storageContext/StorageContext and updated the caller so app-scoped secrets resolve the correct bucket.

Create PR

Preview (913e37f621)
diff --git a/docs/agents/adding-capabilities.md b/docs/agents/adding-capabilities.md
--- a/docs/agents/adding-capabilities.md
+++ b/docs/agents/adding-capabilities.md
@@ -104,6 +104,11 @@
 elsewhere, and secret-bearing capability inputs still require an authenticated
 user.
 
+When a secret has an `allowed_capabilities` policy, Kody also checks that the
+current capability name is explicitly listed before resolving the placeholder.
+An empty `allowed_capabilities` list means no capability is allowed to resolve
+the secret until entries are added.
+
 Use this for capabilities that need to accept a secret value as an argument but
 are not themselves the host-approval boundary. Good fits include:
 
@@ -284,6 +289,8 @@
 
 - annotate only the exact credential fields, not the whole object
 - prefer this for local persistence or device-side credential flows
+- tell users which capability names should be added to a secret's
+  `allowed_capabilities` policy when a workflow depends on restricted secrets
 - avoid using it for generic remote API wrappers where fetch-time host approval
   should remain the enforcement point
 
@@ -295,8 +302,8 @@
   ranked results
 - use `execute` to confirm the capability runs correctly
 
-Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real
-MCP contract.
+Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real MCP
+contract.
 
 Registry invariants (duplicate capability names, domain/capability mismatches,
 duplicate domain registration) are covered in

diff --git a/docs/agents/secret-host-approval.md b/docs/agents/secret-host-approval.md
--- a/docs/agents/secret-host-approval.md
+++ b/docs/agents/secret-host-approval.md
@@ -5,18 +5,19 @@
 
 ## Rule
 
-Allowed outbound hosts for a secret are privileged policy, not normal secret
+Allowed outbound hosts for a secret and allowed capabilities for direct
+capability-input secret resolution are privileged policy, not normal secret
 metadata.
 
-That policy must not be created, widened, or modified by:
+Those policies must not be created, widened, or modified by:
 
 - MCP tools
 - execute-time sandboxed code
 - generated UI code
 - capability handlers that serve agent-driven secret creation or update flows
 
-Allowed outbound hosts may only be changed through the authenticated account
-admin UI approval flow.
+Allowed outbound hosts and allowed capabilities may only be changed through the
+authenticated account admin UI.
 
 In this repo, that means the user must approve host access through the account
 secrets experience, such as `/account/secrets` and the focused approval route at
@@ -29,7 +30,7 @@
 UI.
 
 Saving or updating a secret value does not authorize sending that secret to any
-host.
+host or passing it into any capability.
 
 If an outbound request uses a placeholder such as `{{secret:name}}` and the
 target host is not already approved for that secret, the correct behavior is:
@@ -42,16 +43,22 @@
 ## What agents must not do
 
 Do not design or document any MCP capability, generated UI helper, or client
-library that allows agent-controlled writes to a secret's allowed hosts.
+library that allows agent-controlled writes to a secret's allowed hosts or
+allowed capabilities.
 
 Specifically, do not:
 
 - add `allowed_hosts` or equivalent fields to MCP-facing secret create/update
   inputs
+- add `allowed_capabilities` or equivalent fields to MCP-facing secret
+  create/update inputs
 - imply that a generated UI can self-authorize a host just because it can save a
   secret
+- imply that execute-time capability calls can widen which capabilities may
+  consume a secret
 - imply that execute-time code can widen egress permissions
-- treat host approval as ordinary secret metadata editing
+- treat host approval or capability allowlists as ordinary secret metadata
+  editing
 
 If a workflow would be smoother by auto-approving a host, the fix should be
 better guidance, helper APIs, or UX around the approval flow, not a new write
@@ -62,7 +69,10 @@
 When writing capability descriptions or agent-facing docs:
 
 - say explicitly that secret save/update does not grant outbound use
+- say explicitly that secret save/update does not grant capability access
 - say explicitly that only the authenticated account admin UI can approve hosts
+- say explicitly that only the authenticated account admin UI can restrict which
+  capabilities may consume a secret directly
 - tell agents to inspect secret metadata before making a secret-bearing request
 - tell agents to surface the approval link and stop on deny
 
@@ -84,6 +94,9 @@
 for example to store credentials on a local connector or pass them into a
 device-local action.
 
+If a secret has an allowed-capabilities policy, Kody enforces that allowlist by
+capability name before resolving the placeholder for the handler.
+
 Use it narrowly:
 
 - mark only the exact sensitive fields
@@ -105,11 +118,13 @@
 - save those values as secrets
 - save and read back non-secret values for public configuration
 - inspect secret metadata, including current allowed hosts
+- inspect secret metadata, including current allowed capabilities
 - present approval links returned from blocked requests
 
 Generated UIs may not:
 
 - set allowed hosts directly
+- set allowed capabilities directly
 - bypass the admin approval route
 - silently retry secret-bearing requests after a deny
 - use `executeCode(...)` as a general string interpolation mechanism for

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
@@ -215,7 +215,6 @@
 		errorMessage: 'Saved app not found for this user.',
 	})
 })
-
 test('buildLocalMessageLogRuntimeSource logs sendMessage locally outside hosted contexts', () => {
 	const originalWindow = globalThis.window
 	const originalDocument = globalThis.document

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -22,6 +22,7 @@
 	description: string
 	app_id: string | null
 	allowed_hosts: Array<string>
+	allowed_capabilities: Array<string>
 	created_at: string
 	updated_at: string
 	ttl_ms: number | null
@@ -772,6 +773,10 @@
 				(secret.app_id == null || typeof secret.app_id === 'string') &&
 				Array.isArray(secret.allowed_hosts) &&
 				secret.allowed_hosts.every((host) => typeof host === 'string') &&
+				Array.isArray(secret.allowed_capabilities) &&
+				secret.allowed_capabilities.every(
+					(capability) => typeof capability === 'string',
+				) &&
 				typeof secret.created_at === 'string' &&
 				typeof secret.updated_at === 'string' &&
 				(secret.ttl_ms == null ||

diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -42,6 +42,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -68,6 +69,7 @@
 	description: string
 	value: string
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 }
 
 type SelectionState = {
@@ -108,10 +110,56 @@
 		description: '',
 		value: '',
 		allowedHosts: [''],
+		allowedCapabilities: [''],
 	}
 }
 
+function coerceStringRows(list: Array<unknown>): Array<string> {
+	return list.filter((item): item is string => typeof item === 'string')
+}
+
+/** Matches server `normalizeAllowedHosts` in `#mcp/secrets/allowed-hosts.ts`. */
+function clientNormalizeAllowedHosts(hosts: Array<string>): Array<string> {
+	return Array.from(
+		new Set(
+			hosts
+				.map((host) => host.trim().toLowerCase())
+				.filter((host) => host.length > 0),
+		),
+	).sort()
+}
+
+/** Matches server `normalizeAllowedCapabilities` in `#mcp/secrets/allowed-capabilities.ts`. */
+function clientNormalizeAllowedCapabilities(
+	capabilities: Array<string>,
+): Array<string> {
+	return Array.from(
+		new Set(
+			capabilities
+				.map((value) => value.trim())
+				.filter((value) => value.length > 0),
+		),
+	).sort((left, right) => left.localeCompare(right))
+}
+
+function collectRepeatedTextRows(
+	form: HTMLFormElement,
+	listName: 'allowed-hosts' | 'allowed-capabilities',
+): Array<string> {
+	const root = form.querySelector(`[data-repeat-list="${listName}"]`)
+	if (!root) return []
+	const out: Array<string> = []
+	for (const row of root.children) {
+		if (!(row instanceof HTMLElement)) continue
+		const input = row.querySelector('input[type="text"]')
+		if (input instanceof HTMLInputElement) out.push(input.value)
+	}
+	return out
+}
+
 function createEditorStateFromSecret(secret: SecretDetail): EditorState {
+	const allowedHosts = coerceStringRows(secret.allowedHosts)
+	const allowedCapabilities = coerceStringRows(secret.allowedCapabilities)
 	return {
 		currentId: secret.id,
 		name: secret.name,
@@ -119,7 +167,9 @@
 		appId: secret.appId ?? '',
 		description: secret.description,
 		value: secret.value,
-		allowedHosts: secret.allowedHosts.length > 0 ? secret.allowedHosts : [''],
+		allowedHosts: allowedHosts.length > 0 ? allowedHosts : [''],
+		allowedCapabilities:
+			allowedCapabilities.length > 0 ? allowedCapabilities : [''],
 	}
 }
 
@@ -255,6 +305,7 @@
 			secret.appTitle ?? '',
 			secret.scope,
 			...secret.allowedHosts,
+			...secret.allowedCapabilities,
 		]
 			.join(' ')
 			.toLowerCase()
@@ -435,11 +486,19 @@
 		event.preventDefault()
 		if (saveState !== 'idle') return
 
+		const form = event.currentTarget as HTMLFormElement
+
 		saveState = 'saving'
 		message = null
 		handle.update()
 
 		try {
+			const allowedHosts = clientNormalizeAllowedHosts(
+				collectRepeatedTextRows(form, 'allowed-hosts'),
+			)
+			const allowedCapabilities = clientNormalizeAllowedCapabilities(
+				collectRepeatedTextRows(form, 'allowed-capabilities'),
+			)
 			const response = await fetch(accountSecretsApiPath, {
 				method: 'POST',
 				headers: {
@@ -455,7 +514,8 @@
 					appId: editorState.scope === 'app' ? editorState.appId : null,
 					description: editorState.description,
 					value: editorState.value,
-					allowedHosts: editorState.allowedHosts,
+					allowedHosts,
+					allowedCapabilities,
 				}),
 			})
 			if (response.status === 401) {
@@ -569,6 +629,37 @@
 		handle.update()
 	}
 
+	function updateAllowedCapability(index: number, value: string) {
+		editorState = {
+			...editorState,
+			allowedCapabilities: editorState.allowedCapabilities.map(
+				(capabilityName, capabilityIndex) =>
+					capabilityIndex === index ? value : capabilityName,
+			),
+		}
+		handle.update()
+	}
+
+	function addAllowedCapability() {
+		editorState = {
+			...editorState,
+			allowedCapabilities: [...editorState.allowedCapabilities, ''],
+		}
+		handle.update()
+	}
+
+	function removeAllowedCapability(index: number) {
+		const nextCapabilities = editorState.allowedCapabilities.filter(
+			(_capabilityName, capabilityIndex) => capabilityIndex !== index,
+		)
+		editorState = {
+			...editorState,
+			allowedCapabilities:
+				nextCapabilities.length > 0 ? nextCapabilities : [''],
+		}
+		handle.update()
+	}
+
 	return () => {
 		const currentHref = getCurrentHref()
 		const selection = getSelectionState(currentHref)
@@ -1181,10 +1272,13 @@
 											a secret can be used.
 										</p>
 									</div>
-									<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-hosts"
+									>
 										{editorState.allowedHosts.map((host, index) => (
 											<div
-												key={`${index}-${host}`}
+												key={index}
 												css={{
 													display: 'grid',
 													gridTemplateColumns: 'minmax(0, 1fr) auto',
@@ -1195,9 +1289,9 @@
 												}}
 											>
 												<input
-													type="text"
-													value={host}
-													placeholder="api.example.com"
+														type="text"
+														value={typeof host === 'string' ? host : ''}
+														placeholder="api.example.com"
 													on={{
 														input: (event) =>
 															updateAllowedHost(
@@ -1228,6 +1322,72 @@
 									</div>
 								</div>
 
+								<div css={{ display: 'grid', gap: spacing.sm }}>
+									<div css={{ display: 'grid', gap: spacing.xs }}>
+										<span css={fieldLabelCss}>Allowed capabilities</span>
+										<p css={{ margin: 0, color: colors.textMuted }}>
+											Only capabilities listed here can resolve this secret
+											when used with an
+											<code> x-kody-secret </code>
+											input.
+										</p>
+									</div>
+									<div
+										css={{ display: 'grid', gap: spacing.sm }}
+										data-repeat-list="allowed-capabilities"
+									>
+										{editorState.allowedCapabilities.map(
+											(capabilityName, index) => (
+												<div
+													key={index}
+													css={{
+														display: 'grid',
+														gridTemplateColumns: 'minmax(0, 1fr) auto',
+														gap: spacing.sm,
+														[mq.mobile]: {
+															gridTemplateColumns: '1fr',
+														},
+													}}
+												>
+													<input
+														type="text"
+														value={
+															typeof capabilityName === 'string'
+																? capabilityName
+																: ''
+														}
+														placeholder="home_lutron_set_credentials"
+														on={{
+															input: (event) =>
+																updateAllowedCapability(
+																	index,
+																	event.currentTarget.value,
+																),
+														}}
+														css={inputCss}
+													/>
+													<button
+														type="button"
+														on={{ click: () => removeAllowedCapability(index) }}
+														css={secondaryButtonCss}
+													>
+														Remove
+													</button>
+												</div>
+											),
+										)}
+									</div>
+									<div>
+										<button
+											type="button"
+											on={{ click: addAllowedCapability }}
+											css={secondaryButtonCss}
+										>
+											Add capability
+										</button>
+									</div>
+								</div>
+
 								{selectedSecret ? (
 									<div
 										css={{

diff --git a/packages/worker/migrations/0010-secret-allowed-capabilities.sql b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0010-secret-allowed-capabilities.sql
@@ -1,0 +1,2 @@
+ALTER TABLE secret_entries
+ADD COLUMN allowed_capabilities TEXT NOT NULL DEFAULT '[]';

diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -16,11 +16,13 @@
 	listSecrets,
 	resolveSecret,
 	saveSecret,
+	setSecretAllowedCapabilities,
 	setSecretAllowedHosts,
 } from '#mcp/secrets/service.ts'
 import { type SecretScope } from '#mcp/secrets/types.ts'
 import { listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts'
 import { type routes } from '#app/routes.ts'
+import { normalizeAllowedCapabilities } from '#mcp/secrets/allowed-capabilities.ts'
 import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts'
 
 type AccountEditableSecretScope = Extract<SecretScope, 'app' | 'user'>
@@ -39,6 +41,7 @@
 	appId: string | null
 	appTitle: string | null
 	allowedHosts: Array<string>
+	allowedCapabilities: Array<string>
 	createdAt: string
 	updatedAt: string
 	ttlMs: number | null
@@ -335,6 +338,7 @@
 		description: string
 		appId: string | null
 		allowedHosts: Array<string>
+		allowedCapabilities: Array<string>
 		createdAt: string
 		updatedAt: string
 		ttlMs: number | null
@@ -358,6 +362,7 @@
 		appId: secret.appId,
 		appTitle: secret.appId ? (appTitles.get(secret.appId) ?? null) : null,
 		allowedHosts: secret.allowedHosts,
+		allowedCapabilities: secret.allowedCapabilities,
 		createdAt: secret.createdAt,
 		updatedAt: secret.updatedAt,
 		ttlMs: secret.ttlMs,
@@ -446,6 +451,9 @@
 	const allowedHosts = normalizeAllowedHosts(
 		readStringArray(input.body, 'allowedHosts'),
 	)
+	const allowedCapabilities = normalizeAllowedCapabilities(
+		readStringArray(input.body, 'allowedCapabilities'),
+	)
 
 	if (!name) {
 		return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400)
@@ -523,6 +531,17 @@
 				appId,
 			}),
 		})
+		await setSecretAllowedCapabilities({
+			env: input.env,
+			userId: input.user.mcpUser.userId,
+			name,
+			scope,
+			allowedCapabilities,
+			storageContext: getSecretContextForAccountSecret({
+				scope,
+				appId,
+			}),
+		})
 
 		if (currentSecret && currentSecret.id !== nextId) {
 			await deleteSecret({

diff --git a/packages/worker/src/app/saved-ui-hosted-html.ts b/packages/worker/src/app/saved-ui-hosted-html.ts
--- a/packages/worker/src/app/saved-ui-hosted-html.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.ts
@@ -469,6 +469,10 @@
 			(secret.app_id == null || typeof secret.app_id === 'string') &&
 			Array.isArray(secret.allowed_hosts) &&
 			secret.allowed_hosts.every((host) => typeof host === 'string') &&
+			Array.isArray(secret.allowed_capabilities) &&
+			secret.allowed_capabilities.every(
+				(capability) => typeof capability === 'string',
+			) &&
 			typeof secret.created_at === 'string' &&
 			typeof secret.updated_at === 'string' &&
 			(secret.ttl_ms == null ||

diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
--- a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts
@@ -12,7 +12,7 @@
 	{
 		name: 'secret_list',
 		description:
-			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts, from the sandbox.',
+			'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use `codemode.secret_list({ scope })` inside execute-time code when you want the same metadata, including allowed hosts and allowed capabilities, from the sandbox.',
 		keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'],
 		readOnly: true,
 		idempotent: true,
@@ -46,6 +46,7 @@
 					description: secret.description,
 					app_id: secret.appId,
 					allowed_hosts: secret.allowedHosts,
+					allowed_capabilities: secret.allowedCapabilities,
 					created_at: secret.createdAt,
 					updated_at: secret.updatedAt,
 					ttl_ms: secret.ttlMs,

diff --git a/packages/worker/src/mcp/capabilities/secrets/shared.ts b/packages/worker/src/mcp/capabilities/secrets/shared.ts
--- a/packages/worker/src/mcp/capabilities/secrets/shared.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/shared.ts
@@ -7,6 +7,7 @@
 	description: z.string(),
 	app_id: z.string().nullable(),
 	allowed_hosts: z.array(z.string()),
+	allowed_capabilities: z.array(z.string()),
 	created_at: z.string(),
 	updated_at: z.string(),
 	ttl_ms: z.number().int().nonnegative().nullable(),

diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -1,0 +1,54 @@
+import { expect, test } from 'vitest'
+import {
+	createCapabilitySecretAccessDeniedMessage,
+	createMissingSecretMessage,
+} from '#mcp/secrets/errors.ts'
+import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
+
+test('getExecutionErrorDetails returns concrete guidance for capability access denial', () => {
+	const error = new Error(
+		createCapabilitySecretAccessDeniedMessage(
+			'cloudflareToken',
+			'cloudflare_rest',
+		),
+	)
+
+	expect(getExecutionErrorDetails(error)).toEqual({
+		kind: 'secret_capability_access_required',
+		message:
+			'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
+		nextStep:
+			"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+		secretNames: ['cloudflareToken'],
+		capabilityName: 'cloudflare_rest',
+		suggestedAction: {
+			type: 'edit_secret_policy',
+			policyField: 'allowed_capabilities',
+		},
+	})
+})
+
+test('formatExecutionOutput includes capability access next step', () => {
+	const result = {
+		error: new Error(
+			createCapabilitySecretAccessDeniedMessage(
+				'cloudflareToken',
+				'cloudflare_rest',
+			),
+		),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		"Next step: Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+	)
+})
+
+test('formatExecutionOutput keeps missing secret guidance intact', () => {
+	const result = {
+		error: new Error(createMissingSecretMessage('missingToken')),
+	} as const
+
+	expect(formatExecutionOutput(result)).toContain(
+		'Open a generated UI so the user can provide and save this secret',
+	)
+})

diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,6 +4,7 @@
 import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
 import {
 	isSecretAuthRequiredMessage,
+	parseCapabilityAccessRequiredMessage,
 	parseHostApprovalRequiredMessage,
 	parseMissingSecretMessage,
 } from '#mcp/secrets/errors.ts'
@@ -45,6 +46,17 @@
 			}
 	  }
 	| {
+			kind: 'secret_capability_access_required'
+			message: string
+			nextStep: string
+			secretNames: Array<string>
+			capabilityName: string
+			suggestedAction: {
+				type: 'edit_secret_policy'
+				policyField: 'allowed_capabilities'
+			}
+	  }
+	| {
 			kind: 'secret_required'
 			message: string
 			nextStep: string
@@ -84,6 +96,22 @@
 		}
 	}
 
+	const capabilityAccessDetails = parseCapabilityAccessRequiredMessage(message)
+	if (capabilityAccessDetails) {
+		return {
+			kind: 'secret_capability_access_required',
+			message,
+			nextStep:
+				"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
+			secretNames: [capabilityAccessDetails.secretName],
+			capabilityName: capabilityAccessDetails.capabilityName,
+			suggestedAction: {
+				type: 'edit_secret_policy',
+				policyField: 'allowed_capabilities',
+			},
+		}
+	}
+
 	const missingSecretDetails = parseMissingSecretMessage(message)
 	if (missingSecretDetails) {
 		return {

diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts
--- a/packages/worker/src/mcp/generated-ui-api.ts
+++ b/packages/worker/src/mcp/generated-ui-api.ts
@@ -299,6 +299,7 @@
 						description: secret.description,
 						app_id: secret.appId,
 						allowed_hosts: secret.allowedHosts,
+						allowed_capabilities: secret.allowedCapabilities,
 						created_at: secret.createdAt,
 						updated_at: secret.updatedAt,
 						ttl_ms: secret.ttlMs,
@@ -338,6 +339,7 @@
 						description: saved.description,
 						app_id: saved.appId,
 						allowed_hosts: saved.allowedHosts,
+						allowed_capabilities: saved.allowedCapabilities,
 						created_at: saved.createdAt,
 						updated_at: saved.updatedAt,
 						ttl_ms: saved.ttlMs,

diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts
--- a/packages/worker/src/mcp/index.ts
+++ b/packages/worker/src/mcp/index.ts
@@ -80,10 +80,10 @@
 - Each capability call returns that capability's raw structured result value.
 - When chaining calls, read fields from the previous result using its outputSchema.
 - Chain multiple calls, use conditionals, and return structured results.
-- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, and allowed hosts from the sandbox.
+- Use \`await codemode.secret_list({})\` or \`await codemode.secret_list({ scope: 'app' })\` when you need secret metadata such as names, descriptions, scopes, allowed hosts, and allowed capabilities from the sandbox.
 - Use \`await codemode.value_get({ name })\` or \`await codemode.value_list({ scope })\` for readable non-secret configuration that generated UI code should be able to store and read back later.
 - Use normal \`fetch(...)\` for outbound HTTP. To inject a stored secret, place a placeholder such as \`{{secret:cloudflareToken}}\` or \`{{secret:cloudflareToken|scope=user}}\` in the URL, headers, or request body; the host resolves it server-side and blocks unapproved destinations.
-- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value.
+- Some capability input fields also accept secret placeholders. When an input schema marks a string field with \`x-kody-secret: true\`, you may pass \`{{secret:name}}\` or \`{{secret:name|scope=user}}\` there instead of a raw value. If that secret has an allowed-capabilities policy, the current capability name must be on the allowlist.
 - Secret placeholders are not general-purpose string interpolation. Do not use \`execute\` to build a string or object that merely returns \`{{secret:...}}\`; those placeholders only resolve in secret-aware fetch paths or capability inputs that explicitly opt into \`x-kody-secret\`.
 - Saving or updating a secret does not authorize sending it anywhere. If a fetch fails because a host is not approved for that secret, ask the user whether to open the approval link and approve that host in the web app.
 - Secrets are intentionally not readable or updatable through \`codemode\`. Never ask the user to paste a secret into chat; use generated UI flows such as \`saveSecret(...)\` when the user needs to provide or rotate a value, and use \`codemode.secret_delete(...)\` only when removing a stored secret reference.

diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -1337,6 +1337,7 @@
 		description: 'Session-only verification code',
 		app_id: null,
 		allowed_hosts: [],
+		allowed_capabilities: [],
 		created_at: expect.any(String),
 		updated_at: expect.any(String),
 		ttl_ms: expect.any(Number),
@@ -1401,6 +1402,7 @@
 			description: 'App-scoped Cloudflare deployment token',
 			app_id: appId,
 			allowed_hosts: [],
+			allowed_capabilities: [],
 			created_at: expect.any(String),
 			updated_at: expect.any(String),
 			ttl_ms: null,

diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
--- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
@@ -1,6 +1,7 @@
-import { expect, test } from 'vitest'
+import { expect, test, vi } from 'vitest'
 import { createMcpCallerContext } from '#mcp/context.ts'
 import { buildCodemodeFns } from './run-codemode-registry.ts'
+import * as secretService from '#mcp/secrets/service.ts'
 
 test('buildCodemodeFns resolves annotated home capability secret placeholders', async () => {
 	let toolArguments: Record<string, unknown> | null = null
@@ -72,10 +73,12 @@
 		env,
 		createMcpCallerContext({
 			baseUrl: 'https://heykody.dev',
+			user: { userId: 'user-123' },
 			homeConnectorId: 'default',
 		}),
 		{
-			resolveSecretValue: async (secret) => `${secret.name}-resolved`,
+			resolveSecretValue: async (secret, capabilityName) =>
+				`${secret.name}-${capabilityName}-resolved`,
 		},
 	)
 
@@ -87,7 +90,93 @@
 
 	expect(toolArguments).toEqual({
 		processorId: 'lutron-192-168-0-41',
-		username: 'lutronUsername-resolved',
-		password: 'lutronPassword-resolved',
+		username: 'lutronUsername-home_lutron_set_credentials-resolved',
+		password: 'lutronPassword-home_lutron_set_credentials-resolved',
 	})
 })
+
+test('buildCodemodeFns denies capability secret placeholders for disallowed capabilities', async () => {
+	const resolveSecretSpy = vi
+		.spyOn(secretService, 'resolveSecret')
+		.mockResolvedValue({
+			found: true,
+			value: 'lutronUsername-resolved',
+			scope: 'user',
+			allowedHosts: [],
+			allowedCapabilities: ['some_other_capability'],
+		})
+	const env = {
+		HOME_CONNECTOR_SESSION: {
+			idFromName(name: string) {
+				return name
+			},
+			get() {
+				return {
+					async fetch(input: string | URL | Request) {
+						const url = new URL(
+							typeof input === 'string'
+								? input
+								: input instanceof URL
+									? input.toString()
+									: input.url,
+						)
+						if (url.pathname.endsWith('/snapshot')) {
+							return Response.json({
+								connectorId: 'default',
+								connectedAt: '2026-03-27T00:00:00.000Z',
+								lastSeenAt: '2026-03-27T00:00:01.000Z',
+								tools: [
+									{
+										name: 'lutron_set_credentials',
... diff truncated: showing 800 of 1379 lines
Open in Web Open in Cursor 

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants