@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.
- package/THIRD-PARTY-NOTICES.md +5 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +123 -7
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +4 -3
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +199 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +123 -6
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +5 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- 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
|
-
|
|
155
|
+
const CORE_ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
|
|
143
156
|
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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);
|