Repository navigation
Add secure codemode secret_set capability - #96
Conversation
|
Caution Review failedPull request was closed or merged during review Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client (Codemode)
participant Executor as Codemode Executor
participant Tracker as Secret Tracker
participant Handler as Capability Handler
participant Storage as Secret Storage
participant Redactor as Secret Redactor
Client->>Executor: invoke codemode.secret_set({name, value, scope, ...})
Executor->>Tracker: trackSecretInputValue(value)
Tracker->>Tracker: store tracked value
Executor->>Handler: execute capability with resolved args
Handler->>Storage: saveSecret(name, value, scope, ...)
Storage-->>Handler: return metadata (no plaintext)
Handler-->>Executor: return metadata object
Executor->>Redactor: sanitize result.result, result.logs, result.error
Redactor->>Redactor: replace tracked secret values with "[REDACTED SECRET]"
Redactor-->>Executor: sanitized execution result
Executor-->>Client: return redacted result
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 docstrings
🧪 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 |
|
🔎 Preview deployed: https://kody-pr-96.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.node.test.ts`:
- Around line 278-282: Replace the inline inline typeof import(...) type
annotation with a top-level type import: add a top-level "import type" for
getCapabilityRegistryForContext from '#mcp/capabilities/registry' and then
change the annotation to use Awaited<ReturnType<typeof
getCapabilityRegistryForContext>> (or import a dedicated return type if
available) instead of the inline typeof import(...) expression so the type is
clearer and easier to maintain; update the annotation near the current cast that
references getCapabilityRegistryForContext.
🪄 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: bc2dfed6-1908-40d9-8e1a-655b7cc51dfa
📒 Files selected for processing (12)
docs/agents/adding-capabilities.mddocs/agents/secret-host-approval.mdpackages/worker/src/mcp/capabilities/secrets/domain.tspackages/worker/src/mcp/capabilities/secrets/secret-delete.tspackages/worker/src/mcp/capabilities/secrets/secret-list.tspackages/worker/src/mcp/capabilities/secrets/secret-set.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/skills/infer-codemode-capabilities.node.test.tspackages/worker/src/mcp/tools/execute.ts
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Non-string log entries bypass secret redaction
- Switched log entry redaction to the recursive redactor so structured log entries now have secrets sanitized.
- ✅ Fixed: Duplicated JSON schema traversal helpers across files
- Exported the schema traversal helpers from
capability-inputs.tsand reused them inrun-codemode-registry.tsto remove duplication.
- Exported the schema traversal helpers from
Preview (c243e5cbaa)
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,12 @@
elsewhere, and secret-bearing capability inputs still require an authenticated
user.
+Those inputs are also treated as write-only for the rest of that execution:
+once plaintext crosses an `x-kody-secret` capability boundary, Kody redacts that
+plaintext from later execute results and logs before returning them to the
+caller. Capability authors should still avoid returning or logging secret
+material, but the runtime adds this extra defense-in-depth layer.
+
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
@@ -132,6 +138,8 @@
3. Leave non-secret fields unannotated.
4. Document the intended use in the capability description when it may not be
obvious.
+5. If the capability persists or hands off a secret value, make the description
+ say that the input is write-only and must never be returned to chat.
Example:
@@ -289,6 +297,7 @@
- annotate only the exact credential fields, not the whole object
- prefer this for local persistence or device-side credential flows
+- treat those inputs as write-only even if the runtime redacts accidental echoes
- 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
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
@@ -73,6 +73,8 @@
- 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
+- say explicitly that secret-bearing capability inputs are write-only from the
+ model's perspective and must not be echoed in execute results or logs
- tell agents to inspect secret metadata before making a secret-bearing request
- tell agents to surface the approval link and stop on deny
@@ -102,6 +104,8 @@
- mark only the exact sensitive fields
- prefer it for local persistence or device-side credential handoff
- require an authenticated user, just like fetch-time secret placeholders
+- never return or log the resolved plaintext after it crosses an
+ `x-kody-secret` capability boundary
Do not treat `x-kody-secret` as a generic replacement for execute-time
`fetch(...)` placeholders.
diff --git a/packages/worker/src/mcp/capabilities/secrets/domain.ts b/packages/worker/src/mcp/capabilities/secrets/domain.ts
--- a/packages/worker/src/mcp/capabilities/secrets/domain.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/domain.ts
@@ -2,11 +2,16 @@
import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
import { secretDeleteCapability } from './secret-delete.ts'
import { secretListCapability } from './secret-list.ts'
+import { secretSetCapability } from './secret-set.ts'
export const secretsDomain = defineDomain({
name: capabilityDomainNames.secrets,
description:
- 'Server-side secret references that can be discovered by name, listed with allowed-host metadata, and deleted without placing raw secret values into prompts or saved app source.',
+ 'Server-side secret references that can be created, listed, and deleted without placing raw secret values into prompts, execute results, or saved app source.',
keywords: ['secret', 'credentials', 'reference', 'secure input'],
- capabilities: [secretListCapability, secretDeleteCapability],
+ capabilities: [
+ secretListCapability,
+ secretSetCapability,
+ secretDeleteCapability,
+ ],
})
diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts b/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts
--- a/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts
+++ b/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts
@@ -11,7 +11,7 @@
{
name: 'secret_delete',
description:
- 'Delete an existing secret reference for the signed-in user. Use this when a secret should no longer be available to execute-time code.',
+ 'Delete an existing secret reference for the signed-in user without revealing its plaintext value. Never ask the user to paste a secret, token, API key, password, or credential into chat; use generated UI to collect missing user-provided secrets safely. Use this when a secret should no longer be available to execute-time code.',
keywords: ['secret', 'delete', 'remove', 'revoke', 'credential'],
readOnly: false,
idempotent: false,
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 and allowed capabilities, 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. Never return a secret value from execute, and never ask the user to paste a secret, token, API key, password, or credential into chat; use generated UI to collect missing secrets safely.',
keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'],
readOnly: true,
idempotent: true,
diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-set.ts b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts
@@ -1,0 +1,73 @@
+import { markSecretInputFields } from '@kody-internal/shared/secret-input-schema.ts'
+import { z } from 'zod'
+import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
+import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
+import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
+import { type CapabilityContext } from '#mcp/capabilities/types.ts'
+import { saveSecret } from '#mcp/secrets/service.ts'
+import { secretScopeValues } from '#mcp/secrets/types.ts'
+import { secretMetadataSchema } from './shared.ts'
+
+const secretSetInputSchema = z.object({
+ name: z.string().min(1).describe('Secret name to create or update.'),
+ value: z
+ .string()
+ .min(1)
+ .describe(
+ 'Secret value to persist. This field is write-only and must never be returned to the caller.',
+ ),
+ description: z
+ .string()
+ .optional()
+ .describe('Optional human-readable description of the secret.'),
+ scope: z
+ .enum(secretScopeValues)
+ .describe('Storage scope that owns the secret.'),
+})
+
+const secretSetCapabilityInputJsonSchema = markSecretInputFields(
+ z.toJSONSchema(secretSetInputSchema) as Record<string, unknown>,
+ ['value'],
+) as Record<string, unknown>
+
+export const secretSetCapability = defineDomainCapability(
+ capabilityDomainNames.secrets,
+ {
+ name: 'secret_set',
+ description:
+ 'Create or update a stored secret reference for the signed-in user without ever returning the plaintext value. Use this only for server-side persistence of secret values that are already available inside trusted execution, such as refreshed OAuth tokens. Never ask the user to paste a secret, token, API key, password, or credential into chat; use generated UI to collect missing user-provided secrets safely. Saving a secret value does not authorize outbound host use or direct capability access.',
+ keywords: ['secret', 'persist', 'store', 'oauth', 'token', 'credential'],
+ readOnly: false,
+ idempotent: false,
+ destructive: false,
+ inputSchema: secretSetCapabilityInputJsonSchema,
+ outputSchema: secretMetadataSchema,
+ async handler(args, ctx: CapabilityContext) {
+ const parsed = secretSetInputSchema.parse(args)
+ const user = requireMcpUser(ctx.callerContext)
+ const saved = await saveSecret({
+ env: ctx.env,
+ userId: user.userId,
+ scope: parsed.scope,
+ name: parsed.name,
+ value: parsed.value,
+ description: parsed.description ?? '',
+ storageContext: {
+ sessionId: ctx.callerContext.storageContext?.sessionId ?? null,
+ appId: ctx.callerContext.storageContext?.appId ?? null,
+ },
+ })
+ return {
+ name: saved.name,
+ scope: saved.scope,
+ 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
@@ -37,6 +37,7 @@
- When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable.
- Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills.
- Use \`codemode.secret_list(args)\` during execute-time code to list secret metadata only; it does not return plaintext values.
+- Use \`codemode.secret_set(args)\` only to persist secret values that are already available inside trusted execution, such as refreshed OAuth tokens. It returns metadata only and never returns plaintext values.
Kody source repository
- Kody (this app and MCP server) is developed at https://github.com/kentcdodds/kody. When you launch a Cursor Cloud Agent to improve Kody itself, use that repository URL (unless the user explicitly points you at another fork or repo).
@@ -81,12 +82,14 @@
- 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, allowed hosts, and allowed capabilities from the sandbox.
+- Use \`await codemode.secret_set({ name, value, scope, description? })\` only when a capability or fetch flow has already produced a secret value inside execution and you need to persist it without showing it to the model or returning it to the caller.
- 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. 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.
+- Saving or updating a secret does not authorize sending it anywhere or passing it into arbitrary capabilities. 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.
+- Never ask the user to paste a secret into chat. Use generated UI flows when the user needs to provide or rotate a value themselves. \`codemode.secret_set(...)\` is for persisting secret values that are already available inside execution, not for collecting them from chat.
+- Secret values that pass through \`x-kody-secret\` capability inputs are treated as write-only: they may be persisted or handed to the host-side capability, but they must never be returned in execute results or logs.
- Your code must be an async arrow function that returns the result.
- Example: const result = await codemode[capabilityName](args)
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
@@ -1391,6 +1391,76 @@
expect(executePayload.result?.userSecretNames).toContain('globalApiKey')
expect(executePayload.result?.sessionSecretNames).toContain('ephemeralCode')
+ const refreshedAccessToken = `spotify-access-${crypto.randomUUID()}`
+ const refreshedRefreshToken = `spotify-refresh-${crypto.randomUUID()}`
+ const secretSetExecuteResponse = await fetch(executeUrl!, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ code: `async () => {
+ const savedAccessToken = await codemode.secret_set({
+ name: 'spotifyAccessToken',
+ value: ${JSON.stringify(refreshedAccessToken)},
+ description: 'Spotify OAuth access token',
+ scope: 'user',
+ })
+ const savedRefreshToken = await codemode.secret_set({
+ name: 'spotifyRefreshToken',
+ value: ${JSON.stringify(refreshedRefreshToken)},
+ description: 'Spotify OAuth refresh token',
+ scope: 'user',
+ })
+ return {
+ savedAccessToken,
+ savedRefreshToken,
+ leakedValue: ${JSON.stringify(refreshedAccessToken)},
+ }
+ }`,
+ }),
+ })
+ if (!secretSetExecuteResponse.ok) {
+ throw new Error(await secretSetExecuteResponse.text())
+ }
+ const secretSetExecutePayload =
+ (await secretSetExecuteResponse.json()) as {
+ ok?: boolean
+ result?: {
+ savedAccessToken?: Record<string, unknown>
+ savedRefreshToken?: Record<string, unknown>
+ leakedValue?: string
+ }
+ logs?: Array<string>
+ }
+ expect(secretSetExecutePayload.ok).toBe(true)
+ expect(secretSetExecutePayload.result?.savedAccessToken).toEqual({
+ name: 'spotifyAccessToken',
+ scope: 'user',
+ description: 'Spotify OAuth access token',
+ app_id: null,
+ allowed_hosts: [],
+ allowed_capabilities: [],
+ created_at: expect.any(String),
+ updated_at: expect.any(String),
+ ttl_ms: null,
+ })
+ expect(secretSetExecutePayload.result?.savedRefreshToken).toEqual({
+ name: 'spotifyRefreshToken',
+ scope: 'user',
+ description: 'Spotify OAuth refresh token',
+ app_id: null,
+ allowed_hosts: [],
+ allowed_capabilities: [],
+ created_at: expect.any(String),
+ updated_at: expect.any(String),
+ ttl_ms: null,
+ })
+ expect(secretSetExecutePayload.result?.leakedValue).toBe('[REDACTED SECRET]')
+ expect(secretSetExecutePayload.logs ?? []).toEqual([])
+
const listSecretsResponse = await fetch(`${secretsUrl!}?scope=app`, {
headers: {
Authorization: `Bearer ${token}`,
@@ -1711,6 +1781,10 @@
(match) => match.type === 'secret' && match.name === 'globalApiKey',
)
expect(userSecretMatch?.description).toBe('Reusable cross-app API key')
+ const spotifySecretMatch = matches.find(
+ (match) => match.type === 'secret' && match.name === 'spotifyAccessToken',
+ )
+ expect(spotifySecretMatch?.description).toBe('Spotify OAuth access token')
const sessionSecretMatch = matches.find(
(match) => match.type === 'secret' && match.name === 'ephemeralCode',
)
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
@@ -180,3 +180,127 @@
resolveSecretSpy.mockRestore()
}
})
+
+test('buildCodemodeFns tracks values that crossed secret-marked capability inputs', async () => {
+ const env = {} as Env
+ const callerContext = createMcpCallerContext({
+ baseUrl: 'https://heykody.dev',
+ user: { userId: 'user-123' },
+ })
+ const trackedSecretValues: Array<string> = []
+
+ const getRegistrySpy = vi
+ .spyOn(await import('#mcp/capabilities/registry.ts'), 'getCapabilityRegistryForContext')
+ .mockResolvedValue({
+ capabilityDomains: [],
+ capabilityDomainDescriptionsByName: {} as Record<string, string>,
+ capabilityHandlers: {},
+ capabilityList: [
+ {
+ name: 'secret_set',
+ domain: 'secrets',
+ description: 'Store a secret.',
+ keywords: [],
+ readOnly: false,
+ idempotent: false,
+ destructive: false,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ value: { type: 'string', 'x-kody-secret': true },
+ },
+ required: ['name', 'value'],
+ },
+ outputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ },
+ },
+ async handler(args: Record<string, unknown>) {
+ return {
+ name: args.name,
+ }
+ },
+ },
+ ],
+ capabilityMap: {
+ secret_set: {
+ name: 'secret_set',
+ domain: 'secrets',
+ description: 'Store a secret.',
+ keywords: [],
+ readOnly: false,
+ idempotent: false,
+ destructive: false,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ value: { type: 'string', 'x-kody-secret': true },
+ },
+ required: ['name', 'value'],
+ },
+ outputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ },
+ },
+ async handler(args: Record<string, unknown>) {
+ return {
+ name: args.name,
+ }
+ },
+ },
+ },
+ capabilitySpecs: {},
+ capabilityToolDescriptors: {
+ secret_set: {
+ description: 'Store a secret.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ value: { type: 'string', 'x-kody-secret': true },
+ },
+ required: ['name', 'value'],
+ },
+ outputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ },
+ },
+ },
+ },
+ } as Awaited<
+ ReturnType<
+ typeof import('#mcp/capabilities/registry.ts').getCapabilityRegistryForContext
+ >
+ >)
+
+ try {
+ const codemode = await buildCodemodeFns(
+ env,
+ callerContext,
+ {
+ trackSecretInputValue(value) {
+ trackedSecretValues.push(value)
+ },
+ },
+ )
+ const result = await codemode.secret_set({
+ name: 'spotifyAccessToken',
+ value: 'fresh-access-token',
+ })
+
+ expect(result).toEqual({
+ name: 'spotifyAccessToken',
+ })
+ expect(trackedSecretValues).toEqual(['fresh-access-token'])
+ } finally {
+ getRegistrySpy.mockRestore()
+ }
+})
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -5,8 +5,15 @@
} from '@cloudflare/codemode'
import { exports as workerExports } from 'cloudflare:workers'
import { type McpCallerContext } from '@kody-internal/shared/chat.ts'
-import { resolveCapabilityInputSecrets } from '#mcp/secrets/capability-inputs.ts'
import {
+ getAdditionalPropertiesSchema,
+ getArrayItemSchema,
+ getSchemaProperties,
+ isRecord,
+ isSecretInputSchema,
+ resolveCapabilityInputSecrets,
+} from '#mcp/secrets/capability-inputs.ts'
+import {
capabilityInputSecretAuthRequiredMessage,
createCapabilitySecretAccessDeniedMessage,
createCapabilitySecretAccessDeniedBatchMessage,
@@ -26,6 +33,7 @@
secret: ReferencedSecret,
capabilityName: string,
) => Promise<string>
+ trackSecretInputValue?: (value: string) => void
},
) {
const { capabilityMap } = await getCapabilityRegistryForContext({
@@ -49,6 +57,11 @@
resolveSecretValue: (secret) =>
resolveSecretValue(secret, capability.name),
})
+ collectSecretInputValues({
+ schema: capability.inputSchema,
+ value: resolvedArgs,
+ track: options?.trackSecretInputValue,
+ })
return capability.handler(resolvedArgs as Record<string, unknown>, {
env,
callerContext,
@@ -61,8 +74,11 @@
export async function buildCodemodeProvider(
env: Env,
callerContext: McpCallerContext,
+ options?: {
+ trackSecretInputValue?: (value: string) => void
+ },
): Promise<ResolvedProvider> {
- const tools = await buildCodemodeFns(env, callerContext)
+ const tools = await buildCodemodeFns(env, callerContext, options)
const provider: ToolProvider = {
name: 'codemode',
tools: Object.fromEntries(
@@ -128,6 +144,7 @@
executorExports?: typeof workerExports,
) {
const { createExecuteExecutor } = await import('#mcp/executor.ts')
+ const secretRedactor = createExecutionSecretRedactor()
const normalizedStorageContext = normalizeStorageContext(
callerContext.storageContext ?? null,
)
@@ -140,20 +157,30 @@
storageContext: normalizedStorageContext,
},
})
- const provider = await buildCodemodeProvider(env, callerContext)
+ const provider = await buildCodemodeProvider(env, callerContext, {
+ trackSecretInputValue: (value) => {
+ secretRedactor.track(value)
+ },
+ })
const wrapped =
params !== undefined
? await buildParameterizedSkillCode(code, params)
: code
const result = await executor.execute(wrapped, [provider])
- if (!result.error) return result
+ const sanitizedResult = secretRedactor.sanitizeExecuteResult(result)
+ if (!result.error) return sanitizedResult
const batchMessage = await rewriteCapabilitySecretError({
error: result.error,
code: wrapped,
env,
callerContext,
})
- return batchMessage ? { ...result, error: batchMessage } : result
+ return batchMessage
+ ? {
+ ...sanitizedResult,
+ error: secretRedactor.redactUnknown(batchMessage),
+ }
+ : sanitizedResult
}
async function rewriteCapabilitySecretError(input: {
@@ -208,6 +235,119 @@
).sort((left, right) => left.localeCompare(right))
}
+const redactedSecretText = '[REDACTED SECRET]'
+
+function createExecutionSecretRedactor() {
+ const secretValues = new Set<string>()
+ return {
+ track(value: string) {
+ if (value.length > 0) {
+ secretValues.add(value)
+ }
+ },
+ redactUnknown(value: unknown) {
+ return redactUnknownSecretValues(value, secretValues)
+ },
+ sanitizeExecuteResult<T extends { result?: unknown; logs?: unknown; error?: unknown }>(
+ result: T,
+ ): T {
+ return {
+ ...result,
+ result: redactUnknownSecretValues(result.result, secretValues),
+ logs: Array.isArray(result.logs)
+ ? result.logs.map((entry) =>
+ redactUnknownSecretValues(entry, secretValues),
+ )
+ : result.logs,
+ error: redactUnknownSecretValues(result.error, secretValues),
+ }
+ },
+ }
+}
+
+function collectSecretInputValues(input: {
+ schema: unknown
+ value: unknown
+ track?: (value: string) => void
+}) {
+ if (!input.track) return
+ visitSecretInputValue(input.schema, input.value, input.track)
+}
+
+function visitSecretInputValue(
+ schema: unknown,
+ value: unknown,
+ track: (value: string) => void,
+) {
+ if (typeof value === 'string' && isSecretInputSchema(schema)) {
+ track(value)
+ return
+ }
+ if (Array.isArray(value)) {
+ const itemSchema = getArrayItemSchema(schema)
+ if (!itemSchema) return
+ for (const item of value) {
+ visitSecretInputValue(itemSchema, item, track)
+ }
+ return
+ }
+ if (!isRecord(value)) return
+ const propertySchemas = getSchemaProperties(schema)
+ const additionalProperties = getAdditionalPropertiesSchema(schema)
+ if (!propertySchemas && !additionalProperties) return
+ for (const [key, entryValue] of Object.entries(value)) {
+ const entrySchema = propertySchemas?.[key] ?? additionalProperties
+ if (!entrySchema) continue
+ visitSecretInputValue(entrySchema, entryValue, track)
+ }
+}
+
+function redactUnknownSecretValues(
+ value: unknown,
+ secretValues: ReadonlySet<string>,
+): unknown {
+ if (secretValues.size === 0) return value
+ if (typeof value === 'string') {
+ return redactSecretValuesInString(value, secretValues)
+ }
+ if (value instanceof Error) {
+ const next = new Error(
+ redactSecretValuesInString(value.message, secretValues),
+ )
+ next.name = value.name
+ if (value.stack) {
+ next.stack = redactSecretValuesInString(value.stack, secretValues)
+ }
+ return next
+ }
+ if (Array.isArray(value)) {
+ return value.map((entry) => redactUnknownSecretValues(entry, secretValues))
+ }
+ if (isRecord(value)) {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ key,
+ redactUnknownSecretValues(entry, secretValues),
+ ]),
+ )
+ }
+ return value
+}
+
+function redactSecretValuesInString(
+ value: string,
+ secretValues: ReadonlySet<string>,
+) {
+ if (secretValues.size === 0 || value.length === 0) return value
+ let nextValue = value
+ for (const secretValue of [...secretValues].sort(
+ (left, right) => right.length - left.length,
+ )) {
+ nextValue = nextValue.replaceAll(secretValue, redactedSecretText)
+ }
+ return nextValue
+}
+
function normalizeStorageContext(
storageContext: McpCallerContext['storageContext'] | null,
) {
diff --git a/packages/worker/src/mcp/secrets/capability-inputs.ts b/packages/worker/src/mcp/secrets/capability-inputs.ts
--- a/packages/worker/src/mcp/secrets/capability-inputs.ts
+++ b/packages/worker/src/mcp/secrets/capability-inputs.ts
@@ -81,29 +81,29 @@
return replaceSecretPlaceholders(value, replacements)
}
-function isSecretInputSchema(schema: unknown) {
+export function isSecretInputSchema(schema: unknown) {
if (!isRecord(schema)) return false
return schema[secretInputSchemaFlag] === true
}
-function getSchemaProperties(schema: unknown) {
+export function getSchemaProperties(schema: unknown) {
if (!isRecord(schema)) return null
const properties = schema.properties
return isRecord(properties) ? properties : null
}
-function getArrayItemSchema(schema: unknown) {
+export function getArrayItemSchema(schema: unknown) {
if (!isRecord(schema)) return null
const items = schema.items
return items === undefined || Array.isArray(items) ? null : items
}
-function getAdditionalPropertiesSchema(schema: unknown) {
+export function getAdditionalPropertiesSchema(schema: unknown) {
if (!isRecord(schema)) return null
const additionalProperties = schema.additionalProperties
return isRecord(additionalProperties) ? additionalProperties : null
}
-function isRecord(value: unknown): value is Record<string, unknown> {
+export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
diff --git a/packages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.ts b/packages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.ts
--- a/packages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.ts
+++ b/packages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.ts
@@ -24,6 +24,21 @@
expect(staticNames).toContain('github_rest')
})
+test('infers secret_set member access', () => {
+ const src = `async () => {
+ const refreshedToken = 'token-from-refresh';
+ await codemode.secret_set({
+ name: 'spotifyAccessToken',
+ value: refreshedToken,
+ scope: 'user',
+ description: 'Spotify OAuth access token',
+ });
+ }`
+ const { staticNames, inferencePartial } = inferCodemodeCapabilities(src)
+ expect(inferencePartial).toBe(false)
+ expect(staticNames).toContain('secret_set')
+})
+
test('marks partial for computed non-literal codemode access', () => {
const program = parse(
`async () => { const x = 'ui_save_app'; return await codemode[x]({}) }`,
diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts
--- a/packages/worker/src/mcp/tools/execute.ts
+++ b/packages/worker/src/mcp/tools/execute.ts
@@ -55,7 +55,9 @@
Secrets:
- Never ask the user to paste secrets, tokens, API keys, passwords, OAuth codes, client secrets, or other credentials into chat. If a workflow needs a secret value that is not already stored, use generated UI to collect and save it instead.
- Use \`await codemode.secret_list({})\` to inspect available secret metadata before building a request. The result is metadata only and does not reveal secret values; it includes allowed hosts and allowed capability names.
+- Use \`await codemode.secret_set({ name, value, scope, description })\` only to persist a secret value that is already available inside trusted execution, such as a refreshed OAuth token. The \`value\` input is write-only; execute results and logs redact secret-marked capability inputs before they are returned.
- Pass \`scope\` to narrow results, for example \`await codemode.secret_list({ scope: 'app' })\`.
+- Deleting or saving a secret never grants outbound host approval or capability approval. Those policies are still managed through the authenticated account secrets UI.
- Do not expect \`codemode.secret_get(...)\`, \`codemode.secret_require(...)\`, or any injected \`secrets\` helper to be available in execute-time code.
Values:There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/worker/src/mcp/secrets/capability-inputs.ts (1)
84-109: Consider explicit return types for newly exported helpers.Now that these are module-level exports, explicit return types would lock API shape and reduce accidental contract drift from inference changes.
♻️ Suggested API-clarity diff
-export function isSecretInputSchema(schema: unknown) { +export function isSecretInputSchema(schema: unknown): boolean { if (!isRecord(schema)) return false return schema[secretInputSchemaFlag] === true } -export function getSchemaProperties(schema: unknown) { +export function getSchemaProperties( + schema: unknown, +): Record<string, unknown> | null { if (!isRecord(schema)) return null const properties = schema.properties return isRecord(properties) ? properties : null } -export function getArrayItemSchema(schema: unknown) { +export function getArrayItemSchema(schema: unknown): unknown | null { if (!isRecord(schema)) return null const items = schema.items return items === undefined || Array.isArray(items) ? null : items } -export function getAdditionalPropertiesSchema(schema: unknown) { +export function getAdditionalPropertiesSchema( + schema: unknown, +): Record<string, unknown> | null { if (!isRecord(schema)) return null const additionalProperties = schema.additionalProperties return isRecord(additionalProperties) ? additionalProperties : null }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/secrets/capability-inputs.ts` around lines 84 - 109, Add explicit return types to the exported helper functions to lock the API shape: change isSecretInputSchema to return boolean; getSchemaProperties, getArrayItemSchema and getAdditionalPropertiesSchema to return Record<string, unknown> | null; leave isRecord as the existing type predicate (value is Record<string, unknown>)—update the function signatures for isSecretInputSchema, getSchemaProperties, getArrayItemSchema and getAdditionalPropertiesSchema accordingly so their exported types are explicit and stable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/worker/src/mcp/secrets/capability-inputs.ts`:
- Around line 84-109: Add explicit return types to the exported helper functions
to lock the API shape: change isSecretInputSchema to return boolean;
getSchemaProperties, getArrayItemSchema and getAdditionalPropertiesSchema to
return Record<string, unknown> | null; leave isRecord as the existing type
predicate (value is Record<string, unknown>)—update the function signatures for
isSecretInputSchema, getSchemaProperties, getArrayItemSchema and
getAdditionalPropertiesSchema accordingly so their exported types are explicit
and stable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 74df436b-c973-45be-a391-a4442a29fea8
📒 Files selected for processing (2)
packages/worker/src/mcp/run-codemode-registry.tspackages/worker/src/mcp/secrets/capability-inputs.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/worker/src/mcp/run-codemode-registry.ts
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: Unused
redactUnknownmethod is dead code- Removed the unused
redactUnknownmethod from the redactor since it has no call sites and is redundant with the existing internal helper.
- Removed the unused
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/worker/src/mcp/run-codemode-registry.ts (1)
311-319: Consider preservingError.causeif present.When reconstructing the Error, the
causeproperty (which may contain additional context) is not carried over. If secrets could appear incause, it would also need redaction.♻️ Optional enhancement
if (value instanceof Error) { const next = new Error( redactSecretValuesInString(value.message, secretValues), + value.cause !== undefined + ? { cause: redactUnknownSecretValues(value.cause, secretValues) } + : undefined, ) next.name = value.name if (value.stack) { next.stack = redactSecretValuesInString(value.stack, secretValues) } return next }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 311 - 319, The Error reconstruction in the value instanceof Error branch drops the original Error.cause; update that block (the code that creates next from value) to preserve and redact cause: if value.cause exists, set next.cause to a redacted version — for string causes run redactSecretValuesInString(value.cause, secretValues), for Error causes recursively reconstruct a new Error whose message/name/stack are redacted (same pattern used for value), and for other types convert to string and redact; ensure you reuse redactSecretValuesInString and attach the redacted cause to next.cause so the original causal context is preserved but secrets are removed.
🤖 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 251-261: sanitizeExecuteResult currently calls
redactUnknownSecretValues for the error field which yields unknown and breaks
the ExecuteResult.error type; change the error handling to treat result.error
explicitly (if typeof result.error === 'string' then call
redactSecretValuesInString(result.error, secretValues) and return that string,
otherwise return undefined), referencing sanitizeExecuteResult,
ExecuteResult.error, redactUnknownSecretValues and redactSecretValuesInString so
you only use redactUnknownSecretValues where its unknown return is acceptable
and use redactSecretValuesInString for the error string to satisfy the string |
undefined type.
---
Nitpick comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 311-319: The Error reconstruction in the value instanceof Error
branch drops the original Error.cause; update that block (the code that creates
next from value) to preserve and redact cause: if value.cause exists, set
next.cause to a redacted version — for string causes run
redactSecretValuesInString(value.cause, secretValues), for Error causes
recursively reconstruct a new Error whose message/name/stack are redacted (same
pattern used for value), and for other types convert to string and redact;
ensure you reuse redactSecretValuesInString and attach the redacted cause to
next.cause so the original causal context is preserved but secrets are removed.
🪄 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: 36089a83-14bd-43fd-a530-877bc1e3a151
📒 Files selected for processing (1)
packages/worker/src/mcp/run-codemode-registry.ts
ff2f062 to
b812034
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 345-350: The current branch in redactUnknownSecretValues leaves
object property names unchanged (in the isRecord handling), so secrets used as
keys can leak; update the Object.entries mapping inside
redactUnknownSecretValues to also redact each property name by passing the key
through the same redaction routine (use redactUnknownSecretValues(key,
secretValues) or the single-value redactor if available) and return
[redactedKey, redactUnknownSecretValues(entry, secretValues)] so both keys and
values are sanitized; keep using isRecord and secretValues to locate this
change.
- Around line 321-353: The redactUnknownSecretValues walker isn't cycle-safe and
will recurse forever on self-referential values; update
redactUnknownSecretValues to track visited objects (e.g., WeakSet for
objects/arrays/Errors) and return previously-processed references to avoid
re-traversal, ensuring you mark entries before recursing into Error.cause,
arrays, and records so cycles don't re-enter; keep using
redactSecretValuesInString for strings and apply redaction in-place or by
returning the same object identity when previously seen. Also add a regression
test that calls the secret-marked capability and returns a self-referential
object/array/error from ExecuteResult.result to assert no stack overflow and
that redaction still replaces secrets in strings.
🪄 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: 877ec6dc-2714-4be9-9f12-bbefade99759
📒 Files selected for processing (15)
docs/agents/adding-capabilities.mddocs/agents/secret-host-approval.mdpackage.jsonpackages/worker/src/mcp/capabilities/secrets/domain.tspackages/worker/src/mcp/capabilities/secrets/secret-delete.tspackages/worker/src/mcp/capabilities/secrets/secret-list.tspackages/worker/src/mcp/capabilities/secrets/secret-set.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/capability-inputs.tspackages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.tspackages/worker/src/mcp/tools/execute.tspackages/worker/src/mcp/ui-artifacts-repo.ts
✅ Files skipped from review due to trivial changes (6)
- packages/worker/src/mcp/capabilities/secrets/secret-delete.ts
- packages/worker/src/mcp/skills/infer-codemode-capabilities.node.test.ts
- packages/worker/src/mcp/capabilities/secrets/secret-list.ts
- packages/worker/src/mcp/ui-artifacts-repo.ts
- packages/worker/src/mcp/tools/execute.ts
- docs/agents/adding-capabilities.md
🚧 Files skipped from review as they are similar to previous changes (7)
- packages/worker/src/mcp/index.ts
- docs/agents/secret-host-approval.md
- packages/worker/src/mcp/capabilities/secrets/domain.ts
- packages/worker/src/mcp/secrets/capability-inputs.ts
- packages/worker/src/mcp/capabilities/secrets/secret-set.ts
- packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
- packages/worker/src/mcp/run-codemode-registry.node.test.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Sanitize secret-bearing keys as well as values in execute results, and make the redaction walker cycle-safe so self-referential results cannot recurse forever. Made-with: Cursor
b812034 to
d6562c7
Compare

Summary
secret_setwork onto currentmainand keep it aligned with upstream codemode helper/runtime changessecret_setfor persisting refreshed OAuth tokens and other in-execution secret values with metadata-only outputx-kody-secretinputs write-only by redacting accidental echoes from execute results, logs, and preserved error causesError.causebuild:mcp-appsso the full validation gate can pass consistentlyTesting
npm run validateSummary by CodeRabbit
New Features
Documentation
Tests
Chores