Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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