@pikku/core 0.12.64 → 0.12.67

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 (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. package/tsconfig.tsbuildinfo +1 -1
@@ -5,14 +5,19 @@ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
5
  import {
6
6
  agentSessionScope,
7
7
  assertResourceOwner,
8
+ canAccessThread,
8
9
  buildInstructions,
9
10
  buildToolDefs,
10
11
  createScopedChannel,
11
12
  getAddonCredentialRequirements,
13
+ isOwnedByPrincipal,
12
14
  resolveAgent,
13
15
  resolveOwnerResourceId,
16
+ sessionPrincipals,
17
+ threadOwnerConstraint,
14
18
  } from './ai-agent-prepare.js'
15
19
  import { ForbiddenError } from '../../errors/errors.js'
20
+ import { pikkuAuth } from '../../function/functions.types.js'
16
21
  import type {
17
22
  AIStreamChannel,
18
23
  AIStreamEvent,
@@ -128,6 +133,33 @@ describe('ai-agent-prepare', () => {
128
133
  assert.match(instructions, /When calling a sub-agent/)
129
134
  })
130
135
 
136
+ test('buildInstructions joins role, personality and goal in order, blank-line separated', async () => {
137
+ addAgent('scribe', {
138
+ role: 'ROLE',
139
+ personality: 'PERSONALITY',
140
+ goal: 'GOAL',
141
+ })
142
+
143
+ const instructions = await buildInstructions('scribe', null)
144
+ assert.equal(instructions, 'ROLE\n\nPERSONALITY\n\nGOAL')
145
+ })
146
+
147
+ test('buildInstructions omits missing sections rather than leaving blank gaps', async () => {
148
+ addAgent('terse', { role: 'ROLE', goal: 'GOAL' })
149
+
150
+ const instructions = await buildInstructions('terse', null)
151
+ assert.equal(instructions, 'ROLE\n\nGOAL')
152
+ })
153
+
154
+ test('buildInstructions adds no tool or sub-agent guidance when none are configured', async () => {
155
+ addAgent('bare', { role: 'ROLE', goal: undefined })
156
+
157
+ const instructions = await buildInstructions('bare', null)
158
+ assert.equal(instructions, 'ROLE')
159
+ assert.doesNotMatch(instructions, /Tool usage rules:/)
160
+ assert.doesNotMatch(instructions, /When calling a sub-agent/)
161
+ })
162
+
131
163
  test('createScopedChannel forwards stream events, captures approvals, and suppresses done', () => {
132
164
  const events: AIStreamEvent[] = []
133
165
  const channel = createScopedChannel(
@@ -398,6 +430,71 @@ describe('ai-agent-prepare', () => {
398
430
  })
399
431
  })
400
432
 
433
+ /**
434
+ * The permission's `pikkuAuth` brand survives only on the live objects in the
435
+ * function config, not on the metadata. Reading the metadata's by-name
436
+ * reference resolves against a registry nothing populates, so it collects no
437
+ * predicate and lets every gated tool through — this pins that the live config
438
+ * is what gets evaluated.
439
+ */
440
+ const addFilterAgent = (allow: boolean) => {
441
+ addAgent('gatekeeper', { tools: ['gated'] })
442
+ pikkuState(null, 'agent', 'agentsMeta').gatekeeper = {
443
+ ...pikkuState(null, 'agent', 'agentsMeta').gatekeeper,
444
+ tools: ['gated'],
445
+ } as any
446
+ pikkuState(null, 'rpc', 'meta').gated = 'gated'
447
+ pikkuState(null, 'function', 'meta').gated = {
448
+ description: 'Gated tool',
449
+ permissions: [{ type: 'wire', name: 'mayUse' }],
450
+ inputSchemaName: 'GatedInput',
451
+ sessionless: true,
452
+ } as any
453
+ pikkuState(null, 'misc', 'schemas').set('GatedInput', {
454
+ type: 'object',
455
+ properties: {},
456
+ })
457
+ pikkuState(null, 'function', 'functions').set('gated', {
458
+ func: async () => ({ ok: true }),
459
+ permissions: { mayUse: pikkuAuth(async () => allow) },
460
+ } as any)
461
+ pikkuState(null, 'package', 'singletonServices', {
462
+ logger: { warn: () => {}, debug: () => {} },
463
+ } as any)
464
+ }
465
+
466
+ const sessionParams = () =>
467
+ ({
468
+ sessionService: { get: async () => ({ userId: 'u1' }) },
469
+ }) as any
470
+
471
+ test('buildToolDefs offers a tool whose live auth predicate passes', async () => {
472
+ addFilterAgent(true)
473
+ const { tools } = await buildToolDefs(
474
+ sessionParams(),
475
+ new Map<string, string>(),
476
+ 'resource-1',
477
+ 'gatekeeper',
478
+ null
479
+ )
480
+ assert.deepEqual(
481
+ tools.map((t) => t.name),
482
+ ['gated']
483
+ )
484
+ })
485
+
486
+ test('buildToolDefs filters out a tool whose live auth predicate fails', async () => {
487
+ addFilterAgent(false)
488
+ const { tools } = await buildToolDefs(
489
+ sessionParams(),
490
+ new Map<string, string>(),
491
+ 'resource-1',
492
+ 'gatekeeper',
493
+ null
494
+ )
495
+ assert.deepEqual(tools, [])
496
+ })
497
+
401
498
  test('buildToolDefs adds a workflow tool that runs the workflow and returns its output', async () => {
402
499
  addAgent('planner', { workflows: ['buildReport'] } as any)
403
500
  pikkuState(null, 'agent', 'agentsMeta').planner = {
@@ -572,6 +669,84 @@ describe('C2 thread/run ownership + sessionScope', () => {
572
669
  assertResourceOwner('user-a:d', 'user-a:d', 'run')
573
670
  )
574
671
  })
672
+ })
673
+
674
+ describe('thread-read ownership (session principals)', () => {
675
+ test('a principal owns its own bare key and any sub-partition of it', () => {
676
+ assert.equal(isOwnedByPrincipal('alice', 'alice'), true)
677
+ assert.equal(isOwnedByPrincipal('alice:project-1', 'alice'), true)
678
+ })
679
+
680
+ test('a principal does not own a lookalike prefix', () => {
681
+ assert.equal(isOwnedByPrincipal('alice-evil:p', 'alice'), false)
682
+ assert.equal(isOwnedByPrincipal('bob:alice:secret', 'alice'), false)
683
+ })
684
+
685
+ test('the caller may read a thread owned by their userId', () => {
686
+ assert.equal(canAccessThread('alice:default', { userId: 'alice' }), true)
687
+ })
688
+
689
+ test('the caller may read a thread owned by their orgId', () => {
690
+ assert.equal(
691
+ canAccessThread('org-x:default', { userId: 'alice', orgId: 'org-x' }),
692
+ true
693
+ )
694
+ })
695
+
696
+ test('cross-user thread reads are refused', () => {
697
+ assert.equal(canAccessThread('bob:secret', { userId: 'alice' }), false)
698
+ })
699
+
700
+ test('cross-org thread reads are refused', () => {
701
+ assert.equal(
702
+ canAccessThread('org-y:d', { userId: 'alice', orgId: 'org-x' }),
703
+ false
704
+ )
705
+ })
706
+
707
+ test('a forged owner prefix in the stored id cannot grant access', () => {
708
+ assert.equal(
709
+ canAccessThread('bob:alice:secret', { userId: 'alice' }),
710
+ false
711
+ )
712
+ })
713
+
714
+ // A deployment with no session has explicitly opted out of authorization
715
+ // (agent `no-auth`), so there is no ownership model to enforce — matching
716
+ // resolveOwnerResourceId's own sessionless fallback to a bare resourceId.
717
+ test('a sessionless deployment has no ownership model and is not gated', () => {
718
+ assert.equal(canAccessThread('anything', undefined), true)
719
+ assert.equal(canAccessThread('anything', {}), true)
720
+ })
721
+
722
+ test('sessionPrincipals lists the userId and orgId a caller can read as', () => {
723
+ assert.deepEqual(sessionPrincipals({ userId: 'alice', orgId: 'org-x' }), [
724
+ 'alice',
725
+ 'org-x',
726
+ ])
727
+ assert.deepEqual(sessionPrincipals({ userId: 'alice' }), ['alice'])
728
+ assert.deepEqual(sessionPrincipals(undefined), [])
729
+ })
730
+
731
+ test('threadOwnerConstraint scopes a session to its principals', () => {
732
+ assert.deepEqual(
733
+ threadOwnerConstraint({ userId: 'alice', orgId: 'org-x' }),
734
+ ['alice', 'org-x']
735
+ )
736
+ })
737
+
738
+ // undefined, not [] — [] means "match nothing", which would hide every thread
739
+ // from a no-auth deployment that never had an ownership model to begin with.
740
+ test('threadOwnerConstraint is undefined for a sessionless caller', () => {
741
+ assert.equal(threadOwnerConstraint(undefined), undefined)
742
+ assert.equal(threadOwnerConstraint({}), undefined)
743
+ })
744
+ })
745
+
746
+ describe('C2 sessionScope + resume ownership', () => {
747
+ const params = (session?: Record<string, unknown>) => ({
748
+ sessionService: { get: () => session } as never,
749
+ })
575
750
 
576
751
  test('agentSessionScope reads the declared scope and defaults to user', () => {
577
752
  addAgent('scoped-user')
@@ -12,9 +12,10 @@ import type {
12
12
  } from './ai-agent.types.js'
13
13
  import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
14
14
  import { PikkuError } from '../../errors/error-handler.js'
15
- import { checkAuthPermissions } from '../../permissions.js'
15
+ import { checkAuthPermissions, runPermissions } from '../../permissions.js'
16
16
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
17
17
  import { ForbiddenError } from '../../errors/errors.js'
18
+ import { verifyScopes } from '../../scopes.js'
18
19
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
19
20
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
20
21
  import type { SessionService } from '../../services/user-session-service.js'
@@ -104,6 +105,72 @@ export function assertResourceOwner(
104
105
  }
105
106
  }
106
107
 
108
+ /** A session's trusted principals, in the order they may be read as. */
109
+ export function sessionPrincipals(
110
+ session: { userId?: string; orgId?: string } | undefined
111
+ ): string[] {
112
+ return [session?.userId, session?.orgId].filter(
113
+ (principal): principal is string => Boolean(principal)
114
+ )
115
+ }
116
+
117
+ /**
118
+ * Whether `storedResourceId` belongs to `principal` under the composition
119
+ * {@link resolveOwnerResourceId} performs — either the bare principal or one of
120
+ * its `principal:` sub-partitions. The `:` is required so that `alice` does not
121
+ * match a `alice-evil:…` lookalike.
122
+ */
123
+ export function isOwnedByPrincipal(
124
+ storedResourceId: string,
125
+ principal: string
126
+ ): boolean {
127
+ return (
128
+ storedResourceId === principal ||
129
+ storedResourceId.startsWith(`${principal}:`)
130
+ )
131
+ }
132
+
133
+ /**
134
+ * The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
135
+ *
136
+ * Returns `undefined` — not `[]` — for a session with no principal, because an
137
+ * empty list means "match nothing" whereas a sessionless deployment (agent
138
+ * `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
139
+ * rather than at each call site stops it from being re-derived inconsistently.
140
+ */
141
+ export function threadOwnerConstraint(
142
+ session: { userId?: string; orgId?: string } | undefined
143
+ ): string[] | undefined {
144
+ const principals = sessionPrincipals(session)
145
+ return principals.length > 0 ? principals : undefined
146
+ }
147
+
148
+ /**
149
+ * Whether a stored thread/run may be read by the caller. Shaped as a predicate
150
+ * so it can back a `pikkuPermission` — authorization belongs in a function's
151
+ * `permissions` field, never in its body.
152
+ *
153
+ * Unlike {@link assertResourceOwner}, which compares against a single composed
154
+ * owner key on the run path, this guards the thread-management reads where the
155
+ * caller supplies only a `threadId` — so ownership has to be derived from the
156
+ * session rather than from the request.
157
+ *
158
+ * A session with no principal means the deployment opted out of authorization
159
+ * (agent `no-auth`), so there is no ownership model to enforce and access is not
160
+ * gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
161
+ * bare resourceId.
162
+ */
163
+ export function canAccessThread(
164
+ storedResourceId: string,
165
+ session: { userId?: string; orgId?: string } | undefined
166
+ ): boolean {
167
+ const principals = sessionPrincipals(session)
168
+ if (principals.length === 0) return true
169
+ return principals.some((principal) =>
170
+ isOwnedByPrincipal(storedResourceId, principal)
171
+ )
172
+ }
173
+
107
174
  export type StreamAIAgentOptions = {
108
175
  requiresToolApproval?: 'all' | 'explicit' | false
109
176
  /**
@@ -266,6 +333,56 @@ export const resolveAgent = (
266
333
  throw new Error(`AI agent not found: ${agentName}`)
267
334
  }
268
335
 
336
+ /**
337
+ * Enforces an agent's own authorization before it runs: session presence
338
+ * (`auth`), then `scopes`, then `permissions`.
339
+ *
340
+ * The ordering mirrors the function runner — scopes are an AND gate checked
341
+ * first, so they can only ever narrow access, and a missing scope short-circuits
342
+ * before any permission function does I/O.
343
+ *
344
+ * `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
345
+ * required only when `auth: true` is set explicitly. An agent is normally
346
+ * reached from a function that has already enforced its own auth, and agents are
347
+ * also run from genuinely sessionless contexts (crons, queue workers), so
348
+ * requiring a session by default would reject those without adding a meaningful
349
+ * gate. `scopes` and `permissions` are always enforced when declared.
350
+ *
351
+ * Globals are evaluated here too (via {@link runPermissions}) rather than being
352
+ * assumed to have already run: an agent is reachable from entry points that do
353
+ * not go through the function runner, and re-evaluating an AND gate of
354
+ * side-effect-free predicates is idempotent.
355
+ */
356
+ export async function assertAgentAuthorized(
357
+ agent: CoreAIAgent,
358
+ params: RunAIAgentParams,
359
+ packageName: string | null
360
+ ): Promise<void> {
361
+ const session = params.sessionService
362
+ ? await params.sessionService.get()
363
+ : undefined
364
+
365
+ if (agent.auth === true && !session) {
366
+ throw new ForbiddenError('Authentication required')
367
+ }
368
+
369
+ verifyScopes(agent.scopes, session)
370
+
371
+ const singletonServices = getSingletonServices()
372
+ const wire = params.sessionService
373
+ ? createMiddlewareSessionWireProps(params.sessionService)
374
+ : { session: undefined }
375
+
376
+ await runPermissions({
377
+ funcPermissions: agent.permissions,
378
+ services: singletonServices as any,
379
+ wire: wire as any,
380
+ data: {},
381
+ packageName,
382
+ label: 'agent',
383
+ })
384
+ }
385
+
269
386
  export async function buildInstructions(
270
387
  agentName: string,
271
388
  packageName: string | null
@@ -419,11 +536,17 @@ export async function buildToolDefs(
419
536
  continue
420
537
  }
421
538
 
422
- // Filter out tools the user doesn't have auth for
539
+ // Filter out tools the user doesn't have auth for. The `pikkuAuth` brand
540
+ // only survives on the live permission objects in the function config, so
541
+ // the check reads those rather than the metadata (whose by-name registry
542
+ // is never populated, which would let every gated tool through).
423
543
  if (fnMeta.permissions?.length) {
424
544
  if (!session) continue
545
+ const funcConfig = pikkuFuncId
546
+ ? pikkuState(resolvedPkg, 'function', 'functions').get(pikkuFuncId)
547
+ : undefined
425
548
  const allowed = await checkAuthPermissions(
426
- fnMeta.permissions,
549
+ funcConfig?.permissions,
427
550
  session,
428
551
  singletonServices,
429
552
  resolvedPkg
@@ -519,11 +642,13 @@ export async function buildToolDefs(
519
642
  continue
520
643
  }
521
644
 
522
- // Filter out sub-agents the user doesn't have auth for
645
+ // Filter out sub-agents the user doesn't have auth for, reading the live
646
+ // agent config for the same reason the tool path does.
523
647
  if (subMeta.permissions?.length) {
524
648
  if (!session) continue
649
+ const subAgent = pikkuState(null, 'agent', 'agents').get(subAgentName)
525
650
  const allowed = await checkAuthPermissions(
526
- subMeta.permissions,
651
+ subAgent?.permissions,
527
652
  session,
528
653
  singletonServices
529
654
  )
@@ -796,6 +921,8 @@ export async function prepareAgentRun(
796
921
  const singletonServices = getSingletonServices()
797
922
  const { agent, packageName, resolvedName } = resolveAgent(agentName)
798
923
 
924
+ await assertAgentAuthorized(agent, params, packageName)
925
+
799
926
  let agentRunner = singletonServices.aiAgentRunner
800
927
  if (!agentRunner) {
801
928
  throw new AIProviderNotConfiguredError()
@@ -0,0 +1,207 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ resetPikkuState,
6
+ pikkuState,
7
+ setSingletonServices,
8
+ } from '../../pikku-state.js'
9
+ import { resumeAIAgentSync } from './ai-agent-runner.js'
10
+ import { resumeAIAgent } from './ai-agent-stream.js'
11
+ import { clearPermissionsCache } from '../../permissions.js'
12
+ import { ForbiddenError, MissingScopeError } from '../../errors/errors.js'
13
+ import type { CoreAIAgent } from './ai-agent.types.js'
14
+ import type { CoreUserSession } from '../../types/core.types.js'
15
+
16
+ const logger = {
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ } as any
22
+
23
+ const RUN_ID = 'run-1'
24
+ const TOOL_CALL_ID = 'tc-1'
25
+
26
+ const suspendedRun = (agentName: string, resourceId: string) => ({
27
+ id: RUN_ID,
28
+ agentName,
29
+ resourceId,
30
+ threadId: 'thread-1',
31
+ status: 'suspended',
32
+ pendingApprovals: [
33
+ { toolCallId: TOOL_CALL_ID, toolName: 'refund', args: {}, runId: RUN_ID },
34
+ ],
35
+ messages: [],
36
+ })
37
+
38
+ const setup = (agentName: string, resourceId = 'u1') => {
39
+ const resolved: string[] = []
40
+ setSingletonServices({
41
+ logger,
42
+ aiRunState: {
43
+ getRun: async () => suspendedRun(agentName, resourceId),
44
+ resolveApproval: async (toolCallId: string) => {
45
+ resolved.push(toolCallId)
46
+ },
47
+ },
48
+ } as any)
49
+ return resolved
50
+ }
51
+
52
+ const addAgent = (
53
+ agentName: string,
54
+ overrides: Partial<CoreAIAgent> = {}
55
+ ): CoreAIAgent => {
56
+ const agent: CoreAIAgent = {
57
+ name: agentName,
58
+ description: `${agentName} description`,
59
+ goal: `${agentName} goal`,
60
+ model: 'test/test-model',
61
+ ...overrides,
62
+ } as CoreAIAgent
63
+
64
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
65
+ return agent
66
+ }
67
+
68
+ const sessionService = (session: CoreUserSession | undefined) =>
69
+ ({
70
+ get: () => session,
71
+ setInitial: () => {},
72
+ sessionChanged: false,
73
+ }) as any
74
+
75
+ beforeEach(() => {
76
+ resetPikkuState()
77
+ clearPermissionsCache()
78
+ })
79
+
80
+ describe('resumeAIAgentSync authorization', () => {
81
+ test('rejects when the agent permissions deny', async () => {
82
+ const resolved = setup('denied-agent')
83
+ addAgent('denied-agent', {
84
+ permissions: { denied: async () => false },
85
+ } as Partial<CoreAIAgent>)
86
+
87
+ await assert.rejects(
88
+ () =>
89
+ resumeAIAgentSync(
90
+ RUN_ID,
91
+ [{ toolCallId: TOOL_CALL_ID, approved: true }],
92
+ { sessionService: sessionService({ userId: 'u1' }) } as any
93
+ ),
94
+ ForbiddenError
95
+ )
96
+ assert.deepEqual(
97
+ resolved,
98
+ [],
99
+ 'approvals must not be resolved when authorization fails'
100
+ )
101
+ })
102
+
103
+ test('rejects when a required scope is no longer held', async () => {
104
+ const resolved = setup('scoped-agent')
105
+ addAgent('scoped-agent', { scopes: ['admin:refund'] })
106
+
107
+ await assert.rejects(
108
+ () =>
109
+ resumeAIAgentSync(
110
+ RUN_ID,
111
+ [{ toolCallId: TOOL_CALL_ID, approved: true }],
112
+ {
113
+ sessionService: sessionService({
114
+ userId: 'u1',
115
+ scopes: ['support'],
116
+ }),
117
+ } as any
118
+ ),
119
+ MissingScopeError
120
+ )
121
+ assert.deepEqual(resolved, [], 'a revoked scope must block resumption')
122
+ })
123
+
124
+ test('passes the gate when the session still holds the scope', async () => {
125
+ setup('scoped-agent-ok')
126
+ addAgent('scoped-agent-ok', { scopes: ['admin:refund'] })
127
+
128
+ // Gets past authorization and fails later on the unconfigured provider,
129
+ // which is how we know the gate admitted it.
130
+ await assert.rejects(
131
+ () =>
132
+ resumeAIAgentSync(
133
+ RUN_ID,
134
+ [{ toolCallId: TOOL_CALL_ID, approved: true }],
135
+ {
136
+ sessionService: sessionService({
137
+ userId: 'u1',
138
+ scopes: ['admin:refund'],
139
+ }),
140
+ } as any
141
+ ),
142
+ (error: Error) =>
143
+ !(error instanceof ForbiddenError) &&
144
+ !(error instanceof MissingScopeError)
145
+ )
146
+ })
147
+
148
+ test('rejects when auth is required and the session is gone', async () => {
149
+ setup('auth-agent')
150
+ addAgent('auth-agent', { auth: true })
151
+
152
+ await assert.rejects(
153
+ () =>
154
+ resumeAIAgentSync(
155
+ RUN_ID,
156
+ [{ toolCallId: TOOL_CALL_ID, approved: true }],
157
+ { sessionService: sessionService(undefined) } as any
158
+ ),
159
+ ForbiddenError
160
+ )
161
+ })
162
+ })
163
+
164
+ describe('resumeAIAgent (streaming) authorization', () => {
165
+ const channel = {
166
+ send: async () => {},
167
+ close: async () => {},
168
+ } as any
169
+
170
+ test('rejects when the agent permissions deny', async () => {
171
+ const resolved = setup('stream-denied')
172
+ addAgent('stream-denied', {
173
+ permissions: { denied: async () => false },
174
+ } as Partial<CoreAIAgent>)
175
+
176
+ await assert.rejects(
177
+ () =>
178
+ resumeAIAgent(
179
+ { runId: RUN_ID, toolCallId: TOOL_CALL_ID, approved: true },
180
+ channel,
181
+ { sessionService: sessionService({ userId: 'u1' }) } as any
182
+ ),
183
+ ForbiddenError
184
+ )
185
+ assert.deepEqual(resolved, [])
186
+ })
187
+
188
+ test('rejects when a required scope is no longer held', async () => {
189
+ setup('stream-scoped')
190
+ addAgent('stream-scoped', { scopes: ['admin:refund'] })
191
+
192
+ await assert.rejects(
193
+ () =>
194
+ resumeAIAgent(
195
+ { runId: RUN_ID, toolCallId: TOOL_CALL_ID, approved: true },
196
+ channel,
197
+ {
198
+ sessionService: sessionService({
199
+ userId: 'u1',
200
+ scopes: ['support'],
201
+ }),
202
+ } as any
203
+ ),
204
+ MissingScopeError
205
+ )
206
+ })
207
+ })
@@ -30,6 +30,7 @@ import {
30
30
  resolveOwnerResourceId,
31
31
  agentSessionScope,
32
32
  assertResourceOwner,
33
+ assertAgentAuthorized,
33
34
  type RunAIAgentParams,
34
35
  } from './ai-agent-prepare.js'
35
36
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js'
@@ -416,6 +417,12 @@ export async function resumeAIAgentSync(
416
417
  }
417
418
 
418
419
  const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
420
+
421
+ // Resuming re-runs the agent, so it re-runs the agent's gate. Run ownership
422
+ // alone is not enough: a grant revoked while the run was suspended must stop
423
+ // the caller from approving its pending tool calls.
424
+ await assertAgentAuthorized(agent, params, packageName)
425
+
419
426
  const { storage } = resolveMemoryServices(agent, singletonServices)
420
427
  const memoryConfig = agent.memory
421
428
  const agentRunner = singletonServices.aiAgentRunner