@lanes-sh/link 0.7.2 → 0.9.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/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -61,22 +61,35 @@ export interface TargetInput {
|
|
|
61
61
|
*/
|
|
62
62
|
export type StorageFactory = (area?: string) => BlobStore;
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The workspace's credential store.
|
|
66
|
+
*
|
|
67
|
+
* Takes the adapters and the root, not a `TargetInput` — it never read the
|
|
68
|
+
* profile, and since contract 3 there is nothing in one for it to read: the
|
|
69
|
+
* store is the workspace's. Narrowing the parameter is what lets a *migration*
|
|
70
|
+
* open it, which it must be able to do while the profiles on disk still
|
|
71
|
+
* declare the contract being migrated away from and therefore will not parse.
|
|
72
|
+
*/
|
|
73
|
+
export async function openSecrets(input: {
|
|
74
|
+
readonly declared: TargetConfig;
|
|
75
|
+
readonly root: string;
|
|
76
|
+
readonly target: string;
|
|
77
|
+
}): Promise<SecretStore> {
|
|
78
|
+
const { declared, root, target } = input;
|
|
66
79
|
|
|
67
80
|
switch (declared.credentials.adapter) {
|
|
68
81
|
case 'file':
|
|
69
82
|
return createFileSecretStore({
|
|
70
83
|
path: workspacePath(
|
|
71
84
|
root,
|
|
72
|
-
declared.credentials.path ?? layout.credentials(
|
|
85
|
+
declared.credentials.path ?? layout.credentials(),
|
|
73
86
|
),
|
|
74
87
|
});
|
|
75
88
|
|
|
76
89
|
case 'gcp-secret-manager': {
|
|
77
90
|
if (!declared.credentials.project) {
|
|
78
91
|
throw new ConfigError(
|
|
79
|
-
`
|
|
92
|
+
`workspaces.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
|
|
80
93
|
);
|
|
81
94
|
}
|
|
82
95
|
const { GcpSecretManagerStore } = await import('./adapters/gcp-secret-manager.ts');
|
|
@@ -96,9 +109,17 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
|
|
|
96
109
|
*
|
|
97
110
|
* Its own root, `layout.state`, so it is not addressable from a provider's
|
|
98
111
|
* blob namespace — the same containment `openAudit` relies on.
|
|
112
|
+
*
|
|
113
|
+
* Two roots, because state divides by what a key is *about*. Connection
|
|
114
|
+
* records, the discovery cache and the endpoint's own OAuth server belong to
|
|
115
|
+
* the workspace: a `connect` run once must read as connected from every
|
|
116
|
+
* profile. Cursors and each provider's own keys belong to the profile, because
|
|
117
|
+
* two agents reading one mailbox at different rates must not consume each
|
|
118
|
+
* other's cursor. `isWorkspaceNamespace` in `#stores/state` is the whole rule,
|
|
119
|
+
* and it is closed — a namespace it does not name is the profile's.
|
|
99
120
|
*/
|
|
100
121
|
export function openState(storage: StorageFactory, profile: string): RuntimeState {
|
|
101
|
-
return createRuntimeState(storage(layout.state(profile)));
|
|
122
|
+
return createRuntimeState(storage(layout.state()), storage(layout.profileState(profile)));
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
/**
|
|
@@ -115,8 +136,8 @@ export function openState(storage: StorageFactory, profile: string): RuntimeStat
|
|
|
115
136
|
* into (`layout.audit`), and that separation is what keeps ADR-007's wall
|
|
116
137
|
* intact.
|
|
117
138
|
*/
|
|
118
|
-
export function openAudit(storage: StorageFactory
|
|
119
|
-
return createBlobAuditStore({ storage: storage(layout.audit(
|
|
139
|
+
export function openAudit(storage: StorageFactory): AuditStore {
|
|
140
|
+
return createBlobAuditStore({ storage: storage(layout.audit()) });
|
|
120
141
|
}
|
|
121
142
|
|
|
122
143
|
/**
|
|
@@ -137,7 +158,7 @@ export async function openAuditSinks(
|
|
|
137
158
|
secrets: SecretStore,
|
|
138
159
|
log?: (message: string) => void,
|
|
139
160
|
): Promise<{ sink: AuditSink; reader: AuditReader }> {
|
|
140
|
-
const durable = openAudit(storage
|
|
161
|
+
const durable = openAudit(storage);
|
|
141
162
|
const declared = input.declared.audit?.sinks ?? [];
|
|
142
163
|
if (declared.length === 0) return { sink: durable, reader: durable };
|
|
143
164
|
|
|
@@ -214,7 +235,7 @@ export async function openStorage(
|
|
|
214
235
|
// by hand in a console.
|
|
215
236
|
const { bucket, prefix } = declared.storage;
|
|
216
237
|
if (!bucket) {
|
|
217
|
-
throw new ConfigError(`
|
|
238
|
+
throw new ConfigError(`workspaces.${target}.storage.bucket is required for the gcs adapter.`);
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
const { createGcsBlobStore } = await import('./adapters/gcs.ts');
|
|
@@ -231,19 +252,19 @@ export async function openStorage(
|
|
|
231
252
|
case 's3': {
|
|
232
253
|
const { bucket, endpoint, region, prefix } = declared.storage;
|
|
233
254
|
if (!bucket) {
|
|
234
|
-
throw new ConfigError(`
|
|
255
|
+
throw new ConfigError(`workspaces.${target}.storage.bucket is required for the s3 adapter.`);
|
|
235
256
|
}
|
|
236
257
|
|
|
237
258
|
const accessKeyId = await requireSecret(
|
|
238
259
|
secrets,
|
|
239
260
|
declared.storage.access_key_id_ref,
|
|
240
|
-
`
|
|
261
|
+
`workspaces.${target}.storage.access_key_id_ref`,
|
|
241
262
|
target,
|
|
242
263
|
);
|
|
243
264
|
const secretAccessKey = await requireSecret(
|
|
244
265
|
secrets,
|
|
245
266
|
declared.storage.secret_access_key_ref,
|
|
246
|
-
`
|
|
267
|
+
`workspaces.${target}.storage.secret_access_key_ref`,
|
|
247
268
|
target,
|
|
248
269
|
);
|
|
249
270
|
|
|
@@ -289,7 +310,7 @@ export async function requireSecret(
|
|
|
289
310
|
if (!value) {
|
|
290
311
|
throw new ConfigError(
|
|
291
312
|
`${field} names "${ref}", which is not in this target's secret store. ` +
|
|
292
|
-
`Store it with: lanes link secrets set ${ref} --
|
|
313
|
+
`Store it with: lanes link secrets set ${ref} --workspace ${target}`,
|
|
293
314
|
);
|
|
294
315
|
}
|
|
295
316
|
return value;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
openTarget,
|
|
3
|
-
|
|
3
|
+
CONNECTIONS_FILE,
|
|
4
4
|
WORKSPACE_FILE,
|
|
5
5
|
layout,
|
|
6
6
|
listProfiles,
|
|
@@ -55,13 +55,19 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
|
55
55
|
* forgets means an endpoint that will not boot, and a credential this includes
|
|
56
56
|
* by accident means a credential in a bucket. Forgetting is loud.
|
|
57
57
|
*
|
|
58
|
-
* **Two areas inside `data/` are authored rather than accumulated
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
58
|
+
* **Two areas inside `data/` are authored rather than accumulated.** Skills and
|
|
59
|
+
* provider manifests have to go up or a deployed instance loses both — the
|
|
60
|
+
* regression ADR-014 §2 fixed for skills. So the allowlist names those two
|
|
61
|
+
* areas, by whole path segment and never by prefix:
|
|
62
|
+
* `profiles/work/skills.detour/` is not `skills.d`, and the difference between
|
|
63
|
+
* matching it and not is a credential in a bucket.
|
|
64
|
+
*
|
|
65
|
+
* The two are filtered differently, and that follows from where they sit.
|
|
66
|
+
* A manifest is the workspace's (ADR-057) and defines a connection any profile
|
|
67
|
+
* may grant, so it goes up whole. Skills are back inside a profile (ADR-066),
|
|
68
|
+
* so they are filtered by the profile set exactly as the declarations are —
|
|
69
|
+
* sending a profile's procedures on a deploy that does not carry that profile
|
|
70
|
+
* would put one profile's material in front of another's endpoint.
|
|
65
71
|
*/
|
|
66
72
|
export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
|
|
67
73
|
// **Never the workspace file.** It was sent, and it is the one file that must
|
|
@@ -74,37 +80,54 @@ export function isWorkspaceConfig(key: string, profiles?: readonly string[]): bo
|
|
|
74
80
|
// the upload is done.
|
|
75
81
|
if (key === WORKSPACE_FILE) return false;
|
|
76
82
|
|
|
83
|
+
// **The connections file always goes up.** It is configuration, the endpoint
|
|
84
|
+
// cannot resolve a single grant without it, and unlike the registry above
|
|
85
|
+
// there is nothing machine-specific in it — a connection is a connection
|
|
86
|
+
// wherever the workspace is read from (ADR-057).
|
|
87
|
+
if (key === CONNECTIONS_FILE) return true;
|
|
88
|
+
|
|
77
89
|
// A set rather than one name, because a deploy now sends every profile that
|
|
78
90
|
// declares the target rather than the single one it was told. `undefined`
|
|
79
91
|
// still means the whole workspace, and an *empty* set means nothing — which
|
|
80
92
|
// is a distinction a bare string could not make.
|
|
81
93
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
94
|
+
const carried = (profile: string): boolean => wanted === undefined || wanted.has(profile);
|
|
95
|
+
|
|
96
|
+
if (isManifest(key)) return true;
|
|
97
|
+
|
|
98
|
+
const segments = key.split('/');
|
|
99
|
+
if (segments[0] !== layout.profilesRoot()) return false;
|
|
82
100
|
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
101
|
+
const profile = segments[1];
|
|
102
|
+
if (profile === undefined || !carried(profile)) return false;
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
// The declaration itself.
|
|
105
|
+
if (key === layout.profileConfig(profile)) return true;
|
|
106
|
+
|
|
107
|
+
// `profiles/<profile>/skills.d/<connection>/…`, and at least one segment
|
|
108
|
+
// past the connection — a directory is not a file to send. Both skill
|
|
109
|
+
// layouts satisfy it: `<name>.md` is five segments and `<name>/SKILL.md` is
|
|
110
|
+
// six.
|
|
111
|
+
if (`${segments[0]}/${segments[1]}/${segments[2]}` === layout.skillsRoot(profile)) {
|
|
112
|
+
return segments.length >= 5;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Everything else under a profile is state, a sealed vault, or a provider's
|
|
116
|
+
// own blobs. None of it is configuration and the vault is a credential.
|
|
117
|
+
return false;
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
/**
|
|
92
|
-
*
|
|
121
|
+
* Whether `key` is one of the workspace's own provider manifests.
|
|
93
122
|
*
|
|
94
|
-
* Composed back out of `layout` rather than compared against
|
|
123
|
+
* Composed back out of `layout` rather than compared against a literal, so a
|
|
95
124
|
* renamed directory moves both this and the store that reads it, or neither.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
125
|
+
* Two segments at least, which is the "a directory is not a file to send" rule
|
|
126
|
+
* — `providers.d` alone is the area, `providers.d/<file>` is a manifest.
|
|
98
127
|
*/
|
|
99
|
-
function
|
|
128
|
+
function isManifest(key: string): boolean {
|
|
100
129
|
const segments = key.split('/');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const owner = segments[1]!;
|
|
104
|
-
if (owner.length === 0) return null;
|
|
105
|
-
|
|
106
|
-
const area = `${DATA_DIR}/${owner}/${segments[2]}`;
|
|
107
|
-
return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
|
|
130
|
+
return segments.length >= 2 && segments[0] === layout.providers();
|
|
108
131
|
}
|
|
109
132
|
|
|
110
133
|
/**
|
|
@@ -157,7 +180,16 @@ export async function publishWorkspace(input: {
|
|
|
157
180
|
readonly config: Config;
|
|
158
181
|
readonly workspaceRoot: string;
|
|
159
182
|
readonly target: string;
|
|
160
|
-
|
|
183
|
+
/**
|
|
184
|
+
* The profiles this edit touched, not just the one it was run under.
|
|
185
|
+
*
|
|
186
|
+
* `disconnect` removes a connection from the workspace and drops the grant
|
|
187
|
+
* from *every* profile that named it, so publishing one profile left the
|
|
188
|
+
* bucket holding a `connections.yaml` without the connection and a sibling
|
|
189
|
+
* profile still granting it — which `assertGrantsResolve` refuses at load, so
|
|
190
|
+
* `openReconciled` skipped that profile and it silently stopped being served.
|
|
191
|
+
*/
|
|
192
|
+
readonly profile: string | readonly string[];
|
|
161
193
|
}): Promise<string | null> {
|
|
162
194
|
// Resolution failures are swallowed rather than thrown. The config edit that
|
|
163
195
|
// called this has already succeeded and is on disk; a target that cannot be
|
|
@@ -173,6 +205,7 @@ export async function publishWorkspace(input: {
|
|
|
173
205
|
const destination = deployedWorkspace(declared);
|
|
174
206
|
if (!destination) return null;
|
|
175
207
|
|
|
176
|
-
|
|
208
|
+
const touched = typeof input.profile === 'string' ? [input.profile] : input.profile;
|
|
209
|
+
await uploadWorkspace(input.workspaceRoot, destination, touched);
|
|
177
210
|
return destination;
|
|
178
211
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Principal } from '#auth';
|
|
2
|
+
import type { AuditSink } from '#audit';
|
|
3
|
+
import type { PolicyDocument, ProfilePolicy, RateLimiter } from '#policy';
|
|
4
|
+
import type { Config, ConnectionConfig } from '#profile';
|
|
5
|
+
import type { ProviderRegistry } from '#registry';
|
|
6
|
+
import type { SecretStore } from '#secrets';
|
|
7
|
+
import type { BlobStore } from '#stores/blobs';
|
|
8
|
+
import type { RuntimeState } from '#stores/state';
|
|
9
|
+
import type { AnyConnector } from '#connectivity';
|
|
10
|
+
import type { Logger } from '#connectivity';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the dispatcher is given, and what it is asked.
|
|
14
|
+
*
|
|
15
|
+
* Split from the dispatcher itself so that file stays inside the size budget.
|
|
16
|
+
* The seam is real rather than arithmetic: everything here is what a *caller*
|
|
17
|
+
* assembles, and the class next door is the fixed order in which a call is
|
|
18
|
+
* authorised and run. Reading one has never required reading the other.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The dispatch path.
|
|
23
|
+
*
|
|
24
|
+
* Every invocation goes through here in one fixed order — authenticate,
|
|
25
|
+
* resolve, authorize, rate-limit, dispatch, audit — with no way around it.
|
|
26
|
+
* The ordering is not stylistic: policy is evaluated before a provider is
|
|
27
|
+
* reached, so a provider never sees a request it was not authorised to serve,
|
|
28
|
+
* and authorization is never something provider code could get wrong.
|
|
29
|
+
*
|
|
30
|
+
* Exactly one audit event is written per invocation, on every path including
|
|
31
|
+
* denials. That is enforced structurally by `finally` rather than by
|
|
32
|
+
* remembering to call it at each return.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface DispatchDeps {
|
|
36
|
+
readonly config: Config;
|
|
37
|
+
/**
|
|
38
|
+
* The accounts this workspace holds, and the clients it registered (ADR-057).
|
|
39
|
+
*
|
|
40
|
+
* Separate from `config` because neither is the profile's to declare. The
|
|
41
|
+
* *gate* stays policy: a connection that exists and is not granted resolves
|
|
42
|
+
* and is then denied `denied_default`, which is the same answer a caller gets
|
|
43
|
+
* for one that does not exist — two shapes of "no" that read alike, so probing
|
|
44
|
+
* is not an oracle for what the workspace holds.
|
|
45
|
+
*/
|
|
46
|
+
readonly connections: readonly ConnectionConfig[];
|
|
47
|
+
readonly oauthApps: readonly string[];
|
|
48
|
+
readonly registry: ProviderRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the connector for a provider. Supplied by the caller so core does
|
|
51
|
+
* not import connector implementations, keeping the dependency direction
|
|
52
|
+
* intact: infrastructure -> sdk -> core -> connectors.
|
|
53
|
+
*/
|
|
54
|
+
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
55
|
+
/** Attach whatever the manifest's auth kind requires to an outbound request. */
|
|
56
|
+
readonly authorizeRequest?: (
|
|
57
|
+
providerId: string,
|
|
58
|
+
connectionId: string,
|
|
59
|
+
request: Request,
|
|
60
|
+
) => Promise<Request>;
|
|
61
|
+
readonly policy: ProfilePolicy;
|
|
62
|
+
/** Optional instance floor. Empty in M1; composition is tighten-only. */
|
|
63
|
+
readonly floor?: PolicyDocument;
|
|
64
|
+
readonly state: RuntimeState;
|
|
65
|
+
/**
|
|
66
|
+
* Where events go. Separate from `state` because the log is no longer in
|
|
67
|
+
* it — and because dispatch only ever writes, so taking the sink rather than
|
|
68
|
+
* the whole store means this path structurally cannot read the log back.
|
|
69
|
+
*/
|
|
70
|
+
readonly audit: AuditSink;
|
|
71
|
+
readonly credentials: SecretStore;
|
|
72
|
+
readonly storage: BlobStore;
|
|
73
|
+
readonly limiter: RateLimiter;
|
|
74
|
+
readonly log: Logger;
|
|
75
|
+
readonly now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DispatchRequest {
|
|
79
|
+
readonly principal: Principal;
|
|
80
|
+
/** Fully qualified, e.g. `example.echo`. */
|
|
81
|
+
readonly capabilityId: string;
|
|
82
|
+
/** Fully qualified, e.g. `example.a`. */
|
|
83
|
+
readonly connectionKey: string;
|
|
84
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
85
|
+
/** Self-reported by the client. Observability only — never authorization. */
|
|
86
|
+
readonly clientLabel?: string | undefined;
|
|
87
|
+
readonly signal?: AbortSignal;
|
|
88
|
+
}
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AuditDraft, AuditLogger, AuditSink, AuthorizationResult } from '#audit';
|
|
2
2
|
import { keepKeys, redactAllValues } from '#audit';
|
|
3
|
-
import type
|
|
3
|
+
import { mayReach, type Principal } from '#auth';
|
|
4
4
|
import type { SecretStore } from '#secrets';
|
|
5
5
|
import type { RuntimeState } from '#stores/state';
|
|
6
|
-
import type { PolicyDocument } from '#policy';
|
|
6
|
+
import type { PolicyDocument, ProfilePolicy } from '#policy';
|
|
7
7
|
import { RateLimiter, evaluate } from '#policy';
|
|
8
8
|
import type { BlobStore } from '#stores/blobs';
|
|
9
9
|
import type {
|
|
@@ -14,69 +14,14 @@ import type {
|
|
|
14
14
|
Logger,
|
|
15
15
|
} from '#connectivity';
|
|
16
16
|
import { isToolResult, strategyContextFrom, strategyFor } from '#connectivity';
|
|
17
|
-
import type { Config } from '#profile';
|
|
17
|
+
import type { Config, ConnectionConfig } from '#profile';
|
|
18
18
|
import { buildProviderContext, createProviderLogger } from './context.ts';
|
|
19
19
|
import { fetchStaged, stageAttachment } from './staging.ts';
|
|
20
20
|
import type { FetchStagedRequest, StagedAttachment, StageRequest } from './staging.ts';
|
|
21
21
|
import type { ProviderRegistry } from '#registry';
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
* Every invocation goes through here in one fixed order — authenticate,
|
|
27
|
-
* resolve, authorize, rate-limit, dispatch, audit — with no way around it.
|
|
28
|
-
* The ordering is not stylistic: policy is evaluated before a provider is
|
|
29
|
-
* reached, so a provider never sees a request it was not authorised to serve,
|
|
30
|
-
* and authorization is never something provider code could get wrong.
|
|
31
|
-
*
|
|
32
|
-
* Exactly one audit event is written per invocation, on every path including
|
|
33
|
-
* denials. That is enforced structurally by `finally` rather than by
|
|
34
|
-
* remembering to call it at each return.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
export interface DispatchDeps {
|
|
38
|
-
readonly config: Config;
|
|
39
|
-
readonly registry: ProviderRegistry;
|
|
40
|
-
/**
|
|
41
|
-
* Resolve the connector for a provider. Supplied by the caller so core does
|
|
42
|
-
* not import connector implementations, keeping the dependency direction
|
|
43
|
-
* intact: infrastructure -> sdk -> core -> connectors.
|
|
44
|
-
*/
|
|
45
|
-
readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
|
|
46
|
-
/** Attach whatever the manifest's auth kind requires to an outbound request. */
|
|
47
|
-
readonly authorizeRequest?: (
|
|
48
|
-
providerId: string,
|
|
49
|
-
connectionId: string,
|
|
50
|
-
request: Request,
|
|
51
|
-
) => Promise<Request>;
|
|
52
|
-
readonly policy: PolicyDocument;
|
|
53
|
-
/** Optional instance floor. Empty in M1; composition is tighten-only. */
|
|
54
|
-
readonly floor?: PolicyDocument;
|
|
55
|
-
readonly state: RuntimeState;
|
|
56
|
-
/**
|
|
57
|
-
* Where events go. Separate from `state` because the log is no longer in
|
|
58
|
-
* it — and because dispatch only ever writes, so taking the sink rather than
|
|
59
|
-
* the whole store means this path structurally cannot read the log back.
|
|
60
|
-
*/
|
|
61
|
-
readonly audit: AuditSink;
|
|
62
|
-
readonly credentials: SecretStore;
|
|
63
|
-
readonly storage: BlobStore;
|
|
64
|
-
readonly limiter: RateLimiter;
|
|
65
|
-
readonly log: Logger;
|
|
66
|
-
readonly now?: () => number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface DispatchRequest {
|
|
70
|
-
readonly principal: Principal;
|
|
71
|
-
/** Fully qualified, e.g. `example.echo`. */
|
|
72
|
-
readonly capabilityId: string;
|
|
73
|
-
/** Fully qualified, e.g. `example.a`. */
|
|
74
|
-
readonly connectionKey: string;
|
|
75
|
-
readonly arguments: Readonly<Record<string, unknown>>;
|
|
76
|
-
/** Self-reported by the client. Observability only — never authorization. */
|
|
77
|
-
readonly clientLabel?: string | undefined;
|
|
78
|
-
readonly signal?: AbortSignal;
|
|
79
|
-
}
|
|
23
|
+
export type { DispatchDeps, DispatchRequest } from './deps.ts';
|
|
24
|
+
import type { DispatchDeps, DispatchRequest } from './deps.ts';
|
|
80
25
|
|
|
81
26
|
/**
|
|
82
27
|
* `result` is a union because a capability is not necessarily a tool — a
|
|
@@ -198,7 +143,21 @@ export class Dispatcher {
|
|
|
198
143
|
|
|
199
144
|
auditArguments = (redact ?? redactAllValues)(request.arguments);
|
|
200
145
|
|
|
201
|
-
|
|
146
|
+
// **Who, before what.** A caller reaches a profile only if that profile's
|
|
147
|
+
// `members:` names them (ADR-060), and this is checked before the
|
|
148
|
+
// connection is even resolved: a refusal that first said "no such
|
|
149
|
+
// connection" would answer a question the caller was not entitled to ask.
|
|
150
|
+
// The profile is the principal's: the MCP layer routes on the `profile`
|
|
151
|
+
// argument and builds the principal for it, so this is the same value the
|
|
152
|
+
// caller named and the one the audit event records.
|
|
153
|
+
if (!mayReach(request.principal, request.principal.profile)) {
|
|
154
|
+
return (outcome = deny(
|
|
155
|
+
'denied_default',
|
|
156
|
+
`Profile ${request.principal.profile} is not available`,
|
|
157
|
+
));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const declared = this.#deps.connections.find(
|
|
202
161
|
(connection) => `${connection.provider}.${connection.id}` === request.connectionKey,
|
|
203
162
|
);
|
|
204
163
|
if (!declared || declared.provider !== providerId) {
|
|
@@ -311,7 +270,7 @@ export class Dispatcher {
|
|
|
311
270
|
// Which vendors this profile registered a client of its own for. A
|
|
312
271
|
// provider that could be brokered but is not reads its client from the
|
|
313
272
|
// store, so it has to be able to.
|
|
314
|
-
ownClients:
|
|
273
|
+
ownClients: this.#deps.oauthApps,
|
|
315
274
|
...(entry.manifest.connector.kind === 'local' ? {} : { authorize }),
|
|
316
275
|
});
|
|
317
276
|
|
package/src/policy/index.ts
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* withheld. The floor is empty in M1 — the invariant is implemented
|
|
15
15
|
* anyway, because it is what makes delegated access safe to add later,
|
|
16
16
|
* and retrofitting it once rules exist in the wild is not possible.
|
|
17
|
+
*
|
|
18
|
+
* Since contract 3 a profile's rules are grouped by the connection they govern
|
|
19
|
+
* (ADR-058). Both invariants are unchanged and one is now sharper: a connection
|
|
20
|
+
* with no rules at all is not merely unmatched, it is absent, so default deny
|
|
21
|
+
* bites before a capability is even considered.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
export type { AuthorizationResult } from '#audit';
|
|
@@ -43,6 +48,26 @@ export interface PolicyDocument {
|
|
|
43
48
|
|
|
44
49
|
export const EMPTY_POLICY: PolicyDocument = { rules: [] };
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A profile's rules, grouped by the connection each governs (ADR-058).
|
|
53
|
+
*
|
|
54
|
+
* A map rather than one list carrying a connection per rule, because the
|
|
55
|
+
* lookup is the decision: a call names exactly one connection, and the rules
|
|
56
|
+
* for any other are not evidence about it. Flattening them into one list would
|
|
57
|
+
* mean every evaluation re-filtered, and a filter that was ever wrong would let
|
|
58
|
+
* one account's `allow` answer for another.
|
|
59
|
+
*
|
|
60
|
+
* A connection absent from the map is not granted. That is default deny on the
|
|
61
|
+
* connection axis, and it is why `grants:` with an empty `allow` is a different
|
|
62
|
+
* thing from no row at all — the first says "reachable, nothing permitted", the
|
|
63
|
+
* second says "not reachable".
|
|
64
|
+
*/
|
|
65
|
+
export interface ProfilePolicy {
|
|
66
|
+
readonly byConnection: ReadonlyMap<string, PolicyDocument>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EMPTY_PROFILE_POLICY: ProfilePolicy = { byConnection: new Map() };
|
|
70
|
+
|
|
46
71
|
export interface PolicyRequest {
|
|
47
72
|
/**
|
|
48
73
|
* The authenticated principal. One per profile in M1 (the owner). Carried
|
|
@@ -131,14 +156,21 @@ export function evaluateDocument(
|
|
|
131
156
|
*/
|
|
132
157
|
export function evaluate(
|
|
133
158
|
request: PolicyRequest,
|
|
134
|
-
profile:
|
|
159
|
+
profile: ProfilePolicy,
|
|
135
160
|
floor?: PolicyDocument,
|
|
136
161
|
): PolicyDecision {
|
|
137
162
|
if (floor) {
|
|
138
163
|
const floorDecision = evaluateDocument(floor, request);
|
|
139
164
|
if (!floorDecision.allowed) return floorDecision;
|
|
140
165
|
}
|
|
141
|
-
|
|
166
|
+
|
|
167
|
+
// The floor is still evaluated against the whole request, because a floor is
|
|
168
|
+
// an instance-wide narrowing and knows nothing about which connections a
|
|
169
|
+
// profile happens to hold. Only the profile's half is per connection.
|
|
170
|
+
const granted = profile.byConnection.get(request.connection);
|
|
171
|
+
if (granted === undefined) return { allowed: false, reason: 'denied_default' };
|
|
172
|
+
|
|
173
|
+
return evaluateDocument(granted, request);
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
/**
|
|
@@ -153,7 +185,7 @@ export function allowedConnections(
|
|
|
153
185
|
capability: string,
|
|
154
186
|
connections: readonly string[],
|
|
155
187
|
principal: string,
|
|
156
|
-
profile:
|
|
188
|
+
profile: ProfilePolicy,
|
|
157
189
|
floor?: PolicyDocument,
|
|
158
190
|
at?: Date,
|
|
159
191
|
): string[] {
|
|
@@ -164,16 +196,16 @@ export function allowedConnections(
|
|
|
164
196
|
const provider = capability.slice(0, capability.indexOf('.'));
|
|
165
197
|
const ofProvider = connections.filter((connection) => connection.startsWith(`${provider}.`));
|
|
166
198
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
// One evaluation per account, where this used to be all-or-nothing. That
|
|
200
|
+
// difference is the whole of ADR-058 at the discovery boundary: a profile
|
|
201
|
+
// holding two mailboxes with different rules now advertises the `connection`
|
|
202
|
+
// enum each capability actually permits, instead of offering both wherever
|
|
203
|
+
// either was allowed.
|
|
204
|
+
return ofProvider.filter((connection) => {
|
|
205
|
+
const request: PolicyRequest = at
|
|
206
|
+
? { principal, capability, connection, at }
|
|
207
|
+
: { principal, capability, connection };
|
|
208
|
+
|
|
209
|
+
return evaluate(request, profile, floor).allowed;
|
|
210
|
+
});
|
|
179
211
|
}
|