@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,374 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { AuditDraft, AuditStore } from './index.ts';
3
+
4
+ /**
5
+ * One behavioural suite, run against every backing store.
6
+ *
7
+ * The same reasoning as `#stores/blobs/conformance.ts`: an operator who
8
+ * switches targets must not find that the log they could read locally is one
9
+ * their deployed target answers differently. The chain in particular is worth
10
+ * holding to one rule — a `verify` that passes on a filesystem and misses a
11
+ * tampered record in a bucket would be worse than no `verify` at all.
12
+ *
13
+ * The fixture exposes raw key access because the interesting half of this
14
+ * suite is what happens when somebody edits the log behind the endpoint's
15
+ * back, and there is no honest way to test that through the interface — the
16
+ * absence of a way to mutate an event is the guarantee being tested.
17
+ */
18
+ export interface ContractSink {
19
+ open(options?: { now?: () => Date; run?: string }): AuditStore;
20
+ keys(): Promise<string[]>;
21
+ read(key: string): Promise<Uint8Array | null>;
22
+ write(key: string, bytes: Uint8Array): Promise<void>;
23
+ remove(key: string): Promise<void>;
24
+ dispose?(): Promise<void>;
25
+ }
26
+
27
+ function draft(overrides: Partial<AuditDraft> = {}): AuditDraft {
28
+ return {
29
+ profile: 'personal',
30
+ principal: 'personal:owner',
31
+ provider: 'gmail',
32
+ connection: 'gmail.main',
33
+ capability: 'gmail.users_messages_list',
34
+ arguments: { q: '<string:12>' },
35
+ authorization: 'allowed',
36
+ status: 'ok',
37
+ durationMs: 12,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ /** A clock that advances a second per call, so ordering is deterministic. */
43
+ function ticking(start = Date.UTC(2026, 7, 12, 10, 0, 0)): () => Date {
44
+ let at = start;
45
+ return () => {
46
+ at += 1000;
47
+ return new Date(at);
48
+ };
49
+ }
50
+
51
+ export function describeAuditSinkContract(
52
+ name: string,
53
+ create: () => Promise<ContractSink> | ContractSink,
54
+ ): void {
55
+ describe(`audit sink contract: ${name}`, () => {
56
+ async function use(body: (fixture: ContractSink) => Promise<void>): Promise<void> {
57
+ const fixture = await create();
58
+ try {
59
+ await body(fixture);
60
+ } finally {
61
+ await fixture.dispose?.();
62
+ }
63
+ }
64
+
65
+ describe('writing and reading back', () => {
66
+ test('append stamps an id and a timestamp, and tail returns the event', async () => {
67
+ await use(async (fixture) => {
68
+ const sink = fixture.open({ now: ticking() });
69
+ const written = await sink.append(draft());
70
+
71
+ expect(written.id).toMatch(/^evt_/);
72
+ expect(written.timestamp).toBeInstanceOf(Date);
73
+
74
+ const [read] = await sink.tail();
75
+ expect(read?.id).toBe(written.id);
76
+ expect(read?.capability).toBe('gmail.users_messages_list');
77
+ expect(read?.timestamp.getTime()).toBe(written.timestamp.getTime());
78
+ });
79
+ });
80
+
81
+ test('tail presents oldest first', async () => {
82
+ await use(async (fixture) => {
83
+ const sink = fixture.open({ now: ticking() });
84
+ await sink.append(draft({ capability: 'one' }));
85
+ await sink.append(draft({ capability: 'two' }));
86
+ await sink.append(draft({ capability: 'three' }));
87
+
88
+ expect((await sink.tail()).map((event) => event.capability)).toEqual([
89
+ 'one',
90
+ 'two',
91
+ 'three',
92
+ ]);
93
+ });
94
+ });
95
+
96
+ test('a limit keeps the newest, not the first written', async () => {
97
+ await use(async (fixture) => {
98
+ const sink = fixture.open({ now: ticking() });
99
+ for (const capability of ['one', 'two', 'three', 'four']) {
100
+ await sink.append(draft({ capability }));
101
+ }
102
+
103
+ expect((await sink.tail({ limit: 2 })).map((event) => event.capability)).toEqual([
104
+ 'three',
105
+ 'four',
106
+ ]);
107
+ });
108
+ });
109
+
110
+ test('events sharing a millisecond keep their order', async () => {
111
+ await use(async (fixture) => {
112
+ // Keys are timestamped to the millisecond and then disambiguated by a
113
+ // random id, so sorting by key alone leaves same-millisecond events in
114
+ // arbitrary order. `seq` is the tiebreaker — the job SQLite's implicit
115
+ // `rowid` used to do.
116
+ //
117
+ // This is not a corner case. A refusal is answered without touching a
118
+ // provider, so a rate-limited call and the call before it routinely
119
+ // land in the same millisecond, and getting this wrong hides the
120
+ // denial the log exists to show.
121
+ const frozen = new Date(Date.UTC(2026, 7, 12, 10, 0, 0, 0));
122
+ const sink = fixture.open({ now: () => frozen });
123
+
124
+ await sink.append(draft({ capability: 'first' }));
125
+ await sink.append(draft({ capability: 'second' }));
126
+ await sink.append(draft({ capability: 'third', authorization: 'denied_rate_limited' }));
127
+
128
+ expect((await sink.tail()).map((event) => event.capability)).toEqual([
129
+ 'first',
130
+ 'second',
131
+ 'third',
132
+ ]);
133
+
134
+ // And a limit has to cut by that order too, not by the random half
135
+ // of a key: the newest is the denial.
136
+ expect((await sink.tail({ limit: 1 })).map((event) => event.capability)).toEqual([
137
+ 'third',
138
+ ]);
139
+ });
140
+ });
141
+
142
+ test('redacted arguments survive nesting', async () => {
143
+ await use(async (fixture) => {
144
+ const sink = fixture.open({ now: ticking() });
145
+ const args = { query: '<string:12>', nested: { keep: 1, list: [1, 'two', null] } };
146
+ await sink.append(draft({ arguments: args }));
147
+
148
+ expect((await sink.tail())[0]?.arguments).toEqual(args);
149
+ });
150
+ });
151
+
152
+ test('optional fields are absent rather than null when unset', async () => {
153
+ await use(async (fixture) => {
154
+ const sink = fixture.open({ now: ticking() });
155
+ await sink.append(draft({ arguments: {} }));
156
+
157
+ const [event] = await sink.tail();
158
+ expect(event).not.toHaveProperty('clientLabel');
159
+ expect(event).not.toHaveProperty('error');
160
+ });
161
+ });
162
+
163
+ test('an error is carried through', async () => {
164
+ await use(async (fixture) => {
165
+ const sink = fixture.open({ now: ticking() });
166
+ await sink.append(
167
+ draft({ status: 'error', error: { kind: 'provider_error', message: 'upstream 500' } }),
168
+ );
169
+
170
+ expect((await sink.tail())[0]?.error).toEqual({
171
+ kind: 'provider_error',
172
+ message: 'upstream 500',
173
+ });
174
+ });
175
+ });
176
+
177
+ test('events written either side of midnight both come back', async () => {
178
+ await use(async (fixture) => {
179
+ // The store is date-partitioned, so a day boundary is the seam where
180
+ // a tail that only looked at one prefix would quietly lose half the log.
181
+ const times = [
182
+ new Date(Date.UTC(2026, 7, 12, 23, 59, 59)),
183
+ new Date(Date.UTC(2026, 7, 13, 0, 0, 1)),
184
+ ];
185
+ let index = 0;
186
+ const sink = fixture.open({ now: () => times[index++] ?? times[times.length - 1]! });
187
+
188
+ await sink.append(draft({ capability: 'before' }));
189
+ await sink.append(draft({ capability: 'after' }));
190
+
191
+ expect((await sink.tail()).map((event) => event.capability)).toEqual([
192
+ 'before',
193
+ 'after',
194
+ ]);
195
+ });
196
+ });
197
+ });
198
+
199
+ describe('filters', () => {
200
+ test('each predicate narrows the log', async () => {
201
+ await use(async (fixture) => {
202
+ const sink = fixture.open({ now: ticking() });
203
+ await sink.append(draft({ provider: 'gmail', capability: 'gmail.list' }));
204
+ await sink.append(
205
+ draft({ provider: 'drive', connection: 'drive.main', capability: 'drive.list' }),
206
+ );
207
+ await sink.append(
208
+ draft({ provider: 'vault', capability: 'vault.get', authorization: 'denied_by_policy' }),
209
+ );
210
+
211
+ expect((await sink.tail({ provider: 'drive' })).map((e) => e.capability)).toEqual([
212
+ 'drive.list',
213
+ ]);
214
+ expect((await sink.tail({ connection: 'drive.main' })).map((e) => e.capability)).toEqual([
215
+ 'drive.list',
216
+ ]);
217
+ expect((await sink.tail({ capability: 'gmail.list' })).map((e) => e.provider)).toEqual([
218
+ 'gmail',
219
+ ]);
220
+ expect((await sink.tail({ deniedOnly: true })).map((e) => e.capability)).toEqual([
221
+ 'vault.get',
222
+ ]);
223
+ });
224
+ });
225
+
226
+ test('since excludes anything older', async () => {
227
+ await use(async (fixture) => {
228
+ const sink = fixture.open({ now: ticking() });
229
+ const first = await sink.append(draft({ capability: 'old' }));
230
+ await sink.append(draft({ capability: 'new' }));
231
+
232
+ const after = new Date(first.timestamp.getTime() + 1);
233
+ expect((await sink.tail({ since: after })).map((e) => e.capability)).toEqual(['new']);
234
+ });
235
+ });
236
+ });
237
+
238
+ describe('the chain', () => {
239
+ test('an untouched log verifies', async () => {
240
+ await use(async (fixture) => {
241
+ const sink = fixture.open({ now: ticking() });
242
+ for (const capability of ['one', 'two', 'three']) {
243
+ await sink.append(draft({ capability }));
244
+ }
245
+
246
+ const report = await sink.verify();
247
+ expect(report.ok).toBe(true);
248
+ expect(report.events).toBe(3);
249
+ expect(report.runs).toBe(1);
250
+ expect(report.breaks).toEqual([]);
251
+ });
252
+ });
253
+
254
+ test('editing a record is caught', async () => {
255
+ await use(async (fixture) => {
256
+ const sink = fixture.open({ now: ticking() });
257
+ await sink.append(draft({ capability: 'one' }));
258
+ await sink.append(draft({ capability: 'two' }));
259
+ await sink.append(draft({ capability: 'three' }));
260
+
261
+ // Rewrite the middle event as though it had been allowed, which is
262
+ // the edit somebody covering their tracks would actually make.
263
+ const keys = (await fixture.keys()).filter((key) => key.endsWith('.json')).sort();
264
+ const target = keys[1]!;
265
+ const original = new TextDecoder().decode((await fixture.read(target))!);
266
+ await fixture.write(
267
+ target,
268
+ new TextEncoder().encode(original.replace('"two"', '"rewritten"')),
269
+ );
270
+
271
+ const report = await sink.verify();
272
+ expect(report.ok).toBe(false);
273
+ expect(report.breaks[0]?.kind).toBe('hash');
274
+ });
275
+ });
276
+
277
+ test('deleting a record from the middle is caught', async () => {
278
+ await use(async (fixture) => {
279
+ const sink = fixture.open({ now: ticking() });
280
+ await sink.append(draft({ capability: 'one' }));
281
+ await sink.append(draft({ capability: 'two' }));
282
+ await sink.append(draft({ capability: 'three' }));
283
+
284
+ const keys = (await fixture.keys()).filter((key) => key.endsWith('.json')).sort();
285
+ await fixture.remove(keys[1]!);
286
+
287
+ const report = await sink.verify();
288
+ expect(report.ok).toBe(false);
289
+ expect(report.breaks[0]?.kind).toBe('gap');
290
+ });
291
+ });
292
+
293
+ test('truncating a closed run is caught', async () => {
294
+ await use(async (fixture) => {
295
+ const sink = fixture.open({ now: ticking() });
296
+ await sink.append(draft({ capability: 'one' }));
297
+ await sink.append(draft({ capability: 'two' }));
298
+ await sink.append(draft({ capability: 'three' }));
299
+ await sink.close();
300
+
301
+ const keys = (await fixture.keys())
302
+ .filter((key) => key.endsWith('.json') && !key.startsWith('runs.closed/'))
303
+ .sort();
304
+ await fixture.remove(keys[keys.length - 1]!);
305
+
306
+ // Nothing inside a chain points forward, so the close marker is the
307
+ // only thing that can notice the tail is missing.
308
+ const report = await sink.verify();
309
+ expect(report.ok).toBe(false);
310
+ expect(report.breaks[0]?.detail).toContain('closed at 3 events');
311
+ });
312
+ });
313
+
314
+ test('an unreadable record is reported rather than thrown', async () => {
315
+ await use(async (fixture) => {
316
+ const sink = fixture.open({ now: ticking() });
317
+ await sink.append(draft({ capability: 'one' }));
318
+
319
+ const key = (await fixture.keys()).find((candidate) => candidate.endsWith('.json'))!;
320
+ await fixture.write(key, new TextEncoder().encode('{ not json'));
321
+
322
+ const report = await sink.verify();
323
+ expect(report.ok).toBe(false);
324
+ expect(report.breaks[0]?.kind).toBe('malformed');
325
+
326
+ // And a corrupt object must not take a tail down with it.
327
+ expect(await sink.tail()).toEqual([]);
328
+ });
329
+ });
330
+
331
+ test('a run that wrote nothing leaves no marker', async () => {
332
+ await use(async (fixture) => {
333
+ const sink = fixture.open({ now: ticking() });
334
+ await sink.close();
335
+
336
+ expect(await fixture.keys()).toEqual([]);
337
+ });
338
+ });
339
+
340
+ test('two runs over the same store each verify on their own', async () => {
341
+ await use(async (fixture) => {
342
+ const first = fixture.open({ now: ticking(), run: 'r_first' });
343
+ await first.append(draft({ capability: 'one' }));
344
+ await first.close();
345
+
346
+ const second = fixture.open({ now: ticking(Date.UTC(2026, 7, 12, 11, 0, 0)) });
347
+ await second.append(draft({ capability: 'two' }));
348
+
349
+ const report = await second.verify();
350
+ expect(report.ok).toBe(true);
351
+ expect(report.runs).toBe(2);
352
+ expect(report.events).toBe(2);
353
+ });
354
+ });
355
+ });
356
+
357
+ test('the sink exposes no way to mutate or remove an event', async () => {
358
+ await use(async (fixture) => {
359
+ // The append-only guarantee is the absence of these methods, so assert
360
+ // the absence directly rather than trusting the interface to stay
361
+ // honest. `docs/detailed/security.md` lists `audit.append-only` as ENFORCED on
362
+ // the grounds that the store interface has no update or delete — this
363
+ // is the check behind that claim, and it holds for every adapter.
364
+ const sink = fixture.open();
365
+ const surface = sink as unknown as Record<string, unknown>;
366
+
367
+ expect(Object.keys(sink).sort()).toEqual(['append', 'close', 'tail', 'verify']);
368
+ expect(surface['update']).toBeUndefined();
369
+ expect(surface['delete']).toBeUndefined();
370
+ expect(surface['truncate']).toBeUndefined();
371
+ });
372
+ });
373
+ });
374
+ }
@@ -0,0 +1,97 @@
1
+ import type { AuditDraft, AuditEvent, AuditSink } from './index.ts';
2
+
3
+ /**
4
+ * One record of truth, and any number of copies.
5
+ *
6
+ * The guarantee is one awaited event per invocation, written in a `finally`
7
+ * (`#dispatch`). A network sink cannot carry that: it fails when a collector is
8
+ * down, it adds its latency to every capability call, and making the log a
9
+ * precondition for answering would mean an observability outage takes the
10
+ * gateway with it.
11
+ *
12
+ * So the first sink is awaited and everything after it is best-effort behind a
13
+ * bounded queue. That ordering is not configuration-shaped politeness — it is
14
+ * the difference between a sink and *the* log, and `openAudit` is the only
15
+ * place that decides which is which.
16
+ */
17
+
18
+ /**
19
+ * How many events may be waiting on a secondary sink before they are dropped.
20
+ *
21
+ * Bounded because the alternative is worse in exactly the case that matters: a
22
+ * collector that has stopped answering while a gateway keeps serving. An
23
+ * unbounded queue turns that into memory growth and then a crash, taking the
24
+ * durable log with it — a copy is not worth the thing it is a copy of.
25
+ */
26
+ const MAX_QUEUED = 1000;
27
+
28
+ export interface FanOutOptions {
29
+ /** Written and awaited. This one is the log. */
30
+ readonly primary: AuditSink;
31
+ /** Copies. Failures are reported once and never raised to the caller. */
32
+ readonly secondaries: readonly AuditSink[];
33
+ /** Where a dropped or failed copy is mentioned. Once per sink, not per event. */
34
+ readonly onError?: (message: string) => void;
35
+ }
36
+
37
+ export function fanOutAudit(options: FanOutOptions): AuditSink {
38
+ const { primary, secondaries } = options;
39
+ if (secondaries.length === 0) return primary;
40
+
41
+ const report = options.onError ?? ((): void => {});
42
+ let queued = 0;
43
+ let dropped = 0;
44
+ let complained = false;
45
+
46
+ const copy = (event: AuditEvent): void => {
47
+ if (queued >= MAX_QUEUED) {
48
+ dropped += 1;
49
+ if (!complained) {
50
+ complained = true;
51
+ report(`audit fan-out is behind; dropping copies (the primary log is unaffected)`);
52
+ }
53
+ return;
54
+ }
55
+
56
+ queued += 1;
57
+ void Promise.allSettled(
58
+ // `append` on a secondary re-stamps an id and a timestamp it will not
59
+ // use — the event is already written. Passing the draft keeps the sink
60
+ // interface single, and the cost is one uuid nobody reads.
61
+ secondaries.map((sink) => sink.append(event)),
62
+ )
63
+ .then((results) => {
64
+ for (const result of results) {
65
+ if (result.status === 'rejected' && !complained) {
66
+ complained = true;
67
+ report(`audit fan-out failed: ${String(result.reason)}`);
68
+ }
69
+ }
70
+ })
71
+ .finally(() => {
72
+ queued -= 1;
73
+ });
74
+ };
75
+
76
+ return {
77
+ async append(draft: AuditDraft): Promise<AuditEvent> {
78
+ const event = await primary.append(draft);
79
+ copy(event);
80
+ return event;
81
+ },
82
+
83
+ async close(): Promise<void> {
84
+ // Give the queue a moment to drain rather than waiting on it: a
85
+ // collector that is down must not hold a shutdown open, and Cloud Run
86
+ // gives a bounded grace period before it stops caring.
87
+ const deadline = Date.now() + 2000;
88
+ while (queued > 0 && Date.now() < deadline) {
89
+ await new Promise((resolve) => setTimeout(resolve, 25));
90
+ }
91
+
92
+ if (dropped > 0) report(`audit fan-out dropped ${dropped} copies this run`);
93
+ await Promise.allSettled(secondaries.map((sink) => sink.close()));
94
+ await primary.close();
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Audit — append-only, generic across providers.
3
+ *
4
+ * Every capability invocation produces exactly one event, whether it was
5
+ * allowed, denied, rate-limited, or failed. Denials are the interesting half:
6
+ * an audit log that only records successes cannot answer "what did this agent
7
+ * try to do".
8
+ *
9
+ * Audit records support investigation. They do not prevent an action, and this
10
+ * module makes no claim that they do.
11
+ */
12
+
13
+ import type { AuditVerification } from './chain.ts';
14
+
15
+ export type {
16
+ AuditVerification,
17
+ ChainBreak,
18
+ ChainedEvent,
19
+ ChainFields,
20
+ RunMarker,
21
+ StoredRecord,
22
+ } from './chain.ts';
23
+ export {
24
+ auditKey,
25
+ dayPrefix,
26
+ decodeEvent,
27
+ encodeEvent,
28
+ hashBytes,
29
+ newRunId,
30
+ runMarkerKey,
31
+ stampOf,
32
+ verifyChain,
33
+ } from './chain.ts';
34
+
35
+ /** Why a request ended the way it did. */
36
+ export type AuthorizationResult =
37
+ | 'allowed'
38
+ | 'denied_by_policy'
39
+ | 'denied_default' // nothing granted it — the default-deny path
40
+ | 'denied_unauthenticated'
41
+ | 'denied_rate_limited'
42
+ | 'denied_connection_unauthorized'; // credential missing or expired
43
+
44
+ export type InvocationStatus = 'ok' | 'error' | 'not_invoked';
45
+
46
+ export interface AuditEvent {
47
+ readonly id: string;
48
+ readonly timestamp: Date;
49
+
50
+ /** Which profile's endpoint served this. */
51
+ readonly profile: string;
52
+
53
+ /**
54
+ * The authenticated principal. In M1 there is exactly one per profile (the
55
+ * owner), but the field is recorded rather than assumed so that adding
56
+ * delegated access later does not change the log's shape.
57
+ */
58
+ readonly principal: string;
59
+
60
+ /**
61
+ * The MCP `clientInfo` name, when the caller supplied one.
62
+ *
63
+ * OBSERVABILITY ONLY. This is self-reported by the client and is never
64
+ * consulted for authorization — it exists so you can see which agent made a
65
+ * call, not to decide what that agent may do.
66
+ */
67
+ readonly clientLabel?: string;
68
+
69
+ readonly provider: string;
70
+ readonly connection?: string;
71
+ readonly capability: string;
72
+
73
+ /**
74
+ * Redacted argument metadata. Never raw arguments: a Gmail search query can
75
+ * contain the very content the caller was not allowed to read, and a vault
76
+ * lookup key can name a secret. Providers declare what survives redaction.
77
+ */
78
+ readonly arguments: Readonly<Record<string, unknown>>;
79
+
80
+ readonly authorization: AuthorizationResult;
81
+ readonly status: InvocationStatus;
82
+ readonly durationMs: number;
83
+ readonly error?: { readonly kind: string; readonly message: string };
84
+ }
85
+
86
+ /** What is known before dispatch; the rest is filled in by the writer. */
87
+ export type AuditDraft = Omit<AuditEvent, 'id' | 'timestamp'>;
88
+
89
+ /**
90
+ * Where events go.
91
+ *
92
+ * There is no `update` and no `delete`, and that absence IS the
93
+ * `audit.append-only` guarantee — it is enforced by the type system rather
94
+ * than by convention or by a database trigger. Do not add one. Retention, if
95
+ * it is ever needed, belongs in a separate operator-run command that is
96
+ * explicitly outside this interface.
97
+ *
98
+ * Writing is separate from reading because not every sink can be read back. A
99
+ * log shipped to stdout or to an OTLP collector is somebody else's to query,
100
+ * and giving those a `tail` that throws would be a worse interface than not
101
+ * offering one.
102
+ */
103
+ export interface AuditSink {
104
+ append(draft: AuditDraft): Promise<AuditEvent>;
105
+ /**
106
+ * Finish this writer's run.
107
+ *
108
+ * Not merely releasing a handle: it is what lets `verify` tell a run that
109
+ * ended from a run whose tail was cut off. See `./chain.ts`.
110
+ */
111
+ close(): Promise<void>;
112
+ }
113
+
114
+ /** A sink whose events can be read back — a local directory, or a bucket. */
115
+ export interface AuditReader {
116
+ tail(options?: AuditQuery): Promise<AuditEvent[]>;
117
+ /**
118
+ * Check every chain in the log.
119
+ *
120
+ * Deliberately takes no range. A chain is only checkable while it is
121
+ * contiguous, so verifying "since Tuesday" would drop the records each
122
+ * remaining link is defined against and report breaks that are artefacts of
123
+ * the filter. All of it, or none of it. If that ever becomes slow, retention
124
+ * is the answer, and it lives outside this interface.
125
+ */
126
+ verify(): Promise<AuditVerification>;
127
+ }
128
+
129
+ export interface AuditStore extends AuditSink, AuditReader {}
130
+
131
+ export interface AuditQuery {
132
+ readonly limit?: number;
133
+ readonly since?: Date;
134
+ readonly provider?: string;
135
+ readonly connection?: string;
136
+ readonly capability?: string;
137
+ /** Restrict to denials — the common investigative question. */
138
+ readonly deniedOnly?: boolean;
139
+ }
140
+
141
+ /**
142
+ * What a provider is handed. A provider can add detail to its own invocation's
143
+ * record; it cannot read the log, and it cannot write an event attributed to
144
+ * anything other than the call in progress.
145
+ */
146
+ export interface AuditLogger {
147
+ /** Attach provider-specific, already-redacted detail to this invocation. */
148
+ annotate(detail: Readonly<Record<string, unknown>>): void;
149
+ }
150
+
151
+ /**
152
+ * A provider's redaction rules: given raw capability arguments, return what is
153
+ * safe to persist.
154
+ *
155
+ * The default, applied when a provider declares nothing, is to record argument
156
+ * *names* and value types but no values. Providers opt into recording specific
157
+ * values (a message id is useful and harmless; a search query is neither).
158
+ */
159
+ export type RedactionRule = (args: Readonly<Record<string, unknown>>) => Record<string, unknown>;
160
+
161
+ export const redactAllValues: RedactionRule = (args) => {
162
+ const out: Record<string, unknown> = {};
163
+ for (const [key, value] of Object.entries(args)) {
164
+ out[key] = `<${describeType(value)}>`;
165
+ }
166
+ return out;
167
+ };
168
+
169
+ /** Keep the listed keys verbatim; reduce everything else to a type marker. */
170
+ export function keepKeys(...keys: readonly string[]): RedactionRule {
171
+ const keep = new Set(keys);
172
+ return (args) => {
173
+ const out: Record<string, unknown> = {};
174
+ for (const [key, value] of Object.entries(args)) {
175
+ out[key] = keep.has(key) ? value : `<${describeType(value)}>`;
176
+ }
177
+ return out;
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Keep some keys verbatim, withhold others entirely, type-mark the rest.
183
+ *
184
+ * `keepKeys` covers the common case and cannot express the vault's: reducing a
185
+ * secret to `<string:40>` still discloses its length, while the id beside it is
186
+ * a name worth recording verbatim — an audit log that cannot say *which* item
187
+ * was written answers very little. Withheld keys record `<withheld>` and
188
+ * nothing else.
189
+ *
190
+ * redaction({ keep: ['id'], withhold: ['value'] })
191
+ *
192
+ * A key in both is withheld: the stricter rule wins, so a mistake here fails
193
+ * closed.
194
+ */
195
+ export function redaction(options: {
196
+ readonly keep?: readonly string[];
197
+ readonly withhold?: readonly string[];
198
+ }): RedactionRule {
199
+ const keep = new Set(options.keep ?? []);
200
+ const withhold = new Set(options.withhold ?? []);
201
+
202
+ return (args) => {
203
+ const out: Record<string, unknown> = {};
204
+ for (const [key, value] of Object.entries(args)) {
205
+ if (withhold.has(key)) out[key] = '<withheld>';
206
+ else if (keep.has(key)) out[key] = value;
207
+ else out[key] = `<${describeType(value)}>`;
208
+ }
209
+ return out;
210
+ };
211
+ }
212
+
213
+ function describeType(value: unknown): string {
214
+ if (value === null) return 'null';
215
+ if (Array.isArray(value)) return `array:${value.length}`;
216
+ if (typeof value === 'string') return `string:${value.length}`;
217
+ return typeof value;
218
+ }