@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.
- package/CHANGELOG.md +311 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +22 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/index.ts +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +23 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- 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
|
-
|
|
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
|
-
|
|
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
|