@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.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- 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
|
+
}
|