@pikku/inspector 0.12.50 → 0.12.54
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 +743 -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 +199 -8
- 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 +216 -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/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -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/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -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 +246 -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/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -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/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- 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 +273 -11
- 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 +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -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 +297 -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/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -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/check-secret-sinks.ts +145 -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 +313 -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/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -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/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
import { extractSecretUsage } from './extract-secret-usage.js'
|
|
5
|
+
|
|
6
|
+
const usageOf = (source: string) =>
|
|
7
|
+
extractSecretUsage(
|
|
8
|
+
ts.createSourceFile('/services.ts', source, ts.ScriptTarget.ESNext, true)
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
describe('extractSecretUsage', () => {
|
|
12
|
+
test('collects a literal getSecret key', () => {
|
|
13
|
+
const usage = usageOf(`await secrets.getSecret('STRIPE_KEY')`)
|
|
14
|
+
assert.deepEqual(usage.keys, ['STRIPE_KEY'])
|
|
15
|
+
assert.deepEqual(usage.dynamic, [])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('collects hasSecret and template literals with no substitutions', () => {
|
|
19
|
+
const usage = usageOf(
|
|
20
|
+
'await secrets.hasSecret(`NOTION_TOKEN`)\nawait secrets.getSecret("A")'
|
|
21
|
+
)
|
|
22
|
+
assert.deepEqual(usage.keys, ['A', 'NOTION_TOKEN'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('collects every key of a getSecrets array', () => {
|
|
26
|
+
const usage = usageOf(`await secrets.getSecrets(['B', 'A'])`)
|
|
27
|
+
assert.deepEqual(usage.keys, ['A', 'B'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('flags a computed key as dynamic', () => {
|
|
31
|
+
const usage = usageOf(`await secrets.getSecret(name)`)
|
|
32
|
+
assert.deepEqual(usage.keys, [])
|
|
33
|
+
assert.equal(usage.dynamic.length, 1)
|
|
34
|
+
assert.match(usage.dynamic[0]!, /^1:name$/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('flags a template literal with a substitution as dynamic', () => {
|
|
38
|
+
const usage = usageOf('await secrets.getSecret(`PREFIX_${suffix}`)')
|
|
39
|
+
assert.deepEqual(usage.keys, [])
|
|
40
|
+
assert.equal(usage.dynamic.length, 1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('flags a non-array getSecrets argument as dynamic', () => {
|
|
44
|
+
const usage = usageOf(`await secrets.getSecrets(keys)`)
|
|
45
|
+
assert.equal(usage.dynamic.length, 1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('records both the literal and the dynamic keys of one call', () => {
|
|
49
|
+
const usage = usageOf(`await secrets.getSecrets(['A', other])`)
|
|
50
|
+
assert.deepEqual(usage.keys, ['A'])
|
|
51
|
+
assert.equal(usage.dynamic.length, 1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('finds reads nested inside a factory body', () => {
|
|
55
|
+
const usage = usageOf(`
|
|
56
|
+
export const createSingletonServices = async ({ secrets }) => {
|
|
57
|
+
const key = await secrets.getSecret('DEEP_KEY')
|
|
58
|
+
return { thing: new Thing(key) }
|
|
59
|
+
}
|
|
60
|
+
`)
|
|
61
|
+
assert.deepEqual(usage.keys, ['DEEP_KEY'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('deduplicates a key read twice', () => {
|
|
65
|
+
const usage = usageOf(
|
|
66
|
+
`await secrets.getSecret('A'); await secrets.getSecret('A')`
|
|
67
|
+
)
|
|
68
|
+
assert.deepEqual(usage.keys, ['A'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('ignores unrelated calls', () => {
|
|
72
|
+
const usage = usageOf(`await variables.get('HOME'); logger.info('hi')`)
|
|
73
|
+
assert.deepEqual(usage.keys, [])
|
|
74
|
+
assert.deepEqual(usage.dynamic, [])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('reports the line a dynamic read is on', () => {
|
|
78
|
+
const usage = usageOf(`const a = 1\nconst b = 2\nsecrets.getSecret(name)`)
|
|
79
|
+
assert.match(usage.dynamic[0]!, /^3:/)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
export type SecretUsage = {
|
|
4
|
+
/** Secret ids read with a string literal, so statically known. */
|
|
5
|
+
keys: string[]
|
|
6
|
+
/** Reads whose key could not be resolved statically, as `line:source`. */
|
|
7
|
+
dynamic: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SINGLE_KEY_METHODS = new Set(['getSecret', 'hasSecret'])
|
|
11
|
+
const KEY_LIST_METHODS = new Set(['getSecrets'])
|
|
12
|
+
|
|
13
|
+
const literalKey = (node: ts.Expression): string | undefined => {
|
|
14
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
15
|
+
return node.text
|
|
16
|
+
}
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Secret reads in a file, whether the key is statically known or not. */
|
|
21
|
+
export const extractSecretUsage = (sourceFile: ts.SourceFile): SecretUsage => {
|
|
22
|
+
const keys = new Set<string>()
|
|
23
|
+
const dynamic = new Set<string>()
|
|
24
|
+
|
|
25
|
+
const record = (node: ts.CallExpression, argument?: ts.Expression) => {
|
|
26
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
|
|
27
|
+
dynamic.add(`${line + 1}:${(argument ?? node).getText()}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const visit = (node: ts.Node): void => {
|
|
31
|
+
if (
|
|
32
|
+
ts.isCallExpression(node) &&
|
|
33
|
+
ts.isPropertyAccessExpression(node.expression)
|
|
34
|
+
) {
|
|
35
|
+
const method = node.expression.name.text
|
|
36
|
+
const [firstArgument] = node.arguments
|
|
37
|
+
|
|
38
|
+
if (SINGLE_KEY_METHODS.has(method)) {
|
|
39
|
+
if (!firstArgument) {
|
|
40
|
+
record(node)
|
|
41
|
+
} else {
|
|
42
|
+
const key = literalKey(firstArgument)
|
|
43
|
+
if (key === undefined) {
|
|
44
|
+
record(node, firstArgument)
|
|
45
|
+
} else {
|
|
46
|
+
keys.add(key)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else if (KEY_LIST_METHODS.has(method)) {
|
|
50
|
+
if (firstArgument && ts.isArrayLiteralExpression(firstArgument)) {
|
|
51
|
+
for (const element of firstArgument.elements) {
|
|
52
|
+
const key = literalKey(element)
|
|
53
|
+
if (key === undefined) {
|
|
54
|
+
record(node, element)
|
|
55
|
+
} else {
|
|
56
|
+
keys.add(key)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
record(node, firstArgument)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
ts.forEachChild(node, visit)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
visit(sourceFile)
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
keys: [...keys].sort(),
|
|
71
|
+
dynamic: [...dynamic].sort(),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Readers for object-literal properties that must be statically knowable.
|
|
5
|
+
*
|
|
6
|
+
* Declarations extracted this way are never evaluated — the CLI reads them from
|
|
7
|
+
* source and writes them to JSON that a deployed stage carries without the app.
|
|
8
|
+
* So a computed value is not "unsupported", it is unreadable, and every reader
|
|
9
|
+
* here returns `undefined` for one rather than guessing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const getProperty = (
|
|
13
|
+
config: ts.ObjectLiteralExpression,
|
|
14
|
+
name: string
|
|
15
|
+
): ts.Expression | undefined => {
|
|
16
|
+
const property = config.properties.find(
|
|
17
|
+
(p) =>
|
|
18
|
+
ts.isPropertyAssignment(p) &&
|
|
19
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
20
|
+
p.name.text === name
|
|
21
|
+
)
|
|
22
|
+
return property && ts.isPropertyAssignment(property)
|
|
23
|
+
? property.initializer
|
|
24
|
+
: undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const stringProperty = (
|
|
28
|
+
config: ts.ObjectLiteralExpression,
|
|
29
|
+
name: string
|
|
30
|
+
): string | undefined => {
|
|
31
|
+
const value = getProperty(config, name)
|
|
32
|
+
return value && ts.isStringLiteralLike(value) ? value.text : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const numberProperty = (
|
|
36
|
+
config: ts.ObjectLiteralExpression,
|
|
37
|
+
name: string
|
|
38
|
+
): number | undefined => {
|
|
39
|
+
const value = getProperty(config, name)
|
|
40
|
+
if (!value) {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
// `-1` parses as a unary minus over a literal, not as a literal. Reading it
|
|
44
|
+
// rather than ignoring it is what lets a negative be *rejected* by the caller
|
|
45
|
+
// instead of silently dropped and defaulted.
|
|
46
|
+
if (
|
|
47
|
+
ts.isPrefixUnaryExpression(value) &&
|
|
48
|
+
value.operator === ts.SyntaxKind.MinusToken &&
|
|
49
|
+
ts.isNumericLiteral(value.operand)
|
|
50
|
+
) {
|
|
51
|
+
return -Number(value.operand.text)
|
|
52
|
+
}
|
|
53
|
+
return ts.isNumericLiteral(value) ? Number(value.text) : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const booleanProperty = (
|
|
57
|
+
config: ts.ObjectLiteralExpression,
|
|
58
|
+
name: string
|
|
59
|
+
): boolean | undefined => {
|
|
60
|
+
const value = getProperty(config, name)
|
|
61
|
+
if (!value) {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
if (value.kind === ts.SyntaxKind.FalseKeyword) {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* All-or-nothing: an array with one computed entry reads as unreadable rather
|
|
75
|
+
* than as the entries that happened to be literals. A half-read `roles` or
|
|
76
|
+
* `scopes` array is worse than none — it typechecks, runs, and grants less
|
|
77
|
+
* than the source says.
|
|
78
|
+
*/
|
|
79
|
+
export const stringArrayProperty = (
|
|
80
|
+
config: ts.ObjectLiteralExpression,
|
|
81
|
+
name: string
|
|
82
|
+
): string[] | undefined => {
|
|
83
|
+
const value = getProperty(config, name)
|
|
84
|
+
if (!value || !ts.isArrayLiteralExpression(value)) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
const values = value.elements
|
|
88
|
+
.filter(ts.isStringLiteralLike)
|
|
89
|
+
.map((e) => e.text)
|
|
90
|
+
return values.length === value.elements.length ? values : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Unwraps `x as const` / `x satisfies T` so a cast is still extracted. */
|
|
94
|
+
export const unwrapAs = (node: ts.Expression): ts.Expression =>
|
|
95
|
+
ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
|
|
96
|
+
? unwrapAs(node.expression)
|
|
97
|
+
: node
|
|
@@ -118,6 +118,29 @@ const applyPackageToChannelContracts = (
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Resolves an addon's generated metadata, preferring the verbose copy.
|
|
123
|
+
*
|
|
124
|
+
* An addon publishes both: the minimal one its own bootstrap imports, and the
|
|
125
|
+
* verbose one for anything that needs the fields stripped out of it. A consumer
|
|
126
|
+
* is squarely in the second category — it is reading the addon to describe it,
|
|
127
|
+
* not to run it.
|
|
128
|
+
*/
|
|
129
|
+
const resolveAddonMeta = (
|
|
130
|
+
require: NodeRequire,
|
|
131
|
+
packageName: string,
|
|
132
|
+
baseName: string
|
|
133
|
+
): string | null => {
|
|
134
|
+
for (const suffix of ['-verbose.gen.json', '.gen.json']) {
|
|
135
|
+
try {
|
|
136
|
+
return require.resolve(`${packageName}/.pikku/${baseName}${suffix}`)
|
|
137
|
+
} catch {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
/**
|
|
122
145
|
* After the setup sweep discovers wireAddon() declarations, load each addon
|
|
123
146
|
* package's function metadata so that wiring handlers (channels, HTTP routes,
|
|
@@ -138,9 +161,16 @@ export async function loadAddonFunctionsMeta(
|
|
|
138
161
|
// HOST that runs them — never load or require any of that here.
|
|
139
162
|
if (decl.remote) continue
|
|
140
163
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
// Verbose first. `description` is one of the fields stripped from the
|
|
165
|
+
// minimal copy, and it is what an addon's function is offered to a model
|
|
166
|
+
// under — both as an MCP tool below and as an agent tool. Read the minimal
|
|
167
|
+
// one and every addon function looks undescribed.
|
|
168
|
+
const metaPath = resolveAddonMeta(
|
|
169
|
+
require,
|
|
170
|
+
decl.package,
|
|
171
|
+
'function/pikku-functions-meta'
|
|
143
172
|
)
|
|
173
|
+
if (!metaPath) throw new Error('no function metadata')
|
|
144
174
|
const raw = await readFile(metaPath, 'utf-8')
|
|
145
175
|
const meta = JSON.parse(raw)
|
|
146
176
|
state.addonFunctions[namespace] = meta
|
|
@@ -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
|
+
})
|