@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 +9 -9
- package/src/agent-tools/__tests__/agent-optout.test.ts +252 -0
- package/src/agent-tools/agent-manifest.ts +6 -2
- package/src/agent-tools/tool-catalog.ts +36 -5
- package/src/agent-tools/types.ts +13 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +2 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +2 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +2 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +3 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +3 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- package/src/personal-access-tokens/handlers/create.write.ts +3 -0
- package/src/secrets/handlers/set.write.ts +3 -0
- package/src/sessions/handlers/detail.query.ts +2 -0
- package/src/tenant/handlers/invitations.query.ts +2 -0
- package/src/tenant/handlers/members.query.ts +2 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +2 -0
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.243.
|
|
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.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.243.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.243.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.243.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.243.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.243.
|
|
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.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.243.
|
|
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 {
|
|
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")
|
|
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 };
|
package/src/agent-tools/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) => {
|