Repository navigation
Add capability-scoped secret allowlists - #68
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR adds an Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
| secretName: string, | ||
| capabilityName: string, | ||
| ) { | ||
| return `Secret "${secretName}" is not allowed for capability "${capabilityName}".` |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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 ontomainand 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.
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>
448c026 to
2aefef7
Compare
|
🔎 Preview deployed: https://kody-pr-68.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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/StorageContextand updated the caller so app-scoped secrets resolve the correct bucket.
- Renamed the capability save input to use
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 linesCo-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
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 inpackages/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
📒 Files selected for processing (25)
docs/agents/adding-capabilities.mddocs/agents/secret-host-approval.mdpackages/worker/client/mcp-apps/generated-ui-shell.node.test.tspackages/worker/client/mcp-apps/generated-ui-shell.tspackages/worker/client/routes/account-secrets.tsxpackages/worker/migrations/0010-secret-allowed-capabilities.sqlpackages/worker/src/app/handlers/account-secrets.tspackages/worker/src/app/saved-ui-hosted-html.tspackages/worker/src/mcp/capabilities/secrets/secret-list.tspackages/worker/src/mcp/capabilities/secrets/shared.tspackages/worker/src/mcp/executor.node.test.tspackages/worker/src/mcp/executor.tspackages/worker/src/mcp/generated-ui-api.tspackages/worker/src/mcp/index.tspackages/worker/src/mcp/mcp-server.mcp-e2e.test.tspackages/worker/src/mcp/run-codemode-registry.node.test.tspackages/worker/src/mcp/run-codemode-registry.tspackages/worker/src/mcp/secrets/account-secret-save-body.node.test.tspackages/worker/src/mcp/secrets/allowed-capabilities.tspackages/worker/src/mcp/secrets/errors.node.test.tspackages/worker/src/mcp/secrets/errors.tspackages/worker/src/mcp/secrets/repo.tspackages/worker/src/mcp/secrets/service.tspackages/worker/src/mcp/secrets/types.tspackages/worker/src/mcp/tools/execute.ts
💤 Files with no reviewable changes (1)
- packages/worker/client/mcp-apps/generated-ui-shell.node.test.ts
| 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.` | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | 🔴 CriticalDon’t round-trip
allowed_capabilitiesthrough stale entry snapshots.
saveSecret/updateSecretnow copyexistingEntry.allowed_capabilities, andsetSecretAllowedCapabilitieswrites...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 anupdated_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.
setSecretAllowedHostsandsetSecretAllowedCapabilitiesnow 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
📒 Files selected for processing (2)
packages/worker/src/app/handlers/account-secrets.tspackages/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
| Leave this empty to allow any capability with an | ||
| <code> x-kody-secret </code> | ||
| input to resolve this secret. |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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/StorageContextand updated the caller so app-scoped secrets resolve the correct bucket.
- Renamed the capability save input to use
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 linesCo-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
| An empty `allowed_capabilities` list means the secret is unrestricted for | ||
| capability-input use. |
There was a problem hiding this comment.
@cursoragent I think this is now inaccurate. An empty list should definitely mean it is completely restricted.
There was a problem hiding this comment.
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/StorageContextand updated the caller so app-scoped secrets resolve the correct bucket.
- Renamed the capability save input to use
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 linesCo-authored-by: Kent C. Dodds <me+github@kentcdodds.com>




Summary
allowed_capabilitiesstorage and normalization alongside existing host policyx-kody-secretinputs while leaving fetch-time host approval unchangedTesting
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.tsnpm run test:mcp -- packages/worker/src/mcp/mcp-server.mcp-e2e.test.tsSummary by CodeRabbit
New Features
Documentation
Behavior
Tests