@pikku/core 0.12.64 → 0.12.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/dist/permissions.d.ts +12 -4
  3. package/dist/permissions.js +11 -32
  4. package/dist/testing/service-tests.js +37 -0
  5. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  6. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  7. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  8. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  9. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  10. package/dist/wirings/ai-agent/index.d.ts +1 -1
  11. package/dist/wirings/ai-agent/index.js +1 -1
  12. package/dist/wirings/ai-agent/voice-input.js +3 -3
  13. package/dist/wirings/cli/cli-runner.js +3 -0
  14. package/dist/wirings/cli/command-parser.d.ts +2 -0
  15. package/dist/wirings/cli/command-parser.js +59 -2
  16. package/dist/wirings/credential/credential.types.d.ts +14 -0
  17. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  18. package/dist/wirings/gateway/gateway-runner.js +100 -50
  19. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  20. package/dist/wirings/http/http.types.d.ts +3 -3
  21. package/dist/wirings/secret/secret.types.d.ts +14 -0
  22. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  23. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  24. package/dist/wirings/variable/variable.types.d.ts +14 -0
  25. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  26. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  27. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  28. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  29. package/package.json +2 -1
  30. package/src/permissions.test.ts +14 -8
  31. package/src/permissions.ts +14 -36
  32. package/src/testing/service-tests.ts +49 -0
  33. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  34. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  35. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  36. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  37. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  38. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  39. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  40. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  41. package/src/wirings/ai-agent/index.ts +4 -0
  42. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  43. package/src/wirings/ai-agent/voice-input.ts +8 -10
  44. package/src/wirings/cli/cli-runner.ts +4 -0
  45. package/src/wirings/cli/command-parser.test.ts +130 -0
  46. package/src/wirings/cli/command-parser.ts +80 -2
  47. package/src/wirings/credential/credential.types.ts +14 -0
  48. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  49. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  50. package/src/wirings/gateway/gateway-runner.ts +114 -68
  51. package/src/wirings/gateway/gateway.types.ts +7 -9
  52. package/src/wirings/http/http.types.ts +6 -4
  53. package/src/wirings/secret/secret.types.ts +14 -0
  54. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  55. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  56. package/src/wirings/variable/variable.types.ts +14 -0
  57. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  58. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  59. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  60. package/src/wirings/workflow/workflow.types.ts +0 -2
  61. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,224 @@
1
+ ## 0.12.66
2
+
3
+ ### Patch Changes
4
+
5
+ - 5f19016: Widen the generated agent HTTP surface, and guard attachment downloads against SSRF.
6
+
7
+ `agentCaller` and `agentStreamCaller` declared only `message`, `threadId` and
8
+ `resourceId` (plus `context` on the stream route), so `attachments`, `model`,
9
+ `temperature` — all accepted by `AIAgentInput` — were unreachable over the
10
+ shipped HTTP contract. No deployed app could send an attachment or a per-request
11
+ model override. Both callers now share an `AgentCallerInput` type covering every
12
+ optional field and forward each one to the RPC.
13
+
14
+ Both callers declare that shape **inline** in the generic position rather than
15
+ behind a shared named alias: the schema extractor only reads type literals there
16
+ and synthesises the schema name from the function name. Behind an alias it
17
+ records an `inputSchemaName` with no schema generated for it, and every agent
18
+ HTTP call then fails at runtime with `MissingSchemaError`.
19
+
20
+ Widening that surface makes caller-supplied attachment URLs reachable, which is
21
+ an SSRF vector: the AI SDK downloads attachment URLs **server-side** whenever the
22
+ model cannot consume them natively, using an unguarded `fetch`. A caller could
23
+ point an attachment at the cloud metadata endpoint or another internal host and
24
+ have the response relayed into the model's context. `VercelAIAgentRunner` now
25
+ passes an `experimental_download` implementation backed by `safeFetch` (which
26
+ refuses private/internal hosts and non-HTTP schemes, and re-validates every
27
+ redirect hop) to both `streamText` and `generateText`. URLs the model supports
28
+ natively are passed through untouched, so the provider still fetches those
29
+ itself.
30
+
31
+ The runner takes an optional `allowedAttachmentHosts` allowlist, carried across
32
+ `withApiKey`. `safeFetch` is now exported from `@pikku/core/safe-fetch`.
33
+
34
+ - 78e4778: Stop a failed message persist during an agent stream from killing the process.
35
+
36
+ The persisting channel flushes from inside `send`, which is synchronous and so cannot await the flush. Any rejection — a dropped storage connection, or a model reusing a `toolCallId`, which is a primary key in AI storage — escaped as an unhandled rejection and took the whole server down. Persistence from `send` is now best-effort and logged; the awaited `flush()` on the suspend paths still surfaces failures to its caller.
37
+
38
+ - 4324652: Scope AI agent thread reads to the calling session.
39
+
40
+ The generated thread-management functions (`getAgentThreads`,
41
+ `getAgentThreadMessages`, `getAgentThreadRuns`, `deleteAgentThread`) keyed purely
42
+ off a caller-supplied `threadId` and treated `resourceId` as an optional filter,
43
+ so omitting it enumerated every tenant's threads.
44
+ - `listThreads` gains an `owners` **authorization constraint** (distinct from the
45
+ `resourceId` filter): an empty array matches nothing, and it is always derived
46
+ from the session, never from input. Implemented across the Kysely, Redis and
47
+ MongoDB agent run services, with LIKE/regex metacharacter escaping so an owner
48
+ id containing `_` or `%` cannot match a foreign owner.
49
+ - The three `threadId`-keyed functions are now guarded by an `isThreadOwner`
50
+ `pikkuPermission` rather than an in-body check. A thread that does not exist is
51
+ denied rather than 404'd, so it is indistinguishable from one owned by someone
52
+ else.
53
+ - New `@pikku/core/ai-agent` helpers: `canAccessThread`, `threadOwnerConstraint`,
54
+ `sessionPrincipals`, `isOwnedByPrincipal`.
55
+
56
+ Services destructured by a wired function are now non-optional inside it.
57
+
58
+ The inspector already aggregated the services used by every wired `func`,
59
+ `permissions` and `middleware` into `RequiredSingletonServices`, but the
60
+ generated function types defaulted their service parameter to the raw `Services`
61
+ — so a service declared `foo?: Foo` still arrived as possibly-undefined, forcing
62
+ `if (!foo) throw new MissingServiceError(...)` guards that could never fire.
63
+ Generated types now expose `WiredSingletonServices` / `WiredServices`
64
+ (`RequiredSingletonServices & Services`) and default the `RequiredServices`
65
+ generic of functions, permissions, middleware, auth and approval-description
66
+ helpers to them. Optionality now means only what it should: "this service may
67
+ not be created, because nothing uses it".
68
+
69
+ - de044f8: Fix the agent tool-list permission filter failing open.
70
+
71
+ `buildToolDefs` filtered permission-gated tools by resolving `checkAuthPermissions` from a function's _metadata_ — a by-name lookup into the `misc/permissions` state that nothing ever populates. It therefore collected no predicate and returned `true`, so every auth-gated tool was offered to the model regardless of session (its input schema and description leaked, and the model could attempt calls that then failed at invocation).
72
+
73
+ `checkAuthPermissions` now takes the live `CorePermissionGroup` from the function/agent config, where the `pikkuAuth` brand actually survives — matching how the agent's own gate and the function runner already resolve permissions by reference. The dead by-name lookup (`getPermissionByName`) is removed. Enforcement on invocation was never affected; this closes the exposure gap in the offered tool list.
74
+
75
+ - cd1a811: warn instead of silently ignoring unknown long CLI options
76
+
77
+ An unknown long option (`--sektion functions` or `--sektion=functions`) was parsed
78
+ into the options object and then silently dropped by the command's input schema —
79
+ the command ran with the real option at its default and produced plausible-but-wrong
80
+ output. Unknown long options are still accepted (forward compatibility is preserved),
81
+ but the parser now records a warning that the runner prints to stderr, e.g.
82
+ `Warning: Unknown option: --sektion (ignored) Did you mean --section?`.
83
+
84
+ - 19fa6f0: Fix `HTTPRouteConfig` and `HTTPRoutesGroupConfig`'s default `PikkuPermission`/`PikkuMiddleware` type parameters under-specifying their own generic arguments (e.g. `CorePikkuPermission<any>` instead of `CorePikkuPermission<any, any, any>`). The missing arguments silently fell back to `CorePikkuPermission`'s own defaults (`CoreServices`, with `schema` optional) instead of `any`, so a project whose generated services type guarantees `schema` is always present (any project using `WiredServices`-style non-optional services) failed to type-check against `defineHTTPRoutes`/`wireHTTPRoutes` with a misleading `index signature` error.
85
+ - b501612: Enforce authorization consistently across `pikku*` primitives.
86
+ - `pikkuAIAgent` now enforces `permissions` (previously accepted but never
87
+ checked) and gains `auth` and `scopes`. Scopes are checked before permissions.
88
+ `auth` defaults to `false`, matching `pikkuSessionlessFunc`, since agents are
89
+ typically invoked from an already-authenticated function or from sessionless
90
+ contexts such as crons and queue workers.
91
+ - `pikkuWorkflowFunc` / `pikkuWorkflowComplexFunc` schema config gains `auth`
92
+ and `scopes` alongside `permissions`.
93
+ - `pikkuScenario` no longer accepts `auth`, `scopes`, or `permissions` —
94
+ scenarios drive the app as actors and authorize per step.
95
+ - `wireGateway` no longer accepts `permissions`. A gateway proxies to an agent,
96
+ so access is governed by normal auth plus the target agent's own rules.
97
+ - Removed the dead `permissions` field from `CoreWorkflow`, which was never read.
98
+
99
+ Closed two paths that reached user code without authorization:
100
+ - Gateway handlers were invoked directly, so a handler's own `auth`, `scopes`
101
+ and `permissions` were never evaluated. Webhook, websocket and listener
102
+ gateways now invoke the handler through the function runner. Handlers are
103
+ sessionless by default (inbound gateway traffic is platform-authenticated by
104
+ the adapter, not session-bearing); declare `auth: true` to require a session.
105
+ A gateway's own `auth` field is now honoured too — it was previously ignored.
106
+ Gateway middleware runs before the gate, so `wire.setSession()` in gateway
107
+ middleware — the idiomatic way to map a verified platform sender to a user —
108
+ is visible to the handler's `auth` and `scopes`.
109
+ - Resuming a suspended agent run (`resumeAIAgentSync`, `resumeAIAgent`) checked
110
+ run ownership but never re-ran the agent's own gate, so a scope or permission
111
+ revoked while a run was suspended did not prevent the caller from resuming it
112
+ and approving its pending tool calls. Both now re-run `assertAgentAuthorized`
113
+ before any state is mutated.
114
+
115
+ - eb37b1e: Fix `voiceInput` middleware losing the runner receiver: it grabbed
116
+ `aiAgentRunner.transcribe` as a bare method reference, so calling it left `this`
117
+ undefined and threw `Cannot read properties of undefined (reading 'getModel')`
118
+ on the first audio attachment. It now calls `aiAgentRunner.transcribe(...)`
119
+ directly, preserving the receiver.
120
+
121
+ ## 0.12.65
122
+
123
+ ### Patch Changes
124
+
125
+ - 1a86d3f: Fix a fanout collapsing into a single step, and preserve graph node config.
126
+ - A fanout took its `stepName` from the first step of its body. Node ids _are_
127
+ step names, so the loop and that step got the same id and the step overwrote
128
+ the loop: `await Promise.all(users.map(...))` rendered as one plain call, and
129
+ everything after the loop became unreachable. A fanout is not itself a cached
130
+ step, so it no longer borrows a name.
131
+ - A `workflow.sleep` or `workflow.suspend` inside a fanout body was dropped at
132
+ extraction — `FanoutStepMeta.body` was typed RPC-only. It now admits sleep and
133
+ suspend, and the regenerated body emits them.
134
+ - Regenerating a `pikkuWorkflowGraph` dropped `onError`, `retries` and
135
+ `retryDelay` from every node, and graph-level `notes`. All four are honoured
136
+ at runtime, so the round trip silently changed behaviour.
137
+
138
+ - 1a86d3f: Support multi-step fanout bodies in DSL workflows.
139
+
140
+ A `Promise.all(array.map(...))` (or `for...of`) body containing more than one
141
+ `workflow.do` call previously extracted only a single step: `const`-captured
142
+ steps were skipped entirely by the parallel extractor, so a body like
143
+
144
+ ```ts
145
+ await Promise.all(
146
+ users.map(async (u) => {
147
+ const digestData = await workflow.do('Get pipeline', 'getDigestData', {
148
+ userId: u.id,
149
+ })
150
+ await workflow.do('Send digest', 'sendDigestEmail', { ...digestData })
151
+ })
152
+ )
153
+ ```
154
+
155
+ produced a graph with `getDigestData` missing and `sendDigestEmail` referencing
156
+ an unregistered variable. `FanoutStepMeta.child` is replaced by
157
+ `FanoutStepMeta.body: RpcStepMeta[]`, holding the per-iteration steps inline in
158
+ the same workflow — no sub-workflow boundary. Per-iteration `const` bindings are
159
+ now registered so later steps in the same iteration can reference them, and the
160
+ sequential path no longer hard-errors on bodies with more than one step.
161
+
162
+ - 1a86d3f: Add `onError` compensation to DSL workflows.
163
+
164
+ A DSL workflow had no way to express error handling at all — `try/catch` is not
165
+ an allowed statement, and step options carried only `retries`/`retryDelay`. A
166
+ step can now name a compensation RPC:
167
+
168
+ ```ts
169
+ await workflow.do(
170
+ 'Charge',
171
+ 'chargeCard',
172
+ { id },
173
+ {
174
+ retries: 3,
175
+ onError: 'refundOrder',
176
+ }
177
+ )
178
+ ```
179
+
180
+ Semantics mirror a graph node's `onError` exactly: once the step's retries are
181
+ exhausted the handler is invoked with `{ error: { message } }` and the original
182
+ error is still thrown. This is compensation, not recovery — the workflow fails
183
+ either way. The handler runs as its own durable step, so a replay cannot
184
+ compensate twice, and it does not inherit `onError` itself.
185
+
186
+ The handler is materialised as a real graph node, so it is wired like any other
187
+ RPC and the console draws a dashed red "on error" edge to it rather than the
188
+ route being invisible.
189
+
190
+ - 1a86d3f: Stop silently dropping switch cases and spread returns from workflow graphs.
191
+ - A fall-through case (`case 'a': case 'b': ...`) recorded only the last value.
192
+ A run entering on `'a'` therefore appeared to match no case at all. Empty
193
+ clauses now carry through to the entry they fall into — the next non-empty
194
+ case, otherwise `default`, otherwise the switch exit.
195
+ - `return { ...r, extra: 1 }` produced a return node listing only `extra`, so
196
+ the graph claimed an output shape the workflow does not have, with no
197
+ diagnostic. `return r` produced no return node at all. `ReturnStepMeta` now
198
+ records a `spread` list, and the regenerated code emits it.
199
+
200
+ - 1a86d3f: Stop corrupting values when regenerating a workflow from its graph.
201
+ - A numeric `workflow.sleep('Wait', 5000)` came back as `'5000'`, and a numeric
202
+ `retryDelay` likewise. Durations are `string | number`; only strings are
203
+ quoted now.
204
+ - An assignment to a context variable was stored as an opaque `value`, so
205
+ `count = count + 1` regenerated as `count = 'count + 1'` — an expression
206
+ turned into a string literal. `SetStepMeta` now carries a separate
207
+ `expression` field (mirroring `SwitchCaseMeta`), so a string literal and a
208
+ code expression are no longer indistinguishable in the meta.
209
+ - A `next` that was not a single node id was coerced with a string cast: an
210
+ array became the bogus id `'a,b'` and a branch-key record became
211
+ `'[object Object]'`, severing every downstream node. Arrays, key-based
212
+ routing tables and condition lists now each render in their own shape.
213
+ - A `filter`/`some`/`every` node with no `outputVar` emitted
214
+ `const undefined = ...`, which does not parse.
215
+
216
+ - 1a86d3f: Keep a `workflow.sleep` whose duration is only known at runtime (a loop
217
+ variable, a field off the input). The closure evaluates it, so it is legal DSL;
218
+ its source text is recorded as an `expression` and emitted raw when regenerating
219
+ code, as a set step already does.
220
+ - 3d76f51: Add an optional `docsUrl` to `wireSecret`, `wireVariable`, and `wireCredential`, so a console or deploy UI reporting a missing value can link the user to where they obtain it instead of showing a bare identifier.
221
+
1
222
  ## 0.12.64
2
223
 
3
224
  ### Patch Changes
@@ -1,4 +1,4 @@
1
- import type { CoreServices, CoreUserSession, PermissionMetadata, PikkuWire } from './types/core.types.js';
1
+ import type { CoreServices, CoreUserSession, PikkuWire } from './types/core.types.js';
2
2
  import type { CorePermissionGroup, CorePikkuPermission } from './function/functions.types.js';
3
3
  export declare const clearPermissionsCache: () => void;
4
4
  /**
@@ -36,12 +36,14 @@ export declare const addTagPermission: (_tag: string, _permissions: CorePermissi
36
36
  * A passing global requirement never contributes to the function gate, so a
37
37
  * broad global like `signedIn` can't satisfy an admin-only function.
38
38
  */
39
- export declare const runPermissions: ({ funcPermissions, services, wire, data, packageName, }: {
39
+ export declare const runPermissions: ({ funcPermissions, services, wire, data, packageName, label, }: {
40
40
  funcPermissions?: CorePermissionGroup | CorePikkuPermission[];
41
41
  services: CoreServices;
42
42
  wire: PikkuWire<any, never, any, CoreUserSession, never, never, never>;
43
43
  data: any;
44
44
  packageName?: string | null;
45
+ /** What the non-global gate is called in debug logs, e.g. 'function', 'agent'. */
46
+ label?: string;
45
47
  }) => Promise<void>;
46
48
  /**
47
49
  * Checks whether a session passes the auth checks (pikkuAuth only) for a
@@ -49,10 +51,16 @@ export declare const runPermissions: ({ funcPermissions, services, wire, data, p
49
51
  * request data which isn't available at filter time. Global auth requirements
50
52
  * are included so a filtered list honours app-wide auth.
51
53
  *
52
- * @param funcPermissions - The PermissionMetadata[] from function or agent metadata
54
+ * `funcPermissions` is the live {@link CorePermissionGroup} from the function or
55
+ * agent config, not the metadata form: the `pikkuAuth` brand only survives on
56
+ * the actual predicate objects, and the by-name registry those metadata entries
57
+ * would resolve against is never populated. Passing metadata here would silently
58
+ * collect nothing and let every gated tool through.
59
+ *
60
+ * @param funcPermissions - The live permission group from the func/agent config
53
61
  * @param session - The user's session
54
62
  * @param services - Singleton services
55
63
  * @param packageName - Optional package namespace
56
64
  * @returns true if the session passes the auth checks (or no auth checks exist)
57
65
  */
58
- export declare const checkAuthPermissions: (funcPermissions: PermissionMetadata[] | undefined, session: CoreUserSession, services: CoreServices, packageName?: string | null) => Promise<boolean>;
66
+ export declare const checkAuthPermissions: (funcPermissions: CorePermissionGroup | undefined, session: CoreUserSession, services: CoreServices, packageName?: string | null) => Promise<boolean>;
@@ -40,26 +40,6 @@ const verifyPermissions = async (permissions, services, data, wire) => {
40
40
  }
41
41
  return false;
42
42
  };
43
- /**
44
- * Retrieves a registered permission function by its name.
45
- *
46
- * This function looks up permissions that was registered with registerPermission.
47
- * It's used internally by the framework to resolve permission references in metadata.
48
- *
49
- * @param {string} name - The unique name (pikkuFuncId) of the permission function.
50
- * @param {string | null} packageName - Optional package namespace.
51
- * @returns {CorePikkuPermission | undefined} The permission function, or undefined if not found.
52
- *
53
- * @internal
54
- */
55
- const getPermissionByName = (name, packageName = null) => {
56
- const permissionStore = pikkuState(packageName, 'misc', 'permissions');
57
- const permission = permissionStore[name];
58
- if (Array.isArray(permission) && permission.length === 1) {
59
- return permission[0];
60
- }
61
- return undefined;
62
- };
63
43
  const globalPermissionsCache = {};
64
44
  export const clearPermissionsCache = () => {
65
45
  for (const key of Object.keys(globalPermissionsCache)) {
@@ -131,7 +111,7 @@ const asGroup = (entry) => typeof entry === 'function' ? { permission: entry } :
131
111
  * A passing global requirement never contributes to the function gate, so a
132
112
  * broad global like `signedIn` can't satisfy an admin-only function.
133
113
  */
134
- export const runPermissions = async ({ funcPermissions, services, wire, data, packageName = null, }) => {
114
+ export const runPermissions = async ({ funcPermissions, services, wire, data, packageName = null, label = 'function', }) => {
135
115
  const globals = resolveGlobalPermissions(packageName);
136
116
  for (const entry of globals) {
137
117
  if (!(await verifyPermissions(asGroup(entry), services, data, wire))) {
@@ -145,7 +125,7 @@ export const runPermissions = async ({ funcPermissions, services, wire, data, pa
145
125
  : funcPermissions;
146
126
  if (group && Object.keys(group).length > 0) {
147
127
  if (!(await verifyPermissions(group, services, data, wire))) {
148
- services.logger.debug('Permission denied - function permission');
128
+ services.logger.debug(`Permission denied - ${label} permission`);
149
129
  throw new ForbiddenError('Permission denied');
150
130
  }
151
131
  }
@@ -157,7 +137,13 @@ export const runPermissions = async ({ funcPermissions, services, wire, data, pa
157
137
  * request data which isn't available at filter time. Global auth requirements
158
138
  * are included so a filtered list honours app-wide auth.
159
139
  *
160
- * @param funcPermissions - The PermissionMetadata[] from function or agent metadata
140
+ * `funcPermissions` is the live {@link CorePermissionGroup} from the function or
141
+ * agent config, not the metadata form: the `pikkuAuth` brand only survives on
142
+ * the actual predicate objects, and the by-name registry those metadata entries
143
+ * would resolve against is never populated. Passing metadata here would silently
144
+ * collect nothing and let every gated tool through.
145
+ *
146
+ * @param funcPermissions - The live permission group from the func/agent config
161
147
  * @param session - The user's session
162
148
  * @param services - Singleton services
163
149
  * @param packageName - Optional package namespace
@@ -189,15 +175,8 @@ export const checkAuthPermissions = async (funcPermissions, session, services, p
189
175
  for (const entry of resolveGlobalPermissions(packageName)) {
190
176
  collect(entry);
191
177
  }
192
- if (funcPermissions?.length) {
193
- for (const meta of funcPermissions) {
194
- if (meta.type === 'wire') {
195
- const permission = getPermissionByName(meta.name, packageName);
196
- if (permission) {
197
- collect(permission);
198
- }
199
- }
200
- }
178
+ if (funcPermissions) {
179
+ collect(funcPermissions);
201
180
  }
202
181
  // No auth permissions = allowed (only data-dependent permissions exist)
203
182
  if (authPerms.length === 0)
@@ -742,6 +742,43 @@ export function defineServiceTests(config) {
742
742
  const threads = await agentService.listThreads();
743
743
  assert.ok(Array.isArray(threads));
744
744
  });
745
+ if (services.aiStorageService) {
746
+ const storageFactory = services.aiStorageService;
747
+ // The `owners` constraint is what keeps the generated thread-management
748
+ // functions from leaking across tenants: a caller may only list threads
749
+ // owned by one of their session principals, matching the
750
+ // `principal:sub-partition` composition resolveOwnerResourceId writes.
751
+ test('listThreads scopes to the given owners, including sub-partitions', async () => {
752
+ const storage = await storageFactory();
753
+ await storage.createThread('owner-alice');
754
+ await storage.createThread('owner-alice:project-1');
755
+ await storage.createThread('owner-bob:secret');
756
+ const threads = await agentService.listThreads({
757
+ owners: ['owner-alice'],
758
+ });
759
+ const ids = threads.map((t) => t.resourceId);
760
+ assert.ok(ids.includes('owner-alice'));
761
+ assert.ok(ids.includes('owner-alice:project-1'));
762
+ assert.ok(!ids.some((id) => id.startsWith('owner-bob')), "another owner's threads must not be listed");
763
+ });
764
+ test('listThreads with an owner does not match a lookalike prefix', async () => {
765
+ const storage = await storageFactory();
766
+ await storage.createThread('owner-al');
767
+ await storage.createThread('owner-alice-evil:p');
768
+ const threads = await agentService.listThreads({
769
+ owners: ['owner-al'],
770
+ });
771
+ const ids = threads.map((t) => t.resourceId);
772
+ assert.ok(ids.includes('owner-al'));
773
+ assert.ok(!ids.includes('owner-alice-evil:p'));
774
+ });
775
+ test('listThreads with an empty owners list returns nothing', async () => {
776
+ const storage = await storageFactory();
777
+ await storage.createThread('owner-empty-check');
778
+ const threads = await agentService.listThreads({ owners: [] });
779
+ assert.deepEqual(threads, []);
780
+ });
781
+ }
745
782
  test('getThread returns null for missing', async () => {
746
783
  const thread = await agentService.getThread('missing-thread');
747
784
  assert.equal(thread, null);
@@ -34,6 +34,49 @@ export declare function agentSessionScope(agentName: string): SessionScope;
34
34
  * existence oracle) on a mismatch.
35
35
  */
36
36
  export declare function assertResourceOwner(ownerResourceId: string, storedResourceId: string, kind: 'thread' | 'run'): void;
37
+ /** A session's trusted principals, in the order they may be read as. */
38
+ export declare function sessionPrincipals(session: {
39
+ userId?: string;
40
+ orgId?: string;
41
+ } | undefined): string[];
42
+ /**
43
+ * Whether `storedResourceId` belongs to `principal` under the composition
44
+ * {@link resolveOwnerResourceId} performs — either the bare principal or one of
45
+ * its `principal:` sub-partitions. The `:` is required so that `alice` does not
46
+ * match a `alice-evil:…` lookalike.
47
+ */
48
+ export declare function isOwnedByPrincipal(storedResourceId: string, principal: string): boolean;
49
+ /**
50
+ * The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
51
+ *
52
+ * Returns `undefined` — not `[]` — for a session with no principal, because an
53
+ * empty list means "match nothing" whereas a sessionless deployment (agent
54
+ * `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
55
+ * rather than at each call site stops it from being re-derived inconsistently.
56
+ */
57
+ export declare function threadOwnerConstraint(session: {
58
+ userId?: string;
59
+ orgId?: string;
60
+ } | undefined): string[] | undefined;
61
+ /**
62
+ * Whether a stored thread/run may be read by the caller. Shaped as a predicate
63
+ * so it can back a `pikkuPermission` — authorization belongs in a function's
64
+ * `permissions` field, never in its body.
65
+ *
66
+ * Unlike {@link assertResourceOwner}, which compares against a single composed
67
+ * owner key on the run path, this guards the thread-management reads where the
68
+ * caller supplies only a `threadId` — so ownership has to be derived from the
69
+ * session rather than from the request.
70
+ *
71
+ * A session with no principal means the deployment opted out of authorization
72
+ * (agent `no-auth`), so there is no ownership model to enforce and access is not
73
+ * gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
74
+ * bare resourceId.
75
+ */
76
+ export declare function canAccessThread(storedResourceId: string, session: {
77
+ userId?: string;
78
+ orgId?: string;
79
+ } | undefined): boolean;
37
80
  export type StreamAIAgentOptions = {
38
81
  requiresToolApproval?: 'all' | 'explicit' | false;
39
82
  /**
@@ -93,6 +136,27 @@ export declare const resolveAgent: (agentName: string) => {
93
136
  packageName: string | null;
94
137
  resolvedName: string;
95
138
  };
139
+ /**
140
+ * Enforces an agent's own authorization before it runs: session presence
141
+ * (`auth`), then `scopes`, then `permissions`.
142
+ *
143
+ * The ordering mirrors the function runner — scopes are an AND gate checked
144
+ * first, so they can only ever narrow access, and a missing scope short-circuits
145
+ * before any permission function does I/O.
146
+ *
147
+ * `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
148
+ * required only when `auth: true` is set explicitly. An agent is normally
149
+ * reached from a function that has already enforced its own auth, and agents are
150
+ * also run from genuinely sessionless contexts (crons, queue workers), so
151
+ * requiring a session by default would reject those without adding a meaningful
152
+ * gate. `scopes` and `permissions` are always enforced when declared.
153
+ *
154
+ * Globals are evaluated here too (via {@link runPermissions}) rather than being
155
+ * assumed to have already run: an agent is reachable from entry points that do
156
+ * not go through the function runner, and re-evaluating an AND gate of
157
+ * side-effect-free predicates is idempotent.
158
+ */
159
+ export declare function assertAgentAuthorized(agent: CoreAIAgent, params: RunAIAgentParams, packageName: string | null): Promise<void>;
96
160
  export declare function buildInstructions(agentName: string, packageName: string | null): Promise<string>;
97
161
  export type ScopedChannel = AIStreamChannel & {
98
162
  approvals: Array<{
@@ -1,7 +1,8 @@
1
1
  import { PikkuError } from '../../errors/error-handler.js';
2
- import { checkAuthPermissions } from '../../permissions.js';
2
+ import { checkAuthPermissions, runPermissions } from '../../permissions.js';
3
3
  import { AIProviderNotConfiguredError } from '../../errors/errors.js';
4
4
  import { ForbiddenError } from '../../errors/errors.js';
5
+ import { verifyScopes } from '../../scopes.js';
5
6
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
6
7
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
7
8
  import { randomUUID } from './ai-agent-utils.js';
@@ -62,6 +63,53 @@ export function assertResourceOwner(ownerResourceId, storedResourceId, kind) {
62
63
  throw new ForbiddenError(`Not authorized to access this ${kind}`);
63
64
  }
64
65
  }
66
+ /** A session's trusted principals, in the order they may be read as. */
67
+ export function sessionPrincipals(session) {
68
+ return [session?.userId, session?.orgId].filter((principal) => Boolean(principal));
69
+ }
70
+ /**
71
+ * Whether `storedResourceId` belongs to `principal` under the composition
72
+ * {@link resolveOwnerResourceId} performs — either the bare principal or one of
73
+ * its `principal:` sub-partitions. The `:` is required so that `alice` does not
74
+ * match a `alice-evil:…` lookalike.
75
+ */
76
+ export function isOwnedByPrincipal(storedResourceId, principal) {
77
+ return (storedResourceId === principal ||
78
+ storedResourceId.startsWith(`${principal}:`));
79
+ }
80
+ /**
81
+ * The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
82
+ *
83
+ * Returns `undefined` — not `[]` — for a session with no principal, because an
84
+ * empty list means "match nothing" whereas a sessionless deployment (agent
85
+ * `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
86
+ * rather than at each call site stops it from being re-derived inconsistently.
87
+ */
88
+ export function threadOwnerConstraint(session) {
89
+ const principals = sessionPrincipals(session);
90
+ return principals.length > 0 ? principals : undefined;
91
+ }
92
+ /**
93
+ * Whether a stored thread/run may be read by the caller. Shaped as a predicate
94
+ * so it can back a `pikkuPermission` — authorization belongs in a function's
95
+ * `permissions` field, never in its body.
96
+ *
97
+ * Unlike {@link assertResourceOwner}, which compares against a single composed
98
+ * owner key on the run path, this guards the thread-management reads where the
99
+ * caller supplies only a `threadId` — so ownership has to be derived from the
100
+ * session rather than from the request.
101
+ *
102
+ * A session with no principal means the deployment opted out of authorization
103
+ * (agent `no-auth`), so there is no ownership model to enforce and access is not
104
+ * gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
105
+ * bare resourceId.
106
+ */
107
+ export function canAccessThread(storedResourceId, session) {
108
+ const principals = sessionPrincipals(session);
109
+ if (principals.length === 0)
110
+ return true;
111
+ return principals.some((principal) => isOwnedByPrincipal(storedResourceId, principal));
112
+ }
65
113
  /**
66
114
  * Non-forgeable brand for the sub-agent approval marker. Only framework code
67
115
  * (the delegating sub-agent tools below) sets this Symbol on a tool result; a
@@ -163,6 +211,47 @@ export const resolveAgent = (agentName) => {
163
211
  }
164
212
  throw new Error(`AI agent not found: ${agentName}`);
165
213
  };
214
+ /**
215
+ * Enforces an agent's own authorization before it runs: session presence
216
+ * (`auth`), then `scopes`, then `permissions`.
217
+ *
218
+ * The ordering mirrors the function runner — scopes are an AND gate checked
219
+ * first, so they can only ever narrow access, and a missing scope short-circuits
220
+ * before any permission function does I/O.
221
+ *
222
+ * `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
223
+ * required only when `auth: true` is set explicitly. An agent is normally
224
+ * reached from a function that has already enforced its own auth, and agents are
225
+ * also run from genuinely sessionless contexts (crons, queue workers), so
226
+ * requiring a session by default would reject those without adding a meaningful
227
+ * gate. `scopes` and `permissions` are always enforced when declared.
228
+ *
229
+ * Globals are evaluated here too (via {@link runPermissions}) rather than being
230
+ * assumed to have already run: an agent is reachable from entry points that do
231
+ * not go through the function runner, and re-evaluating an AND gate of
232
+ * side-effect-free predicates is idempotent.
233
+ */
234
+ export async function assertAgentAuthorized(agent, params, packageName) {
235
+ const session = params.sessionService
236
+ ? await params.sessionService.get()
237
+ : undefined;
238
+ if (agent.auth === true && !session) {
239
+ throw new ForbiddenError('Authentication required');
240
+ }
241
+ verifyScopes(agent.scopes, session);
242
+ const singletonServices = getSingletonServices();
243
+ const wire = params.sessionService
244
+ ? createMiddlewareSessionWireProps(params.sessionService)
245
+ : { session: undefined };
246
+ await runPermissions({
247
+ funcPermissions: agent.permissions,
248
+ services: singletonServices,
249
+ wire: wire,
250
+ data: {},
251
+ packageName,
252
+ label: 'agent',
253
+ });
254
+ }
166
255
  export async function buildInstructions(agentName, packageName) {
167
256
  const meta = pikkuState(packageName, 'agent', 'agentsMeta')[agentName];
168
257
  const parts = [];
@@ -279,11 +368,17 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
279
368
  missingRpcs.push(toolName);
280
369
  continue;
281
370
  }
282
- // Filter out tools the user doesn't have auth for
371
+ // Filter out tools the user doesn't have auth for. The `pikkuAuth` brand
372
+ // only survives on the live permission objects in the function config, so
373
+ // the check reads those rather than the metadata (whose by-name registry
374
+ // is never populated, which would let every gated tool through).
283
375
  if (fnMeta.permissions?.length) {
284
376
  if (!session)
285
377
  continue;
286
- const allowed = await checkAuthPermissions(fnMeta.permissions, session, singletonServices, resolvedPkg);
378
+ const funcConfig = pikkuFuncId
379
+ ? pikkuState(resolvedPkg, 'function', 'functions').get(pikkuFuncId)
380
+ : undefined;
381
+ const allowed = await checkAuthPermissions(funcConfig?.permissions, session, singletonServices, resolvedPkg);
287
382
  if (!allowed)
288
383
  continue;
289
384
  }
@@ -351,11 +446,13 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
351
446
  singletonServices.logger.warn(`Sub-agent '${subAgentName}' not found in agent registry`);
352
447
  continue;
353
448
  }
354
- // Filter out sub-agents the user doesn't have auth for
449
+ // Filter out sub-agents the user doesn't have auth for, reading the live
450
+ // agent config for the same reason the tool path does.
355
451
  if (subMeta.permissions?.length) {
356
452
  if (!session)
357
453
  continue;
358
- const allowed = await checkAuthPermissions(subMeta.permissions, session, singletonServices);
454
+ const subAgent = pikkuState(null, 'agent', 'agents').get(subAgentName);
455
+ const allowed = await checkAuthPermissions(subAgent?.permissions, session, singletonServices);
359
456
  if (!allowed)
360
457
  continue;
361
458
  }
@@ -567,6 +664,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
567
664
  export async function prepareAgentRun(agentName, input, params, agentSessionMap, streamContext) {
568
665
  const singletonServices = getSingletonServices();
569
666
  const { agent, packageName, resolvedName } = resolveAgent(agentName);
667
+ await assertAgentAuthorized(agent, params, packageName);
570
668
  let agentRunner = singletonServices.aiAgentRunner;
571
669
  if (!agentRunner) {
572
670
  throw new AIProviderNotConfiguredError();
@@ -1,5 +1,5 @@
1
1
  import { saveMessages, resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
2
- import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, } from './ai-agent-prepare.js';
2
+ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, } from './ai-agent-prepare.js';
3
3
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js';
4
4
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
5
5
  import { resolveModelConfig } from './ai-agent-model-config.js';
@@ -293,6 +293,10 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
293
293
  throw new Error(`Run ${runId} is not suspended (status: ${run.status})`);
294
294
  }
295
295
  const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
296
+ // Resuming re-runs the agent, so it re-runs the agent's gate. Run ownership
297
+ // alone is not enough: a grant revoked while the run was suspended must stop
298
+ // the caller from approving its pending tool calls.
299
+ await assertAgentAuthorized(agent, params, packageName);
296
300
  const { storage } = resolveMemoryServices(agent, singletonServices);
297
301
  const memoryConfig = agent.memory;
298
302
  const agentRunner = singletonServices.aiAgentRunner;