@bevel-software/platform-core-backend 0.12.1 → 0.13.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.
Files changed (159) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -3
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +123 -7
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  37. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  39. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  40. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  41. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  42. package/dist/modules/kb-fs/repo-path.js +54 -0
  43. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  44. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  45. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  46. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  47. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  48. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  50. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  52. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  53. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  55. package/dist/modules/tool-helpers/tool-context.js +1 -0
  56. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  57. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  58. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  59. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  60. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  62. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  63. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  64. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  66. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  68. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  70. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  71. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  72. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  73. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  74. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  75. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +70 -5
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  85. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  87. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  89. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  90. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  91. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  92. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  95. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.js +12 -0
  97. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  98. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  99. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.service.js +4 -0
  101. package/dist/modules/workflow/workflow.service.js.map +1 -1
  102. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  103. package/dist/modules/workspace/workspace.tools.js +31 -15
  104. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  105. package/kb-template/AGENTS.md +52 -8
  106. package/package.json +4 -3
  107. package/src/core/create-core-server.ts +13 -1
  108. package/src/core/create-core-services.ts +1 -0
  109. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  110. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  111. package/src/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
  112. package/src/modules/access/access-control.service.ts +198 -37
  113. package/src/modules/access/admin-locked-commit.ts +1 -0
  114. package/src/modules/access/synced-groups-committer.ts +1 -1
  115. package/src/modules/access-model/__tests__/access-grammar.test.ts +199 -1
  116. package/src/modules/access-model/access-errors.ts +19 -0
  117. package/src/modules/access-model/access-grammar.ts +123 -6
  118. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  119. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  120. package/src/modules/declared-variables/index.ts +1 -0
  121. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  122. package/src/modules/diff/diff.routes.ts +3 -2
  123. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  124. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  126. package/src/modules/kb-fs/repo-path.ts +56 -0
  127. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  128. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  129. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  130. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  131. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  132. package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
  133. package/src/modules/tool-helpers/tool-context.ts +1 -0
  134. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  135. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  136. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  137. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  138. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  139. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  140. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  141. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  142. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  143. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  144. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  145. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  146. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  147. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  148. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  149. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  150. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  151. package/src/modules/workflow/git/git.service.ts +73 -6
  152. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  153. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  154. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  155. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  156. package/src/modules/workflow/workflow.routes.ts +12 -0
  157. package/src/modules/workflow/workflow.service.ts +5 -1
  158. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  159. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -11,6 +11,7 @@
11
11
  * `./group-files.js` import below is access-model's own file, not a breach.)
12
12
  */
13
13
 
14
+ import { parse as parseFullYaml } from 'yaml';
14
15
  import type { GroupsIndex } from './group-files.js';
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -138,12 +139,76 @@ export function isAccessMdPath(p: string): boolean {
138
139
  * the access-declarations scan and the shared `KbReferenceScanner` (which
139
140
  * must scan/rewrite the same set, or a rename strands a live `.tool`
140
141
  * frontmatter grant). `access.md` is covered by `.md`.
142
+ *
143
+ * `.tool` is whole-document YAML: the file IS one `---` fenced block, and its
144
+ * access verbs sit in it as ordinary keys beside the rest of the definition
145
+ * (`parseOwnAccessEntries` ignores every key that is not a verb). It is
146
+ * configuration rather than a graph node — it lives outside the typed
147
+ * ontologies — but it is exactly the kind of file whose edits grant
148
+ * capability, so it needs the same per-file governance as a node.
149
+ *
150
+ * This is the CORE set. An overlay that ships its own whole-document
151
+ * configuration files adds their extensions through
152
+ * {@link registerAccessFrontmatterExtensions} — the grammar is core's, the
153
+ * file kinds are not necessarily.
141
154
  */
142
- export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
155
+ const CORE_ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
143
156
 
144
- /** True when `p` is a file the resolver reads access frontmatter from. */
157
+ const accessFrontmatterExtensions = new Set<string>(CORE_ACCESS_FRONTMATTER_EXTENSIONS);
158
+
159
+ /**
160
+ * Extend the set of files whose own frontmatter carries access verbs.
161
+ *
162
+ * For overlays whose configuration files follow the same whole-document
163
+ * convention and, like a `.tool`, grant capability by being edited. Call once
164
+ * at boot, BEFORE any access resolution or reference scan — the declarations
165
+ * scan and the reference scanner must agree on the set, and a scanner that
166
+ * learned a new extension after a scan would leave a live grant unrewritten
167
+ * on the next rename.
168
+ *
169
+ * Idempotent, and additive only: an extension cannot be removed, because
170
+ * removing one would silently drop grants that are already enforced.
171
+ */
172
+ export function registerAccessFrontmatterExtensions(extensions: readonly string[]): void {
173
+ // Validate EVERYTHING first, then apply. These registrations decide which
174
+ // files' edits carry enforced access grants, so a typo (`pipeline` for
175
+ // `.pipeline`) must THROW rather than be skipped — a silent skip makes a
176
+ // failed registration indistinguishable from a successful one, at boot,
177
+ // where nobody is looking. And it must throw before the registry changes:
178
+ // a list that is half-applied when it throws leaves later scans governing a
179
+ // set nobody asked for.
180
+ const normalized = extensions.map((ext) => {
181
+ const n = ext.trim().toLowerCase();
182
+ if (!/^\.[a-z0-9]+$/.test(n)) {
183
+ throw new Error(
184
+ `access frontmatter extension "${ext}" is malformed — expected a leading dot, e.g. ".pipeline"`,
185
+ );
186
+ }
187
+ return n;
188
+ });
189
+ for (const n of normalized) accessFrontmatterExtensions.add(n);
190
+ }
191
+
192
+ /** Every extension currently in the set, core's plus any an overlay registered. */
193
+ export function accessFrontmatterExtensionList(): string[] {
194
+ return [...accessFrontmatterExtensions];
195
+ }
196
+
197
+ /**
198
+ * True when `p` is a file the resolver reads access frontmatter from.
199
+ *
200
+ * Case-SENSITIVE on the path, as it always was: `doc.MD` is not a node and
201
+ * carries no enforced grant. Registered extensions are normalized to lowercase
202
+ * so `.Pipeline` and `.pipeline` register the same thing, but which FILES are
203
+ * governed must not change because the set became registrable — widening it to
204
+ * `X.MD` silently in a release would be an access-model change nobody asked
205
+ * for, made in a refactor.
206
+ */
145
207
  export function hasAccessFrontmatterExtension(p: string): boolean {
146
- return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
208
+ for (const ext of accessFrontmatterExtensions) {
209
+ if (p.endsWith(ext)) return true;
210
+ }
211
+ return false;
147
212
  }
148
213
  /**
149
214
  * The PRINCIPAL index — canonical name → member emails. Despite the name it
@@ -735,6 +800,60 @@ export function parseAccessFile(
735
800
  * scope — applied after every directory `access.md` in the chain.
736
801
  */
737
802
  export type OwnEntries = Record<Verb, ParsedEntry[]>;
803
+ /**
804
+ * The top-level mapping of a file's own frontmatter, for the verb keys.
805
+ *
806
+ * The subset parser is tried first: it is what every `access.md` and node
807
+ * frontmatter has always been read with, and its answer must not change. But
808
+ * a whole-document configuration file (`.tool`, and whatever an overlay
809
+ * registers) is real YAML — folded descriptions (`>-`), literal blocks (`|`),
810
+ * nested maps — and the subset parser stops at the first line it does not
811
+ * understand. Before this fallback that meant the file's `owner:` / `read:` /
812
+ * `write:` were silently dropped: a grant that was reviewed and merged did
813
+ * not exist, and the file was unreadable by the very principal it named.
814
+ * So on a subset failure the frontmatter is read by the full parser and only
815
+ * its top-level mapping is used — the verb keys are looked up exactly as
816
+ * before, everything else is ignored exactly as before.
817
+ */
818
+ function ownEntriesRoot(frontmatter: string): unknown {
819
+ const subset = parseYamlSubset(frontmatter);
820
+ if (subset.ok && !verbValuesNeedFullYaml(subset.value)) return subset.value;
821
+ try {
822
+ // The FAILSAFE schema: every scalar stays the text it was written as —
823
+ // `true`, `42`, `0x10`, `2026-01-01` — which is all the subset parser has
824
+ // ever handed the entry grammar. The core schema would type them, and a
825
+ // role spelled `0x10` would come back as `16` the moment another verb on
826
+ // the same file used a quoted value.
827
+ const full = parseFullYaml(frontmatter, { schema: 'failsafe' });
828
+ // A document the full parser rejects but the subset parser accepted keeps
829
+ // the subset answer — whatever it read is what has always been read.
830
+ return full == null ? (subset.ok ? subset.value : null) : full;
831
+ } catch {
832
+ return subset.ok ? subset.value : null;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Whether a verb's value, as the subset parser read it, is really YAML syntax
838
+ * the subset parser does not understand and passed through as text: a flow
839
+ * sequence (`read: [A, B]`) or a quoted scalar (`read: "A <a@x>"`, `- 'Role'`).
840
+ * The subset parser SUCCEEDS on these — it just hands the brackets and quotes
841
+ * to the entry parser, which then drops the entry, or worse, keeps the quotes
842
+ * as part of a role name. Such a value means the full parser has to read the
843
+ * document.
844
+ */
845
+ function verbValuesNeedFullYaml(root: unknown): boolean {
846
+ if (!root || typeof root !== 'object' || Array.isArray(root)) return false;
847
+ const looksLikeSyntax = (v: unknown): boolean =>
848
+ typeof v === 'string' && /^\s*(\[\s*\S|\{\s*\S|"|')/.test(v);
849
+ for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
850
+ if (!KNOWN_VERBS_SET.has(key)) continue;
851
+ if (looksLikeSyntax(value)) return true;
852
+ if (Array.isArray(value) && value.some(looksLikeSyntax)) return true;
853
+ }
854
+ return false;
855
+ }
856
+
738
857
  /**
739
858
  * Parse the access verbs a node file declares in its own YAML frontmatter.
740
859
  * Returns the per-verb entry lists, or null when the file has no frontmatter
@@ -748,9 +867,7 @@ export type OwnEntries = Record<Verb, ParsedEntry[]>;
748
867
  export function parseOwnAccessEntries(text: string): OwnEntries | null {
749
868
  const fm = extractFrontmatter(text);
750
869
  if (!fm.ok) return null;
751
- const parsed = parseYamlSubset(fm.frontmatter);
752
- if (!parsed.ok) return null;
753
- const root = parsed.value;
870
+ const root = ownEntriesRoot(fm.frontmatter);
754
871
  if (root == null || typeof root !== 'object' || Array.isArray(root)) return null;
755
872
 
756
873
  const entries = emptyEntries();
@@ -0,0 +1,166 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import express from 'express';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { createDeclaredVariableRoutes } from '../declared-variables.routes.js';
5
+ import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
6
+ import type { ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
7
+
8
+ /**
9
+ * The one core route that releases secret VALUES. What is worth pinning here is
10
+ * not the happy path but the shape of the boundary: the caller names a FILE,
11
+ * the server decides the variables, unreadable means 404, and a
12
+ * platform-executed tool never releases anything.
13
+ */
14
+
15
+ const GIT_TOOL: ToolManualSummary = {
16
+ slug: 'git',
17
+ name: 'git',
18
+ path: 'Plugins/Engineering/software.bevel.hexis/tools/git.tool',
19
+ type: 'inline',
20
+ remote: false,
21
+ variables: [
22
+ { name: 'GITHUB_TOKEN', scope: 'admin' },
23
+ { name: 'OPTIONAL_TOKEN', scope: 'admin' },
24
+ ],
25
+ };
26
+
27
+ const REMOTE_TOOL: ToolManualSummary = {
28
+ slug: 'billing',
29
+ name: 'billing',
30
+ path: 'Plugins/billing.tool',
31
+ type: 'http',
32
+ remote: true,
33
+ variables: [{ name: 'BILLING_KEY', scope: 'admin' }],
34
+ };
35
+
36
+ let httpServer: HttpServer | undefined;
37
+
38
+ interface Harness {
39
+ base: string;
40
+ resolve: ReturnType<typeof vi.fn>;
41
+ }
42
+
43
+ async function mount(opts: {
44
+ userId?: string;
45
+ /** How the caller authenticated. `session` is a browser; anything else is a machine. */
46
+ source?: 'session' | 'external' | 'internal';
47
+ manuals?: ToolManualSummary[];
48
+ secrets?: Record<string, string>;
49
+ }): Promise<Harness> {
50
+ const userId = opts.userId ?? 'user-1';
51
+ const source = opts.source ?? 'external';
52
+ const secrets = opts.secrets ?? {};
53
+ const resolve = vi.fn(async (_u: string, key: string) => secrets[key] ?? null);
54
+
55
+ const toolManualService = {
56
+ listAccessible: async () => opts.manuals ?? [],
57
+ } as unknown as IToolManualService;
58
+ const secretsVault = { resolve } as unknown as ISecretsVaultService;
59
+
60
+ const app = express();
61
+ app.use(express.json());
62
+ app.use(
63
+ createDeclaredVariableRoutes(
64
+ toolManualService,
65
+ secretsVault,
66
+ (req, _res, next) => {
67
+ if (userId) req.toolAuth = { userId, source, scope: 'write' } as typeof req.toolAuth;
68
+ next();
69
+ },
70
+ async () => 'runner@x.eu',
71
+ ),
72
+ );
73
+ httpServer = await new Promise<HttpServer>((r) => {
74
+ const s = app.listen(0, () => r(s));
75
+ });
76
+ const port = (httpServer.address() as { port: number }).port;
77
+ return { base: `http://127.0.0.1:${port}`, resolve };
78
+ }
79
+
80
+ afterEach(async () => {
81
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
82
+ httpServer = undefined;
83
+ vi.restoreAllMocks();
84
+ });
85
+
86
+ describe('POST /agent/local-tools/:slug/variables', () => {
87
+ it('resolves exactly what the manual declares, and reports the rest as missing', async () => {
88
+ const { base, resolve } = await mount({
89
+ manuals: [GIT_TOOL],
90
+ secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
91
+ });
92
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
93
+ expect(res.status).toBe(200);
94
+ expect(await res.json()).toEqual({
95
+ name: 'git',
96
+ variables: { GITHUB_TOKEN: 'ghp_secret' },
97
+ missing: ['OPTIONAL_TOKEN'],
98
+ });
99
+ // The keys asked of the vault come from the FILE, namespaced per manual.
100
+ expect(resolve.mock.calls.map((c) => c[1])).toEqual(['git_GITHUB_TOKEN', 'git_OPTIONAL_TOKEN']);
101
+ });
102
+
103
+ it('returns a variable named __proto__ rather than losing it to the prototype', async () => {
104
+ // `__proto__` is a valid `[A-Za-z0-9_]+` name. On a plain object the
105
+ // assignment sets the prototype, and the resolved secret vanished from the
106
+ // response with nothing to say it had.
107
+ const proto: ToolManualSummary = { ...GIT_TOOL, variables: [{ name: '__proto__', scope: 'admin' }] };
108
+ const { base } = await mount({ manuals: [proto], secrets: { git___proto__: 'p-secret' } });
109
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
110
+ const body = (await res.json()) as { variables: Record<string, string>; missing: string[] };
111
+ expect(body.variables.__proto__).toBe('p-secret');
112
+ expect(body.missing).toEqual([]);
113
+ });
114
+
115
+ it('never caches a response carrying a secret', async () => {
116
+ const { base } = await mount({ manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
117
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
118
+ expect(res.headers.get('cache-control')).toBe('no-store');
119
+ });
120
+
121
+ it('refuses a tool the platform itself executes', async () => {
122
+ // A remote-capable tool's credentials are used server-side; releasing them
123
+ // would egress a secret that had no reason to leave.
124
+ const { base, resolve } = await mount({ manuals: [REMOTE_TOOL], secrets: { billing_BILLING_KEY: 'k' } });
125
+ const res = await fetch(`${base}/agent/local-tools/billing/variables`, { method: 'POST' });
126
+ expect(res.status).toBe(409);
127
+ expect(resolve).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('404s a manual the caller cannot read', async () => {
131
+ const { base, resolve } = await mount({ manuals: [] });
132
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(404);
133
+ expect(resolve).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('403s an unauthenticated caller', async () => {
137
+ const { base } = await mount({ userId: '', manuals: [GIT_TOOL] });
138
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(403);
139
+ });
140
+
141
+ it('refuses a BROWSER SESSION, even one that can read the tool', async () => {
142
+ // `manualAuth` accepts a session JWT, which is right for manual discovery
143
+ // and wrong here: this route hands back secret VALUES, and the Secrets page
144
+ // is deliberately write-only — a field starts empty even when a value
145
+ // exists. A session that could read values back would undo that for anyone
146
+ // with mere READ access to a `.tool`.
147
+ const { base, resolve } = await mount({
148
+ source: 'session',
149
+ manuals: [GIT_TOOL],
150
+ secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
151
+ });
152
+ const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
153
+ expect(res.status).toBe(403);
154
+ expect(await res.text()).not.toContain('ghp_secret');
155
+ expect(resolve).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('allows the machine callers that need it', async () => {
159
+ for (const source of ['external', 'internal'] as const) {
160
+ const { base } = await mount({ source, manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
161
+ expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(200);
162
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
163
+ httpServer = undefined;
164
+ }
165
+ });
166
+ });
@@ -0,0 +1,151 @@
1
+ import express, { type Request, type RequestHandler } from 'express';
2
+ import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
3
+ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
4
+ import type { ISecretsVaultService } from '../secrets-vault/secrets-vault.contract.js';
5
+
6
+ type ResolveUserEmail = (userId: string) => Promise<string | undefined>;
7
+
8
+ /**
9
+ * The ONLY core route that returns secret VALUES.
10
+ *
11
+ * Everywhere else a credential is resolved in-process at tool-call time and
12
+ * never crosses the wire — `resolve()` has one caller, the UTCP variable
13
+ * loader. One runtime outside this process legitimately needs values anyway:
14
+ * the LOCAL MCP server, which executes a `remote: false` `.tool` on the user's
15
+ * own machine and has to expand that manual's `${VAR}` refs there.
16
+ *
17
+ * The discipline that makes this safe, and the reason it is a narrow route
18
+ * rather than a generic "resolve these keys" endpoint: **the caller never names
19
+ * the variables**. It names a knowledge-base file; the server re-reads that
20
+ * file from the DEFAULT branch and resolves exactly what the file declares.
21
+ * The knowledge base is the allowlist, it is reviewable as a diff, and no
22
+ * caller — however privileged its connection key — can widen it.
23
+ *
24
+ * Three further constraints hold here:
25
+ *
26
+ * 1. **Read access is required.** A caller that cannot read the declaring file
27
+ * gets a 404, not an empty object: whether a manual exists is itself
28
+ * information.
29
+ * 2. **Only local manuals.** A remote-capable `.tool`'s credentials are used
30
+ * server-side; handing them to a local caller would egress a secret that had
31
+ * no reason to leave.
32
+ * 3. **Values are never logged, only names.** Every resolve emits one audit
33
+ * line naming the caller, the file and the variable names — enough to
34
+ * reconstruct who was handed what, and never the what itself.
35
+ *
36
+ * Responses are `no-store`: a secret must not sit in an intermediary cache.
37
+ *
38
+ * An overlay that ships its own kind of declaring file adds its own route in
39
+ * this same shape rather than widening this one. The allowlist has to be a file
40
+ * the server itself reads, and a route that understood several file kinds would
41
+ * be one refactor away from taking the list from the caller.
42
+ */
43
+ export function createDeclaredVariableRoutes(
44
+ toolManualService: IToolManualService,
45
+ secretsVault: ISecretsVaultService,
46
+ manualAuth: RequestHandler,
47
+ resolveUserEmail: ResolveUserEmail,
48
+ ): express.Router {
49
+ const router = express.Router();
50
+
51
+ /**
52
+ * Who is asking — and only if it is a MACHINE.
53
+ *
54
+ * `manualAuth` also accepts a browser session JWT, which is right for the
55
+ * rest of the agent surface: manual discovery is a read-only listing a
56
+ * signed-in user may legitimately fetch. It is wrong here. This route hands
57
+ * back secret VALUES, and the Secrets page is deliberately write-only —
58
+ * a field starts empty even when a value exists, and saving replaces rather
59
+ * than reveals. A session token that could read values back would undo that,
60
+ * and would do it for anyone who can merely READ a `.tool`.
61
+ *
62
+ * The caller that needs this is the local MCP server, which authenticates
63
+ * with a connection key or an internal token. A browser has no reason to ask
64
+ * at all, so a session is refused rather than scoped.
65
+ */
66
+ async function machineCaller(req: Request): Promise<{ userId: string; email: string } | null> {
67
+ const auth = req.toolAuth;
68
+ if (!auth?.userId || auth.source === 'session') return null;
69
+ try {
70
+ const email = await resolveUserEmail(auth.userId);
71
+ return email ? { userId: auth.userId, email } : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resolve one variable set, and report which names came back empty.
79
+ *
80
+ * A missing secret is NOT an error here. The runtime that asked is better
81
+ * placed to decide — a local tool may have an optional token, and the
82
+ * execution layer wants to fail the step with the variable's name in the
83
+ * message rather than receive a 500 it can only call "infra". So the value
84
+ * map carries what resolved and `missing` carries the rest.
85
+ */
86
+ async function resolveAll(
87
+ userId: string,
88
+ entries: { name: string; key: string }[],
89
+ ): Promise<{ values: Record<string, string>; missing: string[] }> {
90
+ // Null-prototype: a manual may legitimately declare a variable named
91
+ // `__proto__` (the name grammar is `[A-Za-z0-9_]+`), and on a plain object
92
+ // that assignment silently sets the prototype instead of a property — the
93
+ // secret resolves and then vanishes from the response.
94
+ const values: Record<string, string> = Object.create(null) as Record<string, string>;
95
+ const missing: string[] = [];
96
+ const resolved = await Promise.all(
97
+ entries.map(async (e) => ({ name: e.name, value: await secretsVault.resolve(userId, e.key) })),
98
+ );
99
+ for (const r of resolved) {
100
+ if (typeof r.value === 'string' && r.value.length > 0) values[r.name] = r.value;
101
+ else missing.push(r.name);
102
+ }
103
+ return { values, missing };
104
+ }
105
+
106
+ /**
107
+ * The variables a LOCAL `.tool` declares, for the local MCP server to expand
108
+ * into a tool invocation there. The manual is re-read server-side; the caller
109
+ * sends nothing but the slug.
110
+ */
111
+ router.post('/agent/local-tools/:slug/variables', manualAuth, async (req, res) => {
112
+ const who = await machineCaller(req);
113
+ if (!who) {
114
+ return void res.status(403).json({
115
+ error:
116
+ 'This endpoint releases secret values to a local runtime and is not reachable with a browser session. ' +
117
+ 'Use a connection key.',
118
+ });
119
+ }
120
+ const slug = String(req.params.slug);
121
+ try {
122
+ // `listAccessible` already applies the per-file read verdict, so an
123
+ // unreadable manual is simply absent — same fail-closed read model the
124
+ // catalog uses.
125
+ const manual = (await toolManualService.listAccessible(who.email)).find((m) => m.slug === slug);
126
+ if (!manual) return void res.status(404).json({ error: 'Not found' });
127
+ if (manual.remote !== false) {
128
+ return void res.status(409).json({
129
+ error:
130
+ 'This tool runs on the platform, so its credentials are resolved there and never released. ' +
131
+ 'Only a `remote: false` tool resolves its variables locally.',
132
+ });
133
+ }
134
+ const entries = (manual.variables ?? []).map((v) => ({
135
+ name: v.name,
136
+ key: utcpNamespacedKey(manual.name, v.name),
137
+ }));
138
+ const { values, missing } = await resolveAll(who.userId, entries);
139
+ console.info(
140
+ `[declared-variables] local tool "${manual.path}" resolved for user=${who.userId}: ` +
141
+ `provided=[${Object.keys(values).join(',')}] missing=[${missing.join(',')}]`,
142
+ );
143
+ res.set('Cache-Control', 'no-store').json({ name: manual.name, variables: values, missing });
144
+ } catch (err) {
145
+ console.error('[declared-variables] local tool resolve failed:', err instanceof Error ? err.message : err);
146
+ res.status(500).json({ error: 'Failed to resolve variables' });
147
+ }
148
+ });
149
+
150
+ return router;
151
+ }
@@ -0,0 +1 @@
1
+ export { createDeclaredVariableRoutes } from './declared-variables.routes.js';
@@ -82,7 +82,7 @@ describe('rejectPathsLocked', () => {
82
82
  const h = await makeHarness();
83
83
  workspaceDir = h.workspaceDir;
84
84
 
85
- await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
85
+ await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
86
86
  'knowledge-base/a.md',
87
87
  'knowledge-base/added.md',
88
88
  ]);
@@ -104,7 +104,7 @@ describe('rejectPathsLocked', () => {
104
104
  const h = await makeHarness();
105
105
  workspaceDir = h.workspaceDir;
106
106
  await expect(
107
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, []),
107
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', []),
108
108
  ).resolves.toBeUndefined();
109
109
  expect(h.calls).toEqual([]);
110
110
  });
@@ -119,7 +119,7 @@ describe('rejectPathsLocked', () => {
119
119
  workspaceDir = h.workspaceDir;
120
120
 
121
121
  await expect(
122
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
122
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
123
123
  'knowledge-base/a.md',
124
124
  'knowledge-base/added.md',
125
125
  ]),
@@ -140,7 +140,7 @@ describe('rejectPathsLocked', () => {
140
140
  workspaceDir = h.workspaceDir;
141
141
 
142
142
  await expect(
143
- rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
143
+ rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
144
144
  'knowledge-base/a.md',
145
145
  ]),
146
146
  ).rejects.toBeInstanceOf(WorkflowValidationError);
@@ -45,6 +45,7 @@ export async function rejectPathsLocked(
45
45
  diffService: IDiffService,
46
46
  workspaceId: string,
47
47
  user: AuthUser,
48
+ kbDirName: string,
48
49
  paths: string[],
49
50
  ): Promise<void> {
50
51
  if (paths.length === 0) return;
@@ -54,7 +55,7 @@ export async function rejectPathsLocked(
54
55
  if (total === 0) return;
55
56
  const lockingFs = new LockingFilesystem(
56
57
  { basePath: plan.workspaceDir, contained: true },
57
- { workflow: workflowService, workspaceId, branch, user },
58
+ { workflow: workflowService, workspaceId, branch, user, kbDirName },
58
59
  );
59
60
  try {
60
61
  await lockingFs.writeFiles(
@@ -265,7 +266,7 @@ export function createDiffRoutes(
265
266
  current?.changes.map((c) => c.path) ?? [],
266
267
  );
267
268
  }
268
- await rejectPathsLocked(workflowService, diffService, req.params.id, user, paths);
269
+ await rejectPathsLocked(workflowService, diffService, req.params.id, user, kbDirName, paths);
269
270
  res.json({ session: await diffService.currentSession(req.params.id) });
270
271
  } catch (err) {
271
272
  const { status, body } = toHttpError(err);