@cosmicdrift/kumiko-bundled-features 0.243.1 → 0.243.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.243.1",
3
+ "version": "0.243.3",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.243.1",
134
- "@cosmicdrift/kumiko-framework": "0.243.1",
135
- "@cosmicdrift/kumiko-headless": "0.243.1",
136
- "@cosmicdrift/kumiko-renderer": "0.243.1",
137
- "@cosmicdrift/kumiko-renderer-web": "0.243.1",
138
- "@cosmicdrift/kumiko-types": "0.243.1",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.243.3",
134
+ "@cosmicdrift/kumiko-framework": "0.243.3",
135
+ "@cosmicdrift/kumiko-headless": "0.243.3",
136
+ "@cosmicdrift/kumiko-renderer": "0.243.3",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.243.3",
138
+ "@cosmicdrift/kumiko-types": "0.243.3",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.243.1",
168
- "@cosmicdrift/kumiko-locale-es": "0.243.1"
167
+ "@cosmicdrift/kumiko-locale-de": "0.243.3",
168
+ "@cosmicdrift/kumiko-locale-es": "0.243.3"
169
169
  }
170
170
  }
@@ -0,0 +1,252 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createEntity,
4
+ createRegistry,
5
+ createTextField,
6
+ defineEntityDetailHandler,
7
+ defineEntityListHandler,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { z } from "zod";
11
+ import { authFoundationFeature } from "../../auth-foundation";
12
+ import { AuthMfaHandlers, AuthMfaQueries, createAuthMfaFeature } from "../../auth-mfa";
13
+ import { createConfigFeature } from "../../config";
14
+ import { createCryptoShreddingFeature } from "../../crypto-shredding";
15
+ import { createPersonalAccessTokensFeature, PatHandlers } from "../../personal-access-tokens";
16
+ import { createSecretsFeature } from "../../secrets";
17
+ import { createTenantFeature } from "../../tenant";
18
+ import { createUserFeature } from "../../user/feature";
19
+ import { buildAgentManifest } from "../agent-manifest";
20
+ import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
21
+
22
+ // #2700/#2702 — bundled handlers that carry secrets/irreversible-erase risk are now
23
+ // opted out of the agent tool catalog via `agent: { expose: false }`, and the app-side
24
+ // `denyQns` cut removes a handler the catalog would otherwise expose (including the
25
+ // entity-CRUD tools, which bypass the manifest entirely).
26
+
27
+ const SECRETS_HANDLING_FEATURE_QNS = [
28
+ AuthMfaHandlers.enableStart,
29
+ AuthMfaHandlers.enableStartPreauth,
30
+ AuthMfaHandlers.regenerateRecovery,
31
+ PatHandlers.create,
32
+ "crypto-shredding:write:forget-subject",
33
+ "secrets:write:set",
34
+ ] as const;
35
+
36
+ // enable-start-preauth is gated to the literal role "all" (login-flow guest caller,
37
+ // see auth-mfa/handlers/enable-start-preauth.write.ts) — none of the three roles
38
+ // exercised below carry that role, so hasAccess alone would already exclude it and
39
+ // this one QN's absence doesn't prove agent.expose:false is doing the work. The other
40
+ // five QNs are openToAll or role-reachable by at least one of ["User"], ["TenantAdmin"],
41
+ // ["SystemAdmin"], so their absence does.
42
+ function buildSecretHandlingFeatures() {
43
+ return [
44
+ createConfigFeature(),
45
+ createUserFeature(),
46
+ createTenantFeature(),
47
+ authFoundationFeature,
48
+ createAuthMfaFeature({
49
+ setupTokenSecret: "test-mfa-setup-secret-at-least-32-bytes!!",
50
+ issuer: "Kumiko Test",
51
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
52
+ }),
53
+ createPersonalAccessTokensFeature({ scopes: {} }),
54
+ createCryptoShreddingFeature(),
55
+ createSecretsFeature(),
56
+ ];
57
+ }
58
+
59
+ describe("bundled secret-bearing handlers are not in the catalog", () => {
60
+ const roleCases: ReadonlyArray<readonly string[]> = [["User"], ["TenantAdmin"], ["SystemAdmin"]];
61
+
62
+ for (const roles of roleCases) {
63
+ test(`roles ${JSON.stringify(roles)}: none of the opted-out handlers reach the catalog or manifest`, () => {
64
+ const registry = createRegistry(buildSecretHandlingFeatures());
65
+ const manifest = buildAgentManifest(registry, { locale: "en", roles });
66
+ const catalog = buildToolCatalog(registry, manifest, { mode: "edit" });
67
+ const toolNames = catalog.tools.map((t) => t.name);
68
+ const manifestQns = manifest.handlers.map((h) => h.qn);
69
+
70
+ for (const qn of SECRETS_HANDLING_FEATURE_QNS) {
71
+ const toolName = toolNameForQn(qn);
72
+ expect(toolNames).not.toContain(toolName);
73
+ expect(catalog.dispatchTable.has(toolName)).toBe(false);
74
+ expect(manifestQns).not.toContain(qn);
75
+ }
76
+
77
+ // Positive control: a still-exposed bundled handler ("is MFA enabled for me?")
78
+ // must survive — without this, the six assertions above would also pass for a
79
+ // registry that mounted no features at all.
80
+ const statusToolName = toolNameForQn(AuthMfaQueries.status);
81
+ expect(toolNames).toContain(statusToolName);
82
+ expect(catalog.dispatchTable.has(statusToolName)).toBe(true);
83
+ expect(manifestQns).toContain(AuthMfaQueries.status);
84
+ });
85
+ }
86
+ });
87
+
88
+ function buildDenyTestFeature() {
89
+ return defineFeature("deny-test", (r) => {
90
+ r.queryHandler("alpha", z.object({}), async () => ({ ok: true }), {
91
+ access: { roles: ["Admin"] },
92
+ description: "Alpha query handler.",
93
+ });
94
+ r.queryHandler("beta", z.object({}), async () => ({ ok: true }), {
95
+ access: { roles: ["Admin"] },
96
+ description: "Beta query handler.",
97
+ });
98
+ });
99
+ }
100
+
101
+ const DENY_TEST_QN_A = "deny-test:query:alpha";
102
+ const DENY_TEST_QN_B = "deny-test:query:beta";
103
+
104
+ describe("denyQns removes a tool", () => {
105
+ function buildDenyCatalog(denyQns?: readonly string[]) {
106
+ const registry = createRegistry([buildDenyTestFeature()]);
107
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"], denyQns });
108
+ const catalog = buildToolCatalog(registry, manifest, { mode: "edit", denyQns });
109
+ return { manifest, catalog };
110
+ }
111
+
112
+ test("without denyQns both handler-derived tools are present", () => {
113
+ const { catalog } = buildDenyCatalog();
114
+ const names = catalog.tools.map((t) => t.name);
115
+ expect(names).toContain(toolNameForQn(DENY_TEST_QN_A));
116
+ expect(names).toContain(toolNameForQn(DENY_TEST_QN_B));
117
+ });
118
+
119
+ test("denyQns on both buildAgentManifest and buildToolCatalog removes only the denied tool", () => {
120
+ const { manifest, catalog } = buildDenyCatalog([DENY_TEST_QN_A]);
121
+ const nameA = toolNameForQn(DENY_TEST_QN_A);
122
+ const nameB = toolNameForQn(DENY_TEST_QN_B);
123
+
124
+ expect(catalog.tools.map((t) => t.name)).not.toContain(nameA);
125
+ expect(catalog.dispatchTable.has(nameA)).toBe(false);
126
+ expect(manifest.handlers.map((h) => h.qn)).not.toContain(DENY_TEST_QN_A);
127
+
128
+ expect(catalog.tools.map((t) => t.name)).toContain(nameB);
129
+ expect(catalog.dispatchTable.has(nameB)).toBe(true);
130
+ expect(manifest.handlers.map((h) => h.qn)).toContain(DENY_TEST_QN_B);
131
+ });
132
+
133
+ test("denyQns passed only to buildToolCatalog still removes the tool, even though the manifest still lists the handler", () => {
134
+ const registry = createRegistry([buildDenyTestFeature()]);
135
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"] });
136
+ expect(manifest.handlers.map((h) => h.qn)).toContain(DENY_TEST_QN_A);
137
+
138
+ const catalog = buildToolCatalog(registry, manifest, {
139
+ mode: "edit",
140
+ denyQns: [DENY_TEST_QN_A],
141
+ });
142
+ const nameA = toolNameForQn(DENY_TEST_QN_A);
143
+ expect(catalog.tools.map((t) => t.name)).not.toContain(nameA);
144
+ expect(catalog.dispatchTable.has(nameA)).toBe(false);
145
+ });
146
+ });
147
+
148
+ const gadgetEntity = createEntity({
149
+ fields: {
150
+ name: createTextField({ searchable: true, filterable: true }),
151
+ },
152
+ });
153
+
154
+ function buildGadgetCrudFeature() {
155
+ return defineFeature("gadget-crud-test", (r) => {
156
+ r.crud("gadget", gadgetEntity, {
157
+ read: { access: { roles: ["Admin"] } },
158
+ write: { access: { roles: ["Admin"] } },
159
+ });
160
+ });
161
+ }
162
+
163
+ const GADGET_LIST_QN = "gadget-crud-test:query:gadget:list";
164
+ const GADGET_DETAIL_QN = "gadget-crud-test:query:gadget:detail";
165
+
166
+ describe("denyQns reaches the entity CRUD tools", () => {
167
+ function buildGadgetCatalog(denyQns?: readonly string[]) {
168
+ const registry = createRegistry([buildGadgetCrudFeature()]);
169
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"], denyQns });
170
+ return buildToolCatalog(registry, manifest, { mode: "edit", denyQns });
171
+ }
172
+
173
+ test("without denyQns the full entity CRUD tool set is present", () => {
174
+ const names = buildGadgetCatalog().tools.map((t) => t.name);
175
+ expect(names).toContain("get_gadget");
176
+ expect(names).toContain("list_gadget");
177
+ expect(names).toContain("search_gadget");
178
+ expect(names).toContain("find_gadget_by_name");
179
+ });
180
+
181
+ test("denying the :list QN removes list/search/find-by but keeps get", () => {
182
+ const names = buildGadgetCatalog([GADGET_LIST_QN]).tools.map((t) => t.name);
183
+ expect(names).not.toContain("list_gadget");
184
+ expect(names).not.toContain("search_gadget");
185
+ expect(names).not.toContain("find_gadget_by_name");
186
+ expect(names).toContain("get_gadget");
187
+ });
188
+
189
+ test("denying the :detail QN removes get_gadget", () => {
190
+ const names = buildGadgetCatalog([GADGET_DETAIL_QN]).tools.map((t) => t.name);
191
+ expect(names).not.toContain("get_gadget");
192
+ });
193
+ });
194
+
195
+ const plainItemEntity = createEntity({
196
+ fields: { label: createTextField({ searchable: true, filterable: true }) },
197
+ });
198
+ const cloakedItemEntity = createEntity({
199
+ fields: { label: createTextField({ searchable: true, filterable: true }) },
200
+ });
201
+
202
+ // Both entities mounted in the same feature so the "default stays exposed"
203
+ // assertion and the "explicit opt-out hides it" assertion run against one
204
+ // shared registry/catalog build and prove each other's premise.
205
+ function buildExposureTestFeature() {
206
+ return defineFeature("expose-test", (r) => {
207
+ r.crud("plain-item", plainItemEntity, {
208
+ read: { access: { roles: ["Admin"] } },
209
+ write: { access: { roles: ["Admin"] } },
210
+ });
211
+
212
+ r.entity("cloaked-item", cloakedItemEntity);
213
+ r.queryHandler(
214
+ defineEntityListHandler("cloaked-item", cloakedItemEntity, {
215
+ access: { roles: ["Admin"] },
216
+ agent: { expose: false },
217
+ }),
218
+ );
219
+ r.queryHandler(
220
+ defineEntityDetailHandler("cloaked-item", cloakedItemEntity, {
221
+ access: { roles: ["Admin"] },
222
+ }),
223
+ );
224
+ });
225
+ }
226
+
227
+ describe("#2700 — explicit agent.expose:false on an entity handler, both directions", () => {
228
+ function buildExposureCatalog() {
229
+ const registry = createRegistry([buildExposureTestFeature()]);
230
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"] });
231
+ return buildToolCatalog(registry, manifest, { mode: "edit" });
232
+ }
233
+
234
+ test("a CRUD entity without any agent hint keeps its default entity tools — resolveAgentExposure never runs on it", () => {
235
+ const names = buildExposureCatalog().tools.map((t) => t.name);
236
+ // CRUD-generated list/detail handlers carry no `description`, and
237
+ // resolveAgentExposure is fail-closed on a missing description — applying it here
238
+ // would delete every entity tool in every app, not just an opted-out one.
239
+ expect(names).toContain("get_plain-item");
240
+ expect(names).toContain("list_plain-item");
241
+ expect(names).toContain("search_plain-item");
242
+ expect(names).toContain("find_plain-item_by_label");
243
+ });
244
+
245
+ test("an entity's list handler with an explicit agent.expose:false loses list/search/find-by but keeps get", () => {
246
+ const names = buildExposureCatalog().tools.map((t) => t.name);
247
+ expect(names).not.toContain("list_cloaked-item");
248
+ expect(names).not.toContain("search_cloaked-item");
249
+ expect(names).not.toContain("find_cloaked-item_by_label");
250
+ expect(names).toContain("get_cloaked-item");
251
+ });
252
+ });
@@ -155,7 +155,9 @@ function buildHandlerEntry(
155
155
  def: QueryHandlerDef | WriteHandlerDef,
156
156
  roles: readonly string[],
157
157
  getHandlerEntity: (qualifiedHandler: string) => string | undefined,
158
+ denyQns: ReadonlySet<string>,
158
159
  ): AgentManifestHandler | undefined {
160
+ if (denyQns.has(qn)) return undefined;
159
161
  const exposure = resolveAgentExposure(def, kind);
160
162
  if (!exposure.expose) return undefined;
161
163
  if (!hasAccess({ roles }, def.access)) return undefined;
@@ -190,14 +192,15 @@ function buildHandlers(
190
192
  writeHandlers: ReadonlyMap<string, WriteHandlerDef>,
191
193
  roles: readonly string[],
192
194
  getHandlerEntity: (qualifiedHandler: string) => string | undefined,
195
+ denyQns: ReadonlySet<string>,
193
196
  ): readonly AgentManifestHandler[] {
194
197
  const result: AgentManifestHandler[] = [];
195
198
  for (const [qn, def] of queryHandlers) {
196
- const entry = buildHandlerEntry(qn, "query", def, roles, getHandlerEntity);
199
+ const entry = buildHandlerEntry(qn, "query", def, roles, getHandlerEntity, denyQns);
197
200
  if (entry) result.push(entry);
198
201
  }
199
202
  for (const [qn, def] of writeHandlers) {
200
- const entry = buildHandlerEntry(qn, "write", def, roles, getHandlerEntity);
203
+ const entry = buildHandlerEntry(qn, "write", def, roles, getHandlerEntity, denyQns);
201
204
  if (entry) result.push(entry);
202
205
  }
203
206
  return result;
@@ -323,6 +326,7 @@ export function buildAgentManifest(
323
326
  registry.getAllWriteHandlers(),
324
327
  roles,
325
328
  (qn) => registry.getHandlerEntity(qn),
329
+ new Set(options.denyQns ?? []),
326
330
  );
327
331
  const screenMap = registry.getAllScreens();
328
332
  const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
@@ -1,4 +1,8 @@
1
- import type { EntityDefinition, FieldDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type {
2
+ EntityDefinition,
3
+ FieldDefinition,
4
+ QueryHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { hasAccess } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import type {
4
8
  AgentManifest,
@@ -94,6 +98,14 @@ function isDetailHandlerQn(qn: string, entityName: string): boolean {
94
98
  return qn.endsWith(`:${entityName}:detail`);
95
99
  }
96
100
 
101
+ /** #2700: the entity CRUD tools are enumerated off the registry, not off the manifest, so
102
+ * `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
103
+ * the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
104
+ * handlers never carry one, so reusing it would delete every entity tool in every app. */
105
+ function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean {
106
+ return def.agent?.expose === false;
107
+ }
108
+
97
109
  function addToolsForListHandler(
98
110
  registry: RegistrySearchView,
99
111
  qn: string,
@@ -375,11 +387,13 @@ function addTool(
375
387
  function addRegistrySearchTools(
376
388
  registry: RegistrySearchView,
377
389
  roleFilter: { roles: readonly string[] },
390
+ denyQns: ReadonlySet<string>,
378
391
  sink: CatalogSink,
379
392
  ): void {
380
393
  for (const [qn, def] of registry.getAllQueryHandlers()) {
381
394
  const entityName = registry.getHandlerEntity(qn);
382
395
  if (!entityName || !isListHandlerQn(qn, entityName)) continue;
396
+ if (denyQns.has(qn) || isExplicitlyAgentHidden(def)) continue;
383
397
  if (!hasAccess(roleFilter, def.access)) continue;
384
398
 
385
399
  const entity = registry.getEntity(entityName);
@@ -398,6 +412,7 @@ type EntityHandlerQns = {
398
412
  function collectEntityHandlerQns(
399
413
  registry: RegistrySearchView,
400
414
  roleFilter: { roles: readonly string[] },
415
+ denyQns: ReadonlySet<string>,
401
416
  ): EntityHandlerQns {
402
417
  const detailQnByEntity = new Map<string, string>();
403
418
  const listQnByEntity = new Map<string, string>();
@@ -413,6 +428,7 @@ function collectEntityHandlerQns(
413
428
  for (const [qn, def] of registry.getAllQueryHandlers()) {
414
429
  const entityName = registry.getHandlerEntity(qn);
415
430
  if (!entityName || !hasAccess(roleFilter, def.access)) continue;
431
+ if (denyQns.has(qn) || isExplicitlyAgentHidden(def)) continue;
416
432
  if (isDetailHandlerQn(qn, entityName)) detailQnByEntity.set(entityName, qn);
417
433
  if (isListHandlerQn(qn, entityName)) listQnByEntity.set(entityName, qn);
418
434
  }
@@ -459,10 +475,12 @@ function addListTools(
459
475
  function addQueryHandlerTools(
460
476
  manifest: AgentManifest,
461
477
  entityListDetailQns: ReadonlySet<string>,
478
+ denyQns: ReadonlySet<string>,
462
479
  sink: CatalogSink,
463
480
  ): void {
464
481
  for (const handler of manifest.handlers) {
465
482
  if (handler.kind !== "query") continue;
483
+ if (denyQns.has(handler.qn)) continue;
466
484
  if (entityListDetailQns.has(handler.qn)) continue;
467
485
  const name = toolNameForQn(handler.qn);
468
486
 
@@ -483,10 +501,12 @@ function addQueryHandlerTools(
483
501
  function addWriteHandlerTools(
484
502
  manifest: AgentManifest,
485
503
  detailQnByEntity: ReadonlyMap<string, string>,
504
+ denyQns: ReadonlySet<string>,
486
505
  sink: CatalogSink,
487
506
  ): void {
488
507
  for (const handler of manifest.handlers) {
489
508
  if (handler.kind !== "write") continue;
509
+ if (denyQns.has(handler.qn)) continue;
490
510
  const name = toolNameForQn(handler.qn);
491
511
 
492
512
  const detailQn =
@@ -544,7 +564,14 @@ function addClientTools(manifest: AgentManifest, mode: AgentToolMode, sink: Cata
544
564
  * tool (custom query/write handlers, navigate/open_form/ask_user) is manifest-derived, since
545
565
  * the manifest already carries the role-filtered handler/screen shape needed for those. Roles
546
566
  * and locale both come from the manifest (`manifest.builtForRoles` / `manifest.tenantSettings.locale`)
547
- * so the registry-derived and manifest-derived halves share one source and cannot disagree. */
567
+ * so the registry-derived and manifest-derived halves share one source and cannot disagree.
568
+ *
569
+ * `options.denyQns` is the app-side cut, applied to both halves: it is the only way to keep a
570
+ * handler out of the catalog that the app does not own (a bundled feature's), and the only way
571
+ * to drop an entity CRUD tool, which no `agent.expose` on the manifest side can reach. It is
572
+ * NOT read off the manifest on purpose — the manifest is prompt payload, and a list of the
573
+ * handlers the model may not call has no business travelling to the provider. Pass the same
574
+ * list to `buildAgentManifest` so the manifest stops describing what the catalog withholds. */
548
575
  export function buildToolCatalog(
549
576
  registry: RegistrySearchView,
550
577
  manifest: AgentManifest,
@@ -554,16 +581,20 @@ export function buildToolCatalog(
554
581
  const roleFilter = { roles: manifest.builtForRoles };
555
582
  const locale = manifest.tenantSettings.locale;
556
583
  const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
584
+ const denyQns = new Set(options.denyQns ?? []);
557
585
 
558
- addRegistrySearchTools(registry, roleFilter, sink);
586
+ addRegistrySearchTools(registry, roleFilter, denyQns, sink);
559
587
  const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(
560
588
  registry,
561
589
  roleFilter,
590
+ denyQns,
562
591
  );
563
592
  addGetTools(detailQnByEntity, entityByName, locale, sink);
564
593
  addListTools(registry, listQnByEntity, entityByName, locale, sink);
565
- addQueryHandlerTools(manifest, entityListDetailQns, sink);
566
- if (options.mode !== "read-only") addWriteHandlerTools(manifest, detailQnByEntity, sink);
594
+ addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
595
+ if (options.mode !== "read-only") {
596
+ addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
597
+ }
567
598
  addClientTools(manifest, options.mode, sink);
568
599
 
569
600
  return { tools: sink.tools, dispatchTable: sink.dispatchTable };
@@ -14,6 +14,13 @@ export type AgentToolMode = "read-only" | "approval" | "edit";
14
14
 
15
15
  export type ToolCatalogOptions = {
16
16
  readonly mode: AgentToolMode;
17
+ /** Handler QNs the mounting app refuses to expose, whatever the handler's own
18
+ * `agent.expose` says. Applies to every tool shape, including the entity
19
+ * CRUD tools (`get_`/`list_`/`search_`/`find_*_by_*`) that are enumerated off
20
+ * the registry rather than off the manifest. Pass the same list to
21
+ * `buildAgentManifest`, or the manifest will still describe what the catalog
22
+ * no longer offers. */
23
+ readonly denyQns?: readonly string[];
17
24
  };
18
25
 
19
26
  /** Mirrors `ToolDefinition` in `@cosmicdriftgamestudio/kumiko-ai-foundation` (providers/types.ts)
@@ -104,6 +111,12 @@ export type ToolCatalog = {
104
111
  export type AgentManifestOptions = {
105
112
  readonly locale: string;
106
113
  readonly roles: readonly string[];
114
+ /** Handler QNs the mounting app refuses to expose, whatever the handler's own
115
+ * `agent.expose` says. Denied handlers are left out of `manifest.handlers`
116
+ * entirely — the manifest is prompt payload, so a denied handler must not
117
+ * reach the model as a description either. Pass the same list to
118
+ * `buildToolCatalog`. */
119
+ readonly denyQns?: readonly string[];
107
120
  /** Tenant currency — the registry knows nothing about tenants, so the
108
121
  * caller passes it through into the manifest's tenant-settings block. */
109
122
  readonly currency?: string;
@@ -66,6 +66,8 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
66
66
  access: { roles: ["all"] },
67
67
  description:
68
68
  "Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
69
+ // Changes the caller's authentication state and mints a session.
70
+ agent: { risk: "high" },
69
71
  handler: async (event, ctx) => {
70
72
  const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
71
73
  if (!verify.ok) return invalidSetupToken();
@@ -40,6 +40,8 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
40
40
  access: { openToAll: true },
41
41
  description:
42
42
  "Completes TOTP enrollment for the signed-in user by checking a code against the secret carried in the setup token from enable-start, then storing the factor and signing every other session and access token out.",
43
+ // Changes the caller's authentication state and signs their other sessions out.
44
+ agent: { risk: "high" },
43
45
  handler: async (event, ctx) => {
44
46
  const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
45
47
  if (!verify.ok) return invalidSetupToken();
@@ -37,6 +37,8 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
37
37
  access: { roles: ["all"] },
38
38
  description:
39
39
  "Begins TOTP enrollment for a user whose sign-in was blocked because the tenant requires two-factor authentication, taking identity from the pre-auth token login issued instead of from a session.",
40
+ // Same secret-bearing result as enable-start.
41
+ agent: { expose: false },
40
42
  handler: async (event, ctx) => {
41
43
  const verified = verifyMfaPreauthSetupToken(
42
44
  event.payload.preauthSetupToken,
@@ -32,6 +32,9 @@ export function createEnableStartHandler(opts: EnableStartOptions) {
32
32
  access: { openToAll: true },
33
33
  description:
34
34
  "Begins TOTP enrollment for the signed-in user by generating a secret plus recovery codes and returning them as a short-lived setup token, an otpauth:// URI and the one-time plaintext recovery codes; nothing is persisted until enable-confirm.",
35
+ // The result is the TOTP secret, the otpauth:// URI and the plaintext
36
+ // recovery codes — an agent turn would put all three in the LLM transcript.
37
+ agent: { expose: false },
35
38
  handler: async (event, ctx) => {
36
39
  const existing = await findUserMfaRow(ctx.db, event.user);
37
40
  if (existing) return mfaAlreadyEnabled();
@@ -29,7 +29,9 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
29
29
  access: { openToAll: true },
30
30
  description:
31
31
  "Destroys every existing recovery code of the caller, including unused ones, and returns a fresh set once in plaintext; use it when recovery codes may have leaked but TOTP itself should stay enrolled.",
32
- agent: { risk: "high" },
32
+ // Returns a fresh set of plaintext recovery codes — same transcript leak as
33
+ // enable-start.
34
+ agent: { expose: false },
33
35
  handler: async (event, ctx) => {
34
36
  const row = await findUserMfaRow(ctx.db, event.user);
35
37
  if (!row) return mfaNotEnabled();
@@ -189,7 +189,9 @@ export const forgetSubjectWrite = defineWriteHandler({
189
189
  access: { roles: [ROLES.DataProtectionOfficer, ROLES.SystemAdmin] },
190
190
  description:
191
191
  "Irreversibly crypto-shreds one user or tenant subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
192
- agent: { risk: "high" },
192
+ // Erasing the subject key is irreversible: there is no undo, so an agent must
193
+ // not be able to reach it at all.
194
+ agent: { expose: false },
193
195
  handler: async (event, ctx) => {
194
196
  const kms = configuredPiiSubjectKms();
195
197
  if (!kms) {
@@ -60,6 +60,9 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
60
60
  access: { openToAll: true },
61
61
  description:
62
62
  "Mints a personal access token for the calling user after re-verifying their password (and MFA code when enrolled) and returns the plaintext token exactly once; use it when a user needs a long-lived API credential.",
63
+ // Returns the plaintext token once; an agent turn would archive it in the
64
+ // LLM transcript, where it stays valid until revoked.
65
+ agent: { expose: false },
63
66
  handler: async (event, ctx) => {
64
67
  const systemUser = createSystemUser(event.user.tenantId);
65
68
  const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
@@ -8,6 +8,9 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
8
8
  name: "set",
9
9
  description:
10
10
  "Stores or replaces one encrypted secret for the caller's tenant under the given key, together with a redacted preview and optional hint; use it to enter or rotate a credential.",
11
+ // `value` is the plaintext credential: as a tool call it would travel to
12
+ // the model provider before it is ever encrypted at rest.
13
+ agent: { expose: false },
11
14
  schema: z.object({
12
15
  key: z.string().min(1).max(100),
13
16
  value: z.string(),
@@ -22,6 +22,8 @@ export const detailQuery = defineQueryHandler({
22
22
  access: { roles: access.admin },
23
23
  description:
24
24
  "Loads one session of the active tenant by its id with owner, timestamps and decrypted IP/user-agent; use it when an admin investigates a specific session found through the session list.",
25
+ // Decrypted IP and user-agent.
26
+ agent: { risk: "high" },
25
27
  outputSchema: sessionRowSchema.nullable(),
26
28
  handler: async (query, ctx) => {
27
29
  const row = await fetchOne<{
@@ -25,6 +25,8 @@ export const invitationsQuery = defineQueryHandler({
25
25
  access: { roles: access.admin },
26
26
  description:
27
27
  "Lists the still-pending invitations of the caller's own tenant with their email and inviter decrypted; accepted, cancelled and expired ones are left out.",
28
+ // Decrypted invitee and inviter email.
29
+ agent: { risk: "high" },
28
30
  handler: async (query, ctx) => {
29
31
  if (!ctx.systemDb) {
30
32
  throw new InternalError({
@@ -26,6 +26,8 @@ export const membersQuery = defineQueryHandler({
26
26
  access: { roles: access.admin },
27
27
  description:
28
28
  "Lists the memberships of the caller's own tenant with each member's decrypted email, display name and roles, optionally narrowed to a single user id.",
29
+ // Decrypted email + display name: legitimate for an agent, never unattended.
30
+ agent: { risk: "high" },
29
31
  handler: async (query, ctx) => {
30
32
  if (!ctx.systemDb) {
31
33
  throw new InternalError({
@@ -25,6 +25,8 @@ export const listDownloadAttemptsQuery = defineQueryHandler({
25
25
  access: { roles: ["Admin", "SystemAdmin"] },
26
26
  description:
27
27
  "Lists invalid export-download attempts inside the caller's own tenant, filterable by failure result, source IP and time range, for a data protection officer triaging brute-force probing of download links.",
28
+ // Source IPs of the probing attempts.
29
+ agent: { risk: "high" },
28
30
  handler: async (query, ctx) => {
29
31
  const p = query.payload;
30
32
  const where: WhereObject = { tenantId: query.user.tenantId };
@@ -62,6 +62,8 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
62
62
  access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
63
63
  description:
64
64
  "Mails an account-deletion verification link to the given address, for a user who wants to be erased but can no longer log in; it answers identically whether or not the address belongs to an active account and never returns the link.",
65
+ // Mails a deletion link to any address the caller names.
66
+ agent: { risk: "high" },
65
67
  // Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
66
68
  rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
67
69
  handler: async (event, ctx) => {