@integrity-labs/cloud-broker 0.7.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +68 -3
  2. package/dist/index.js +577 -15
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -1,13 +1,18 @@
1
1
  # `@integrity-labs/cloud-broker`
2
2
 
3
- MCP server for the Augmented ephemeral cloud-access broker. Exposes the agent-facing tools (`aws_request_access`, `aws_poll_grant`, `aws_release_access`, `aws_describe_scope`, `aws_preview_request`, `aws_get_credentials`) that mint, poll, and release scoped, TTL-bounded cloud credentials for a single task.
3
+ MCP server for the Augmented ephemeral cloud-access broker. Exposes per-cloud tool families that mint, poll, and release scoped, TTL-bounded credentials for a single task.
4
4
 
5
- **v1 ships AWS support** (STS AssumeRole under the hood; pair with the `aws-cli` toolkit or any AWS SDK as the consumer). All tools are namespaced `aws_*` so GCP, Azure, and Cloudflare can land in this same package as `gcp_*` / `azure_*` / `cf_*` siblings without colliding (ENG-4782).
5
+ - **AWS** (STS `AssumeRole` under the hood `aws_*` tools, paired with the `aws-cli` toolkit or any AWS SDK).
6
+ - **GCP** (IAM Credentials `generateAccessToken` under the hood — `gcp_*` tools, paired with the `gcloud` toolkit). Added in ENG-5074.
6
7
 
7
- See the [PRD](../../docs/prds/aws-ephemeral-access.md) and the [toolkit doc](../../docs/toolkits/aws.md) for the full design.
8
+ All tools are namespaced per cloud so additions are purely additive (ENG-4782). Azure / Cloudflare slots are reserved (`azure_*` / `cf_*`) but unimplemented.
9
+
10
+ See the PRDs ([AWS](../../docs/prds/aws-ephemeral-access.md), [GCP](../../docs/prds/gcp-ephemeral-access.md)) and toolkit docs ([aws](../../docs/toolkits/aws.md), [gcloud](../../docs/toolkits/gcloud.md)) for the full design.
8
11
 
9
12
  ## Tools
10
13
 
14
+ ### AWS
15
+
11
16
  | Tool | Purpose |
12
17
  |---|---|
13
18
  | `aws_describe_scope` | Returns the team's resolved policy ceiling for an account — what the agent is allowed to ask for. Free, idempotent. |
@@ -16,6 +21,21 @@ See the [PRD](../../docs/prds/aws-ephemeral-access.md) and the [toolkit doc](../
16
21
  | `aws_poll_grant` | Single-shot status check. Escape hatch — the broker pushes resolution via direct-chat. |
17
22
  | `aws_get_credentials` | Fetch the AWS_* values for an active grant. Call after `aws_request_access` returns active or after the resolution-notification arrives. |
18
23
  | `aws_release_access` | Voluntarily release a grant before TTL. Idempotent. |
24
+ | `aws_check_approval_channel` | Pre-flight Slack channel reachability check (ENG-4824). |
25
+ | `aws_list_accounts` | Live inventory of enrolled AWS accounts (ENG-5048). |
26
+
27
+ ### GCP
28
+
29
+ | Tool | Purpose |
30
+ |---|---|
31
+ | `gcp_describe_scope` | Resolved policy ceiling for a GCP project. Free, idempotent. |
32
+ | `gcp_preview_request` | Dry-run a candidate request. Writes nothing. |
33
+ | `gcp_request_access` | Mint or queue a grant. |
34
+ | `gcp_poll_grant` | Single-shot status check. Escape hatch. |
35
+ | `gcp_get_credentials` | Fetch the GCP access token for an active grant. |
36
+ | `gcp_release_access` | Voluntarily release a grant before TTL. Idempotent. |
37
+ | `gcp_check_approval_channel` | Pre-flight Slack channel reachability for the project's approval channel. |
38
+ | `gcp_list_accounts` | Live inventory of enrolled GCP projects. |
19
39
 
20
40
  ## Environment
21
41
 
@@ -69,6 +89,51 @@ aws_release_access({ grant_id: "..." })
69
89
 
70
90
  If `aws_request_access` returns `status: "pending"`, a human approver was paged. The broker pushes the resolution to you via direct-chat — save the `grant_id`, return control, and resume when the inbound message arrives. On `denied`, the `denial_reason` field explains why. `aws_poll_grant` is an escape hatch for explicit re-checks.
71
91
 
92
+ ## Worked example (GCP)
93
+
94
+ An agent reading one Cloud Storage object:
95
+
96
+ ```jsonc
97
+ // 1. Inspect the envelope.
98
+ gcp_describe_scope({ project_id: "my-gcp-project" })
99
+
100
+ // 2. Dry-run.
101
+ gcp_preview_request({
102
+ project_id: "my-gcp-project",
103
+ permissions: ["storage.objects.get", "storage.objects.list"],
104
+ resources: ["projects/_/buckets/reports/objects/*", "projects/_/buckets/reports"],
105
+ ttl_seconds: 900
106
+ })
107
+ // → { "would": "auto_approve", "reason": null }
108
+
109
+ // 3. Mint.
110
+ gcp_request_access({
111
+ project_id: "my-gcp-project",
112
+ permissions: ["storage.objects.get", "storage.objects.list"],
113
+ resources: ["projects/_/buckets/reports/objects/*", "projects/_/buckets/reports"],
114
+ ttl_seconds: 900,
115
+ reason: "fetch the daily reports CSV for the user"
116
+ })
117
+ // → { "grant_id": "...", "status": "active", "secret_ref": "secret_ref://gcp/runs/<run_id>/<grant_id>", "expires_at": "..." }
118
+
119
+ // 4. Use the credentials. The runtime adapter resolves the secret_ref
120
+ // pointer to env vars at tool-call boundary — never substitute the
121
+ // raw token into shell history or logs.
122
+ // Inside an agent-spawned shell: gcloud storage cp gs://reports/today.csv -
123
+ // (CLOUDSDK_AUTH_ACCESS_TOKEN / GOOGLE_OAUTH_ACCESS_TOKEN are populated by
124
+ // the runtime; gcloud, gsutil, and bq pick them up automatically.)
125
+ //
126
+ // If you genuinely need the raw token (e.g. for non-gcloud HTTP calls),
127
+ // gcp_get_credentials({ grant_id: "..." }) returns it inline. The runtime
128
+ // MUST NOT echo the token to shell history; use it via a transient env
129
+ // only.
130
+
131
+ // 5. (Optional) release early.
132
+ gcp_release_access({ grant_id: "..." })
133
+ ```
134
+
135
+ Same `pending` / direct-chat-push resolution semantics as AWS. The runtime resolves `secret_ref://gcp/runs/...` to `CLOUDSDK_AUTH_ACCESS_TOKEN` and `GOOGLE_OAUTH_ACCESS_TOKEN` at exec time, so `gcloud`, `gsutil`, and `bq` all pick it up automatically without the agent ever handling the raw token.
136
+
72
137
  ## Running locally
73
138
 
74
139
  ```bash
package/dist/index.js CHANGED
@@ -21004,7 +21004,7 @@ var BrokerClient = class {
21004
21004
  exchangeInFlight = null;
21005
21005
  constructor(config2) {
21006
21006
  this.host = config2.host.replace(/\/+$/, "");
21007
- const rawPrefix = config2.apiPathPrefix ?? "/cloud/aws";
21007
+ const rawPrefix = config2.apiPathPrefix && config2.apiPathPrefix.trim() || "/aws";
21008
21008
  this.apiPathPrefix = (rawPrefix.startsWith("/") ? rawPrefix : `/${rawPrefix}`).replace(/\/+$/, "");
21009
21009
  this.agentId = config2.agentId;
21010
21010
  this.runId = config2.runId;
@@ -21127,7 +21127,9 @@ var BrokerClient = class {
21127
21127
  return this.request("POST", `${this.apiPathPrefix}/grants`, { body });
21128
21128
  }
21129
21129
  pollGrant(args) {
21130
- return this.request("GET", `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}`);
21130
+ return this.request("GET", `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}`, {
21131
+ query: this.agentId ? { agent_id: this.agentId } : void 0
21132
+ });
21131
21133
  }
21132
21134
  /**
21133
21135
  * ENG-4824: pre-flight check that the agent's Slack bot can actually
@@ -21149,7 +21151,8 @@ var BrokerClient = class {
21149
21151
  releaseAccess(args) {
21150
21152
  return this.request(
21151
21153
  "POST",
21152
- `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}/release`
21154
+ `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}/release`,
21155
+ { query: this.agentId ? { agent_id: this.agentId } : void 0 }
21153
21156
  );
21154
21157
  }
21155
21158
  /**
@@ -21175,7 +21178,162 @@ var BrokerClient = class {
21175
21178
  getCredentials(args) {
21176
21179
  return this.request(
21177
21180
  "POST",
21178
- `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}/credentials`
21181
+ `${this.apiPathPrefix}/grants/${encodeURIComponent(args.grant_id)}/credentials`,
21182
+ { query: this.agentId ? { agent_id: this.agentId } : void 0 }
21183
+ );
21184
+ }
21185
+ // ──────────────────────────────── GCP (ENG-5077) ────────────────────────
21186
+ // Parallel surface to the AWS methods above. Same JWT, same agent_id /
21187
+ // run_id auto-fill, same error envelope. Hits /gcp/* endpoints on the
21188
+ // broker API. Grant_id is provider-agnostic — `gcp_get_credentials`
21189
+ // returns a GCP access_token instead of an AWS triple, but the rest of
21190
+ // the lifecycle (poll, release, status) is identical in shape.
21191
+ gcpDescribeScope(args) {
21192
+ if (!this.agentId) {
21193
+ throw makeBrokerError(400, "BrokerClient.gcpDescribeScope requires agentId \u2014 pass it in BrokerClientConfig");
21194
+ }
21195
+ return this.request(
21196
+ "GET",
21197
+ "/gcp/scope",
21198
+ { query: { project_id: args.project_id, agent_id: this.agentId } }
21199
+ );
21200
+ }
21201
+ gcpPreviewRequest(args) {
21202
+ if (!this.agentId) {
21203
+ throw makeBrokerError(400, "BrokerClient.gcpPreviewRequest requires agentId \u2014 pass it in BrokerClientConfig");
21204
+ }
21205
+ const body = { ...args };
21206
+ if (body.agent_id === void 0) body.agent_id = this.agentId;
21207
+ return this.request(
21208
+ "POST",
21209
+ "/gcp/scope/preview",
21210
+ { body }
21211
+ );
21212
+ }
21213
+ async gcpRequestAccess(args) {
21214
+ const agentId = args.agent_id ?? this.agentId;
21215
+ const runId = args.run_id ?? this.runId;
21216
+ if (!agentId) {
21217
+ throw makeBrokerError(400, "BrokerClient.gcpRequestAccess requires agent_id (pass it in args, or set agentId on BrokerClientConfig)");
21218
+ }
21219
+ if (!runId) {
21220
+ throw makeBrokerError(400, "BrokerClient.gcpRequestAccess requires run_id (pass it in args, or set runId on BrokerClientConfig)");
21221
+ }
21222
+ const body = { ...args, agent_id: agentId, run_id: runId };
21223
+ return this.request("POST", "/gcp/grants", { body });
21224
+ }
21225
+ gcpPollGrant(args) {
21226
+ return this.request("GET", `/gcp/grants/${encodeURIComponent(args.grant_id)}`);
21227
+ }
21228
+ gcpCheckApprovalChannel(args) {
21229
+ if (!this.agentId) {
21230
+ throw makeBrokerError(400, "BrokerClient.gcpCheckApprovalChannel requires agentId \u2014 pass it in BrokerClientConfig");
21231
+ }
21232
+ return this.request(
21233
+ "GET",
21234
+ "/gcp/approval-channel-status",
21235
+ { query: { project_id: args.project_id, agent_id: this.agentId } }
21236
+ );
21237
+ }
21238
+ gcpReleaseAccess(args) {
21239
+ return this.request(
21240
+ "POST",
21241
+ `/gcp/grants/${encodeURIComponent(args.grant_id)}/release`
21242
+ );
21243
+ }
21244
+ gcpListInventory() {
21245
+ if (!this.agentId) {
21246
+ throw makeBrokerError(400, "BrokerClient.gcpListInventory requires agentId \u2014 pass it in BrokerClientConfig");
21247
+ }
21248
+ return this.request(
21249
+ "GET",
21250
+ "/gcp/inventory",
21251
+ { query: { agent_id: this.agentId } }
21252
+ );
21253
+ }
21254
+ gcpGetCredentials(args) {
21255
+ return this.request(
21256
+ "POST",
21257
+ `/gcp/grants/${encodeURIComponent(args.grant_id)}/credentials`
21258
+ );
21259
+ }
21260
+ // ──────────────────────────────── Supabase ────────────────────────────────
21261
+ // Short-lived Supabase JWT tokens for querying enrolled Supabase projects.
21262
+ // Prod projects always route_to_approver (HITL); dev/staging can be
21263
+ // configured for auto_approve via policy_ceiling.auto_approved_roles.
21264
+ supabaseDescribeScope(args) {
21265
+ if (!this.agentId) {
21266
+ throw makeBrokerError(400, "BrokerClient.supabaseDescribeScope requires agentId \u2014 pass it in BrokerClientConfig");
21267
+ }
21268
+ return this.request(
21269
+ "GET",
21270
+ "/supabase/scope",
21271
+ { query: { project_ref: args.project_ref, agent_id: this.agentId } }
21272
+ );
21273
+ }
21274
+ supabasePreviewRequest(args) {
21275
+ if (!this.agentId) {
21276
+ throw makeBrokerError(400, "BrokerClient.supabasePreviewRequest requires agentId \u2014 pass it in BrokerClientConfig");
21277
+ }
21278
+ const body = { ...args };
21279
+ if (body.agent_id === void 0) body.agent_id = this.agentId;
21280
+ return this.request(
21281
+ "POST",
21282
+ "/supabase/scope/preview",
21283
+ { body }
21284
+ );
21285
+ }
21286
+ async supabaseRequestAccess(args) {
21287
+ const agentId = args.agent_id ?? this.agentId;
21288
+ const runId = args.run_id ?? this.runId;
21289
+ if (!agentId) {
21290
+ throw makeBrokerError(400, "BrokerClient.supabaseRequestAccess requires agent_id (pass it in args, or set agentId on BrokerClientConfig)");
21291
+ }
21292
+ if (!runId) {
21293
+ throw makeBrokerError(400, "BrokerClient.supabaseRequestAccess requires run_id (pass it in args, or set runId on BrokerClientConfig)");
21294
+ }
21295
+ const body = { ...args, agent_id: agentId, run_id: runId };
21296
+ return this.request("POST", "/supabase/grants", { body });
21297
+ }
21298
+ supabasePollGrant(args) {
21299
+ return this.request(
21300
+ "GET",
21301
+ `/supabase/grants/${encodeURIComponent(args.grant_id)}`,
21302
+ { query: this.agentId ? { agent_id: this.agentId } : void 0 }
21303
+ );
21304
+ }
21305
+ supabaseCheckApprovalChannel(args) {
21306
+ if (!this.agentId) {
21307
+ throw makeBrokerError(400, "BrokerClient.supabaseCheckApprovalChannel requires agentId \u2014 pass it in BrokerClientConfig");
21308
+ }
21309
+ return this.request(
21310
+ "GET",
21311
+ "/supabase/approval-channel-status",
21312
+ { query: { project_ref: args.project_ref, agent_id: this.agentId } }
21313
+ );
21314
+ }
21315
+ supabaseReleaseAccess(args) {
21316
+ return this.request(
21317
+ "POST",
21318
+ `/supabase/grants/${encodeURIComponent(args.grant_id)}/release`,
21319
+ { query: this.agentId ? { agent_id: this.agentId } : void 0 }
21320
+ );
21321
+ }
21322
+ supabaseListInventory() {
21323
+ if (!this.agentId) {
21324
+ throw makeBrokerError(400, "BrokerClient.supabaseListInventory requires agentId \u2014 pass it in BrokerClientConfig");
21325
+ }
21326
+ return this.request(
21327
+ "GET",
21328
+ "/supabase/inventory",
21329
+ { query: { agent_id: this.agentId } }
21330
+ );
21331
+ }
21332
+ supabaseGetCredentials(args) {
21333
+ return this.request(
21334
+ "POST",
21335
+ `/supabase/grants/${encodeURIComponent(args.grant_id)}/credentials`,
21336
+ { query: this.agentId ? { agent_id: this.agentId } : void 0 }
21179
21337
  );
21180
21338
  }
21181
21339
  };
@@ -21197,6 +21355,36 @@ function renderInventoryBlock(accounts) {
21197
21355
  Available accounts for this team (use the 12-digit account_id, NEVER invent one):
21198
21356
  ${lines.join("\n")}${truncated}`;
21199
21357
  }
21358
+ function renderGcpInventoryBlock(projects) {
21359
+ if (projects.length === 0) return "";
21360
+ const shown = projects.slice(0, INVENTORY_RENDER_LIMIT);
21361
+ const lines = shown.map((p) => {
21362
+ const sa = p.service_account_email ? ` (sa: ${p.service_account_email})` : "";
21363
+ return ` - \`${p.project_id}\` \u2014 ${p.display_name}${sa}`;
21364
+ });
21365
+ const truncated = projects.length > INVENTORY_RENDER_LIMIT ? `
21366
+
21367
+ \u2026and ${projects.length - INVENTORY_RENDER_LIMIT} more \u2014 call \`gcp_describe_scope\` for any unlisted project.` : "";
21368
+ return `
21369
+
21370
+ Available GCP projects for this team (use the project_id, NEVER invent one):
21371
+ ${lines.join("\n")}${truncated}`;
21372
+ }
21373
+ function renderSupabaseInventoryBlock(projects) {
21374
+ if (projects.length === 0) return "";
21375
+ const shown = projects.slice(0, INVENTORY_RENDER_LIMIT);
21376
+ const lines = shown.map((p) => {
21377
+ const url = p.supabase_url ? ` (${p.supabase_url})` : "";
21378
+ return ` - \`${p.project_ref}\` \u2014 ${p.display_name}${url}`;
21379
+ });
21380
+ const truncated = projects.length > INVENTORY_RENDER_LIMIT ? `
21381
+
21382
+ \u2026and ${projects.length - INVENTORY_RENDER_LIMIT} more \u2014 call \`supabase_describe_scope\` for any unlisted project.` : "";
21383
+ return `
21384
+
21385
+ Available Supabase projects for this team (use the project_ref, NEVER invent one):
21386
+ ${lines.join("\n")}${truncated}`;
21387
+ }
21200
21388
 
21201
21389
  // src/tool-schemas.ts
21202
21390
  var accountIdSchema = external_exports.string().regex(/^\d{12}$/, "AWS account_id must be a 12-digit string");
@@ -21270,11 +21458,123 @@ var releaseAccessShape = releaseAccessSchema.shape;
21270
21458
  var getCredentialsShape = getCredentialsSchema.shape;
21271
21459
  var checkApprovalChannelShape = checkApprovalChannelSchema.shape;
21272
21460
  var listAccountsShape = listAccountsSchema.shape;
21461
+ var projectIdSchema = external_exports.string().regex(
21462
+ /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/,
21463
+ "GCP project_id must be 6\u201330 chars, lowercase letters/digits/hyphens, starting with a letter and not ending with a hyphen"
21464
+ );
21465
+ var permissionListSchema = external_exports.array(external_exports.string().min(1)).min(1, "At least one permission is required").describe(
21466
+ 'GCP IAM permissions to allow, e.g. ["storage.objects.get", "storage.objects.list"]. See https://cloud.google.com/iam/docs/permissions-reference for the canonical list.'
21467
+ );
21468
+ var gcpResourceListSchema = external_exports.array(external_exports.string().min(1)).min(1, "At least one resource is required").describe(
21469
+ 'GCP resource names (e.g. "projects/_/buckets/reports/objects/*", "projects/<project>/zones/us-central1-a/instances/*"). Must be inside the enrolled project.'
21470
+ );
21471
+ var gcpDescribeScopeSchema = external_exports.object({
21472
+ project_id: projectIdSchema
21473
+ });
21474
+ var gcpPreviewRequestSchema = external_exports.object({
21475
+ project_id: projectIdSchema,
21476
+ permissions: permissionListSchema,
21477
+ resources: gcpResourceListSchema,
21478
+ ttl_seconds: ttlSecondsSchema
21479
+ });
21480
+ var gcpRequestAccessSchema = external_exports.object({
21481
+ agent_id: external_exports.string().uuid("agent_id must be a UUID").optional().describe(
21482
+ "Optional. Defaults to the host MCP env (AGT_AGENT_ID). Pass explicitly only to attribute the grant to a different agent."
21483
+ ),
21484
+ run_id: external_exports.string().uuid("run_id must be a UUID").optional().describe(
21485
+ "Optional. Defaults to the host MCP env (AGT_RUN_ID). Pass explicitly only to attribute the grant to a different run."
21486
+ ),
21487
+ project_id: projectIdSchema,
21488
+ permissions: permissionListSchema,
21489
+ resources: gcpResourceListSchema,
21490
+ ttl_seconds: ttlSecondsSchema,
21491
+ task_id: external_exports.string().min(1).optional().describe("Optional Augmented task identifier (e.g. kanban:task-7842)."),
21492
+ reason: external_exports.string().min(1).max(500).optional().describe(
21493
+ "One-sentence rationale shown to a human approver. Must be safe to render \u2014 never contains credentials."
21494
+ ),
21495
+ source_context: sourceContextSchema.optional().describe(
21496
+ "Optional. Inbound conversation that triggered this request. Persisted on the grant so the resolution notification can thread the answer back to the original channel/thread instead of dead-ending in direct-chat."
21497
+ )
21498
+ });
21499
+ var gcpPollGrantSchema = external_exports.object({
21500
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21501
+ });
21502
+ var gcpReleaseAccessSchema = external_exports.object({
21503
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21504
+ });
21505
+ var gcpGetCredentialsSchema = external_exports.object({
21506
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21507
+ });
21508
+ var gcpCheckApprovalChannelSchema = external_exports.object({
21509
+ project_id: projectIdSchema
21510
+ });
21511
+ var gcpListAccountsSchema = external_exports.object({});
21512
+ var gcpDescribeScopeShape = gcpDescribeScopeSchema.shape;
21513
+ var gcpPreviewRequestShape = gcpPreviewRequestSchema.shape;
21514
+ var gcpRequestAccessShape = gcpRequestAccessSchema.shape;
21515
+ var gcpPollGrantShape = gcpPollGrantSchema.shape;
21516
+ var gcpReleaseAccessShape = gcpReleaseAccessSchema.shape;
21517
+ var gcpGetCredentialsShape = gcpGetCredentialsSchema.shape;
21518
+ var gcpCheckApprovalChannelShape = gcpCheckApprovalChannelSchema.shape;
21519
+ var gcpListAccountsShape = gcpListAccountsSchema.shape;
21520
+ var supabaseProjectRefSchema = external_exports.string().regex(/^[a-z0-9]{20}$/, "Supabase project_ref must be exactly 20 lowercase alphanumeric characters");
21521
+ var schemaListSchema = external_exports.array(external_exports.string().min(1)).min(1, "At least one schema is required").describe(`Postgres schemas to access, e.g. ["public"]. Must be within the team's allowed_schemas ceiling.`);
21522
+ var supabaseDescribeScopeSchema = external_exports.object({
21523
+ project_ref: supabaseProjectRefSchema
21524
+ });
21525
+ var supabasePreviewRequestSchema = external_exports.object({
21526
+ project_ref: supabaseProjectRefSchema,
21527
+ role: external_exports.string().min(1).describe('Postgres role for the JWT (e.g. "authenticated"). "service_role" always routes to approver.'),
21528
+ allowed_schemas: schemaListSchema,
21529
+ allowed_tables: external_exports.array(external_exports.string().min(1)).optional().describe('Optional schema.table allowlist (e.g. ["public.agents", "public.runs"]). Absent = all tables in allowed_schemas.'),
21530
+ read_only: external_exports.boolean().describe("True if only SELECT access is needed. Surfaced as a JWT claim for RLS enforcement."),
21531
+ ttl_seconds: ttlSecondsSchema
21532
+ });
21533
+ var supabaseRequestAccessSchema = external_exports.object({
21534
+ agent_id: external_exports.string().uuid("agent_id must be a UUID").optional().describe(
21535
+ "Optional. Defaults to the host MCP env (AGT_AGENT_ID)."
21536
+ ),
21537
+ run_id: external_exports.string().uuid("run_id must be a UUID").optional().describe(
21538
+ "Optional. Defaults to the host MCP env (AGT_RUN_ID)."
21539
+ ),
21540
+ project_ref: supabaseProjectRefSchema,
21541
+ role: external_exports.string().min(1).describe('Postgres role for the JWT (e.g. "authenticated"). "service_role" always routes to approver.'),
21542
+ allowed_schemas: schemaListSchema,
21543
+ allowed_tables: external_exports.array(external_exports.string().min(1)).optional().describe("Optional schema.table allowlist. Absent = all tables in allowed_schemas."),
21544
+ read_only: external_exports.boolean().describe("True if only SELECT access is needed."),
21545
+ ttl_seconds: ttlSecondsSchema,
21546
+ task_id: external_exports.string().min(1).optional().describe("Optional Augmented task identifier."),
21547
+ reason: external_exports.string().min(1).max(500).optional().describe("One-sentence rationale shown to a human approver."),
21548
+ source_context: sourceContextSchema.optional().describe(
21549
+ "Optional. Inbound conversation that triggered this request. Persisted so the resolution notification can thread back to the original channel."
21550
+ )
21551
+ });
21552
+ var supabasePollGrantSchema = external_exports.object({
21553
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21554
+ });
21555
+ var supabaseReleaseAccessSchema = external_exports.object({
21556
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21557
+ });
21558
+ var supabaseGetCredentialsSchema = external_exports.object({
21559
+ grant_id: external_exports.string().uuid("grant_id must be a UUID")
21560
+ });
21561
+ var supabaseCheckApprovalChannelSchema = external_exports.object({
21562
+ project_ref: supabaseProjectRefSchema
21563
+ });
21564
+ var supabaseListProjectsSchema = external_exports.object({});
21565
+ var supabaseDescribeScopeShape = supabaseDescribeScopeSchema.shape;
21566
+ var supabasePreviewRequestShape = supabasePreviewRequestSchema.shape;
21567
+ var supabaseRequestAccessShape = supabaseRequestAccessSchema.shape;
21568
+ var supabasePollGrantShape = supabasePollGrantSchema.shape;
21569
+ var supabaseReleaseAccessShape = supabaseReleaseAccessSchema.shape;
21570
+ var supabaseGetCredentialsShape = supabaseGetCredentialsSchema.shape;
21571
+ var supabaseCheckApprovalChannelShape = supabaseCheckApprovalChannelSchema.shape;
21572
+ var supabaseListProjectsShape = supabaseListProjectsSchema.shape;
21273
21573
 
21274
21574
  // package.json
21275
21575
  var package_default = {
21276
21576
  name: "@integrity-labs/cloud-broker",
21277
- version: "0.7.0",
21577
+ version: "0.7.3",
21278
21578
  description: "Cloud Access Broker \u2014 MCP server that mints scoped, TTL-bounded cloud credentials per agent task. Ships AWS support (aws_request_access, aws_poll_grant, aws_release_access, aws_describe_scope, aws_preview_request, aws_get_credentials \u2014 STS AssumeRole under the hood); GCP, Azure, and Cloudflare land alongside in the same package as the broker grows.",
21279
21579
  type: "module",
21280
21580
  bin: {
@@ -21301,7 +21601,9 @@ var package_default = {
21301
21601
  test: "vitest run",
21302
21602
  clean: "rm -rf dist",
21303
21603
  "publish:templates": "bash cloudformation/publish.sh",
21304
- "check-published": "bash cloudformation/check-published.sh"
21604
+ "check-published": "bash cloudformation/check-published.sh",
21605
+ "publish:terraform": "bash terraform/publish.sh",
21606
+ "check-published-terraform": "bash terraform/check-published.sh"
21305
21607
  },
21306
21608
  dependencies: {
21307
21609
  "@modelcontextprotocol/sdk": "1.27.1",
@@ -21343,7 +21645,7 @@ var broker = new BrokerClient({
21343
21645
  runId: AGT_RUN_ID || void 0,
21344
21646
  initialToken: AGT_TOKEN || void 0,
21345
21647
  apiKey: AGT_API_KEY || void 0,
21346
- apiPathPrefix: "/cloud/aws"
21648
+ apiPathPrefix: "/aws"
21347
21649
  });
21348
21650
  function formatBrokerError(err) {
21349
21651
  if (err && typeof err === "object" && "status" in err && "message" in err) {
@@ -21381,9 +21683,59 @@ async function loadInventoryBlock() {
21381
21683
  }
21382
21684
  }
21383
21685
  var inventoryBlock = await loadInventoryBlock();
21686
+ var NO_DIRECT_HTTP_BLOCK = "\n\nUse this MCP tool \u2014 never call /aws/* or /cloud/aws/* HTTP endpoints directly. The broker client handles JWT refresh, slug disambiguation, secret_ref redirection, and per-call audit context that direct HTTP loses.";
21687
+ async function loadGcpInventoryBlock() {
21688
+ const ctl = new AbortController();
21689
+ const timer = setTimeout(() => ctl.abort(), INVENTORY_FETCH_TIMEOUT_MS);
21690
+ try {
21691
+ const result = await Promise.race([
21692
+ broker.gcpListInventory(),
21693
+ new Promise((_, reject) => {
21694
+ ctl.signal.addEventListener(
21695
+ "abort",
21696
+ () => reject(new Error(`gcp_inventory_lookup timed out after ${INVENTORY_FETCH_TIMEOUT_MS}ms`))
21697
+ );
21698
+ })
21699
+ ]);
21700
+ return renderGcpInventoryBlock(result.projects);
21701
+ } catch (err) {
21702
+ console.error(
21703
+ `cloud-broker: gcp_inventory_lookup_failed (non-fatal \u2014 tool descriptions render project-agnostic): ${formatBrokerError(err)}`
21704
+ );
21705
+ return "";
21706
+ } finally {
21707
+ clearTimeout(timer);
21708
+ }
21709
+ }
21710
+ var gcpInventoryBlock = await loadGcpInventoryBlock();
21711
+ async function loadSupabaseInventoryBlock() {
21712
+ const ctl = new AbortController();
21713
+ const timer = setTimeout(() => ctl.abort(), INVENTORY_FETCH_TIMEOUT_MS);
21714
+ try {
21715
+ const result = await Promise.race([
21716
+ broker.supabaseListInventory(),
21717
+ new Promise((_, reject) => {
21718
+ ctl.signal.addEventListener(
21719
+ "abort",
21720
+ () => reject(new Error(`supabase_inventory_lookup timed out after ${INVENTORY_FETCH_TIMEOUT_MS}ms`))
21721
+ );
21722
+ })
21723
+ ]);
21724
+ return renderSupabaseInventoryBlock(result.projects);
21725
+ } catch (err) {
21726
+ console.error(
21727
+ `cloud-broker: supabase_inventory_lookup_failed (non-fatal): ${formatBrokerError(err)}`
21728
+ );
21729
+ return "";
21730
+ } finally {
21731
+ clearTimeout(timer);
21732
+ }
21733
+ }
21734
+ var supabaseInventoryBlock = await loadSupabaseInventoryBlock();
21735
+ var NO_DIRECT_SUPABASE_HTTP_BLOCK = "\n\nUse this MCP tool \u2014 never call /supabase/* HTTP endpoints directly. The broker client handles JWT refresh, slug disambiguation, secret_ref redirection, and per-call audit context that direct HTTP loses.";
21384
21736
  server.tool(
21385
21737
  "aws_describe_scope",
21386
- "Return the team's resolved AWS policy ceiling for an account: max_ttl_seconds, allowed_regions, auto_approved_actions, and the action denylist. Call this before aws_request_access if you're not sure what you're allowed to ask for \u2014 it's free, idempotent, and writes nothing." + inventoryBlock,
21738
+ "Return the team's resolved AWS policy ceiling for an account: max_ttl_seconds, allowed_regions, auto_approved_actions, and the action denylist. Call this before aws_request_access if you're not sure what you're allowed to ask for \u2014 it's free, idempotent, and writes nothing." + inventoryBlock + NO_DIRECT_HTTP_BLOCK,
21387
21739
  describeScopeShape,
21388
21740
  async (args) => {
21389
21741
  try {
@@ -21396,7 +21748,7 @@ server.tool(
21396
21748
  );
21397
21749
  server.tool(
21398
21750
  "aws_preview_request",
21399
- 'Dry-run a candidate request. Returns one of "auto_approve", "route_to_approver", or "hard_deny" without dispatching anything or writing audit_log. Useful for letting the agent self-tighten its scope before triggering a human approval.',
21751
+ 'Dry-run a candidate request. Returns one of "auto_approve", "route_to_approver", or "hard_deny" without dispatching anything or writing audit_log. Useful for letting the agent self-tighten its scope before triggering a human approval.' + NO_DIRECT_HTTP_BLOCK,
21400
21752
  previewRequestShape,
21401
21753
  async (args) => {
21402
21754
  try {
@@ -21409,7 +21761,7 @@ server.tool(
21409
21761
  );
21410
21762
  server.tool(
21411
21763
  "aws_check_approval_channel",
21412
- `Verify the agent's Slack bot can post to the AWS-approval channel for an account BEFORE calling aws_request_access. Returns { ok, channel?, bot_user_handle?, reason? }. When ok=false, branch on reason: "channel_not_found" or "not_in_channel" \u2192 tell the user "I need to be invited to the approvals channel before I can request access \u2014 please run \`/invite @<bot_user_handle>\` in your AWS-approvals channel and let me know when done"; "archived" \u2192 tell the user the configured channel is archived and an admin needs to repoint it in Team Settings; "no_approval_channel_configured" \u2192 tell the user no approval channel is set up for this AWS account and ask them to configure one in Team Settings; "agent_slack_not_configured" \u2192 escalate to operator (you have no Slack bot at all, so /invite won't help); "auth_failed" or "unknown" \u2192 escalate to operator with raw_error. ALWAYS call this on the FIRST aws_request_access for a given account_id in a session \u2014 once you've gotten ok=true once, you can skip subsequent pre-checks. If ok=false, DO NOT call aws_request_access \u2014 surface the user-facing message above and wait. Same jargon-free, no-mechanics rules as aws_request_access: never expose "broker", "grant", "MCP", "Slack channel ID", etc to the user \u2014 talk about the AWS account and the invite action.` + inventoryBlock,
21764
+ `Verify the agent's Slack bot can post to the AWS-approval channel for an account BEFORE calling aws_request_access. Returns { ok, channel?, bot_user_handle?, reason? }. When ok=false, branch on reason: "channel_not_found" or "not_in_channel" \u2192 tell the user "I need to be invited to the approvals channel before I can request access \u2014 please run \`/invite @<bot_user_handle>\` in your AWS-approvals channel and let me know when done"; "archived" \u2192 tell the user the configured channel is archived and an admin needs to repoint it in Team Settings; "no_approval_channel_configured" \u2192 tell the user no approval channel is set up for this AWS account and ask them to configure one in Team Settings; "agent_slack_not_configured" \u2192 escalate to operator (you have no Slack bot at all, so /invite won't help); "auth_failed" or "unknown" \u2192 escalate to operator with raw_error. ALWAYS call this on the FIRST aws_request_access for a given account_id in a session \u2014 once you've gotten ok=true once, you can skip subsequent pre-checks. If ok=false, DO NOT call aws_request_access \u2014 surface the user-facing message above and wait. Same jargon-free, no-mechanics rules as aws_request_access: never expose "broker", "grant", "MCP", "Slack channel ID", etc to the user \u2014 talk about the AWS account and the invite action.` + inventoryBlock + NO_DIRECT_HTTP_BLOCK,
21413
21765
  checkApprovalChannelShape,
21414
21766
  async (args) => {
21415
21767
  try {
@@ -21426,7 +21778,7 @@ server.tool(
21426
21778
  // accounts inline with the tool that consumes account_id. This is the
21427
21779
  // call site where a wrong/invented account_id would actually do harm
21428
21780
  // (route to a nonexistent enrolment → 404 → confusing failure mode).
21429
- 'Request scoped, TTL-bounded AWS credentials for the current task. CALL aws_check_approval_channel FIRST on the FIRST request for a given account_id in a session \u2014 if it returns ok=false, DO NOT call this tool, surface the invite-the-bot message instead. agent_id and run_id are optional \u2014 the broker fills them from the host MCP env (AGT_AGENT_ID / AGT_RUN_ID). source_context is optional but you SHOULD pass it whenever the request was triggered by an inbound channel message: extract { channel_type, channel_id, thread_ts? } from the `<channel>` tag in the conversation that triggered this. Slack: channel_type="slack", channel_id=tag\'s `channel`, thread_ts=tag\'s `thread_ts`. Telegram: channel_type="telegram", channel_id=tag\'s `chat_id`. Direct-chat: channel_type="direct-chat", channel_id=tag\'s `session_id`. Without source_context the resolution notification dead-ends in your direct-chat instead of threading back to the original conversation. Returns { grant_id, status, secret_ref?, expires_at?, denial_reason?, notification_status?, notification_failure_reason?, notification_channel_name?, notification_channel_id?, notification_permalink?, notification_bot_user_handle? }. status="active" means credentials are ready, use them now. status="denied" means the request was rejected (denial_reason explains). status="pending" means approval is still outstanding \u2014 DO NOT poll. Post a brief, jargon-free acknowledgement to the user first. When notification_channel_name is populated, QUOTE IT VERBATIM in the acknowledgement (e.g. "Requesting access to the <aws_account_name> account so I can <do the task> \u2014 pinged an admin in #<notification_channel_name> to approve, will resume the moment it lands"); NEVER invent or guess a channel name from training (e.g. "#aws-approvals" is wrong if the configured channel is "#agt-approvals"). When notification_permalink is populated, you MAY append it as a markdown link (e.g. "[approval card](<notification_permalink>)") so the user can jump straight to it; otherwise omit. When notification_channel_name is absent, fall back to the channel-agnostic phrasing ("pinged an admin to approve"). Then save the grant_id and return control. NEVER expose broker mechanics to the user \u2014 phrases like "firing a broker grant", "requesting a grant", "broker grant", "grant_id", "secret_ref", "STS", "AssumeRole", or any aws_* tool name must not appear in user-facing messages. Also NEVER paste the grant UUID into user-facing prose (e.g. "request c7a0b7be-5e09-\u2026 is queued") \u2014 it is operator-only metadata and reads as noise to the user. Talk about the task and the AWS account, not the plumbing. The broker pushes the resolution to you via direct-chat the moment a human approves or denies. The notification body will include an "Original conversation:" line naming the channel/thread to reply in \u2014 when it arrives, post a one-line acknowledgement there in the same jargon-free style ("Approval came through for <aws_account_name> \u2014 kicking off <the task> now" on active; "Couldn\'t get access to <aws_account_name> for <the task>: <paraphrased reason> \u2014 let me know how you\'d like to proceed" on denied) BEFORE calling aws_get_credentials or doing the work, then complete the user\'s task in that same channel/thread (not direct-chat). Going silent between the request and the work loses the human-in-the-loop signal. Only check notification_status if you need to flag a setup issue to the user: "sent" means a human was paged; "failed" or "not_attempted" means no human was paged (typically channel_not_found because the approval-bot is not a member of the configured channel). When notification_status is "failed" and notification_bot_user_handle + notification_channel_name are populated, name them specifically in the user message: "I queued the access request for <aws_account_name> but couldn\'t notify the approver \u2014 please run `/invite @<notification_bot_user_handle>` in #<notification_channel_name> and let me know when done so I can re-fire". When the handle/channel-name aren\'t populated, fall back to the generic version: "I couldn\'t reach an approver for the <aws_account_name> account (the approval bot isn\'t in the configured channel) \u2014 please ping an admin manually, or fix the AWS approval channel in Team Settings". Keep the user-facing text jargon-free and paraphrased. grant_id and notification_failure_reason are operator/escalation-only metadata; if and only if you are escalating to an operator, append "(reference: <grant_id>, failure: <notification_failure_reason>)" to the operator-facing escalation note. aws_poll_grant exists as an escape hatch for explicit re-checks but the autonomous flow does not need it.' + inventoryBlock,
21781
+ 'Request scoped, TTL-bounded AWS credentials for the current task. CALL aws_check_approval_channel FIRST on the FIRST request for a given account_id in a session \u2014 if it returns ok=false, DO NOT call this tool, surface the invite-the-bot message instead. agent_id and run_id are optional \u2014 the broker fills them from the host MCP env (AGT_AGENT_ID / AGT_RUN_ID). source_context is optional but you SHOULD pass it whenever the request was triggered by an inbound channel message: extract { channel_type, channel_id, thread_ts? } from the `<channel>` tag in the conversation that triggered this. Slack: channel_type="slack", channel_id=tag\'s `channel`, thread_ts=tag\'s `thread_ts`. Telegram: channel_type="telegram", channel_id=tag\'s `chat_id`. Direct-chat: channel_type="direct-chat", channel_id=tag\'s `session_id`. Without source_context the resolution notification dead-ends in your direct-chat instead of threading back to the original conversation. Returns { grant_id, status, secret_ref?, expires_at?, denial_reason?, notification_status?, notification_failure_reason?, notification_channel_name?, notification_channel_id?, notification_permalink?, notification_bot_user_handle? }. status="active" means credentials are ready, use them now. status="denied" means the request was rejected (denial_reason explains). status="pending" means approval is still outstanding \u2014 DO NOT poll. Post a brief, jargon-free acknowledgement to the user first. When notification_channel_name is populated, QUOTE IT VERBATIM in the acknowledgement (e.g. "Requesting access to the <aws_account_name> account so I can <do the task> \u2014 pinged an admin in #<notification_channel_name> to approve, will resume the moment it lands"); NEVER invent or guess a channel name from training (e.g. "#aws-approvals" is wrong if the configured channel is "#agt-approvals"). When notification_permalink is populated, you MAY append it as a markdown link (e.g. "[approval card](<notification_permalink>)") so the user can jump straight to it; otherwise omit. When notification_channel_name is absent, fall back to the channel-agnostic phrasing ("pinged an admin to approve"). Then save the grant_id and return control. NEVER expose broker mechanics to the user \u2014 phrases like "firing a broker grant", "requesting a grant", "broker grant", "grant_id", "secret_ref", "STS", "AssumeRole", or any aws_* tool name must not appear in user-facing messages. Also NEVER paste the grant UUID into user-facing prose (e.g. "request c7a0b7be-5e09-\u2026 is queued") \u2014 it is operator-only metadata and reads as noise to the user. Talk about the task and the AWS account, not the plumbing. The broker pushes the resolution to you via direct-chat the moment a human approves or denies. The notification body will include an "Original conversation:" line naming the channel/thread to reply in \u2014 when it arrives, post a one-line acknowledgement there in the same jargon-free style ("Approval came through for <aws_account_name> \u2014 kicking off <the task> now" on active; "Couldn\'t get access to <aws_account_name> for <the task>: <paraphrased reason> \u2014 let me know how you\'d like to proceed" on denied) BEFORE calling aws_get_credentials or doing the work, then complete the user\'s task in that same channel/thread (not direct-chat). Going silent between the request and the work loses the human-in-the-loop signal. Only check notification_status if you need to flag a setup issue to the user: "sent" means a human was paged; "failed" or "not_attempted" means no human was paged (typically channel_not_found because the approval-bot is not a member of the configured channel). When notification_status is "failed" and notification_bot_user_handle + notification_channel_name are populated, name them specifically in the user message: "I queued the access request for <aws_account_name> but couldn\'t notify the approver \u2014 please run `/invite @<notification_bot_user_handle>` in #<notification_channel_name> and let me know when done so I can re-fire". When the handle/channel-name aren\'t populated, fall back to the generic version: "I couldn\'t reach an approver for the <aws_account_name> account (the approval bot isn\'t in the configured channel) \u2014 please ping an admin manually, or fix the AWS approval channel in Team Settings". Keep the user-facing text jargon-free and paraphrased. grant_id and notification_failure_reason are operator/escalation-only metadata; if and only if you are escalating to an operator, append "(reference: <grant_id>, failure: <notification_failure_reason>)" to the operator-facing escalation note. aws_poll_grant exists as an escape hatch for explicit re-checks but the autonomous flow does not need it. RETRY CONTRACT (ENG-5166) \u2014 if you have a cached grant_id from a prior aws_request_access in this conversation and the user asks you again to do work that needs AWS access (retry, continue, "try again", or any new task on the same account), DO NOT short-circuit on "I already have a request in flight": ALWAYS call aws_poll_grant({grant_id}) FIRST to re-validate. If aws_poll_grant returns status="pending", keep waiting and tell the user "the previous access request is still pending an approver \u2014 will resume the moment it lands". If status="active" and expires_at is still in the future, reuse the existing grant (skip straight to aws_get_credentials). If status="denied", "expired", "failed", "revoked", or "active" past its expires_at, DROP the cached grant_id entirely and call aws_request_access AGAIN with a fresh request; surface "the previous access request was denied/expired/was revoked/expired \u2014 submitting a fresh one" to the user (paraphrase to match the terminal status, jargon-free, no grant_id leakage). NEVER claim "request already in flight" without having just polled and confirmed a genuinely-pending or unexpired-active status. The dispatcher considers status terminal once it leaves the pending state, so a cached grant_id is only trustworthy after a fresh poll.' + inventoryBlock + NO_DIRECT_HTTP_BLOCK,
21430
21782
  requestAccessShape,
21431
21783
  async (args) => {
21432
21784
  try {
@@ -21440,7 +21792,7 @@ server.tool(
21440
21792
  );
21441
21793
  server.tool(
21442
21794
  "aws_poll_grant",
21443
- "Single-shot status check for a grant. The broker pushes resolution updates to you via direct-chat automatically \u2014 you do NOT need to poll in the normal flow. Use this only as an escape hatch: explicit re-check after a notification, or if you suspect a notification was lost (e.g. you got `aws_request_access` returning pending more than ~5 minutes ago and have heard nothing). Returns { grant_id, status, secret_ref?, expires_at?, denial_reason? }.",
21795
+ "Single-shot status check for a grant. The broker pushes resolution updates to you via direct-chat automatically \u2014 you do NOT need to poll in the normal flow. Use this only as an escape hatch: explicit re-check after a notification, or if you suspect a notification was lost (e.g. you got `aws_request_access` returning pending more than ~5 minutes ago and have heard nothing). Returns { grant_id, status, secret_ref?, expires_at?, denial_reason? }." + NO_DIRECT_HTTP_BLOCK,
21444
21796
  pollGrantShape,
21445
21797
  async (args) => {
21446
21798
  try {
@@ -21453,7 +21805,7 @@ server.tool(
21453
21805
  );
21454
21806
  server.tool(
21455
21807
  "aws_release_access",
21456
- "Voluntarily release a grant before its TTL expires. Idempotent \u2014 safe to call on already-revoked or already-expired grants. Returns { grant_id, status }.",
21808
+ "Voluntarily release a grant before its TTL expires. Idempotent \u2014 safe to call on already-revoked or already-expired grants. Returns { grant_id, status }." + NO_DIRECT_HTTP_BLOCK,
21457
21809
  releaseAccessShape,
21458
21810
  async (args) => {
21459
21811
  try {
@@ -21466,7 +21818,7 @@ server.tool(
21466
21818
  );
21467
21819
  server.tool(
21468
21820
  "aws_get_credentials",
21469
- "Fetch the AWS credentials for an active grant. Call this AFTER aws_request_access returns active (auto-approve) OR after the resolution-notification arrives (route_to_approver). Returns { grant_id, expires_at, credentials: { access_key_id, secret_access_key, session_token } }. Use them by prefixing your bash invocation, e.g. `AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... aws ec2 describe-instances`. The grant must still be active and unexpired \u2014 409 if already released, 410 if past expires_at. Safe to call multiple times within the TTL window; the credentials don't change. Call aws_release_access when done.",
21821
+ "Fetch the AWS credentials for an active grant. Call this AFTER aws_request_access returns active (auto-approve) OR after the resolution-notification arrives (route_to_approver). Returns { grant_id, expires_at, credentials: { access_key_id, secret_access_key, session_token } }. Use them by prefixing your bash invocation, e.g. `AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... aws ec2 describe-instances`. The grant must still be active and unexpired \u2014 409 if already released, 410 if past expires_at. Safe to call multiple times within the TTL window; the credentials don't change. Call aws_release_access when done." + NO_DIRECT_HTTP_BLOCK,
21470
21822
  getCredentialsShape,
21471
21823
  async (args) => {
21472
21824
  try {
@@ -21479,7 +21831,7 @@ server.tool(
21479
21831
  );
21480
21832
  server.tool(
21481
21833
  "aws_list_accounts",
21482
- `List the AWS accounts your team currently has enrolled with the broker. Returns { accounts: [{ account_id, display_name, description?, default_region?, allowed_regions? }] }. The boot-time inventory injected into aws_request_access's description is frozen for this MCP process \u2014 call this tool when an account you expect to see is missing (e.g. an operator just re-enrolled one), when you want to confirm the canonical display_name / default_region / allowed_regions before firing aws_request_access, or to recover from "no enrolment" errors that hint the inventory may be stale. No arguments \u2014 agent_id is filled from the host MCP env (AGT_AGENT_ID). Cheap and idempotent; calls the same /aws/inventory endpoint that boots the description block.`,
21834
+ `List the AWS accounts your team currently has enrolled with the broker. Returns { accounts: [{ account_id, display_name, description?, default_region?, allowed_regions? }] }. The boot-time inventory injected into aws_request_access's description is frozen for this MCP process \u2014 call this tool when an account you expect to see is missing (e.g. an operator just re-enrolled one), when you want to confirm the canonical display_name / default_region / allowed_regions before firing aws_request_access, or to recover from "no enrolment" errors that hint the inventory may be stale. No arguments \u2014 agent_id is filled from the host MCP env (AGT_AGENT_ID). Cheap and idempotent; calls the same /aws/inventory endpoint that boots the description block.` + NO_DIRECT_HTTP_BLOCK,
21483
21835
  listAccountsShape,
21484
21836
  async () => {
21485
21837
  try {
@@ -21490,6 +21842,216 @@ server.tool(
21490
21842
  }
21491
21843
  }
21492
21844
  );
21845
+ server.tool(
21846
+ "gcp_describe_scope",
21847
+ "Return the team's resolved GCP policy ceiling for a project: max_ttl_seconds, auto-approved permissions, allowed resource patterns, and the permission denylist. Call before gcp_request_access if you're not sure what you're allowed to ask for \u2014 free, idempotent, writes nothing." + gcpInventoryBlock + NO_DIRECT_HTTP_BLOCK,
21848
+ gcpDescribeScopeShape,
21849
+ async (args) => {
21850
+ try {
21851
+ const result = await broker.gcpDescribeScope(args);
21852
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21853
+ } catch (err) {
21854
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21855
+ }
21856
+ }
21857
+ );
21858
+ server.tool(
21859
+ "gcp_preview_request",
21860
+ 'Dry-run a candidate GCP access request. Returns "auto_approve" / "route_to_approver" / "hard_deny" without dispatching anything or writing audit_log. Lets the agent self-tighten scope before triggering a human approval.' + NO_DIRECT_HTTP_BLOCK,
21861
+ gcpPreviewRequestShape,
21862
+ async (args) => {
21863
+ try {
21864
+ const result = await broker.gcpPreviewRequest(args);
21865
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21866
+ } catch (err) {
21867
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21868
+ }
21869
+ }
21870
+ );
21871
+ server.tool(
21872
+ "gcp_check_approval_channel",
21873
+ 'Verify the agent\'s Slack bot can post to the GCP-approval channel for a project BEFORE calling gcp_request_access. Returns { ok, channel?, bot_user_handle?, reason? }. Same failure-mode taxonomy as aws_check_approval_channel \u2014 branch on reason: "channel_not_found"/"not_in_channel" \u2192 ask user to `/invite @<bot_user_handle>` in the approvals channel; "archived" \u2192 ask admin to repoint; "no_approval_channel_configured" \u2192 ask user to configure one in Team Settings; "agent_slack_not_configured" \u2192 escalate to operator; "auth_failed"/"unknown" \u2192 escalate. ALWAYS call this on the FIRST gcp_request_access for a given project_id in a session. If ok=false, DO NOT call gcp_request_access. Same jargon-free, no-mechanics rules as gcp_request_access \u2014 talk about the GCP project and the invite action, never "broker", "grant", "MCP".' + gcpInventoryBlock + NO_DIRECT_HTTP_BLOCK,
21874
+ gcpCheckApprovalChannelShape,
21875
+ async (args) => {
21876
+ try {
21877
+ const result = await broker.gcpCheckApprovalChannel(args);
21878
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21879
+ } catch (err) {
21880
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21881
+ }
21882
+ }
21883
+ );
21884
+ server.tool(
21885
+ "gcp_request_access",
21886
+ 'Request scoped, TTL-bounded GCP credentials for the current task. CALL gcp_check_approval_channel FIRST on the FIRST request for a given project_id in a session \u2014 if it returns ok=false, do not call this tool, surface the invite-the-bot message instead. agent_id and run_id default from the host MCP env. Pass source_context whenever an inbound channel triggered the request so the resolution notification threads back to the original conversation. Returns { grant_id, status, secret_ref?, expires_at?, denial_reason?, notification_status?, notification_failure_reason?, notification_channel_name?, notification_channel_id?, notification_permalink?, notification_bot_user_handle? }. status="active" \u2192 credentials are ready, call gcp_get_credentials. status="denied" \u2192 request rejected, denial_reason explains. status="pending" \u2192 DO NOT poll, the broker pushes resolution via direct-chat. Post a brief jargon-free acknowledgement first. Quote notification_channel_name VERBATIM when populated ("pinged an admin in #<notification_channel_name> to approve, will resume the moment it lands"); never invent a channel name. Append the notification_permalink as a markdown link when present. Save the grant_id and return control. NEVER expose broker mechanics ("grant", "secret_ref", "IAM Credentials", "generateAccessToken", "MCP", any gcp_* tool name) in user-facing prose \u2014 talk about the task and the GCP project. When the resolution lands via direct-chat, post a one-line acknowledgement in the original channel/thread before calling gcp_get_credentials or starting the work; complete the task in that same channel/thread. Same notification_status branching as aws_request_access: "sent" \u2192 human paged; "failed"/"not_attempted" \u2192 no human paged, surface specific invite message when bot_user_handle + channel_name populated, generic recovery otherwise.' + gcpInventoryBlock + NO_DIRECT_HTTP_BLOCK,
21887
+ gcpRequestAccessShape,
21888
+ async (args) => {
21889
+ try {
21890
+ const result = await broker.gcpRequestAccess(args);
21891
+ const safe = { ...result, credentials: void 0 };
21892
+ return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] };
21893
+ } catch (err) {
21894
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21895
+ }
21896
+ }
21897
+ );
21898
+ server.tool(
21899
+ "gcp_poll_grant",
21900
+ "Single-shot status check for a GCP grant. The broker pushes resolution updates via direct-chat automatically \u2014 you do NOT need to poll in the normal flow. Escape hatch only: explicit re-check after a notification, or if you suspect a notification was lost. Returns { grant_id, status, secret_ref?, expires_at?, denial_reason? }." + NO_DIRECT_HTTP_BLOCK,
21901
+ gcpPollGrantShape,
21902
+ async (args) => {
21903
+ try {
21904
+ const result = await broker.gcpPollGrant(args);
21905
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21906
+ } catch (err) {
21907
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21908
+ }
21909
+ }
21910
+ );
21911
+ server.tool(
21912
+ "gcp_release_access",
21913
+ "Voluntarily release a GCP grant before its TTL expires. Idempotent \u2014 safe on already-revoked or already-expired grants. Returns { grant_id, status }. Note: in v1 this marks the grant revoked in the broker DB and tears down the secret_ref; the already-issued access_token remains valid in GCP until expires_at. For true in-flight kill, an operator can disable the agent service account via the team kill switch (see PRD \xA76.5)." + NO_DIRECT_HTTP_BLOCK,
21914
+ gcpReleaseAccessShape,
21915
+ async (args) => {
21916
+ try {
21917
+ const result = await broker.gcpReleaseAccess(args);
21918
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21919
+ } catch (err) {
21920
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21921
+ }
21922
+ }
21923
+ );
21924
+ server.tool(
21925
+ "gcp_get_credentials",
21926
+ "Fetch the GCP access token for an active grant. Call AFTER gcp_request_access returns active OR after the resolution-notification arrives. Returns { grant_id, expires_at, credentials: { access_token } }. Use by prefixing your bash invocation, e.g. `CLOUDSDK_AUTH_ACCESS_TOKEN=... gcloud storage ls gs://reports/` \u2014 gcloud, gsutil, and bq all pick it up automatically. The grant must still be active and unexpired \u2014 409 if already released, 410 if past expires_at. Safe to call multiple times within the TTL window. Call gcp_release_access when done." + NO_DIRECT_HTTP_BLOCK,
21927
+ gcpGetCredentialsShape,
21928
+ async (args) => {
21929
+ try {
21930
+ const result = await broker.gcpGetCredentials(args);
21931
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21932
+ } catch (err) {
21933
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21934
+ }
21935
+ }
21936
+ );
21937
+ server.tool(
21938
+ "gcp_list_accounts",
21939
+ `List the GCP projects your team currently has enrolled with the broker. Returns { projects: [{ project_id, display_name, description?, service_account_email? }] }. The boot-time inventory injected into gcp_request_access's description is frozen for this MCP process \u2014 call this tool when a project you expect to see is missing (e.g. an operator just re-enrolled one), to confirm the canonical display_name before firing gcp_request_access, or to recover from "no enrolment" errors that hint the inventory may be stale. No arguments \u2014 agent_id is filled from the host MCP env. Cheap and idempotent.` + NO_DIRECT_HTTP_BLOCK,
21940
+ gcpListAccountsShape,
21941
+ async () => {
21942
+ try {
21943
+ const result = await broker.gcpListInventory();
21944
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21945
+ } catch (err) {
21946
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21947
+ }
21948
+ }
21949
+ );
21950
+ server.tool(
21951
+ "supabase_describe_scope",
21952
+ "Return the team's resolved Supabase policy ceiling for a project: max_ttl_seconds, auto_approved_roles, allowed_schemas, and allow_write. Call before supabase_request_access if you're not sure what you're allowed to ask for \u2014 free, idempotent, writes nothing." + supabaseInventoryBlock + NO_DIRECT_SUPABASE_HTTP_BLOCK,
21953
+ supabaseDescribeScopeShape,
21954
+ async (args) => {
21955
+ try {
21956
+ const result = await broker.supabaseDescribeScope(args);
21957
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21958
+ } catch (err) {
21959
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21960
+ }
21961
+ }
21962
+ );
21963
+ server.tool(
21964
+ "supabase_preview_request",
21965
+ 'Dry-run a candidate Supabase access request. Returns "auto_approve" / "route_to_approver" / "hard_deny" without dispatching anything or writing audit_log. Lets the agent self-tighten scope before triggering a human approval.' + NO_DIRECT_SUPABASE_HTTP_BLOCK,
21966
+ supabasePreviewRequestShape,
21967
+ async (args) => {
21968
+ try {
21969
+ const result = await broker.supabasePreviewRequest(args);
21970
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21971
+ } catch (err) {
21972
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21973
+ }
21974
+ }
21975
+ );
21976
+ server.tool(
21977
+ "supabase_check_approval_channel",
21978
+ 'Verify the agent\'s Slack bot can post to the Supabase-approval channel for a project BEFORE calling supabase_request_access. Returns { ok, channel?, bot_user_handle?, reason? }. Same failure-mode taxonomy as aws_check_approval_channel \u2014 branch on reason: "channel_not_found"/"not_in_channel" \u2192 ask user to `/invite @<bot_user_handle>` in the approvals channel; "archived" \u2192 ask admin to repoint; "no_approval_channel_configured" \u2192 ask user to configure one in Team Settings; "agent_slack_not_configured" \u2192 escalate to operator; "auth_failed"/"unknown" \u2192 escalate. ALWAYS call this on the FIRST supabase_request_access for a given project_ref in a session. If ok=false, DO NOT call supabase_request_access. Same jargon-free, no-mechanics rules \u2014 talk about the Supabase project and the invite action, never "broker", "grant", "MCP".' + supabaseInventoryBlock + NO_DIRECT_SUPABASE_HTTP_BLOCK,
21979
+ supabaseCheckApprovalChannelShape,
21980
+ async (args) => {
21981
+ try {
21982
+ const result = await broker.supabaseCheckApprovalChannel(args);
21983
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
21984
+ } catch (err) {
21985
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
21986
+ }
21987
+ }
21988
+ );
21989
+ server.tool(
21990
+ "supabase_request_access",
21991
+ `Request a short-lived Supabase JWT to query a Supabase project. CALL supabase_check_approval_channel FIRST on the FIRST request for a given project_ref in a session \u2014 if ok=false, surface the invite-the-bot message instead. "service_role" always routes to a human approver; "authenticated" may auto-approve if in the team's policy ceiling. agent_id and run_id default from host MCP env. Pass source_context so the resolution notification threads back to the original conversation. Returns { grant_id, status, expires_at?, credentials?, denial_reason?, notification_status?, notification_channel_name?, notification_permalink? }. status="active" \u2192 credentials are ready, call supabase_get_credentials. status="denied" \u2192 request rejected. status="pending" \u2192 DO NOT poll; broker pushes resolution via direct-chat. Post a brief jargon-free acknowledgement first; quote notification_channel_name VERBATIM if present ("pinged an admin in #<notification_channel_name> to approve"). Save grant_id and return control. NEVER expose broker mechanics ("grant", "JWT", "MCP", any supabase_* tool name) in user-facing prose \u2014 talk about the task and the Supabase project. When resolution lands via direct-chat, post a one-line acknowledgement in the original channel/thread BEFORE calling supabase_get_credentials. RETRY CONTRACT: if you have a cached grant_id, ALWAYS call supabase_poll_grant first to re-validate before claiming "request in flight".` + supabaseInventoryBlock + NO_DIRECT_SUPABASE_HTTP_BLOCK,
21992
+ supabaseRequestAccessShape,
21993
+ async (args) => {
21994
+ try {
21995
+ const result = await broker.supabaseRequestAccess(args);
21996
+ const safe = { ...result, credentials: void 0 };
21997
+ return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] };
21998
+ } catch (err) {
21999
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
22000
+ }
22001
+ }
22002
+ );
22003
+ server.tool(
22004
+ "supabase_poll_grant",
22005
+ "Single-shot status check for a Supabase grant. The broker pushes resolution via direct-chat automatically \u2014 you do NOT need to poll in the normal flow. Escape hatch only. Returns { grant_id, status, expires_at?, denial_reason? }." + NO_DIRECT_SUPABASE_HTTP_BLOCK,
22006
+ supabasePollGrantShape,
22007
+ async (args) => {
22008
+ try {
22009
+ const result = await broker.supabasePollGrant(args);
22010
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
22011
+ } catch (err) {
22012
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
22013
+ }
22014
+ }
22015
+ );
22016
+ server.tool(
22017
+ "supabase_release_access",
22018
+ "Voluntarily release a Supabase grant before its TTL expires. Idempotent \u2014 safe on already-revoked or already-expired grants. Returns { grant_id, status }. Note: the already-issued JWT remains cryptographically valid until its exp claim \u2014 revocation marks it in the broker DB and prevents supabase_get_credentials from returning it again, but does not invalidate it at Supabase's JWT verification layer. Call this when done to signal intent; the TTL is the hard expiry." + NO_DIRECT_SUPABASE_HTTP_BLOCK,
22019
+ supabaseReleaseAccessShape,
22020
+ async (args) => {
22021
+ try {
22022
+ const result = await broker.supabaseReleaseAccess(args);
22023
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
22024
+ } catch (err) {
22025
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
22026
+ }
22027
+ }
22028
+ );
22029
+ server.tool(
22030
+ "supabase_get_credentials",
22031
+ 'Fetch the Supabase JWT for an active grant. Call AFTER supabase_request_access returns active OR after the resolution-notification arrives. Returns { grant_id, expires_at, credentials: { access_token, supabase_url, db_host, project_ref } }. Two usage patterns: (1) REST \u2014 curl -H "Authorization: Bearer <access_token>" <supabase_url>/rest/v1/<table>?select=*; (2) psql \u2014 psql "postgresql://postgres.<project_ref>:<access_token>@<db_host>:5432/postgres". The grant must still be active and unexpired \u2014 409 if already released, 410 if past expires_at. Safe to call multiple times within the TTL window. Call supabase_release_access when done.' + NO_DIRECT_SUPABASE_HTTP_BLOCK,
22032
+ supabaseGetCredentialsShape,
22033
+ async (args) => {
22034
+ try {
22035
+ const result = await broker.supabaseGetCredentials(args);
22036
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
22037
+ } catch (err) {
22038
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
22039
+ }
22040
+ }
22041
+ );
22042
+ server.tool(
22043
+ "supabase_list_projects",
22044
+ `List the Supabase projects your team currently has enrolled with the broker. Returns { projects: [{ project_ref, display_name, description?, supabase_url? }] }. The boot-time inventory injected into supabase_request_access's description is frozen for this MCP process \u2014 call this tool when a project you expect to see is missing, to confirm the canonical display_name, or to recover from "no enrolment" errors. No arguments \u2014 agent_id is filled from the host MCP env. Cheap and idempotent.` + NO_DIRECT_SUPABASE_HTTP_BLOCK,
22045
+ supabaseListProjectsShape,
22046
+ async () => {
22047
+ try {
22048
+ const result = await broker.supabaseListInventory();
22049
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
22050
+ } catch (err) {
22051
+ return { content: [{ type: "text", text: formatBrokerError(err) }], isError: true };
22052
+ }
22053
+ }
22054
+ );
21493
22055
  var transport = new StdioServerTransport();
21494
22056
  await server.connect(transport);
21495
22057
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@integrity-labs/cloud-broker",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Cloud Access Broker — MCP server that mints scoped, TTL-bounded cloud credentials per agent task. Ships AWS support (aws_request_access, aws_poll_grant, aws_release_access, aws_describe_scope, aws_preview_request, aws_get_credentials — STS AssumeRole under the hood); GCP, Azure, and Cloudflare land alongside in the same package as the broker grows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,9 @@
27
27
  "test": "vitest run",
28
28
  "clean": "rm -rf dist",
29
29
  "publish:templates": "bash cloudformation/publish.sh",
30
- "check-published": "bash cloudformation/check-published.sh"
30
+ "check-published": "bash cloudformation/check-published.sh",
31
+ "publish:terraform": "bash terraform/publish.sh",
32
+ "check-published-terraform": "bash terraform/check-published.sh"
31
33
  },
32
34
  "dependencies": {
33
35
  "@modelcontextprotocol/sdk": "1.27.1",