@bevel-software/platform-core-backend 0.28.1 → 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.
Files changed (135) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.service.d.ts +36 -14
  25. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.js +145 -43
  27. package/dist/modules/access/access-control.service.js.map +1 -1
  28. package/dist/modules/auth/index.d.ts +15 -0
  29. package/dist/modules/auth/index.d.ts.map +1 -0
  30. package/dist/modules/auth/index.js +20 -0
  31. package/dist/modules/auth/index.js.map +1 -0
  32. package/dist/modules/embed/embed.interface.d.ts +22 -0
  33. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  34. package/dist/modules/embed/embed.service.d.ts +28 -14
  35. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.js +38 -17
  37. package/dist/modules/embed/embed.service.js.map +1 -1
  38. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  39. package/dist/modules/embed/embed.tools.js +10 -2
  40. package/dist/modules/embed/embed.tools.js.map +1 -1
  41. package/dist/modules/kb-fs/index.d.ts +7 -0
  42. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  43. package/dist/modules/kb-fs/index.js +2 -0
  44. package/dist/modules/kb-fs/index.js.map +1 -0
  45. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  46. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  47. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  48. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  49. package/dist/modules/skills/skills.tools.d.ts +8 -1
  50. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.js +115 -11
  52. package/dist/modules/skills/skills.tools.js.map +1 -1
  53. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  54. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  55. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  56. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  57. package/dist/modules/tool-helpers/index.d.ts +1 -0
  58. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/index.js +1 -0
  60. package/dist/modules/tool-helpers/index.js.map +1 -1
  61. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  62. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.js +70 -18
  64. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  65. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  66. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  67. package/dist/modules/workflow/git/git.service.js +24 -0
  68. package/dist/modules/workflow/git/git.service.js.map +1 -1
  69. package/dist/modules/workflow/index.d.ts +8 -0
  70. package/dist/modules/workflow/index.d.ts.map +1 -0
  71. package/dist/modules/workflow/index.js +8 -0
  72. package/dist/modules/workflow/index.js.map +1 -0
  73. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  74. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  75. package/dist/modules/workflow/pending-commits.service.js +4 -24
  76. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  77. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  78. package/dist/modules/workflow/workflow.routes.js +20 -1
  79. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  80. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  81. package/dist/modules/workflow/workflow.service.js +5 -3
  82. package/dist/modules/workflow/workflow.service.js.map +1 -1
  83. package/dist/modules/workspace/index.d.ts +10 -0
  84. package/dist/modules/workspace/index.d.ts.map +1 -0
  85. package/dist/modules/workspace/index.js +10 -0
  86. package/dist/modules/workspace/index.js.map +1 -0
  87. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  88. package/dist/modules/workspace/workspace.tools.js +106 -20
  89. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  90. package/dist/shared/workspace-id.d.ts +17 -0
  91. package/dist/shared/workspace-id.d.ts.map +1 -1
  92. package/dist/shared/workspace-id.js +24 -0
  93. package/dist/shared/workspace-id.js.map +1 -1
  94. package/mcp-app/page.html +142 -8
  95. package/package.json +3 -3
  96. package/src/core/__tests__/compose-embed.test.ts +65 -0
  97. package/src/core/__tests__/lifecycle.test.ts +29 -26
  98. package/src/core/catalog-revision.ts +4 -2
  99. package/src/core/compose-embed.ts +50 -0
  100. package/src/core/core-ports.ts +10 -0
  101. package/src/core/create-core-server.ts +7 -1
  102. package/src/core/create-core-services.ts +27 -21
  103. package/src/core/lifecycle.ts +24 -12
  104. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  105. package/src/modules/access/access-control.service.ts +162 -46
  106. package/src/modules/auth/index.ts +29 -0
  107. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  108. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  109. package/src/modules/embed/embed.interface.ts +24 -0
  110. package/src/modules/embed/embed.service.ts +44 -19
  111. package/src/modules/embed/embed.tools.ts +10 -2
  112. package/src/modules/kb-fs/index.ts +6 -0
  113. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  114. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  115. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  116. package/src/modules/skills/skills.contract.ts +4 -0
  117. package/src/modules/skills/skills.tools.ts +120 -10
  118. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  119. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  120. package/src/modules/tool-helpers/index.ts +5 -0
  121. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  122. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  123. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  124. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  125. package/src/modules/workflow/file-lock.service.ts +79 -18
  126. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  127. package/src/modules/workflow/git/git.service.ts +23 -0
  128. package/src/modules/workflow/index.ts +7 -0
  129. package/src/modules/workflow/pending-commits.service.ts +4 -23
  130. package/src/modules/workflow/workflow.routes.ts +24 -1
  131. package/src/modules/workflow/workflow.service.ts +3 -2
  132. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  133. package/src/modules/workspace/index.ts +14 -0
  134. package/src/modules/workspace/workspace.tools.ts +120 -20
  135. package/src/shared/workspace-id.ts +24 -0
@@ -5,6 +5,7 @@ import AdmZip from 'adm-zip';
5
5
  import { hasHttpStatus, ToolError } from '../tool-helpers/tool.contract.js';
6
6
  import { BRANCH_INPUT, toolDef } from '../tool-helpers/tool-def.js';
7
7
  import { notifyAgentRead, assertAgentWriteAllowed, SESSION_ID_INPUT, } from './agent-access.gate.js';
8
+ import { CAN_WRITE_CLAUSE, defaultBranchWriteVerdicts } from '../tool-helpers/index.js';
8
9
  import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
9
10
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
10
11
  import { GitInternalsError, WorkflowValidationError } from '../../shared/domain-errors.js';
@@ -705,6 +706,23 @@ disk) {
705
706
  });
706
707
  /** A repo-relative path in the workspace-relative form every tool speaks. */
707
708
  const toWs = (rel) => (rel ? `${kbDirName}/${rel}` : kbDirName);
709
+ /**
710
+ * `canWrite` for workspace paths, as `list_files` and `read_file` report it:
711
+ * whether the caller may commit a change there directly on the DEFAULT
712
+ * branch, whichever branch the call read — one batch per call (see
713
+ * `defaultBranchWriteVerdicts`). The repository folder itself is judged as
714
+ * the repository root; a path outside the repository never lands on the
715
+ * default branch, so it is `false`.
716
+ */
717
+ const canWriteAt = async (ctx, wsPaths) => {
718
+ const relOf = (p) => {
719
+ const norm = p.replace(/^\/+/, '').replace(/\/+$/, '');
720
+ return norm === kbDirName ? '' : toKbRelative(norm, kbDirName);
721
+ };
722
+ const rels = wsPaths.map(relOf);
723
+ const verdicts = await defaultBranchWriteVerdicts({ accessControl, defaultWorkspaceId: () => kb.defaultWorkspaceId() }, ctx, rels.filter((r) => r !== null));
724
+ return new Map(wsPaths.map((p, i) => [p, rels[i] !== null && verdicts.get(rels[i]) === true]));
725
+ };
708
726
  const sourceToWs = (s) => (s ? { ...s, path: toWs(s.path) } : null);
709
727
  /**
710
728
  * What `file_stat` adds to its `access` verdicts when asked to explain them:
@@ -1467,7 +1485,14 @@ disk) {
1467
1485
  * hook, the extraction and the not-found message all have to match, and a
1468
1486
  * copy drifts on the first change to any of them.
1469
1487
  */
1470
- const readForTool = async (branch, p, ctx) => {
1488
+ const readForTool = async (branch, p, ctx) => (await readServed(branch, p, ctx)).result;
1489
+ /**
1490
+ * {@link readForTool}, also saying, at the guide's name, whether a file of
1491
+ * the knowledge base's own answered first on THIS branch (`guide`: `own`)
1492
+ * or the platform's guide answered alone (`alone`); `null` for any other
1493
+ * path. `read_file` needs it to judge `canWrite` there (see its handler).
1494
+ */
1495
+ const readServed = async (branch, p, ctx) => {
1471
1496
  // The guide's name at the repository root answers with the platform's
1472
1497
  // guide, which is text the code owns and every agent may read: no gate
1473
1498
  // and no read hook for it. A file the knowledge base keeps under that
@@ -1476,7 +1501,8 @@ disk) {
1476
1501
  // guide an earlier release wrote to disk (still on a draft, say) is
1477
1502
  // recognised by its header and not served a second time.
1478
1503
  if (agentGuide && isAgentGuidePath(toKbRelative(p, kbDirName) ?? '')) {
1479
- return { kind: 'text', text: await guideAt(branch, ctx, p) };
1504
+ const { text, own } = await guideServed(branch, ctx, p);
1505
+ return { result: { kind: 'text', text }, guide: own ? 'own' : 'alone' };
1480
1506
  }
1481
1507
  await notifyAgentRead(agentAccessGate, ctx, branch, p);
1482
1508
  await assertCanRead(readGateFor(branch, ctx), p);
@@ -1485,18 +1511,38 @@ disk) {
1485
1511
  // the access gate and the read hook above — a document read is still a
1486
1512
  // KB read. ONE registry dispatch picks the reader by extension.
1487
1513
  const bytes = await orNotFound(p, async () => asBytes(await fs.readFile(p)));
1488
- return readers.readerFor(p).read(bytes, p);
1514
+ return { result: await readers.readerFor(p).read(bytes, p), guide: null };
1515
+ };
1516
+ /**
1517
+ * Whether the DEFAULT branch has a file of the knowledge base's own at the
1518
+ * guide's name that this caller may read — not a copy of the platform's
1519
+ * guide an earlier release wrote. Telling the two apart reads the file, so
1520
+ * that branch's read hook is asked first, as {@link ownGuideFile} does —
1521
+ * after the gate, before the bytes. A refusal, like any failure, is
1522
+ * `false`, so `canWrite` fails closed.
1523
+ */
1524
+ const ownGuideOnDefault = async (ctx, p) => {
1525
+ try {
1526
+ const fs = await ctx.getFilesystem(kb.defaultBranch);
1527
+ if (!(await ownGuideReadable(fs, kb.defaultBranch, ctx, p)))
1528
+ return false;
1529
+ await notifyAgentRead(agentAccessGate, ctx, kb.defaultBranch, p);
1530
+ return await isOwnEntryStill(fs, p);
1531
+ }
1532
+ catch {
1533
+ return false;
1534
+ }
1489
1535
  };
1490
1536
  mount({
1491
1537
  name: 'read_file',
1492
1538
  gated: true,
1493
- description: 'Read a workspace file as text. Returns `{ path, content }`. What comes back for a document, an email file, an image ' +
1494
- 'or any other binary file is the content rule\'s business (see the shared rules): text files as text, documents and ' +
1495
- 'email files as extracted text, an image as the picture itself, anything else as a one-line description. ' +
1496
- 'Optional `offset`/`limit` slice the content (characters for a file, bytes for a `__tool_chain_spill__/…` ref; ignored ' +
1497
- 'for an image) — use them to page through large files or a `call_tool_chain` spill rather than reading multi-MB in full. ' +
1498
- 'It also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: such a ref belongs to no ' +
1499
- 'workspace, so `branch` is ignored for it.',
1539
+ description: 'Read a workspace file as text. Returns `{ path, content, canWrite }`. Per the shared content rule: text as ' +
1540
+ 'text, documents and email as extracted text, an image as the picture, anything else as a one-line ' +
1541
+ 'description. Also reads a `__tool_chain_spill__/…` ref back from a truncated `call_tool_chain`: it ' +
1542
+ 'belongs to no workspace, so `branch` is ignored and no `canWrite` comes back. `offset`/`limit` slice the ' +
1543
+ 'content (characters; bytes for a spill ref; ignored for an image): page through large files and ' +
1544
+ 'spills, never read multi-MB whole. `canWrite`: may you change this file directly on the default branch. ' +
1545
+ CAN_WRITE_CLAUSE,
1500
1546
  inputs: {
1501
1547
  type: 'object',
1502
1548
  properties: {
@@ -1511,7 +1557,15 @@ disk) {
1511
1557
  },
1512
1558
  outputs: {
1513
1559
  type: 'object',
1514
- properties: { path: str('The path that was read (echoes the input).'), content: str('File (or spill) content, sliced if offset/limit were given.') },
1560
+ properties: {
1561
+ path: str('The path that was read (echoes the input).'),
1562
+ content: str('File (or spill) content, sliced if offset/limit were given.'),
1563
+ canWrite: {
1564
+ type: 'boolean',
1565
+ description: 'Whether you may commit a change to this file directly on the default branch, under that branch\'s access ' +
1566
+ 'rules — the same answer whichever branch you read. Absent for a spill ref.',
1567
+ },
1568
+ },
1515
1569
  required: ['path', 'content'],
1516
1570
  },
1517
1571
  write: false,
@@ -1528,7 +1582,18 @@ disk) {
1528
1582
  ? content.slice(start, limit !== undefined ? start + limit : undefined)
1529
1583
  : content;
1530
1584
  };
1531
- const result = await readForTool(a.branch, p, ctx);
1585
+ const branch = a.branch;
1586
+ const { result, guide } = await readServed(branch, p, ctx);
1587
+ // Judged after the read, so a path the caller may not read is refused
1588
+ // before anything is said about writing it. At the guide's name this
1589
+ // answers for the knowledge base's own file there, as `file_stat` does:
1590
+ // where the platform's guide stands alone there is nothing to change —
1591
+ // `false`, whether nothing is there or a file the caller may not read.
1592
+ // Like every verdict here, that is decided on the DEFAULT branch, not
1593
+ // the one read: a draft that deleted or added the file must not move
1594
+ // the answer about what can land directly.
1595
+ const ownOnDefault = guide === null || (branch === kb.defaultBranch ? guide === 'own' : await ownGuideOnDefault(ctx, p));
1596
+ const canWrite = ownOnDefault && (await canWriteAt(ctx, [p])).get(p) === true;
1532
1597
  // Images return the picture itself as an MCP image content block, so a
1533
1598
  // multimodal model SEES it. The handler returns the `McpImageResult`
1534
1599
  // sentinel; the MCP result shaping (`toCallToolResult` in
@@ -1540,14 +1605,17 @@ disk) {
1540
1605
  // content blocks before any client could try to validate it, so the
1541
1606
  // schema keeps describing the text path it has always described.
1542
1607
  // `offset`/`limit` are meaningless on a picture and are ignored.
1608
+ // The picture's answer has no fields beside it, so its `canWrite` rides
1609
+ // in the text note that accompanies it.
1543
1610
  if (result.kind === 'image') {
1544
- return mcpImageResult(result.data, result.mimeType, result.note);
1611
+ const verdict = `canWrite: ${canWrite}`;
1612
+ return mcpImageResult(result.data, result.mimeType, result.note ? `${result.note} (${verdict})` : verdict);
1545
1613
  }
1546
1614
  // Text and refusals alike land in `content` — a refusal (corrupt
1547
1615
  // document, unreadable binary, oversized image) IS the file's honest
1548
1616
  // textual answer, sliced like any other content.
1549
1617
  const content = result.kind === 'text' ? result.text : result.message;
1550
- return { path: p, content: slice(content) };
1618
+ return { path: p, content: slice(content), canWrite };
1551
1619
  },
1552
1620
  });
1553
1621
  /**
@@ -1566,10 +1634,12 @@ disk) {
1566
1634
  * node is indistinguishable from an absent one on every other read.
1567
1635
  */
1568
1636
  /** What a read of the guide's path answers: the guide, after the knowledge base's own readable file when it has one. */
1569
- const guideAt = async (branch, ctx, p) => {
1637
+ const guideAt = async (branch, ctx, p) => (await guideServed(branch, ctx, p)).text;
1638
+ /** {@link guideAt}, also saying whether a file of the knowledge base's own came first. */
1639
+ const guideServed = async (branch, ctx, p) => {
1570
1640
  const guide = await agentGuide();
1571
1641
  const own = await ownGuideFile(branch, ctx, p);
1572
- return own === null ? guide : withPlatformGuideAppended(own, guide);
1642
+ return own === null ? { text: guide, own: false } : { text: withPlatformGuideAppended(own, guide), own: true };
1573
1643
  };
1574
1644
  const ownGuideFile = async (branch, ctx, p) => {
1575
1645
  const fs = await ctx.getFilesystem(branch);
@@ -1646,7 +1716,8 @@ disk) {
1646
1716
  mount({
1647
1717
  name: 'list_files',
1648
1718
  gated: true,
1649
- description: `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 is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it.`,
1719
+ description: `List a directory. Returns \`{ path, entries: [{ name, type, size?, canWrite }] }\`. Omit \`path\` for the workspace root, which holds the repository as the \`${kbDirName}/\` folder: every content path is under it (e.g. \`${kbDirName}/KnowledgeBase\`), and a path given without that prefix is placed under it. ` +
1720
+ `\`canWrite\`: may you change that file, or add a file directly inside that folder, directly on the default branch. ${CAN_WRITE_CLAUSE}`,
1650
1721
  inputs: {
1651
1722
  type: 'object',
1652
1723
  properties: {
@@ -1666,8 +1737,18 @@ disk) {
1666
1737
  description: 'Directory entries.',
1667
1738
  items: {
1668
1739
  type: 'object',
1669
- properties: { name: str('Entry name.'), type: str('`file` or `directory`.'), size: int('Size in bytes (files only).') },
1670
- required: ['name', 'type'],
1740
+ properties: {
1741
+ name: str('Entry name.'),
1742
+ type: str('`file` or `directory`.'),
1743
+ size: int('Size in bytes (files only).'),
1744
+ canWrite: {
1745
+ type: 'boolean',
1746
+ description: 'Whether you may commit directly on the default branch, under that branch\'s access rules: for a ' +
1747
+ 'file, a change to it; for a folder, a new file directly inside it, by that folder\'s own rules. ' +
1748
+ 'The same answer whichever branch you listed.',
1749
+ },
1750
+ },
1751
+ required: ['name', 'type', 'canWrite'],
1671
1752
  },
1672
1753
  },
1673
1754
  },
@@ -1680,7 +1761,12 @@ disk) {
1680
1761
  const fs = await ctx.getFilesystem(a.branch);
1681
1762
  const entries = withoutPlaceholder((await fs.readdir(dir || '.')));
1682
1763
  const filtered = await filterReadableEntries(readGateFor(a.branch, ctx), dir, entries);
1683
- return { path: a.path ?? '', entries: filtered };
1764
+ // One batch for the listing. A folder is judged at its own path, whose
1765
+ // rules (its `access.md` and those above it) are what a file added
1766
+ // directly inside it would get.
1767
+ const wsPathOf = (e) => (dir ? `${dir}/${e.name}` : e.name);
1768
+ const verdicts = await canWriteAt(ctx, filtered.map(wsPathOf));
1769
+ return { path: a.path ?? '', entries: filtered.map((e) => ({ ...e, canWrite: verdicts.get(wsPathOf(e)) === true })) };
1684
1770
  },
1685
1771
  });
1686
1772
  mount({