@zackbart/connecta 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -8,7 +8,7 @@ import { INTERNAL_READS, MERGE, READ_PERMISSIONS, RELEASE_PERMISSIONS } from "./
8
8
  import { errorMeanings } from "../../http/errors.js";
9
9
  import { ConnectorCallError } from "../../errors.js";
10
10
  import { guardedClient } from "../../http/client.js";
11
- import { captureClassifierFacts, StatusFailure } from "../../http/errors.js";
11
+ import { captureClassifierFacts, StatusFailure, } from "../../http/errors.js";
12
12
  import { resultSafety } from "../../http/safety.js";
13
13
  import { connect, get, operations } from "../../http/vendor.js";
14
14
  const repository = { owner: Schema.String, repo: Schema.String };
@@ -25,7 +25,7 @@ export function rejectedToken(status, headers) {
25
25
  const numbers = captureClassifierFacts(undefined, errors, headers)?.numbers;
26
26
  return !rateLimited(status, numbers?.["retryAfterMs"], numbers?.["remaining"]);
27
27
  }
28
- const AUTH = "Verify the GitHub App key, installation, repository selection and required permissions.";
28
+ const AUTH = "GitHub rejected the credential (HTTP 401). Verify the GitHub App ID, private key and installation.";
29
29
  const FAILED = "The GitHub API request failed; downstream error text is withheld.";
30
30
  function rate(input) {
31
31
  if (!rateLimited(input.status, input.retryAfterMs, input.numbers["remaining"]))
@@ -39,7 +39,99 @@ function rate(input) {
39
39
  advice: " Honor the retry timing before trying again.",
40
40
  };
41
41
  }
42
+ // GitHub's permission and scope headers are vendor-controlled text. A header
43
+ // that is not wholly the documented shape is dropped, never partly repeated,
44
+ // so nothing but a bounded permission or scope name reaches an agent.
45
+ const PERMISSION = /^[a-z][a-z0-9_]{0,63}=(?:read|write|admin)$/;
46
+ const OAUTH_SCOPE = /^[a-z][a-z0-9_]{0,31}(?::[a-z][a-z0-9_]{0,31})?$/;
47
+ const MAX_ALTERNATIVES = 4;
48
+ const MAX_NAMES = 8;
49
+ /** `a=read,b=read; c=read` lists alternative sets, each needing all its members. */
50
+ function acceptedPermissions(header) {
51
+ if (!header?.trim())
52
+ return undefined;
53
+ const sets = header.split(";").map((set) => set.split(",").map((name) => name.trim()));
54
+ if (sets.length > MAX_ALTERNATIVES ||
55
+ sets.some((set) => set.length > MAX_NAMES || set.some((name) => !PERMISSION.test(name))))
56
+ return undefined;
57
+ return sets.map((set) => set.join(" and ")).join(", or ");
58
+ }
59
+ function scopeNames(header) {
60
+ if (!header?.trim())
61
+ return undefined;
62
+ const names = header.split(",").map((name) => name.trim());
63
+ return names.length <= MAX_NAMES && names.every((name) => OAUTH_SCOPE.test(name)) ? names : undefined;
64
+ }
65
+ /** The accepted scopes this token does not already hold; an unreadable held list cannot subtract. */
66
+ function missingScopes(accepted, held) {
67
+ const needed = scopeNames(accepted);
68
+ if (!needed)
69
+ return undefined;
70
+ const has = scopeNames(held) ?? [];
71
+ const missing = needed.filter((name) => !has.includes(name));
72
+ return missing.length > 0 ? missing.join(", ") : undefined;
73
+ }
74
+ const WITHHELD = "its reason is withheld";
75
+ const WAIT = "After a burst of requests, wait a minute before one retry.";
76
+ const ALL_REFUSED = "If every GitHub call through this connection is refused, verify the App ID, private key and installation.";
77
+ const OTHER_CAUSES = "An unmarked secondary rate limit, an IP allow list or a repository or organization policy can also refuse a request with sufficient permissions, and repeated invalid credentials can lock out authentication with a 403.";
78
+ const REMEDIES = "A rate limit or lockout clears with time; a confirmed permission or repository gap needs the App owner and installation owner; a confirmed policy or IP restriction needs an administrator.";
79
+ /**
80
+ * A 403 establishes a refusal and not its cause: classification never reads
81
+ * GitHub's prose, and the permission headers describe the endpoint rather than
82
+ * this token. Each message says what is known, what is likely, then what to do.
83
+ */
84
+ function refused({ headers, operation, status }) {
85
+ // Only the App's own JWT is in play here, so no repository grant can explain it.
86
+ if (operation.template === "/app/installations")
87
+ return {
88
+ code: "auth_required",
89
+ retryable: false,
90
+ words: () => `GitHub refused the App's own request to list its installations (HTTP ${status}), before any repository permission applied; ${WITHHELD}. A lockout after repeated invalid credentials is likely, and an unmarked secondary rate limit, IP allow list or policy is possible. ${WAIT} If it persists, verify the GitHub App ID and private key.`,
91
+ };
92
+ const repository = operation.context?.["repository"];
93
+ const where = repository ? `for ${repository}` : "for this resource";
94
+ if (/^required(?:;|$)/i.test(headers["x-github-sso"] ?? ""))
95
+ return denied(`GitHub refused this request ${where}: the organization enforces SAML single sign-on and this credential is not authorized for it. An organization owner must authorize it; replacing the key will not change this.`);
96
+ const accepted = acceptedPermissions(headers["x-accepted-github-permissions"]);
97
+ const scopes = accepted ? undefined : missingScopes(headers["x-accepted-oauth-scopes"], headers["x-oauth-scopes"]);
98
+ const requested = operation.context?.["permissions"];
99
+ const need = accepted
100
+ ? ` This endpoint accepts ${accepted}.`
101
+ : scopes
102
+ ? ` This endpoint lists the OAuth scope ${scopes}, which this token does not report.`
103
+ : requested
104
+ ? ` This call requested ${requested}.`
105
+ : "";
106
+ return denied(`GitHub refused this request ${where} (HTTP ${status}); ${WITHHELD}.${need} The likely cause is access the App or its installation does not hold. ${OTHER_CAUSES} Report this as an access gap and continue with other repositories; do not reset the credential or switch tools on this result alone. ${WAIT} ${REMEDIES} ${ALL_REFUSED}`);
107
+ }
108
+ function denied(message) {
109
+ return { code: "provider_permission_denied", retryable: false, words: () => message };
110
+ }
111
+ /**
112
+ * GitHub documents a mint's 422 as a validation failure or spam protection, so
113
+ * the status alone never establishes a permission gap. `missing` is the positive
114
+ * evidence: requested permissions absent from GitHub's own installation record.
115
+ */
116
+ function refusedToken({ status, operation }) {
117
+ if ((status !== 422 && status !== 403) || !/^\/app\/installations\/\d+\/access_tokens$/.test(operation.template))
118
+ return undefined;
119
+ const repository = operation.context?.["repository"], requested = operation.context?.["permissions"], missing = operation.context?.["missing"];
120
+ const asked = `GitHub would not issue an installation token${repository ? ` for ${repository}` : ""}${requested ? ` with ${requested}` : ""} (HTTP ${status})`;
121
+ if (missing)
122
+ return denied(`${asked}. The installation does not hold ${missing}. Report this as an access gap; other reads may still work. The App owner must grant that permission and the installation owner must approve it; replacing the key will not add it.`);
123
+ if (status === 422)
124
+ return {
125
+ code: "connector_call_failed",
126
+ retryable: false,
127
+ words: () => `${asked}; ${WITHHELD}. GitHub documents this status for validation failures and spam protection, so a missing permission or repository is possible but not established. Report this failure; other reads may still work. Wait before one retry, and if it persists check the permissions and repositories granted to this installation.`,
128
+ };
129
+ return denied(`${asked}; ${WITHHELD}. A suspended installation, an IP allow list or policy, an unmarked secondary rate limit or a lockout after repeated invalid credentials can each cause this. Report this failure; other repositories may still work. ${WAIT} ${ALL_REFUSED}`);
130
+ }
42
131
  export const errors = {
132
+ headers: ["x-accepted-github-permissions", "x-accepted-oauth-scopes", "x-oauth-scopes", "x-github-sso"],
133
+ // An SSO challenge carries a long signed URL, read only as far as its keyword.
134
+ maxHeaderChars: 512,
43
135
  numericFacts: {
44
136
  retryAfterMs: {
45
137
  header: "retry-after",
@@ -79,8 +171,9 @@ export const errors = {
79
171
  }
80
172
  : undefined,
81
173
  rate,
174
+ refusedToken,
82
175
  ],
83
- detail: ({ status: code }) => code === 401 || code === 403
176
+ detail: ({ status: code }) => code === 401
84
177
  ? AUTH
85
178
  : code === 409
86
179
  ? "GitHub reported a conflict. Read the current state."
@@ -89,7 +182,7 @@ export const errors = {
89
182
  : FAILED,
90
183
  statuses: {
91
184
  401: () => ({ code: "auth_required", retryable: false }),
92
- 403: () => ({ code: "auth_required", retryable: false }),
185
+ 403: refused,
93
186
  404: () => ({ code: "connector_call_failed", retryable: false }),
94
187
  409: () => ({ code: "conflict", retryable: false, advice: " before resubmitting" }),
95
188
  },
@@ -144,8 +237,7 @@ export function githubConnection() {
144
237
  export const kit = {
145
238
  operations: operations(GitHubApi),
146
239
  pageSize: { default: 30, max: 100 },
147
- hatch: { name: GITHUB_HATCH.name, verdicts: GITHUB_HATCH.verdicts, paging: "argument" },
240
+ hatch: { name: GITHUB_HATCH.name, verdicts: GITHUB_HATCH.verdicts },
148
241
  errors: errorMeanings(errors),
149
242
  idempotencyKey: false,
150
- refusesWithheld: true,
151
243
  };
@@ -1,5 +1,6 @@
1
1
  import { array, keys, optionsOf } from "../../config-schema.js";
2
2
  import { ConnectorCallError } from "../../errors.js";
3
+ import { carryConnectorVocabulary } from "../../operation-vocabulary.js";
3
4
  import { delegatesEachCall, knownProviderFailure } from "../../provider-call.js";
4
5
  import { defineProvider, PROVIDER_COMMON } from "../../provider.js";
5
6
  import { kit } from "./api.js";
@@ -9,6 +10,7 @@ import { parseScopes, ScopePolicy } from "./lib/scope.js";
9
10
  import { skill } from "./skill.generated.js";
10
11
  /** One GitHub App, multiple owner installations, explicit org/repo grants. */
11
12
  export const github = defineProvider({
13
+ blocks: { keys: ["writes"], select: (options) => (options.readOnly ? [] : ["writes"]) },
12
14
  name: "github",
13
15
  title: "GitHub",
14
16
  kind: "api",
@@ -36,7 +38,7 @@ export const github = defineProvider({
36
38
  context: [
37
39
  `Act as the GitHub App. Reachable configuration: ${policy.summary()}.`,
38
40
  `Purpose: ${options.purpose}`,
39
- "The App identity is already configured. Installation access is resolved on use; ask the operator to repair configuration only after an auth failure.",
41
+ "The App identity is already configured. Installation access is resolved on use; ask the operator to repair configuration only after an auth or permission failure.",
40
42
  ...(options.readOnly === true ? ["This connection is read-only; every write is refused."] : []),
41
43
  ],
42
44
  summary: "Load for GitHub repository searches, releases and guarded merges.",
@@ -50,7 +52,7 @@ export const github = defineProvider({
50
52
  }
51
53
  : undefined;
52
54
  // Each call goes to its REST boundary, whose write-outcome verdict stands.
53
- return delegatesEachCall({
55
+ const connector = delegatesEachCall({
54
56
  id,
55
57
  title: options.title ?? "GitHub",
56
58
  // Preserve the REST tools' existing MCP result envelope.
@@ -79,5 +81,8 @@ export const github = defineProvider({
79
81
  return { state: "ok", message: "GitHub App configured; installation access is resolved lazily on use." };
80
82
  },
81
83
  });
84
+ // Search reads hatch operations through this outer object, not the REST one.
85
+ carryConnectorVocabulary(rest, connector);
86
+ return connector;
82
87
  },
83
88
  });
@@ -7,6 +7,8 @@ export interface GitHubApp {
7
7
  privateKey?: string;
8
8
  }
9
9
  export type Permissions = Readonly<Record<string, "read" | "write">>;
10
+ /** Reviewed permission names only, in GitHub's own `name=access` spelling. */
11
+ export declare const permissionWords: (permissions: Permissions) => string;
10
12
  export declare class AppAuth {
11
13
  readonly app: GitHubApp;
12
14
  readonly policy: ScopePolicy;
@@ -21,7 +23,7 @@ export declare class AppAuth {
21
23
  constructor(app: GitHubApp, policy: ScopePolicy);
22
24
  private key;
23
25
  /** `write` sends the request once: a failure that may follow the change is not retryable. */
24
- json(request: GuardedRequest, bearer: string, ctx: ConnectorContext, write?: boolean): Promise<unknown>;
26
+ json(request: GuardedRequest, bearer: string, ctx: ConnectorContext, write?: boolean, context?: Readonly<Record<string, string>>): Promise<unknown>;
25
27
  private invalidate;
26
28
  rejectToken(bearer: string): Promise<void>;
27
29
  private installation;
@@ -5,10 +5,15 @@ import { errors, rejectedToken } from "../api.js";
5
5
  import { appJwt, privateKeyDer } from "./key.js";
6
6
  import { target } from "./scope.js";
7
7
  import { sentSecretsFor } from "../../../sent-secrets.js";
8
+ /** Reviewed permission names only, in GitHub's own `name=access` spelling. */
9
+ export const permissionWords = (permissions) => Object.entries(permissions)
10
+ .map(([name, access]) => `${name}=${access}`)
11
+ .join(" and ");
8
12
  const MAX_ENTRIES = 256;
9
13
  const REFRESH_MARGIN = 60_000;
10
14
  const INSTALLATION_TTL = 300_000;
11
15
  const REPOSITORY_TTL = 300_000;
16
+ const MAX_NAMED = 3;
12
17
  function boundedSet(map, key, value) {
13
18
  if (!map.has(key) && map.size >= MAX_ENTRIES)
14
19
  map.delete(map.keys().next().value);
@@ -17,6 +22,23 @@ function boundedSet(map, key, value) {
17
22
  function record(value) {
18
23
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
19
24
  }
25
+ const ACCESS = { read: 1, write: 2, admin: 3 };
26
+ /** Well-formed entries only; an empty or malformed record proves nothing. */
27
+ function heldPermissions(value) {
28
+ const held = Object.entries(record(value)).filter((entry) => typeof entry[1] === "string" && ACCESS[entry[1]] !== undefined);
29
+ return held.length > 0 ? Object.fromEntries(held) : undefined;
30
+ }
31
+ /**
32
+ * Requested permissions the installation's own record lacks, the only evidence
33
+ * that names a refused mint a permission gap. Metadata read is implicit for every App.
34
+ */
35
+ function missingPermissions(installation, requested) {
36
+ const held = installation.permissions;
37
+ if (!held)
38
+ return undefined;
39
+ const missing = Object.entries(requested).filter(([name, access]) => name !== "metadata" && (ACCESS[held[name] ?? ""] ?? 0) < ACCESS[access]);
40
+ return missing.length > 0 ? Object.fromEntries(missing) : undefined;
41
+ }
20
42
  export class AppAuth {
21
43
  app;
22
44
  policy;
@@ -83,7 +105,7 @@ export class AppAuth {
83
105
  };
84
106
  }
85
107
  /** `write` sends the request once: a failure that may follow the change is not retryable. */
86
- async json(request, bearer, ctx, write = false) {
108
+ async json(request, bearer, ctx, write = false, context) {
87
109
  ctx.signal?.throwIfAborted();
88
110
  return this.send({
89
111
  classification: write ? "write" : "read",
@@ -99,7 +121,7 @@ export class AppAuth {
99
121
  status: response.status,
100
122
  headers: response.headers,
101
123
  body: undefined,
102
- operation: { method: request.method, template: request.path },
124
+ operation: { method: request.method, template: request.path, ...(context ? { context } : {}) },
103
125
  }), captureClassifierFacts(undefined, errors, response.headers));
104
126
  throw toConnectorCallError(failure, errors, {
105
127
  classification: write ? "write" : "read",
@@ -152,13 +174,15 @@ export class AppAuth {
152
174
  if (typeof account.login !== "string" || account.login.toLowerCase() !== owner)
153
175
  continue;
154
176
  if (!Number.isSafeInteger(row.id) || Number(row.id) <= 0 || row.suspended_at != null) {
155
- throw new ConnectorCallError("auth_required", "Install or unsuspend the GitHub App on the configured owner.");
177
+ throw new ConnectorCallError("provider_permission_denied", "Install or unsuspend the GitHub App on the configured owner. The App credential works, so resetting it will not help.");
156
178
  }
179
+ const permissions = heldPermissions(row.permissions);
157
180
  found = {
158
181
  id: Number(row.id),
159
182
  owner,
160
183
  all: row.repository_selection === "all",
161
184
  organization: account.type === "Organization",
185
+ ...(permissions ? { permissions } : {}),
162
186
  expires: Date.now() + INSTALLATION_TTL,
163
187
  };
164
188
  }
@@ -168,9 +192,9 @@ export class AppAuth {
168
192
  throw new ConnectorCallError("connector_call_failed", "The GitHub installation list exceeded its paging bound; no partial mapping was cached.");
169
193
  }
170
194
  if (!found)
171
- throw new ConnectorCallError("auth_required", "Install the GitHub App on the configured owner and grant the configured repositories.");
195
+ throw new ConnectorCallError("provider_permission_denied", "Install the GitHub App on the configured owner and grant the configured repositories. The App credential works, so resetting it will not help.");
172
196
  if (this.policy.repositories(owner) === undefined && (!found.all || !found.organization)) {
173
- throw new ConnectorCallError("auth_required", "Org scopes require an organization installation with All repositories selected, including future repositories.");
197
+ throw new ConnectorCallError("provider_permission_denied", "Org scopes require an organization installation with All repositories selected, including future repositories.");
174
198
  }
175
199
  ctx.signal?.throwIfAborted();
176
200
  // Compare-and-set by entry identity. A slower request cannot overwrite a
@@ -180,7 +204,7 @@ export class AppAuth {
180
204
  if (this.policy.repositories(owner) === undefined) {
181
205
  const bound = this.orgInstallations.get(owner);
182
206
  if (bound !== undefined && bound !== resolved.id)
183
- throw new ConnectorCallError("auth_required", "The organization grant is bound to a different installation. Reconfigure after reviewing the replacement installation.");
207
+ throw new ConnectorCallError("provider_permission_denied", "The organization grant is bound to a different installation. Reconfigure after reviewing the replacement installation.");
184
208
  this.orgInstallations.set(owner, resolved.id);
185
209
  }
186
210
  if (this.installations.get(cacheKey) === previous)
@@ -196,7 +220,7 @@ export class AppAuth {
196
220
  // GitHub requires an installation token for /installation/repositories.
197
221
  // Mint with the App JWT, metadata only, and never give this resolver token
198
222
  // to the hosted server or a caller-selected REST operation.
199
- const resolver = await this.mint(owner, installation, key, undefined, { metadata: "read" }, ctx, "resolver");
223
+ const resolver = await this.mint(owner, installation, key, undefined, { metadata: "read" }, ctx, owner, "resolver");
200
224
  const found = new Map();
201
225
  const configured = this.policy.scopes.filter((scope) => scope.repo?.startsWith(`${owner}/`));
202
226
  for (let page = 1; page <= 100; page++) {
@@ -228,7 +252,7 @@ export class AppAuth {
228
252
  if (!this.grants.has(grant.repo) && repo)
229
253
  this.grants.set(grant.repo, repo.id);
230
254
  if (!this.grants.has(grant.repo))
231
- throw new ConnectorCallError("auth_required", "A configured repository grant could not be bound in this installation.");
255
+ throw new ConnectorCallError("provider_permission_denied", `The configured grant ${grant.repo} could not be bound in this installation: select that repository for the GitHub App, or remove the grant. Until then this owner's repositories stay unavailable. The App credential works, so resetting it will not help.`);
232
256
  if (!repo) {
233
257
  const renamed = [...found.values()].find((candidate) => candidate.id === this.grants.get(grant.repo));
234
258
  if (renamed)
@@ -245,7 +269,7 @@ export class AppAuth {
245
269
  }
246
270
  const resolved = this.repositories.get(cacheKey);
247
271
  if (!resolved || resolved.expires <= Date.now())
248
- throw new ConnectorCallError("auth_required", "The repository is not reachable in the configured installation. Verify its selection or current name.");
272
+ throw new ConnectorCallError("provider_permission_denied", "This repository is not reachable in the configured installation. Verify its selection or current name; other repositories may still work. The App credential works, so resetting it will not help.");
249
273
  return resolved;
250
274
  }
251
275
  authorizeId(repo, permissions) {
@@ -281,9 +305,11 @@ export class AppAuth {
281
305
  ids.push(repo.id);
282
306
  }
283
307
  const repositorySet = names ? [...new Set(ids)].sort((a, b) => a - b) : undefined;
284
- return this.mint(owner, installation, key, repositorySet, permissions, ctx);
308
+ // Each name is now bound to a repository this installation returned.
309
+ const named = names && names.length <= MAX_NAMED ? names.map((name) => `${owner}/${name}`).join(", ") : owner;
310
+ return this.mint(owner, installation, key, repositorySet, permissions, ctx, named);
285
311
  }
286
- async mint(owner, installation, key, repositorySet, permissions, ctx, purpose = "operation") {
312
+ async mint(owner, installation, key, repositorySet, permissions, ctx, repository, purpose = "operation") {
287
313
  const permissionSet = Object.fromEntries(Object.entries(permissions).sort(([a], [b]) => a.localeCompare(b)));
288
314
  const cacheKey = JSON.stringify([key.fingerprint, installation.id, repositorySet ?? "all", permissionSet, purpose]);
289
315
  const previous = this.tokens.get(cacheKey);
@@ -296,6 +322,7 @@ export class AppAuth {
296
322
  catch {
297
323
  throw new ConnectorCallError("auth_required", "The GitHub App key could not sign an RS256 JWT. Replace the key.");
298
324
  }
325
+ const missing = missingPermissions(installation, permissionSet);
299
326
  let response;
300
327
  try {
301
328
  response = record(await this.json({
@@ -303,7 +330,11 @@ export class AppAuth {
303
330
  method: "POST",
304
331
  path: `/app/installations/${installation.id}/access_tokens`,
305
332
  body: { permissions: permissionSet, ...(repositorySet ? { repository_ids: repositorySet } : {}) },
306
- }, jwt, ctx));
333
+ }, jwt, ctx, false, {
334
+ repository,
335
+ permissions: permissionWords(permissionSet),
336
+ ...(missing ? { missing: permissionWords(missing) } : {}),
337
+ }));
307
338
  }
308
339
  catch (error) {
309
340
  // Re-resolve on the next call, never replay this call. Fence a slow
@@ -23,6 +23,7 @@ export function contentsPath(path) {
23
23
  workflowPath(relative);
24
24
  return match[1] + encodeURIComponent(relative);
25
25
  }
26
+ const COMMIT_SUBRESOURCE = /^(\/repos\/[^/]+\/[^/]+\/commits\/)([^/]+)(\/(?:check-runs|check-suites|status|statuses))$/;
26
27
  // Prefer the deployed coverage for natural queries. Explicit paths and queries
27
28
  // without reviewed matches retain full-pin refusal discovery. Metadata, contract
28
29
  // lookup, validation and the manifest still use the complete immutable pin.
@@ -35,6 +36,15 @@ class RepositoryIndex extends OperationIndex {
35
36
  const match = super.resolve(method, `${path}/root`);
36
37
  return { ...match, params: { ...match.params, path: "" } };
37
38
  }
39
+ // The shared index admits an encoded slash only in a final parameter, so a
40
+ // branch like feature%2Fx is checked as commits/{ref} itself, then bound to
41
+ // its reviewed CI read. Nothing here widens the path the request is sent to.
42
+ const nested = method === "GET" ? COMMIT_SUBRESOURCE.exec(path) : null;
43
+ if (nested && /%2f/i.test(nested[2])) {
44
+ const { params } = super.resolve(method, `${nested[1]}${nested[2]}`);
45
+ const match = super.resolve(method, `${nested[1]}ref${nested[3]}`);
46
+ return { ...match, params: { ...match.params, ref: params.ref } };
47
+ }
38
48
  try {
39
49
  return super.resolve(method, contentsPath(path));
40
50
  }
@@ -55,7 +65,7 @@ class RepositoryIndex extends OperationIndex {
55
65
  if (param.name === "path")
56
66
  param.description =
57
67
  "Canonical relative file or directory path; nested paths such as src/index.ts are accepted. For the root directory use /repos/{owner}/{repo}/contents without a final path.";
58
- if (["/repos/{owner}/{repo}/commits/{ref}", "/repos/{owner}/{repo}/compare/{basehead}"].includes(op.path))
68
+ if (/^\/repos\/\{owner\}\/\{repo\}\/(?:commits\/\{ref\}(?:\/[a-z-]+)?|compare\/\{basehead\})$/.test(op.path))
59
69
  for (const param of contract.parameters)
60
70
  if (param.name === "ref" || param.name === "basehead")
61
71
  param.description =
@@ -32,6 +32,14 @@ export const READ_PERMISSIONS = {
32
32
  "GET /repos/{owner}/{repo}/actions/runs/{run_id}": { actions: "read" },
33
33
  "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs": { actions: "read" },
34
34
  "GET /repos/{owner}/{repo}/actions/jobs/{job_id}": { actions: "read" },
35
+ "GET /repos/{owner}/{repo}/commits/{ref}/check-runs": { checks: "read" },
36
+ "GET /repos/{owner}/{repo}/commits/{ref}/check-suites": { checks: "read" },
37
+ "GET /repos/{owner}/{repo}/check-runs/{check_run_id}": { checks: "read" },
38
+ "GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations": { checks: "read" },
39
+ "GET /repos/{owner}/{repo}/check-suites/{check_suite_id}": { checks: "read" },
40
+ "GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs": { checks: "read" },
41
+ "GET /repos/{owner}/{repo}/commits/{ref}/status": { statuses: "read" },
42
+ "GET /repos/{owner}/{repo}/commits/{ref}/statuses": { statuses: "read" },
35
43
  };
36
44
  export const RELEASE_PERMISSIONS = {
37
45
  "POST /repos/{owner}/{repo}/releases": { contents: "write" },
@@ -98,6 +98,45 @@ const RESPONSE_PATHS = {
98
98
  "redact": [],
99
99
  "keep": ["commit.verification.signature"],
100
100
  },
101
+ "GET /repos/{owner}/{repo}/check-suites/{check_suite_id}": {
102
+ "redact": ["repository.temp_clone_token"],
103
+ "keep": [
104
+ "repository.security_and_analysis.advanced_security",
105
+ "repository.security_and_analysis.secret_scanning",
106
+ "repository.security_and_analysis.secret_scanning_ai_detection",
107
+ "repository.security_and_analysis.secret_scanning_delegated_alert_dismissal",
108
+ "repository.security_and_analysis.secret_scanning_delegated_bypass",
109
+ "repository.security_and_analysis.secret_scanning_delegated_bypass_options",
110
+ "repository.security_and_analysis.secret_scanning_delegated_bypass_options.reviewers",
111
+ "repository.security_and_analysis.secret_scanning_non_provider_patterns",
112
+ ],
113
+ },
114
+ "GET /repos/{owner}/{repo}/commits/{ref}/check-suites": {
115
+ "redact": ["check_suites[].repository.temp_clone_token"],
116
+ "keep": [
117
+ "check_suites[].repository.security_and_analysis.advanced_security",
118
+ "check_suites[].repository.security_and_analysis.secret_scanning",
119
+ "check_suites[].repository.security_and_analysis.secret_scanning_ai_detection",
120
+ "check_suites[].repository.security_and_analysis.secret_scanning_delegated_alert_dismissal",
121
+ "check_suites[].repository.security_and_analysis.secret_scanning_delegated_bypass",
122
+ "check_suites[].repository.security_and_analysis.secret_scanning_delegated_bypass_options",
123
+ "check_suites[].repository.security_and_analysis.secret_scanning_delegated_bypass_options.reviewers",
124
+ "check_suites[].repository.security_and_analysis.secret_scanning_non_provider_patterns",
125
+ ],
126
+ },
127
+ "GET /repos/{owner}/{repo}/commits/{ref}/status": {
128
+ "redact": ["repository.temp_clone_token"],
129
+ "keep": [
130
+ "repository.security_and_analysis.advanced_security",
131
+ "repository.security_and_analysis.secret_scanning",
132
+ "repository.security_and_analysis.secret_scanning_ai_detection",
133
+ "repository.security_and_analysis.secret_scanning_delegated_alert_dismissal",
134
+ "repository.security_and_analysis.secret_scanning_delegated_bypass",
135
+ "repository.security_and_analysis.secret_scanning_delegated_bypass_options",
136
+ "repository.security_and_analysis.secret_scanning_delegated_bypass_options.reviewers",
137
+ "repository.security_and_analysis.secret_scanning_non_provider_patterns",
138
+ ],
139
+ },
101
140
  "GET /repos/{owner}/{repo}/compare/{basehead}": {
102
141
  "redact": [],
103
142
  "keep": [
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## Choose the target\n\nAct as the configured GitHub App. `list_scopes` identifies its configured grants\nand reachable repositories. An exact repository grant can narrow its parent\norganization grant. Organization access covers repository operations, including\nfuture repositories in the installation, rather than organization administration.\nA repository rename keeps its ID-bound grant; a replacement at the old name does\nnot inherit it. A transfer cannot use the old installation's token.\n\nUse `search_scoped` for literal-word data search across installations. Each item\ncarries its partition and verified repository. Search completeness can be false\nat GitHub's search limit; narrow the selected scopes or terms when that matters.\nInstallation tokens do not override GitHub's own code-search restrictions.\nKeep the same `limit` with every named-list cursor. Omission uses 30, so a\ncursor issued with limit 30 can continue without an explicit limit.\n\n## Join the right identifiers\n\nAn issue or pull request number belongs to one owner/repository pair; a numeric\nrecord ID is a different identifier. An issue-list response can contain pull\nrequests: the `pull_request` property distinguishes them. Issue IDs returned for\npull requests are not necessarily the PR IDs from the pull-request endpoint.\nReviews join by pull request number; their `commit_id` identifies the reviewed\nrevision. Compare review and head commit IDs when deciding whether feedback is\ncurrent. Issue comments and pull-request review comments are different resources.\n\nRead files at an explicit Git ref when a stable revision matters. Nested contents\npaths are relative: `/repos/alice/widget/contents/lib/index.ts` reads that file,\nand `/repos/alice/widget/contents` lists the root directory. No dot segments,\nempty segments, backslashes or encoded traversal are accepted. In `commits/{ref}`\nand `compare/{basehead}`, encode branch-name slashes as `%2F`, for example\n`commits/feature%2Fx` or `compare/main...feature%2Fx`. Refs stay inside the selected\nrepository; owner-prefixed object refs and cross-repository compares are unavailable.\nFor a PR list, use `head:\"alice:feature/x\"`: the required `login:branch` filter\nselects this repository's PRs and never reads the login's repository. A bare\n`head` branch is refused because GitHub silently ignores it. `base` stays a branch name.\nContents are\nbase64 encoded; decode the inline content before interpreting it. Directory\nlistings have a vendor cap and are not recursive. Missing inline content or a\nmissing patch does not establish an empty file or an unchanged diff. Raw diff,\nlog/archive downloads, secret/admin operations and arbitrary GraphQL are unavailable.\n\n## Release and merge semantics\n\nPublishing a draft release updates that release's `draft` to false. Deleting a\nrelease does not delete its Git tag. Resolve the intended repository and existing\nrelease before changing it; a tag name is not a numeric release ID.\n\nUse `merge_pull_request` for merges. It requires the expected head SHA and a\nsame-repository head and base, plus explicit workflow-write access. GitHub's\natomic SHA guard binds the head rather than the base: a concurrent base retarget\ncan introduce workflow changes after the preflight. A response with `merged:false`\nis a failed merge, not a completed one. Inspect current PR state before a new attempt.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `github_api_details`, `github_api_read`, `github_api_search`, `list_scopes`, `search_scoped`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 30, at most 100). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `github_api_search` finds this API's operations and reads no account data, `github_api_details` shows one operation's contract, and `github_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. `github_api_write` calls a reviewed write; the host may ask the user to confirm it. An operation without a reviewed verdict is refused. 5 operations are refused by design; use `github_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart. `conflict` means: HTTP 409 is a conflict; a merged:false response and the expected-SHA/same-repository preflight also refuse a merge.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## Choose the target\n\nAct as the configured GitHub App. `list_scopes` identifies its configured grants\nand reachable repositories. An exact repository grant can narrow its parent\norganization grant. Organization access covers repository operations, including\nfuture repositories in the installation, rather than organization administration.\nA repository rename keeps its ID-bound grant; a replacement at the old name does\nnot inherit it. A transfer cannot use the old installation's token.\n\nUse `search_scoped` for literal-word data search across installations. Each item\ncarries its partition and verified repository. Search completeness can be false\nat GitHub's search limit; narrow the selected scopes or terms when that matters.\nInstallation tokens do not override GitHub's own code-search restrictions.\nKeep the same `limit` with every named-list cursor. Omission uses 30, so a\ncursor issued with limit 30 can continue without an explicit limit.\n\n## Join the right identifiers\n\nAn issue or pull request number belongs to one owner/repository pair; a numeric\nrecord ID is a different identifier. An issue-list response can contain pull\nrequests: the `pull_request` property distinguishes them. Issue IDs returned for\npull requests are not necessarily the PR IDs from the pull-request endpoint.\nReviews join by pull request number; their `commit_id` identifies the reviewed\nrevision. Compare review and head commit IDs when deciding whether feedback is\ncurrent. Issue comments and pull-request review comments are different resources.\n\nRead files at an explicit Git ref when a stable revision matters. Nested contents\npaths are relative: `/repos/alice/widget/contents/lib/index.ts` reads that file,\nand `/repos/alice/widget/contents` lists the root directory. No dot segments,\nempty segments, backslashes or encoded traversal are accepted. In `commits/{ref}`\nand `compare/{basehead}`, encode branch-name slashes as `%2F`, for example\n`commits/feature%2Fx` or `compare/main...feature%2Fx`. Refs stay inside the selected\nrepository; owner-prefixed object refs and cross-repository compares are unavailable.\nFor a PR list, use `head:\"alice:feature/x\"`: the required `login:branch` filter\nselects this repository's PRs and never reads the login's repository. A bare\n`head` branch is refused because GitHub silently ignores it. `base` stays a branch name.\nContents are\nbase64 encoded; decode the inline content before interpreting it. Directory\nlistings have a vendor cap and are not recursive. Missing inline content or a\nmissing patch does not establish an empty file or an unchanged diff. Raw diff,\nlog/archive downloads, secret/admin operations and arbitrary GraphQL are unavailable.\n\n## Read CI state\n\nA commit's CI lives in two systems, so read both: `commits/{ref}/check-runs` holds\nActions and App checks, and `commits/{ref}/status` holds older commit statuses. A\nstatus of `pending` with no statuses means none were posted, not that CI is running.\nCheck-run annotations give failing paths and lines, since log downloads are unavailable.\n\n## When access is refused\n\n`provider_permission_denied` is a refusal: usually a gap in the App's access to that\nrepository or permission, though a policy, an unmarked rate limit or a lockout can\nalso cause it. Report the gap and continue; other repositories may still work. Do not\nreset the credential or switch tools on this alone. `auth_required` is the failure\nthat points at the credential itself.\n\n<!-- when: writes -->\n## Release and merge semantics\n\nPublishing a draft release updates that release's `draft` to false. Deleting a\nrelease does not delete its Git tag. Resolve the intended repository and existing\nrelease before changing it; a tag name is not a numeric release ID.\n\nUse `merge_pull_request` for merges. It requires the expected head SHA and a\nsame-repository head and base, plus explicit workflow-write access. GitHub's\natomic SHA guard binds the head rather than the base: a concurrent base retarget\ncan introduce workflow changes after the preflight. A response with `merged:false`\nis a failed merge, not a completed one. Inspect current PR state before a new attempt.\n<!-- end -->\n\n## Conventions\n\n- **Hatch:** `github_api_search` finds operations, `github_api_details` shows one contract, `github_api_read` calls a reviewed read and `github_api_write` a reviewed write. 5 operations are refused by design; use `github_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Errors:** An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart. `conflict` means: HTTP 409 is a conflict; a merged:false response and the expected-SHA/same-repository preflight also refuse a merge.",
4
4
  "instructionsHeading": "Repository instructions"
5
5
  };
@@ -16,6 +16,7 @@ import { tracksEveryRequest } from "../../provider-call.js";
16
16
  import { checked } from "../_shared/rest/kit.js";
17
17
  import { withholdEmbeddedUrlsIn } from "../_shared/rest/value-safety.js";
18
18
  import { githubConnection, rejectedToken } from "./api.js";
19
+ import { permissionWords } from "./lib/auth.js";
19
20
  import { contentsPath, githubIndex, GITHUB_HATCH } from "./lib/discovery.js";
20
21
  import { READ_PERMISSIONS, RELEASE_PERMISSIONS, hatchRefusal } from "./lib/operations.js";
21
22
  import { target, workflowPath, isPullHead } from "./lib/scope.js";
@@ -231,6 +232,10 @@ export function restConnector(id, auth, policy) {
231
232
  void auth.rejectToken(bearer);
232
233
  })));
233
234
  });
235
+ /** What this request's failure wording may state: a bound repository and the reviewed permissions. */
236
+ const refusing = (repository, permissions) => ({
237
+ context: { repository, permissions: permissionWords(permissions) },
238
+ });
234
239
  const cursorArgs = (args) => {
235
240
  const { fields: _fields, cursor: _cursor, ...rest } = args;
236
241
  return { ...rest, grants: policy.scopes };
@@ -299,7 +304,7 @@ export function restConnector(id, auth, policy) {
299
304
  }
300
305
  const owner = yield* checked(() => policy.owner(args.owner)), ctx = yield* contextOf;
301
306
  const token = yield* authCall(ctx, (view) => auth.token(owner, undefined, { metadata: "read" }, view));
302
- const answer = yield* send({ method: "GET", path: "/installation/repositories", query: { page, per_page: limit } }, token, { responseHeaders: ["link"] });
307
+ const answer = yield* send({ method: "GET", path: "/installation/repositories", query: { page, per_page: limit } }, token, { responseHeaders: ["link"], ...refusing(owner, { metadata: "read" }) });
303
308
  const body = row(answer.data);
304
309
  if (!Array.isArray(body.repositories))
305
310
  return yield* checked(() => {
@@ -389,7 +394,7 @@ export function restConnector(id, auth, policy) {
389
394
  ? { issues: "read" }
390
395
  : { pull_requests: "read" };
391
396
  const ctx = yield* contextOf, token = yield* authCall(ctx, (view) => auth.token(partition.owner, partition.repos, permissions, view));
392
- const answer = yield* send({ method: "GET", path: `/search/${kind}`, query: { q: query, page, per_page: limit } }, token, { responseHeaders: ["link"] });
397
+ const answer = yield* send({ method: "GET", path: `/search/${kind}`, query: { q: query, page, per_page: limit } }, token, { responseHeaders: ["link"], ...refusing(partition.owner, permissions) });
393
398
  const body = row(answer.data);
394
399
  if (!Array.isArray(body.items))
395
400
  return yield* checked(() => {
@@ -447,6 +452,7 @@ export function restConnector(id, auth, policy) {
447
452
  const pr = row((yield* send({ method: "GET", path: `${repoPath(t)}/pulls/${args.pull_number}` }, reader, {
448
453
  step: "pull",
449
454
  effect: "read",
455
+ ...refusing(`${t.owner}/${t.repo}`, { pull_requests: "read" }),
450
456
  })).data);
451
457
  const head = row(pr.head), base = row(pr.base);
452
458
  if (head.sha !== args.sha ||
@@ -455,12 +461,13 @@ export function restConnector(id, auth, policy) {
455
461
  return yield* checked(() => {
456
462
  throw new ConnectorCallError("conflict", "Merge requires the expected head SHA and a same-repository head and base. Reread the pull request.");
457
463
  });
458
- const token = yield* authCall(ctx, (view) => auth.repo(t, { contents: "write", pull_requests: "write", workflows: "write" }, view));
464
+ const mergeAccess = { contents: "write", pull_requests: "write", workflows: "write" };
465
+ const token = yield* authCall(ctx, (view) => auth.repo(t, mergeAccess, view));
459
466
  return row((yield* send({
460
467
  method: "PUT",
461
468
  path: `${repoPath(t)}/pulls/${args.pull_number}/merge`,
462
469
  body: { sha: args.sha, merge_method: args.merge_method ?? "merge" },
463
- }, token)).data);
470
+ }, token, refusing(`${t.owner}/${t.repo}`, mergeAccess))).data);
464
471
  }),
465
472
  });
466
473
  const binding = {
@@ -526,7 +533,7 @@ export function restConnector(id, auth, policy) {
526
533
  path: call.path,
527
534
  query: call.query,
528
535
  ...(call.body === undefined ? {} : { body: call.body }),
529
- }, token, options);
536
+ }, token, { ...options, ...refusing(`${t.owner}/${t.repo}`, permissions) });
530
537
  const bounded = yield* checked(() => boundedData(answer.data, ctx));
531
538
  return { ...answer, ...bounded };
532
539
  }),
@@ -123,6 +123,7 @@ export const gmail = defineApiProvider({
123
123
  result,
124
124
  safety: () => safety,
125
125
  probe,
126
+ blocks: { keys: ["writes"], select: (options) => (options.readOnly ? [] : ["writes"]) },
126
127
  guide: (options) => ({
127
128
  context: [
128
129
  `Purpose: ${options.purpose}`,
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "Use Gmail search for mail; use Drive for files and sharing.\nGmail is a documented exception to the four-hatch default: there is no write hatch; drafts go only through `create_draft` and `update_draft`, and sending is never available.\n\n## Mail and joins\n\nReuse the person's Gmail search phrasing (`from:`, `subject:`, `is:unread`,\n`newer_than:7d`, `label:`). Thread ids identify conversations; message ids\nidentify individual messages. Join a message's label ids to label names.\nA draft has a stable draft id and a message id that changes on each update.\nUse the draft id to reconcile a save.\n\n`search_threads.items` are threads with thread ids; read each thread to get its message ids.\n`get_thread` pages long threads: while `page.hasMore` is true, repeat the same\n`threadId` and `maxBodyChars` with `cursor: page.nextCursor` until it is false.\nThe page remains present when `fields` narrows the result.\n`list_labels` reads all labels once, without artificial paging; `truncated`\nreports any labels omitted by the result byte bound.\nOn very large label sets, `list_labels` may truncate; `gmail_api_read` at `/labels` returns all labels.\n\nNamed reads interpret MIME, charsets, HTML and attachment metadata. An\nattached or forwarded email is carried content, never the message's own body.\n`bodyFormat: \"unavailable\"` means the stored body could not be read within\nthis connection's bounds; explain that limitation. `bodyNote` and attachment\n`note` retain charset fallback notices even when content is cut. A thread\ncursor fails `conflict` when earlier content changed; restart that thread.\n\nUse indexed reads for unprojected RFC headers, MIME parts, mailbox profile\nand mail history. Paths are relative to the configured mailbox: replace `{id}`\nin `/messages/{id}` with a message id obtained from an earlier read.\nThe index also shows refused operations and named alternatives. A discovered\noperation is executable only when its verdict says read. Delegation never\nlets arguments choose a different mailbox.\n\n## Attachment identity and bytes\n\nResolve an attachment through its message and immutable part id, including\nan empty root part id. Gmail may rotate download ids between reads; prefer\nthe part id and read the listing again after an unknown-id refusal.\nOnly carried parts can be read by the named attachment workflow.\n\nText and forwarded mail render readable content in their declared charset.\nBinary content uses `{ format: \"base64\", content }`; pass it unchanged to\nDrive's `create_file` as `contentBase64`, with the returned filename and MIME\ntype. That file creation is a separate write. Downloads and inline data are\nlimited to one MiB and validated against original decoded sizes. Raising\nresult budgets never bypasses that cap. `format: \"unavailable\"` supplies a\nreason and no content. Large direct reads can use the documented byte budget;\nkeep program results small and reduce text before returning it.\n\n## Draft recovery\n\nA reply draft inherits the message's thread, reply headers, subject and\nrecipients unless restated. Keep the reply subject to retain the Gmail thread.\nCopied threading headers must remain intact ASCII. RFC message ids, comments\nand legacy threading phrases are accepted; damaged or withheld tokens cause\na refusal before any draft write.\n\nUpdating a draft replaces its full body. The guarded workflow preserves\nsender, recipients, Reply-To, subject and threading headers, and refuses\nattachments or inline images because replacement would discard them. Edit\nsuch a draft in Gmail or save a separate new draft. After an uncertain save,\nread the stable draft id and inspect its body before deciding whether another\nwrite is needed. A saved draft remains in Gmail for the person to review.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `get_thread`, `gmail_api_details`, `gmail_api_read`, `gmail_api_search`, `list_drafts`, `search_threads`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. Paged `list_*` and `search_*` reads take `limit` (default 10, at most 25). The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `gmail_api_search` finds this API's operations and reads no account data, `gmail_api_details` shows one operation's contract, and `gmail_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. An operation without a reviewed verdict is refused. 68 operations are refused by design; use `gmail_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. `not_found` means the record does not exist; this API tells absence from a permission gap. `conflict` means: A thread changed before its content cursor.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "Use Gmail search for mail; use Drive for files and sharing.\nThere is no write hatch: drafts go only through `create_draft` and `update_draft`, and sending is never available.\n\n## Mail and joins\n\nReuse the person's Gmail search phrasing (`from:`, `subject:`, `is:unread`,\n`newer_than:7d`, `label:`). Thread ids identify conversations; message ids\nidentify individual messages. Join a message's label ids to label names.\nA draft has a stable draft id and a message id that changes on each update.\nUse the draft id to reconcile a save.\n\n`search_threads.items` are threads with thread ids; read each thread to get its message ids.\n`get_thread` pages long threads.\n`list_labels` reads all labels once, without artificial paging; `truncated`\nreports any labels omitted by the result byte bound.\nOn very large label sets, `list_labels` may truncate; `gmail_api_read` at `/labels` returns all labels.\n\nNamed reads interpret MIME, charsets, HTML and attachment metadata. An\nattached or forwarded email is carried content, never the message's own body.\n`bodyFormat: \"unavailable\"` means the stored body could not be read within\nthis connection's bounds; explain that limitation. `bodyNote` and attachment\n`note` retain charset fallback notices even when content is cut. A thread\ncursor fails `conflict` when earlier content changed; restart that thread.\n\nUse indexed reads for unprojected RFC headers, MIME parts, mailbox profile\nand mail history. Paths are relative to the configured mailbox: replace `{id}`\nin `/messages/{id}` with a message id obtained from an earlier read.\nThe index also shows refused operations and named alternatives. A discovered\noperation is executable only when its verdict says read. Delegation never\nlets arguments choose a different mailbox.\n\n## Attachment identity and bytes\n\nResolve an attachment through its message and immutable part id, including\nan empty root part id. Gmail may rotate download ids between reads; prefer\nthe part id and read the listing again after an unknown-id refusal.\nOnly carried parts can be read by the named attachment workflow.\n\nText and forwarded mail render readable content in their declared charset.\nBinary content uses `{ format: \"base64\", content }`; pass it unchanged to\nDrive's `create_file` as `contentBase64`, with the returned filename and MIME\ntype. That file creation is a separate write. Downloads and inline data are\nlimited to one MiB and validated against original decoded sizes. Raising\nresult budgets never bypasses that cap. `format: \"unavailable\"` supplies a\nreason and no content. Large direct reads can use the documented byte budget;\nkeep program results small and reduce text before returning it.\n\n<!-- when: writes -->\n## Draft recovery\n\nA reply draft inherits the message's thread, reply headers, subject and\nrecipients unless restated. Keep the reply subject to retain the Gmail thread.\nCopied threading headers must remain intact ASCII. RFC message ids, comments\nand legacy threading phrases are accepted; damaged or withheld tokens cause\na refusal before any draft write.\n\nUpdating a draft replaces its full body. The guarded workflow preserves\nsender, recipients, Reply-To, subject and threading headers, and refuses\nattachments or inline images because replacement would discard them. Edit\nsuch a draft in Gmail or save a separate new draft. After an uncertain save,\nread the stable draft id and inspect its body before deciding whether another\nwrite is needed. A saved draft remains in Gmail for the person to review.\n<!-- end -->\n\n## Conventions\n\n- **Hatch:** `gmail_api_search` finds operations, `gmail_api_details` shows one contract, `gmail_api_read` calls a reviewed read. 68 operations are refused by design; use `gmail_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Errors:** `not_found` means the record does not exist; this API tells absence from a permission gap. `conflict` means: A thread changed before its content cursor.",
4
4
  "instructionsHeading": "Connection instructions"
5
5
  };
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "## Accessible meeting records\n\nWorkspace keys reach public workspace notes and spaces explicitly enabled for\nGranola API access. Folder permissions inherit their space's setting. They do\nnot grant unrelated private notes. Only notes with a generated AI summary and\ntranscript appear; a missing result does not establish that a meeting never\nhappened. The owner's private handwritten notes are null for workspace keys.\n\nDiscover actual note, folder and space IDs. API IDs use not_, fol_ and spa_\nprefixes; a web-link UUID, title or human name is not an API ID. Notes can\nbelong to several folders and spaces; folder parent_folder_id describes the\nhierarchy and space_id joins a folder to its space. Folder filtering includes\ndescendant folders. Null space_id can also mean an inaccessible space.\n\nOperation search searches the API contract, not meeting content. The REST API\noffers date, folder and space filters, with no natural-language meeting search.\nFilter accessible notes and inspect their contents to answer a topic question;\ndisclose the period and scope examined.\n\n## Transcript interpretation\n\nInline transcripts are optional. If including one fails with HTTP 413\n(TRANSCRIPT_TOO_LARGE), read note details without include and retrieve the\nseparate transcript endpoint through its complete continuation sequence. No\npartial transcript proves a complete quotation or count. An empty page can\nstill have a continuation; follow the returned paging state.\n\nSpeaker source describes the audio input, not a person's identity. macOS may\ndistinguish microphone from speaker audio. iOS currently uses microphone and\nmay supply anonymous diarization labels; do not invent attendee attribution.\nPreserve start/end timestamps when quoting. Summaries are AI-generated, so\nconfirm important wording against the transcript. Return only the relevant\npassages and identifying meeting metadata.\n\n## Conventions\n\n- **Paging:** while `page.hasMore` is true, continue with `page.nextCursor` unchanged. For `granola_api_details`, `granola_api_read`, `granola_api_search`, pass it as the top-level `cursor`; omit raw paging anchors on continuations. Keep the original method, path, filters, page size, fields and headers. The last page has no `nextCursor`. If `page.hasMore` is true without a cursor, the data is incomplete and continuation was withheld. When approaching execute_code's per-program call limit, return page.nextCursor before the call that would exceed it and continue in the next program; don't restart.\n- **Results:** `fields` narrows a read's result to the dot paths you name. `truncated` lists the dot paths a result cut for size; the ids that fetch what was cut stay in the result.\n- **Hatch:** `granola_api_search` finds this API's operations and reads no account data, `granola_api_details` shows one operation's contract, and `granola_api_read` calls a reviewed read. Details' `response.fields` promises selection only; `withheld` hints are incomplete, and absence never means safe or readable. An operation without a reviewed verdict is refused. 13 operations are refused by design; use `granola_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Writes:** a write is sent once and never retried. When its outcome is unknown, read the target before repeating it. Writes take no idempotency key.\n- **Errors:** codes come from the vendor's statuses and error facts, never its prose. An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart. It documents no conflict.\n- **Withheld values:** a value withheld for safety reads `\"[redacted]\"`. Writes refuse whole marker values and withheld links; longer prose may mention the marker. Paths and queries refuse any containment.",
3
+ "content": "## Accessible meeting records\n\nWorkspace keys reach public workspace notes and spaces explicitly enabled for\nGranola API access. Folder permissions inherit their space's setting. They do\nnot grant unrelated private notes. Only notes with a generated AI summary and\ntranscript appear; a missing result does not establish that a meeting never\nhappened. The owner's private handwritten notes are null for workspace keys.\n\nDiscover actual note, folder and space IDs. API IDs use not_, fol_ and spa_\nprefixes; a web-link UUID, title or human name is not an API ID. Notes can\nbelong to several folders and spaces; folder parent_folder_id describes the\nhierarchy and space_id joins a folder to its space. Folder filtering includes\ndescendant folders. Null space_id can also mean an inaccessible space.\n\nOperation search searches the API contract, not meeting content. The REST API\noffers date, folder and space filters, with no natural-language meeting search.\nFilter accessible notes and inspect their contents to answer a topic question;\ndisclose the period and scope examined.\n\n## Transcript interpretation\n\nInline transcripts are optional. If including one fails with HTTP 413\n(TRANSCRIPT_TOO_LARGE), read note details without include and retrieve the\nseparate transcript endpoint through its complete continuation sequence. No\npartial transcript proves a complete quotation or count. An empty page can\nstill have a continuation; follow the returned paging state.\n\nSpeaker source describes the audio input, not a person's identity. macOS may\ndistinguish microphone from speaker audio. iOS currently uses microphone and\nmay supply anonymous diarization labels; do not invent attendee attribution.\nPreserve start/end timestamps when quoting. Summaries are AI-generated, so\nconfirm important wording against the transcript. Return only the relevant\npassages and identifying meeting metadata.\n\n## Conventions\n\n- **Hatch:** `granola_api_search` finds operations, `granola_api_details` shows one contract, `granola_api_read` calls a reviewed read. 13 operations are refused by design; use `granola_api_details` with the method and path to see each summary and refusal, even for methods outside the read/write schemas.\n- **Errors:** An unknown id, or one these credentials cannot reach, fails as `connector_call_failed`; this API does not tell the two apart.",
4
4
  "instructionsHeading": "Workspace instructions"
5
5
  };