@neuralis/brain-core 0.3.0 → 0.3.2

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 (123) hide show
  1. package/README.md +41 -40
  2. package/app/README.md +3 -3
  3. package/app/filesystem/FilesystemHost.tsx +7 -0
  4. package/app/filesystem/README.md +1 -1
  5. package/app/filesystem/api/index.ts +4 -4
  6. package/app/filesystem/hooks/README.md +1 -1
  7. package/app/filesystem/hooks/useFilesystemSSE.ts +41 -1
  8. package/app/filesystem/host/README.md +2 -2
  9. package/app/filesystem/host/buildFilesystemHostValue.ts +9 -2
  10. package/app/filesystem/lib/sourceKindForm.ts +109 -5
  11. package/app/filesystem/store/filesystemStore.ts +13 -3
  12. package/app/filesystem/ui/DiscoverDock.tsx +5 -1
  13. package/app/filesystem/ui/FileTree.tsx +34 -8
  14. package/app/filesystem/ui/FilesystemWidget.tsx +10 -2
  15. package/app/filesystem/ui/GitControlPanel.tsx +2 -2
  16. package/app/filesystem/ui/PendingChangesPanel.tsx +1 -1
  17. package/app/filesystem/ui/README.md +14 -13
  18. package/app/filesystem/ui/SourcesPanel.tsx +124 -24
  19. package/app/filesystem/ui/VectorHealthPanel.tsx +2 -2
  20. package/dist/app/host.js +41 -37
  21. package/dist/src/channels/filesystem.d.ts +1 -1
  22. package/dist/src/channels/filesystem.js +1 -1
  23. package/dist/src/domain/mime.d.ts +3 -3
  24. package/dist/src/domain/mime.js +3 -3
  25. package/dist/src/domain/scopeAccessCtx.d.ts +2 -2
  26. package/dist/src/domain/scopeAccessCtx.js +2 -2
  27. package/dist/src/domain/types.d.ts +8 -1
  28. package/dist/src/domain/types.d.ts.map +1 -1
  29. package/dist/src/events/pending.d.ts +1 -1
  30. package/dist/src/events/pending.js +1 -1
  31. package/dist/src/git/sourceGitControl.d.ts +2 -2
  32. package/dist/src/git/sourceGitControl.js +3 -3
  33. package/dist/src/lifecycle.d.ts +1 -1
  34. package/dist/src/routes/approve.d.ts +1 -1
  35. package/dist/src/routes/approve.js +1 -1
  36. package/dist/src/routes/events.d.ts +1 -1
  37. package/dist/src/routes/events.js +1 -1
  38. package/dist/src/routes/git.d.ts +7 -7
  39. package/dist/src/routes/git.js +18 -18
  40. package/dist/src/routes/health.d.ts +6 -6
  41. package/dist/src/routes/health.js +12 -12
  42. package/dist/src/routes/helpers/sourceNotConfigured.d.ts +1 -1
  43. package/dist/src/routes/helpers/sourceNotConfigured.js +1 -1
  44. package/dist/src/routes/helpers.d.ts +1 -1
  45. package/dist/src/routes/helpers.js +1 -1
  46. package/dist/src/routes/list.d.ts +1 -1
  47. package/dist/src/routes/list.js +1 -1
  48. package/dist/src/routes/pending.d.ts +4 -4
  49. package/dist/src/routes/pending.js +9 -9
  50. package/dist/src/routes/policies.d.ts +4 -4
  51. package/dist/src/routes/policies.d.ts.map +1 -1
  52. package/dist/src/routes/policies.js +11 -11
  53. package/dist/src/routes/policies.js.map +1 -1
  54. package/dist/src/routes/raw.d.ts +1 -1
  55. package/dist/src/routes/raw.js +1 -1
  56. package/dist/src/routes/read.d.ts +1 -1
  57. package/dist/src/routes/read.js +1 -1
  58. package/dist/src/routes/search.d.ts +1 -1
  59. package/dist/src/routes/search.js +1 -1
  60. package/dist/src/routes/sources.d.ts +20 -5
  61. package/dist/src/routes/sources.d.ts.map +1 -1
  62. package/dist/src/routes/sources.js +118 -118
  63. package/dist/src/routes/sources.js.map +1 -1
  64. package/dist/src/routes/upload.d.ts +1 -1
  65. package/dist/src/routes/upload.js +1 -1
  66. package/dist/src/routes/write.d.ts +2 -2
  67. package/dist/src/routes/write.js +2 -2
  68. package/dist/src/services/BridgeService.d.ts +7 -1
  69. package/dist/src/services/BridgeService.d.ts.map +1 -1
  70. package/dist/src/services/BridgeService.js +15 -3
  71. package/dist/src/services/BridgeService.js.map +1 -1
  72. package/dist/src/services/FilesystemService.d.ts +1 -1
  73. package/dist/src/services/FilesystemService.js +2 -2
  74. package/dist/src/services/declaredSources.d.ts +9 -3
  75. package/dist/src/services/declaredSources.d.ts.map +1 -1
  76. package/dist/src/services/declaredSources.js +2 -1
  77. package/dist/src/services/declaredSources.js.map +1 -1
  78. package/dist/src/tools/execCtx.d.ts +2 -2
  79. package/dist/src/tools/execCtx.js +2 -2
  80. package/dist/src/tools/fs_read.d.ts.map +1 -1
  81. package/dist/src/tools/fs_read.js +5 -0
  82. package/dist/src/tools/fs_read.js.map +1 -1
  83. package/dist/src/usecases/embeddingBacklog.js +1 -1
  84. package/dist/src/usecases/embeddingBacklog.js.map +1 -1
  85. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  86. package/dist/src/usecases/readFiles.js +84 -1
  87. package/dist/src/usecases/readFiles.js.map +1 -1
  88. package/dist/src/usecases/syncPointWrites.d.ts +2 -0
  89. package/dist/src/usecases/syncPointWrites.d.ts.map +1 -1
  90. package/dist/src/usecases/syncPointWrites.js +5 -1
  91. package/dist/src/usecases/syncPointWrites.js.map +1 -1
  92. package/dist/src/usecases/vectorMaintenance.js +1 -1
  93. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  94. package/dist/src/usecases/write/content.js +1 -1
  95. package/dist/tools/fs_list.json +1 -1
  96. package/dist/tools/fs_read.json +1 -1
  97. package/dist/tools/fs_search.json +1 -1
  98. package/dist/tools/fs_write.json +1 -1
  99. package/instructions/brain-model.md +1 -1
  100. package/package.json +70 -63
  101. package/rules/brain-rules.md +2 -2
  102. package/skills/brain-start/SKILL.md +1 -1
  103. package/skills/connect-business-data/SKILL.md +1 -1
  104. package/skills/create-package/SKILL.md +8 -8
  105. package/skills/create-package/references/connectors-and-uri-policies.md +7 -2
  106. package/skills/create-package/references/runtime-and-build.md +1 -1
  107. package/skills/create-package/references/skills-and-context.md +1 -1
  108. package/skills/create-package/references/test-and-ship.md +4 -4
  109. package/skills/create-package/scripts/check-package.sh +1 -1
  110. package/skills/git/SKILL.md +23 -23
  111. package/skills/manage-files/SKILL.md +13 -13
  112. package/skills/manage-files/scripts/upload.sh +1 -1
  113. package/skills/manage-sources/SKILL.md +55 -54
  114. package/skills/manage-sources/scripts/reembed.sh +1 -1
  115. package/skills/manage-uri-policy/SKILL.md +9 -9
  116. package/skills/organize/SKILL.md +1 -1
  117. package/skills/sync/SKILL.md +2 -2
  118. package/tools/fs_list.json +1 -1
  119. package/tools/fs_read.json +1 -1
  120. package/tools/fs_search.json +1 -1
  121. package/tools/fs_write.json +1 -1
  122. package/workflows/sources-digest.json +1 -1
  123. package/docs/drive-overview.md +0 -59
@@ -6,7 +6,7 @@
6
6
  * GET /sources/:slug — get single source config
7
7
  * GET /sources/kinds — list registered source kinds (+ trust, package attribution)
8
8
  * GET /sources/kinds/:kind/schema — JSON Schema for admin source form
9
- * GET /sources/discoverable — well-known unattached source roots (W3E; drive.mount)
9
+ * GET /sources/discoverable — well-known unattached source roots (W3E; sources.mount)
10
10
  * POST /sources — create source
11
11
  * POST /sources/preflight — validate/authorize create without writing
12
12
  * PATCH /sources — update source
@@ -18,9 +18,9 @@
18
18
  * POST /sources/reset — reset sync state (cancels active job)
19
19
  * GET /sources/sync-status — job status (?source= single, else all visible)
20
20
  * POST /sources/enabled — light enable/disable toggle (W3K)
21
- * GET /sources/browse — browse filesystem directories (drive.mount;
21
+ * GET /sources/browse — browse filesystem directories (sources.mount;
22
22
  * protected zones + the fs root need
23
- * drive.mount.privileged)
23
+ * sources.privileged)
24
24
  * GET /sources/health — get sources health
25
25
  *
26
26
  * Sync-shaped POSTs return 202 + a SyncQueueJobSnapshot: the run executes on
@@ -45,17 +45,15 @@ import { listScopeDocumentUris, estimateScopedReembed } from '../usecases/vector
45
45
  import { embeddingEnabled, resolveActiveEmbeddingShape } from '../config/env.js';
46
46
  import { parsePurgeSelector, purgeSourceSelection } from '../usecases/purgeExcludedPoints.js';
47
47
  export const pattern = 'sources/*';
48
- export const feature = 'drive.read';
48
+ export const feature = 'files.read';
49
49
  // The module gate is the read tier (listing a source is member-visible); every
50
50
  // mutating / enumerating arm escalates in-handler through `hasRouteFeature`.
51
- // `drive.mount.host` never appears as a literal at a gate — the host-plane arm
52
- // folds it through `hostPlaneRequiredFeatures()` and enforces each returned id
53
- // in a loop — so the drift guard carries it as a named exemption row.
51
+ // A host-plane (paired-device) source is attached by its owner under
52
+ // `device.own`, which agent-core's device gate answers — not a gate here.
54
53
  export const escalations = [
55
- 'drive.mount',
56
- 'drive.mount.privileged',
57
- 'drive.mount.host',
58
- 'drive.sync',
54
+ 'sources.mount',
55
+ 'sources.privileged',
56
+ 'sources.sync',
59
57
  'platform.vector.maintain',
60
58
  ];
61
59
  export const GET = async (req, raw) => {
@@ -124,7 +122,7 @@ export const GET = async (req, raw) => {
124
122
  const origin = matched?.originPackage ?? null;
125
123
  const git = await resolveSourceGit(config, infra, ctx);
126
124
  const readable = await rootReadable(infra, ctx, config.source, configCache);
127
- const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', protectedRoots, readable, !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
125
+ const view = await sourceConfigView(req.session, config, infra?.projectRoot ?? '', protectedRoots, readable, deviceAttachVerdict(state, ctx, config.connection), !config.sync && matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId) ? dataSyncExclude : undefined);
128
126
  return {
129
127
  ...toPublicSourceConfig(config, view),
130
128
  // DERIVED, never persisted — the same chain the `<runtime_stack>`
@@ -141,7 +139,7 @@ export const GET = async (req, raw) => {
141
139
  kind_trust: metadata?.packageTrust ?? null,
142
140
  origin_package: origin,
143
141
  orphan: !metadata, embedding_enabled: embeddingEnabled(),
144
- canMaintain: hasRouteFeature(req.session, 'drive.mount') && hasRouteFeature(req.session, 'drive.sync')
142
+ canMaintain: hasRouteFeature(req.session, 'sources.mount') && hasRouteFeature(req.session, 'sources.sync')
145
143
  && canAccessScope(config.scope, access, 'write') && readable,
146
144
  orphan_hint: metadata ? null : `Package contributing kind "${config.connection.kind}" is missing — remove the source or reinstall the package.`,
147
145
  ...(git ? { git } : {}),
@@ -209,8 +207,8 @@ export const POST = async (req, raw) => {
209
207
  if (sub === 'purge') {
210
208
  const ctx = extractCtx(req);
211
209
  await ensureAccess(ctx, state);
212
- if (!hasRouteFeature(req.session, 'drive.mount') || !hasRouteFeature(req.session, 'drive.sync')) {
213
- return { status: 403, body: { error: 'Index cleanup requires drive.mount and drive.sync.' } };
210
+ if (!hasRouteFeature(req.session, 'sources.mount') || !hasRouteFeature(req.session, 'sources.sync')) {
211
+ return { status: 403, body: { error: 'Index cleanup requires sources.mount and sources.sync.' } };
214
212
  }
215
213
  const body = req.body;
216
214
  const source = typeof body?.source === 'string' ? body.source : '';
@@ -392,8 +390,12 @@ async function handleSourcesCreate(req, state, preflight = false) {
392
390
  return { status: 400, body: { error: 'recognizesPackages must be a boolean if provided' } };
393
391
  }
394
392
  await ensureAccess(ctx, state);
395
- if (!hasRouteFeature(req.session, 'drive.mount')) {
396
- return { status: 403, body: { error: 'Creating sources requires drive.mount.' } };
393
+ // A source on the caller's OWN paired device: its owner with `device.own`
394
+ // satisfies the whole attach stack (`sources.mount` included). On the host
395
+ // plane that is the ONLY way in; every other source meets the stack.
396
+ const ownDevice = deviceAttachVerdict(state, ctx, connection);
397
+ if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
398
+ return { status: 403, body: { error: 'Creating sources requires sources.mount.' } };
397
399
  }
398
400
  if (recognizesPackages !== undefined && !isOwnerOrAdminEffective(req.session)) {
399
401
  return { status: 403, body: { error: 'Setting recognizesPackages requires an owner/admin-effective role.' } };
@@ -401,30 +403,31 @@ async function handleSourcesCreate(req, state, preflight = false) {
401
403
  if (recognizesPackages === true && connection.kind !== 'local') {
402
404
  return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
403
405
  }
404
- // fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `drive.mount`
406
+ // fenced-courtyard (HEALTH1 B3) — privileged-root ADD gate. `sources.mount`
405
407
  // attaches ordinary sources, but attaching a source rooted IN, OVER, or AT a
406
408
  // protected host zone (App Zone, the Neuralis tree, the whole host) requires
407
- // the `drive.mount.privileged` feature (not granted below the `admin` tier).
409
+ // the `sources.privileged` feature (not granted below the `admin` tier).
408
410
  // The check is canonical
409
411
  // path CONTAINMENT against the SHARED, filter-free protected-root table — NOT
410
412
  // exact-root or a client suggestion id (both spoofable via subpath / ancestor /
411
413
  // `..` / trailing-slash / a Docker free-root attach with no suggestion). The
412
414
  // requested root is resolved with `resolveLocalRoot` — the SAME canonicaliser
413
415
  // the connector uses — so the gate sees exactly what will be mounted.
414
- // native-nightjar BFIX (B2) — the HOST plane is privileged unconditionally,
415
- // BEFORE the root-sensitive container gate below. No root-string inspection
416
- // and no `protectedRoots.length` precondition: a host-plane attach is gated
417
- // even when the runtime stack is unavailable (fail closed), and a
418
- // sub-directory root is gated exactly like `/`.
416
+ // The HOST plane is decided BEFORE the root-sensitive container gate below:
417
+ // the device's owner with `device.own`, nobody else, with no root-string
418
+ // inspection and no `protectedRoots.length` precondition (fail closed even
419
+ // when the runtime stack is unavailable; a sub-directory is gated like `/`).
419
420
  // The container-plane arm is kind-AGNOSTIC (see `missingAttachFeature`).
420
- const missingForAttach = await missingAttachFeature(req.session, connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot);
421
+ const missingForAttach = await missingAttachFeature(req.session, connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot, ownDevice);
421
422
  if (missingForAttach) {
422
423
  return {
423
424
  status: 403,
424
425
  body: {
425
- error: missingForAttach.plane === 'host'
426
- ? `Attaching a host-plane source requires the ${missingForAttach.feature} feature.`
427
- : `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
426
+ error: missingForAttach.plane === 'device'
427
+ ? "Only the device's owner may attach a source on it."
428
+ : missingForAttach.plane === 'host'
429
+ ? `Attaching a host-plane source requires the ${missingForAttach.feature} feature.`
430
+ : `Attaching a source rooted in a protected host zone requires the ${missingForAttach.feature} feature.`,
428
431
  },
429
432
  };
430
433
  }
@@ -519,47 +522,11 @@ function isOwnerOrAdminEffective(session) {
519
522
  // is containment in BOTH directions (architect MAJOR-1).
520
523
  // ---------------------------------------------------------------------------
521
524
  /** Feature required to SEE/ADD a high-blast-radius host zone (C8 — no grant below the admin tier). */
522
- const PRIVILEGED_MOUNT_FEATURE = 'drive.mount.privileged';
523
- /**
524
- * Feature required to attach ANY source on the HOST plane (native-nightjar
525
- * BFIX / B2 — no grant below the admin tier).
526
- *
527
- * `drive.mount` is a MANAGER default. The privileged-root machinery above was
528
- * once scoped to `kind === 'local'`, so a new connector kind that reaches
529
- * outside the container would have walked straight past it: an ordinary
530
- * manager could attach a `host-*` source rooted at `/` and get full host
531
- * read/write over the `fs_*` tools — with no `exec.host`, no owner,
532
- * no privileged gate. (The container arm is kind-agnostic now — any
533
- * non-host-plane kind with a string `root` is gated — but rule 1 below is
534
- * still what makes the HOST plane safe independent of root shape.)
535
- *
536
- * Two rules close it, and BOTH always apply on this plane:
537
- * 1. every host-plane root is privileged BY CONSTRUCTION (there is no
538
- * unprivileged host root — see `hostPlaneRequiredFeatures`), and
539
- * 2. attaching one additionally requires this feature.
540
- *
541
- * The operator-owned broker ceiling is the third, out-of-band clamp: even a
542
- * granted caller only reaches paths the operator declared. The ceiling is the
543
- * floor; the feature is the audit trail.
544
- */
545
- const HOST_PLANE_MOUNT_FEATURE = 'drive.mount.host';
546
- // `isHostPlaneKind` lives in `@neuralis/package-system/contracts` — imported,
547
- // never re-declared. Two copies of a plane predicate is how the attach gate and
548
- // the terminal gate drift apart while both look correct in isolation.
549
- /**
550
- * Features required to attach a host-plane source, independent of its root.
551
- *
552
- * There is deliberately NO root-sensitivity here: on the host plane every root
553
- * is a protected zone, so a `/home/me/notes` attach is gated exactly like `/`.
554
- * Anything less would re-open B2 through a subdirectory.
555
- */
556
- function hostPlaneRequiredFeatures() {
557
- return [HOST_PLANE_MOUNT_FEATURE, PRIVILEGED_MOUNT_FEATURE];
558
- }
525
+ const PRIVILEGED_MOUNT_FEATURE = 'sources.privileged';
559
526
  /**
560
527
  * Build the filter-free protected-root table from the runtime stack. App Zone,
561
528
  * the Neuralis source tree, and the whole host (`/`) all require
562
- * `drive.mount.privileged`. `/` is ALWAYS included (both Docker and native) so
529
+ * `sources.privileged`. `/` is ALWAYS included (both Docker and native) so
563
530
  * the ADD gate catches a raw `config.root:'/'` attach that has no discoverable
564
531
  * suggestion under Docker.
565
532
  */
@@ -619,18 +586,21 @@ export function requiredFeaturesForRoot(root, protectedRoots, kind, mode = 'cont
619
586
  * the listing projection, so "you see the path you could attach" cannot drift
620
587
  * from what attaching requires.
621
588
  *
622
- * - Host plane: every `hostPlaneRequiredFeatures()` id, with no root inspection
623
- * and no runtime-stack precondition (fail closed when the stack is absent).
589
+ * - Host plane: the DEVICE's owner holding `device.own` (`ownDevice`,
590
+ * agent-core's device gate) and nobody else — a non-owner is refused as
591
+ * `device`, an owner without `device.own` is told the feature it lacks; no
592
+ * root inspection and no runtime-stack precondition (fail closed).
624
593
  * - Container plane, kind-AGNOSTIC: any kind whose config carries a string
625
594
  * `root` — `local`, and every root-bearing kind a package registers later —
626
595
  * resolves through `resolveRoot` (the connector's own canonicaliser) and hits
627
596
  * the SAME containment table. Scoping this to one kind is how a newly
628
597
  * registered root-bearing kind walks straight past the gate.
629
598
  */
630
- async function missingAttachFeature(session, connection, protectedRoots, resolveRoot) {
599
+ async function missingAttachFeature(session, connection, protectedRoots, resolveRoot, ownDevice) {
631
600
  if (isHostPlaneKind(connection.kind)) {
632
- const feature = hostPlaneRequiredFeatures().find((f) => !hasRouteFeature(session, f));
633
- return feature ? { feature, plane: 'host' } : null;
601
+ if (ownDevice.allowed)
602
+ return null;
603
+ return { feature: 'device.own', plane: ownDevice.owner ? 'host' : 'device' };
634
604
  }
635
605
  if (protectedRoots.length === 0)
636
606
  return null;
@@ -641,6 +611,30 @@ async function missingAttachFeature(session, connection, protectedRoots, resolve
641
611
  const feature = required.find((f) => !hasRouteFeature(session, f));
642
612
  return feature ? { feature, plane: 'zone' } : null;
643
613
  }
614
+ /**
615
+ * Ask agent-core's ONE device gate (`devices.mayAttach`) about the device a
616
+ * host-plane connection names. brain-core reads `config.device` as an opaque
617
+ * string and knows no device semantics; no agent-core, no device or a
618
+ * non-host kind ⇒ `{ allowed: false, owner: false }` (fail closed).
619
+ */
620
+ function deviceAttachVerdict(state, ctx, connection) {
621
+ const denied = { allowed: false, owner: false };
622
+ if (!isHostPlaneKind(connection.kind))
623
+ return denied;
624
+ const device = connection.config?.device;
625
+ if (typeof device !== 'string' || !device)
626
+ return denied;
627
+ const session = {
628
+ userId: ctx.userId,
629
+ projectId: ctx.projectId,
630
+ ...(ctx.agentId ? { agentId: ctx.agentId } : {}),
631
+ callerId: '@neuralis/brain-core',
632
+ callerTrust: 'first-party',
633
+ grantedFeatures: ctx.grantedFeatures,
634
+ };
635
+ const agentCore = state.ctx.packages?.getPackageApi('@neuralis/agent-core', session);
636
+ return agentCore?.devices?.mayAttach({ userId: ctx.userId, grantedFeatures: ctx.grantedFeatures }, device) ?? denied;
637
+ }
644
638
  /**
645
639
  * May this caller READ the source's root — the same per-uri read predicate the
646
640
  * listing paths use. Fails closed per row: no config store, or a throwing
@@ -653,16 +647,16 @@ async function rootReadable(infra, ctx, source, configCache) {
653
647
  }
654
648
  /**
655
649
  * The view `toPublicSourceConfig` answers with. The connection config goes to
656
- * a caller who could attach this source (`drive.mount` plus the gates
650
+ * a caller who could attach this source (`sources.mount` plus the gates
657
651
  * `missingAttachFeature` runs) OR can read its root — the files there already
658
652
  * show their host path as `os_uri`, so withholding it from a reader hides
659
- * nothing; the raw last-sync error goes to a `drive.sync` holder, the tier
653
+ * nothing; the raw last-sync error goes to a `sources.sync` holder, the tier
660
654
  * `sources/sync-status` answers to. A feature, never a role name.
661
655
  */
662
- async function sourceConfigView(session, config, projectRoot, protectedRoots, readable, defaultSyncExclude) {
663
- const mayAttach = hasRouteFeature(session, 'drive.mount')
664
- && await missingAttachFeature(session, config.connection, protectedRoots, (root) => resolveLocalRoot({ configured: root, projectRoot, source: config.source }).resolvedRoot) === null;
665
- return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'drive.sync'),
656
+ async function sourceConfigView(session, config, projectRoot, protectedRoots, readable, ownDevice, defaultSyncExclude) {
657
+ const mayAttach = (ownDevice.allowed || hasRouteFeature(session, 'sources.mount'))
658
+ && await missingAttachFeature(session, config.connection, protectedRoots, (root) => resolveLocalRoot({ configured: root, projectRoot, source: config.source }).resolvedRoot, ownDevice) === null;
659
+ return { mayReadPaths: mayAttach || readable, maySeeSyncErrors: hasRouteFeature(session, 'sources.sync'),
666
660
  ...(defaultSyncExclude ? { defaultSyncExclude } : {}) };
667
661
  }
668
662
  /** `sourceConfigView` for a single-row answer (single GET, mutation echoes). */
@@ -673,7 +667,7 @@ async function publicViewFor(session, ctx, config, state, infra) {
673
667
  }) : null;
674
668
  const defaults = matched && isOwnDataZoneDeclaration(matched.originPackage, matched.decl, state.ctx.selfId)
675
669
  ? seedExcludedMachineWrittenGlobs(state.ctx.platform.machineWrittenPaths()) : undefined;
676
- return sourceConfigView(session, config, infra?.projectRoot ?? '', buildProtectedRoots(state.ctx.platform.runtimeStack()), readable, defaults);
670
+ return sourceConfigView(session, config, infra?.projectRoot ?? '', buildProtectedRoots(state.ctx.platform.runtimeStack()), readable, deviceAttachVerdict(state, ctx, config.connection), defaults);
677
671
  }
678
672
  function filterSourceForList(config, ctx, requestedScope, includeObserved) {
679
673
  if (requestedScope && config.scope.kind !== requestedScope)
@@ -723,12 +717,9 @@ async function handleSourcesUpdate(req, state) {
723
717
  return { status: 400, body: { error: 'recognizesPackages must be a boolean if provided' } };
724
718
  }
725
719
  await ensureAccess(ctx, state);
726
- if (!hasRouteFeature(req.session, 'drive.mount')) {
727
- return { status: 403, body: { error: 'Updating sources requires drive.mount.' } };
728
- }
729
720
  // R2 (architect MINOR-2) — flagging a source `recognizesPackages` creates a
730
721
  // code-LOAD surface, so the WRITE is owner/admin-effective only, even though
731
- // other source fields are drive.mount. Deny-by-default.
722
+ // other source fields are sources.mount. Deny-by-default.
732
723
  if (recognizesPackages !== undefined && !isOwnerOrAdminEffective(req.session)) {
733
724
  return { status: 403, body: { error: 'Setting recognizesPackages requires an owner/admin-effective role.' } };
734
725
  }
@@ -737,6 +728,10 @@ async function handleSourcesUpdate(req, state) {
737
728
  const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
738
729
  if (!existing)
739
730
  return { status: 404, body: { error: `Source not found: ${source}` } };
731
+ const ownDevice = deviceAttachVerdict(state, ctx, existing.connection);
732
+ if (!ownDevice.allowed && !hasRouteFeature(req.session, 'sources.mount')) {
733
+ return { status: 403, body: { error: 'Updating sources requires sources.mount.' } };
734
+ }
740
735
  if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
741
736
  return { status: 403, body: { error: 'Forbidden for source scope' } };
742
737
  }
@@ -752,22 +747,24 @@ async function handleSourcesUpdate(req, state) {
752
747
  if (recognizesPackages === true && existing.connection.kind !== 'local') {
753
748
  return { status: 400, body: { error: 'recognizesPackages may only be true for local-kind sources.' } };
754
749
  }
755
- // native-nightjar BFIX (B2) — PATCH cannot change `connection`, but it CAN
756
- // widen an existing host-plane source's permissions (read → write). Editing
757
- // one therefore needs the same grants attaching one does; otherwise a
758
- // plain-`drive.mount` manager could escalate a read-only host source in place.
750
+ // PATCH cannot change `connection`, but it CAN widen an existing host-plane
751
+ // source's permissions (read → write). Editing one therefore needs what
752
+ // attaching one does — the device's owner with `device.own` — otherwise a
753
+ // plain-`sources.mount` manager could escalate a read-only host source in place.
759
754
  // Same rule for the container plane: a PATCH can widen permissions (read →
760
755
  // write) on a source already rooted in a protected zone, so editing one
761
756
  // requires the same grants attaching one does — for EVERY root-bearing kind,
762
757
  // not just `local`.
763
- const missingForEdit = await missingAttachFeature(req.session, existing.connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot);
758
+ const missingForEdit = await missingAttachFeature(req.session, existing.connection, buildProtectedRoots(state.ctx.platform.runtimeStack()), async (root) => resolveLocalRoot({ configured: root, projectRoot: (await state.infraFactory.getInfra(projectId)).projectRoot ?? '', source }).resolvedRoot, ownDevice);
764
759
  if (missingForEdit) {
765
760
  return {
766
761
  status: 403,
767
762
  body: {
768
- error: missingForEdit.plane === 'host'
769
- ? `Editing a host-plane source requires the ${missingForEdit.feature} feature.`
770
- : `Editing a source rooted in a protected host zone requires the ${missingForEdit.feature} feature.`,
763
+ error: missingForEdit.plane === 'device'
764
+ ? "Only the device's owner may edit a source on it."
765
+ : missingForEdit.plane === 'host'
766
+ ? `Editing a host-plane source requires the ${missingForEdit.feature} feature.`
767
+ : `Editing a source rooted in a protected host zone requires the ${missingForEdit.feature} feature.`,
771
768
  },
772
769
  };
773
770
  }
@@ -809,12 +806,14 @@ async function handleSourcesEnabled(req, state) {
809
806
  return { status: 400, body: { error: 'enabled must be boolean or "auto"' } };
810
807
  }
811
808
  await ensureAccess(ctx, state);
812
- if (!hasRouteFeature(req.session, 'drive.mount')) {
813
- return { status: 403, body: { error: 'Toggling source enabled state requires drive.mount.' } };
814
- }
815
809
  const bridge = await resolveBridge(state, ctx.projectId);
816
810
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
817
811
  const existing = await bridge.getSourceConfig(scope, source, toScopeAccessCtx(ctx));
812
+ // The owner of a paired device manages a source on it with `device.own` —
813
+ // online or offline (D24).
814
+ if (!hasRouteFeature(req.session, 'sources.mount') && !(existing && deviceAttachVerdict(state, ctx, existing.connection).allowed)) {
815
+ return { status: 403, body: { error: 'Toggling source enabled state requires sources.mount.' } };
816
+ }
818
817
  if (!existing)
819
818
  return { status: 404, body: { error: `Source not found: ${source}` } };
820
819
  if (!canAccessScope(existing.scope, toScopeAccessCtx(ctx), 'write')) {
@@ -832,13 +831,14 @@ async function handleSourcesEnabled(req, state) {
832
831
  async function handleSourcesDelete(slug, req, state) {
833
832
  const ctx = extractCtx(req);
834
833
  await ensureAccess(ctx, state);
835
- if (!hasRouteFeature(req.session, 'drive.mount')) {
836
- return { status: 403, body: { error: 'Deleting sources requires drive.mount.' } };
837
- }
838
834
  const bridge = await resolveBridge(state, ctx.projectId);
839
835
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
840
836
  const configs = await bridge.listSourceConfigs(scope, toScopeAccessCtx(ctx));
841
837
  const match = configs.find((c) => c.source.replace(/[^a-zA-Z0-9-]/g, '-') === slug);
838
+ // The owner of a paired device removes a source on it with `device.own` (D24).
839
+ if (!hasRouteFeature(req.session, 'sources.mount') && !(match && deviceAttachVerdict(state, ctx, match.connection).allowed)) {
840
+ return { status: 403, body: { error: 'Deleting sources requires sources.mount.' } };
841
+ }
842
842
  if (!match)
843
843
  return { status: 404, body: { error: `Source not found: ${slug}` } };
844
844
  if (!canAccessScope(match.scope, toScopeAccessCtx(ctx), 'write')) {
@@ -876,8 +876,8 @@ async function handleSourcesSync(req, state) {
876
876
  if (!source)
877
877
  return { status: 400, body: { error: 'Missing source' } };
878
878
  await ensureAccess(ctx, state);
879
- if (!hasRouteFeature(req.session, 'drive.sync')) {
880
- return { status: 403, body: { error: 'Sync requires drive.sync.' } };
879
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
880
+ return { status: 403, body: { error: 'Sync requires sources.sync.' } };
881
881
  }
882
882
  const bridge = await resolveBridge(state, projectId);
883
883
  const scope = { userId: ctx.userId, projectId };
@@ -906,13 +906,13 @@ async function handleSourcesSync(req, state) {
906
906
  * (architect F4): cancel is allowed exactly where the caller could trigger a
907
907
  * sync — the same `getSourceConfig(…, access)` visibility check — which
908
908
  * deliberately covers background/system jobs on that source (the freeze
909
- * scenario must be killable by any drive.sync holder who can see the source).
909
+ * scenario must be killable by any sources.sync holder who can see the source).
910
910
  */
911
911
  async function handleSourcesCancel(req, state) {
912
912
  const ctx = extractCtx(req);
913
913
  await ensureAccess(ctx, state);
914
- if (!hasRouteFeature(req.session, 'drive.sync')) {
915
- return { status: 403, body: { error: 'Cancelling sync requires drive.sync.' } };
914
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
915
+ return { status: 403, body: { error: 'Cancelling sync requires sources.sync.' } };
916
916
  }
917
917
  const bridge = await resolveBridge(state, ctx.projectId);
918
918
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -935,7 +935,7 @@ async function handleSourcesCancel(req, state) {
935
935
  return { status: 200, body: { ok: true, cancelled } };
936
936
  }
937
937
  /**
938
- * POST /sources/reindex — three operations behind ONE gate (`drive.sync` +
938
+ * POST /sources/reindex — three operations behind ONE gate (`sources.sync` +
939
939
  * source visibility):
940
940
  * - `{source}` — the full re-sync from scratch ("Rescan changes"): markers
941
941
  * cleared, a full job enqueued; unchanged indexed files reuse their vectors,
@@ -951,7 +951,7 @@ async function handleSourcesCancel(req, state) {
951
951
  * `scope:'platform'` (`platform.vector.maintain`, whole source only) it
952
952
  * catches up EVERY writer's points — the way back after a dense clear.
953
953
  * The document set is the sync inventory's scope, filtered by the caller's
954
- * per-uri READ policy before anything is counted — a `drive.sync` holder never
954
+ * per-uri READ policy before anything is counted — a `sources.sync` holder never
955
955
  * learns the size of a file it may not read.
956
956
  */
957
957
  async function handleSourcesReindex(req, state) {
@@ -965,8 +965,8 @@ async function handleSourcesReindex(req, state) {
965
965
  // CLAUDE.md principle 12).
966
966
  const ctx = extractCtx(req);
967
967
  await ensureAccess(ctx, state);
968
- if (!hasRouteFeature(req.session, 'drive.sync')) {
969
- return { status: 403, body: { error: 'Reindex requires drive.sync.' } };
968
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
969
+ return { status: 403, body: { error: 'Reindex requires sources.sync.' } };
970
970
  }
971
971
  const force = body?.force === true;
972
972
  const embeddingOnly = body?.embeddingOnly === true;
@@ -1089,8 +1089,8 @@ async function handleScopedReembed(ctx, state, bridge, req) {
1089
1089
  async function handleSyncStatus(req, state) {
1090
1090
  const ctx = extractCtx(req);
1091
1091
  await ensureAccess(ctx, state);
1092
- if (!hasRouteFeature(req.session, 'drive.sync')) {
1093
- return { status: 403, body: { error: 'Sync status requires drive.sync.' } };
1092
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1093
+ return { status: 403, body: { error: 'Sync status requires sources.sync.' } };
1094
1094
  }
1095
1095
  const bridge = await resolveBridge(state, ctx.projectId);
1096
1096
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -1116,10 +1116,10 @@ async function handleSourcesReset(req, state) {
1116
1116
  const ctx = extractCtx(req);
1117
1117
  await ensureAccess(ctx, state);
1118
1118
  // Reset mutates sync markers AND cancels the source's active job — it is
1119
- // a sync-shaped mutation, so it carries the same drive.sync gate + source
1119
+ // a sync-shaped mutation, so it carries the same sources.sync gate + source
1120
1120
  // visibility check as sync/cancel/reindex (architect MAJOR-2, F4 rule).
1121
- if (!hasRouteFeature(req.session, 'drive.sync')) {
1122
- return { status: 403, body: { error: 'Reset requires drive.sync.' } };
1121
+ if (!hasRouteFeature(req.session, 'sources.sync')) {
1122
+ return { status: 403, body: { error: 'Reset requires sources.sync.' } };
1123
1123
  }
1124
1124
  const bridge = await resolveBridge(state, ctx.projectId);
1125
1125
  const scope = { userId: ctx.userId, projectId: ctx.projectId };
@@ -1164,8 +1164,8 @@ async function handleSourcesDiscoverable(req, state) {
1164
1164
  // without this a viewer still read every mount's name and host path. The UI
1165
1165
  // is already fail-soft on a 403 here (`FileTree` gates its fetch on the same
1166
1166
  // feature; `SourcesPanel` catches into an empty list).
1167
- if (!hasRouteFeature(req.session, 'drive.mount')) {
1168
- return { status: 403, body: { error: 'Listing discoverable source roots requires the drive.mount feature.' } };
1167
+ if (!hasRouteFeature(req.session, 'sources.mount')) {
1168
+ return { status: 403, body: { error: 'Listing discoverable source roots requires the sources.mount feature.' } };
1169
1169
  }
1170
1170
  const stack = state.ctx.platform.runtimeStack();
1171
1171
  const bridge = await resolveBridge(state, ctx.projectId);
@@ -1368,7 +1368,7 @@ async function handleSourcesDiscoverable(req, state) {
1368
1368
  // Full host — only in native (non-Docker) mode, where `/` IS the host root
1369
1369
  // and is directly readable. In Docker the host is invisible unless bound
1370
1370
  // (`pnpm neuralis:mount add / --slug host`), which then surfaces via the
1371
- // generic mount loop below. Opt-in, gated by `drive.mount.privileged` (no
1371
+ // generic mount loop below. Opt-in, gated by `sources.privileged` (no
1372
1372
  // grant below the `admin` tier), attach READ-ONLY by default
1373
1373
  // (suggestedAccess) — the whole-host blast radius warrants deny-by-default;
1374
1374
  // the attacher narrows the uri-policy (or widens write) before use.
@@ -1449,15 +1449,15 @@ async function handleSourcesDiscoverable(req, state) {
1449
1449
  * warded-wren (HOST-OSACC-FB9). This handler used to be the one route in the
1450
1450
  * file that checked nothing: no `ensureAccess`, no in-handler feature, no
1451
1451
  * protected-root table, and a raw `err.message` in the body. The route-level
1452
- * `feature = 'drive.read'` is a VIEWER default, so on a native install any
1452
+ * `feature = 'files.read'` is a VIEWER default, so on a native install any
1453
1453
  * signed-in reader could walk the operator's host one level at a time
1454
1454
  * (`?path=/` → `/home` → `/root`), and on win32 enumerate the drive letters.
1455
1455
  *
1456
1456
  * The rule is one sentence: **you may browse exactly what you would be allowed
1457
- * to attach.** The base feature is `drive.mount` — the same gate
1457
+ * to attach.** The base feature is `sources.mount` — the same gate
1458
1458
  * `handleSourcesCreate` gives the act this one prepares — and per-path the
1459
1459
  * SHARED protected-root table decides, so `/`, the App Zone and the Neuralis
1460
- * tree cost `drive.mount.privileged` here exactly as they do at attach time.
1460
+ * tree cost `sources.privileged` here exactly as they do at attach time.
1461
1461
  * `'see'` is the semantically right column; today both columns hold the same
1462
1462
  * feature, so nothing in the suite proves the distinction — it is intent, not
1463
1463
  * enforcement.
@@ -1468,8 +1468,8 @@ async function handleSourcesBrowse(req, state) {
1468
1468
  // Not the security gate — the gates are the feature check below and the
1469
1469
  // per-path table. `ensureAccess` buys uniformity with every sibling handler
1470
1470
  // and the project-override 403 in `extractCtx`.
1471
- if (!hasRouteFeature(req.session, 'drive.mount')) {
1472
- return { status: 403, body: { error: 'Browsing filesystem paths requires the drive.mount feature.' } };
1471
+ if (!hasRouteFeature(req.session, 'sources.mount')) {
1472
+ return { status: 403, body: { error: 'Browsing filesystem paths requires the sources.mount feature.' } };
1473
1473
  }
1474
1474
  const { readdir, realpath } = await import('node:fs/promises');
1475
1475
  const { resolve, join, dirname } = await import('node:path');