@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,22 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* Readers for object-literal properties that must be statically knowable.
|
|
4
|
+
*
|
|
5
|
+
* Declarations extracted this way are never evaluated — the CLI reads them from
|
|
6
|
+
* source and writes them to JSON that a deployed stage carries without the app.
|
|
7
|
+
* So a computed value is not "unsupported", it is unreadable, and every reader
|
|
8
|
+
* here returns `undefined` for one rather than guessing.
|
|
9
|
+
*/
|
|
10
|
+
export declare const getProperty: (config: ts.ObjectLiteralExpression, name: string) => ts.Expression | undefined;
|
|
11
|
+
export declare const stringProperty: (config: ts.ObjectLiteralExpression, name: string) => string | undefined;
|
|
12
|
+
export declare const numberProperty: (config: ts.ObjectLiteralExpression, name: string) => number | undefined;
|
|
13
|
+
export declare const booleanProperty: (config: ts.ObjectLiteralExpression, name: string) => boolean | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* All-or-nothing: an array with one computed entry reads as unreadable rather
|
|
16
|
+
* than as the entries that happened to be literals. A half-read `roles` or
|
|
17
|
+
* `scopes` array is worse than none — it typechecks, runs, and grants less
|
|
18
|
+
* than the source says.
|
|
19
|
+
*/
|
|
20
|
+
export declare const stringArrayProperty: (config: ts.ObjectLiteralExpression, name: string) => string[] | undefined;
|
|
21
|
+
/** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
|
|
22
|
+
export declare const unwrapAs: (node: ts.Expression) => ts.Expression;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* Readers for object-literal properties that must be statically knowable.
|
|
4
|
+
*
|
|
5
|
+
* Declarations extracted this way are never evaluated — the CLI reads them from
|
|
6
|
+
* source and writes them to JSON that a deployed stage carries without the app.
|
|
7
|
+
* So a computed value is not "unsupported", it is unreadable, and every reader
|
|
8
|
+
* here returns `undefined` for one rather than guessing.
|
|
9
|
+
*/
|
|
10
|
+
export const getProperty = (config, name) => {
|
|
11
|
+
const property = config.properties.find((p) => ts.isPropertyAssignment(p) &&
|
|
12
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
13
|
+
p.name.text === name);
|
|
14
|
+
return property && ts.isPropertyAssignment(property)
|
|
15
|
+
? property.initializer
|
|
16
|
+
: undefined;
|
|
17
|
+
};
|
|
18
|
+
export const stringProperty = (config, name) => {
|
|
19
|
+
const value = getProperty(config, name);
|
|
20
|
+
return value && ts.isStringLiteralLike(value) ? value.text : undefined;
|
|
21
|
+
};
|
|
22
|
+
export const numberProperty = (config, name) => {
|
|
23
|
+
const value = getProperty(config, name);
|
|
24
|
+
if (!value) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
// `-1` parses as a unary minus over a literal, not as a literal. Reading it
|
|
28
|
+
// rather than ignoring it is what lets a negative be *rejected* by the caller
|
|
29
|
+
// instead of silently dropped and defaulted.
|
|
30
|
+
if (ts.isPrefixUnaryExpression(value) &&
|
|
31
|
+
value.operator === ts.SyntaxKind.MinusToken &&
|
|
32
|
+
ts.isNumericLiteral(value.operand)) {
|
|
33
|
+
return -Number(value.operand.text);
|
|
34
|
+
}
|
|
35
|
+
return ts.isNumericLiteral(value) ? Number(value.text) : undefined;
|
|
36
|
+
};
|
|
37
|
+
export const booleanProperty = (config, name) => {
|
|
38
|
+
const value = getProperty(config, name);
|
|
39
|
+
if (!value) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* All-or-nothing: an array with one computed entry reads as unreadable rather
|
|
52
|
+
* than as the entries that happened to be literals. A half-read `roles` or
|
|
53
|
+
* `scopes` array is worse than none — it typechecks, runs, and grants less
|
|
54
|
+
* than the source says.
|
|
55
|
+
*/
|
|
56
|
+
export const stringArrayProperty = (config, name) => {
|
|
57
|
+
const value = getProperty(config, name);
|
|
58
|
+
if (!value || !ts.isArrayLiteralExpression(value)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const values = value.elements
|
|
62
|
+
.filter(ts.isStringLiteralLike)
|
|
63
|
+
.map((e) => e.text);
|
|
64
|
+
return values.length === value.elements.length ? values : undefined;
|
|
65
|
+
};
|
|
66
|
+
/** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
|
|
67
|
+
export const unwrapAs = (node) => ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
|
|
68
|
+
? unwrapAs(node.expression)
|
|
69
|
+
: node;
|
|
@@ -69,6 +69,25 @@ const applyPackageToChannelContracts = (contracts, packageName, namespace) => {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Resolves an addon's generated metadata, preferring the verbose copy.
|
|
74
|
+
*
|
|
75
|
+
* An addon publishes both: the minimal one its own bootstrap imports, and the
|
|
76
|
+
* verbose one for anything that needs the fields stripped out of it. A consumer
|
|
77
|
+
* is squarely in the second category — it is reading the addon to describe it,
|
|
78
|
+
* not to run it.
|
|
79
|
+
*/
|
|
80
|
+
const resolveAddonMeta = (require, packageName, baseName) => {
|
|
81
|
+
for (const suffix of ['-verbose.gen.json', '.gen.json']) {
|
|
82
|
+
try {
|
|
83
|
+
return require.resolve(`${packageName}/.pikku/${baseName}${suffix}`);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
72
91
|
/**
|
|
73
92
|
* After the setup sweep discovers wireAddon() declarations, load each addon
|
|
74
93
|
* package's function metadata so that wiring handlers (channels, HTTP routes,
|
|
@@ -86,7 +105,13 @@ export async function loadAddonFunctionsMeta(logger, state) {
|
|
|
86
105
|
if (decl.remote)
|
|
87
106
|
continue;
|
|
88
107
|
try {
|
|
89
|
-
|
|
108
|
+
// Verbose first. `description` is one of the fields stripped from the
|
|
109
|
+
// minimal copy, and it is what an addon's function is offered to a model
|
|
110
|
+
// under — both as an MCP tool below and as an agent tool. Read the minimal
|
|
111
|
+
// one and every addon function looks undescribed.
|
|
112
|
+
const metaPath = resolveAddonMeta(require, decl.package, 'function/pikku-functions-meta');
|
|
113
|
+
if (!metaPath)
|
|
114
|
+
throw new Error('no function metadata');
|
|
90
115
|
const raw = await readFile(metaPath, 'utf-8');
|
|
91
116
|
const meta = JSON.parse(raw);
|
|
92
117
|
state.addonFunctions[namespace] = meta;
|
|
@@ -42,6 +42,14 @@ export function extractPermissionPikkuNames(node, checker, rootDir) {
|
|
|
42
42
|
if (ts.isPropertyAssignment(prop)) {
|
|
43
43
|
extractFromElement(prop.initializer);
|
|
44
44
|
}
|
|
45
|
+
else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
46
|
+
// `permissions: { canAdminOrg }`. The runtime resolves this exactly
|
|
47
|
+
// like the longhand form — `verifyPermissions` has a non-array branch —
|
|
48
|
+
// so dropping it here reported a guarded function as having no
|
|
49
|
+
// permissions at all, which is the most dangerous direction to be wrong
|
|
50
|
+
// in: an audit reading meta sees an open door where one is shut.
|
|
51
|
+
extractFromElement(prop.name);
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
return names;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { InspectorState, InspectorLogger, InspectorOptions } from '../types.js';
|
|
2
2
|
import type { MiddlewareMetadata, PermissionMetadata } from '@pikku/core';
|
|
3
|
+
import type ts from 'typescript';
|
|
3
4
|
/**
|
|
4
5
|
* Stamp the inspected authorize/callbacks service set onto the generated auth
|
|
5
6
|
* handler's function meta.
|
|
@@ -69,8 +70,11 @@ export declare function validateScenarioServices(logger: InspectorLogger, state:
|
|
|
69
70
|
*
|
|
70
71
|
* - the step target must be a static string literal (PKU678) — otherwise it
|
|
71
72
|
* can't be bundled, typed or drawn;
|
|
72
|
-
* - a `browser
|
|
73
|
-
* context signs in as that persona.
|
|
73
|
+
* - a step declaring a `browser` binding must be given an actor (PKU677), since
|
|
74
|
+
* the browser context signs in as that persona. It is checked statically even
|
|
75
|
+
* though the binding only runs on a browser run: a step that can never be
|
|
76
|
+
* driven by a human is a latent failure, not a passing default run.
|
|
77
|
+
* - a scenario written as a step ladder must assert something (PKU680).
|
|
74
78
|
*
|
|
75
79
|
* It also marks the step's function as used, so `scenario run` boots the
|
|
76
80
|
* singletons the step actually needs.
|
|
@@ -92,12 +96,73 @@ export declare function validateWorkflowGraphAddons(logger: InspectorLogger, sta
|
|
|
92
96
|
* because the pikku state metadata doesn't exist in the CLI context.
|
|
93
97
|
*/
|
|
94
98
|
export declare function validateSchemaWiringSeparation(logger: InspectorLogger, state: InspectorState): void;
|
|
99
|
+
/** Marks the functions that keep the full `SecretService`. */
|
|
100
|
+
export declare function computeSecretBrokers(state: InspectorState): void;
|
|
101
|
+
/**
|
|
102
|
+
* Refuses a singleton service that is a `SecretService` under another name.
|
|
103
|
+
*
|
|
104
|
+
* `secrets` is omitted from every function-facing services type, but
|
|
105
|
+
* `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
|
|
106
|
+
* a name the omit does not cover. A name-based rule misses this; the type does
|
|
107
|
+
* not.
|
|
108
|
+
*/
|
|
109
|
+
export declare function validateNoSecretAliasServices(logger: InspectorLogger, checker: ts.TypeChecker, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
|
|
110
|
+
/**
|
|
111
|
+
* Reports secret reads the catalogue does not account for.
|
|
112
|
+
*
|
|
113
|
+
* `getSecret('X')` with no matching `defineSecret` is a value that will be missing
|
|
114
|
+
* at first request rather than at deploy; a non-literal key is a read the
|
|
115
|
+
* manifest cannot cover, so a per-unit scope cannot be narrowed around it.
|
|
116
|
+
*/
|
|
117
|
+
export declare function validateSecretUsage(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
|
|
95
118
|
export declare function computeDiagnostics(state: InspectorState): void;
|
|
96
119
|
/**
|
|
97
120
|
* Validates that every scope referenced by a function is declared via
|
|
98
|
-
* `
|
|
121
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
99
122
|
*
|
|
100
123
|
* A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
|
|
101
124
|
* to be declared, and grants its whole subtree.
|
|
102
125
|
*/
|
|
103
126
|
export declare function validateScopeReferences(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
|
|
127
|
+
/**
|
|
128
|
+
* Resolves every `tools:` entry on every agent and reports the ones that do not
|
|
129
|
+
* land on a real function, or that land on one with nothing to tell the model.
|
|
130
|
+
*
|
|
131
|
+
* `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
|
|
132
|
+
* resolved, only unwrapped — so until now a reference to a function that never
|
|
133
|
+
* existed generated cleanly and failed at agent-run time. Addon namespaces come
|
|
134
|
+
* from `wireAddon`, and each addon's generated metadata is read off disk, which
|
|
135
|
+
* is the only view of it this project has.
|
|
136
|
+
*
|
|
137
|
+
* The description check is separate and opt-in (`--strict-meta`), because the
|
|
138
|
+
* description is what the model is told the tool does: without one, the tool is
|
|
139
|
+
* offered to it under its own name (`ai-agent-prepare.ts`), which is a silent
|
|
140
|
+
* quality loss rather than a failure. A title deliberately does not satisfy it —
|
|
141
|
+
* a title labels a tool in a UI, it does not tell a model when to reach for it.
|
|
142
|
+
*/
|
|
143
|
+
export declare function validateAgentToolReferences(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>, options?: InspectorOptions): void;
|
|
144
|
+
/**
|
|
145
|
+
* Validates that every scope granted by a system role is declared via
|
|
146
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
147
|
+
*
|
|
148
|
+
* The same rule functions get, for a sharper reason: a role granting an
|
|
149
|
+
* undeclared scope is not merely useless, it is *invisibly* useless. No
|
|
150
|
+
* function checks the scope, so the role appears to work — every call a
|
|
151
|
+
* persona holding it makes succeeds or fails for unrelated reasons, and the
|
|
152
|
+
* authorization boundary it was written to describe is never exercised.
|
|
153
|
+
*
|
|
154
|
+
* A `*` suffix grants a subtree; the node it hangs off must exist.
|
|
155
|
+
*/
|
|
156
|
+
export declare function validateSystemRoleScopes(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
|
|
157
|
+
/**
|
|
158
|
+
* Validates that every role a persona names is declared with
|
|
159
|
+
* `defineSystemRole`. Runs after all visitors, so declaration order does not
|
|
160
|
+
* matter.
|
|
161
|
+
*
|
|
162
|
+
* Custom roles are refused rather than tolerated. A role composed in the
|
|
163
|
+
* console does not exist at build time and can be deleted after it, so a
|
|
164
|
+
* persona pinned to one silently stops testing what it claims to — and the
|
|
165
|
+
* symptom is a scatter of 403s that read like authorization findings rather
|
|
166
|
+
* than like a deleted role.
|
|
167
|
+
*/
|
|
168
|
+
export declare function validatePersonaRoles(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
|
|
@@ -2,6 +2,9 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { extractTypeKeys } from './type-utils.js';
|
|
4
4
|
import { ErrorCode } from '../error-codes.js';
|
|
5
|
+
import { isSecretBrokerFunction } from './secret-brokers.js';
|
|
6
|
+
import { findSecretAliasServices } from './secret-alias-services.js';
|
|
7
|
+
import { relative } from 'node:path';
|
|
5
8
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
|
|
6
9
|
import { flattenScopeDefinitions } from '@pikku/core/scope';
|
|
7
10
|
import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js';
|
|
@@ -484,6 +487,9 @@ const serializeGroupMap = (groupMap) => {
|
|
|
484
487
|
count: meta.count,
|
|
485
488
|
instanceIds: meta.instanceIds,
|
|
486
489
|
isFactory: meta.isFactory,
|
|
490
|
+
...(meta.additionalRegistrations && {
|
|
491
|
+
additionalRegistrations: meta.additionalRegistrations,
|
|
492
|
+
}),
|
|
487
493
|
};
|
|
488
494
|
}
|
|
489
495
|
return result;
|
|
@@ -594,16 +600,21 @@ export function validateScenarioServices(logger, state) {
|
|
|
594
600
|
*
|
|
595
601
|
* - the step target must be a static string literal (PKU678) — otherwise it
|
|
596
602
|
* can't be bundled, typed or drawn;
|
|
597
|
-
* - a `browser
|
|
598
|
-
* context signs in as that persona.
|
|
603
|
+
* - a step declaring a `browser` binding must be given an actor (PKU677), since
|
|
604
|
+
* the browser context signs in as that persona. It is checked statically even
|
|
605
|
+
* though the binding only runs on a browser run: a step that can never be
|
|
606
|
+
* driven by a human is a latent failure, not a passing default run.
|
|
607
|
+
* - a scenario written as a step ladder must assert something (PKU680).
|
|
599
608
|
*
|
|
600
609
|
* It also marks the step's function as used, so `scenario run` boots the
|
|
601
610
|
* singletons the step actually needs.
|
|
602
611
|
*/
|
|
603
612
|
export function validateScenarioSteps(logger, state) {
|
|
613
|
+
const phasesSeen = new Set();
|
|
604
614
|
const visit = (workflowName, steps) => {
|
|
605
615
|
for (const step of steps) {
|
|
606
616
|
if (step.type === 'scenarioStep') {
|
|
617
|
+
phasesSeen.add(step.phase);
|
|
607
618
|
if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
|
|
608
619
|
logger.critical(ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC, `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
|
|
609
620
|
`A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
|
|
@@ -612,8 +623,9 @@ export function validateScenarioSteps(logger, state) {
|
|
|
612
623
|
}
|
|
613
624
|
state.serviceAggregation.usedFunctions.add(step.stepFunc);
|
|
614
625
|
const stepMeta = state.functions.meta[step.stepFunc];
|
|
615
|
-
if (stepMeta?.
|
|
616
|
-
|
|
626
|
+
if (stepMeta?.scenarioStepSurfaces?.includes('browser') &&
|
|
627
|
+
!step.actor) {
|
|
628
|
+
logger.critical(ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR, `Scenario '${workflowName}' calls step '${step.stepFunc}', which declares a browser binding, without an actor. ` +
|
|
617
629
|
`Pass { actor: actors.<name> } so the browser context signs in as that persona.`);
|
|
618
630
|
}
|
|
619
631
|
continue;
|
|
@@ -641,7 +653,17 @@ export function validateScenarioSteps(logger, state) {
|
|
|
641
653
|
}
|
|
642
654
|
};
|
|
643
655
|
for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
|
|
656
|
+
phasesSeen.clear();
|
|
644
657
|
visit(workflowName, meta.steps ?? []);
|
|
658
|
+
// A scenario written as a step ladder but with no `then` proves only that
|
|
659
|
+
// nothing threw. It is also invisible to witness coverage — it contributes
|
|
660
|
+
// 0/0, so it can never lower the number — which makes an assertion-free
|
|
661
|
+
// flow the cheapest way to make a suite look better covered than it is.
|
|
662
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then')) {
|
|
663
|
+
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
664
|
+
`Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
|
|
665
|
+
`only proves nothing threw, and contributes nothing to witness coverage.`);
|
|
666
|
+
}
|
|
645
667
|
}
|
|
646
668
|
}
|
|
647
669
|
/**
|
|
@@ -692,13 +714,18 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
692
714
|
for (const file of state.http.files) {
|
|
693
715
|
wiringFiles.add(file);
|
|
694
716
|
}
|
|
695
|
-
// Middleware wirings (addHTTPMiddleware calls)
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
717
|
+
// Middleware wirings (addHTTPMiddleware calls). A group can be registered
|
|
718
|
+
// from more than one file, and every one of them is a wiring file.
|
|
719
|
+
const addGroupFiles = (groups) => {
|
|
720
|
+
for (const meta of groups.values()) {
|
|
721
|
+
wiringFiles.add(meta.sourceFile);
|
|
722
|
+
for (const registration of meta.additionalRegistrations ?? []) {
|
|
723
|
+
wiringFiles.add(registration.sourceFile);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
addGroupFiles(state.http.routeMiddleware);
|
|
728
|
+
addGroupFiles(state.middleware.tagMiddleware);
|
|
702
729
|
// Check for overlap
|
|
703
730
|
for (const file of schemaFiles) {
|
|
704
731
|
if (wiringFiles.has(file)) {
|
|
@@ -711,6 +738,85 @@ export function validateSchemaWiringSeparation(logger, state) {
|
|
|
711
738
|
}
|
|
712
739
|
}
|
|
713
740
|
}
|
|
741
|
+
/** Marks the functions that keep the full `SecretService`. */
|
|
742
|
+
export function computeSecretBrokers(state) {
|
|
743
|
+
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
744
|
+
if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
|
|
745
|
+
meta.secretBroker = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Refuses a singleton service that is a `SecretService` under another name.
|
|
751
|
+
*
|
|
752
|
+
* `secrets` is omitted from every function-facing services type, but
|
|
753
|
+
* `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
|
|
754
|
+
* a name the omit does not cover. A name-based rule misses this; the type does
|
|
755
|
+
* not.
|
|
756
|
+
*/
|
|
757
|
+
export function validateNoSecretAliasServices(logger, checker, state) {
|
|
758
|
+
if (!('typesLookup' in state)) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const singletonServicesTypes = state.typesLookup.get('SingletonServices');
|
|
762
|
+
if (!singletonServicesTypes?.length) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const aliases = findSecretAliasServices(singletonServicesTypes[0], checker);
|
|
766
|
+
if (aliases.length === 0) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const report = (kind, id, services) => {
|
|
770
|
+
const used = services.filter((service) => aliases.includes(service));
|
|
771
|
+
if (used.length === 0) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
logger.critical(ErrorCode.SECRET_SERVICE_ALIASED, `${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(', ')}, which ${used.length === 1 ? 'is' : 'are'} a SecretService under another name. ` +
|
|
775
|
+
`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.`);
|
|
776
|
+
};
|
|
777
|
+
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
778
|
+
if (meta.secretBroker) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
report('Function', id, meta.services?.services ?? []);
|
|
782
|
+
}
|
|
783
|
+
for (const [id, def] of Object.entries(state.permissions.definitions)) {
|
|
784
|
+
report('Permission', id, def.services?.services ?? []);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Reports secret reads the catalogue does not account for.
|
|
789
|
+
*
|
|
790
|
+
* `getSecret('X')` with no matching `defineSecret` is a value that will be missing
|
|
791
|
+
* at first request rather than at deploy; a non-literal key is a read the
|
|
792
|
+
* manifest cannot cover, so a per-unit scope cannot be narrowed around it.
|
|
793
|
+
*/
|
|
794
|
+
export function validateSecretUsage(logger, state) {
|
|
795
|
+
const declared = new Set(state.secrets.definitions.map((d) => d.secretId));
|
|
796
|
+
for (const definition of state.secrets.definitions) {
|
|
797
|
+
if (definition.oauth2?.tokenSecretId) {
|
|
798
|
+
declared.add(definition.oauth2.tokenSecretId);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const [file, usage] of state.secrets.usage) {
|
|
802
|
+
const relativeFile = relative(state.rootDir, file);
|
|
803
|
+
const undeclared = usage.keys.filter((key) => !declared.has(key));
|
|
804
|
+
if (undeclared.length > 0) {
|
|
805
|
+
logger.diagnostic({
|
|
806
|
+
severity: 'warn',
|
|
807
|
+
code: ErrorCode.SECRET_NOT_DECLARED,
|
|
808
|
+
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.`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (usage.dynamic.length > 0) {
|
|
812
|
+
logger.diagnostic({
|
|
813
|
+
severity: 'warn',
|
|
814
|
+
code: ErrorCode.SECRET_KEY_NOT_STATIC,
|
|
815
|
+
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.`,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
714
820
|
export function computeDiagnostics(state) {
|
|
715
821
|
const diagnostics = [];
|
|
716
822
|
for (const [id, meta] of Object.entries(state.functions.meta)) {
|
|
@@ -787,7 +893,7 @@ export function computeDiagnostics(state) {
|
|
|
787
893
|
}
|
|
788
894
|
/**
|
|
789
895
|
* Validates that every scope referenced by a function is declared via
|
|
790
|
-
* `
|
|
896
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
791
897
|
*
|
|
792
898
|
* A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
|
|
793
899
|
* to be declared, and grants its whole subtree.
|
|
@@ -806,7 +912,131 @@ export function validateScopeReferences(logger, state) {
|
|
|
806
912
|
}
|
|
807
913
|
if (!declared.has(declaredForm)) {
|
|
808
914
|
const available = Array.from(declared);
|
|
809
|
-
logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with
|
|
915
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Resolves every `tools:` entry on every agent and reports the ones that do not
|
|
922
|
+
* land on a real function, or that land on one with nothing to tell the model.
|
|
923
|
+
*
|
|
924
|
+
* `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
|
|
925
|
+
* resolved, only unwrapped — so until now a reference to a function that never
|
|
926
|
+
* existed generated cleanly and failed at agent-run time. Addon namespaces come
|
|
927
|
+
* from `wireAddon`, and each addon's generated metadata is read off disk, which
|
|
928
|
+
* is the only view of it this project has.
|
|
929
|
+
*
|
|
930
|
+
* The description check is separate and opt-in (`--strict-meta`), because the
|
|
931
|
+
* description is what the model is told the tool does: without one, the tool is
|
|
932
|
+
* offered to it under its own name (`ai-agent-prepare.ts`), which is a silent
|
|
933
|
+
* quality loss rather than a failure. A title deliberately does not satisfy it —
|
|
934
|
+
* a title labels a tool in a UI, it does not tell a model when to reach for it.
|
|
935
|
+
*/
|
|
936
|
+
export function validateAgentToolReferences(logger, state, options = {}) {
|
|
937
|
+
const agents = Object.entries(state.agents.agentsMeta);
|
|
938
|
+
if (agents.length === 0)
|
|
939
|
+
return;
|
|
940
|
+
const { wireAddonDeclarations } = state.rpc;
|
|
941
|
+
for (const [agentKey, agent] of agents) {
|
|
942
|
+
const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
|
|
943
|
+
for (const tool of agent.tools ?? []) {
|
|
944
|
+
// A workflow reference is resolved against the workflow map, not here.
|
|
945
|
+
if (tool.startsWith('workflow:'))
|
|
946
|
+
continue;
|
|
947
|
+
const separator = tool.indexOf(':');
|
|
948
|
+
let meta;
|
|
949
|
+
if (separator === -1) {
|
|
950
|
+
const funcId = state.rpc.internalMeta[tool] ?? tool;
|
|
951
|
+
meta = state.functions.meta[funcId];
|
|
952
|
+
if (!meta) {
|
|
953
|
+
logger.critical(ErrorCode.AGENT_TOOL_NOT_FOUND, `AI agent '${agentKey}'${where} references tool '${tool}', which is not a function in this project.`);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
const namespace = tool.slice(0, separator);
|
|
959
|
+
const funcName = tool.slice(separator + 1);
|
|
960
|
+
const addon = wireAddonDeclarations?.get(namespace);
|
|
961
|
+
if (!addon) {
|
|
962
|
+
const known = Array.from(wireAddonDeclarations?.keys() ?? []);
|
|
963
|
+
logger.critical(ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE, `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
|
|
964
|
+
`Wired namespaces: ${known.join(', ') || 'none'}.`);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
// An addon that has not been built yet contributed no metadata, which
|
|
968
|
+
// is not the same as one whose function is missing.
|
|
969
|
+
const addonMeta = state.addonFunctions[namespace];
|
|
970
|
+
if (!addonMeta)
|
|
971
|
+
continue;
|
|
972
|
+
meta = addonMeta[funcName];
|
|
973
|
+
if (!meta) {
|
|
974
|
+
logger.critical(ErrorCode.AGENT_TOOL_NOT_FOUND, `AI agent '${agentKey}'${where} references tool '${tool}', but addon '${namespace}' ('${addon.package}') exposes no function '${funcName}'.`);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (options.strictMeta && !meta.description) {
|
|
979
|
+
logger.critical(ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION, `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
|
|
980
|
+
`An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
|
|
981
|
+
`Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Validates that every scope granted by a system role is declared via
|
|
988
|
+
* `defineScope`. Runs after all visitors, so declaration order does not matter.
|
|
989
|
+
*
|
|
990
|
+
* The same rule functions get, for a sharper reason: a role granting an
|
|
991
|
+
* undeclared scope is not merely useless, it is *invisibly* useless. No
|
|
992
|
+
* function checks the scope, so the role appears to work — every call a
|
|
993
|
+
* persona holding it makes succeeds or fails for unrelated reasons, and the
|
|
994
|
+
* authorization boundary it was written to describe is never exercised.
|
|
995
|
+
*
|
|
996
|
+
* A `*` suffix grants a subtree; the node it hangs off must exist.
|
|
997
|
+
*/
|
|
998
|
+
export function validateSystemRoleScopes(logger, state) {
|
|
999
|
+
if (!state.systemRoles.definitions.length) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const declared = new Set(flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id));
|
|
1003
|
+
for (const role of state.systemRoles.definitions) {
|
|
1004
|
+
for (const scope of role.scopes) {
|
|
1005
|
+
if (scope === '*') {
|
|
1006
|
+
logger.critical(ErrorCode.INVALID_VALUE, `System role '${role.name}' grants the bare wildcard scope '*'. Grant the roots it should cover instead — '*' hides what the role actually confers.`);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
|
|
1010
|
+
if (!declared.has(declaredForm)) {
|
|
1011
|
+
const available = Array.from(declared);
|
|
1012
|
+
logger.critical(ErrorCode.INVALID_VALUE, `System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Validates that every role a persona names is declared with
|
|
1019
|
+
* `defineSystemRole`. Runs after all visitors, so declaration order does not
|
|
1020
|
+
* matter.
|
|
1021
|
+
*
|
|
1022
|
+
* Custom roles are refused rather than tolerated. A role composed in the
|
|
1023
|
+
* console does not exist at build time and can be deleted after it, so a
|
|
1024
|
+
* persona pinned to one silently stops testing what it claims to — and the
|
|
1025
|
+
* symptom is a scatter of 403s that read like authorization findings rather
|
|
1026
|
+
* than like a deleted role.
|
|
1027
|
+
*/
|
|
1028
|
+
export function validatePersonaRoles(logger, state) {
|
|
1029
|
+
if (!state.personas.definitions.length) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const declared = new Set(state.systemRoles.definitions.map((r) => r.name));
|
|
1033
|
+
for (const persona of state.personas.definitions) {
|
|
1034
|
+
for (const role of persona.roles) {
|
|
1035
|
+
if (!declared.has(role)) {
|
|
1036
|
+
const available = Array.from(declared).sort();
|
|
1037
|
+
logger.critical(ErrorCode.INVALID_VALUE, `Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
|
|
1038
|
+
`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. ` +
|
|
1039
|
+
`Declared roles: ${available.join(', ') || 'none'}`);
|
|
810
1040
|
}
|
|
811
1041
|
}
|
|
812
1042
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type ts from 'typescript';
|
|
2
|
+
/** Whether a type structurally satisfies `SecretService`. */
|
|
3
|
+
export declare const isSecretServiceType: (type: ts.Type, checker: ts.TypeChecker) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Singleton service names, other than `secrets`, whose type is a
|
|
6
|
+
* `SecretService`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const findSecretAliasServices: (singletonServicesType: ts.Type, checker: ts.TypeChecker) => string[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SECRET_SERVICE_METHODS = [
|
|
2
|
+
'getSecret',
|
|
3
|
+
'hasSecret',
|
|
4
|
+
'setSecret',
|
|
5
|
+
'deleteSecret',
|
|
6
|
+
'getSecrets',
|
|
7
|
+
];
|
|
8
|
+
/** Whether a type structurally satisfies `SecretService`. */
|
|
9
|
+
export const isSecretServiceType = (type, checker) => {
|
|
10
|
+
const nonNullable = checker.getNonNullableType(type);
|
|
11
|
+
return SECRET_SERVICE_METHODS.every((method) => {
|
|
12
|
+
const property = nonNullable.getProperty(method);
|
|
13
|
+
if (!property) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0];
|
|
17
|
+
if (!declaration) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(property, declaration);
|
|
21
|
+
return propertyType.getCallSignatures().length > 0;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Singleton service names, other than `secrets`, whose type is a
|
|
26
|
+
* `SecretService`.
|
|
27
|
+
*/
|
|
28
|
+
export const findSecretAliasServices = (singletonServicesType, checker) => {
|
|
29
|
+
const aliases = [];
|
|
30
|
+
for (const property of singletonServicesType.getProperties()) {
|
|
31
|
+
const name = property.getName();
|
|
32
|
+
if (name === 'secrets') {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const declaration = property.valueDeclaration ?? property.declarations?.[0];
|
|
36
|
+
if (!declaration) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const type = checker.getTypeOfSymbolAtLocation(property, declaration);
|
|
40
|
+
if (isSecretServiceType(type, checker)) {
|
|
41
|
+
aliases.push(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return aliases.sort();
|
|
45
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The console's secret administration functions, and nothing else. */
|
|
2
|
+
const SECRET_BROKER_FUNCTIONS = new Set([
|
|
3
|
+
'pikkuConsoleSetSecret',
|
|
4
|
+
'pikkuConsoleGetSecret',
|
|
5
|
+
'pikkuConsoleHasSecret',
|
|
6
|
+
]);
|
|
7
|
+
/** Whether this function keeps the full `SecretService`. */
|
|
8
|
+
export const isSecretBrokerFunction = (functionId) => SECRET_BROKER_FUNCTIONS.has(functionId) ||
|
|
9
|
+
SECRET_BROKER_FUNCTIONS.has(functionId.split(':').pop() ?? '');
|