@lanes-sh/link 0.1.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 (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,195 @@
1
+ import type { AuditLogger } from '#audit';
2
+ import type { SecretRef, SecretStore } from '#secrets';
3
+ import { scopeSecrets } from '#secrets';
4
+ import type { RuntimeState } from '#stores/state';
5
+ import type { BlobStore } from '#stores/blobs';
6
+ import { scopeBlobStore } from '#stores/blobs';
7
+ import type {
8
+ ConnectionInfo,
9
+ Logger,
10
+ ProviderContext,
11
+ ProviderDefinition,
12
+ ProviderManifest,
13
+ ScopedStore,
14
+ } from '#connectivity';
15
+ import { credentialRefForConnection } from '#connectivity';
16
+ import type { ConnectionConfig } from '#profile';
17
+
18
+ /**
19
+ * Assembling what a provider is handed.
20
+ *
21
+ * Providers receive scoped capabilities, never raw backends. Every boundary
22
+ * here is enforced by construction rather than by asking providers to behave:
23
+ * the state namespace is applied before the provider sees the store, the blob
24
+ * namespace likewise, and credentials are restricted to an explicit allowlist
25
+ * computed from what the connection declares.
26
+ */
27
+
28
+ /** `<provider>/<connection>` — the namespace both state and blobs are scoped to. */
29
+ export function scopeNamespace(provider: string, connectionId: string): string {
30
+ return `${provider}/${connectionId}`;
31
+ }
32
+
33
+ export function createScopedStore(state: RuntimeState, namespace: string): ScopedStore {
34
+ return {
35
+ get: (key) => state.kv.get(namespace, key),
36
+ set: (key, value) => state.kv.set(namespace, key, value),
37
+ delete: (key) => state.kv.delete(namespace, key),
38
+ keys: () => state.kv.keys(namespace),
39
+
40
+ async getJson<T>(key: string): Promise<T | null> {
41
+ const raw = await state.kv.get(namespace, key);
42
+ if (raw === null) return null;
43
+ try {
44
+ return JSON.parse(raw) as T;
45
+ } catch {
46
+ // Stored state that will not parse is a provider bug, not a reason to
47
+ // fail the request; treat it as absent and let the provider rewrite it.
48
+ return null;
49
+ }
50
+ },
51
+
52
+ setJson: (key, value) => state.kv.set(namespace, key, JSON.stringify(value)),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Which credential refs this connection may read.
58
+ *
59
+ * A provider declares them via `credentialRefs`; if it declares nothing, the
60
+ * connection's own `credential_ref` is the entire allowlist. Either way the set
61
+ * is computed here rather than asked for at read time, so a provider cannot
62
+ * widen it by asking for more.
63
+ */
64
+ export function resolveSecretRefs(
65
+ manifest: ProviderManifest,
66
+ connection: ConnectionConfig,
67
+ definition?: ProviderDefinition,
68
+ /**
69
+ * Which `app` entries this profile declares a client of its own for.
70
+ *
71
+ * Only consulted for a provider that could also authorise against a broker.
72
+ * Defaults to none, which is correct for every provider that declares no
73
+ * broker: the gate below leaves those exactly as they were.
74
+ */
75
+ ownClients: readonly string[] = [],
76
+ ): SecretRef[] {
77
+ const refs = new Set<SecretRef>(
78
+ definition?.credentialRefs?.(connection.id, connection.config ?? {}) ?? [],
79
+ );
80
+
81
+ if (connection.credential_ref) refs.add(connection.credential_ref);
82
+
83
+ const auth = manifest.auth;
84
+
85
+ if (auth.kind === 'oauth') {
86
+ // The tokens for this connection, and — for a manual registration — the
87
+ // shared app credentials every connection of that vendor authorises
88
+ // against. Nothing wider: `gmail.main` must not reach `gmail/side`.
89
+ refs.add(`${manifest.id}/${connection.id}`);
90
+ if (auth.registration === 'manual' && auth.app) {
91
+ // Only where this profile holds a client of its own. When the client is
92
+ // the broker's there is no id, secret, or registration anywhere in this
93
+ // store, so naming the three refs would grant reach to nothing while
94
+ // describing an arrangement that does not exist — and a grant list is
95
+ // read by people deciding what a provider can see.
96
+ if (!auth.broker || ownClients.includes(auth.app)) {
97
+ refs.add(`${auth.app}/client_id`);
98
+ refs.add(`${auth.app}/client_secret`);
99
+ refs.add(`${auth.app}/client`);
100
+ }
101
+ } else {
102
+ refs.add(`${manifest.id}/client`);
103
+ }
104
+ }
105
+
106
+ if (auth.kind !== 'none' && auth.kind !== 'oauth') {
107
+ // The connection's own credential, wherever the manifest puts it — which
108
+ // may be a ref shared with sibling providers of the same vendor
109
+ // (`icloud/ada` serves mail, calendar, and contacts). Shared across
110
+ // *providers* is not shared across *accounts*: this adds exactly one ref, so
111
+ // `icloud_mail.will` still cannot reach `icloud/sam`.
112
+ const ref = credentialRefForConnection(manifest, connection.id);
113
+ if (ref) refs.add(ref);
114
+ }
115
+
116
+ return [...refs];
117
+ }
118
+
119
+ export interface BuildContextOptions {
120
+ readonly manifest: ProviderManifest;
121
+ /** Present only for `local` providers. */
122
+ readonly definition?: ProviderDefinition | undefined;
123
+ readonly connection: ConnectionConfig;
124
+ readonly state: RuntimeState;
125
+ readonly credentials: SecretStore;
126
+ readonly storage: BlobStore;
127
+ readonly audit: AuditLogger;
128
+ readonly log: Logger;
129
+ readonly signal: AbortSignal;
130
+ /** The same closure the connector context gets. Absent for `local` providers. */
131
+ readonly authorize?: ((request: Request) => Promise<Request>) | undefined;
132
+ /** `oauth_apps` entries this profile declares. See `resolveSecretRefs`. */
133
+ readonly ownClients?: readonly string[] | undefined;
134
+ }
135
+
136
+ export function buildProviderContext(options: BuildContextOptions): ProviderContext {
137
+ const { manifest, definition, connection } = options;
138
+ const namespace = scopeNamespace(manifest.id, connection.id);
139
+
140
+ const info: ConnectionInfo = {
141
+ id: connection.id,
142
+ key: `${manifest.id}.${connection.id}`,
143
+ displayName: connection.account,
144
+ config: connection.config ?? {},
145
+ };
146
+
147
+ return {
148
+ connection: info,
149
+ state: createScopedStore(options.state, namespace),
150
+ storage: scopeBlobStore(options.storage, namespace),
151
+ credentials: scopeSecrets(
152
+ options.credentials,
153
+ resolveSecretRefs(manifest, connection, definition, options.ownClients ?? []),
154
+ ),
155
+ audit: options.audit,
156
+ log: options.log,
157
+ signal: options.signal,
158
+ ...(options.authorize ? { authorize: options.authorize } : {}),
159
+ };
160
+ }
161
+
162
+ /** A logger that prefixes provider and connection, so lines are attributable. */
163
+ export function createProviderLogger(base: Logger, provider: string, connection: string): Logger {
164
+ const prefix = `[${provider}.${connection}]`;
165
+ return {
166
+ debug: (message, detail) => base.debug(`${prefix} ${message}`, detail),
167
+ info: (message, detail) => base.info(`${prefix} ${message}`, detail),
168
+ warn: (message, detail) => base.warn(`${prefix} ${message}`, detail),
169
+ error: (message, detail) => base.error(`${prefix} ${message}`, detail),
170
+ };
171
+ }
172
+
173
+ export function createConsoleLogger(level: 'debug' | 'info' | 'warn' | 'error' = 'info'): Logger {
174
+ const order = { debug: 0, info: 1, warn: 2, error: 3 } as const;
175
+ const threshold = order[level];
176
+
177
+ const emit = (
178
+ kind: keyof typeof order,
179
+ message: string,
180
+ detail?: Record<string, unknown>,
181
+ ): void => {
182
+ if (order[kind] < threshold) return;
183
+ const suffix = detail && Object.keys(detail).length > 0 ? ` ${JSON.stringify(detail)}` : '';
184
+ // stderr throughout: stdout belongs to the CLI's own output, and a log line
185
+ // interleaved into a printed token or config diff is worse than useless.
186
+ process.stderr.write(`${kind.padEnd(5)} ${message}${suffix}\n`);
187
+ };
188
+
189
+ return {
190
+ debug: (message, detail) => emit('debug', message, detail),
191
+ info: (message, detail) => emit('info', message, detail),
192
+ warn: (message, detail) => emit('warn', message, detail),
193
+ error: (message, detail) => emit('error', message, detail),
194
+ };
195
+ }
@@ -0,0 +1,350 @@
1
+ import type { AuditDraft, AuditLogger, AuditSink, AuthorizationResult } from '#audit';
2
+ import { keepKeys, redactAllValues } from '#audit';
3
+ import type { Principal } from '#auth';
4
+ import type { SecretStore } from '#secrets';
5
+ import type { RuntimeState } from '#stores/state';
6
+ import type { PolicyDocument } from '#policy';
7
+ import { RateLimiter, evaluate } from '#policy';
8
+ import type { BlobStore } from '#stores/blobs';
9
+ import type { AnyConnector, CapabilityResult, ConnectorContext, Logger } from '#connectivity';
10
+ import { isToolResult } from '#connectivity';
11
+ import type { Config } from '#profile';
12
+ import { buildProviderContext, createProviderLogger } from './context.ts';
13
+ import { stageAttachment, type StagedAttachment, type StageRequest } from './staging.ts';
14
+ import type { ProviderRegistry } from '#registry';
15
+
16
+ /**
17
+ * The dispatch path.
18
+ *
19
+ * Every invocation goes through here in one fixed order — authenticate,
20
+ * resolve, authorize, rate-limit, dispatch, audit — with no way around it.
21
+ * The ordering is not stylistic: policy is evaluated before a provider is
22
+ * reached, so a provider never sees a request it was not authorised to serve,
23
+ * and authorization is never something provider code could get wrong.
24
+ *
25
+ * Exactly one audit event is written per invocation, on every path including
26
+ * denials. That is enforced structurally by `finally` rather than by
27
+ * remembering to call it at each return.
28
+ */
29
+
30
+ export interface DispatchDeps {
31
+ readonly config: Config;
32
+ readonly registry: ProviderRegistry;
33
+ /**
34
+ * Resolve the connector for a provider. Supplied by the caller so core does
35
+ * not import connector implementations, keeping the dependency direction
36
+ * intact: infrastructure -> sdk -> core -> connectors.
37
+ */
38
+ readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
39
+ /** Attach whatever the manifest's auth kind requires to an outbound request. */
40
+ readonly authorizeRequest?: (
41
+ providerId: string,
42
+ connectionId: string,
43
+ request: Request,
44
+ ) => Promise<Request>;
45
+ readonly policy: PolicyDocument;
46
+ /** Optional instance floor. Empty in M1; composition is tighten-only. */
47
+ readonly floor?: PolicyDocument;
48
+ readonly state: RuntimeState;
49
+ /**
50
+ * Where events go. Separate from `state` because the log is no longer in
51
+ * it — and because dispatch only ever writes, so taking the sink rather than
52
+ * the whole store means this path structurally cannot read the log back.
53
+ */
54
+ readonly audit: AuditSink;
55
+ readonly credentials: SecretStore;
56
+ readonly storage: BlobStore;
57
+ readonly limiter: RateLimiter;
58
+ readonly log: Logger;
59
+ readonly now?: () => number;
60
+ }
61
+
62
+ export interface DispatchRequest {
63
+ readonly principal: Principal;
64
+ /** Fully qualified, e.g. `example.echo`. */
65
+ readonly capabilityId: string;
66
+ /** Fully qualified, e.g. `example.a`. */
67
+ readonly connectionKey: string;
68
+ readonly arguments: Readonly<Record<string, unknown>>;
69
+ /** Self-reported by the client. Observability only — never authorization. */
70
+ readonly clientLabel?: string | undefined;
71
+ readonly signal?: AbortSignal;
72
+ }
73
+
74
+ /**
75
+ * `result` is a union because a capability is not necessarily a tool — a
76
+ * resource read, a resource listing, and a prompt render come back through this
77
+ * same path, deliberately, so that all four are policy-checked and audited
78
+ * identically. Narrow it with the `is*Result` guards from the provider SDK.
79
+ */
80
+ export type DispatchOutcome =
81
+ | { readonly ok: true; readonly result: CapabilityResult }
82
+ | { readonly ok: false; readonly authorization: AuthorizationResult; readonly message: string };
83
+
84
+ export class Dispatcher {
85
+ readonly #deps: DispatchDeps;
86
+ readonly #now: () => number;
87
+
88
+ constructor(deps: DispatchDeps) {
89
+ this.#deps = deps;
90
+ this.#now = deps.now ?? Date.now;
91
+ }
92
+
93
+ /**
94
+ * Take bytes for a later call to name, and record that it happened.
95
+ *
96
+ * The body is in `staging.ts`; what belongs here is that it goes through the
97
+ * dispatcher at all. Staging is a write against one account — it puts the
98
+ * operator's file inside the endpoint, where a later send can post it outward
99
+ * — so it is audited on the same path as the send rather than beside it.
100
+ */
101
+ async stageAttachment(request: StageRequest): Promise<StagedAttachment> {
102
+ return stageAttachment(
103
+ {
104
+ storage: this.#deps.storage,
105
+ audit: this.#deps.audit,
106
+ profile: this.#deps.config.instance.profile,
107
+ now: this.#now,
108
+ },
109
+ request,
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Record an attempt that was refused before dispatch.
115
+ *
116
+ * Policy-filtered discovery means a capability the caller may not use is
117
+ * never advertised, so a call naming it is rejected by the protocol layer
118
+ * and never reaches `invoke`. Nothing was invoked — but an agent probing for
119
+ * tools it does not have is precisely the behaviour the log exists to
120
+ * capture, and without this it would leave no trace at all.
121
+ */
122
+ async recordRefusal(request: {
123
+ readonly principal: Principal;
124
+ readonly capabilityId: string;
125
+ readonly connectionKey?: string | undefined;
126
+ readonly clientLabel?: string | undefined;
127
+ }): Promise<void> {
128
+ const [providerId = ''] = splitKey(request.capabilityId);
129
+
130
+ await this.#deps.audit.append({
131
+ profile: this.#deps.config.instance.profile,
132
+ principal: request.principal.id,
133
+ ...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
134
+ provider: providerId,
135
+ ...(request.connectionKey ? { connection: request.connectionKey } : {}),
136
+ capability: request.capabilityId,
137
+ arguments: {},
138
+ authorization: 'denied_default',
139
+ status: 'not_invoked',
140
+ durationMs: 0,
141
+ error: {
142
+ kind: 'not_available',
143
+ message: 'capability is not advertised to this principal',
144
+ },
145
+ });
146
+ }
147
+
148
+ async invoke(request: DispatchRequest): Promise<DispatchOutcome> {
149
+ const started = this.#now();
150
+ const { config, registry, state, audit } = this.#deps;
151
+ const [providerId = ''] = splitKey(request.capabilityId);
152
+
153
+ const annotations: Record<string, unknown> = {};
154
+ let authorization: AuthorizationResult = 'denied_default';
155
+ let status: AuditDraft['status'] = 'not_invoked';
156
+ let auditArguments: Record<string, unknown> = redactAllValues(request.arguments);
157
+ let error: { kind: string; message: string } | undefined;
158
+ let outcome: DispatchOutcome = {
159
+ ok: false,
160
+ authorization: 'denied_default',
161
+ message: 'not evaluated',
162
+ };
163
+
164
+ try {
165
+ const registered = registry.findCapability(request.capabilityId);
166
+ if (!registered) {
167
+ error = { kind: 'unknown_capability', message: `No such capability: ${request.capabilityId}` };
168
+ // An unknown capability is reported as a plain denial rather than
169
+ // "no such tool", so probing cannot distinguish a capability that does
170
+ // not exist from one this profile may not use.
171
+ return (outcome = deny('denied_default', `Capability ${request.capabilityId} is not available`));
172
+ }
173
+
174
+ const entry = registry.get(providerId)!;
175
+
176
+ // Apply redaction now that we know which capability this is, so a denial
177
+ // is logged with the same redaction an allowed call would get.
178
+ //
179
+ // Local capabilities carry their own rule. Discovered ones cannot: we did
180
+ // not author them and cannot know what is sensitive, so the default
181
+ // withholds every value and a manifest opts specific keys back in.
182
+ const declaredKeys = entry.manifest.redact?.[registered.capability?.name ?? registered.discovered?.name ?? ''];
183
+ const redact =
184
+ registered.capability?.redact ?? (declaredKeys ? keepKeys(...declaredKeys) : undefined);
185
+
186
+ auditArguments = (redact ?? redactAllValues)(request.arguments);
187
+
188
+ const declared = config.connections.find(
189
+ (connection) => `${connection.provider}.${connection.id}` === request.connectionKey,
190
+ );
191
+ if (!declared || declared.provider !== providerId) {
192
+ return (outcome = deny(
193
+ 'denied_default',
194
+ `Connection ${request.connectionKey} is not available`,
195
+ ));
196
+ }
197
+
198
+ const decision = evaluate(
199
+ {
200
+ principal: request.principal.id,
201
+ capability: request.capabilityId,
202
+ connection: request.connectionKey,
203
+ },
204
+ this.#deps.policy,
205
+ this.#deps.floor,
206
+ );
207
+
208
+ authorization = decision.reason;
209
+ if (!decision.allowed) {
210
+ return (outcome = deny(
211
+ decision.reason,
212
+ `Not permitted: ${request.capabilityId} on ${request.connectionKey}`,
213
+ ));
214
+ }
215
+
216
+ // Two buckets: one for the endpoint as a whole, one per connection to
217
+ // protect a vendor's quota from an agent stuck in a retry loop.
218
+ const perProfile = this.#deps.limiter.take(
219
+ `profile:${config.instance.profile}`,
220
+ config.limits.requests_per_minute,
221
+ );
222
+ const perConnection = perProfile.allowed
223
+ ? this.#deps.limiter.take(
224
+ `connection:${request.connectionKey}`,
225
+ config.limits.upstream_calls_per_minute,
226
+ )
227
+ : perProfile;
228
+
229
+ if (!perProfile.allowed || !perConnection.allowed) {
230
+ const retryAfterMs = Math.max(perProfile.retryAfterMs, perConnection.retryAfterMs);
231
+ authorization = 'denied_rate_limited';
232
+ return (outcome = deny(
233
+ 'denied_rate_limited',
234
+ `Rate limit exceeded. Retry in ${Math.ceil(retryAfterMs / 1000)}s.`,
235
+ ));
236
+ }
237
+
238
+ const record = await state.connections.get(declared.provider, declared.id);
239
+ if (record && record.status !== 'active') {
240
+ authorization = 'denied_connection_unauthorized';
241
+ return (outcome = deny(
242
+ 'denied_connection_unauthorized',
243
+ record.status === 'unauthorized'
244
+ ? `Connection ${request.connectionKey} has no valid credential. Run: lanes link connect ${request.connectionKey}`
245
+ : `Connection ${request.connectionKey} is disabled.`,
246
+ ));
247
+ }
248
+
249
+ const auditLogger: AuditLogger = {
250
+ annotate(detail) {
251
+ Object.assign(annotations, detail);
252
+ },
253
+ };
254
+
255
+ // One closure, handed to both contexts. A provider that authors a
256
+ // capability its transport cannot express still calls the vendor through
257
+ // the same authorizer the transport would have used.
258
+ const authorize = (outbound: Request): Promise<Request> =>
259
+ this.#deps.authorizeRequest
260
+ ? this.#deps.authorizeRequest(providerId, declared.id, outbound)
261
+ : Promise.resolve(outbound);
262
+
263
+ const providerContext = buildProviderContext({
264
+ manifest: entry.manifest,
265
+ definition: entry.definition,
266
+ connection: declared,
267
+ state,
268
+ credentials: this.#deps.credentials,
269
+ storage: this.#deps.storage,
270
+ audit: auditLogger,
271
+ log: createProviderLogger(this.#deps.log, providerId, declared.id),
272
+ signal: request.signal ?? new AbortController().signal,
273
+ // Which vendors this profile registered a client of its own for. A
274
+ // provider that could be brokered but is not reads its client from the
275
+ // store, so it has to be able to.
276
+ ownClients: Object.keys(this.#deps.config.oauth_apps),
277
+ ...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
278
+ });
279
+
280
+ const connector = this.#deps.connectorFor(providerId, declared.id);
281
+ if (!connector) {
282
+ status = 'error';
283
+ error = { kind: 'no_connector', message: `No connector for provider ${providerId}` };
284
+ return (outcome = {
285
+ ok: false,
286
+ authorization: 'allowed',
287
+ message: `Provider ${providerId} has no usable connector.`,
288
+ });
289
+ }
290
+
291
+ const connectorContext: ConnectorContext = {
292
+ manifest: entry.manifest,
293
+ provider: providerContext,
294
+ authorize,
295
+ };
296
+
297
+ // The connector owns argument validation: a local provider validates
298
+ // against its Zod schema, a proxied one against the upstream JSON Schema
299
+ // the MCP layer already enforced at registration.
300
+ const target = registered.discovered ?? {
301
+ name: registered.capability!.name,
302
+ description: registered.capability!.description,
303
+ inputSchema: {},
304
+ };
305
+
306
+ const result = await connector.invoke(target, request.arguments, connectorContext);
307
+ // Only a tool can report a soft failure. A resource or a prompt that
308
+ // cannot produce its answer throws, and lands in the catch below.
309
+ status = isToolResult(result) && result.isError ? 'error' : 'ok';
310
+ return (outcome = { ok: true, result });
311
+ } catch (caught) {
312
+ status = 'error';
313
+ error = { kind: 'provider_error', message: (caught as Error).message };
314
+ return (outcome = {
315
+ ok: false,
316
+ authorization,
317
+ message: `${request.capabilityId} failed: ${(caught as Error).message}`,
318
+ });
319
+ } finally {
320
+ // One event per invocation, on every path. An audit log that records
321
+ // only successes cannot answer "what did this agent try to do", which is
322
+ // the question worth asking.
323
+ await audit.append({
324
+ profile: config.instance.profile,
325
+ principal: request.principal.id,
326
+ ...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
327
+ provider: providerId,
328
+ connection: request.connectionKey,
329
+ capability: request.capabilityId,
330
+ arguments:
331
+ Object.keys(annotations).length > 0
332
+ ? { ...auditArguments, _annotations: annotations }
333
+ : auditArguments,
334
+ authorization: outcome.ok ? 'allowed' : authorization,
335
+ status,
336
+ durationMs: this.#now() - started,
337
+ ...(error ? { error } : {}),
338
+ });
339
+ }
340
+ }
341
+ }
342
+
343
+ function deny(authorization: AuthorizationResult, message: string): DispatchOutcome {
344
+ return { ok: false, authorization, message };
345
+ }
346
+
347
+ function splitKey(key: string): [string, string] {
348
+ const index = key.indexOf('.');
349
+ return index === -1 ? [key, ''] : [key.slice(0, index), key.slice(index + 1)];
350
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The invocation path — the one sequence every call runs, with no way around
3
+ * it:
4
+ *
5
+ * policy → limits → provider → audit
6
+ *
7
+ * The ordering is not stylistic. Policy is evaluated *before* a provider is
8
+ * reached, so a provider never sees a request it was not authorised to serve
9
+ * and authorization is never something provider code could get wrong. Exactly
10
+ * one audit event is written per invocation, by a `finally` rather than by
11
+ * remembering to call it at each return.
12
+ *
13
+ * `context.ts` is what a provider is handed: scoped state, scoped blobs, an
14
+ * allowlist of credential refs, and a logger. Never a raw backend.
15
+ */
16
+
17
+ export {
18
+ buildProviderContext,
19
+ createConsoleLogger,
20
+ createProviderLogger,
21
+ createScopedStore,
22
+ resolveSecretRefs,
23
+ scopeNamespace,
24
+ type BuildContextOptions,
25
+ } from './context.ts';
26
+
27
+ export {
28
+ Dispatcher,
29
+ type DispatchDeps,
30
+ type DispatchOutcome,
31
+ type DispatchRequest,
32
+ } from './dispatch.ts';
@@ -0,0 +1,102 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AuditSink } from '#audit';
3
+ import type { Principal } from '#auth';
4
+ import { newHandle, putStaged, sweepStaged, STAGED_TTL_MS } from '#connectivity/mail';
5
+ import type { BlobStore } from '#stores/blobs';
6
+ import { scopeBlobStore } from '#stores/blobs';
7
+ import { scopeNamespace } from './context.ts';
8
+
9
+ /**
10
+ * Taking bytes now for a call to name later.
11
+ *
12
+ * In dispatch rather than in the transport that receives the upload, for two
13
+ * reasons. The dependency direction is one: `server` may not reach a store, and
14
+ * should not — how blobs are namespaced is not something a component that speaks
15
+ * HTTP has any business knowing. The audit log is the other, and the better one.
16
+ * Staging is a write against one account: it puts the operator's file *inside*
17
+ * the endpoint, where a later send can post it outward. That belongs in the same
18
+ * log as the send, and this is the only place that writes it.
19
+ *
20
+ * Beside `dispatch.ts` rather than in it because it shares nothing with `invoke`
21
+ * but the deps — no policy evaluation, no rate limit, no connector — and that
22
+ * file was at the size budget, which is the budget doing its job.
23
+ */
24
+
25
+ export interface StageRequest {
26
+ readonly principal: Principal;
27
+ readonly providerId: string;
28
+ readonly connectionId: string;
29
+ readonly bytes: Uint8Array;
30
+ readonly filename: string;
31
+ readonly contentType: string;
32
+ readonly clientLabel?: string | undefined;
33
+ }
34
+
35
+ export interface StagedAttachment {
36
+ readonly handle: string;
37
+ readonly sha256: string;
38
+ readonly expiresAt: number;
39
+ }
40
+
41
+ export async function stageAttachment(
42
+ deps: {
43
+ readonly storage: BlobStore;
44
+ readonly audit: AuditSink;
45
+ readonly profile: string;
46
+ readonly now: () => number;
47
+ },
48
+ request: StageRequest,
49
+ ): Promise<StagedAttachment> {
50
+ // Namespaced per provider and connection, exactly as `buildProviderContext`
51
+ // scopes the store it hands a provider — which is what makes the handle
52
+ // resolvable from that connection and from no other.
53
+ const storage = scopeBlobStore(
54
+ deps.storage,
55
+ scopeNamespace(request.providerId, request.connectionId),
56
+ );
57
+
58
+ // Swept before writing: staging is the only thing that creates this garbage,
59
+ // and there is no scheduler in this process, so the moment someone stages is
60
+ // both the cheapest and the most reliable time to clear the last batch. A
61
+ // failure to sweep must not fail the upload — the worst case is a stale blob
62
+ // that the next sweep catches.
63
+ await sweepStaged(storage).catch(() => 0);
64
+
65
+ const handle = newHandle();
66
+ const sha256 = createHash('sha256').update(request.bytes).digest('hex');
67
+ const expiresAt = deps.now() + STAGED_TTL_MS;
68
+
69
+ await putStaged(storage, {
70
+ handle,
71
+ bytes: request.bytes,
72
+ metadata: {
73
+ filename: request.filename,
74
+ content_type: request.contentType,
75
+ sha256,
76
+ expires_at: expiresAt,
77
+ },
78
+ });
79
+
80
+ await deps.audit.append({
81
+ profile: deps.profile,
82
+ principal: request.principal.id,
83
+ ...(request.clientLabel ? { clientLabel: request.clientLabel } : {}),
84
+ provider: request.providerId,
85
+ connection: `${request.providerId}.${request.connectionId}`,
86
+ capability: 'attachments.stage',
87
+ // Identifiers, not content — the same rule the send path follows. The
88
+ // filename and digest are what make "which file entered the endpoint" an
89
+ // answerable question; the bytes are the thing being withheld.
90
+ arguments: {
91
+ filename: request.filename,
92
+ bytes: request.bytes.byteLength,
93
+ content_type: request.contentType,
94
+ sha256,
95
+ },
96
+ authorization: 'allowed',
97
+ status: 'ok',
98
+ durationMs: 0,
99
+ });
100
+
101
+ return { handle, sha256, expiresAt };
102
+ }