@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +743 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +199 -8
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +216 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/add/add-workflow.js +12 -1
  19. package/dist/error-codes.d.ts +17 -1
  20. package/dist/error-codes.js +23 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/inspector.js +33 -1
  23. package/dist/types.d.ts +55 -2
  24. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  25. package/dist/utils/annotate-http-route-auth.js +48 -0
  26. package/dist/utils/check-secret-sinks.d.ts +31 -0
  27. package/dist/utils/check-secret-sinks.js +117 -0
  28. package/dist/utils/extract-function-name.js +13 -4
  29. package/dist/utils/extract-function-node.js +18 -1
  30. package/dist/utils/extract-secret-usage.d.ts +9 -0
  31. package/dist/utils/extract-secret-usage.js +61 -0
  32. package/dist/utils/literal-properties.d.ts +22 -0
  33. package/dist/utils/literal-properties.js +69 -0
  34. package/dist/utils/load-addon-functions-meta.js +26 -1
  35. package/dist/utils/permissions.js +8 -0
  36. package/dist/utils/post-process.d.ts +68 -3
  37. package/dist/utils/post-process.js +246 -13
  38. package/dist/utils/secret-alias-services.d.ts +8 -0
  39. package/dist/utils/secret-alias-services.js +45 -0
  40. package/dist/utils/secret-brokers.d.ts +2 -0
  41. package/dist/utils/secret-brokers.js +9 -0
  42. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  43. package/dist/utils/serialize-inspector-state.js +18 -0
  44. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  45. package/dist/utils/single-declaration.d.ts +32 -0
  46. package/dist/utils/single-declaration.js +43 -0
  47. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  48. package/dist/utils/validate-exposed-functions-gated.js +93 -0
  49. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  50. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  51. package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
  52. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
  53. package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
  54. package/dist/utils/workflow/dsl/patterns.js +26 -4
  55. package/dist/visit.js +4 -0
  56. package/package.json +3 -3
  57. package/src/add/add-auth.test.ts +5 -3
  58. package/src/add/add-auth.ts +1 -1
  59. package/src/add/add-cli.ts +8 -0
  60. package/src/add/add-credential.ts +1 -1
  61. package/src/add/add-functions.test.ts +263 -1
  62. package/src/add/add-functions.ts +273 -11
  63. package/src/add/add-http-route.ts +20 -7
  64. package/src/add/add-middleware.test.ts +196 -0
  65. package/src/add/add-middleware.ts +64 -4
  66. package/src/add/add-personas.test.ts +382 -0
  67. package/src/add/add-personas.ts +285 -0
  68. package/src/add/add-scenario-step.test.ts +87 -5
  69. package/src/add/add-scope.test.ts +103 -30
  70. package/src/add/add-scope.ts +1 -1
  71. package/src/add/add-secret.ts +1 -1
  72. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  73. package/src/add/add-system-role.test.ts +297 -0
  74. package/src/add/add-system-role.ts +164 -0
  75. package/src/add/add-variable.ts +1 -1
  76. package/src/add/add-wire-addon.test.ts +111 -0
  77. package/src/add/add-wire-addon.ts +29 -0
  78. package/src/add/add-workflow.ts +21 -1
  79. package/src/add/addon-bans.test.ts +4 -4
  80. package/src/add/allow-escape-hatches.test.ts +144 -0
  81. package/src/add/audit-meta.test.ts +75 -0
  82. package/src/add/config-value-metadata.test.ts +11 -11
  83. package/src/add/secret-sink-check.test.ts +211 -0
  84. package/src/add/wire-name-literal.test.ts +2 -2
  85. package/src/error-codes.ts +26 -0
  86. package/src/index.ts +4 -1
  87. package/src/inspector.ts +39 -0
  88. package/src/types.ts +56 -2
  89. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  90. package/src/utils/annotate-http-route-auth.ts +52 -0
  91. package/src/utils/check-secret-sinks.ts +145 -0
  92. package/src/utils/extract-function-name.ts +14 -4
  93. package/src/utils/extract-function-node.ts +18 -6
  94. package/src/utils/extract-secret-usage.test.ts +81 -0
  95. package/src/utils/extract-secret-usage.ts +73 -0
  96. package/src/utils/literal-properties.ts +97 -0
  97. package/src/utils/load-addon-functions-meta.ts +32 -2
  98. package/src/utils/permissions-shorthand.test.ts +79 -0
  99. package/src/utils/permissions.ts +7 -0
  100. package/src/utils/post-process.test.ts +133 -0
  101. package/src/utils/post-process.ts +313 -12
  102. package/src/utils/secret-alias-services.test.ts +146 -0
  103. package/src/utils/secret-alias-services.ts +58 -0
  104. package/src/utils/secret-brokers.test.ts +53 -0
  105. package/src/utils/secret-brokers.ts +11 -0
  106. package/src/utils/serialize-inspector-state.ts +28 -0
  107. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  108. package/src/utils/single-declaration.ts +57 -0
  109. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  110. package/src/utils/validate-exposed-functions-gated.ts +103 -0
  111. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  112. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  113. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
  114. package/src/utils/workflow/dsl/patterns.ts +33 -4
  115. package/src/visit.ts +4 -0
  116. package/tsconfig.tsbuildinfo +1 -1
@@ -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: true` step must be given an actor (PKU677), since the 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
- * `wireScope`. Runs after all visitors, so declaration order does not matter.
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: true` step must be given an actor (PKU677), since the 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?.scenarioStepBrowser && !step.actor) {
616
- logger.critical(ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR, `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
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,20 @@ 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
+ // `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) {
666
+ logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
667
+ `Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
668
+ `only proves nothing threw, and contributes nothing to witness coverage.`);
669
+ }
645
670
  }
646
671
  }
647
672
  /**
@@ -692,13 +717,18 @@ export function validateSchemaWiringSeparation(logger, state) {
692
717
  for (const file of state.http.files) {
693
718
  wiringFiles.add(file);
694
719
  }
695
- // Middleware wirings (addHTTPMiddleware calls)
696
- for (const meta of state.http.routeMiddleware.values()) {
697
- wiringFiles.add(meta.sourceFile);
698
- }
699
- for (const meta of state.middleware.tagMiddleware.values()) {
700
- wiringFiles.add(meta.sourceFile);
701
- }
720
+ // Middleware wirings (addHTTPMiddleware calls). A group can be registered
721
+ // from more than one file, and every one of them is a wiring file.
722
+ const addGroupFiles = (groups) => {
723
+ for (const meta of groups.values()) {
724
+ wiringFiles.add(meta.sourceFile);
725
+ for (const registration of meta.additionalRegistrations ?? []) {
726
+ wiringFiles.add(registration.sourceFile);
727
+ }
728
+ }
729
+ };
730
+ addGroupFiles(state.http.routeMiddleware);
731
+ addGroupFiles(state.middleware.tagMiddleware);
702
732
  // Check for overlap
703
733
  for (const file of schemaFiles) {
704
734
  if (wiringFiles.has(file)) {
@@ -711,6 +741,85 @@ export function validateSchemaWiringSeparation(logger, state) {
711
741
  }
712
742
  }
713
743
  }
744
+ /** Marks the functions that keep the full `SecretService`. */
745
+ export function computeSecretBrokers(state) {
746
+ for (const [id, meta] of Object.entries(state.functions.meta)) {
747
+ if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
748
+ meta.secretBroker = true;
749
+ }
750
+ }
751
+ }
752
+ /**
753
+ * Refuses a singleton service that is a `SecretService` under another name.
754
+ *
755
+ * `secrets` is omitted from every function-facing services type, but
756
+ * `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
757
+ * a name the omit does not cover. A name-based rule misses this; the type does
758
+ * not.
759
+ */
760
+ export function validateNoSecretAliasServices(logger, checker, state) {
761
+ if (!('typesLookup' in state)) {
762
+ return;
763
+ }
764
+ const singletonServicesTypes = state.typesLookup.get('SingletonServices');
765
+ if (!singletonServicesTypes?.length) {
766
+ return;
767
+ }
768
+ const aliases = findSecretAliasServices(singletonServicesTypes[0], checker);
769
+ if (aliases.length === 0) {
770
+ return;
771
+ }
772
+ const report = (kind, id, services) => {
773
+ const used = services.filter((service) => aliases.includes(service));
774
+ if (used.length === 0) {
775
+ return;
776
+ }
777
+ 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. ` +
778
+ `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.`);
779
+ };
780
+ for (const [id, meta] of Object.entries(state.functions.meta)) {
781
+ if (meta.secretBroker) {
782
+ continue;
783
+ }
784
+ report('Function', id, meta.services?.services ?? []);
785
+ }
786
+ for (const [id, def] of Object.entries(state.permissions.definitions)) {
787
+ report('Permission', id, def.services?.services ?? []);
788
+ }
789
+ }
790
+ /**
791
+ * Reports secret reads the catalogue does not account for.
792
+ *
793
+ * `getSecret('X')` with no matching `defineSecret` is a value that will be missing
794
+ * at first request rather than at deploy; a non-literal key is a read the
795
+ * manifest cannot cover, so a per-unit scope cannot be narrowed around it.
796
+ */
797
+ export function validateSecretUsage(logger, state) {
798
+ const declared = new Set(state.secrets.definitions.map((d) => d.secretId));
799
+ for (const definition of state.secrets.definitions) {
800
+ if (definition.oauth2?.tokenSecretId) {
801
+ declared.add(definition.oauth2.tokenSecretId);
802
+ }
803
+ }
804
+ for (const [file, usage] of state.secrets.usage) {
805
+ const relativeFile = relative(state.rootDir, file);
806
+ const undeclared = usage.keys.filter((key) => !declared.has(key));
807
+ if (undeclared.length > 0) {
808
+ logger.diagnostic({
809
+ severity: 'warn',
810
+ code: ErrorCode.SECRET_NOT_DECLARED,
811
+ 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.`,
812
+ });
813
+ }
814
+ if (usage.dynamic.length > 0) {
815
+ logger.diagnostic({
816
+ severity: 'warn',
817
+ code: ErrorCode.SECRET_KEY_NOT_STATIC,
818
+ 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.`,
819
+ });
820
+ }
821
+ }
822
+ }
714
823
  export function computeDiagnostics(state) {
715
824
  const diagnostics = [];
716
825
  for (const [id, meta] of Object.entries(state.functions.meta)) {
@@ -787,7 +896,7 @@ export function computeDiagnostics(state) {
787
896
  }
788
897
  /**
789
898
  * Validates that every scope referenced by a function is declared via
790
- * `wireScope`. Runs after all visitors, so declaration order does not matter.
899
+ * `defineScope`. Runs after all visitors, so declaration order does not matter.
791
900
  *
792
901
  * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
793
902
  * to be declared, and grants its whole subtree.
@@ -806,7 +915,131 @@ export function validateScopeReferences(logger, state) {
806
915
  }
807
916
  if (!declared.has(declaredForm)) {
808
917
  const available = Array.from(declared);
809
- logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`);
918
+ logger.critical(ErrorCode.INVALID_VALUE, `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`);
919
+ }
920
+ }
921
+ }
922
+ }
923
+ /**
924
+ * Resolves every `tools:` entry on every agent and reports the ones that do not
925
+ * land on a real function, or that land on one with nothing to tell the model.
926
+ *
927
+ * `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
928
+ * resolved, only unwrapped — so until now a reference to a function that never
929
+ * existed generated cleanly and failed at agent-run time. Addon namespaces come
930
+ * from `wireAddon`, and each addon's generated metadata is read off disk, which
931
+ * is the only view of it this project has.
932
+ *
933
+ * The description check is separate and opt-in (`--strict-meta`), because the
934
+ * description is what the model is told the tool does: without one, the tool is
935
+ * offered to it under its own name (`ai-agent-prepare.ts`), which is a silent
936
+ * quality loss rather than a failure. A title deliberately does not satisfy it —
937
+ * a title labels a tool in a UI, it does not tell a model when to reach for it.
938
+ */
939
+ export function validateAgentToolReferences(logger, state, options = {}) {
940
+ const agents = Object.entries(state.agents.agentsMeta);
941
+ if (agents.length === 0)
942
+ return;
943
+ const { wireAddonDeclarations } = state.rpc;
944
+ for (const [agentKey, agent] of agents) {
945
+ const where = agent.sourceFile ? ` (${agent.sourceFile})` : '';
946
+ for (const tool of agent.tools ?? []) {
947
+ // A workflow reference is resolved against the workflow map, not here.
948
+ if (tool.startsWith('workflow:'))
949
+ continue;
950
+ const separator = tool.indexOf(':');
951
+ let meta;
952
+ if (separator === -1) {
953
+ const funcId = state.rpc.internalMeta[tool] ?? tool;
954
+ meta = state.functions.meta[funcId];
955
+ if (!meta) {
956
+ logger.critical(ErrorCode.AGENT_TOOL_NOT_FOUND, `AI agent '${agentKey}'${where} references tool '${tool}', which is not a function in this project.`);
957
+ continue;
958
+ }
959
+ }
960
+ else {
961
+ const namespace = tool.slice(0, separator);
962
+ const funcName = tool.slice(separator + 1);
963
+ const addon = wireAddonDeclarations?.get(namespace);
964
+ if (!addon) {
965
+ const known = Array.from(wireAddonDeclarations?.keys() ?? []);
966
+ logger.critical(ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE, `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
967
+ `Wired namespaces: ${known.join(', ') || 'none'}.`);
968
+ continue;
969
+ }
970
+ // An addon that has not been built yet contributed no metadata, which
971
+ // is not the same as one whose function is missing.
972
+ const addonMeta = state.addonFunctions[namespace];
973
+ if (!addonMeta)
974
+ continue;
975
+ meta = addonMeta[funcName];
976
+ if (!meta) {
977
+ logger.critical(ErrorCode.AGENT_TOOL_NOT_FOUND, `AI agent '${agentKey}'${where} references tool '${tool}', but addon '${namespace}' ('${addon.package}') exposes no function '${funcName}'.`);
978
+ continue;
979
+ }
980
+ }
981
+ if (options.strictMeta && !meta.description) {
982
+ logger.critical(ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION, `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
983
+ `An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
984
+ `Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`);
985
+ }
986
+ }
987
+ }
988
+ }
989
+ /**
990
+ * Validates that every scope granted by a system role is declared via
991
+ * `defineScope`. Runs after all visitors, so declaration order does not matter.
992
+ *
993
+ * The same rule functions get, for a sharper reason: a role granting an
994
+ * undeclared scope is not merely useless, it is *invisibly* useless. No
995
+ * function checks the scope, so the role appears to work — every call a
996
+ * persona holding it makes succeeds or fails for unrelated reasons, and the
997
+ * authorization boundary it was written to describe is never exercised.
998
+ *
999
+ * A `*` suffix grants a subtree; the node it hangs off must exist.
1000
+ */
1001
+ export function validateSystemRoleScopes(logger, state) {
1002
+ if (!state.systemRoles.definitions.length) {
1003
+ return;
1004
+ }
1005
+ const declared = new Set(flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id));
1006
+ for (const role of state.systemRoles.definitions) {
1007
+ for (const scope of role.scopes) {
1008
+ if (scope === '*') {
1009
+ 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.`);
1010
+ continue;
1011
+ }
1012
+ const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope;
1013
+ if (!declared.has(declaredForm)) {
1014
+ const available = Array.from(declared);
1015
+ 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'}`);
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Validates that every role a persona names is declared with
1022
+ * `defineSystemRole`. Runs after all visitors, so declaration order does not
1023
+ * matter.
1024
+ *
1025
+ * Custom roles are refused rather than tolerated. A role composed in the
1026
+ * console does not exist at build time and can be deleted after it, so a
1027
+ * persona pinned to one silently stops testing what it claims to — and the
1028
+ * symptom is a scatter of 403s that read like authorization findings rather
1029
+ * than like a deleted role.
1030
+ */
1031
+ export function validatePersonaRoles(logger, state) {
1032
+ if (!state.personas.definitions.length) {
1033
+ return;
1034
+ }
1035
+ const declared = new Set(state.systemRoles.definitions.map((r) => r.name));
1036
+ for (const persona of state.personas.definitions) {
1037
+ for (const role of persona.roles) {
1038
+ if (!declared.has(role)) {
1039
+ const available = Array.from(declared).sort();
1040
+ logger.critical(ErrorCode.INVALID_VALUE, `Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
1041
+ `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. ` +
1042
+ `Declared roles: ${available.join(', ') || 'none'}`);
810
1043
  }
811
1044
  }
812
1045
  }
@@ -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,2 @@
1
+ /** Whether this function keeps the full `SecretService`. */
2
+ export declare const isSecretBrokerFunction: (functionId: string) => boolean;
@@ -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() ?? '');
@@ -1,3 +1,4 @@
1
+ import type { SecretUsage } from './extract-secret-usage.js';
1
2
  import type { JSONValue } from '@pikku/core';
2
3
  import type { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
3
4
  /**
@@ -228,6 +229,7 @@ export interface SerializableInspectorState {
228
229
  secrets: {
229
230
  definitions: InspectorState['secrets']['definitions'];
230
231
  files: string[];
232
+ usage: Record<string, SecretUsage>;
231
233
  };
232
234
  credentials: {
233
235
  definitions: InspectorState['credentials']['definitions'];
@@ -237,6 +239,14 @@ export interface SerializableInspectorState {
237
239
  definitions: InspectorState['scopes']['definitions'];
238
240
  files: string[];
239
241
  };
242
+ systemRoles: {
243
+ definitions: InspectorState['systemRoles']['definitions'];
244
+ files: string[];
245
+ };
246
+ personas: {
247
+ definitions: InspectorState['personas']['definitions'];
248
+ files: string[];
249
+ };
240
250
  variables: {
241
251
  definitions: InspectorState['variables']['definitions'];
242
252
  files: string[];
@@ -112,6 +112,7 @@ export function serializeInspectorState(state) {
112
112
  secrets: {
113
113
  definitions: state.secrets.definitions,
114
114
  files: Array.from(state.secrets.files),
115
+ usage: Object.fromEntries(state.secrets.usage),
115
116
  },
116
117
  credentials: {
117
118
  definitions: state.credentials.definitions,
@@ -121,6 +122,14 @@ export function serializeInspectorState(state) {
121
122
  definitions: state.scopes.definitions,
122
123
  files: Array.from(state.scopes.files),
123
124
  },
125
+ systemRoles: {
126
+ definitions: state.systemRoles.definitions,
127
+ files: Array.from(state.systemRoles.files),
128
+ },
129
+ personas: {
130
+ definitions: state.personas.definitions,
131
+ files: Array.from(state.personas.files),
132
+ },
124
133
  variables: {
125
134
  definitions: state.variables.definitions,
126
135
  files: Array.from(state.variables.files),
@@ -276,6 +285,7 @@ export function deserializeInspectorState(data) {
276
285
  secrets: {
277
286
  definitions: data.secrets?.definitions || [],
278
287
  files: new Set(data.secrets?.files || []),
288
+ usage: new Map(Object.entries(data.secrets?.usage || {})),
279
289
  },
280
290
  credentials: {
281
291
  definitions: data.credentials?.definitions || [],
@@ -285,6 +295,14 @@ export function deserializeInspectorState(data) {
285
295
  definitions: data.scopes?.definitions || [],
286
296
  files: new Set(data.scopes?.files || []),
287
297
  },
298
+ systemRoles: {
299
+ definitions: data.systemRoles?.definitions || [],
300
+ files: new Set(data.systemRoles?.files || []),
301
+ },
302
+ personas: {
303
+ definitions: data.personas?.definitions || [],
304
+ files: new Set(data.personas?.files || []),
305
+ },
288
306
  variables: {
289
307
  definitions: data.variables?.definitions || [],
290
308
  files: new Set(data.variables?.files || []),
@@ -9,6 +9,9 @@ const serializeGroupMap = (groupMap) => {
9
9
  count: meta.count,
10
10
  instanceIds: meta.instanceIds,
11
11
  isFactory: meta.isFactory,
12
+ ...(meta.additionalRegistrations && {
13
+ additionalRegistrations: meta.additionalRegistrations,
14
+ }),
12
15
  };
13
16
  }
14
17
  return result;
@@ -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
+ };