@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,173 @@
1
+ import {
2
+ assertKeyLength,
3
+ blobDocumentIO,
4
+ envOnlyKeySource,
5
+ fileDocumentIO,
6
+ fileKeySource,
7
+ generateKey,
8
+ open,
9
+ seal,
10
+ type DocumentIO,
11
+ type KeySource,
12
+ } from './document.ts';
13
+ import { assertValidSecretRef, type SecretRef, type SecretStore } from './index.ts';
14
+ import type { BlobStore } from '#stores/blobs';
15
+
16
+ /**
17
+ * The **system** store — what the system holds in order to do its job.
18
+ *
19
+ * OAuth refresh tokens, OAuth client secrets, the profile API token. These
20
+ * authorise the system itself, and they are NEVER reachable from MCP, in any
21
+ * form, for any client. If an agent could read the Gmail refresh token it would
22
+ * simply call Google directly, and the entire policy layer would become
23
+ * decorative.
24
+ *
25
+ * Its sibling is `./vault.ts` — the owner's own passwords and API keys, which an
26
+ * agent may legitimately be granted access to under policy. They share this
27
+ * component, the document format, and the adapters. They share no key, no
28
+ * document, and no path from a `ProviderContext` — see the two tests named in
29
+ * `./index.ts`.
30
+ *
31
+ * WHAT THIS DOES NOT DO, stated because the security model says so rather than
32
+ * implying a guarantee the code does not deliver: encryption at rest protects
33
+ * stored credentials from someone reading the file. It does not protect a
34
+ * credential while a process is using it — the plaintext is in memory whenever
35
+ * a provider makes a call. That limit is inherent.
36
+ */
37
+
38
+ const MAGIC = 'lanes-link-credentials';
39
+ const KEY_ENV = 'LANES_LINK_CREDENTIAL_KEY';
40
+ const DEFAULT_BLOB_KEY = 'credentials.enc';
41
+
42
+ export interface FileSystemSecretsOptions {
43
+ /** Path to the encrypted file, e.g. `./data/personal/credentials.enc`. */
44
+ readonly path: string;
45
+ /**
46
+ * 32-byte key. When omitted, read from `LANES_LINK_CREDENTIAL_KEY` (base64),
47
+ * else from a sibling `.key` file, which is created on first use at 0600.
48
+ */
49
+ readonly key?: Uint8Array;
50
+ readonly env?: Record<string, string | undefined>;
51
+ }
52
+
53
+ export function createFileSecretStore(options: FileSystemSecretsOptions): SecretStore {
54
+ const keyPath = `${options.path}.key`;
55
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
56
+
57
+ return new DocumentSecretStore(
58
+ fileDocumentIO(options.path, keyPath),
59
+ fileKeySource({ keyPath, envVar: KEY_ENV, env, explicit: options.key }),
60
+ );
61
+ }
62
+
63
+ export interface BlobSecretsOptions {
64
+ /** Where the document lives. Scope it before passing it in. */
65
+ readonly store: BlobStore;
66
+ readonly key?: string;
67
+ readonly encryptionKey?: Uint8Array;
68
+ readonly env?: Record<string, string | undefined>;
69
+ }
70
+
71
+ export function createBlobSecretStore(options: BlobSecretsOptions): SecretStore {
72
+ const key = options.key ?? DEFAULT_BLOB_KEY;
73
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
74
+
75
+ return new DocumentSecretStore(
76
+ blobDocumentIO(options.store, key),
77
+ envOnlyKeySource({
78
+ envVar: KEY_ENV,
79
+ env,
80
+ explicit: options.encryptionKey,
81
+ label: key,
82
+ remedy: 'openssl rand -base64 32',
83
+ }),
84
+ );
85
+ }
86
+
87
+ /**
88
+ * A `SecretStore` over one encrypted document.
89
+ *
90
+ * The whole document is rewritten on every change, which is right for something
91
+ * holding tens of entries and would not be for something holding millions.
92
+ * Per-entry encryption with plaintext keys would be marginally more convenient
93
+ * and would leak which accounts exist.
94
+ */
95
+ class DocumentSecretStore implements SecretStore {
96
+ readonly #io: DocumentIO;
97
+ readonly #keySource: KeySource;
98
+ #key: Uint8Array | undefined;
99
+ #cache: Map<string, string> | undefined;
100
+
101
+ constructor(io: DocumentIO, keySource: KeySource) {
102
+ this.#io = io;
103
+ this.#keySource = keySource;
104
+ }
105
+
106
+ async get(ref: SecretRef): Promise<string | null> {
107
+ assertValidSecretRef(ref);
108
+ return (await this.#load()).get(ref) ?? null;
109
+ }
110
+
111
+ async has(ref: SecretRef): Promise<boolean> {
112
+ assertValidSecretRef(ref);
113
+ return (await this.#load()).has(ref);
114
+ }
115
+
116
+ async set(ref: SecretRef, value: string): Promise<void> {
117
+ assertValidSecretRef(ref);
118
+ const entries = await this.#load();
119
+ entries.set(ref, value);
120
+ await this.#save(entries);
121
+ }
122
+
123
+ async delete(ref: SecretRef): Promise<void> {
124
+ assertValidSecretRef(ref);
125
+ const entries = await this.#load();
126
+ if (entries.delete(ref)) await this.#save(entries);
127
+ }
128
+
129
+ async list(prefix?: string): Promise<SecretRef[]> {
130
+ const entries = await this.#load();
131
+ return [...entries.keys()].filter((ref) => !prefix || ref.startsWith(prefix)).sort();
132
+ }
133
+
134
+ /**
135
+ * Drop the decrypted copy from memory.
136
+ *
137
+ * Named for the interface it satisfies rather than for the hygiene it also
138
+ * provides. It was `forget()` and nothing ever called it, which is precisely
139
+ * how a rotated token stayed invisible to a running endpoint.
140
+ */
141
+ refresh(): void {
142
+ this.#cache = undefined;
143
+ }
144
+
145
+ async #resolveKey(): Promise<Uint8Array> {
146
+ return (this.#key ??= await this.#keySource());
147
+ }
148
+
149
+ async #load(): Promise<Map<string, string>> {
150
+ if (this.#cache) return this.#cache;
151
+
152
+ const text = await this.#io.read();
153
+ if (text === null) return (this.#cache = new Map());
154
+
155
+ const entries = open<Record<string, string>>(
156
+ MAGIC,
157
+ await this.#resolveKey(),
158
+ text,
159
+ this.#io,
160
+ KEY_ENV,
161
+ );
162
+ return (this.#cache = new Map(Object.entries(entries)));
163
+ }
164
+
165
+ async #save(entries: Map<string, string>): Promise<void> {
166
+ await this.#io.write(seal(MAGIC, await this.#resolveKey(), Object.fromEntries(entries)));
167
+ this.#cache = entries;
168
+ }
169
+ }
170
+
171
+ export { assertKeyLength };
172
+ /** A fresh credential-store key, base64, printed once and stored by the operator. */
173
+ export const generateCredentialKey = generateKey;
@@ -0,0 +1,336 @@
1
+ import {
2
+ blobDocumentIO,
3
+ envOnlyKeySource,
4
+ fileDocumentIO,
5
+ fileKeySource,
6
+ secretDocumentIO,
7
+ generateKey,
8
+ open,
9
+ seal,
10
+ type DocumentIO,
11
+ type KeySource,
12
+ } from './document.ts';
13
+ import type { BlobStore } from '#stores/blobs';
14
+
15
+ /**
16
+ * The **vault** store — the owner's own passwords and API keys.
17
+ *
18
+ * Its sibling is `./system.ts`, and the difference between them is the whole
19
+ * point of both: system credentials authorise Lanes Link itself and are
20
+ * unreachable from MCP; vault items belong to the owner and an agent may be
21
+ * granted one, by name, under policy.
22
+ *
23
+ * They share this component, the document format in `./document.ts`, and the
24
+ * adapters. They share **no key, no document, and no environment variable** —
25
+ * `LANES_LINK_VAULT_KEY` against `LANES_LINK_CREDENTIAL_KEY`. One master secret
26
+ * reused across purposes turns any single compromise into a total one, which is
27
+ * why the merge stopped where it did: two implementations of one format was
28
+ * duplication worth removing, two keys is the security model.
29
+ *
30
+ * The shapes differ too, and honestly so. A system credential is a ref and a
31
+ * string. A vault item carries a description and belongs to a connection,
32
+ * because each one becomes its own `vault.get.<id>` capability — which is what
33
+ * makes per-item policy expressible without teaching the policy engine about
34
+ * arguments (ADR-012 §3).
35
+ */
36
+
37
+ const MAGIC = 'lanes-link-vault';
38
+ const DEFAULT_BLOB_KEY = 'vault.enc';
39
+
40
+ /**
41
+ * The environment variable the key arrives in, and where the sealed document
42
+ * sits when the target keeps it in its credential store.
43
+ *
44
+ * Exported because a deploy has to wire both: it mints the key into the store
45
+ * and tells the platform to mount it under this name. Spelling either of them a
46
+ * second time in `deployments/` is how they drift, and the failure that causes
47
+ * is a revision whose vault reads fail with the key sitting right there.
48
+ */
49
+ export const VAULT_KEY_ENV = 'LANES_LINK_VAULT_KEY';
50
+ export const DEFAULT_SECRET_REF = 'vault/document';
51
+
52
+ /**
53
+ * Where the key itself lives, for a target whose credential store can hold it.
54
+ *
55
+ * Beside the document rather than somewhere else, which reads wrong and is not:
56
+ * ADR-022 put the ciphertext in Secret Manager *because* the key already came
57
+ * from there, and a key the deploy cannot reach is one an operator has to carry
58
+ * by hand forever. What keeps them apart is that this one is mounted as an
59
+ * environment variable and the document is not — an attacker holding the
60
+ * document alone still holds ciphertext.
61
+ */
62
+ export const VAULT_KEY_REF = 'vault/key';
63
+
64
+ const KEY_ENV = VAULT_KEY_ENV;
65
+
66
+ /** Item ids become part of a capability name, so they are held to its grammar. */
67
+ export const VAULT_ITEM_ID = /^[a-z0-9][a-z0-9_]*$/;
68
+
69
+ export interface VaultItem {
70
+ readonly id: string;
71
+ readonly value: string;
72
+ readonly description?: string;
73
+ readonly updatedAt: string;
74
+ }
75
+
76
+ /**
77
+ * Storage for vault items, scoped by connection.
78
+ *
79
+ * `ids` returns names only — there is no operation that enumerates values,
80
+ * which is the one property this and the system store genuinely share.
81
+ */
82
+ export interface VaultStore {
83
+ get(connectionId: string, id: string): Promise<VaultItem | null>;
84
+ put(connectionId: string, item: Omit<VaultItem, 'updatedAt'>): Promise<void>;
85
+ delete(connectionId: string, id: string): Promise<boolean>;
86
+ /** Every `<connection>/<item>` pair held, so the runtime can register reads. */
87
+ ids(): Promise<ReadonlyArray<{ connectionId: string; id: string; description?: string }>>;
88
+ }
89
+
90
+ type Entries = Record<string, { value: string; description?: string; updatedAt: string }>;
91
+
92
+ export interface FileVaultStoreOptions {
93
+ /** Path to the encrypted file, e.g. `./data/personal/vault.enc`. */
94
+ readonly path: string;
95
+ /**
96
+ * 32-byte key. When omitted, read from `LANES_LINK_VAULT_KEY` (base64), else
97
+ * from a sibling `.key` file created on first use at 0600.
98
+ */
99
+ readonly key?: Uint8Array;
100
+ readonly env?: Record<string, string | undefined>;
101
+ }
102
+
103
+ export function createFileVaultStore(options: FileVaultStoreOptions): VaultStore {
104
+ const keyPath = `${options.path}.key`;
105
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
106
+
107
+ return new DocumentVaultStore(
108
+ fileDocumentIO(options.path, keyPath),
109
+ fileKeySource({ keyPath, envVar: KEY_ENV, env, explicit: options.key }),
110
+ );
111
+ }
112
+
113
+ export interface BlobVaultStoreOptions {
114
+ /** Where the document lives. Scope it before passing it in. */
115
+ readonly store: BlobStore;
116
+ /** Object key. One document, so one key. */
117
+ readonly key?: string;
118
+ /** 32-byte key. When omitted, `LANES_LINK_VAULT_KEY` — and nothing else. */
119
+ readonly encryptionKey?: Uint8Array;
120
+ readonly env?: Record<string, string | undefined>;
121
+ }
122
+
123
+ /**
124
+ * The vault in blob storage — what a deployed instance uses.
125
+ *
126
+ * The file adapter was unconditional before ADR-014: a deployed instance wrote
127
+ * its vault to a container filesystem, and every item in it was discarded by the
128
+ * next revision without an error to say so.
129
+ */
130
+ export function createBlobVaultStore(options: BlobVaultStoreOptions): VaultStore {
131
+ const key = options.key ?? DEFAULT_BLOB_KEY;
132
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
133
+
134
+ return new DocumentVaultStore(
135
+ blobDocumentIO(options.store, key),
136
+ envOnlyKeySource({
137
+ envVar: KEY_ENV,
138
+ env,
139
+ explicit: options.encryptionKey,
140
+ label: key,
141
+ remedy: 'lanes link vault key generate',
142
+ }),
143
+ );
144
+ }
145
+
146
+ export interface SecretVaultStoreOptions {
147
+ /** The target's own secret store. */
148
+ readonly store: {
149
+ get(ref: string): Promise<string | null>;
150
+ set(ref: string, value: string): Promise<void>;
151
+ };
152
+ /** Where the document lives. One document, so one ref. */
153
+ readonly ref?: string;
154
+ /** 32-byte key. When omitted, `LANES_LINK_VAULT_KEY` — and nothing else. */
155
+ readonly encryptionKey?: Uint8Array;
156
+ readonly env?: Record<string, string | undefined>;
157
+ }
158
+
159
+ /**
160
+ * The vault as a single entry in the secret store — what a deployment uses.
161
+ *
162
+ * This looks like the merge the file above spends three paragraphs forbidding,
163
+ * and it is not. What must never be shared is the **key**, and it is not: the
164
+ * document is sealed under `LANES_LINK_VAULT_KEY` before it gets here, so the
165
+ * secret store holds ciphertext it cannot read. Separate document, separate
166
+ * key, separate environment variable — the three things `./index.ts` names.
167
+ * The backend was never on that list, and the two stores have shared adapters
168
+ * since they were merged.
169
+ *
170
+ * **Why the bucket arrangement it replaces was backwards.** `blob` put the
171
+ * vault in object storage so reading it took two things: bucket access *and*
172
+ * the key. But the key already arrives from Secret Manager via `--set-secrets`,
173
+ * and anyone with Secret Manager read access already holds every OAuth refresh
174
+ * token — the whole of Gmail, Drive, and iCloud. The vault is the smaller
175
+ * asset and it was the one behind the taller fence. See ADR-022.
176
+ *
177
+ * Item *names* stay encrypted because they are inside the document, which is
178
+ * why this is one secret rather than one per item.
179
+ *
180
+ * The cost, stated: a secret store that versions writes gains a version per
181
+ * `vault put`. That is a few cents a month on Secret Manager and `vault put` is
182
+ * a rare human action, so nothing prunes them — a prune would need
183
+ * `secretmanager.versions.destroy`, which is deliberately not granted to the
184
+ * identity a deployment runs as.
185
+ */
186
+ export function createSecretVaultStore(options: SecretVaultStoreOptions): VaultStore {
187
+ const ref = options.ref ?? DEFAULT_SECRET_REF;
188
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
189
+
190
+ return new DocumentVaultStore(
191
+ secretDocumentIO(options.store, ref),
192
+ envOnlyKeySource({
193
+ envVar: KEY_ENV,
194
+ env,
195
+ explicit: options.encryptionKey,
196
+ label: ref,
197
+ remedy: 'lanes link vault key generate',
198
+ }),
199
+ );
200
+ }
201
+
202
+ class DocumentVaultStore implements VaultStore {
203
+ readonly #io: DocumentIO;
204
+ readonly #keySource: KeySource;
205
+ #key: Uint8Array | undefined;
206
+ #cache: Entries | undefined;
207
+
208
+ constructor(io: DocumentIO, keySource: KeySource) {
209
+ this.#io = io;
210
+ this.#keySource = keySource;
211
+ }
212
+
213
+ async get(connectionId: string, id: string): Promise<VaultItem | null> {
214
+ const entry = (await this.#load())[keyFor(connectionId, id)];
215
+ if (!entry) return null;
216
+
217
+ return {
218
+ id,
219
+ value: entry.value,
220
+ ...(entry.description ? { description: entry.description } : {}),
221
+ updatedAt: entry.updatedAt,
222
+ };
223
+ }
224
+
225
+ async put(connectionId: string, item: Omit<VaultItem, 'updatedAt'>): Promise<void> {
226
+ assertItemId(item.id);
227
+ const entries = { ...(await this.#load()) };
228
+
229
+ entries[keyFor(connectionId, item.id)] = {
230
+ value: item.value,
231
+ ...(item.description ? { description: item.description } : {}),
232
+ updatedAt: new Date().toISOString(),
233
+ };
234
+
235
+ await this.#save(entries);
236
+ }
237
+
238
+ async delete(connectionId: string, id: string): Promise<boolean> {
239
+ const entries = { ...(await this.#load()) };
240
+ const key = keyFor(connectionId, id);
241
+ if (!(key in entries)) return false;
242
+
243
+ delete entries[key];
244
+ await this.#save(entries);
245
+ return true;
246
+ }
247
+
248
+ async ids(): Promise<ReadonlyArray<{ connectionId: string; id: string; description?: string }>> {
249
+ return listIds(await this.#load());
250
+ }
251
+
252
+ async #resolveKey(): Promise<Uint8Array> {
253
+ return (this.#key ??= await this.#keySource());
254
+ }
255
+
256
+ async #load(): Promise<Entries> {
257
+ if (this.#cache) return this.#cache;
258
+
259
+ const text = await this.#io.read();
260
+ if (text === null) return (this.#cache = {});
261
+
262
+ return (this.#cache = open<Entries>(MAGIC, await this.#resolveKey(), text, this.#io, KEY_ENV));
263
+ }
264
+
265
+ async #save(entries: Entries): Promise<void> {
266
+ await this.#io.write(seal(MAGIC, await this.#resolveKey(), entries));
267
+ this.#cache = entries;
268
+ }
269
+ }
270
+
271
+ /** An in-memory store, for tests and for a target that persists nothing. */
272
+ export function createMemoryVaultStore(seed: Entries = {}): VaultStore {
273
+ const entries: Entries = { ...seed };
274
+
275
+ return {
276
+ async get(connectionId, id) {
277
+ const entry = entries[keyFor(connectionId, id)];
278
+ if (!entry) return null;
279
+ return {
280
+ id,
281
+ value: entry.value,
282
+ ...(entry.description ? { description: entry.description } : {}),
283
+ updatedAt: entry.updatedAt,
284
+ };
285
+ },
286
+ async put(connectionId, item) {
287
+ assertItemId(item.id);
288
+ entries[keyFor(connectionId, item.id)] = {
289
+ value: item.value,
290
+ ...(item.description ? { description: item.description } : {}),
291
+ updatedAt: new Date().toISOString(),
292
+ };
293
+ },
294
+ async delete(connectionId, id) {
295
+ const key = keyFor(connectionId, id);
296
+ if (!(key in entries)) return false;
297
+ delete entries[key];
298
+ return true;
299
+ },
300
+ async ids() {
301
+ return listIds(entries);
302
+ },
303
+ };
304
+ }
305
+
306
+ function listIds(
307
+ entries: Entries,
308
+ ): ReadonlyArray<{ connectionId: string; id: string; description?: string }> {
309
+ return Object.entries(entries)
310
+ .map(([key, entry]) => {
311
+ const slash = key.indexOf('/');
312
+ return {
313
+ connectionId: key.slice(0, slash),
314
+ id: key.slice(slash + 1),
315
+ ...(entry.description ? { description: entry.description } : {}),
316
+ };
317
+ })
318
+ .sort((a, b) => `${a.connectionId}/${a.id}`.localeCompare(`${b.connectionId}/${b.id}`));
319
+ }
320
+
321
+ function keyFor(connectionId: string, id: string): string {
322
+ return `${connectionId}/${id}`;
323
+ }
324
+
325
+ export function assertItemId(id: string): void {
326
+ if (!VAULT_ITEM_ID.test(id)) {
327
+ throw new Error(
328
+ `Vault item id ${JSON.stringify(id)} must be lowercase letters, digits and "_". ` +
329
+ 'The id becomes part of the capability name — "vault.get.github_token" — which is what ' +
330
+ 'lets policy grant one item without granting the rest (ADR-012).',
331
+ );
332
+ }
333
+ }
334
+
335
+ /** Generate a fresh vault key, distinct from the system store's. */
336
+ export const generateVaultKey = generateKey;
@@ -0,0 +1,197 @@
1
+ import { mergeCapabilities, type ProfileRuntime } from './mcp/index.ts';
2
+ import type { Principal } from '#auth';
3
+
4
+ /**
5
+ * Handing the endpoint some bytes, so a later call can name them.
6
+ *
7
+ * The one thing the protocol cannot do. A tool argument carries JSON, so a file
8
+ * in a tool call is base64 in the model's output — which for a 239 KB PDF is
9
+ * around 320,000 characters, past what a model can write in one message. There is
10
+ * no client-to-server binary channel in any released version of MCP, and `roots`
11
+ * never carried bytes even before it was deprecated. So the bytes come in over
12
+ * ordinary HTTP, out of band, and what travels through the model afterwards is a
13
+ * handle. That is also the shape the protocol's own draft file-transfer work
14
+ * settles on.
15
+ *
16
+ * This is what makes attachments work when the endpoint is not on the caller's
17
+ * machine. A `path` names the filesystem the *server* can see, which on a hosted
18
+ * deployment is a container and not the operator's Mac; a handle does not care
19
+ * where either end is.
20
+ *
21
+ * **Staged for one connection, not for the profile.** A blob store handed to a
22
+ * provider is already namespaced by provider and connection, and the point of
23
+ * that is that one account's bytes are not reachable from another. Staging into a
24
+ * shared area would quietly widen it. So an upload names its target, the
25
+ * principal has to be able to reach that target, and the handle resolves only
26
+ * from there.
27
+ *
28
+ * This file does HTTP and authorization; the write itself is
29
+ * `Dispatcher.stageAttachment`. Not a split for its own sake — `server` may not
30
+ * reach a store, and should not: how blobs are namespaced is not something a
31
+ * component that speaks HTTP should know, and putting the write behind the
32
+ * dispatcher is also what gets it into the audit log.
33
+ */
34
+
35
+ export const ATTACHMENTS_PATH = '/attachments';
36
+
37
+ /**
38
+ * Largest upload accepted.
39
+ *
40
+ * Above every mail host's own ceiling — 35 MiB is the largest here — so this is
41
+ * never the binding limit on a legitimate send, and well under Bun's 128 MiB
42
+ * default body cap so a refusal is ours and legible rather than the runtime's.
43
+ */
44
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
45
+
46
+ export interface StageOptions {
47
+ readonly profiles: ReadonlyMap<string, ProfileRuntime>;
48
+ readonly primary: string;
49
+ readonly principal: Principal;
50
+ readonly request: Request;
51
+ readonly clientLabel?: string | undefined;
52
+ }
53
+
54
+ export async function stageAttachment(options: StageOptions): Promise<Response> {
55
+ const { request } = options;
56
+
57
+ if (request.method !== 'POST') {
58
+ return problem(405, 'Stage an attachment with POST.');
59
+ }
60
+
61
+ const url = new URL(request.url);
62
+ const profileName = url.searchParams.get('profile') ?? options.primary;
63
+ const target = url.searchParams.get('connection');
64
+
65
+ if (!target) {
66
+ return problem(
67
+ 400,
68
+ 'Name the connection this is for, as ?connection=<provider>.<account> — a staged file belongs to one account, not to the endpoint.',
69
+ );
70
+ }
71
+
72
+ const runtime = options.profiles.get(profileName);
73
+ if (!runtime) {
74
+ return problem(404, `No profile "${profileName}" on this endpoint.`);
75
+ }
76
+
77
+ const [providerId, ...rest] = target.split('.');
78
+ const connectionId = rest.join('.');
79
+ if (!providerId || !connectionId) {
80
+ return problem(400, `"${target}" is not a connection. Use <provider>.<account>.`);
81
+ }
82
+
83
+ // The same policy that decides which connections a tool may name. Staging into
84
+ // an account the caller cannot act on would be a write they are not permitted
85
+ // to make, even though nothing is sent by it.
86
+ if (!reachable(runtime, options, profileName, target)) {
87
+ return problem(
88
+ 403,
89
+ `Connection "${target}" is not reachable for this token in profile "${profileName}".`,
90
+ );
91
+ }
92
+
93
+ const bytes = await readCapped(request);
94
+ if (!bytes) {
95
+ return problem(413, `An attachment must be smaller than ${MAX_UPLOAD_BYTES} bytes.`);
96
+ }
97
+ if (bytes.byteLength === 0) {
98
+ return problem(400, 'The request body was empty, so there is nothing to stage.');
99
+ }
100
+
101
+ const filename = filenameFrom(request.headers.get('x-filename'));
102
+ const contentType = request.headers.get('content-type') ?? 'application/octet-stream';
103
+
104
+ const staged = await runtime.dispatcher.stageAttachment({
105
+ principal: options.principal,
106
+ providerId,
107
+ connectionId,
108
+ bytes,
109
+ filename,
110
+ contentType,
111
+ clientLabel: options.clientLabel,
112
+ });
113
+
114
+ return Response.json({
115
+ handle: staged.handle,
116
+ filename,
117
+ bytes: bytes.byteLength,
118
+ content_type: contentType,
119
+ sha256: staged.sha256,
120
+ expires_at: new Date(staged.expiresAt).toISOString(),
121
+ connection: target,
122
+ profile: profileName,
123
+ hint: `Pass it as an attachment: { "handle": "${staged.handle}" }`,
124
+ });
125
+ }
126
+
127
+ /** Whether this principal can reach that connection for anything at all. */
128
+ function reachable(
129
+ runtime: ProfileRuntime,
130
+ options: StageOptions,
131
+ profileName: string,
132
+ target: string,
133
+ ): boolean {
134
+ const merged = mergeCapabilities({
135
+ profiles: new Map([[profileName, runtime]]),
136
+ principal: options.principal,
137
+ });
138
+
139
+ for (const capability of merged.values()) {
140
+ for (const connections of capability.reachable.values()) {
141
+ if (connections.includes(target)) return true;
142
+ }
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ /**
149
+ * Read the body, refusing past the cap.
150
+ *
151
+ * Streamed rather than buffered-then-measured, so an oversized upload costs the
152
+ * bytes it takes to notice rather than all of them.
153
+ */
154
+ async function readCapped(request: Request): Promise<Uint8Array | null> {
155
+ const declared = Number(request.headers.get('content-length') ?? Number.NaN);
156
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) return null;
157
+
158
+ if (!request.body) return new Uint8Array();
159
+
160
+ const reader = request.body.getReader();
161
+ const chunks: Uint8Array[] = [];
162
+ let total = 0;
163
+
164
+ for (;;) {
165
+ const { done, value } = await reader.read();
166
+ if (done) break;
167
+ total += value.byteLength;
168
+ if (total > MAX_UPLOAD_BYTES) {
169
+ await reader.cancel().catch(() => {});
170
+ return null;
171
+ }
172
+ chunks.push(value);
173
+ }
174
+
175
+ const bytes = new Uint8Array(total);
176
+ let offset = 0;
177
+ for (const chunk of chunks) {
178
+ bytes.set(chunk, offset);
179
+ offset += chunk.byteLength;
180
+ }
181
+ return bytes;
182
+ }
183
+
184
+ /**
185
+ * A filename safe to record and to put in a MIME header.
186
+ *
187
+ * Basename only, and no traversal: the value is echoed into a
188
+ * `Content-Disposition` and stored beside the bytes, and neither wants a path.
189
+ */
190
+ function filenameFrom(header: string | null): string {
191
+ const candidate = (header ?? '').split(/[/\\]/).pop()?.trim() ?? '';
192
+ return candidate === '' || candidate === '.' || candidate === '..' ? 'attachment' : candidate;
193
+ }
194
+
195
+ function problem(status: number, message: string): Response {
196
+ return Response.json({ error: message }, { status });
197
+ }