@pikku/inspector 0.12.50 → 0.12.52
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 +441 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +156 -7
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +210 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +243 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +219 -9
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +214 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +310 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import * as ts from 'typescript'
|
|
4
|
+
import { extractPermissionPikkuNames } from './permissions.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A checker stub. With no symbols to resolve, every reference falls back to a
|
|
8
|
+
* deterministic `__temp_` id — which is fine, because the question here is
|
|
9
|
+
* whether a declared permission is *seen*, not what it resolves to. A dropped
|
|
10
|
+
* property yields nothing at all, and that is the bug being pinned.
|
|
11
|
+
*/
|
|
12
|
+
const checker = {
|
|
13
|
+
getSymbolAtLocation: () => undefined,
|
|
14
|
+
getAliasedSymbol: () => undefined,
|
|
15
|
+
getShorthandAssignmentValueSymbol: () => undefined,
|
|
16
|
+
} as unknown as ts.TypeChecker
|
|
17
|
+
|
|
18
|
+
/** Parses `permissions: <expr>` and counts the references the inspector records. */
|
|
19
|
+
const countFor = (permissionsExpression: string): number => {
|
|
20
|
+
const file = ts.createSourceFile(
|
|
21
|
+
'fn.ts',
|
|
22
|
+
`const config = { permissions: ${permissionsExpression} }`,
|
|
23
|
+
ts.ScriptTarget.Latest,
|
|
24
|
+
true
|
|
25
|
+
)
|
|
26
|
+
let node: ts.Expression | undefined
|
|
27
|
+
const visit = (n: ts.Node) => {
|
|
28
|
+
if (
|
|
29
|
+
ts.isPropertyAssignment(n) &&
|
|
30
|
+
ts.isIdentifier(n.name) &&
|
|
31
|
+
n.name.text === 'permissions'
|
|
32
|
+
) {
|
|
33
|
+
node = n.initializer
|
|
34
|
+
}
|
|
35
|
+
ts.forEachChild(n, visit)
|
|
36
|
+
}
|
|
37
|
+
visit(file)
|
|
38
|
+
assert.ok(node, 'failed to find the permissions node')
|
|
39
|
+
return extractPermissionPikkuNames(node, checker, '/root').length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('extractPermissionPikkuNames', () => {
|
|
43
|
+
test('records a permission declared with object shorthand', () => {
|
|
44
|
+
// `permissions: { canAdminOrg }` is enforced identically to the longhand
|
|
45
|
+
// form at runtime — verifyPermissions has a non-array branch — but was
|
|
46
|
+
// dropped here, so meta reported a guarded function as carrying no
|
|
47
|
+
// permissions at all. An audit reading meta saw an open door where one
|
|
48
|
+
// was shut.
|
|
49
|
+
assert.equal(countFor('{ canAdminOrg }'), 1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('records the longhand form', () => {
|
|
53
|
+
assert.equal(countFor('{ org: canAdminOrg }'), 1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('records shorthand and longhand together', () => {
|
|
57
|
+
assert.equal(countFor('{ canAdminOrg, billing: canBill }'), 2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('records several shorthands', () => {
|
|
61
|
+
assert.equal(countFor('{ canAdminOrg, canBill }'), 2)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('records the array form', () => {
|
|
65
|
+
assert.equal(countFor('[canAdminOrg, canBill]'), 2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('records a group whose value is an array', () => {
|
|
69
|
+
assert.equal(countFor('{ org: [canAdminOrg, canBill] }'), 2)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('records a call expression, shorthand-adjacent', () => {
|
|
73
|
+
assert.equal(countFor('{ quota: hasEmailQuota(100) }'), 1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('records nothing for an empty object', () => {
|
|
77
|
+
assert.equal(countFor('{}'), 0)
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/utils/permissions.ts
CHANGED
|
@@ -51,6 +51,13 @@ export function extractPermissionPikkuNames(
|
|
|
51
51
|
for (const prop of node.properties) {
|
|
52
52
|
if (ts.isPropertyAssignment(prop)) {
|
|
53
53
|
extractFromElement(prop.initializer)
|
|
54
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
55
|
+
// `permissions: { canAdminOrg }`. The runtime resolves this exactly
|
|
56
|
+
// like the longhand form — `verifyPermissions` has a non-array branch —
|
|
57
|
+
// so dropping it here reported a guarded function as having no
|
|
58
|
+
// permissions at all, which is the most dangerous direction to be wrong
|
|
59
|
+
// in: an audit reading meta sees an open door where one is shut.
|
|
60
|
+
extractFromElement(prop.name)
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
63
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
validateVariableOverrides,
|
|
11
11
|
validateRemoteAddonDependencies,
|
|
12
12
|
validateRemoteAddonAuth,
|
|
13
|
+
validateAgentToolReferences,
|
|
13
14
|
} from './post-process.js'
|
|
14
15
|
import { ErrorCode } from '../error-codes.js'
|
|
15
16
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
@@ -580,3 +581,135 @@ describe('validateRemoteAddonAuth (bound credential/secret must exist)', () => {
|
|
|
580
581
|
assert.equal(criticals[0]!.code, ErrorCode.REMOTE_ADDON_AUTH_UNRESOLVED)
|
|
581
582
|
})
|
|
582
583
|
})
|
|
584
|
+
|
|
585
|
+
const makeAgentState = (
|
|
586
|
+
tools: string[],
|
|
587
|
+
{
|
|
588
|
+
functions = {},
|
|
589
|
+
addonFunctions = {},
|
|
590
|
+
addons = [],
|
|
591
|
+
internalMeta = {},
|
|
592
|
+
}: {
|
|
593
|
+
functions?: Record<string, any>
|
|
594
|
+
addonFunctions?: Record<string, Record<string, any>>
|
|
595
|
+
addons?: string[]
|
|
596
|
+
internalMeta?: Record<string, string>
|
|
597
|
+
} = {}
|
|
598
|
+
): Omit<InspectorState, 'typesLookup'> =>
|
|
599
|
+
({
|
|
600
|
+
agents: {
|
|
601
|
+
agentsMeta: {
|
|
602
|
+
triage: { tools, sourceFile: 'src/triage.agent.ts' },
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
rpc: {
|
|
606
|
+
internalMeta,
|
|
607
|
+
wireAddonDeclarations: new Map(
|
|
608
|
+
addons.map((name) => [name, { package: `@addon/${name}` }])
|
|
609
|
+
),
|
|
610
|
+
},
|
|
611
|
+
functions: { meta: functions },
|
|
612
|
+
addonFunctions,
|
|
613
|
+
}) as unknown as Omit<InspectorState, 'typesLookup'>
|
|
614
|
+
|
|
615
|
+
describe('validateAgentToolReferences (ref() resolved at build time)', () => {
|
|
616
|
+
test('a local tool that does not exist is an error', () => {
|
|
617
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
618
|
+
validateAgentToolReferences(
|
|
619
|
+
logger,
|
|
620
|
+
makeAgentState(['doesNotExist'], {
|
|
621
|
+
functions: { realTool: { description: 'A real one' } },
|
|
622
|
+
})
|
|
623
|
+
)
|
|
624
|
+
assert.equal(criticals.length, 1)
|
|
625
|
+
assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_NOT_FOUND)
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
test('an addon namespace that is not wired is an error, and lists the ones that are', () => {
|
|
629
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
630
|
+
validateAgentToolReferences(
|
|
631
|
+
logger,
|
|
632
|
+
makeAgentState(['nope:thing'], { addons: ['todos'] })
|
|
633
|
+
)
|
|
634
|
+
assert.equal(criticals.length, 1)
|
|
635
|
+
assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE)
|
|
636
|
+
assert.match(criticals[0]!.message, /Wired namespaces: todos/)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('a wired addon that does not export the function is an error', () => {
|
|
640
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
641
|
+
validateAgentToolReferences(
|
|
642
|
+
logger,
|
|
643
|
+
makeAgentState(['todos:noSuchFn'], {
|
|
644
|
+
addons: ['todos'],
|
|
645
|
+
addonFunctions: { todos: { addTodo: { description: 'Adds a todo' } } },
|
|
646
|
+
})
|
|
647
|
+
)
|
|
648
|
+
assert.equal(criticals.length, 1)
|
|
649
|
+
assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_NOT_FOUND)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
test('an addon that has not been built yet is not treated as a missing function', () => {
|
|
653
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
654
|
+
validateAgentToolReferences(
|
|
655
|
+
logger,
|
|
656
|
+
makeAgentState(['todos:addTodo'], { addons: ['todos'] })
|
|
657
|
+
)
|
|
658
|
+
assert.deepEqual(criticals, [])
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('a workflow reference is resolved elsewhere and left alone', () => {
|
|
662
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
663
|
+
validateAgentToolReferences(
|
|
664
|
+
logger,
|
|
665
|
+
makeAgentState(['workflow:onboarding']),
|
|
666
|
+
{ strictMeta: true }
|
|
667
|
+
)
|
|
668
|
+
assert.deepEqual(criticals, [])
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
test('a missing description is only an error under strictMeta', () => {
|
|
672
|
+
const state = makeAgentState(['bareTool'], {
|
|
673
|
+
functions: { bareTool: {} },
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
const lenient = makeCriticalLogger()
|
|
677
|
+
validateAgentToolReferences(lenient.logger, state)
|
|
678
|
+
assert.deepEqual(lenient.criticals, [])
|
|
679
|
+
|
|
680
|
+
const strict = makeCriticalLogger()
|
|
681
|
+
validateAgentToolReferences(strict.logger, state, { strictMeta: true })
|
|
682
|
+
assert.equal(strict.criticals.length, 1)
|
|
683
|
+
assert.equal(
|
|
684
|
+
strict.criticals[0]!.code,
|
|
685
|
+
ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION
|
|
686
|
+
)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
test('a title does not satisfy strictMeta, and the message says so', () => {
|
|
690
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
691
|
+
validateAgentToolReferences(
|
|
692
|
+
logger,
|
|
693
|
+
makeAgentState(['titledTool'], {
|
|
694
|
+
functions: { titledTool: { title: 'Titled Tool' } },
|
|
695
|
+
}),
|
|
696
|
+
{ strictMeta: true }
|
|
697
|
+
)
|
|
698
|
+
assert.equal(criticals.length, 1)
|
|
699
|
+
assert.equal(criticals[0]!.code, ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION)
|
|
700
|
+
assert.match(criticals[0]!.message, /a 'title' does not count/)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('an addon function is checked against the addon’s own meta', () => {
|
|
704
|
+
const { logger, criticals } = makeCriticalLogger()
|
|
705
|
+
validateAgentToolReferences(
|
|
706
|
+
logger,
|
|
707
|
+
makeAgentState(['todos:addTodo'], {
|
|
708
|
+
addons: ['todos'],
|
|
709
|
+
addonFunctions: { todos: { addTodo: { description: 'Adds a todo' } } },
|
|
710
|
+
}),
|
|
711
|
+
{ strictMeta: true }
|
|
712
|
+
)
|
|
713
|
+
assert.deepEqual(criticals, [])
|
|
714
|
+
})
|
|
715
|
+
})
|
|
@@ -10,10 +10,14 @@ import type {
|
|
|
10
10
|
MiddlewareMetadata,
|
|
11
11
|
PermissionMetadata,
|
|
12
12
|
} from '@pikku/core'
|
|
13
|
+
import type ts from 'typescript'
|
|
13
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
15
|
import { join } from 'node:path'
|
|
15
16
|
import { extractTypeKeys } from './type-utils.js'
|
|
16
17
|
import { ErrorCode } from '../error-codes.js'
|
|
18
|
+
import { isSecretBrokerFunction } from './secret-brokers.js'
|
|
19
|
+
import { findSecretAliasServices } from './secret-alias-services.js'
|
|
20
|
+
import { relative } from 'node:path'
|
|
17
21
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
|
|
18
22
|
import { flattenScopeDefinitions } from '@pikku/core/scope'
|
|
19
23
|
import type { WorkflowStepMeta } from '@pikku/core/workflow'
|
|
@@ -611,6 +615,9 @@ const serializeGroupMap = (
|
|
|
611
615
|
count: meta.count,
|
|
612
616
|
instanceIds: meta.instanceIds,
|
|
613
617
|
isFactory: meta.isFactory,
|
|
618
|
+
...(meta.additionalRegistrations && {
|
|
619
|
+
additionalRegistrations: meta.additionalRegistrations,
|
|
620
|
+
}),
|
|
614
621
|
}
|
|
615
622
|
}
|
|
616
623
|
return result
|
|
@@ -745,8 +752,11 @@ export function validateScenarioServices(
|
|
|
745
752
|
*
|
|
746
753
|
* - the step target must be a static string literal (PKU678) — otherwise it
|
|
747
754
|
* can't be bundled, typed or drawn;
|
|
748
|
-
* - a `browser
|
|
749
|
-
* context signs in as that persona.
|
|
755
|
+
* - a step declaring a `browser` binding must be given an actor (PKU677), since
|
|
756
|
+
* the browser context signs in as that persona. It is checked statically even
|
|
757
|
+
* though the binding only runs on a browser run: a step that can never be
|
|
758
|
+
* driven by a human is a latent failure, not a passing default run.
|
|
759
|
+
* - a scenario written as a step ladder must assert something (PKU680).
|
|
750
760
|
*
|
|
751
761
|
* It also marks the step's function as used, so `scenario run` boots the
|
|
752
762
|
* singletons the step actually needs.
|
|
@@ -755,9 +765,12 @@ export function validateScenarioSteps(
|
|
|
755
765
|
logger: InspectorLogger,
|
|
756
766
|
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
757
767
|
): void {
|
|
768
|
+
const phasesSeen = new Set<string>()
|
|
769
|
+
|
|
758
770
|
const visit = (workflowName: string, steps: WorkflowStepMeta[]): void => {
|
|
759
771
|
for (const step of steps) {
|
|
760
772
|
if (step.type === 'scenarioStep') {
|
|
773
|
+
phasesSeen.add(step.phase)
|
|
761
774
|
if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
|
|
762
775
|
logger.critical(
|
|
763
776
|
ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC,
|
|
@@ -769,10 +782,13 @@ export function validateScenarioSteps(
|
|
|
769
782
|
}
|
|
770
783
|
state.serviceAggregation.usedFunctions.add(step.stepFunc)
|
|
771
784
|
const stepMeta = state.functions.meta[step.stepFunc]
|
|
772
|
-
if (
|
|
785
|
+
if (
|
|
786
|
+
stepMeta?.scenarioStepSurfaces?.includes('browser') &&
|
|
787
|
+
!step.actor
|
|
788
|
+
) {
|
|
773
789
|
logger.critical(
|
|
774
790
|
ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR,
|
|
775
|
-
`Scenario '${workflowName}' calls
|
|
791
|
+
`Scenario '${workflowName}' calls step '${step.stepFunc}', which declares a browser binding, without an actor. ` +
|
|
776
792
|
`Pass { actor: actors.<name> } so the browser context signs in as that persona.`
|
|
777
793
|
)
|
|
778
794
|
}
|
|
@@ -795,7 +811,20 @@ export function validateScenarioSteps(
|
|
|
795
811
|
}
|
|
796
812
|
|
|
797
813
|
for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
|
|
814
|
+
phasesSeen.clear()
|
|
798
815
|
visit(workflowName, meta.steps ?? [])
|
|
816
|
+
// A scenario written as a step ladder but with no `then` proves only that
|
|
817
|
+
// nothing threw. It is also invisible to witness coverage — it contributes
|
|
818
|
+
// 0/0, so it can never lower the number — which makes an assertion-free
|
|
819
|
+
// flow the cheapest way to make a suite look better covered than it is.
|
|
820
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then')) {
|
|
821
|
+
logger.critical(
|
|
822
|
+
ErrorCode.SCENARIO_HAS_NO_ASSERTION,
|
|
823
|
+
`Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
824
|
+
`Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
|
|
825
|
+
`only proves nothing threw, and contributes nothing to witness coverage.`
|
|
826
|
+
)
|
|
827
|
+
}
|
|
799
828
|
}
|
|
800
829
|
}
|
|
801
830
|
|
|
@@ -863,13 +892,18 @@ export function validateSchemaWiringSeparation(
|
|
|
863
892
|
wiringFiles.add(file)
|
|
864
893
|
}
|
|
865
894
|
|
|
866
|
-
// Middleware wirings (addHTTPMiddleware calls)
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
895
|
+
// Middleware wirings (addHTTPMiddleware calls). A group can be registered
|
|
896
|
+
// from more than one file, and every one of them is a wiring file.
|
|
897
|
+
const addGroupFiles = (groups: Map<string, MiddlewareGroupMeta>) => {
|
|
898
|
+
for (const meta of groups.values()) {
|
|
899
|
+
wiringFiles.add(meta.sourceFile)
|
|
900
|
+
for (const registration of meta.additionalRegistrations ?? []) {
|
|
901
|
+
wiringFiles.add(registration.sourceFile)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
872
904
|
}
|
|
905
|
+
addGroupFiles(state.http.routeMiddleware)
|
|
906
|
+
addGroupFiles(state.middleware.tagMiddleware)
|
|
873
907
|
|
|
874
908
|
// Check for overlap
|
|
875
909
|
for (const file of schemaFiles) {
|
|
@@ -888,6 +922,101 @@ export function validateSchemaWiringSeparation(
|
|
|
888
922
|
}
|
|
889
923
|
}
|
|
890
924
|
|
|
925
|
+
/** Marks the functions that keep the full `SecretService`. */
|
|
926
|
+
export function computeSecretBrokers(state: InspectorState): void {
|
|
927
|
+
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
928
|
+
if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
|
|
929
|
+
meta.secretBroker = true
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Refuses a singleton service that is a `SecretService` under another name.
|
|
936
|
+
*
|
|
937
|
+
* `secrets` is omitted from every function-facing services type, but
|
|
938
|
+
* `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
|
|
939
|
+
* a name the omit does not cover. A name-based rule misses this; the type does
|
|
940
|
+
* not.
|
|
941
|
+
*/
|
|
942
|
+
export function validateNoSecretAliasServices(
|
|
943
|
+
logger: InspectorLogger,
|
|
944
|
+
checker: ts.TypeChecker,
|
|
945
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
946
|
+
): void {
|
|
947
|
+
if (!('typesLookup' in state)) {
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
const singletonServicesTypes = state.typesLookup.get('SingletonServices')
|
|
951
|
+
if (!singletonServicesTypes?.length) {
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
const aliases = findSecretAliasServices(singletonServicesTypes[0]!, checker)
|
|
955
|
+
if (aliases.length === 0) {
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const report = (kind: string, id: string, services: string[]) => {
|
|
960
|
+
const used = services.filter((service) => aliases.includes(service))
|
|
961
|
+
if (used.length === 0) {
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
logger.critical(
|
|
965
|
+
ErrorCode.SECRET_SERVICE_ALIASED,
|
|
966
|
+
`${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(', ')}, which ${used.length === 1 ? 'is' : 'are'} a SecretService under another name. ` +
|
|
967
|
+
`SecretService is confined to pikkuServices, pikkuWireServices, addon service factories and middleware — give a service the secret value when you construct it and expose only what the function needs.`
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
972
|
+
if (meta.secretBroker) {
|
|
973
|
+
continue
|
|
974
|
+
}
|
|
975
|
+
report('Function', id, meta.services?.services ?? [])
|
|
976
|
+
}
|
|
977
|
+
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
978
|
+
report('Permission', id, def.services?.services ?? [])
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Reports secret reads the catalogue does not account for.
|
|
984
|
+
*
|
|
985
|
+
* `getSecret('X')` with no matching `defineSecret` is a value that will be missing
|
|
986
|
+
* at first request rather than at deploy; a non-literal key is a read the
|
|
987
|
+
* manifest cannot cover, so a per-unit scope cannot be narrowed around it.
|
|
988
|
+
*/
|
|
989
|
+
export function validateSecretUsage(
|
|
990
|
+
logger: InspectorLogger,
|
|
991
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
992
|
+
): void {
|
|
993
|
+
const declared = new Set(state.secrets.definitions.map((d) => d.secretId))
|
|
994
|
+
for (const definition of state.secrets.definitions) {
|
|
995
|
+
if (definition.oauth2?.tokenSecretId) {
|
|
996
|
+
declared.add(definition.oauth2.tokenSecretId)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
for (const [file, usage] of state.secrets.usage) {
|
|
1001
|
+
const relativeFile = relative(state.rootDir, file)
|
|
1002
|
+
const undeclared = usage.keys.filter((key) => !declared.has(key))
|
|
1003
|
+
if (undeclared.length > 0) {
|
|
1004
|
+
logger.diagnostic({
|
|
1005
|
+
severity: 'warn',
|
|
1006
|
+
code: ErrorCode.SECRET_NOT_DECLARED,
|
|
1007
|
+
message: `${relativeFile} reads ${undeclared.map((k) => `'${k}'`).join(', ')}, which no defineSecret declares. Declare it so it is validated at deploy time and shown to whoever has to provision it.`,
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
if (usage.dynamic.length > 0) {
|
|
1011
|
+
logger.diagnostic({
|
|
1012
|
+
severity: 'warn',
|
|
1013
|
+
code: ErrorCode.SECRET_KEY_NOT_STATIC,
|
|
1014
|
+
message: `${relativeFile} reads a secret with a non-literal key (${usage.dynamic.join(', ')}). The deployment cannot narrow its secret scope around a key it cannot see.`,
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
891
1020
|
export function computeDiagnostics(state: InspectorState): void {
|
|
892
1021
|
const diagnostics: InspectorDiagnostic[] = []
|
|
893
1022
|
|
|
@@ -969,7 +1098,7 @@ export function computeDiagnostics(state: InspectorState): void {
|
|
|
969
1098
|
|
|
970
1099
|
/**
|
|
971
1100
|
* Validates that every scope referenced by a function is declared via
|
|
972
|
-
* `
|
|
1101
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
973
1102
|
*
|
|
974
1103
|
* A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
|
|
975
1104
|
* to be declared, and grants its whole subtree.
|
|
@@ -1001,7 +1130,176 @@ export function validateScopeReferences(
|
|
|
1001
1130
|
const available = Array.from(declared)
|
|
1002
1131
|
logger.critical(
|
|
1003
1132
|
ErrorCode.INVALID_VALUE,
|
|
1004
|
-
`Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with
|
|
1133
|
+
`Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
|
|
1134
|
+
)
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Resolves every `tools:` entry on every agent and reports the ones that do not
|
|
1142
|
+
* land on a real function, or that land on one with nothing to tell the model.
|
|
1143
|
+
*
|
|
1144
|
+
* `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
|
|
1145
|
+
* resolved, only unwrapped — so until now a reference to a function that never
|
|
1146
|
+
* existed generated cleanly and failed at agent-run time. Addon namespaces come
|
|
1147
|
+
* from `wireAddon`, and each addon's generated metadata is read off disk, which
|
|
1148
|
+
* is the only view of it this project has.
|
|
1149
|
+
*
|
|
1150
|
+
* The description check is separate and opt-in (`--strict-meta`), because the
|
|
1151
|
+
* description is what the model is told the tool does: without one, the tool is
|
|
1152
|
+
* offered to it under its own name (`ai-agent-prepare.ts`), which is a silent
|
|
1153
|
+
* quality loss rather than a failure. A title deliberately does not satisfy it —
|
|
1154
|
+
* a title labels a tool in a UI, it does not tell a model when to reach for it.
|
|
1155
|
+
*/
|
|
1156
|
+
export function validateAgentToolReferences(
|
|
1157
|
+
logger: InspectorLogger,
|
|
1158
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>,
|
|
1159
|
+
options: InspectorOptions = {}
|
|
1160
|
+
): void {
|
|
1161
|
+
const agents = Object.entries(state.agents.agentsMeta)
|
|
1162
|
+
if (agents.length === 0) return
|
|
1163
|
+
|
|
1164
|
+
const { wireAddonDeclarations } = state.rpc
|
|
1165
|
+
|
|
1166
|
+
for (const [agentKey, agent] of agents) {
|
|
1167
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
|
|
1168
|
+
|
|
1169
|
+
for (const tool of agent.tools ?? []) {
|
|
1170
|
+
// A workflow reference is resolved against the workflow map, not here.
|
|
1171
|
+
if (tool.startsWith('workflow:')) continue
|
|
1172
|
+
|
|
1173
|
+
const separator = tool.indexOf(':')
|
|
1174
|
+
let meta: { description?: string; title?: string } | undefined
|
|
1175
|
+
|
|
1176
|
+
if (separator === -1) {
|
|
1177
|
+
const funcId = state.rpc.internalMeta[tool] ?? tool
|
|
1178
|
+
meta = state.functions.meta[funcId]
|
|
1179
|
+
if (!meta) {
|
|
1180
|
+
logger.critical(
|
|
1181
|
+
ErrorCode.AGENT_TOOL_NOT_FOUND,
|
|
1182
|
+
`AI agent '${agentKey}'${where} references tool '${tool}', which is not a function in this project.`
|
|
1183
|
+
)
|
|
1184
|
+
continue
|
|
1185
|
+
}
|
|
1186
|
+
} else {
|
|
1187
|
+
const namespace = tool.slice(0, separator)
|
|
1188
|
+
const funcName = tool.slice(separator + 1)
|
|
1189
|
+
const addon = wireAddonDeclarations?.get(namespace)
|
|
1190
|
+
if (!addon) {
|
|
1191
|
+
const known = Array.from(wireAddonDeclarations?.keys() ?? [])
|
|
1192
|
+
logger.critical(
|
|
1193
|
+
ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE,
|
|
1194
|
+
`AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
|
|
1195
|
+
`Wired namespaces: ${known.join(', ') || 'none'}.`
|
|
1196
|
+
)
|
|
1197
|
+
continue
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// An addon that has not been built yet contributed no metadata, which
|
|
1201
|
+
// is not the same as one whose function is missing.
|
|
1202
|
+
const addonMeta = state.addonFunctions[namespace]
|
|
1203
|
+
if (!addonMeta) continue
|
|
1204
|
+
|
|
1205
|
+
meta = addonMeta[funcName]
|
|
1206
|
+
if (!meta) {
|
|
1207
|
+
logger.critical(
|
|
1208
|
+
ErrorCode.AGENT_TOOL_NOT_FOUND,
|
|
1209
|
+
`AI agent '${agentKey}'${where} references tool '${tool}', but addon '${namespace}' ('${addon.package}') exposes no function '${funcName}'.`
|
|
1210
|
+
)
|
|
1211
|
+
continue
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (options.strictMeta && !meta.description) {
|
|
1216
|
+
logger.critical(
|
|
1217
|
+
ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION,
|
|
1218
|
+
`AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
|
|
1219
|
+
`An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
|
|
1220
|
+
`Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`
|
|
1221
|
+
)
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Validates that every scope granted by a system role is declared via
|
|
1229
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
1230
|
+
*
|
|
1231
|
+
* The same rule functions get, for a sharper reason: a role granting an
|
|
1232
|
+
* undeclared scope is not merely useless, it is *invisibly* useless. No
|
|
1233
|
+
* function checks the scope, so the role appears to work — every call a
|
|
1234
|
+
* persona holding it makes succeeds or fails for unrelated reasons, and the
|
|
1235
|
+
* authorization boundary it was written to describe is never exercised.
|
|
1236
|
+
*
|
|
1237
|
+
* A `*` suffix grants a subtree; the node it hangs off must exist.
|
|
1238
|
+
*/
|
|
1239
|
+
export function validateSystemRoleScopes(
|
|
1240
|
+
logger: InspectorLogger,
|
|
1241
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
1242
|
+
): void {
|
|
1243
|
+
if (!state.systemRoles.definitions.length) {
|
|
1244
|
+
return
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const declared = new Set(
|
|
1248
|
+
flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
for (const role of state.systemRoles.definitions) {
|
|
1252
|
+
for (const scope of role.scopes) {
|
|
1253
|
+
if (scope === '*') {
|
|
1254
|
+
logger.critical(
|
|
1255
|
+
ErrorCode.INVALID_VALUE,
|
|
1256
|
+
`System role '${role.name}' grants the bare wildcard scope '*'. Grant the roots it should cover instead — '*' hides what the role actually confers.`
|
|
1257
|
+
)
|
|
1258
|
+
continue
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
|
|
1262
|
+
if (!declared.has(declaredForm)) {
|
|
1263
|
+
const available = Array.from(declared)
|
|
1264
|
+
logger.critical(
|
|
1265
|
+
ErrorCode.INVALID_VALUE,
|
|
1266
|
+
`System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
|
|
1267
|
+
)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Validates that every role a persona names is declared with
|
|
1275
|
+
* `defineSystemRole`. Runs after all visitors, so declaration order does not
|
|
1276
|
+
* matter.
|
|
1277
|
+
*
|
|
1278
|
+
* Custom roles are refused rather than tolerated. A role composed in the
|
|
1279
|
+
* console does not exist at build time and can be deleted after it, so a
|
|
1280
|
+
* persona pinned to one silently stops testing what it claims to — and the
|
|
1281
|
+
* symptom is a scatter of 403s that read like authorization findings rather
|
|
1282
|
+
* than like a deleted role.
|
|
1283
|
+
*/
|
|
1284
|
+
export function validatePersonaRoles(
|
|
1285
|
+
logger: InspectorLogger,
|
|
1286
|
+
state: InspectorState | Omit<InspectorState, 'typesLookup'>
|
|
1287
|
+
): void {
|
|
1288
|
+
if (!state.personas.definitions.length) {
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const declared = new Set(state.systemRoles.definitions.map((r) => r.name))
|
|
1293
|
+
|
|
1294
|
+
for (const persona of state.personas.definitions) {
|
|
1295
|
+
for (const role of persona.roles) {
|
|
1296
|
+
if (!declared.has(role)) {
|
|
1297
|
+
const available = Array.from(declared).sort()
|
|
1298
|
+
logger.critical(
|
|
1299
|
+
ErrorCode.INVALID_VALUE,
|
|
1300
|
+
`Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
|
|
1301
|
+
`A persona may only name a system role — a role composed in the console can be deleted, and the persona would go on claiming to test it. ` +
|
|
1302
|
+
`Declared roles: ${available.join(', ') || 'none'}`
|
|
1005
1303
|
)
|
|
1006
1304
|
}
|
|
1007
1305
|
}
|