@bevel-software/platform-core-backend 0.7.5 → 0.8.0

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 (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -1,237 +1,237 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
-
3
- import {
4
- recordOntologyRead,
5
- assertOntologyWriteAllowed,
6
- assertShellAllowedWithinOntology,
7
- MissingSessionError,
8
- type SessionOntologyGate,
9
- } from '../session-ontology.gate.js';
10
- import type { ToolContext } from '../../tool-helpers/tool.contract.js';
11
- import type { ISessionOntologyService, OperationDecision } from '../../workflow/session-ontology.service.js';
12
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
13
- import { ontologyOf } from '../../../shared/kb-layout.js';
14
-
15
- const KB = 'knowledge-base';
16
- const P_FILE = 'KnowledgeBase/Product/Knowledge/Foo.md';
17
- const PL_FILE = 'KnowledgeBase/Platform/Knowledge/Bar.md';
18
- const NEUTRAL = 'access.md';
19
- const RECOVERY_EMAIL = 'recovery-bot@bevel.local';
20
-
21
- /**
22
- * In-memory stand-in for the touched-set service — applies the real
23
- * reads-record / writes-block-once-multi rule against a per-session set, so the
24
- * gate's interaction with it is exercised end to end without a DB.
25
- */
26
- function makeFakeService(): ISessionOntologyService {
27
- const touched = new Map<string, Set<string>>();
28
- const setFor = (s: string) => touched.get(s) ?? new Set<string>();
29
- return {
30
- async checkOperation(sessionId, wsPath, isWrite): Promise<OperationDecision> {
31
- const ont = ontologyOf(wsPath, KB);
32
- const set = setFor(sessionId);
33
- if (ont === null) return { allow: true, touched: [...set].sort() };
34
- if (isWrite) {
35
- const spanned = new Set(set).add(ont);
36
- if (spanned.size > 1) {
37
- return {
38
- allow: false,
39
- reason: set.size >= 2 ? 'multi-ontology' : 'different-ontology',
40
- touched: [...set].sort(),
41
- attempted: ont,
42
- };
43
- }
44
- }
45
- set.add(ont);
46
- touched.set(sessionId, set);
47
- return { allow: true, touched: [...set].sort() };
48
- },
49
- checkOntology: async () => ({ allow: true, touched: [] }),
50
- getTouched: async (s) => [...setFor(s)].sort(),
51
- delete: async (s) => { touched.delete(s); },
52
- sweepOlderThan: async () => 0,
53
- };
54
- }
55
-
56
- /**
57
- * Build a gate wired the way the CORE composition wires it: the fake
58
- * touched-set service plus an EMPTY hooks registry. Core registers no
59
- * `preWrite` hook, so the gate only TRACKS touches and never blocks — the
60
- * blocking assertions (the ontology-block hook the enterprise composition
61
- * root registers) live with that hook in the enterprise repo.
62
- */
63
- function makeGate(): SessionOntologyGate {
64
- const service = makeFakeService();
65
- const hooks = new WorkflowHooks();
66
- return { service, enabled: true, kbDirName: KB, recoveryBotEmail: RECOVERY_EMAIL, hooks };
67
- }
68
-
69
- function ctx(overrides: Partial<ToolContext> = {}): ToolContext {
70
- return {
71
- user: { id: 'u1', email: 'dev@bevel.local', name: 'Dev' },
72
- scope: 'write',
73
- source: 'internal',
74
- sessionId: 's1',
75
- abortSignal: new AbortController().signal,
76
- workspaceService: {} as never,
77
- workflowService: {} as never,
78
- events: {} as never,
79
- getFilesystem: async () => ({} as never),
80
- ...overrides,
81
- };
82
- }
83
-
84
- describe('recordOntologyRead', () => {
85
- let gate: SessionOntologyGate;
86
-
87
- beforeEach(() => {
88
- gate = makeGate();
89
- });
90
-
91
- it('does nothing when the flag is disabled', async () => {
92
- gate.enabled = false;
93
- await expect(recordOntologyRead(gate, ctx(), P_FILE)).resolves.toBeUndefined();
94
- expect(await gate.service.getTouched('s1')).toEqual([]);
95
- });
96
-
97
- it('skips neutral paths (no record)', async () => {
98
- await expect(recordOntologyRead(gate, ctx(), NEUTRAL)).resolves.toBeUndefined();
99
- expect(await gate.service.getTouched('s1')).toEqual([]);
100
- });
101
-
102
- it('skips non-agent (browser session) callers', async () => {
103
- await expect(
104
- recordOntologyRead(gate, ctx({ source: 'session', sessionId: undefined }), P_FILE),
105
- ).resolves.toBeUndefined();
106
- });
107
-
108
- it('skips the recovery bot by identity', async () => {
109
- await expect(
110
- recordOntologyRead(
111
- gate,
112
- ctx({ user: { id: 'bot', email: 'Recovery-Bot@Bevel.Local', name: 'bot' }, sessionId: undefined }),
113
- PL_FILE,
114
- ),
115
- ).resolves.toBeUndefined();
116
- });
117
-
118
- it('fails closed when an agent read of a named path has no session id', async () => {
119
- await expect(recordOntologyRead(gate, ctx({ sessionId: undefined }), P_FILE)).rejects.toBeInstanceOf(
120
- MissingSessionError,
121
- );
122
- });
123
-
124
- it('allows a neutral read without a session id', async () => {
125
- await expect(recordOntologyRead(gate, ctx({ sessionId: undefined }), NEUTRAL)).resolves.toBeUndefined();
126
- });
127
-
128
- it('records a read and allows it', async () => {
129
- await recordOntologyRead(gate, ctx(), P_FILE);
130
- expect(await gate.service.getTouched('s1')).toEqual(['KnowledgeBase/Product']);
131
- });
132
-
133
- it('allows reads across ontologies (recording both)', async () => {
134
- await recordOntologyRead(gate, ctx(), P_FILE);
135
- await expect(recordOntologyRead(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
136
- expect(await gate.service.getTouched('s1')).toEqual(['KnowledgeBase/Platform', 'KnowledgeBase/Product']);
137
- });
138
-
139
- it('never blocks — a read into a second ontology still passes (delete_file relies on this)', async () => {
140
- await recordOntologyRead(gate, ctx(), P_FILE);
141
- // A delete_file in a different ontology records its touch but never throws.
142
- await expect(recordOntologyRead(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
143
- });
144
- });
145
-
146
- describe('assertOntologyWriteAllowed', () => {
147
- let gate: SessionOntologyGate;
148
-
149
- beforeEach(() => {
150
- gate = makeGate();
151
- });
152
-
153
- it('does nothing when the flag is disabled', async () => {
154
- gate.enabled = false;
155
- // Even a cross-ontology write must pass when disabled.
156
- await recordOntologyRead(gate, ctx(), P_FILE);
157
- await expect(assertOntologyWriteAllowed(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
158
- });
159
-
160
- it('skips neutral paths (no record, no block)', async () => {
161
- await expect(assertOntologyWriteAllowed(gate, ctx(), NEUTRAL)).resolves.toBeUndefined();
162
- expect(await gate.service.getTouched('s1')).toEqual([]);
163
- });
164
-
165
- it('fails closed when an agent write of a named path has no session id', async () => {
166
- await expect(assertOntologyWriteAllowed(gate, ctx({ sessionId: undefined }), P_FILE)).rejects.toBeInstanceOf(
167
- MissingSessionError,
168
- );
169
- });
170
-
171
- it('allows a write confined to the one touched ontology', async () => {
172
- await recordOntologyRead(gate, ctx(), P_FILE);
173
- await expect(assertOntologyWriteAllowed(gate, ctx(), P_FILE)).resolves.toBeUndefined();
174
- });
175
-
176
- // NOTE (core split): the "blocks a write to a different ontology" and
177
- // "blocks ALL writes once the session has read across two ontologies" cases
178
- // moved with the ontology-block hook (`registerOntologyBlockHook`) to the
179
- // enterprise repo — core registers no preWrite hook, so a core-only
180
- // deployment TRACKS but never blocks. The tests below pin exactly that.
181
-
182
- it('core: allows a write to a different ontology (tracking only, no block hook)', async () => {
183
- await recordOntologyRead(gate, ctx(), P_FILE);
184
- await expect(assertOntologyWriteAllowed(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
185
- });
186
-
187
- it('core: still allows writes after reading across two ontologies (no block hook)', async () => {
188
- await recordOntologyRead(gate, ctx(), P_FILE);
189
- await recordOntologyRead(gate, ctx(), PL_FILE);
190
- await expect(assertOntologyWriteAllowed(gate, ctx(), P_FILE)).resolves.toBeUndefined();
191
- });
192
- });
193
-
194
- describe('assertShellAllowedWithinOntology (shell / no-path tools)', () => {
195
- let gate: SessionOntologyGate;
196
-
197
- beforeEach(() => {
198
- gate = makeGate();
199
- });
200
-
201
- it('allows a fresh session (nothing touched)', async () => {
202
- await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
203
- });
204
-
205
- it('allows a session confined to a single ontology', async () => {
206
- await recordOntologyRead(gate, ctx(), P_FILE);
207
- await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
208
- });
209
-
210
- // NOTE (core split): the "blocks once the session has crossed into two
211
- // ontologies" case moved with the enterprise ontology-block hook. Core
212
- // registers no preWrite hook, so shell is never refused here.
213
- it('core: allows shell even after crossing into two ontologies (no block hook)', async () => {
214
- await recordOntologyRead(gate, ctx(), P_FILE);
215
- await recordOntologyRead(gate, ctx(), PL_FILE);
216
- await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
217
- });
218
-
219
- it('does nothing when the flag is disabled', async () => {
220
- gate.enabled = false;
221
- await gate.service.checkOperation('s1', P_FILE, false);
222
- await gate.service.checkOperation('s1', PL_FILE, false);
223
- await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
224
- });
225
-
226
- it('exempts the recovery bot', async () => {
227
- await gate.service.checkOperation('s1', P_FILE, false);
228
- await gate.service.checkOperation('s1', PL_FILE, false);
229
- await expect(
230
- assertShellAllowedWithinOntology(gate, ctx({ user: { id: 'bot', email: RECOVERY_EMAIL, name: 'bot' } })),
231
- ).resolves.toBeUndefined();
232
- });
233
-
234
- it('allows when there is no session id (nothing to evaluate)', async () => {
235
- await expect(assertShellAllowedWithinOntology(gate, ctx({ sessionId: undefined }))).resolves.toBeUndefined();
236
- });
237
- });
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ import {
4
+ recordOntologyRead,
5
+ assertOntologyWriteAllowed,
6
+ assertShellAllowedWithinOntology,
7
+ MissingSessionError,
8
+ type SessionOntologyGate,
9
+ } from '../session-ontology.gate.js';
10
+ import type { ToolContext } from '../../tool-helpers/tool.contract.js';
11
+ import type { ISessionOntologyService, OperationDecision } from '../../workflow/session-ontology.service.js';
12
+ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
13
+ import { ontologyOf } from '../../../shared/kb-layout.js';
14
+
15
+ const KB = 'knowledge-base';
16
+ const P_FILE = 'KnowledgeBase/Product/Knowledge/Foo.md';
17
+ const PL_FILE = 'KnowledgeBase/Platform/Knowledge/Bar.md';
18
+ const NEUTRAL = 'access.md';
19
+ const RECOVERY_EMAIL = 'recovery-bot@bevel.local';
20
+
21
+ /**
22
+ * In-memory stand-in for the touched-set service — applies the real
23
+ * reads-record / writes-block-once-multi rule against a per-session set, so the
24
+ * gate's interaction with it is exercised end to end without a DB.
25
+ */
26
+ function makeFakeService(): ISessionOntologyService {
27
+ const touched = new Map<string, Set<string>>();
28
+ const setFor = (s: string) => touched.get(s) ?? new Set<string>();
29
+ return {
30
+ async checkOperation(sessionId, wsPath, isWrite): Promise<OperationDecision> {
31
+ const ont = ontologyOf(wsPath, KB);
32
+ const set = setFor(sessionId);
33
+ if (ont === null) return { allow: true, touched: [...set].sort() };
34
+ if (isWrite) {
35
+ const spanned = new Set(set).add(ont);
36
+ if (spanned.size > 1) {
37
+ return {
38
+ allow: false,
39
+ reason: set.size >= 2 ? 'multi-ontology' : 'different-ontology',
40
+ touched: [...set].sort(),
41
+ attempted: ont,
42
+ };
43
+ }
44
+ }
45
+ set.add(ont);
46
+ touched.set(sessionId, set);
47
+ return { allow: true, touched: [...set].sort() };
48
+ },
49
+ checkOntology: async () => ({ allow: true, touched: [] }),
50
+ getTouched: async (s) => [...setFor(s)].sort(),
51
+ delete: async (s) => { touched.delete(s); },
52
+ sweepOlderThan: async () => 0,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Build a gate wired the way the CORE composition wires it: the fake
58
+ * touched-set service plus an EMPTY hooks registry. Core registers no
59
+ * `preWrite` hook, so the gate only TRACKS touches and never blocks — the
60
+ * blocking assertions (the ontology-block hook the enterprise composition
61
+ * root registers) live with that hook in the enterprise repo.
62
+ */
63
+ function makeGate(): SessionOntologyGate {
64
+ const service = makeFakeService();
65
+ const hooks = new WorkflowHooks();
66
+ return { service, enabled: true, kbDirName: KB, recoveryBotEmail: RECOVERY_EMAIL, hooks };
67
+ }
68
+
69
+ function ctx(overrides: Partial<ToolContext> = {}): ToolContext {
70
+ return {
71
+ user: { id: 'u1', email: 'dev@bevel.local', name: 'Dev' },
72
+ scope: 'write',
73
+ source: 'internal',
74
+ sessionId: 's1',
75
+ abortSignal: new AbortController().signal,
76
+ workspaceService: {} as never,
77
+ workflowService: {} as never,
78
+ events: {} as never,
79
+ getFilesystem: async () => ({} as never),
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ describe('recordOntologyRead', () => {
85
+ let gate: SessionOntologyGate;
86
+
87
+ beforeEach(() => {
88
+ gate = makeGate();
89
+ });
90
+
91
+ it('does nothing when the flag is disabled', async () => {
92
+ gate.enabled = false;
93
+ await expect(recordOntologyRead(gate, ctx(), P_FILE)).resolves.toBeUndefined();
94
+ expect(await gate.service.getTouched('s1')).toEqual([]);
95
+ });
96
+
97
+ it('skips neutral paths (no record)', async () => {
98
+ await expect(recordOntologyRead(gate, ctx(), NEUTRAL)).resolves.toBeUndefined();
99
+ expect(await gate.service.getTouched('s1')).toEqual([]);
100
+ });
101
+
102
+ it('skips non-agent (browser session) callers', async () => {
103
+ await expect(
104
+ recordOntologyRead(gate, ctx({ source: 'session', sessionId: undefined }), P_FILE),
105
+ ).resolves.toBeUndefined();
106
+ });
107
+
108
+ it('skips the recovery bot by identity', async () => {
109
+ await expect(
110
+ recordOntologyRead(
111
+ gate,
112
+ ctx({ user: { id: 'bot', email: 'Recovery-Bot@Bevel.Local', name: 'bot' }, sessionId: undefined }),
113
+ PL_FILE,
114
+ ),
115
+ ).resolves.toBeUndefined();
116
+ });
117
+
118
+ it('fails closed when an agent read of a named path has no session id', async () => {
119
+ await expect(recordOntologyRead(gate, ctx({ sessionId: undefined }), P_FILE)).rejects.toBeInstanceOf(
120
+ MissingSessionError,
121
+ );
122
+ });
123
+
124
+ it('allows a neutral read without a session id', async () => {
125
+ await expect(recordOntologyRead(gate, ctx({ sessionId: undefined }), NEUTRAL)).resolves.toBeUndefined();
126
+ });
127
+
128
+ it('records a read and allows it', async () => {
129
+ await recordOntologyRead(gate, ctx(), P_FILE);
130
+ expect(await gate.service.getTouched('s1')).toEqual(['KnowledgeBase/Product']);
131
+ });
132
+
133
+ it('allows reads across ontologies (recording both)', async () => {
134
+ await recordOntologyRead(gate, ctx(), P_FILE);
135
+ await expect(recordOntologyRead(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
136
+ expect(await gate.service.getTouched('s1')).toEqual(['KnowledgeBase/Platform', 'KnowledgeBase/Product']);
137
+ });
138
+
139
+ it('never blocks — a read into a second ontology still passes (delete_file relies on this)', async () => {
140
+ await recordOntologyRead(gate, ctx(), P_FILE);
141
+ // A delete_file in a different ontology records its touch but never throws.
142
+ await expect(recordOntologyRead(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
143
+ });
144
+ });
145
+
146
+ describe('assertOntologyWriteAllowed', () => {
147
+ let gate: SessionOntologyGate;
148
+
149
+ beforeEach(() => {
150
+ gate = makeGate();
151
+ });
152
+
153
+ it('does nothing when the flag is disabled', async () => {
154
+ gate.enabled = false;
155
+ // Even a cross-ontology write must pass when disabled.
156
+ await recordOntologyRead(gate, ctx(), P_FILE);
157
+ await expect(assertOntologyWriteAllowed(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
158
+ });
159
+
160
+ it('skips neutral paths (no record, no block)', async () => {
161
+ await expect(assertOntologyWriteAllowed(gate, ctx(), NEUTRAL)).resolves.toBeUndefined();
162
+ expect(await gate.service.getTouched('s1')).toEqual([]);
163
+ });
164
+
165
+ it('fails closed when an agent write of a named path has no session id', async () => {
166
+ await expect(assertOntologyWriteAllowed(gate, ctx({ sessionId: undefined }), P_FILE)).rejects.toBeInstanceOf(
167
+ MissingSessionError,
168
+ );
169
+ });
170
+
171
+ it('allows a write confined to the one touched ontology', async () => {
172
+ await recordOntologyRead(gate, ctx(), P_FILE);
173
+ await expect(assertOntologyWriteAllowed(gate, ctx(), P_FILE)).resolves.toBeUndefined();
174
+ });
175
+
176
+ // NOTE (core split): the "blocks a write to a different ontology" and
177
+ // "blocks ALL writes once the session has read across two ontologies" cases
178
+ // moved with the ontology-block hook (`registerOntologyBlockHook`) to the
179
+ // enterprise repo — core registers no preWrite hook, so a core-only
180
+ // deployment TRACKS but never blocks. The tests below pin exactly that.
181
+
182
+ it('core: allows a write to a different ontology (tracking only, no block hook)', async () => {
183
+ await recordOntologyRead(gate, ctx(), P_FILE);
184
+ await expect(assertOntologyWriteAllowed(gate, ctx(), PL_FILE)).resolves.toBeUndefined();
185
+ });
186
+
187
+ it('core: still allows writes after reading across two ontologies (no block hook)', async () => {
188
+ await recordOntologyRead(gate, ctx(), P_FILE);
189
+ await recordOntologyRead(gate, ctx(), PL_FILE);
190
+ await expect(assertOntologyWriteAllowed(gate, ctx(), P_FILE)).resolves.toBeUndefined();
191
+ });
192
+ });
193
+
194
+ describe('assertShellAllowedWithinOntology (shell / no-path tools)', () => {
195
+ let gate: SessionOntologyGate;
196
+
197
+ beforeEach(() => {
198
+ gate = makeGate();
199
+ });
200
+
201
+ it('allows a fresh session (nothing touched)', async () => {
202
+ await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
203
+ });
204
+
205
+ it('allows a session confined to a single ontology', async () => {
206
+ await recordOntologyRead(gate, ctx(), P_FILE);
207
+ await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
208
+ });
209
+
210
+ // NOTE (core split): the "blocks once the session has crossed into two
211
+ // ontologies" case moved with the enterprise ontology-block hook. Core
212
+ // registers no preWrite hook, so shell is never refused here.
213
+ it('core: allows shell even after crossing into two ontologies (no block hook)', async () => {
214
+ await recordOntologyRead(gate, ctx(), P_FILE);
215
+ await recordOntologyRead(gate, ctx(), PL_FILE);
216
+ await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
217
+ });
218
+
219
+ it('does nothing when the flag is disabled', async () => {
220
+ gate.enabled = false;
221
+ await gate.service.checkOperation('s1', P_FILE, false);
222
+ await gate.service.checkOperation('s1', PL_FILE, false);
223
+ await expect(assertShellAllowedWithinOntology(gate, ctx())).resolves.toBeUndefined();
224
+ });
225
+
226
+ it('exempts the recovery bot', async () => {
227
+ await gate.service.checkOperation('s1', P_FILE, false);
228
+ await gate.service.checkOperation('s1', PL_FILE, false);
229
+ await expect(
230
+ assertShellAllowedWithinOntology(gate, ctx({ user: { id: 'bot', email: RECOVERY_EMAIL, name: 'bot' } })),
231
+ ).resolves.toBeUndefined();
232
+ });
233
+
234
+ it('allows when there is no session id (nothing to evaluate)', async () => {
235
+ await expect(assertShellAllowedWithinOntology(gate, ctx({ sessionId: undefined }))).resolves.toBeUndefined();
236
+ });
237
+ });