@bevel-software/platform-core-backend 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -3
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  37. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  39. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  40. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  41. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  42. package/dist/modules/kb-fs/repo-path.js +54 -0
  43. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  44. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  45. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  46. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  47. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  48. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  50. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  52. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  53. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  55. package/dist/modules/tool-helpers/tool-context.js +1 -0
  56. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  57. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  58. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  59. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  60. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  62. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  63. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  64. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  66. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  68. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  70. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  71. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  72. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  73. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  74. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  75. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +70 -5
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  85. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  87. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  89. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  90. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  91. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  92. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  95. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.js +12 -0
  97. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  98. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  99. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.service.js +4 -0
  101. package/dist/modules/workflow/workflow.service.js.map +1 -1
  102. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  103. package/dist/modules/workspace/workspace.tools.js +31 -15
  104. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  105. package/kb-template/AGENTS.md +52 -8
  106. package/package.json +4 -3
  107. package/src/core/create-core-server.ts +13 -1
  108. package/src/core/create-core-services.ts +1 -0
  109. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  110. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  111. package/src/modules/access/access-control.service.ts +198 -37
  112. package/src/modules/access/admin-locked-commit.ts +1 -0
  113. package/src/modules/access/synced-groups-committer.ts +1 -1
  114. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  115. package/src/modules/access-model/access-errors.ts +19 -0
  116. package/src/modules/access-model/access-grammar.ts +67 -3
  117. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  118. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  119. package/src/modules/declared-variables/index.ts +1 -0
  120. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  121. package/src/modules/diff/diff.routes.ts +3 -2
  122. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  123. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  124. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  125. package/src/modules/kb-fs/repo-path.ts +56 -0
  126. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  127. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  128. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  129. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  130. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  131. package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
  132. package/src/modules/tool-helpers/tool-context.ts +1 -0
  133. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  134. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  135. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  136. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  137. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  138. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  139. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  140. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  141. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  142. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  143. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  144. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  145. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  147. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  148. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  149. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  150. package/src/modules/workflow/git/git.service.ts +73 -6
  151. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  152. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  153. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  154. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  155. package/src/modules/workflow/workflow.routes.ts +12 -0
  156. package/src/modules/workflow/workflow.service.ts +5 -1
  157. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  158. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -13,6 +13,7 @@ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
13
13
  import type { GitService } from '../../git/git.service.js';
14
14
  import type { IAccessControl } from '../../../access/access-control.interface.js';
15
15
  import { hashEmail as hash } from '../../../../shared/hash-email.js';
16
+ import { AccessUnreadableError } from '../../../access-model/access-errors.js';
16
17
 
17
18
  function file(overrides: Partial<PullRequestFile>): PullRequestFile {
18
19
  return {
@@ -557,3 +558,62 @@ describe('mergePr — roles.yaml privilege-escalation guard', () => {
557
558
  expect(mergeChangeRequestMock).toHaveBeenCalledTimes(1);
558
559
  });
559
560
  });
561
+
562
+ /**
563
+ * An access tree that could not be READ is not the same as "no eligible
564
+ * writers": the latter drops a file out of the merge gate, so answering it for
565
+ * a lost git read would let a change merge past a deny nobody managed to
566
+ * load. The unreadable case propagates (the caller fails closed, 503); any
567
+ * other lookup failure still degrades to empty eligibility as documented.
568
+ */
569
+ describe('ReviewWorkflowService.getApprovalStates — unreadable access tree', () => {
570
+ const FILES = [file({ path: 'Knowledge/Foo.md' })];
571
+ function serviceWith(overrides: Partial<IAccessControl>) {
572
+ const workspace = {
573
+ ensureRemotesFetched: vi.fn(async () => undefined),
574
+ getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
575
+ } as unknown as WorkspaceService;
576
+ const git = { mergeChangeRequest: mergeChangeRequestMock } as unknown as GitService;
577
+ return new ReviewWorkflowService(makeDb([]), { ...makeAccessControl({}), ...overrides }, workspace, git);
578
+ }
579
+
580
+ it('propagates AccessUnreadableError from the eligibility lookup', async () => {
581
+ const svc = serviceWith({
582
+ eligibleWritersForPathsAtRef: async () => {
583
+ throw new AccessUnreadableError('origin/current-company-state', 'Knowledge/access.md');
584
+ },
585
+ });
586
+ await expect(
587
+ svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base', 'alice@bevel.software'),
588
+ ).rejects.toBeInstanceOf(AccessUnreadableError);
589
+ });
590
+
591
+ it('propagates AccessUnreadableError from the viewer batch too', async () => {
592
+ const svc = serviceWith({
593
+ canWriteBatchAtRef: async () => {
594
+ throw new AccessUnreadableError('origin/current-company-state', 'Knowledge/access.md');
595
+ },
596
+ });
597
+ await expect(
598
+ svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base', 'alice@bevel.software'),
599
+ ).rejects.toBeInstanceOf(AccessUnreadableError);
600
+ });
601
+
602
+ it('still degrades any other lookup failure to empty eligibility', async () => {
603
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
604
+ try {
605
+ const svc = serviceWith({
606
+ eligibleWritersForPathsAtRef: async () => {
607
+ throw new Error('origin not fetched yet');
608
+ },
609
+ });
610
+ const states = await svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base');
611
+ expect(states).toHaveLength(1);
612
+ expect(states[0].eligibleApprovers).toEqual({ roles: [], users: [] });
613
+ expect(states[0].isApproved).toBe(false);
614
+ expect(warn).toHaveBeenCalled();
615
+ } finally {
616
+ warn.mockRestore();
617
+ }
618
+ });
619
+ });
@@ -12,6 +12,7 @@ import type {
12
12
  } from '@bevel-software/platform-shared';
13
13
  import type { Database } from '../../database/connection.js';
14
14
  import { changeRequests, prComments, prFileApprovals, prMergeLog } from '../../database/schema.js';
15
+ import { AccessUnreadableError } from '../../access-model/access-errors.js';
15
16
  import type { IAccessControl } from '../../access/access-control.interface.js';
16
17
  import { isAccessMdPath } from '../../access-model/access-grammar.js';
17
18
  import type { GitService } from '../git/git.service.js';
@@ -374,6 +375,9 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
374
375
  );
375
376
  if (resolved) eligibilityByPath = resolved;
376
377
  } catch (err) {
378
+ // An unreadable tree is not "no eligible writers": that answer would
379
+ // drop every file out of the merge gate. Fail closed instead.
380
+ if (err instanceof AccessUnreadableError) throw err;
377
381
  console.warn(
378
382
  `[review-workflow] eligibleWritersForPathsAtRef failed for PR #${prNumber} (base=${baseBranch}):`,
379
383
  err,
@@ -390,6 +394,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
390
394
  );
391
395
  if (batch) viewerCanApproveByPath = batch;
392
396
  } catch (err) {
397
+ if (err instanceof AccessUnreadableError) throw err;
393
398
  console.warn(
394
399
  `[review-workflow] canWriteBatchAtRef failed for PR #${prNumber} viewer=${viewerEmail}:`,
395
400
  err,
@@ -764,6 +764,10 @@ export function createWorkflowRoutes(
764
764
  fresh: true,
765
765
  workspaceId: workspace.id,
766
766
  viewerEmail: user.email,
767
+ // Internal read: this route pins its work on the detail and never
768
+ // reads a patch; skipping them is one git subprocess per file saved
769
+ // on every click, and the result stays out of the client cache.
770
+ patches: false,
767
771
  });
768
772
  if (!detail) {
769
773
  res.status(404).json({ error: 'change request not found' });
@@ -805,6 +809,10 @@ export function createWorkflowRoutes(
805
809
  fresh: true,
806
810
  workspaceId: workspace.id,
807
811
  viewerEmail: user.email,
812
+ // Internal read: this route pins its work on the detail and never
813
+ // reads a patch; skipping them is one git subprocess per file saved
814
+ // on every click, and the result stays out of the client cache.
815
+ patches: false,
808
816
  });
809
817
  if (!detail) {
810
818
  res.status(404).json({ error: 'change request not found' });
@@ -841,6 +849,10 @@ export function createWorkflowRoutes(
841
849
  fresh: true,
842
850
  workspaceId: workspace.id,
843
851
  viewerEmail: user.email,
852
+ // Internal read: this route pins its work on the detail and never
853
+ // reads a patch; skipping them is one git subprocess per file saved
854
+ // on every click, and the result stays out of the client cache.
855
+ patches: false,
844
856
  });
845
857
  if (!detail) {
846
858
  res.status(404).json({ error: 'change request not found' });
@@ -1241,7 +1241,7 @@ export class WorkflowService implements IWorkflowService {
1241
1241
 
1242
1242
  getChangeRequestDetail(
1243
1243
  number: number,
1244
- opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
1244
+ opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
1245
1245
  ): Promise<ChangeRequestDetail | null> {
1246
1246
  return this.prs.getPrDetail(number, opts);
1247
1247
  }
@@ -1896,6 +1896,10 @@ export class WorkflowService implements IWorkflowService {
1896
1896
  fresh: true,
1897
1897
  workspaceId,
1898
1898
  viewerEmail: user.email,
1899
+ // Only `files[].path` is read below; a patch per file would be one
1900
+ // git subprocess each, generated for nothing, on every reject by a
1901
+ // non-author.
1902
+ patches: false,
1899
1903
  });
1900
1904
  const paths = detail?.files.map((f) => f.path) ?? [];
1901
1905
  if (paths.length > 0) {
@@ -1065,3 +1065,48 @@ describe('branch is a required parameter in the tool contract', () => {
1065
1065
  }
1066
1066
  });
1067
1067
  });
1068
+
1069
+ /**
1070
+ * The tools are rooted at the WORKSPACE dir, one level above the git clone, so
1071
+ * a path has to start with the clone folder to reach git at all. Every place an
1072
+ * agent reads before choosing a path says so, in the `path` input itself rather
1073
+ * than only in prose it may not read: the root listing (where the folder is
1074
+ * discoverable) and each content-writing tool.
1075
+ */
1076
+ describe('path inputs tell the agent about the repository folder', () => {
1077
+ // `toolDef` wraps a tool's inputs under a single `body` property.
1078
+ const inputDescription = (def: { inputs?: unknown } | undefined, ...keys: string[]): string => {
1079
+ let node = (def?.inputs ?? {}) as Record<string, unknown>;
1080
+ for (const key of ['body', ...keys]) {
1081
+ node = ((node.properties as Record<string, unknown> | undefined)?.[key] ?? {}) as Record<string, unknown>;
1082
+ if (key === 'files') node = (node.items ?? {}) as Record<string, unknown>;
1083
+ }
1084
+ return typeof node.description === 'string' ? node.description : '';
1085
+ };
1086
+
1087
+ it('every content-writing tool says paths start with `knowledge-base/`, so a repo-relative path is never guessed', async () => {
1088
+ await start();
1089
+ const tools = await toolRegistry.listInternal();
1090
+ const byName = (name: string) => {
1091
+ const def = tools.find((t) => t.name === name);
1092
+ expect(def, name).toBeDefined();
1093
+ return def;
1094
+ };
1095
+ for (const name of ['write_file', 'edit_file', 'mkdir', 'delete_file', 'unzip']) {
1096
+ expect(inputDescription(byName(name), 'path'), name).toContain(`\`${KB_DIR}/\``);
1097
+ }
1098
+ expect(inputDescription(byName('write_files'), 'files', 'path'), 'write_files').toContain(`\`${KB_DIR}/\``);
1099
+ for (const name of ['move_file', 'copy_file']) {
1100
+ expect(inputDescription(byName(name), 'dest'), name).toContain(`\`${KB_DIR}/\``);
1101
+ }
1102
+ });
1103
+
1104
+ it('the root listing names the clone folder, so an agent that lists first learns the prefix', async () => {
1105
+ await start();
1106
+ const tools = await toolRegistry.listInternal();
1107
+ const list = tools.find((t) => t.name === 'list_files');
1108
+ expect(list).toBeDefined();
1109
+ expect(list!.description).toContain(`\`${KB_DIR}/\``);
1110
+ expect(inputDescription(list, 'path')).toContain(`\`${KB_DIR}/\``);
1111
+ });
1112
+ });
@@ -20,6 +20,7 @@ import { workspaceIdForBranch } from '../../shared/workspace-id.js';
20
20
  // Leaf-level shared primitive (same exception `workspace.service.ts` already
21
21
  // relies on) — not a workflow service, so this stays inside the module boundary.
22
22
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
23
+ import { assertInsideRepo } from '../kb-fs/repo-path.js';
23
24
  import type { ISessionSink } from './session-sink.js';
24
25
  import type { IAccessControl } from '../access/access-control.interface.js';
25
26
  import { toKbRelative, resolveReadableMap } from '../access-model/kb-read-filter.js';
@@ -111,6 +112,22 @@ const int = (description: string): JsonSchema => ({ type: 'integer', description
111
112
 
112
113
  const str = (description: string): JsonSchema => ({ type: 'string', description });
113
114
 
115
+ /**
116
+ * A path input that names the clone folder. The tools are rooted at the
117
+ * WORKSPACE dir, one level above the git clone, so a path only reaches git
118
+ * when it starts with that folder; an agent that reads `KnowledgeBase/Foo.md`
119
+ * in a URL or a doc and passes it verbatim would otherwise write beside the
120
+ * repository. Saying so in the input itself, not only in prose, is what the
121
+ * agent actually sees when it fills the argument. `refused` is false for the
122
+ * inputs that may legitimately name a stray (a source to rescue, a file to
123
+ * remove).
124
+ */
125
+ const wsPath = (kbDirName: string, what: string, refused = true): JsonSchema =>
126
+ str(
127
+ `${what}: starts with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`).` +
128
+ (refused ? ' A path without that prefix is outside the repository and is refused.' : ''),
129
+ );
130
+
114
131
  function asText(content: string | Buffer): string {
115
132
  return typeof content === 'string' ? content : content.toString('utf8');
116
133
  }
@@ -394,7 +411,7 @@ export function registerWorkspaceTools(
394
411
  type: 'object',
395
412
  properties: {
396
413
  branch: BRANCH_INPUT,
397
- path: str('Workspace-relative path, or a `__tool_chain_spill__/…` ref from a truncated `call_tool_chain`.'),
414
+ path: str(`Path to read, starting with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`), or a \`__tool_chain_spill__/…\` ref from a truncated \`call_tool_chain\`.`),
398
415
  offset: int('Start character index (default 0).'),
399
416
  limit: int('Max characters to return from `offset`.'),
400
417
  sessionId: SESSION_ID_INPUT,
@@ -453,13 +470,13 @@ export function registerWorkspaceTools(
453
470
  mount({
454
471
  name: 'list_files',
455
472
  description:
456
- 'List a directory. Returns `{ path, entries: [{ name, type, size? }] }`. Omit `path` for the workspace root.' +
473
+ `List a directory. Returns \`{ path, entries: [{ name, type, size? }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path starts with it (e.g. \`${kbDirName}/KnowledgeBase\`).` +
457
474
  ONTOLOGY_BOUNDARY_NOTE,
458
475
  inputs: {
459
476
  type: 'object',
460
477
  properties: {
461
478
  branch: BRANCH_INPUT,
462
- path: str('Workspace-relative directory (default: root).'),
479
+ path: str(`Directory to list, starting with \`${kbDirName}/\` (default: the workspace root, where the repository is the \`${kbDirName}/\` folder).`),
463
480
  sessionId: SESSION_ID_INPUT,
464
481
  },
465
482
  required: ['branch'],
@@ -501,7 +518,7 @@ export function registerWorkspaceTools(
501
518
  type: 'object',
502
519
  properties: {
503
520
  branch: BRANCH_INPUT,
504
- path: str('Workspace-relative path.'),
521
+ path: wsPath(kbDirName, 'Path'),
505
522
  sessionId: SESSION_ID_INPUT,
506
523
  },
507
524
  required: ['branch', 'path'],
@@ -610,7 +627,7 @@ export function registerWorkspaceTools(
610
627
  type: 'object',
611
628
  properties: {
612
629
  branch: BRANCH_INPUT,
613
- path: str('Workspace-relative path.'),
630
+ path: wsPath(kbDirName, 'Path'),
614
631
  content: str('Full file content.'),
615
632
  sessionId: SESSION_ID_INPUT,
616
633
  },
@@ -657,7 +674,7 @@ export function registerWorkspaceTools(
657
674
  description: 'Files to write; each created or overwritten.',
658
675
  items: {
659
676
  type: 'object',
660
- properties: { path: str('Workspace-relative path.'), content: str('Full file content.') },
677
+ properties: { path: wsPath(kbDirName, 'Path'), content: str('Full file content.') },
661
678
  required: ['path', 'content'],
662
679
  additionalProperties: false,
663
680
  },
@@ -705,7 +722,7 @@ export function registerWorkspaceTools(
705
722
  type: 'object',
706
723
  properties: {
707
724
  branch: BRANCH_INPUT,
708
- path: str('Workspace-relative path.'),
725
+ path: wsPath(kbDirName, 'Path'),
709
726
  old_string: str('Exact text to replace (include enough context to be unique).'),
710
727
  new_string: str('Replacement text.'),
711
728
  replace_all: { type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match.' },
@@ -750,7 +767,7 @@ export function registerWorkspaceTools(
750
767
  type: 'object',
751
768
  properties: {
752
769
  branch: BRANCH_INPUT,
753
- path: str('Workspace-relative path.'),
770
+ path: wsPath(kbDirName, 'Path to the file', false),
754
771
  sessionId: SESSION_ID_INPUT,
755
772
  },
756
773
  required: ['branch', 'path'],
@@ -781,7 +798,7 @@ export function registerWorkspaceTools(
781
798
  type: 'object',
782
799
  properties: {
783
800
  branch: BRANCH_INPUT,
784
- path: str('Workspace-relative directory.'),
801
+ path: wsPath(kbDirName, 'Directory to create'),
785
802
  sessionId: SESSION_ID_INPUT,
786
803
  },
787
804
  required: ['branch', 'path'],
@@ -808,8 +825,8 @@ export function registerWorkspaceTools(
808
825
  type: 'object',
809
826
  properties: {
810
827
  branch: BRANCH_INPUT,
811
- src: str('Source path.'),
812
- dest: str('Destination path.'),
828
+ src: wsPath(kbDirName, 'Source path', false),
829
+ dest: wsPath(kbDirName, 'Destination path'),
813
830
  sessionId: SESSION_ID_INPUT,
814
831
  },
815
832
  required: ['branch', 'src', 'dest'],
@@ -842,8 +859,8 @@ export function registerWorkspaceTools(
842
859
  type: 'object',
843
860
  properties: {
844
861
  branch: BRANCH_INPUT,
845
- src: str('Source path.'),
846
- dest: str('Destination path.'),
862
+ src: wsPath(kbDirName, 'Source path', false),
863
+ dest: wsPath(kbDirName, 'Destination path'),
847
864
  sessionId: SESSION_ID_INPUT,
848
865
  },
849
866
  required: ['branch', 'src', 'dest'],
@@ -877,8 +894,8 @@ export function registerWorkspaceTools(
877
894
  type: 'object',
878
895
  properties: {
879
896
  branch: BRANCH_INPUT,
880
- path: str('Workspace-relative .zip path.'),
881
- destination: str("Directory to extract into (default: the zip's parent)."),
897
+ path: wsPath(kbDirName, 'Path to the .zip archive', false),
898
+ destination: wsPath(kbDirName, "Directory to extract into (default: the zip's parent)"),
882
899
  sessionId: SESSION_ID_INPUT,
883
900
  },
884
901
  required: ['branch', 'path'],
@@ -916,6 +933,9 @@ export function registerWorkspaceTools(
916
933
  // extension policy applies per entry too, so a restricted run can't unzip a
917
934
  // `.md` into the graph.
918
935
  (wsRelPath) => {
936
+ // An entry that would land beside the repository is skipped with the
937
+ // corrected-path reason, like any other refused entry.
938
+ assertInsideRepo(wsRelPath, kbDirName);
919
939
  writePolicy.assertPathWritable(ctx.sessionId, wsRelPath);
920
940
  return assertOntologyWriteAllowed(sessionOntologyGate, ctx, wsRelPath);
921
941
  },