@bevel-software/platform-core-backend 0.19.0 → 0.20.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +22 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +10 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts +54 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -0
- package/dist/modules/audit/agent-audit.service.js +290 -0
- package/dist/modules/audit/agent-audit.service.js.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts +40 -0
- package/dist/modules/audit/agent-rest-audit.middleware.d.ts.map +1 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js +75 -0
- package/dist/modules/audit/agent-rest-audit.middleware.js.map +1 -0
- package/dist/modules/audit/audit.contract.d.ts +145 -0
- package/dist/modules/audit/audit.contract.d.ts.map +1 -0
- package/dist/modules/audit/audit.contract.js +35 -0
- package/dist/modules/audit/audit.contract.js.map +1 -0
- package/dist/modules/audit/audit.routes.d.ts +21 -0
- package/dist/modules/audit/audit.routes.d.ts.map +1 -0
- package/dist/modules/audit/audit.routes.js +157 -0
- package/dist/modules/audit/audit.routes.js.map +1 -0
- package/dist/modules/audit/event-classifier.d.ts +52 -0
- package/dist/modules/audit/event-classifier.d.ts.map +1 -0
- package/dist/modules/audit/event-classifier.js +123 -0
- package/dist/modules/audit/event-classifier.js.map +1 -0
- package/dist/modules/audit/index.d.ts +7 -0
- package/dist/modules/audit/index.d.ts.map +1 -0
- package/dist/modules/audit/index.js +7 -0
- package/dist/modules/audit/index.js.map +1 -0
- package/dist/modules/audit/request-audit.d.ts +57 -0
- package/dist/modules/audit/request-audit.d.ts.map +1 -0
- package/dist/modules/audit/request-audit.js +117 -0
- package/dist/modules/audit/request-audit.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +414 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +99 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +6 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts +9 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/git-guarded-filesystem.js +16 -3
- package/dist/modules/kb-fs/git-guarded-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +32 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +8 -1
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +9 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +48 -5
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +59 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +180 -8
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +10 -2
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +32 -6
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +21 -5
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +22 -9
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +10 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.js +1 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +8 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +9 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +8 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +9 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +49 -13
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git-internals.d.ts +7 -1
- package/dist/shared/git-internals.d.ts.map +1 -1
- package/dist/shared/git-internals.js +67 -9
- package/dist/shared/git-internals.js.map +1 -1
- package/migrations/0012_agent_audit.sql +41 -0
- package/migrations/0013_agent_identity_and_key_soft_delete.sql +29 -0
- package/migrations/meta/0012_snapshot.json +2247 -0
- package/migrations/meta/0013_snapshot.json +2259 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +4 -4
- package/src/core/create-core-server.ts +28 -0
- package/src/core/create-core-services.ts +13 -0
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +370 -0
- package/src/modules/audit/__tests__/agent-rest-audit.middleware.test.ts +113 -0
- package/src/modules/audit/__tests__/audit.routes.test.ts +201 -0
- package/src/modules/audit/__tests__/event-classifier.test.ts +121 -0
- package/src/modules/audit/agent-audit.service.ts +316 -0
- package/src/modules/audit/agent-rest-audit.middleware.ts +83 -0
- package/src/modules/audit/audit.contract.ts +164 -0
- package/src/modules/audit/audit.routes.ts +168 -0
- package/src/modules/audit/event-classifier.ts +155 -0
- package/src/modules/audit/index.ts +14 -0
- package/src/modules/audit/request-audit.ts +141 -0
- package/src/modules/database/core-schema.ts +104 -0
- package/src/modules/diff/diff.service.ts +6 -1
- package/src/modules/kb-fs/git-guarded-filesystem.ts +16 -3
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +183 -13
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -2
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +24 -4
- package/src/modules/mcp/__tests__/mcp.routes.stateless.test.ts +1 -1
- package/src/modules/mcp/__tests__/mcp.service.test.ts +120 -3
- package/src/modules/mcp/mcp-auth.middleware.ts +31 -0
- package/src/modules/mcp/mcp.routes.ts +11 -1
- package/src/modules/mcp/mcp.service.ts +62 -4
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +184 -6
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +35 -0
- package/src/modules/settings/deployment-settings.service.ts +47 -8
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +29 -8
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +6 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +22 -5
- package/src/modules/tool-auth/external-api-key.service.ts +25 -8
- package/src/modules/tool-auth/internal-token.service.ts +11 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +17 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +135 -44
- package/src/modules/workspace/workspace.service.ts +9 -0
- package/src/modules/workspace/workspace.tools.ts +47 -11
- package/src/shared/__tests__/git-internals.test.ts +41 -0
- package/src/shared/git-internals.ts +61 -6
- package/dist/modules/access/access-errors.d.ts +0 -34
- package/dist/modules/access/access-errors.d.ts.map +0 -1
- package/dist/modules/access/access-errors.js +0 -40
- package/dist/modules/access/access-errors.js.map +0 -1
- package/dist/modules/access/access-splice.d.ts +0 -140
- package/dist/modules/access/access-splice.d.ts.map +0 -1
- package/dist/modules/access/access-splice.js +0 -389
- package/dist/modules/access/access-splice.js.map +0 -1
- package/dist/modules/access/group-files.d.ts +0 -83
- package/dist/modules/access/group-files.d.ts.map +0 -1
- package/dist/modules/access/group-files.js +0 -167
- package/dist/modules/access/group-files.js.map +0 -1
- package/dist/modules/access/kb-read-filter.d.ts +0 -41
- package/dist/modules/access/kb-read-filter.d.ts.map +0 -1
- package/dist/modules/access/kb-read-filter.js +0 -60
- package/dist/modules/access/kb-read-filter.js.map +0 -1
- package/dist/modules/access/render-roles-yaml.d.ts +0 -22
- package/dist/modules/access/render-roles-yaml.d.ts.map +0 -1
- package/dist/modules/access/render-roles-yaml.js +0 -56
- package/dist/modules/access/render-roles-yaml.js.map +0 -1
- package/dist/modules/access/roles-yaml-guard.d.ts +0 -56
- package/dist/modules/access/roles-yaml-guard.d.ts.map +0 -1
- package/dist/modules/access/roles-yaml-guard.js +0 -79
- package/dist/modules/access/roles-yaml-guard.js.map +0 -1
- package/dist/modules/diff/diff-paths.d.ts +0 -2
- package/dist/modules/diff/diff-paths.d.ts.map +0 -1
- package/dist/modules/diff/diff-paths.js +0 -9
- package/dist/modules/diff/diff-paths.js.map +0 -1
- package/dist/modules/groups/group-provision.service.d.ts +0 -130
- package/dist/modules/groups/group-provision.service.d.ts.map +0 -1
- package/dist/modules/groups/group-provision.service.js +0 -288
- package/dist/modules/groups/group-provision.service.js.map +0 -1
- package/dist/modules/groups/groups.contract.d.ts +0 -106
- package/dist/modules/groups/groups.contract.d.ts.map +0 -1
- package/dist/modules/groups/groups.contract.js +0 -36
- package/dist/modules/groups/groups.contract.js.map +0 -1
- package/dist/modules/groups/groups.routes.d.ts +0 -42
- package/dist/modules/groups/groups.routes.d.ts.map +0 -1
- package/dist/modules/groups/groups.routes.js +0 -379
- package/dist/modules/groups/groups.routes.js.map +0 -1
- package/dist/modules/groups/groups.service.d.ts +0 -60
- package/dist/modules/groups/groups.service.d.ts.map +0 -1
- package/dist/modules/groups/groups.service.js +0 -172
- package/dist/modules/groups/groups.service.js.map +0 -1
- package/dist/modules/groups/index.d.ts +0 -7
- package/dist/modules/groups/index.d.ts.map +0 -1
- package/dist/modules/groups/index.js +0 -6
- package/dist/modules/groups/index.js.map +0 -1
- package/dist/modules/groups/join-proposals.d.ts +0 -53
- package/dist/modules/groups/join-proposals.d.ts.map +0 -1
- package/dist/modules/groups/join-proposals.js +0 -67
- package/dist/modules/groups/join-proposals.js.map +0 -1
- package/dist/modules/groups/join-requests.service.d.ts +0 -81
- package/dist/modules/groups/join-requests.service.d.ts.map +0 -1
- package/dist/modules/groups/join-requests.service.js +0 -135
- package/dist/modules/groups/join-requests.service.js.map +0 -1
- package/dist/modules/mcp/mcp-session-store.d.ts +0 -74
- package/dist/modules/mcp/mcp-session-store.d.ts.map +0 -1
- package/dist/modules/mcp/mcp-session-store.js +0 -131
- package/dist/modules/mcp/mcp-session-store.js.map +0 -1
- package/dist/modules/workflow/file-change-notifier.d.ts +0 -38
- package/dist/modules/workflow/file-change-notifier.d.ts.map +0 -1
- package/dist/modules/workflow/file-change-notifier.js +0 -22
- package/dist/modules/workflow/file-change-notifier.js.map +0 -1
- package/dist/modules/workflow/git/branch-name.d.ts +0 -10
- package/dist/modules/workflow/git/branch-name.d.ts.map +0 -1
- package/dist/modules/workflow/git/branch-name.js +0 -76
- package/dist/modules/workflow/git/branch-name.js.map +0 -1
- package/dist/modules/workflow/git/clone-config.d.ts +0 -61
- package/dist/modules/workflow/git/clone-config.d.ts.map +0 -1
- package/dist/modules/workflow/git/clone-config.js +0 -69
- package/dist/modules/workflow/git/clone-config.js.map +0 -1
- package/dist/modules/workflow/git/mutex.d.ts +0 -11
- package/dist/modules/workflow/git/mutex.d.ts.map +0 -1
- package/dist/modules/workflow/git/mutex.js +0 -23
- package/dist/modules/workflow/git/mutex.js.map +0 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +0 -137
- package/dist/modules/workflow/locking-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/locking-filesystem.js +0 -553
- package/dist/modules/workflow/locking-filesystem.js.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.d.ts +0 -24
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +0 -1
- package/dist/modules/workflow/read-only-filesystem.js +0 -39
- package/dist/modules/workflow/read-only-filesystem.js.map +0 -1
- package/dist/modules/workflow/workflow.errors.d.ts +0 -197
- package/dist/modules/workflow/workflow.errors.d.ts.map +0 -1
- package/dist/modules/workflow/workflow.errors.js +0 -298
- package/dist/modules/workflow/workflow.errors.js.map +0 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +0 -30
- package/dist/modules/workspace/bevel-ignore.d.ts.map +0 -1
- package/dist/modules/workspace/bevel-ignore.js +0 -61
- package/dist/modules/workspace/bevel-ignore.js.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +0 -36
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.interface.js +0 -2
- package/dist/modules/workspace/kb-seed.interface.js.map +0 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +0 -125
- package/dist/modules/workspace/kb-seed.service.d.ts.map +0 -1
- package/dist/modules/workspace/kb-seed.service.js +0 -535
- package/dist/modules/workspace/kb-seed.service.js.map +0 -1
- package/dist/modules/workspace/plugins-migration.d.ts +0 -50
- package/dist/modules/workspace/plugins-migration.d.ts.map +0 -1
- package/dist/modules/workspace/plugins-migration.js +0 -379
- package/dist/modules/workspace/plugins-migration.js.map +0 -1
- package/dist/shared/fs-errors.d.ts +0 -8
- package/dist/shared/fs-errors.d.ts.map +0 -1
- package/dist/shared/fs-errors.js +0 -11
- package/dist/shared/fs-errors.js.map +0 -1
- package/dist/shared/fs-walk.d.ts +0 -23
- package/dist/shared/fs-walk.d.ts.map +0 -1
- package/dist/shared/fs-walk.js +0 -42
- package/dist/shared/fs-walk.js.map +0 -1
- package/dist/shared/hash-email.d.ts +0 -11
- package/dist/shared/hash-email.d.ts.map +0 -1
- package/dist/shared/hash-email.js +0 -14
- package/dist/shared/hash-email.js.map +0 -1
- package/dist/shared/kb-walk.d.ts +0 -52
- package/dist/shared/kb-walk.d.ts.map +0 -1
- package/dist/shared/kb-walk.js +0 -76
- package/dist/shared/kb-walk.js.map +0 -1
|
@@ -144,14 +144,25 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
async listForUser(userId: string): Promise<ExternalApiKeySummary[]> {
|
|
147
|
+
// A key the owner deleted for good is gone from THEIR listings — that is
|
|
148
|
+
// what the deletion means to them; the row lives on for the Audit log.
|
|
147
149
|
const rows = await this.db
|
|
148
150
|
.select()
|
|
149
151
|
.from(externalApiKeys)
|
|
150
|
-
.where(eq(externalApiKeys.userId, userId))
|
|
152
|
+
.where(and(eq(externalApiKeys.userId, userId), isNull(externalApiKeys.deletedAt)))
|
|
151
153
|
.orderBy(desc(externalApiKeys.createdAt));
|
|
152
154
|
return rows.map(toSummary);
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
async ownerOf(id: string): Promise<string | null> {
|
|
158
|
+
const [row] = await this.db
|
|
159
|
+
.select({ userId: externalApiKeys.userId })
|
|
160
|
+
.from(externalApiKeys)
|
|
161
|
+
.where(eq(externalApiKeys.id, id))
|
|
162
|
+
.limit(1);
|
|
163
|
+
return row?.userId ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
155
166
|
async listForDeployment(): Promise<AdminExternalApiKeySummary[]> {
|
|
156
167
|
// Owner joined in so the admin overview is one round-trip; ordered by
|
|
157
168
|
// owner email so per-account grouping is a linear pass, newest key
|
|
@@ -219,14 +230,16 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
|
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
async remove(id: string, userId: string): Promise<void> {
|
|
222
|
-
// Only revoked rows may be
|
|
233
|
+
// Only revoked rows may be deleted — deleting an active key would
|
|
223
234
|
// silently cut off a live agent. Scope by userId so a user can only
|
|
224
235
|
// delete their own tokens. Validate first (so we can return a precise
|
|
225
|
-
// not-found vs still-active error), then
|
|
236
|
+
// not-found vs still-active error), then mark the row deleted.
|
|
226
237
|
const [existing] = await this.db
|
|
227
238
|
.select({ revokedAt: externalApiKeys.revokedAt })
|
|
228
239
|
.from(externalApiKeys)
|
|
229
|
-
.where(
|
|
240
|
+
.where(
|
|
241
|
+
and(eq(externalApiKeys.id, id), eq(externalApiKeys.userId, userId), isNull(externalApiKeys.deletedAt)),
|
|
242
|
+
)
|
|
230
243
|
.limit(1);
|
|
231
244
|
if (!existing) {
|
|
232
245
|
throw new TokenNotFoundError();
|
|
@@ -236,16 +249,19 @@ export class ExternalApiKeyService implements IExternalApiKeyService {
|
|
|
236
249
|
throw new TokenStillActiveError();
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
252
|
+
// A deletion in name: the row stays, marked, because the Audit log's
|
|
253
|
+
// events hang off it and the trail must outlive the key's owner's wish
|
|
254
|
+
// to be rid of it. The key was revoked already, so nothing can use it;
|
|
255
|
+
// deleting hides it from the owner and tells an admin it was deleted.
|
|
242
256
|
await this.db
|
|
243
|
-
.
|
|
257
|
+
.update(externalApiKeys)
|
|
258
|
+
.set({ deletedAt: new Date() })
|
|
244
259
|
.where(
|
|
245
260
|
and(
|
|
246
261
|
eq(externalApiKeys.id, id),
|
|
247
262
|
eq(externalApiKeys.userId, userId),
|
|
248
263
|
isNotNull(externalApiKeys.revokedAt),
|
|
264
|
+
isNull(externalApiKeys.deletedAt),
|
|
249
265
|
),
|
|
250
266
|
);
|
|
251
267
|
}
|
|
@@ -271,5 +287,6 @@ function toSummary(row: typeof externalApiKeys.$inferSelect): ExternalApiKeySumm
|
|
|
271
287
|
lastUsedAt: row.lastUsedAt ? row.lastUsedAt.getTime() : null,
|
|
272
288
|
revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
|
|
273
289
|
revokedBy: (row.revokedBy as RevokedBy | null) ?? null,
|
|
290
|
+
deletedAt: row.deletedAt ? row.deletedAt.getTime() : null,
|
|
274
291
|
};
|
|
275
292
|
}
|
|
@@ -37,6 +37,16 @@ export interface InternalTokenClaim {
|
|
|
37
37
|
* expected to carry `sessionId` on the tool body, not in the token.
|
|
38
38
|
*/
|
|
39
39
|
externalProxy?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The agent connection (`agent_connections.id`) an external-proxy token
|
|
42
|
+
* stands in for. The LOCAL MCP server swaps its OAuth grant for one of these
|
|
43
|
+
* at `/api/mcp/local-token` and presents it everywhere the grant would have
|
|
44
|
+
* gone, so without this claim every call it makes would arrive with a user
|
|
45
|
+
* and no agent — unattributable in the Audit log. Carried, never decided:
|
|
46
|
+
* the exchange copies it from the verified grant. Absent on every other
|
|
47
|
+
* internal token.
|
|
48
|
+
*/
|
|
49
|
+
connectionId?: string;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
interface SignedPayload extends InternalTokenClaim {
|
|
@@ -137,6 +147,7 @@ export class InternalTokenService {
|
|
|
137
147
|
...(typeof payload.sessionId === 'string' ? { sessionId: payload.sessionId } : {}),
|
|
138
148
|
...(typeof payload.focusedBranch === 'string' ? { focusedBranch: payload.focusedBranch } : {}),
|
|
139
149
|
...(payload.externalProxy === true ? { externalProxy: true } : {}),
|
|
150
|
+
...(typeof payload.connectionId === 'string' ? { connectionId: payload.connectionId } : {}),
|
|
140
151
|
};
|
|
141
152
|
}
|
|
142
153
|
|
|
@@ -34,6 +34,14 @@ export interface ToolAuth {
|
|
|
34
34
|
* never carries it and must name the branch explicitly.
|
|
35
35
|
*/
|
|
36
36
|
focusedBranch?: string;
|
|
37
|
+
/**
|
|
38
|
+
* The agent connection an `externalProxy` token stands in for — the local
|
|
39
|
+
* server's exchanged grant names one (see `InternalTokenClaim.connectionId`).
|
|
40
|
+
* Only that grant carries it: the hosted proxy's own loopback tokens do
|
|
41
|
+
* not, and a connection key has a `tokenId` instead. What the REST audit
|
|
42
|
+
* recorder attributes a direct tool call to.
|
|
43
|
+
*/
|
|
44
|
+
connectionId?: string;
|
|
37
45
|
/** Always `'write'` now (the middleware sets it for both internal + external — neither credential carries scope). The read path is dormant until consumer agents are removed. */
|
|
38
46
|
scope: 'read' | 'write';
|
|
39
47
|
}
|
|
@@ -129,7 +137,15 @@ export function createTokenVerifier(
|
|
|
129
137
|
const claim = internalTokenService.verify(token);
|
|
130
138
|
if (!claim) return { ok: false, status: 401, message: 'Invalid or expired internal token' };
|
|
131
139
|
return claim.externalProxy
|
|
132
|
-
? {
|
|
140
|
+
? {
|
|
141
|
+
ok: true,
|
|
142
|
+
auth: {
|
|
143
|
+
source: 'external',
|
|
144
|
+
userId: claim.userId,
|
|
145
|
+
...(claim.connectionId ? { connectionId: claim.connectionId } : {}),
|
|
146
|
+
scope: 'write',
|
|
147
|
+
},
|
|
148
|
+
}
|
|
133
149
|
: { ok: true, auth: { source: 'internal', userId: claim.userId, sessionId: claim.sessionId, focusedBranch: claim.focusedBranch, scope: 'write' } };
|
|
134
150
|
}
|
|
135
151
|
|
|
@@ -59,15 +59,50 @@ function fileForms(name: string): Record<string, string> {
|
|
|
59
59
|
'leading slash': `/${KB}/.git/${name}`,
|
|
60
60
|
dotted: `${KB}/Notes/../.git/${name}`,
|
|
61
61
|
'dot segments': `./${KB}/./.git/${name}`,
|
|
62
|
+
// The five families production measured answering the path rule's 400
|
|
63
|
+
// instead of this rule's 403 (parent-climb, dot-segment, backslash,
|
|
64
|
+
// climb-out, absolute). `dotted`, `dot segments` and `backslashed` above
|
|
65
|
+
// are three of them; these are the two the set was missing.
|
|
66
|
+
'climb-out': `../${KB}/.git/${name}`,
|
|
67
|
+
// Production's own spelling, kept verbatim: it names a directory that
|
|
68
|
+
// exists on no test machine, so it is the LEXICAL half this covers.
|
|
69
|
+
absolute: `/app/apps/server/workspaces/main/${KB}/.git/${name}`,
|
|
70
|
+
// The same family pointed at this run's workspace, so the resolved half
|
|
71
|
+
// is exercised too — the folder it names is really there.
|
|
72
|
+
'absolute, in workspace': `{{WORKSPACE_DIR}}/${KB}/.git/${name}`,
|
|
62
73
|
encoded: `${KB}/%2egit/${name}`,
|
|
63
74
|
'double-encoded': `${KB}/%252Egit/${name}`,
|
|
64
75
|
'upper-case': `${KB}/.GIT/${name}`,
|
|
65
76
|
'mixed-case': `${KB}/.Git/${name}`,
|
|
66
77
|
backslashed: `${KB}\\.git\\${name}`,
|
|
67
78
|
symlinked: `${KB}/gitlink/${name}`,
|
|
79
|
+
// A LINK into the folder, reached through each of those same five
|
|
80
|
+
// spellings. Nothing in these names is `.git`, so only the resolved half
|
|
81
|
+
// of the rule sees them — and while that half ran after the normaliser,
|
|
82
|
+
// every one of them was answered by the path rule instead. This is the
|
|
83
|
+
// shape that has now broken twice; it is spelled out per family so a
|
|
84
|
+
// failure names the one that regressed.
|
|
85
|
+
'symlinked, parent-climb': `${KB}/Notes/../gitlink/${name}`,
|
|
86
|
+
'symlinked, dot segments': `./${KB}/./gitlink/${name}`,
|
|
87
|
+
'symlinked, backslashed': `${KB}\\gitlink\\${name}`,
|
|
88
|
+
'symlinked, climb-out': `../${KB}/gitlink/${name}`,
|
|
89
|
+
'symlinked, absolute': `{{WORKSPACE_DIR}}/${KB}/gitlink/${name}`,
|
|
90
|
+
'symlinked, chained link': `${KB}/Notes/../chained/${name}`,
|
|
91
|
+
// A link named `..link` sitting in the WORKSPACE root: relative to the
|
|
92
|
+
// root this rule judges against, the spelling begins with `..`, which is
|
|
93
|
+
// the one shape a `startsWith('..')` climb test swallows. Spelled from
|
|
94
|
+
// anywhere else (`knowledge-base/..link/…`) it never begins with `..` and
|
|
95
|
+
// pins nothing.
|
|
96
|
+
'symlinked, dotted name at the root': `..link/${name}`,
|
|
68
97
|
};
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The absolute forms name the workspace on disk, which only exists once the
|
|
102
|
+
* temp root does — so they carry a placeholder until the test runs.
|
|
103
|
+
*/
|
|
104
|
+
const resolved = (form: string): string => form.replace('{{WORKSPACE_DIR}}', workspaceDir);
|
|
105
|
+
|
|
71
106
|
/** Every spelling of the git folder ITSELF, for the operations that take a directory. */
|
|
72
107
|
const DIR_FORMS: Record<string, string> = {
|
|
73
108
|
plain: `${KB}/.git`,
|
|
@@ -77,6 +112,20 @@ const DIR_FORMS: Record<string, string> = {
|
|
|
77
112
|
'upper-case': `${KB}/.GIT`,
|
|
78
113
|
symlinked: `${KB}/gitlink`,
|
|
79
114
|
'symlinked, chained': `${KB}/chained`,
|
|
115
|
+
'symlinked, parent-climb': `${KB}/Notes/../gitlink`,
|
|
116
|
+
'symlinked, dot segments': `./${KB}/./gitlink`,
|
|
117
|
+
'symlinked, backslashed': `${KB}\\gitlink`,
|
|
118
|
+
'symlinked, climb-out': `../${KB}/gitlink`,
|
|
119
|
+
'symlinked, absolute': `{{WORKSPACE_DIR}}/${KB}/gitlink`,
|
|
120
|
+
'symlinked, dotted name at the root': `..link`,
|
|
121
|
+
// The folder ITSELF in the same five families the file forms carry, so every
|
|
122
|
+
// directory-taking operation — list, mkdir, delete, folder download, unzip
|
|
123
|
+
// destination — has a regression case for each of them too.
|
|
124
|
+
'parent-climb': `${KB}/Notes/../.git`,
|
|
125
|
+
'dot segments': `./${KB}/./.git`,
|
|
126
|
+
backslashed: `${KB}\\.git`,
|
|
127
|
+
'climb-out': `../${KB}/.git`,
|
|
128
|
+
absolute: `{{WORKSPACE_DIR}}/${KB}/.git`,
|
|
80
129
|
};
|
|
81
130
|
|
|
82
131
|
const FILE_FORMS = { ...fileForms('config'), 'symlinked file': `${KB}/cfglink` };
|
|
@@ -107,6 +156,9 @@ beforeEach(async () => {
|
|
|
107
156
|
await symlink('.git', join(kb, 'gitlink'));
|
|
108
157
|
await symlink('.git/config', join(kb, 'cfglink'));
|
|
109
158
|
await symlink('gitlink', join(kb, 'chained'));
|
|
159
|
+
// An ordinary name that merely BEGINS with dots — not a climb, and a link by
|
|
160
|
+
// that name reaches the folder like any other.
|
|
161
|
+
await symlink(join(KB, '.git'), join(workspaceDir, '..link'));
|
|
110
162
|
const zip = new AdmZip();
|
|
111
163
|
zip.addFile('extracted.md', Buffer.from('# extracted\n'));
|
|
112
164
|
zip.writeZip(join(kb, 'archive.zip'));
|
|
@@ -153,28 +205,27 @@ async function listen(app: express.Express): Promise<{ server: Server; baseUrl:
|
|
|
153
205
|
}
|
|
154
206
|
|
|
155
207
|
/**
|
|
156
|
-
* The
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* The refusal, whichever rule got there first. Pass the form's name and a
|
|
167
|
-
* spelling the normaliser refuses is checked against ITS answer; leave it out
|
|
168
|
-
* and only the git refusal will do.
|
|
208
|
+
* The ONE refusal, for every spelling.
|
|
209
|
+
*
|
|
210
|
+
* This used to make an exception: the path normaliser refuses a `..` segment,
|
|
211
|
+
* a `.` segment, a backslash and an absolute path as PATHS, and for a while
|
|
212
|
+
* that answer — a 400 quoting the spelling back — reached those spellings of a
|
|
213
|
+
* git path first. Production measured it on all thirteen file tools. The git
|
|
214
|
+
* rule now reads the caller's raw spelling before anything may rewrite or
|
|
215
|
+
* refuse it, so no form is answered by anything but the sanitized 403; the
|
|
216
|
+
* `form` argument is kept so a failure names the spelling that broke.
|
|
169
217
|
*/
|
|
170
218
|
async function expectRefused(res: Response, form?: string): Promise<unknown> {
|
|
171
219
|
const body = (await res.json()) as Record<string, unknown>;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
220
|
+
expect(res.status, form).toBe(403);
|
|
221
|
+
// The WHOLE body, not a search through it: the sanitized message stands
|
|
222
|
+
// alone, and no field beside it can carry a spelling of the caller's path or
|
|
223
|
+
// a "use this instead" correction. Two shapes exist — the tool surface
|
|
224
|
+
// answers `{ error }`, the routes add the error's `kind` — so the one the
|
|
225
|
+
// answer claims is the one it is held to, exactly.
|
|
226
|
+
expect(body, form).toEqual(
|
|
227
|
+
'kind' in body ? { kind: 'git-internals', error: GIT_INTERNALS_MESSAGE } : { error: GIT_INTERNALS_MESSAGE },
|
|
228
|
+
);
|
|
178
229
|
return body;
|
|
179
230
|
}
|
|
180
231
|
|
|
@@ -276,14 +327,15 @@ describe('workspace tools refuse the git folder', () => {
|
|
|
276
327
|
for (const [op, args] of fileOps) {
|
|
277
328
|
const tool = op.split(' ')[0];
|
|
278
329
|
describe(op, () => {
|
|
279
|
-
it.each(Object.entries(FILE_FORMS))('%s form', async (form,
|
|
330
|
+
it.each(Object.entries(FILE_FORMS))('%s form', async (form, raw) => {
|
|
331
|
+
const p = resolved(raw);
|
|
280
332
|
await expectRefused(await call(tool, args(p)), form);
|
|
281
333
|
});
|
|
282
334
|
|
|
283
335
|
it('answers a missing path exactly as an existing one', async () => {
|
|
284
336
|
for (const form of Object.keys(MISSING_FORMS)) {
|
|
285
|
-
const existing = await call(tool, args(fileForms('config')[form]));
|
|
286
|
-
const missing = await call(tool, args(MISSING_FORMS[form]));
|
|
337
|
+
const existing = await call(tool, args(resolved(fileForms('config')[form])));
|
|
338
|
+
const missing = await call(tool, args(resolved(MISSING_FORMS[form])));
|
|
287
339
|
expect(missing.status, form).toBe(existing.status);
|
|
288
340
|
// Compared with the file name put back. A spelling the PATH rule
|
|
289
341
|
// refuses is answered on the spelling alone, and that answer QUOTES
|
|
@@ -301,7 +353,8 @@ describe('workspace tools refuse the git folder', () => {
|
|
|
301
353
|
|
|
302
354
|
for (const [op, args] of dirOps) {
|
|
303
355
|
const tool = op.split(' ')[0];
|
|
304
|
-
it.each(Object.entries(DIR_FORMS))(`${op} — %s form`, async (form,
|
|
356
|
+
it.each(Object.entries(DIR_FORMS))(`${op} — %s form`, async (form, raw) => {
|
|
357
|
+
const p = resolved(raw);
|
|
305
358
|
await expectRefused(await call(tool, args(p)), form);
|
|
306
359
|
});
|
|
307
360
|
}
|
|
@@ -444,14 +497,15 @@ describe('workspace routes refuse the git folder', () => {
|
|
|
444
497
|
// No form passed: on this surface the git guard is mounted ahead of every
|
|
445
498
|
// handler on the `/workspace/:id` prefix, so it answers before the
|
|
446
499
|
// normaliser is ever asked — the one 403, in every spelling, as before.
|
|
447
|
-
it.each(Object.entries(FILE_FORMS))('%s form', async (_form,
|
|
500
|
+
it.each(Object.entries(FILE_FORMS))('%s form', async (_form, raw) => {
|
|
501
|
+
const p = resolved(raw);
|
|
448
502
|
await expectRefused(await send(p));
|
|
449
503
|
});
|
|
450
504
|
|
|
451
505
|
it('answers a missing path exactly as an existing one', async () => {
|
|
452
506
|
for (const form of Object.keys(MISSING_FORMS)) {
|
|
453
|
-
const existing = await send(fileForms('config')[form]);
|
|
454
|
-
const missing = await send(MISSING_FORMS[form]);
|
|
507
|
+
const existing = await send(resolved(fileForms('config')[form]));
|
|
508
|
+
const missing = await send(resolved(MISSING_FORMS[form]));
|
|
455
509
|
expect(missing.status).toBe(existing.status);
|
|
456
510
|
expect(await missing.json()).toEqual(await existing.json());
|
|
457
511
|
}
|
|
@@ -460,7 +514,8 @@ describe('workspace routes refuse the git folder', () => {
|
|
|
460
514
|
}
|
|
461
515
|
|
|
462
516
|
for (const [name, send] of dirRoutes) {
|
|
463
|
-
it.each(Object.entries(DIR_FORMS))(`${name} — %s form`, async (_form,
|
|
517
|
+
it.each(Object.entries(DIR_FORMS))(`${name} — %s form`, async (_form, raw) => {
|
|
518
|
+
const p = resolved(raw);
|
|
464
519
|
await expectRefused(await send(p));
|
|
465
520
|
});
|
|
466
521
|
}
|
|
@@ -533,23 +588,58 @@ describe('WorkspaceService refuses the git folder on its own', () => {
|
|
|
533
588
|
];
|
|
534
589
|
|
|
535
590
|
for (const [name, run] of ops) {
|
|
536
|
-
|
|
591
|
+
// `readFileAtRef` reads a path out of a git REF, never off the working
|
|
592
|
+
// tree, so no link on disk is followed and the link spellings do not
|
|
593
|
+
// apply to it — a ref read of a link's path yields the link's own blob
|
|
594
|
+
// (the target's NAME), never anything from inside the folder.
|
|
595
|
+
it.each(
|
|
596
|
+
Object.entries(fileForms('config')).filter(
|
|
597
|
+
([form]) => !(name === 'readFileAtRef' && form.startsWith('symlinked')),
|
|
598
|
+
),
|
|
599
|
+
)(
|
|
537
600
|
`${name} — %s form`,
|
|
538
|
-
async (form,
|
|
539
|
-
const err = await run(
|
|
540
|
-
|
|
541
|
-
// above), so it is not a workspace path and does not meet the
|
|
542
|
-
// normaliser; every other op does, and for the spellings the path rule
|
|
543
|
-
// refuses that refusal is the one that answers.
|
|
544
|
-
if (UNSPELLABLE.has(form) && name !== 'readFileAtRef') {
|
|
545
|
-
expect((err as Error).message, form).toMatch(/is outside the knowledge base repository/);
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
expect(err).toBeInstanceOf(GitInternalsError);
|
|
601
|
+
async (form, raw) => {
|
|
602
|
+
const err = await run(resolved(raw)).catch((e: unknown) => e);
|
|
603
|
+
expect(err, form).toBeInstanceOf(GitInternalsError);
|
|
549
604
|
},
|
|
550
605
|
);
|
|
551
606
|
}
|
|
552
607
|
|
|
608
|
+
it('a ref read of a link path reads the REF, not the folder the link points into', async () => {
|
|
609
|
+
// The exception the matrix above excludes, asserted rather than assumed:
|
|
610
|
+
// `readFileAtRef` runs `git show <ref>:<path>`, which reads out of the
|
|
611
|
+
// object database and never follows a link on disk. A ref read of a link's
|
|
612
|
+
// path yields the link's own blob — the target's NAME — so there is
|
|
613
|
+
// nothing from inside the folder to refuse, and refusing it would take a
|
|
614
|
+
// legitimate read away. The git rule still refuses every LITERAL spelling.
|
|
615
|
+
const gitRunner = {
|
|
616
|
+
defaultTimeoutMs: 1000,
|
|
617
|
+
run: vi.fn(async (_cwd: string, args: string[]) => {
|
|
618
|
+
// What `git show` really answers for a link's path: its target name.
|
|
619
|
+
expect(args[0]).toBe('show');
|
|
620
|
+
return { stdout: '.git\n', stderr: '', code: 0 };
|
|
621
|
+
}),
|
|
622
|
+
};
|
|
623
|
+
const refService = new WorkspaceService(root, 'https://example.invalid/kb.git', KB, new NodeFs(), 'x-access-token', gitRunner as never);
|
|
624
|
+
|
|
625
|
+
for (const [form, raw] of Object.entries(fileForms('config'))) {
|
|
626
|
+
if (!form.startsWith('symlinked')) continue;
|
|
627
|
+
// The service takes a repo-relative path here, as its callers pass it.
|
|
628
|
+
const repoPath = resolved(raw).replace(`${KB}/`, '');
|
|
629
|
+
const result = await refService.readFileAtRef(WS, 'main', repoPath).catch((e: unknown) => e);
|
|
630
|
+
expect(result, form).not.toBeInstanceOf(GitInternalsError);
|
|
631
|
+
}
|
|
632
|
+
expect(gitRunner.run).toHaveBeenCalled();
|
|
633
|
+
|
|
634
|
+
// …while a literal git path at a ref is still refused, whatever its form.
|
|
635
|
+
for (const [form, raw] of Object.entries(fileForms('config'))) {
|
|
636
|
+
if (form.startsWith('symlinked')) continue;
|
|
637
|
+
const repoPath = resolved(raw).replace(`${KB}/`, '');
|
|
638
|
+
const err = await refService.readFileAtRef(WS, 'main', repoPath).catch((e: unknown) => e);
|
|
639
|
+
expect(err, form).toBeInstanceOf(Error);
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
553
643
|
it('a folder download leaves out a git folder spelled in another case', async () => {
|
|
554
644
|
const upper = join(workspaceDir, KB, 'Notes', '.GIT');
|
|
555
645
|
await mkdir(upper, { recursive: true });
|
|
@@ -641,7 +731,7 @@ describe('the route guard on its own', () => {
|
|
|
641
731
|
|
|
642
732
|
it('refuses every spelling before the route runs, even unauthenticated', async () => {
|
|
643
733
|
userId = undefined;
|
|
644
|
-
const spellings = Object.values(FILE_FORMS);
|
|
734
|
+
const spellings = Object.values(FILE_FORMS).map(resolved);
|
|
645
735
|
for (const p of spellings) {
|
|
646
736
|
const res = await fetch(`${baseUrl}/api/workspace/${WS}/anything?path=${encodeURIComponent(p)}`);
|
|
647
737
|
// Every spelling that NAMES the folder is refused with no disk touched.
|
|
@@ -702,7 +792,8 @@ describe('DiffService refuses the git folder on its own', () => {
|
|
|
702
792
|
];
|
|
703
793
|
|
|
704
794
|
for (const [name, run] of ops) {
|
|
705
|
-
it.each(Object.entries(FILE_FORMS))(`${name} — %s form`, async (_form,
|
|
795
|
+
it.each(Object.entries(FILE_FORMS))(`${name} — %s form`, async (_form, raw) => {
|
|
796
|
+
const p = resolved(raw);
|
|
706
797
|
await expect(run(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
707
798
|
});
|
|
708
799
|
}
|
|
@@ -740,7 +831,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
|
|
|
740
831
|
{ basePath: workspaceDir, contained: true },
|
|
741
832
|
{ workflow: workflow as unknown as IWorkflowService, workspaceId: WS, branch: BRANCH, user: USER, kbDirName: KB },
|
|
742
833
|
);
|
|
743
|
-
for (const p of Object.values(FILE_FORMS)) {
|
|
834
|
+
for (const p of Object.values(FILE_FORMS).map(resolved)) {
|
|
744
835
|
await expect(lockingFs.readFile(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
745
836
|
await expect(lockingFs.stat(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
746
837
|
await expect(lockingFs.writeFile(p, 'x')).rejects.toBeInstanceOf(GitInternalsError);
|
|
@@ -750,7 +841,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
|
|
|
750
841
|
await expect(lockingFs.moveFile(p, `${KB}/Notes/b.md`)).rejects.toBeInstanceOf(GitInternalsError);
|
|
751
842
|
await expect(lockingFs.writeFiles([{ path: p, content: 'x' }], 'batch')).rejects.toBeInstanceOf(GitInternalsError);
|
|
752
843
|
}
|
|
753
|
-
for (const p of Object.values(DIR_FORMS)) {
|
|
844
|
+
for (const p of Object.values(DIR_FORMS).map(resolved)) {
|
|
754
845
|
await expect(lockingFs.readdir(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
755
846
|
await expect(lockingFs.mkdir(`${p}/new`)).rejects.toBeInstanceOf(GitInternalsError);
|
|
756
847
|
}
|
|
@@ -760,7 +851,7 @@ describe('agent filesystems refuse the git folder on their own', () => {
|
|
|
760
851
|
|
|
761
852
|
it('ReadOnlyFilesystem answers a git path with the git refusal, not the read-only one', async () => {
|
|
762
853
|
const readOnly = new ReadOnlyFilesystem({ basePath: workspaceDir, contained: true });
|
|
763
|
-
for (const p of Object.values(FILE_FORMS)) {
|
|
854
|
+
for (const p of Object.values(FILE_FORMS).map(resolved)) {
|
|
764
855
|
await expect(readOnly.readFile(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
765
856
|
await expect(readOnly.exists(p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
766
857
|
await expect(readOnly.writeFile(p, 'x')).rejects.toBeInstanceOf(GitInternalsError);
|
|
@@ -995,6 +995,14 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
995
995
|
* is the path the operation will use, and a spelling that normalises inside
|
|
996
996
|
* and resolves outside is exactly the miss this check exists to catch.
|
|
997
997
|
*
|
|
998
|
+
* The git rule reads the caller's RAW spelling before either step — the
|
|
999
|
+
* whole rule, links resolved, not just the spelling. Those two refuse
|
|
1000
|
+
* `.`/`..`, backslashes and absolute paths with a message that quotes the
|
|
1001
|
+
* path back and names a corrected one, and a git path spelled any of those
|
|
1002
|
+
* ways was answered by THAT rather than by the one sanitized refusal
|
|
1003
|
+
* (measured in production), as was a LINK into the folder spelled the same
|
|
1004
|
+
* ways. Which rule speaks is not the caller's to choose.
|
|
1005
|
+
*
|
|
998
1006
|
* Callers use the returned `relativePath` for everything downstream — the
|
|
999
1007
|
* git-internals check, the diff baseline, the lock-free path turns, their own
|
|
1000
1008
|
* error messages — so what is checked is what is written. Nine inline
|
|
@@ -1006,6 +1014,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
1006
1014
|
wsPath: string,
|
|
1007
1015
|
): Promise<{ workspaceDir: string; relativePath: string; absolutePath: string }> {
|
|
1008
1016
|
const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
|
|
1017
|
+
await assertNotGitInternals(workspaceDir, wsPath); // The RAW spelling, first — see above.
|
|
1009
1018
|
const relativePath = normalizeWorkspacePath(wsPath, this.kbDirName);
|
|
1010
1019
|
const absolutePath = path.resolve(workspaceDir, relativePath);
|
|
1011
1020
|
assertWithinDirectory(absolutePath, this.repoRoot(workspaceDir));
|
|
@@ -23,7 +23,7 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
|
23
23
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
24
24
|
import { assertInsideRepo, assertRepoRootNameFreeArgs, normalizePathArgs } from '../kb-fs/repo-path.js';
|
|
25
25
|
import { GitGuardedFilesystem } from '../kb-fs/git-guarded-filesystem.js';
|
|
26
|
-
import { assertNoGitInternalsSegment, hasGitInternalsSegment } from '../../shared/git-internals.js';
|
|
26
|
+
import { assertNoGitInternalsSegment, assertNotGitInternals, hasGitInternalsSegment } from '../../shared/git-internals.js';
|
|
27
27
|
import { isRolesYamlPath } from '../access-model/roles-yaml-guard.js';
|
|
28
28
|
import type { ISessionSink } from './session-sink.js';
|
|
29
29
|
import { isAbsence } from '../../shared/fs.contract.js';
|
|
@@ -782,7 +782,7 @@ export function registerWorkspaceTools(
|
|
|
782
782
|
* not cloned yet (or that does not resolve) is left to the handler; the
|
|
783
783
|
* filesystem refuses again underneath regardless.
|
|
784
784
|
*/
|
|
785
|
-
const
|
|
785
|
+
const toolPathArgs = (args: Record<string, unknown>): string[] => {
|
|
786
786
|
const paths: string[] = [];
|
|
787
787
|
for (const key of ['path', 'src', 'dest', 'destination'] as const) {
|
|
788
788
|
if (typeof args[key] === 'string') paths.push(args[key]);
|
|
@@ -793,18 +793,52 @@ export function registerWorkspaceTools(
|
|
|
793
793
|
if (typeof fp === 'string') paths.push(fp);
|
|
794
794
|
}
|
|
795
795
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
796
|
+
return paths;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The branch's workspace root, to judge a spelling against what is on disk
|
|
801
|
+
* — or null when there is nothing to judge it against yet. Only a branch
|
|
802
|
+
* ALREADY cloned is used: bootstrapping one here would clone before the
|
|
803
|
+
* handler's access and ontology gates have had their say.
|
|
804
|
+
*/
|
|
805
|
+
const gitCheckRootFor = async (args: Record<string, unknown>, ctx: ToolContext): Promise<string | null> => {
|
|
806
|
+
if (typeof args.branch !== 'string' || args.branch === '') return null;
|
|
800
807
|
try {
|
|
801
|
-
if (!(await ctx.workspaceService.hasBootstrappedWorkspace(workspaceIdForBranch(args.branch)))) return;
|
|
802
|
-
fs = await ctx.getFilesystem(args.branch);
|
|
808
|
+
if (!(await ctx.workspaceService.hasBootstrappedWorkspace(workspaceIdForBranch(args.branch)))) return null;
|
|
809
|
+
const fs = await ctx.getFilesystem(args.branch);
|
|
810
|
+
return fs instanceof GitGuardedFilesystem ? fs.basePath : null;
|
|
803
811
|
} catch {
|
|
804
|
-
return;
|
|
812
|
+
return null;
|
|
805
813
|
}
|
|
806
|
-
|
|
807
|
-
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The WHOLE rule — the spelling and where it lands, links resolved — over
|
|
818
|
+
* the caller's own arguments, before any gate, lock or read.
|
|
819
|
+
*
|
|
820
|
+
* Run before `normalizePathArgs`, which refuses a `..` segment, a `.`
|
|
821
|
+
* segment, a backslash and an absolute path as PATHS: a 400 that quotes the
|
|
822
|
+
* spelling back and names a corrected one. That answer used to arrive first
|
|
823
|
+
* for five families of spelling, so `knowledge-base/Notes/../.git/config`
|
|
824
|
+
* was told which path it meant instead of being refused. Running only the
|
|
825
|
+
* LEXICAL half here fixed those and left the same hole one step along: a
|
|
826
|
+
* link into the folder (`Notes/../gitlink/config`) has no `.git` to read in
|
|
827
|
+
* its spelling, so it took the path rule's answer too. Both halves therefore
|
|
828
|
+
* read the caller's spelling before anything may rewrite or refuse it.
|
|
829
|
+
*
|
|
830
|
+
* Nothing under the folder was reachable through any of it — the filesystem
|
|
831
|
+
* refuses again underneath — but which rule answers is not the caller's to
|
|
832
|
+
* choose by how they spell the path.
|
|
833
|
+
*/
|
|
834
|
+
const assertToolPathsNotGitInternals = async (args: Record<string, unknown>, ctx: ToolContext): Promise<void> => {
|
|
835
|
+
const paths = toolPathArgs(args);
|
|
836
|
+
for (const p of paths) assertNoGitInternalsSegment(p);
|
|
837
|
+
const onDisk = paths.filter((p) => !spillStore.isSpillRef(p));
|
|
838
|
+
if (onDisk.length === 0) return;
|
|
839
|
+
const root = await gitCheckRootFor(args, ctx);
|
|
840
|
+
if (root === null) return;
|
|
841
|
+
for (const p of onDisk) await assertNotGitInternals(root, p);
|
|
808
842
|
};
|
|
809
843
|
|
|
810
844
|
// ── preflight for moves and deletes ─────────────────────────────────────
|
|
@@ -1188,6 +1222,8 @@ export function registerWorkspaceTools(
|
|
|
1188
1222
|
// never reached the repository — the whole bug, spelled with a prefix.
|
|
1189
1223
|
toolHandler(
|
|
1190
1224
|
async (args, ctx) => {
|
|
1225
|
+
// BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
|
|
1226
|
+
if (spec.fileTool !== false) await assertToolPathsNotGitInternals(args, ctx);
|
|
1191
1227
|
const normalized = normalizePathArgs(
|
|
1192
1228
|
args,
|
|
1193
1229
|
kbDirName,
|
|
@@ -98,6 +98,47 @@ describe('assertNotGitInternals — the resolved form', () => {
|
|
|
98
98
|
await expect(assertNotGitInternals(root, 'knowledge-base/dangling')).rejects.toMatchObject({ code: 'EACCES' });
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
it.each(['/etc/shadow', '../../../../etc/shadow', '/proc/1/root/secret'])(
|
|
102
|
+
'never probes a spelling that lands outside the workspace on disk: %s',
|
|
103
|
+
async (outside) => {
|
|
104
|
+
// A raw spelling fans out into candidates, and one can name somewhere
|
|
105
|
+
// the server may not read. Probing it would answer with that place's own
|
|
106
|
+
// EACCES instead of the path rule's typed refusal for a spelling that
|
|
107
|
+
// was never a workspace path — so the disk is only asked about
|
|
108
|
+
// candidates that land under the root. The lexical reading still judges
|
|
109
|
+
// the rest, which is what catches a climb into another checkout's .git.
|
|
110
|
+
const realpath = fs.realpath.bind(fs);
|
|
111
|
+
const asked: string[] = [];
|
|
112
|
+
vi.spyOn(fs, 'realpath').mockImplementation((async (p: string) => {
|
|
113
|
+
asked.push(p);
|
|
114
|
+
if (!p.startsWith(root)) throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
115
|
+
return realpath(p);
|
|
116
|
+
}) as typeof fs.realpath);
|
|
117
|
+
|
|
118
|
+
await expect(assertNotGitInternals(root, outside)).resolves.toBeUndefined();
|
|
119
|
+
expect(asked.filter((p) => !p.startsWith(root))).toEqual([]);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
it.each(['..link', '..link/config', './..link/config', '..dir/deeper'])(
|
|
124
|
+
'probes a name that merely BEGINS with dots, and refuses it when it lands in the folder: %s',
|
|
125
|
+
async (p) => {
|
|
126
|
+
// Judged from the folder these sit DIRECTLY in, which is where the
|
|
127
|
+
// outside-the-root test can mistake them: relative to that root, the
|
|
128
|
+
// whole path is `..link/…`. `..link` is not a climb — it is a file name
|
|
129
|
+
// — so it is probed like any other entry, or a link by that name reaches
|
|
130
|
+
// the folder unchecked.
|
|
131
|
+
const kb = path.join(root, 'knowledge-base');
|
|
132
|
+
await fs.symlink('.git', path.join(kb, '..link'));
|
|
133
|
+
await fs.symlink('.git', path.join(kb, '..dir'));
|
|
134
|
+
await expect(assertNotGitInternals(kb, p)).rejects.toBeInstanceOf(GitInternalsError);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it('still refuses a climb into another checkout’s git folder, which is judged without the disk', async () => {
|
|
139
|
+
await expect(assertNotGitInternals(root, '../other-checkout/.git/config')).rejects.toBeInstanceOf(GitInternalsError);
|
|
140
|
+
});
|
|
141
|
+
|
|
101
142
|
it('a link loop is no refusal of its own, and does not hang', async () => {
|
|
102
143
|
await expect(assertNotGitInternals(root, 'knowledge-base/loop-a')).resolves.toBeUndefined();
|
|
103
144
|
});
|