Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ The schema is defined by migrations in `packages/worker/migrations/`:
and their latest published commit
- `saved_packages`: package metadata/search projection derived from published
`package.json` source
- `secret_buckets`: encrypted-secret ownership buckets scoped to `user`,
`package`, or `session`. Package buckets bind directly to `saved_packages.id`;
package runtimes may use their own package secrets, while user secrets require
an explicit `allowed_packages` grant on every package read path.

App access pattern:

Expand Down Expand Up @@ -485,7 +489,8 @@ on write unless a migration backfills existing rows.
(`packages/worker/migrations/0018-jobs.sql`,
`0025-jobs-repo-check-policy.sql`, `packages/worker/src/jobs/repo.ts`).
`run_history_json` is capped in code; the other fields rely on parser and
normalizer compatibility.
normalizer compatibility. Package jobs persist both `storageContext.appId` for
value scope and `storageContext.packageId` for package-owned secret scope.
- `saved_packages.tags_json` and `community_listings.tags_json`
(`0027-saved-packages.sql`, `0045-community-listings.sql`) are `string[]`
projections.
Expand Down Expand Up @@ -514,6 +519,8 @@ on write unless a migration backfills existing rows.
policy inputs (`0009-secret-allowed-hosts.sql`,
`0010-secret-allowed-capabilities.sql`, `0023-secret-allowed-packages.sql`).
Tightening parse-error behavior requires explicit compatibility review.
`allowed_packages` applies only to user-scoped secrets; package-scoped secrets
are owned exclusively by the package id in their bucket binding.
- `package_runtime_runs.metadata_json` and `package_runtime_logs.fields_json`
(`0037-package-runtime-debug.sql`) store bounded debug metadata and log
fields.
Expand Down
5 changes: 3 additions & 2 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,9 @@ primitives:
group: assistant
name: Secret references
summary:
Server-side secret references resolved through placeholders at the fetch
gateway; raw values never enter prompts or results.
User- or package-owned server-side secret references resolved through
package-aware mounts, fetch placeholders, and capability inputs; raw
values never enter prompts or results.
code:
- packages/worker/src/mcp/capabilities/secrets/
- packages/worker/src/security/
Expand Down
5 changes: 3 additions & 2 deletions docs/contributing/skill-patterns/cloudflare-api-v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import it from other packages with `kody:@...`.
- Base URL: `https://api.cloudflare.com` (or your account’s documented API
hostname).
- Send `Authorization: Bearer {{secret:yourSecretName}}` (or with `|scope=user`
/ `app` / `session` as needed).
/ `package` / `session` as needed).
- The user must approve **`api.cloudflare.com`** for that secret (host approval
in the account UI). Saving a secret does not approve hosts automatically.

Capability **allowlists** on secrets apply to **capability inputs** that use
`x-kody-secret`. Fetch-based access is primarily gated by **allowed hosts**.
`x-kody-secret`. Package code also needs package approval for user-scoped
secrets; package-scoped secrets are available only to their owning package.

## Paths

Expand Down
6 changes: 3 additions & 3 deletions docs/guides/account-secret-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Provide the user a URL like:
| `allowedHosts` | no | Comma-separated hosts to review for approval. |
| `allowedCapabilities` | no | Comma-separated capability names to review. Use only real Kody capability names from `search` or `meta_list_capabilities`. |
| `allowedPackages` | no | Comma-separated saved package ids to review for approval. |
| `scope` | no | `user` (default) or `app`. |
| `appId` | no | Required when `scope=app`. Use the saved package id that owns the package app secret scope. |
| `scope` | no | `user` (default) or `package`. |
| `packageId` | no | Required when `scope=package`. Use the saved package id that owns the secret. |

## Approval policy reminders

Expand All @@ -49,7 +49,7 @@ Provide the user a URL like:
## Agent instructions

1. Generate the URL with the required `name` and any optional params.
- When using `scope=app`, include the saved package id in `appId`.
- When using `scope=package`, include the saved package id in `packageId`.
- Only include `allowedCapabilities` when you have confirmed the capability
names exist in Kody.
2. Ask the user to open the URL in their browser.
Expand Down
7 changes: 6 additions & 1 deletion docs/use/secrets-and-values.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ Use **search** first to discover saved secret references or integrations before
switching to **execute**.

During **execute**, **`await kody.secret_list({})`** (or a narrowed **`scope`**
such as **`app`**) returns **metadata only**: names, descriptions, allowed
such as **`package`**) returns **metadata only**: names, descriptions, allowed
hosts, allowed capabilities — not plaintext values.

Package-scoped secrets belong to one saved package and are available only while
that package runs. User-scoped secrets require explicit package approval before
package code can list, read, update, or delete them through mounts, fetch
placeholders, or secret-aware capabilities.

**`kody.secret_set(...)`** persists a value that is already available inside
execution (for example a refreshed OAuth token). It does not return secret
values.
Expand Down
20 changes: 10 additions & 10 deletions packages/shared/src/account-secret-route.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ test('account secret paths encode route segments and round-trip through the pars
}),
).toBe('/account/secrets/user/github%20token')

const appPath = buildAccountSecretPath({
scope: 'app',
appId: 'package/id',
const packagePath = buildAccountSecretPath({
scope: 'package',
packageId: 'package/id',
name: 'api/key',
})
expect(appPath).toBe('/account/secrets/app/package%2Fid/api%2Fkey')
expect(parseAccountSecretPath(appPath)).toEqual({
id: 'app::package%2Fid::api%2Fkey',
scope: 'app',
appId: 'package/id',
expect(packagePath).toBe('/account/secrets/package/package%2Fid/api%2Fkey')
expect(parseAccountSecretPath(packagePath)).toEqual({
id: 'package::package%2Fid::api%2Fkey',
scope: 'package',
packageId: 'package/id',
sessionId: null,
name: 'api/key',
})
Expand All @@ -38,10 +38,10 @@ test('account secret paths encode route segments and round-trip through the pars
test('account secret paths fail fast when scope binding ids are missing', () => {
expect(() =>
buildAccountSecretPath({
scope: 'app',
scope: 'package',
name: 'api/key',
}),
).toThrow(/appId is required/)
).toThrow(/packageId is required/)
expect(() =>
buildAccountSecretPath({
scope: 'session',
Expand Down
41 changes: 22 additions & 19 deletions packages/shared/src/account-secret-route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { createHref } from 'remix/route-pattern/href'

type AccountSecretRouteScope = 'app' | 'session' | 'user'
type AccountSecretRouteScope = 'package' | 'session' | 'user'
const accountSecretsBasePath = '/account/secrets'
const accountSecretUserPathPattern = `${accountSecretsBasePath}/user/:secretName`
const accountSecretAppPathPattern = `${accountSecretsBasePath}/app/:appId/:secretName`
const accountSecretPackagePathPattern = `${accountSecretsBasePath}/package/:packageId/:secretName`
const accountSecretSessionPathPattern = `${accountSecretsBasePath}/session/:sessionId/:secretName`

export type AccountSecretRouteIdInput = {
name: string
scope: AccountSecretRouteScope
appId?: string | null
packageId?: string | null
sessionId?: string | null
}

export type ParsedAccountSecretRouteId = {
name: string
scope: AccountSecretRouteScope
appId: string | null
packageId: string | null
sessionId: string | null
}

Expand All @@ -26,8 +26,8 @@ export type ParsedAccountSecretRoutePath = ParsedAccountSecretRouteId & {

export function buildAccountSecretId(input: AccountSecretRouteIdInput) {
const bindingId =
input.scope === 'app'
? (input.appId ?? '')
input.scope === 'package'
? (input.packageId ?? '')
: input.scope === 'session'
? (input.sessionId ?? '')
: ''
Expand All @@ -41,7 +41,8 @@ export function parseAccountSecretId(
): ParsedAccountSecretRouteId | null {
const [scope, encodedBindingId, encodedName, ...rest] = secretId.split('::')
if (rest.length > 0) return null
if (scope !== 'app' && scope !== 'session' && scope !== 'user') return null
if (scope !== 'package' && scope !== 'session' && scope !== 'user')
return null

try {
const name = decodeURIComponent(encodedName ?? '')
Expand All @@ -51,7 +52,7 @@ export function parseAccountSecretId(
return {
name,
scope,
appId: scope === 'app' ? bindingId || null : null,
packageId: scope === 'package' ? bindingId || null : null,
sessionId: scope === 'session' ? bindingId || null : null,
}
} catch {
Expand All @@ -63,12 +64,14 @@ export function buildAccountSecretPath(input: AccountSecretRouteIdInput) {
if (input.scope === 'user') {
return createHref(accountSecretUserPathPattern, { secretName: input.name })
}
if (input.scope === 'app') {
if (!input.appId) {
throw new Error('appId is required for app-scoped account secret paths')
if (input.scope === 'package') {
if (!input.packageId) {
throw new Error(
'packageId is required for package-scoped account secret paths',
)
}
return createHref(accountSecretAppPathPattern, {
appId: input.appId,
return createHref(accountSecretPackagePathPattern, {
packageId: input.packageId,
secretName: input.name,
})
}
Expand Down Expand Up @@ -99,29 +102,29 @@ export function parseAccountSecretPath(
const parsed = {
name: decodeURIComponent(rest[1] ?? ''),
scope: 'user' as const,
appId: null,
packageId: null,
sessionId: null,
}
if (!parsed.name.trim()) return null
const id = buildAccountSecretId(parsed)
return { ...parsed, id }
}
if (rest.length === 3 && rest[0] === 'app') {
if (rest.length === 3 && rest[0] === 'package') {
const parsed = {
name: decodeURIComponent(rest[2] ?? ''),
scope: 'app' as const,
appId: decodeURIComponent(rest[1] ?? '') || null,
scope: 'package' as const,
packageId: decodeURIComponent(rest[1] ?? '') || null,
sessionId: null,
}
if (!parsed.name.trim() || !parsed.appId) return null
if (!parsed.name.trim() || !parsed.packageId) return null
const id = buildAccountSecretId(parsed)
return { ...parsed, id }
}
if (rest.length === 3 && rest[0] === 'session') {
const parsed = {
name: decodeURIComponent(rest[2] ?? ''),
scope: 'session' as const,
appId: null,
packageId: null,
sessionId: decodeURIComponent(rest[1] ?? '') || null,
}
if (!parsed.name.trim() || !parsed.sessionId) return null
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const mcpUserContextSchema = object({
export const mcpStorageContextSchema = object({
sessionId: optional(nullable(string())),
appId: optional(nullable(string())),
packageId: optional(nullable(string())),
storageId: optional(nullable(string())),
})

Expand Down
4 changes: 2 additions & 2 deletions packages/worker/client/routes/account-approval-shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type AccountStatus = 'loading' | 'ready' | 'error'
export type ApprovalAction = 'approve' | 'reject'
export type ApprovalScope = 'session' | 'app' | 'user'
export type ApprovalScope = 'session' | 'package' | 'user'

export type ApprovalView = {
name: string
Expand All @@ -16,7 +16,7 @@ export const accountSecretsApiPath = '/account/secrets.json'
export const accountProfileApiPath = '/account/profile.json'

export function getScopeLabel(scope: ApprovalScope) {
if (scope === 'app') return 'App'
if (scope === 'package') return 'Package'
if (scope === 'session') return 'Session'
return 'User'
}
Expand Down
Loading
Loading