@bevel-software/platform-core-backend 0.28.0 → 0.29.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.
- package/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +7 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -14
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +11 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +13 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +5 -2
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +36 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +208 -85
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +7 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +2 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +13 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +24 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +10 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +106 -20
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/kb-template/access.md +2 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +29 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +7 -1
- package/src/core/create-core-services.ts +27 -21
- package/src/core/lifecycle.ts +24 -12
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
- package/src/modules/access/access-control.interface.ts +5 -2
- package/src/modules/access/access-control.service.ts +236 -95
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/index.ts +6 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/git.service.ts +23 -0
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
- package/src/modules/workspace/index.ts +14 -0
- package/src/modules/workspace/workspace.tools.ts +120 -20
- package/src/shared/workspace-id.ts +24 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
10
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
12
|
+
import { createToolHandlerFactory } from '../tool-handler.js';
|
|
13
|
+
import { ToolError, type ToolContext } from '../tool.contract.js';
|
|
14
|
+
import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
|
|
15
|
+
import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
|
|
16
|
+
import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
|
|
17
|
+
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
18
|
+
import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
|
|
19
|
+
import { SpillStore } from '../../workspace/spill-store.js';
|
|
20
|
+
import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
|
|
21
|
+
import { AccessControlService } from '../../access/access-control.service.js';
|
|
22
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
23
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
24
|
+
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
25
|
+
import { BranchNotFoundError } from '../../../shared/domain-errors.js';
|
|
26
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
27
|
+
import { SkillService, registerSkillsTools } from '../../skills/index.js';
|
|
28
|
+
import { defaultBranchWriteVerdicts } from '../index.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `canWrite` on `list_skills`, `get_skill`, `list_files` and `read_file`,
|
|
32
|
+
* driven over the REAL resolver and the real skill catalog on two on-disk
|
|
33
|
+
* clones: the default branch, and a draft whose access rules let anyone
|
|
34
|
+
* write. The verdict must be the default branch's — what can LAND directly —
|
|
35
|
+
* whichever branch the call reads.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const KB = 'knowledge-base';
|
|
39
|
+
const MAIN = 'main';
|
|
40
|
+
const DRAFT = 'eng/draft';
|
|
41
|
+
const ADMIN = 'admin@x.io';
|
|
42
|
+
const ENG = 'eng@x.io';
|
|
43
|
+
|
|
44
|
+
const SKILL = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n\n# ${name}\n`;
|
|
45
|
+
|
|
46
|
+
/** The default branch: write is Admin's, except where a folder opens it. */
|
|
47
|
+
const MAIN_TREE: Record<string, string> = {
|
|
48
|
+
'roles.yaml': `roles:\n Admin:\n - ${ADMIN}\n Engineer:\n - ${ENG}\n`,
|
|
49
|
+
'access.md': '---\nread:\n - everyone\nwrite:\n - Admin\n---\n',
|
|
50
|
+
// The knowledge base's own conventions file, whose frontmatter opens it.
|
|
51
|
+
'AGENTS.md': '---\nwrite:\n - everyone\n---\n# Our conventions\n',
|
|
52
|
+
// A plugin whose access.md grants write to Admin only.
|
|
53
|
+
'Plugins/Everyone/access.md': '---\nread:\n - everyone\nwrite:\n - Admin\n---\n',
|
|
54
|
+
'Plugins/Everyone/html-knowledge-view/SKILL.md': SKILL('html-knowledge-view'),
|
|
55
|
+
// A plugin anyone may write, with one bundled file whose own rules refuse Eng.
|
|
56
|
+
'Plugins/Shared/access.md': '---\nwrite:\n - everyone\n---\n',
|
|
57
|
+
'Plugins/Shared/deck/SKILL.md': SKILL('deck'),
|
|
58
|
+
'Plugins/Shared/deck/reference.md': `---\nwrite:\n - deny Eng <${ENG}>\n---\n# Reference\n`,
|
|
59
|
+
'Plugins/Shared/notes/SKILL.md': SKILL('notes'),
|
|
60
|
+
'Plugins/Shared/notes/scripts/run.sh': 'echo hi\n',
|
|
61
|
+
// Files and folders for list_files.
|
|
62
|
+
'Notes/access.md': '---\nwrite:\n - everyone\n---\n',
|
|
63
|
+
'Notes/open.md': '# Open\n',
|
|
64
|
+
'Notes/locked.md': `---\nwrite:\n - deny Eng <${ENG}>\n---\n# Locked\n`,
|
|
65
|
+
'Notes/Open/inside.md': '# Inside\n',
|
|
66
|
+
'Notes/Sub/access.md': `---\nwrite:\n - deny Eng <${ENG}>\n---\n`,
|
|
67
|
+
'Notes/Sub/inside.md': '# Inside\n',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The draft: the same tree with every write rule thrown open, and two skills only it has. */
|
|
71
|
+
const DRAFT_TREE: Record<string, string> = {
|
|
72
|
+
...MAIN_TREE,
|
|
73
|
+
'access.md': '---\nread:\n - everyone\nwrite:\n - everyone\n---\n',
|
|
74
|
+
'Plugins/Everyone/access.md': '---\nread:\n - everyone\nwrite:\n - everyone\n---\n',
|
|
75
|
+
'Notes/locked.md': '# Locked, unlocked on the draft\n',
|
|
76
|
+
'Notes/Sub/access.md': '---\nwrite:\n - everyone\n---\n',
|
|
77
|
+
'Plugins/Everyone/draft-only/SKILL.md': SKILL('draft-only'),
|
|
78
|
+
'Plugins/Shared/shared-draft/SKILL.md': SKILL('shared-draft'),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let root = '';
|
|
82
|
+
let docCache = '';
|
|
83
|
+
let base = '';
|
|
84
|
+
let server: HttpServer;
|
|
85
|
+
let access: AccessControlService;
|
|
86
|
+
let skills: SkillService;
|
|
87
|
+
let spills: SpillStore;
|
|
88
|
+
let caller = ENG;
|
|
89
|
+
let scope: 'read' | 'write' = 'write';
|
|
90
|
+
/** Calls the access layer took for write verdicts, by method, since the last reset. */
|
|
91
|
+
let writeChecks: { method: string; workspaceId: string; ref?: string; paths: string[] }[] = [];
|
|
92
|
+
/** A deployment's read hook: refuses the reads this matches; every read it is asked about is noted. */
|
|
93
|
+
let refuseRead: ((branch: string, wsPath: string) => boolean) | null = null;
|
|
94
|
+
let readsNoted: { branch: string; wsPath: string }[] = [];
|
|
95
|
+
/** Set, the listing of a skill's committed files fails with it — the git behind `get_skill`'s verdict going away. */
|
|
96
|
+
let listingFails: Error | null = null;
|
|
97
|
+
/** The refs the skill's committed files were listed at, in order. */
|
|
98
|
+
let listedAt: string[] = [];
|
|
99
|
+
const hooks = new WorkflowHooks();
|
|
100
|
+
hooks.onAgentRead(async ({ branch, wsPath = '' }) => {
|
|
101
|
+
readsNoted.push({ branch, wsPath });
|
|
102
|
+
if (refuseRead?.(branch, wsPath)) throw new ToolError('Not in this conversation.', 403);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const wsDir = (branch: string) => join(root, workspaceIdForBranch(branch));
|
|
106
|
+
|
|
107
|
+
const execFileAsync = promisify(execFile);
|
|
108
|
+
const git = async (cwd: string, ...args: string[]): Promise<string> =>
|
|
109
|
+
(await execFileAsync('git', ['-C', cwd, '-c', 'user.email=t@x.io', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args])).stdout;
|
|
110
|
+
/** Commit everything in the default branch's clone: what a direct write is judged by. */
|
|
111
|
+
const commitMain = async (message: string): Promise<void> => {
|
|
112
|
+
const repo = join(wsDir(MAIN), KB);
|
|
113
|
+
await git(repo, 'add', '-A');
|
|
114
|
+
await git(repo, 'commit', '-q', '--allow-empty', '-m', message);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
root = await mkdtemp(join(tmpdir(), 'can-write-'));
|
|
119
|
+
for (const [branch, tree] of [[MAIN, MAIN_TREE], [DRAFT, DRAFT_TREE]] as const) {
|
|
120
|
+
for (const [rel, text] of Object.entries(tree)) {
|
|
121
|
+
const abs = join(wsDir(branch), KB, rel);
|
|
122
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
123
|
+
await writeFile(abs, text);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// The default branch is a real clone with its rules COMMITTED: a verdict is
|
|
127
|
+
// judged at its HEAD, as a direct write is. The draft stays a plain tree;
|
|
128
|
+
// nothing is judged at a ref there.
|
|
129
|
+
await git(join(wsDir(MAIN), KB), 'init', '-q', '-b', MAIN);
|
|
130
|
+
await commitMain('rules');
|
|
131
|
+
const kb = testKbContext({ kbDirName: KB, branchModel: { defaultBranch: MAIN, protectedBranches: [MAIN] } });
|
|
132
|
+
const workspaceService = {
|
|
133
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
134
|
+
ensureRemotesFetched: async () => undefined,
|
|
135
|
+
getOrCreateForBranch: async (branch: string) => {
|
|
136
|
+
if (branch !== MAIN && branch !== DRAFT) throw new BranchNotFoundError(branch);
|
|
137
|
+
return { id: workspaceIdForBranch(branch) };
|
|
138
|
+
},
|
|
139
|
+
readFile: async (id: string, rel: string) => readFile(join(root, id, rel), 'utf-8'),
|
|
140
|
+
} as unknown as WorkspaceService;
|
|
141
|
+
access = new AccessControlService(workspaceService, KB, new NodeFs());
|
|
142
|
+
// The real resolver, with its write checks counted: the cost criterion is
|
|
143
|
+
// ONE batched check per call, against the default branch.
|
|
144
|
+
const counted = new Proxy(access, {
|
|
145
|
+
get(target, prop, receiver) {
|
|
146
|
+
const value = Reflect.get(target, prop, receiver);
|
|
147
|
+
if (prop === 'canWriteBatchAtRef') {
|
|
148
|
+
return (workspaceId: string, ref: string, email: string, paths: string[]) => {
|
|
149
|
+
writeChecks.push({ method: prop, workspaceId, ref, paths });
|
|
150
|
+
return (value as (...a: unknown[]) => unknown).call(target, workspaceId, ref, email, paths);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (prop === 'canWriteBatch' || prop === 'canWrite') {
|
|
154
|
+
return (workspaceId: string, email: string, paths: string[] | string) => {
|
|
155
|
+
writeChecks.push({ method: prop, workspaceId, paths: Array.isArray(paths) ? paths : [paths] });
|
|
156
|
+
return (value as (...a: unknown[]) => unknown).call(target, workspaceId, email, paths);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
160
|
+
},
|
|
161
|
+
}) as unknown as IAccessControl;
|
|
162
|
+
skills = new SkillService(workspaceService, counted, kb, new NodeFs());
|
|
163
|
+
spills = new SpillStore(join(root, 'spills'));
|
|
164
|
+
|
|
165
|
+
const resolve = async (auth: ToolAuth, signal: AbortSignal, sessionId?: string): Promise<ToolContext> => ({
|
|
166
|
+
user: { id: caller, email: caller, name: caller },
|
|
167
|
+
scope: auth.scope,
|
|
168
|
+
source: auth.source,
|
|
169
|
+
sessionId,
|
|
170
|
+
abortSignal: signal,
|
|
171
|
+
workspaceService: workspaceService as never,
|
|
172
|
+
workflowService: {} as never,
|
|
173
|
+
events: {} as never,
|
|
174
|
+
getFilesystem: async (branch: string) => new LocalFilesystem({ basePath: wsDir(branch), contained: true }),
|
|
175
|
+
});
|
|
176
|
+
const app = express();
|
|
177
|
+
app.use(express.json());
|
|
178
|
+
const router = express.Router();
|
|
179
|
+
const toolAuth = ((req, _res, next) => {
|
|
180
|
+
req.toolAuth = { source: 'internal', userId: caller, scope };
|
|
181
|
+
next();
|
|
182
|
+
}) as express.RequestHandler;
|
|
183
|
+
const toolHandler = createToolHandlerFactory(resolve);
|
|
184
|
+
docCache = await mkdtemp(join(tmpdir(), 'can-write-doc-'));
|
|
185
|
+
registerWorkspaceTools(
|
|
186
|
+
new ToolRegistry(),
|
|
187
|
+
router,
|
|
188
|
+
toolAuth,
|
|
189
|
+
toolHandler,
|
|
190
|
+
spills,
|
|
191
|
+
new DocExtractService(docCache),
|
|
192
|
+
counted,
|
|
193
|
+
kb,
|
|
194
|
+
{ recoveryBotEmail: 'recovery-bot@bevel.local', hooks, notes: new ToolDescriptionNotes() },
|
|
195
|
+
new RoutineWritePolicyService(),
|
|
196
|
+
{} as never,
|
|
197
|
+
undefined,
|
|
198
|
+
undefined,
|
|
199
|
+
undefined,
|
|
200
|
+
async () => 'THE PLATFORM GUIDE\n',
|
|
201
|
+
);
|
|
202
|
+
registerSkillsTools(new ToolRegistry(), router, toolAuth, toolHandler, skills, undefined, {
|
|
203
|
+
accessControl: counted,
|
|
204
|
+
defaultWorkspaceId: () => kb.defaultWorkspaceId(),
|
|
205
|
+
// The skill's files as committed: read off the default branch's clone
|
|
206
|
+
// with git, as the server reads them, so a deletion on disk alone does
|
|
207
|
+
// not reach the verdict.
|
|
208
|
+
committedFiles: {
|
|
209
|
+
commitAtRef: async (workspaceId, ref) => (await git(join(root, workspaceId, KB), 'rev-parse', '--verify', `${ref}^{commit}`)).trim(),
|
|
210
|
+
listFilesAtRef: async (workspaceId, ref, folder) => {
|
|
211
|
+
if (listingFails) throw listingFails;
|
|
212
|
+
listedAt.push(ref);
|
|
213
|
+
const out = await git(join(root, workspaceId, KB), 'ls-tree', '-r', '-z', '--name-only', ref, '--', folder);
|
|
214
|
+
return out.split('\0').filter((line) => line.length > 0);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
app.use('/api', router);
|
|
219
|
+
server = await new Promise<HttpServer>((r) => {
|
|
220
|
+
const s = app.listen(0, () => r(s));
|
|
221
|
+
});
|
|
222
|
+
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterAll(async () => {
|
|
226
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
227
|
+
await rm(root, { recursive: true, force: true });
|
|
228
|
+
await rm(docCache, { recursive: true, force: true });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
caller = ENG;
|
|
233
|
+
scope = 'write';
|
|
234
|
+
writeChecks = [];
|
|
235
|
+
refuseRead = null;
|
|
236
|
+
readsNoted = [];
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
async function call<T>(tool: string, body: Record<string, unknown>, as = caller): Promise<T> {
|
|
240
|
+
caller = as;
|
|
241
|
+
const res = await fetch(`${base}/api/agent/tools/${tool}`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
|
|
244
|
+
body: JSON.stringify(body),
|
|
245
|
+
});
|
|
246
|
+
expect(res.status, await res.clone().text()).toBe(200);
|
|
247
|
+
return (await res.json()) as T;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type Listed = { skills: { name: string; canWrite: boolean }[] };
|
|
251
|
+
const verdictsOf = (listed: Listed) => Object.fromEntries(listed.skills.map((s) => [s.name, s.canWrite]));
|
|
252
|
+
|
|
253
|
+
describe('list_skills carries canWrite, judged on SKILL.md by the default branch', () => {
|
|
254
|
+
it('a plugin that grants write to Admin only: false for a non-admin, true for an admin', async () => {
|
|
255
|
+
expect(verdictsOf(await call<Listed>('list_skills', {}, ENG))).toMatchObject({
|
|
256
|
+
'html-knowledge-view': false,
|
|
257
|
+
deck: true,
|
|
258
|
+
notes: true,
|
|
259
|
+
});
|
|
260
|
+
expect(verdictsOf(await call<Listed>('list_skills', {}, ADMIN))).toMatchObject({
|
|
261
|
+
'html-knowledge-view': true,
|
|
262
|
+
deck: true,
|
|
263
|
+
notes: true,
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('on a draft, every verdict is the default branch\'s — a draft-only skill judged where it would land', async () => {
|
|
268
|
+
// The draft's rules let Eng write everything; what lands is the default branch's call.
|
|
269
|
+
expect(verdictsOf(await call<Listed>('list_skills', { branch: DRAFT }, ENG))).toEqual({
|
|
270
|
+
'html-knowledge-view': false,
|
|
271
|
+
'draft-only': false,
|
|
272
|
+
deck: true,
|
|
273
|
+
notes: true,
|
|
274
|
+
'shared-draft': true,
|
|
275
|
+
});
|
|
276
|
+
expect(writeChecks.every((c) => c.workspaceId === workspaceIdForBranch(MAIN))).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('two users in turn, within the catalog cache, each see only their own verdicts', async () => {
|
|
280
|
+
const first = verdictsOf(await call<Listed>('list_skills', {}, ENG));
|
|
281
|
+
const second = verdictsOf(await call<Listed>('list_skills', {}, ADMIN));
|
|
282
|
+
const third = verdictsOf(await call<Listed>('list_skills', {}, ENG));
|
|
283
|
+
expect(first['html-knowledge-view']).toBe(false);
|
|
284
|
+
expect(second['html-knowledge-view']).toBe(true);
|
|
285
|
+
expect(third).toEqual(first);
|
|
286
|
+
// The shared catalog itself never carries a verdict.
|
|
287
|
+
for (const s of await skills.listSkills()) expect(s).not.toHaveProperty('canWrite');
|
|
288
|
+
for (const s of await skills.listSkills(ENG)) expect(s).not.toHaveProperty('canWrite');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('makes one batched write check per call, against the default branch at its HEAD', async () => {
|
|
292
|
+
await call<Listed>('list_skills', { branch: DRAFT });
|
|
293
|
+
expect(writeChecks).toEqual([
|
|
294
|
+
expect.objectContaining({ method: 'canWriteBatchAtRef', workspaceId: workspaceIdForBranch(MAIN), ref: 'HEAD' }),
|
|
295
|
+
]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('get_skill carries canWrite for the whole skill', () => {
|
|
300
|
+
type Got = { skill: { canWrite: boolean; files: string[] } };
|
|
301
|
+
|
|
302
|
+
it('is true only when every file of the skill may be written', async () => {
|
|
303
|
+
// SKILL.md is writable (list says true) but a bundled file's own rules refuse Eng.
|
|
304
|
+
expect((await call<Got>('get_skill', { name: 'deck' }, ENG)).skill.canWrite).toBe(false);
|
|
305
|
+
expect((await call<Got>('get_skill', { name: 'notes' }, ENG)).skill.canWrite).toBe(true);
|
|
306
|
+
expect((await call<Got>('get_skill', { name: 'html-knowledge-view' }, ENG)).skill.canWrite).toBe(false);
|
|
307
|
+
expect((await call<Got>('get_skill', { name: 'html-knowledge-view' }, ADMIN)).skill.canWrite).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('judges a bundled `file` on its own, and a draft read by the default branch', async () => {
|
|
311
|
+
const file = await call<{ file: { canWrite: boolean } }>('get_skill', { name: 'deck', file: 'reference.md' }, ENG);
|
|
312
|
+
expect(file.file.canWrite).toBe(false);
|
|
313
|
+
writeChecks = [];
|
|
314
|
+
const onDraft = await call<Got>('get_skill', { name: 'html-knowledge-view', branch: DRAFT }, ENG);
|
|
315
|
+
expect(onDraft.skill.canWrite).toBe(false);
|
|
316
|
+
expect(writeChecks).toHaveLength(1);
|
|
317
|
+
expect(writeChecks[0].workspaceId).toBe(workspaceIdForBranch(MAIN));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('judges the files the skill has on the default branch, so a draft that drops a denied file stays false', async () => {
|
|
321
|
+
const onDraft = join(wsDir(DRAFT), KB, 'Plugins/Shared/deck/reference.md');
|
|
322
|
+
await rm(onDraft);
|
|
323
|
+
try {
|
|
324
|
+
const draft = await call<Got>('get_skill', { name: 'deck', branch: DRAFT }, ENG);
|
|
325
|
+
// The draft's copy no longer bundles reference.md, but the default branch's does, and its rules refuse Eng.
|
|
326
|
+
expect(draft.skill.files.some((f) => f.endsWith('reference.md'))).toBe(false);
|
|
327
|
+
expect(draft.skill.canWrite).toBe(false);
|
|
328
|
+
expect((await call<Got>('get_skill', { name: 'deck', branch: MAIN }, ENG)).skill.canWrite).toBe(false);
|
|
329
|
+
expect(writeChecks.filter((c) => c.method === 'canWriteBatchAtRef')).toHaveLength(2);
|
|
330
|
+
} finally {
|
|
331
|
+
await writeFile(onDraft, MAIN_TREE['Plugins/Shared/deck/reference.md']);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('judges the files as COMMITTED on the default branch: a bundled file deleted on disk but not committed still counts', async () => {
|
|
336
|
+
const onMain = join(wsDir(MAIN), KB, 'Plugins/Shared/deck/reference.md');
|
|
337
|
+
await rm(onMain);
|
|
338
|
+
try {
|
|
339
|
+
// Gone from the clone's working tree, still at HEAD with its rules: the
|
|
340
|
+
// verdict stays false, as the direct write of it would be refused.
|
|
341
|
+
expect((await call<Got>('get_skill', { name: 'deck' }, ENG)).skill.canWrite).toBe(false);
|
|
342
|
+
// Committed, the skill is SKILL.md alone, which Eng may write.
|
|
343
|
+
await commitMain('drop reference.md');
|
|
344
|
+
expect((await call<Got>('get_skill', { name: 'deck' }, ENG)).skill.canWrite).toBe(true);
|
|
345
|
+
} finally {
|
|
346
|
+
await writeFile(onMain, MAIN_TREE['Plugins/Shared/deck/reference.md']);
|
|
347
|
+
await commitMain('reference.md again');
|
|
348
|
+
}
|
|
349
|
+
expect((await call<Got>('get_skill', { name: 'deck' }, ENG)).skill.canWrite).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('judges a skill only the draft has by its draft files, where they would land', async () => {
|
|
353
|
+
expect((await call<Got>('get_skill', { name: 'draft-only', branch: DRAFT }, ENG)).skill.canWrite).toBe(false);
|
|
354
|
+
expect((await call<Got>('get_skill', { name: 'draft-only', branch: DRAFT }, ADMIN)).skill.canWrite).toBe(true);
|
|
355
|
+
expect((await call<Got>('get_skill', { name: 'shared-draft', branch: DRAFT }, ENG)).skill.canWrite).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('lists the files and judges them at ONE commit, the one HEAD named when asked', async () => {
|
|
359
|
+
listedAt = [];
|
|
360
|
+
writeChecks = [];
|
|
361
|
+
expect((await call<Got>('get_skill', { name: 'notes' }, ENG)).skill.canWrite).toBe(true);
|
|
362
|
+
const head = (await git(join(wsDir(MAIN), KB), 'rev-parse', 'HEAD')).trim();
|
|
363
|
+
// Not "HEAD", which a commit landing in between would move: the commit itself.
|
|
364
|
+
expect(listedAt).toEqual([head]);
|
|
365
|
+
expect(writeChecks.map((c) => c.ref)).toEqual([head]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('a folder of the skill\'s name holding other tracked files on the default branch is not the skill', async () => {
|
|
369
|
+
// The default branch has no shared-draft SKILL.md, only an unrelated file
|
|
370
|
+
// under that folder whose rules refuse Eng: not the skill, so the draft's
|
|
371
|
+
// own files are what is judged, and Eng may write those.
|
|
372
|
+
const stray = join(wsDir(MAIN), KB, 'Plugins/Shared/shared-draft/locked.md');
|
|
373
|
+
await mkdir(dirname(stray), { recursive: true });
|
|
374
|
+
await writeFile(stray, `---\nwrite:\n - deny Eng <${ENG}>\n---\n# Not the skill\n`);
|
|
375
|
+
await commitMain('a stray under the draft-only skill\'s folder');
|
|
376
|
+
try {
|
|
377
|
+
expect((await call<Got>('get_skill', { name: 'shared-draft', branch: DRAFT }, ENG)).skill.canWrite).toBe(true);
|
|
378
|
+
} finally {
|
|
379
|
+
await rm(stray);
|
|
380
|
+
await commitMain('stray gone');
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('a blank `version` is the latest default copy: no second lookup of the released skill', async () => {
|
|
385
|
+
const loads = vi.spyOn(skills, 'getSkill');
|
|
386
|
+
try {
|
|
387
|
+
expect((await call<Got>('get_skill', { name: 'notes', version: ' ' }, ENG)).skill.canWrite).toBe(true);
|
|
388
|
+
expect(loads).toHaveBeenCalledTimes(1);
|
|
389
|
+
} finally {
|
|
390
|
+
loads.mockRestore();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('fails closed when the files committed on the default branch cannot be listed', async () => {
|
|
395
|
+
const draftRead = { name: 'html-knowledge-view', branch: DRAFT };
|
|
396
|
+
// The baseline: the admin may write every file of it on the default branch.
|
|
397
|
+
expect((await call<Got>('get_skill', draftRead, ADMIN)).skill.canWrite).toBe(true);
|
|
398
|
+
// The draft read itself goes through; the listing of the skill's committed files fails.
|
|
399
|
+
listingFails = new Error('git is unavailable');
|
|
400
|
+
writeChecks = [];
|
|
401
|
+
try {
|
|
402
|
+
const got = await call<Got>('get_skill', draftRead, ADMIN);
|
|
403
|
+
expect(got.skill.canWrite).toBe(false);
|
|
404
|
+
// Nothing to judge, so the access layer is not asked.
|
|
405
|
+
expect(writeChecks).toEqual([]);
|
|
406
|
+
} finally {
|
|
407
|
+
listingFails = null;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('list_files carries canWrite per entry', () => {
|
|
413
|
+
type Listing = { entries: { name: string; type: string; canWrite: boolean }[] };
|
|
414
|
+
const byName = (l: Listing) => Object.fromEntries(l.entries.map((e) => [e.name, e.canWrite]));
|
|
415
|
+
|
|
416
|
+
it('a file by its own rules, a folder by whether a file may be added directly inside it', async () => {
|
|
417
|
+
const listing = await call<Listing>('list_files', { branch: MAIN, path: `${KB}/Notes` }, ENG);
|
|
418
|
+
expect(byName(listing)).toMatchObject({
|
|
419
|
+
'open.md': true,
|
|
420
|
+
'locked.md': false, // its own frontmatter denies Eng
|
|
421
|
+
Open: true, // inherits Notes/'s grant
|
|
422
|
+
Sub: false, // its own access.md denies Eng
|
|
423
|
+
});
|
|
424
|
+
expect(writeChecks).toHaveLength(1);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('on a draft, reports what the default branch would accept', async () => {
|
|
428
|
+
const listing = await call<Listing>('list_files', { branch: DRAFT, path: `${KB}/Notes` }, ENG);
|
|
429
|
+
expect(byName(listing)).toMatchObject({ 'open.md': true, 'locked.md': false, Open: true, Sub: false });
|
|
430
|
+
const plugin = await call<Listing>('list_files', { branch: DRAFT, path: `${KB}/Plugins/Everyone` }, ENG);
|
|
431
|
+
expect(byName(plugin)).toMatchObject({ 'html-knowledge-view': false, 'draft-only': false });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('the repository folder at the workspace root is judged as the repository root', async () => {
|
|
435
|
+
const asEng = await call<Listing>('list_files', { branch: MAIN }, ENG);
|
|
436
|
+
const asAdmin = await call<Listing>('list_files', { branch: MAIN }, ADMIN);
|
|
437
|
+
expect(byName(asEng)[KB]).toBe(false);
|
|
438
|
+
expect(byName(asAdmin)[KB]).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// The verdict is what a direct write is judged by: the rules COMMITTED on
|
|
442
|
+
// the default branch. A grant written to the clone's working tree but not
|
|
443
|
+
// yet committed — a queued save, an edit in flight — is not yet in force.
|
|
444
|
+
it('judges the rules as committed, not a grant still only in the working tree', async () => {
|
|
445
|
+
const rules = join(wsDir(MAIN), KB, 'Notes/Sub/access.md');
|
|
446
|
+
try {
|
|
447
|
+
await writeFile(rules, '---\nwrite:\n - everyone\n---\n');
|
|
448
|
+
expect(byName(await call<Listing>('list_files', { branch: MAIN, path: `${KB}/Notes` }, ENG)).Sub).toBe(false);
|
|
449
|
+
await commitMain('open Sub');
|
|
450
|
+
expect(byName(await call<Listing>('list_files', { branch: MAIN, path: `${KB}/Notes` }, ENG)).Sub).toBe(true);
|
|
451
|
+
} finally {
|
|
452
|
+
await writeFile(rules, MAIN_TREE['Notes/Sub/access.md']);
|
|
453
|
+
await commitMain('close Sub again');
|
|
454
|
+
}
|
|
455
|
+
expect(byName(await call<Listing>('list_files', { branch: MAIN, path: `${KB}/Notes` }, ENG)).Sub).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('read_file carries canWrite for the file read', () => {
|
|
460
|
+
type Read = { path: string; content: string; canWrite?: boolean };
|
|
461
|
+
|
|
462
|
+
it('says what the default branch would accept, on either branch', async () => {
|
|
463
|
+
expect((await call<Read>('read_file', { branch: MAIN, path: `${KB}/Notes/open.md` })).canWrite).toBe(true);
|
|
464
|
+
expect((await call<Read>('read_file', { branch: MAIN, path: `${KB}/Notes/locked.md` })).canWrite).toBe(false);
|
|
465
|
+
expect((await call<Read>('read_file', { branch: DRAFT, path: `${KB}/Notes/locked.md` })).canWrite).toBe(false);
|
|
466
|
+
expect((await call<Read>('read_file', { branch: MAIN, path: `${KB}/Notes/locked.md` }, ADMIN)).canWrite).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('answers for the knowledge base\'s own file at the root AGENTS.md', async () => {
|
|
470
|
+
const read = await call<Read>('read_file', { branch: MAIN, path: `${KB}/AGENTS.md` }, ENG);
|
|
471
|
+
expect(read.content).toContain('THE PLATFORM GUIDE');
|
|
472
|
+
// Root write is Admin's; the file's own frontmatter opens it to everyone.
|
|
473
|
+
expect(read.canWrite).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('gives the default branch\'s AGENTS.md verdict on a draft that deleted the file', async () => {
|
|
477
|
+
const onDraft = join(wsDir(DRAFT), KB, 'AGENTS.md');
|
|
478
|
+
await rm(onDraft);
|
|
479
|
+
try {
|
|
480
|
+
for (const as of [ADMIN, ENG]) {
|
|
481
|
+
const main = await call<Read>('read_file', { branch: MAIN, path: `${KB}/AGENTS.md` }, as);
|
|
482
|
+
const draft = await call<Read>('read_file', { branch: DRAFT, path: `${KB}/AGENTS.md` }, as);
|
|
483
|
+
// The draft serves the platform's guide alone; what lands is still the default branch's file.
|
|
484
|
+
expect(draft.content).not.toContain('Our conventions');
|
|
485
|
+
expect(draft.content).toBe('THE PLATFORM GUIDE\n');
|
|
486
|
+
expect(main.canWrite, as).toBe(true);
|
|
487
|
+
expect(draft.canWrite, as).toBe(true);
|
|
488
|
+
}
|
|
489
|
+
} finally {
|
|
490
|
+
await writeFile(onDraft, MAIN_TREE['AGENTS.md']);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('gives false on a draft that added AGENTS.md when the default branch has none', async () => {
|
|
495
|
+
const onMain = join(wsDir(MAIN), KB, 'AGENTS.md');
|
|
496
|
+
// Gone from the default branch as COMMITTED, where the verdict is judged,
|
|
497
|
+
// not only from its working tree.
|
|
498
|
+
await rm(onMain);
|
|
499
|
+
await commitMain('no AGENTS.md');
|
|
500
|
+
try {
|
|
501
|
+
for (const as of [ADMIN, ENG]) {
|
|
502
|
+
const main = await call<Read>('read_file', { branch: MAIN, path: `${KB}/AGENTS.md` }, as);
|
|
503
|
+
const draft = await call<Read>('read_file', { branch: DRAFT, path: `${KB}/AGENTS.md` }, as);
|
|
504
|
+
expect(draft.content).toContain('Our conventions');
|
|
505
|
+
expect(main.canWrite, as).toBe(false);
|
|
506
|
+
expect(draft.canWrite, as).toBe(false);
|
|
507
|
+
}
|
|
508
|
+
} finally {
|
|
509
|
+
await writeFile(onMain, MAIN_TREE['AGENTS.md']);
|
|
510
|
+
await commitMain('AGENTS.md again');
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('asks the default branch\'s read hook before judging AGENTS.md from a draft, and gives false on refusal', async () => {
|
|
515
|
+
const path = `${KB}/AGENTS.md`;
|
|
516
|
+
const allowed = await call<Read>('read_file', { branch: DRAFT, path }, ADMIN);
|
|
517
|
+
expect(allowed.canWrite).toBe(true);
|
|
518
|
+
expect(readsNoted).toContainEqual({ branch: MAIN, wsPath: path });
|
|
519
|
+
// The draft read itself is allowed; only the look at the default branch's file is refused.
|
|
520
|
+
refuseRead = (branch, wsPath) => branch === MAIN && wsPath === path;
|
|
521
|
+
writeChecks = [];
|
|
522
|
+
const refused = await call<Read>('read_file', { branch: DRAFT, path }, ADMIN);
|
|
523
|
+
expect(refused.content).toContain('Our conventions');
|
|
524
|
+
expect(refused.canWrite).toBe(false);
|
|
525
|
+
expect(writeChecks).toEqual([]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('carries no canWrite for a spill ref', async () => {
|
|
529
|
+
const { ref } = await spills.write('spilled');
|
|
530
|
+
const read = await call<Read>('read_file', { branch: MAIN, path: ref });
|
|
531
|
+
expect(read.content).toBe('spilled');
|
|
532
|
+
expect(read).not.toHaveProperty('canWrite');
|
|
533
|
+
expect(writeChecks).toEqual([]);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe('a read-only credential', () => {
|
|
538
|
+
it('always gets canWrite: false, without asking the access layer', async () => {
|
|
539
|
+
scope = 'read';
|
|
540
|
+
const listed = await call<Listed>('list_skills', {}, ADMIN);
|
|
541
|
+
expect(listed.skills.length).toBeGreaterThan(0);
|
|
542
|
+
expect(listed.skills.every((s) => s.canWrite === false)).toBe(true);
|
|
543
|
+
expect((await call<{ skill: { canWrite: boolean } }>('get_skill', { name: 'notes' }, ADMIN)).skill.canWrite).toBe(false);
|
|
544
|
+
const listing = await call<{ entries: { canWrite: boolean }[] }>('list_files', { branch: MAIN, path: `${KB}/Notes` }, ADMIN);
|
|
545
|
+
expect(listing.entries.every((e) => e.canWrite === false)).toBe(true);
|
|
546
|
+
expect((await call<{ canWrite: boolean }>('read_file', { branch: MAIN, path: `${KB}/Notes/open.md` }, ADMIN)).canWrite).toBe(false);
|
|
547
|
+
expect(writeChecks).toEqual([]);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe('defaultBranchWriteVerdicts fails closed', () => {
|
|
552
|
+
const ctx = { scope: 'write' as const, user: { id: 'u', email: ENG, name: 'u' } };
|
|
553
|
+
|
|
554
|
+
it('a check that throws is false for every path', async () => {
|
|
555
|
+
const verdicts = await defaultBranchWriteVerdicts(
|
|
556
|
+
{ accessControl: { canWriteBatchAtRef: async () => Promise.reject(new Error('no clone')) }, defaultWorkspaceId: () => 'ws' },
|
|
557
|
+
ctx,
|
|
558
|
+
['a.md', 'b.md'],
|
|
559
|
+
);
|
|
560
|
+
expect([...verdicts]).toEqual([['a.md', false], ['b.md', false]]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('no rules at HEAD yet (null) is the gate\'s bootstrap: true for every path, as the direct write is allowed', async () => {
|
|
564
|
+
const verdicts = await defaultBranchWriteVerdicts(
|
|
565
|
+
{ accessControl: { canWriteBatchAtRef: async () => null }, defaultWorkspaceId: () => 'ws' },
|
|
566
|
+
ctx,
|
|
567
|
+
['a.md', 'b.md'],
|
|
568
|
+
);
|
|
569
|
+
expect([...verdicts]).toEqual([['a.md', true], ['b.md', true]]);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('a path the answer leaves out is false', async () => {
|
|
573
|
+
const verdicts = await defaultBranchWriteVerdicts(
|
|
574
|
+
{ accessControl: { canWriteBatchAtRef: async () => new Map([['a.md', true]]) }, defaultWorkspaceId: () => 'ws' },
|
|
575
|
+
ctx,
|
|
576
|
+
['a.md', 'b.md'],
|
|
577
|
+
);
|
|
578
|
+
expect(verdicts.get('a.md')).toBe(true);
|
|
579
|
+
expect(verdicts.get('b.md')).toBe(false);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
2
|
+
import type { ToolContext } from './tool.contract.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the read tools need to say whether a change can land directly: the
|
|
6
|
+
* access layer and the default branch's workspace. A function for the
|
|
7
|
+
* workspace, not a value, because first-run setup may name the default branch
|
|
8
|
+
* after the tools are mounted.
|
|
9
|
+
*/
|
|
10
|
+
export interface DefaultBranchWriteDeps {
|
|
11
|
+
accessControl: Pick<IAccessControl, 'canWriteBatchAtRef'>;
|
|
12
|
+
defaultWorkspaceId: () => string;
|
|
13
|
+
/**
|
|
14
|
+
* The files under a folder as COMMITTED at a ref of a workspace, repo-root
|
|
15
|
+
* relative — what `get_skill`'s whole-skill verdict covers: the skill's
|
|
16
|
+
* files as the default branch's `HEAD` has them, not as its working tree
|
|
17
|
+
* has them, so a bundled file deleted on disk but not yet committed is
|
|
18
|
+
* still judged. Absent, `get_skill` answers `false` for the whole skill.
|
|
19
|
+
*/
|
|
20
|
+
committedFiles?: {
|
|
21
|
+
/** The commit `ref` names right now, or null when it names none (an unborn HEAD). */
|
|
22
|
+
commitAtRef(workspaceId: string, ref: string): Promise<string | null>;
|
|
23
|
+
listFilesAtRef(workspaceId: string, ref: string, folder: string): Promise<string[]>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `canWrite` verdicts `list_skills`, `get_skill`, `list_files` and
|
|
29
|
+
* `read_file` report: whether the caller may commit a change to each
|
|
30
|
+
* repo-relative path DIRECTLY on the default branch, under the access rules
|
|
31
|
+
* COMMITTED there. `false` means "make the change on a branch and open a
|
|
32
|
+
* change request", never "you cannot write at all" — on a draft, anyone who
|
|
33
|
+
* may read a path may write it, so "writable on the branch read" would say
|
|
34
|
+
* yes exactly where a direct write is later refused.
|
|
35
|
+
*
|
|
36
|
+
* Always judged against the default branch, whichever branch the call read:
|
|
37
|
+
* that is where a direct change lands. And judged at that clone's `HEAD`, the
|
|
38
|
+
* way the direct write itself is gated (`assertCanWriteAtRef` in the git
|
|
39
|
+
* service), not from the rules as the working tree has them: a grant written
|
|
40
|
+
* to disk but not yet committed — a queued save, an edit in flight — would
|
|
41
|
+
* otherwise say `true` for a write that is then refused. ONE batched check
|
|
42
|
+
* per call.
|
|
43
|
+
*
|
|
44
|
+
* Fails closed: a read-only credential gets `false` for every path without
|
|
45
|
+
* the access layer being asked; a check that throws, or a path the answer
|
|
46
|
+
* leaves out, is `false`. Read the result with `=== true`. The one answer
|
|
47
|
+
* that is not closed is the gate's own: `null` — no rules at `HEAD` yet, a
|
|
48
|
+
* knowledge base being set up — is where the direct write is allowed
|
|
49
|
+
* (bootstrap, `assertCanWriteAtRef`), so every path is `true` there; a
|
|
50
|
+
* `false` would send the agent to a change request the gate does not ask
|
|
51
|
+
* for.
|
|
52
|
+
*
|
|
53
|
+
* Per caller and never cached here: a verdict belongs to one caller's answer,
|
|
54
|
+
* not to anything shared across users.
|
|
55
|
+
*/
|
|
56
|
+
export async function defaultBranchWriteVerdicts(
|
|
57
|
+
deps: DefaultBranchWriteDeps,
|
|
58
|
+
ctx: Pick<ToolContext, 'scope' | 'user'>,
|
|
59
|
+
paths: readonly string[],
|
|
60
|
+
/**
|
|
61
|
+
* The commit to judge at, when the caller has already pinned one — a
|
|
62
|
+
* caller that read a file set at a commit judges it at THAT commit, so a
|
|
63
|
+
* commit landing in between cannot pair one snapshot's files with
|
|
64
|
+
* another's rules. Default: `HEAD` of the default branch's clone.
|
|
65
|
+
*/
|
|
66
|
+
opts: { ref?: string } = {},
|
|
67
|
+
): Promise<Map<string, boolean>> {
|
|
68
|
+
const verdicts = new Map<string, boolean>(paths.map((p) => [p, false]));
|
|
69
|
+
if (ctx.scope !== 'write' || paths.length === 0) return verdicts;
|
|
70
|
+
try {
|
|
71
|
+
const answer = await deps.accessControl.canWriteBatchAtRef(deps.defaultWorkspaceId(), opts.ref ?? 'HEAD', ctx.user.email, [
|
|
72
|
+
...new Set(paths),
|
|
73
|
+
]);
|
|
74
|
+
// Bootstrap: no rules at HEAD, and the gate lets the write through.
|
|
75
|
+
if (!answer) return new Map(paths.map((p) => [p, true]));
|
|
76
|
+
for (const p of paths) verdicts.set(p, answer.get(p) === true);
|
|
77
|
+
} catch {
|
|
78
|
+
// A verdict that cannot be computed is `false`: the agent then proposes
|
|
79
|
+
// the change, which can land; a wrong `true` would offer a direct write
|
|
80
|
+
// that is refused.
|
|
81
|
+
}
|
|
82
|
+
return verdicts;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The clause each of the four tools carries in its description. Said the
|
|
87
|
+
* same way everywhere so an agent reading any one of them learns the rule.
|
|
88
|
+
*/
|
|
89
|
+
export const CAN_WRITE_CLAUSE =
|
|
90
|
+
'`canWrite: false` means use a branch and a change request, never a direct write; a read-only connection can do neither.';
|