@pikku/inspector 0.12.52 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +302 -0
  2. package/dist/add/add-functions.js +46 -4
  3. package/dist/add/add-personas.js +7 -1
  4. package/dist/add/add-workflow.js +12 -1
  5. package/dist/error-codes.d.ts +8 -1
  6. package/dist/error-codes.js +12 -0
  7. package/dist/types.d.ts +9 -0
  8. package/dist/utils/check-secret-sinks.d.ts +31 -0
  9. package/dist/utils/check-secret-sinks.js +117 -0
  10. package/dist/utils/post-process.js +4 -1
  11. package/dist/utils/single-declaration.d.ts +32 -0
  12. package/dist/utils/single-declaration.js +43 -0
  13. package/dist/utils/validate-exposed-functions-gated.js +15 -7
  14. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  15. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  16. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  17. package/dist/utils/workflow/dsl/patterns.js +26 -4
  18. package/package.json +3 -3
  19. package/src/add/add-functions.ts +57 -5
  20. package/src/add/add-personas.test.ts +115 -1
  21. package/src/add/add-personas.ts +18 -9
  22. package/src/add/add-scenario-step.test.ts +38 -2
  23. package/src/add/add-scope.test.ts +73 -0
  24. package/src/add/add-system-role.test.ts +83 -0
  25. package/src/add/add-workflow.ts +21 -1
  26. package/src/add/allow-escape-hatches.test.ts +144 -0
  27. package/src/add/audit-meta.test.ts +75 -0
  28. package/src/add/secret-sink-check.test.ts +211 -0
  29. package/src/error-codes.ts +13 -0
  30. package/src/types.ts +9 -0
  31. package/src/utils/check-secret-sinks.ts +145 -0
  32. package/src/utils/post-process.ts +4 -1
  33. package/src/utils/single-declaration.ts +57 -0
  34. package/src/utils/validate-exposed-functions-gated.test.ts +2 -2
  35. package/src/utils/validate-exposed-functions-gated.ts +16 -7
  36. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  37. package/src/utils/workflow/dsl/patterns.ts +33 -4
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,117 @@
1
+ import * as ts from 'typescript';
2
+ import { findPiiPaths } from './check-pii-output.js';
3
+ import { doesTypeExtendsCore } from './does-type-extend-core-type.js';
4
+ /**
5
+ * A method that writes its arguments somewhere a secret must never land, keyed
6
+ * by the core interface that owns it. `'*'` means every method on the type is a
7
+ * sink, which is true of a logger.
8
+ */
9
+ const SINKS = [
10
+ { coreType: 'Logger', methods: '*' },
11
+ { coreType: 'QueueService', methods: ['add'] },
12
+ { coreType: 'EmailService', methods: ['send'] },
13
+ { coreType: 'WebhookService', methods: ['send'] },
14
+ { coreType: 'AuditService', methods: ['audit', 'write'] },
15
+ { coreType: 'AuditLog', methods: ['write'] },
16
+ ];
17
+ /**
18
+ * Finds revealed vault secrets that flow into a sink.
19
+ *
20
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
21
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
22
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
23
+ * follows, which means it catches the revealed value both inline
24
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
25
+ * inferred type carries the brand too.
26
+ *
27
+ * The brand is optional by design, so an explicit annotation (`const t: string =
28
+ * s.reveal()`) erases it and this scan goes quiet. That is the documented limit
29
+ * of a structural marker: it catches the mistake, not someone determined to
30
+ * silence it.
31
+ *
32
+ * Sinks are matched on the receiver's resolved core interface, which means the
33
+ * scan needs the services parameter to be typed. Real projects get that from
34
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
35
+ * factory without annotating its services leaves the parameter `any`, and an
36
+ * `any` receiver matches nothing.
37
+ */
38
+ export function findRevealedSecretSinks(checker, handler) {
39
+ const violations = [];
40
+ const visit = (node) => {
41
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
42
+ const sink = describeSink(checker, node.expression);
43
+ if (sink) {
44
+ for (const arg of node.arguments) {
45
+ const found = findSecretExpression(checker, arg);
46
+ if (found) {
47
+ violations.push({
48
+ sink,
49
+ path: found.getText(),
50
+ line: found
51
+ .getSourceFile()
52
+ .getLineAndCharacterOfPosition(found.getStart()).line + 1,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ts.forEachChild(node, visit);
59
+ };
60
+ visit(handler);
61
+ return violations;
62
+ }
63
+ /**
64
+ * Finds the first expression inside a sink argument whose own type carries the
65
+ * secret brand, and returns it so the diagnostic can quote it.
66
+ *
67
+ * This walks the argument rather than typing it whole, because an object
68
+ * literal in an argument position is contextually typed by the parameter — for
69
+ * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
70
+ * it drops the optional brand before it can be read. Each inner expression
71
+ * (`token.reveal()`, or a reference to a local holding it) carries its own type
72
+ * regardless of the surrounding context, so reading those is what actually
73
+ * sees the secret.
74
+ */
75
+ function findSecretExpression(checker, node) {
76
+ // Descend first, so an object literal wrapping the secret reports the
77
+ // expression that actually holds it (`token.reveal()`) rather than the whole
78
+ // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
79
+ // bare `token.reveal()` still falls through to the self-check below.
80
+ for (const child of node.getChildren()) {
81
+ // A property name is not a value, and typing it re-resolves the property.
82
+ if (ts.isPropertyAssignment(node) && child === node.name)
83
+ continue;
84
+ const found = findSecretExpression(checker, child);
85
+ if (found)
86
+ return found;
87
+ }
88
+ if (ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
89
+ const type = checker.getTypeAtLocation(node);
90
+ const isSecret = findPiiPaths(checker, type).some((f) => f.classification === 'secret');
91
+ if (isSecret)
92
+ return node;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Names the sink a call writes to, or undefined when the call is not one.
98
+ * `console` is matched by identifier because it is a global with no Pikku type;
99
+ * everything else is matched on its resolved core interface so a renamed
100
+ * service or a decorator still counts.
101
+ */
102
+ function describeSink(checker, callee) {
103
+ const method = callee.name.text;
104
+ const receiver = callee.expression;
105
+ if (ts.isIdentifier(receiver) && receiver.text === 'console') {
106
+ return `console.${method}`;
107
+ }
108
+ const receiverType = checker.getTypeAtLocation(receiver);
109
+ for (const { coreType, methods } of SINKS) {
110
+ if (methods !== '*' && !methods.includes(method))
111
+ continue;
112
+ if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
113
+ return `${receiver.getText()}.${method}`;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
@@ -659,7 +659,10 @@ export function validateScenarioSteps(logger, state) {
659
659
  // nothing threw. It is also invisible to witness coverage — it contributes
660
660
  // 0/0, so it can never lower the number — which makes an assertion-free
661
661
  // flow the cheapest way to make a suite look better covered than it is.
662
- if (phasesSeen.size > 0 && !phasesSeen.has('then')) {
662
+ // `asserts` is the escape for a flow whose witness is an expectation helper
663
+ // instead: those are inline steps and leave no phase behind, but a recorded
664
+ // service call or a refusal is an assertion all the same.
665
+ if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
663
666
  logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
664
667
  `Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
665
668
  `only proves nothing threw, and contributes nothing to witness coverage.`);
@@ -0,0 +1,32 @@
1
+ import type { ErrorCode } from '../error-codes.js';
2
+ import type { InspectorLogger } from '../types.js';
3
+ /**
4
+ * Claims the one call site a single-declaration construct is allowed.
5
+ *
6
+ * `definePersonas` takes a keyed object, so one call already declares as many
7
+ * entries as you like. Letting the call itself repeat buys nothing and costs
8
+ * the thing that matters: a single place to read the declared set from, and a
9
+ * single place to add to it. The same rule `pikkuBetterAuth` has had all along.
10
+ *
11
+ * A second call in the SAME file is refused too — "the file" is not an answer
12
+ * to "where do I add a persona?" when the file holds two calls, so allowing it
13
+ * would keep the ambiguity the rule exists to remove.
14
+ *
15
+ * A generated file is exempt, and never claims the slot either. The rule exists
16
+ * to name the one place a person adds to, and nobody adds to a file the CLI
17
+ * rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
18
+ * project that scaffolds one unbuildable, and take the app's own declaration
19
+ * down with it.
20
+ *
21
+ * `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
22
+ * until the `admin` tree stops being app-declared. The exemption above is not
23
+ * enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
24
+ * `@pikku/addon-console` spells the same tree out again in a hand-written file,
25
+ * so two non-generated declarations remain and the build still fails. The fix
26
+ * is for `admin` to be a default scope nobody declares, and the rule comes back
27
+ * for scopes and roles once that lands.
28
+ *
29
+ * @param files - The construct's `files` set, which doubles as the claim.
30
+ * @returns true when the caller holds the claim and should carry on.
31
+ */
32
+ export declare const claimSingleDeclaration: (logger: InspectorLogger, files: Set<string>, code: ErrorCode, helper: string, sourceFile: string) => boolean;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Claims the one call site a single-declaration construct is allowed.
3
+ *
4
+ * `definePersonas` takes a keyed object, so one call already declares as many
5
+ * entries as you like. Letting the call itself repeat buys nothing and costs
6
+ * the thing that matters: a single place to read the declared set from, and a
7
+ * single place to add to it. The same rule `pikkuBetterAuth` has had all along.
8
+ *
9
+ * A second call in the SAME file is refused too — "the file" is not an answer
10
+ * to "where do I add a persona?" when the file holds two calls, so allowing it
11
+ * would keep the ambiguity the rule exists to remove.
12
+ *
13
+ * A generated file is exempt, and never claims the slot either. The rule exists
14
+ * to name the one place a person adds to, and nobody adds to a file the CLI
15
+ * rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
16
+ * project that scaffolds one unbuildable, and take the app's own declaration
17
+ * down with it.
18
+ *
19
+ * `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
20
+ * until the `admin` tree stops being app-declared. The exemption above is not
21
+ * enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
22
+ * `@pikku/addon-console` spells the same tree out again in a hand-written file,
23
+ * so two non-generated declarations remain and the build still fails. The fix
24
+ * is for `admin` to be a default scope nobody declares, and the rule comes back
25
+ * for scopes and roles once that lands.
26
+ *
27
+ * @param files - The construct's `files` set, which doubles as the claim.
28
+ * @returns true when the caller holds the claim and should carry on.
29
+ */
30
+ export const claimSingleDeclaration = (logger, files, code, helper, sourceFile) => {
31
+ if (sourceFile.endsWith('.gen.ts')) {
32
+ return true;
33
+ }
34
+ const [first] = files;
35
+ if (first === undefined) {
36
+ files.add(sourceFile);
37
+ return true;
38
+ }
39
+ logger.critical(code, first === sourceFile
40
+ ? `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second call in ${sourceFile}. Declare them all in one call.`
41
+ : `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second in ${sourceFile} (first: ${first}). Declare them all in one call.`);
42
+ return false;
43
+ };
@@ -26,7 +26,7 @@ export function validateExposedFunctionsGated(logger, state) {
26
26
  // this check has no way to see. Taking the claim at face value is the
27
27
  // point: without it the warning fires forever on functions that are fine,
28
28
  // and a warning that is usually wrong stops being read.
29
- if (meta.selfAuthenticated === true)
29
+ if (meta.permissionsInBody === true)
30
30
  continue;
31
31
  // The function gates itself.
32
32
  if (meta.auth === true)
@@ -43,17 +43,25 @@ export function validateExposedFunctionsGated(logger, state) {
43
43
  return;
44
44
  const shown = ungated.slice(0, 10);
45
45
  const rest = ungated.length - shown.length;
46
+ const one = ungated.length === 1;
47
+ // Every function here is sessionless — that is the population, not the
48
+ // finding, so the message names it as given rather than reporting it as a
49
+ // defect. The remedies are the ones that can actually apply to an anonymous
50
+ // caller: `scopes` cannot, because `verifyScopes` fails closed on a session
51
+ // that does not exist, and telling someone to add them sends them to a
52
+ // MissingScopeError on every public request.
46
53
  logger.diagnostic({
47
54
  severity: 'warn',
48
55
  code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
49
- message: `${ungated.length} exposed function${ungated.length === 1 ? '' : 's'} ` +
50
- `require neither a session nor any permission or scope, and ` +
51
- `${ungated.length === 1 ? 'is' : 'are'} reachable by anyone through ` +
56
+ message: `${ungated.length} exposed sessionless function${one ? '' : 's'} ` +
57
+ `carr${one ? 'ies' : 'y'} no permission and no addon gate, so ` +
58
+ `${one ? 'it is' : 'they are'} reachable by anyone through ` +
52
59
  `POST /rpc/:rpcName: ${shown.join(', ')}` +
53
60
  (rest > 0 ? `, and ${rest} more` : '') +
54
- `. Add scopes or permissions to the function, gate the whole addon with ` +
55
- `wireAddon({ scopes: [...] }), or drop \`expose: true\` if it was not ` +
56
- `meant to be callable from outside.`,
61
+ `. Add permissions to the function, set \`auth: true\` if a session is ` +
62
+ `required after all, gate the whole addon with ` +
63
+ `wireAddon({ auth: true }), or drop \`expose: true\` if ` +
64
+ `${one ? 'it was' : 'they were'} not meant to be callable from outside.`,
57
65
  });
58
66
  }
59
67
  /**
@@ -8,6 +8,12 @@ export interface ExtractionResult {
8
8
  steps?: WorkflowStepMeta[];
9
9
  /** Workflow context (top-level variables) */
10
10
  context?: WorkflowContext;
11
+ /**
12
+ * The flow asserts something through an expectation helper rather than a
13
+ * `then` step. Recorded separately because those helpers are inline steps and
14
+ * leave no phase behind, so PKU680 has no other way to see them.
15
+ */
16
+ asserts?: boolean;
11
17
  reason?: string;
12
18
  }
13
19
  /**
@@ -1,7 +1,53 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
+ /**
6
+ * A function the walk must not step into, because nothing here says it runs.
7
+ *
8
+ * A callback handed straight to a call — a fanout's `map`, an inline
9
+ * `workflow.do` step — executes as part of the body and counts. A function that
10
+ * is merely *declared* or assigned to a name does not: an assertion parked in an
11
+ * unused helper asserts nothing, and counting it would let a scenario silence
12
+ * PKU680 with dead code.
13
+ */
14
+ function isUncalledFunction(node) {
15
+ if (!ts.isFunctionDeclaration(node) &&
16
+ !ts.isFunctionExpression(node) &&
17
+ !ts.isArrowFunction(node)) {
18
+ return false;
19
+ }
20
+ if (ts.isFunctionDeclaration(node))
21
+ return true;
22
+ const parent = node.parent;
23
+ return !(parent &&
24
+ ts.isCallExpression(parent) &&
25
+ parent.arguments.some((argument) => argument === node));
26
+ }
27
+ /**
28
+ * Whether the body calls one of the expectation helpers anywhere it runs.
29
+ *
30
+ * A whole-body walk rather than a check inside step extraction: the helpers
31
+ * produce no step of their own, so there is no extraction result to hang the
32
+ * answer off, and one nested inside a branch asserts just as much as one at the
33
+ * top level.
34
+ */
35
+ function hasScenarioExpectation(body) {
36
+ let found = false;
37
+ const visit = (node) => {
38
+ if (found)
39
+ return;
40
+ if (ts.isCallExpression(node) && isScenarioExpectationCall(node)) {
41
+ found = true;
42
+ return;
43
+ }
44
+ if (node !== body && isUncalledFunction(node))
45
+ return;
46
+ ts.forEachChild(node, visit);
47
+ };
48
+ visit(body);
49
+ return found;
50
+ }
5
51
  /**
6
52
  * Extract DSL workflow metadata from a function declaration
7
53
  */
@@ -74,6 +120,7 @@ export function extractDSLWorkflow(funcNode, checker, options) {
74
120
  status: 'ok',
75
121
  steps,
76
122
  context: Object.keys(workflowContext).length > 0 ? workflowContext : undefined,
123
+ asserts: hasScenarioExpectation(arrowFunc.body) || undefined,
77
124
  };
78
125
  }
79
126
  catch (error) {
@@ -20,12 +20,12 @@ export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.Ty
20
20
  * hit repeatedly on workflow graph round-trips.
21
21
  */
22
22
  export declare const DYNAMIC_SCENARIO_STEP_TARGET = "<dynamic>";
23
- declare const SCENARIO_STEP_PHASES: readonly ["step", "given", "when", "then"];
23
+ declare const SCENARIO_STEP_PHASES: readonly ["given", "when", "then"];
24
24
  export type ScenarioStepPhaseName = (typeof SCENARIO_STEP_PHASES)[number];
25
25
  /**
26
- * Check if a call expression is scenario.step()/given()/when()/then() — a
27
- * declared `pikkuScenarioStep` invoked by name. `given`/`when`/`then` are pure
28
- * sugar over `step`: the phase only changes the prose a reporter renders.
26
+ * Check if a call expression is scenario.given()/when()/then() — a declared
27
+ * `pikkuScenarioStep` invoked by name. `given`/`when` are pure sugar for each
28
+ * other; `then` additionally makes the step's bindings witnesses.
29
29
  */
30
30
  export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
31
31
  /**
@@ -33,6 +33,11 @@ export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
33
33
  * isn't one.
34
34
  */
35
35
  export declare function getScenarioStepPhase(node: ts.CallExpression): ScenarioStepPhaseName | undefined;
36
+ /**
37
+ * Check if a call expression is one of the scenario expectation helpers —
38
+ * `scenario.expectService()`, `.expectError()` or `.expectEventually()`.
39
+ */
40
+ export declare function isScenarioExpectationCall(node: ts.CallExpression): boolean;
36
41
  /**
37
42
  * Check if a call expression is workflow.expectEventually()
38
43
  */
@@ -31,11 +31,11 @@ export function isWorkflowDoCall(node, checker) {
31
31
  * hit repeatedly on workflow graph round-trips.
32
32
  */
33
33
  export const DYNAMIC_SCENARIO_STEP_TARGET = '<dynamic>';
34
- const SCENARIO_STEP_PHASES = ['step', 'given', 'when', 'then'];
34
+ const SCENARIO_STEP_PHASES = ['given', 'when', 'then'];
35
35
  /**
36
- * Check if a call expression is scenario.step()/given()/when()/then() — a
37
- * declared `pikkuScenarioStep` invoked by name. `given`/`when`/`then` are pure
38
- * sugar over `step`: the phase only changes the prose a reporter renders.
36
+ * Check if a call expression is scenario.given()/when()/then() — a declared
37
+ * `pikkuScenarioStep` invoked by name. `given`/`when` are pure sugar for each
38
+ * other; `then` additionally makes the step's bindings witnesses.
39
39
  */
40
40
  export function isScenarioStepCall(node) {
41
41
  return getScenarioStepPhase(node) !== undefined;
@@ -54,6 +54,28 @@ export function getScenarioStepPhase(node) {
54
54
  }
55
55
  return SCENARIO_STEP_PHASES.find((phase) => phase === propAccess.name.text);
56
56
  }
57
+ /**
58
+ * The expectation helpers that assert without being a `then`.
59
+ *
60
+ * `expectService` and `expectError` are inline steps rather than scenario
61
+ * steps, so they carry no phase and never reach the step meta — but each one is
62
+ * a witness: a recorded service call, or a refusal. PKU680 has to see them, or
63
+ * a scenario whose only assertion is one of these reads as asserting nothing.
64
+ */
65
+ const SCENARIO_EXPECTATION_CALLS = [
66
+ 'expectService',
67
+ 'expectError',
68
+ 'expectEventually',
69
+ ];
70
+ /**
71
+ * Check if a call expression is one of the scenario expectation helpers —
72
+ * `scenario.expectService()`, `.expectError()` or `.expectEventually()`.
73
+ */
74
+ export function isScenarioExpectationCall(node) {
75
+ return (ts.isPropertyAccessExpression(node.expression) &&
76
+ SCENARIO_EXPECTATION_CALLS.some((name) => name === node.expression.name.text) &&
77
+ isWorkflowWireIdentifier(node.expression.expression));
78
+ }
57
79
  /**
58
80
  * Check if a call expression is workflow.expectEventually()
59
81
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.52",
3
+ "version": "0.12.54",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.74"
48
+ "@pikku/core": "^0.12.76"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.74",
51
+ "@pikku/core": "^0.12.76",
52
52
  "@types/node": "^24.11.0"
53
53
  },
54
54
  "engines": {
@@ -8,7 +8,7 @@ import {
8
8
  } from '../utils/extract-function-name.js'
9
9
  import { extractFunctionNode } from '../utils/extract-function-node.js'
10
10
  import { extractUsedWires } from '../utils/extract-services.js'
11
- import type { FunctionServicesMeta } from '@pikku/core'
11
+ import type { AuditDurability, FunctionServicesMeta } from '@pikku/core'
12
12
  import type { ScenarioStepKind, ScenarioSurface } from '@pikku/core/workflow'
13
13
  import { formatVersionedId, parseVersionedId } from '@pikku/core'
14
14
 
@@ -42,6 +42,7 @@ import { resolvePermissions } from '../utils/permissions.js'
42
42
  import { extractWireNames } from '../utils/post-process.js'
43
43
  import { ErrorCode } from '../error-codes.js'
44
44
  import { findPiiPaths } from '../utils/check-pii-output.js'
45
+ import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js'
45
46
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
46
47
  import type { NodeType } from '@pikku/core/node'
47
48
 
@@ -580,7 +581,9 @@ export const addFunctions: AddWiring = (
580
581
  */
581
582
  let auth: boolean | undefined
582
583
  /** The author's claim that the body authorizes its own callers. */
583
- let selfAuthenticated: boolean | undefined
584
+ let permissionsInBody: boolean | undefined
585
+ /** The function's `audit` config, resolved to its object form. */
586
+ let audit: { durability: AuditDurability } | undefined
584
587
  let readonly_: boolean | undefined
585
588
  let deploy: 'serverless' | 'server' | 'auto' | undefined
586
589
  let approvalRequired: boolean | undefined
@@ -679,9 +682,43 @@ export const addFunctions: AddWiring = (
679
682
  remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
680
683
  mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
681
684
  auth = getPropertyValue(firstArg, 'auth') as boolean | undefined
682
- selfAuthenticated = getPropertyValue(firstArg, 'selfAuthenticated') as
685
+ permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody') as
683
686
  | boolean
684
687
  | undefined
688
+ if (permissionsInBody && !options.allow?.permissionsInBody) {
689
+ logger.critical(
690
+ ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED,
691
+ `'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
692
+ `It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
693
+ `set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
694
+ `permission checks that only a reader can verify. Otherwise express the check as a ` +
695
+ `\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
696
+ `callable from outside.`
697
+ )
698
+ return
699
+ }
700
+ // `audit` is either `true` or `{ durability }`, so it needs the nested read:
701
+ // getPropertyValue stringifies an object initializer rather than parsing it.
702
+ // Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
703
+ // claim a durability the runner does not use.
704
+ const auditProp = firstArg.properties.find(
705
+ (p) =>
706
+ ts.isPropertyAssignment(p) &&
707
+ ts.isIdentifier(p.name) &&
708
+ p.name.text === 'audit'
709
+ )
710
+ if (auditProp && ts.isPropertyAssignment(auditProp)) {
711
+ const auditInit = auditProp.initializer
712
+ if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
713
+ audit = { durability: 'best-effort' }
714
+ } else if (ts.isObjectLiteralExpression(auditInit)) {
715
+ const durability = getPropertyValue(
716
+ auditInit,
717
+ 'durability'
718
+ ) as AuditDurability | null
719
+ audit = { durability: durability ?? 'best-effort' }
720
+ }
721
+ }
685
722
  readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
686
723
  deploy = getPropertyValue(firstArg, 'deploy') as
687
724
  | 'serverless'
@@ -1196,6 +1233,20 @@ export const addFunctions: AddWiring = (
1196
1233
  // it runs ONLY when explicitly requested (`pikku all --security`) — see the
1197
1234
  // classificationCheck option. Default codegen skips it entirely.
1198
1235
  if (options.classificationCheck) {
1236
+ // A revealed vault secret is invisible to the return-type scan below when it
1237
+ // is written out mid-body rather than returned, so walk the sinks too.
1238
+ for (const violation of findRevealedSecretSinks(checker, handler)) {
1239
+ logger.diagnostic({
1240
+ severity: 'error',
1241
+ code: ErrorCode.SECRET_REVEALED_INTO_SINK,
1242
+ message:
1243
+ `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
1244
+ `at line ${violation.line} (path '${violation.path}').\n ` +
1245
+ `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
1246
+ `Pass the value only to the client that needs it, and reveal it there.`,
1247
+ })
1248
+ }
1249
+
1199
1250
  const sig = checker.getSignatureFromDeclaration(handler)
1200
1251
  if (sig) {
1201
1252
  const rawRet = checker.getReturnTypeOfSignature(sig)
@@ -1232,7 +1283,7 @@ export const addFunctions: AddWiring = (
1232
1283
  `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
1233
1284
  privatePaths.map((p) => `'${p}'`).join(', ') +
1234
1285
  `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
1235
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
1286
+ `Either require a session (use pikkuFunc) or set the column's security to 'public' in db/annotations.ts if it is safe to expose publicly.`,
1236
1287
  })
1237
1288
  }
1238
1289
  }
@@ -1311,7 +1362,8 @@ export const addFunctions: AddWiring = (
1311
1362
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
1312
1363
  expose: expose || undefined,
1313
1364
  auth: auth || undefined,
1314
- selfAuthenticated: selfAuthenticated || undefined,
1365
+ permissionsInBody: permissionsInBody || undefined,
1366
+ audit,
1315
1367
  remote: remote || undefined,
1316
1368
  scenarioStep: isScenarioStep || undefined,
1317
1369
  scenario: isScenario || undefined,