@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,194 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
defineLocalProvider,
|
|
4
|
+
keepKeys,
|
|
5
|
+
redaction,
|
|
6
|
+
type Capability,
|
|
7
|
+
type ProviderDefinition,
|
|
8
|
+
} from '#connectivity';
|
|
9
|
+
import { assertItemId, type VaultStore } from '#secrets';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `vault` — the owner's own secret material.
|
|
13
|
+
*
|
|
14
|
+
* Three properties, each of which is a decision recorded in ADR-012:
|
|
15
|
+
*
|
|
16
|
+
* **Tools only, never resources.** Resources are listable and cacheable, and
|
|
17
|
+
* both are wrong for a secret (`docs/detailed/providers.md`).
|
|
18
|
+
*
|
|
19
|
+
* **Per-item policy through the capability name.** Each stored item gets its own
|
|
20
|
+
* read capability — `vault.get.github_token` — so `deny: [vault.get.bank]` and
|
|
21
|
+
* `allow: [vault.get.*]` are both already expressible against the policy engine
|
|
22
|
+
* exactly as it stands. The alternative was teaching policy about arguments,
|
|
23
|
+
* which `packages/core/src/config/schema.ts` warns against by name: a policy
|
|
24
|
+
* expression language is a language with its own bugs, and a bug here is the one
|
|
25
|
+
* this project cannot afford.
|
|
26
|
+
*
|
|
27
|
+
* Because capabilities are fixed for the life of a process, the item list is
|
|
28
|
+
* read from the store when the runtime is built. **An item written by
|
|
29
|
+
* `vault.put` is not readable until the endpoint restarts.** That is a feature:
|
|
30
|
+
* a write cannot hand itself a read, so granting access to a new secret is a
|
|
31
|
+
* deliberate act by the operator between two runs.
|
|
32
|
+
*
|
|
33
|
+
* There is deliberately no `vault.list`. The policy-filtered tool list *is* the
|
|
34
|
+
* listing, and it is the only one that cannot over-report — an agent granted one
|
|
35
|
+
* item cannot discover that the others exist.
|
|
36
|
+
*
|
|
37
|
+
* **A separate store and a separate encryption key.** Never `SecretStore`.
|
|
38
|
+
* See `vault-store.ts`; the store arrives as a constructor argument rather than
|
|
39
|
+
* through `ProviderContext`, because `ProviderContext` deliberately carries no
|
|
40
|
+
* path to any raw backend.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export interface VaultProviderOptions {
|
|
44
|
+
readonly store: VaultStore;
|
|
45
|
+
/**
|
|
46
|
+
* Which items exist, read from the store at startup. Each becomes a
|
|
47
|
+
* `vault.get.<id>` capability.
|
|
48
|
+
*
|
|
49
|
+
* Passed in rather than read here because building a provider is synchronous
|
|
50
|
+
* and reading an encrypted file is not — and because the runtime is the right
|
|
51
|
+
* place to decide when the store is opened.
|
|
52
|
+
*/
|
|
53
|
+
readonly items?: ReadonlyArray<{ id: string; description?: string }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createVaultProvider(options: VaultProviderOptions): ProviderDefinition {
|
|
57
|
+
const { store } = options;
|
|
58
|
+
|
|
59
|
+
// Deduplicated across connections: a capability id is provider-wide, so two
|
|
60
|
+
// connections holding an item of the same name contribute one capability. The
|
|
61
|
+
// handler still reads from the invoked connection's own namespace, so the two
|
|
62
|
+
// values never mix.
|
|
63
|
+
const items = new Map<string, string | undefined>();
|
|
64
|
+
for (const item of options.items ?? []) {
|
|
65
|
+
if (!items.has(item.id)) items.set(item.id, item.description);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const reads: Capability[] = [...items].map(([id, description]) => ({
|
|
69
|
+
kind: 'tool' as const,
|
|
70
|
+
name: `get.${id}`,
|
|
71
|
+
title: `Read vault item "${id}"`,
|
|
72
|
+
description:
|
|
73
|
+
description ??
|
|
74
|
+
`Return the stored value of vault item "${id}". Handle it as a secret: it is the owner's credential, not context to quote back.`,
|
|
75
|
+
inputSchema: z.object({}),
|
|
76
|
+
// Nothing to redact — the id is in the capability name, which the audit
|
|
77
|
+
// event records as `capability`, and there are no arguments at all.
|
|
78
|
+
async handler(_input, context) {
|
|
79
|
+
const item = await store.get(context.connection.id, id);
|
|
80
|
+
if (!item) {
|
|
81
|
+
return {
|
|
82
|
+
content: [{ type: 'text', text: `No vault item "${id}" on ${context.connection.key}.` }],
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Length rather than value: enough to tell a truncated paste from a
|
|
88
|
+
// correct one, and it never reaches the log as the value itself.
|
|
89
|
+
context.audit.annotate({ item: id, bytes: item.value.length });
|
|
90
|
+
return { content: [{ type: 'text', text: item.value }] };
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
return defineLocalProvider({
|
|
95
|
+
id: 'vault',
|
|
96
|
+
name: 'Vault',
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
description:
|
|
99
|
+
"The owner's own passwords and API keys, in a store separate from the system's credentials and under a separate key. Reads are granted one item at a time.",
|
|
100
|
+
|
|
101
|
+
configSchema: z.object({}),
|
|
102
|
+
connectionSchema: z.object({}),
|
|
103
|
+
|
|
104
|
+
bundles: [
|
|
105
|
+
{
|
|
106
|
+
name: 'read',
|
|
107
|
+
description: 'Read stored items. Grant one at a time, not as a bundle.',
|
|
108
|
+
oauth_scopes: [],
|
|
109
|
+
capabilities: [...items.keys()].map((id) => `get.${id}`),
|
|
110
|
+
default: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'write',
|
|
114
|
+
description: 'Store and remove items.',
|
|
115
|
+
oauth_scopes: [],
|
|
116
|
+
capabilities: ['put', 'remove'],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
|
|
120
|
+
capabilities: [
|
|
121
|
+
...reads,
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
kind: 'tool',
|
|
125
|
+
name: 'put',
|
|
126
|
+
title: 'Store a vault item',
|
|
127
|
+
description:
|
|
128
|
+
'Store or replace a secret under an id. The item becomes readable only after the endpoint restarts, and only if policy grants "vault.get.<id>" — a write cannot grant itself a read.',
|
|
129
|
+
inputSchema: z.object({
|
|
130
|
+
id: z
|
|
131
|
+
.string()
|
|
132
|
+
.min(1)
|
|
133
|
+
.describe('Item id: lowercase letters, digits and "_". Becomes part of a capability name.'),
|
|
134
|
+
value: z.string().min(1).describe('The secret to store'),
|
|
135
|
+
description: z.string().optional().describe('What this is, for the operator'),
|
|
136
|
+
}),
|
|
137
|
+
// The id and its description are names worth recording — an audit log
|
|
138
|
+
// that cannot say *which* item was written answers very little. The
|
|
139
|
+
// value is withheld outright rather than type-marked, because
|
|
140
|
+
// `<string:40>` still discloses a secret's length. Redaction resolves
|
|
141
|
+
// before the policy decision in `dispatch.ts`, so a denied call is
|
|
142
|
+
// redacted identically to an allowed one.
|
|
143
|
+
redact: redaction({ keep: ['id', 'description'], withhold: ['value'] }),
|
|
144
|
+
async handler({ id, value, description }, context) {
|
|
145
|
+
assertItemId(id);
|
|
146
|
+
await store.put(context.connection.id, {
|
|
147
|
+
id,
|
|
148
|
+
value,
|
|
149
|
+
...(description ? { description } : {}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
context.audit.annotate({ item: id, bytes: value.length });
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text:
|
|
159
|
+
`Stored vault item "${id}" on ${context.connection.key}. ` +
|
|
160
|
+
`It is not readable yet: restart the endpoint, and allow "vault.get.${id}" in policy.`,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
kind: 'tool',
|
|
169
|
+
name: 'remove',
|
|
170
|
+
title: 'Remove a vault item',
|
|
171
|
+
description: 'Delete a stored item. Its read capability disappears when the endpoint restarts.',
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
id: z.string().min(1).describe('Item id'),
|
|
174
|
+
}),
|
|
175
|
+
redact: keepKeys('id'),
|
|
176
|
+
async handler({ id }, context) {
|
|
177
|
+
const removed = await store.delete(context.connection.id, id);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: 'text',
|
|
183
|
+
text: removed
|
|
184
|
+
? `Removed vault item "${id}" from ${context.connection.key}.`
|
|
185
|
+
: `No vault item "${id}" on ${context.connection.key}.`,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
...(removed ? {} : { isError: true }),
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What exists, and what it is called.
|
|
3
|
+
*
|
|
4
|
+
* The registry holds every provider available to a profile — built in, or
|
|
5
|
+
* declared as workspace YAML — and the capabilities each one contributes,
|
|
6
|
+
* addressed `provider.capability`. `reconcile` is the other half of the same
|
|
7
|
+
* question: the config file says what should exist, the database records what
|
|
8
|
+
* did, and reconcile makes the second match the first without ever deleting
|
|
9
|
+
* history.
|
|
10
|
+
*
|
|
11
|
+
* `policy-bridge` turns a profile's declared rules into the document
|
|
12
|
+
* `#policy` evaluates, and lives here because it is a translation of config,
|
|
13
|
+
* not a policy decision.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
ProviderRegistry,
|
|
18
|
+
matchesGlob,
|
|
19
|
+
type RegisteredCapability,
|
|
20
|
+
type RegisteredProvider,
|
|
21
|
+
} from './registry.ts';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
applyReconcile,
|
|
25
|
+
credentialRefFor,
|
|
26
|
+
formatPlan,
|
|
27
|
+
planIsNoop,
|
|
28
|
+
planReconcile,
|
|
29
|
+
ownClientRefsFor,
|
|
30
|
+
rotatableCredentialRefsFor,
|
|
31
|
+
type AuthenticatingProviders,
|
|
32
|
+
type ReconcileAction,
|
|
33
|
+
type ReconcilePlan,
|
|
34
|
+
} from './reconcile.ts';
|
|
35
|
+
|
|
36
|
+
export { toPolicyDocument } from './policy-bridge.ts';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PolicyDocument, PolicyRule } from '#policy';
|
|
2
|
+
import type { Config } from '#profile';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turn the config's `policy` block into the document the policy engine
|
|
6
|
+
* evaluates.
|
|
7
|
+
*
|
|
8
|
+
* The config format keeps `allow` and `deny` as separate lists because that is
|
|
9
|
+
* what reads clearly in YAML; the engine wants one list carrying an effect,
|
|
10
|
+
* because evaluation must consider both together to make deny win regardless of
|
|
11
|
+
* ordering. Converting here keeps that difference from leaking either way.
|
|
12
|
+
*/
|
|
13
|
+
export function toPolicyDocument(config: Config): PolicyDocument {
|
|
14
|
+
const rules: PolicyRule[] = [];
|
|
15
|
+
|
|
16
|
+
for (const [effect, entries] of [
|
|
17
|
+
['allow', config.policy.allow],
|
|
18
|
+
['deny', config.policy.deny],
|
|
19
|
+
] as const) {
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
rules.push({
|
|
22
|
+
capability: entry.capability,
|
|
23
|
+
effect,
|
|
24
|
+
...('expires_at' in entry && entry.expires_at
|
|
25
|
+
? { expiresAt: new Date(entry.expires_at) }
|
|
26
|
+
: {}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { rules };
|
|
32
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { SecretStore } from '#secrets';
|
|
2
|
+
import type { ConnectionRecord, ConnectionStatus, RuntimeState } from '#stores/state';
|
|
3
|
+
import {
|
|
4
|
+
credentialRefForConnection,
|
|
5
|
+
rotatableCredentialRefs,
|
|
6
|
+
type ProviderManifest,
|
|
7
|
+
} from '#connectivity';
|
|
8
|
+
import type { ConnectionConfig, Config } from '#profile';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a connection's credential lives.
|
|
12
|
+
*
|
|
13
|
+
* The single authority, because there used to be two that disagreed: reconcile
|
|
14
|
+
* derived `<provider>/<id>` and never read the manifest, while the request
|
|
15
|
+
* authorizer read the manifest and never derived. A provider declaring
|
|
16
|
+
* `credential_ref: mything/api_key` was therefore reported unauthorized forever
|
|
17
|
+
* — `doctor` told you to run `connect`, and `connect` did not help.
|
|
18
|
+
*
|
|
19
|
+
* The manifest answers it, and the connection's own `credential_ref` does not
|
|
20
|
+
* overrule that. It used to, and the override was reported rather than obeyed:
|
|
21
|
+
* `resolveSecretRefs` treats the field as an *addition* to what a connection may
|
|
22
|
+
* reach, and every path that actually reads a credential — the OAuth refresh,
|
|
23
|
+
* `connect`, `requirements`, `setup` — derives from the manifest and has never
|
|
24
|
+
* read it. Substituting here made the status disagree with the request in both
|
|
25
|
+
* directions: `active` on a connection whose every call 401s, and unauthorized
|
|
26
|
+
* on one that works, which is the same bug this function was written to end
|
|
27
|
+
* arriving from the other side.
|
|
28
|
+
*
|
|
29
|
+
* It is still the answer when the manifest gives none. A `local` provider
|
|
30
|
+
* authenticates with nothing, so there is no derived ref to contradict, and
|
|
31
|
+
* `resolveSecretRefs` makes this field that connection's whole allowlist —
|
|
32
|
+
* which leaves reporting whether it is present the only signal there is.
|
|
33
|
+
*/
|
|
34
|
+
export function credentialRefFor(
|
|
35
|
+
connection: ConnectionConfig,
|
|
36
|
+
manifest: ProviderManifest | undefined,
|
|
37
|
+
): string | undefined {
|
|
38
|
+
const derived = manifest ? credentialRefForConnection(manifest, connection.id) : undefined;
|
|
39
|
+
return derived ?? connection.credential_ref;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The same question for a deploy, which needs the refs a *running* instance
|
|
44
|
+
* rewrites so it can grant them and nothing else.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not routed through `credentialRefFor`, and so deliberately blind
|
|
47
|
+
* to a hand-placed `credential_ref`: the refresh path derives from the manifest
|
|
48
|
+
* and has never read the config field, so the ref written is the derived one
|
|
49
|
+
* whatever the connection says. Granting what the config names instead would
|
|
50
|
+
* bind a secret nobody writes and leave the one that is written unbound.
|
|
51
|
+
*
|
|
52
|
+
* `#deployments` cannot import `#connectivity` — see `architecture.test.ts` —
|
|
53
|
+
* which is why the crossing happens here, next to the sibling that already
|
|
54
|
+
* makes it.
|
|
55
|
+
*/
|
|
56
|
+
export function rotatableCredentialRefsFor(
|
|
57
|
+
connection: ConnectionConfig,
|
|
58
|
+
manifest: ProviderManifest | undefined,
|
|
59
|
+
): readonly string[] {
|
|
60
|
+
return manifest ? rotatableCredentialRefs(manifest, connection.id) : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The OAuth client a revision signs its refreshes with, where the profile holds one.
|
|
65
|
+
*
|
|
66
|
+
* Read-only, and only for a profile that declares the entry: the client is the
|
|
67
|
+
* operator's, shared by every connection of that vendor, and ADR-026's line is
|
|
68
|
+
* that a revision rotates what is its own and never rewrites somebody else's.
|
|
69
|
+
* So this is deliberately *not* part of `rotatableCredentialRefsFor`.
|
|
70
|
+
*
|
|
71
|
+
* These were missing, and the failure was silent in the worst way. The
|
|
72
|
+
* gcp-secret-manager adapter answers `null` only on a 404 — an *unbound* secret
|
|
73
|
+
* is a 403, which throws — so a bring-your-own Google connection on Cloud Run
|
|
74
|
+
* would serve until its access token expired and then fail every call, an hour
|
|
75
|
+
* after the revision reported healthy. A profile that authorises against a
|
|
76
|
+
* broker declares no entry and so adds nothing here, which is the arrangement
|
|
77
|
+
* this is meant to leave alone.
|
|
78
|
+
*
|
|
79
|
+
* Here rather than in `#deployments` for the reason its sibling gives: that
|
|
80
|
+
* component cannot import `#connectivity`, and this needs the manifest.
|
|
81
|
+
*/
|
|
82
|
+
export function ownClientRefsFor(
|
|
83
|
+
manifest: ProviderManifest | undefined,
|
|
84
|
+
oauthApps: Readonly<Record<string, { client_id_ref: string; client_secret_ref: string }>>,
|
|
85
|
+
): readonly string[] {
|
|
86
|
+
if (manifest?.auth.kind !== 'oauth' || manifest.auth.registration !== 'manual') return [];
|
|
87
|
+
const app = manifest.auth.app;
|
|
88
|
+
if (!app) return [];
|
|
89
|
+
|
|
90
|
+
const declared = oauthApps[app];
|
|
91
|
+
return declared ? [declared.client_id_ref, declared.client_secret_ref] : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How to find a provider's manifest, so a missing `credential_ref` can derive.
|
|
96
|
+
*
|
|
97
|
+
* A lookup rather than a boolean: "does this authenticate" was never enough to
|
|
98
|
+
* answer "where does the credential live", and pretending otherwise is what let
|
|
99
|
+
* the two answers drift apart.
|
|
100
|
+
*/
|
|
101
|
+
export type AuthenticatingProviders = (providerId: string) => ProviderManifest | undefined;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reconcile declared config against runtime state.
|
|
105
|
+
*
|
|
106
|
+
* Planning and applying are separate functions over the same plan, so
|
|
107
|
+
* `lanes link plan` and `lanes link start` cannot disagree about what is about to happen. If
|
|
108
|
+
* the preview were computed differently from the mutation, the preview would
|
|
109
|
+
* eventually become a lie.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
export type ReconcileAction =
|
|
113
|
+
| { kind: 'create'; connection: string; displayName: string; status: ConnectionStatus }
|
|
114
|
+
| { kind: 'update'; connection: string; changes: readonly string[] }
|
|
115
|
+
| { kind: 'status'; connection: string; from: ConnectionStatus; to: ConnectionStatus; reason: string }
|
|
116
|
+
/**
|
|
117
|
+
* An undeclared connection is disabled, never deleted. Deleting it would
|
|
118
|
+
* orphan its audit history, and the log's value is precisely that it outlives
|
|
119
|
+
* the configuration that produced it.
|
|
120
|
+
*/
|
|
121
|
+
| { kind: 'disable'; connection: string; reason: string }
|
|
122
|
+
| { kind: 'unchanged'; connection: string };
|
|
123
|
+
|
|
124
|
+
export interface ReconcilePlan {
|
|
125
|
+
readonly actions: readonly ReconcileAction[];
|
|
126
|
+
/** Declared in config but absent from the state. */
|
|
127
|
+
readonly missingInDatabase: readonly string[];
|
|
128
|
+
/** Present in the state but no longer declared. */
|
|
129
|
+
readonly undeclared: readonly string[];
|
|
130
|
+
/** Declared connections whose credential could not be resolved. */
|
|
131
|
+
readonly unauthorized: readonly string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function planIsNoop(plan: ReconcilePlan): boolean {
|
|
135
|
+
return plan.actions.every((action) => action.kind === 'unchanged');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Work out what reconcile would do, without touching anything.
|
|
140
|
+
*
|
|
141
|
+
* A missing credential does NOT fail: the connection is marked `unauthorized`,
|
|
142
|
+
* startup continues, and the problem surfaces in `lanes link doctor` and as an
|
|
143
|
+
* actionable error if that connection's capability is actually called. A
|
|
144
|
+
* half-configured Gmail account must not stop the other providers from serving.
|
|
145
|
+
*/
|
|
146
|
+
export async function planReconcile(
|
|
147
|
+
config: Config,
|
|
148
|
+
state: RuntimeState,
|
|
149
|
+
credentials: SecretStore,
|
|
150
|
+
/**
|
|
151
|
+
* Optional so tests and callers without a registry keep working: without it,
|
|
152
|
+
* only an explicit `credential_ref` is checked, which is the pre-derivation
|
|
153
|
+
* behaviour.
|
|
154
|
+
*/
|
|
155
|
+
manifestFor: AuthenticatingProviders = () => undefined,
|
|
156
|
+
): Promise<ReconcilePlan> {
|
|
157
|
+
const existing = new Map<string, ConnectionRecord>();
|
|
158
|
+
for (const record of await state.connections.list()) {
|
|
159
|
+
existing.set(`${record.provider}.${record.id}`, record);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const actions: ReconcileAction[] = [];
|
|
163
|
+
const missingInDatabase: string[] = [];
|
|
164
|
+
const unauthorized: string[] = [];
|
|
165
|
+
const declared = new Set<string>();
|
|
166
|
+
|
|
167
|
+
for (const connection of config.connections) {
|
|
168
|
+
const key = `${connection.provider}.${connection.id}`;
|
|
169
|
+
declared.add(key);
|
|
170
|
+
|
|
171
|
+
const ref = credentialRefFor(connection, manifestFor(connection.provider));
|
|
172
|
+
const authorized = ref
|
|
173
|
+
? await credentials.has(ref)
|
|
174
|
+
: true; // A provider needing no credential is authorized by construction.
|
|
175
|
+
|
|
176
|
+
if (!authorized) unauthorized.push(key);
|
|
177
|
+
|
|
178
|
+
// Declaring the connection is what enables it. There is no separate
|
|
179
|
+
// `enabled` flag to disagree with the connection's own existence; to turn
|
|
180
|
+
// one off, delete it or deny its capabilities.
|
|
181
|
+
const desired: ConnectionStatus = authorized ? 'active' : 'unauthorized';
|
|
182
|
+
|
|
183
|
+
const record = existing.get(key);
|
|
184
|
+
if (!record) {
|
|
185
|
+
missingInDatabase.push(key);
|
|
186
|
+
actions.push({
|
|
187
|
+
kind: 'create',
|
|
188
|
+
connection: key,
|
|
189
|
+
displayName: connection.account,
|
|
190
|
+
status: desired,
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const changes: string[] = [];
|
|
196
|
+
if (record.displayName !== connection.account) changes.push('account');
|
|
197
|
+
|
|
198
|
+
if (changes.length > 0) actions.push({ kind: 'update', connection: key, changes });
|
|
199
|
+
|
|
200
|
+
if (record.status !== desired) {
|
|
201
|
+
actions.push({
|
|
202
|
+
kind: 'status',
|
|
203
|
+
connection: key,
|
|
204
|
+
from: record.status,
|
|
205
|
+
to: desired,
|
|
206
|
+
reason: authorized
|
|
207
|
+
? 'credential resolves'
|
|
208
|
+
: `credential ${ref} is missing from the credential store`,
|
|
209
|
+
});
|
|
210
|
+
} else if (changes.length === 0) {
|
|
211
|
+
actions.push({ kind: 'unchanged', connection: key });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const undeclared: string[] = [];
|
|
216
|
+
for (const [key, record] of existing) {
|
|
217
|
+
if (declared.has(key)) continue;
|
|
218
|
+
undeclared.push(key);
|
|
219
|
+
if (record.status !== 'disabled') {
|
|
220
|
+
actions.push({
|
|
221
|
+
kind: 'disable',
|
|
222
|
+
connection: key,
|
|
223
|
+
reason: 'no longer declared in config; disabled rather than deleted to preserve audit history',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { actions, missingInDatabase, undeclared, unauthorized };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function applyReconcile(
|
|
232
|
+
config: Config,
|
|
233
|
+
state: RuntimeState,
|
|
234
|
+
plan: ReconcilePlan,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const byKey = new Map(config.connections.map((c) => [`${c.provider}.${c.id}`, c]));
|
|
237
|
+
|
|
238
|
+
for (const action of plan.actions) {
|
|
239
|
+
if (action.kind === 'unchanged') continue;
|
|
240
|
+
|
|
241
|
+
const [provider = '', id = ''] = splitConnectionKey(action.connection);
|
|
242
|
+
|
|
243
|
+
if (action.kind === 'disable') {
|
|
244
|
+
await state.connections.setStatus(provider, id, 'disabled');
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (action.kind === 'status') {
|
|
249
|
+
await state.connections.setStatus(provider, id, action.to);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const declared = byKey.get(action.connection);
|
|
254
|
+
if (!declared) continue;
|
|
255
|
+
|
|
256
|
+
await state.connections.upsert({
|
|
257
|
+
provider,
|
|
258
|
+
id,
|
|
259
|
+
displayName: declared.account,
|
|
260
|
+
status: action.kind === 'create' ? action.status : await currentStatus(state, provider, id),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function currentStatus(
|
|
266
|
+
state: RuntimeState,
|
|
267
|
+
provider: string,
|
|
268
|
+
id: string,
|
|
269
|
+
): Promise<ConnectionStatus> {
|
|
270
|
+
return (await state.connections.get(provider, id))?.status ?? 'active';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function splitConnectionKey(key: string): [string, string] {
|
|
274
|
+
const index = key.indexOf('.');
|
|
275
|
+
return index === -1 ? [key, ''] : [key.slice(0, index), key.slice(index + 1)];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Render a plan for `lanes link plan`.
|
|
280
|
+
*
|
|
281
|
+
* Reconcile disables undeclared connections, so this exists specifically so
|
|
282
|
+
* that outcome is never a surprise.
|
|
283
|
+
*/
|
|
284
|
+
export function formatPlan(plan: ReconcilePlan): string {
|
|
285
|
+
if (planIsNoop(plan)) return 'No changes. Runtime state matches the declared config.';
|
|
286
|
+
|
|
287
|
+
const lines: string[] = [];
|
|
288
|
+
for (const action of plan.actions) {
|
|
289
|
+
switch (action.kind) {
|
|
290
|
+
case 'create':
|
|
291
|
+
lines.push(` + ${action.connection} create (${action.status})`);
|
|
292
|
+
break;
|
|
293
|
+
case 'update':
|
|
294
|
+
lines.push(` ~ ${action.connection} update ${action.changes.join(', ')}`);
|
|
295
|
+
break;
|
|
296
|
+
case 'status':
|
|
297
|
+
lines.push(` ~ ${action.connection} ${action.from} -> ${action.to} (${action.reason})`);
|
|
298
|
+
break;
|
|
299
|
+
case 'disable':
|
|
300
|
+
lines.push(` - ${action.connection} disable (${action.reason})`);
|
|
301
|
+
break;
|
|
302
|
+
case 'unchanged':
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (plan.unauthorized.length > 0) {
|
|
308
|
+
lines.push('', 'Unauthorized connections (startup continues; these will error if called):');
|
|
309
|
+
for (const key of plan.unauthorized) lines.push(` ${key} — run: lanes link connect ${key}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
}
|