melchizedek-agents 1.1.0 → 1.2.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 (118) hide show
  1. package/.env.example +10 -0
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +232 -0
  4. package/DOCUMENTATION.md +91 -16
  5. package/ONBOARDING.md +339 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +5 -2
  8. package/config/agents/examples/council.yaml +1 -1
  9. package/config/agents/examples/critic.yaml +8 -8
  10. package/config/agents/examples/image_production.yaml +4 -3
  11. package/config/agents/syndicate.schema.json +65 -7
  12. package/config/agents/syndicateSchema.yaml +18 -6
  13. package/config/agents/templates/intake_extractor.yaml +3 -2
  14. package/config/agents/templates/systems_operator.yaml +5 -2
  15. package/dist/lib/a2a/app.d.ts +12 -0
  16. package/dist/lib/a2a/app.js +22 -11
  17. package/dist/lib/a2a/executor.js +14 -6
  18. package/dist/lib/a2a/oauthSetup.d.ts +16 -0
  19. package/dist/lib/a2a/oauthSetup.js +57 -3
  20. package/dist/lib/agentDialect.d.ts +18 -8
  21. package/dist/lib/agentDialect.js +36 -19
  22. package/dist/lib/compile.d.ts +38 -7
  23. package/dist/lib/compile.js +57 -37
  24. package/dist/lib/compileNative.d.ts +11 -3
  25. package/dist/lib/compileNative.js +18 -1
  26. package/dist/lib/doctor.d.ts +76 -0
  27. package/dist/lib/doctor.js +159 -7
  28. package/dist/lib/loadSyndicate.d.ts +9 -0
  29. package/dist/lib/models/capabilities.d.ts +9 -0
  30. package/dist/lib/models/capabilities.js +24 -0
  31. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  32. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  33. package/dist/lib/models/claudeAdapter.js +4 -0
  34. package/dist/lib/models/contract.d.ts +5 -2
  35. package/dist/lib/models/gateway.d.ts +2 -1
  36. package/dist/lib/models/gateway.js +4 -2
  37. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  38. package/dist/lib/models/gatewayAdapter.js +8 -0
  39. package/dist/lib/models/geminiAdapter.js +8 -2
  40. package/dist/lib/models/genaiMapping.d.ts +8 -4
  41. package/dist/lib/models/genaiMapping.js +23 -9
  42. package/dist/lib/models/gptAdapter.d.ts +4 -2
  43. package/dist/lib/models/gptAdapter.js +11 -4
  44. package/dist/lib/models/grokAdapter.d.ts +3 -1
  45. package/dist/lib/models/grokAdapter.js +11 -3
  46. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  47. package/dist/lib/models/kimiAdapter.js +3 -3
  48. package/dist/lib/models/reasoning.d.ts +30 -2
  49. package/dist/lib/models/reasoning.js +56 -5
  50. package/dist/lib/onboarding.d.ts +134 -0
  51. package/dist/lib/onboarding.js +539 -0
  52. package/dist/lib/runtime/approvals.d.ts +2 -0
  53. package/dist/lib/runtime/approvals.js +4 -3
  54. package/dist/lib/runtime/credentials.d.ts +6 -0
  55. package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
  56. package/dist/lib/runtime/native/agentLoop.js +2 -0
  57. package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
  58. package/dist/lib/runtime/native/checkpoint.js +13 -19
  59. package/dist/lib/runtime/native/delegate.d.ts +82 -1
  60. package/dist/lib/runtime/native/delegate.js +157 -2
  61. package/dist/lib/runtime/native/interrupts.d.ts +88 -5
  62. package/dist/lib/runtime/native/interrupts.js +220 -30
  63. package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
  64. package/dist/lib/runtime/syndicateTurn.js +295 -62
  65. package/dist/lib/runtime/turnControl.d.ts +41 -0
  66. package/dist/lib/session/transcript.d.ts +9 -0
  67. package/dist/lib/session/transcript.js +20 -1
  68. package/dist/lib/skills.d.ts +18 -0
  69. package/dist/lib/skills.js +73 -1
  70. package/dist/lib/storage/postgres/index.d.ts +6 -1
  71. package/dist/lib/storage/postgres/index.js +1 -1
  72. package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
  73. package/dist/lib/storage/postgres/taskQueue.js +10 -3
  74. package/dist/lib/syndicateSchema.d.ts +38 -1
  75. package/dist/lib/syndicateSchema.js +25 -18
  76. package/dist/lib/tools/auth.d.ts +13 -1
  77. package/dist/lib/tools/auth.js +10 -2
  78. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  79. package/dist/lib/tools/credentialHosts.js +165 -0
  80. package/dist/lib/tools/credentialUses.d.ts +92 -0
  81. package/dist/lib/tools/credentialUses.js +112 -0
  82. package/dist/lib/tools/mcpToolFactory.js +1 -1
  83. package/dist/lib/tools/oauthConsent.js +8 -1
  84. package/dist/lib/tools/oauthHosts.d.ts +2 -0
  85. package/dist/lib/tools/oauthHosts.js +2 -2
  86. package/dist/lib/tools/oauthTools.d.ts +10 -1
  87. package/dist/lib/tools/oauthTools.js +29 -3
  88. package/dist/lib/tools/openapiTools.d.ts +7 -0
  89. package/dist/lib/tools/openapiTools.js +49 -3
  90. package/dist/lib/tools/taskTools.d.ts +32 -2
  91. package/dist/lib/tools/taskTools.js +93 -33
  92. package/dist/lib/workflow/turn.d.ts +22 -3
  93. package/dist/lib/workflow/turn.js +151 -31
  94. package/dist/lib/workflow.d.ts +10 -7
  95. package/dist/lib/workflow.js +10 -7
  96. package/dist/scripts/a2a_server.d.ts +2 -2
  97. package/dist/scripts/a2a_server.js +11 -2
  98. package/dist/scripts/assistant_worker.d.ts +2 -0
  99. package/dist/scripts/assistant_worker.js +13 -4
  100. package/dist/scripts/doctor.d.ts +4 -2
  101. package/dist/scripts/doctor.js +6 -4
  102. package/dist/scripts/setup.d.ts +23 -0
  103. package/dist/scripts/setup.js +154 -0
  104. package/dist/scripts/skills_install.d.ts +3 -1
  105. package/dist/scripts/skills_install.js +17 -2
  106. package/dist/scripts/yaml_codemod.d.ts +12 -2
  107. package/dist/scripts/yaml_codemod.js +115 -33
  108. package/package.json +4 -1
  109. package/skills/README.md +11 -2
  110. package/skills/briefs/_shared.md +1 -1
  111. package/skills/briefs/melchizedek-models.md +4 -2
  112. package/skills/briefs/skills-readme.md +3 -1
  113. package/skills/melchizedek-models/SKILL.md +6 -2
  114. package/skills/melchizedek-onboard/SKILL.md +49 -0
  115. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  116. package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
  117. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  118. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * lib/tools/credentialHosts.ts — the operator's credential host allowlist:
3
+ * which hosts the value of each static credential variable may be sent to
4
+ * (ADR 0122).
5
+ *
6
+ * WHY this file exists:
7
+ * A syndicate YAML (possibly registry-stored) names a credential variable
8
+ * and, beside it, the host its value goes to: an OpenAPI `auth` sends
9
+ * `bearer_env` or `api_key.env` to the spec's server (or `base_url`), and
10
+ * an `oauth2` block sends `client_secret_env` to its `token_url`.
11
+ * credentialEnvProblem (lib/tools/credentialEnv.ts) keeps the framework's
12
+ * own secrets out of reach, and the OAuth host allowlist (ADR 0114) binds
13
+ * a provider's tokens, but nothing bound a static secret to its API: a
14
+ * YAML that names TRACKER_TOKEN could aim it at a host of its own. The
15
+ * binding is the operator's, never the YAML's: an environment variable or
16
+ * `createA2AApp({ credentialHosts })`.
17
+ *
18
+ * THE RULE (ADR 0122):
19
+ * - With no allowlist configured, nothing changes: a credential goes to
20
+ * the host the YAML names, as before. The doctor and the server binary
21
+ * name every credential variable a served YAML sends, as unbound.
22
+ * - With an allowlist configured, it is the whole list: every credential
23
+ * variable a YAML sends must be on it, and every host its value goes to
24
+ * must be one of that variable's hosts.
25
+ * - The check runs when a syndicate is loaded to be served, when its tools
26
+ * are compiled, and again before each call sends the value.
27
+ *
28
+ * WHAT IT COVERS: `bearer_env`, `api_key.env` and `client_secret_env` (both
29
+ * grants: the client-credentials token request, the consent step's code
30
+ * exchange and its refresh). Not `client_id_env`: a client id is not a
31
+ * secret. Not MCP_BEARER_TOKENS: it maps each token to its host already,
32
+ * and the operator writes both.
33
+ *
34
+ * THE FORMAT: `MELCHIZEDEK_CREDENTIAL_HOSTS="TRACKER_TOKEN=api.tracker.example.com;WEATHER_KEY=api.weather.example.com,*.weather.example.com"`.
35
+ * Entries are separated by `;` (or a newline), a variable from its hosts by
36
+ * `=`, hosts by `,`. A host is what the OAuth host allowlist takes
37
+ * (lib/tools/oauthHosts.ts): a hostname, `*.example.com`, IPv4 or
38
+ * `[IPv6]`, with no scheme, port or path.
39
+ *
40
+ * Nothing here reads or prints a credential's value: variable names and
41
+ * hosts only. No regular expression here can backtrack: the separators are
42
+ * single characters, and a variable name is one bounded character class.
43
+ */
44
+ import { hostAllowed, hostPatternProblem } from './oauthHosts.js';
45
+ /** The environment variable the allowlist is read from when no option sets it. */
46
+ export const CREDENTIAL_HOSTS_ENV = 'MELCHIZEDEK_CREDENTIAL_HOSTS';
47
+ /** A credential variable's name, as a YAML may write it (lib/syndicateSchema.ts), bounded. */
48
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
49
+ /**
50
+ * The allowlist, checked and normalized (hosts lowercased, duplicates
51
+ * dropped). Throws, naming the variable and the host, on anything malformed.
52
+ */
53
+ export function checkCredentialHosts(allowlist, source = 'credentialHosts') {
54
+ if (!allowlist || typeof allowlist !== 'object' || Array.isArray(allowlist))
55
+ throw new Error(`${source} must map each credential variable to its hosts.`);
56
+ const out = {};
57
+ for (const [name, hosts] of Object.entries(allowlist)) {
58
+ if (!ENV_NAME.test(name))
59
+ throw new Error(`${source}: "${name.slice(0, 64)}" is not an environment variable name (A–Z, 0–9, _).`);
60
+ if (!Array.isArray(hosts) || hosts.length === 0)
61
+ throw new Error(`${source}: ${name} needs at least one host.`);
62
+ const seen = new Set();
63
+ for (const raw of hosts) {
64
+ const host = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
65
+ const problem = host ? hostPatternProblem(host) : 'is empty';
66
+ if (problem)
67
+ throw new Error(`${source}: ${name}: host "${String(raw).slice(0, 80)}" ${problem}.`);
68
+ seen.add(host);
69
+ }
70
+ out[name] = [...seen];
71
+ }
72
+ return out;
73
+ }
74
+ /** The allowlist from its text form (MELCHIZEDEK_CREDENTIAL_HOSTS). Throws on a malformed entry. */
75
+ export function parseCredentialHosts(text) {
76
+ const parsed = {};
77
+ for (const entry of text.split(/[;\n]/)) {
78
+ const trimmed = entry.trim();
79
+ if (!trimmed)
80
+ continue;
81
+ const eq = trimmed.indexOf('=');
82
+ if (eq <= 0)
83
+ throw new Error(`${CREDENTIAL_HOSTS_ENV}: each entry is VARIABLE=host,host (got "${trimmed.slice(0, 80)}").`);
84
+ const name = trimmed.slice(0, eq).trim();
85
+ if (Object.hasOwn(parsed, name))
86
+ throw new Error(`${CREDENTIAL_HOSTS_ENV}: ${name.slice(0, 64)} is listed twice.`);
87
+ parsed[name] = trimmed.slice(eq + 1).split(',').map((h) => h.trim()).filter((h) => h !== '');
88
+ }
89
+ return checkCredentialHosts(parsed, CREDENTIAL_HOSTS_ENV);
90
+ }
91
+ let configured;
92
+ let envCache;
93
+ /**
94
+ * Sets this process's allowlist (createA2AApp's `credentialHosts` does),
95
+ * which wins over the environment variable. `undefined` returns to the
96
+ * variable.
97
+ */
98
+ export function setCredentialHosts(allowlist) {
99
+ configured = allowlist === undefined ? undefined : checkCredentialHosts(allowlist);
100
+ }
101
+ /**
102
+ * The allowlist in force: the one set by `setCredentialHosts`, else the
103
+ * environment variable's, else undefined (not configured). A malformed
104
+ * variable throws, so the first check refuses rather than allowing.
105
+ */
106
+ export function credentialHosts(env = process.env) {
107
+ if (configured)
108
+ return configured;
109
+ const raw = env[CREDENTIAL_HOSTS_ENV];
110
+ if (raw === undefined || raw.trim() === '')
111
+ return undefined;
112
+ if (envCache?.raw !== raw)
113
+ envCache = { raw, value: parseCredentialHosts(raw) };
114
+ return envCache.value;
115
+ }
116
+ /**
117
+ * Why the value of credential variable `name` may not be sent to `url`, or
118
+ * null. `role` names the URL in the message (`server`, `token_url`).
119
+ * `allowlist` defaults to the one in force; `null` means none is
120
+ * configured, which allows (the behaviour before ADR 0122).
121
+ */
122
+ export function credentialHostProblem(name, url, role = 'server', allowlist = credentialHosts()) {
123
+ const list = allowlist ?? undefined;
124
+ if (!list)
125
+ return null;
126
+ if (!Object.hasOwn(list, name)) {
127
+ return `${name} is not on the operator's credential host allowlist (${CREDENTIAL_HOSTS_ENV} or createA2AApp's credentialHosts), so it is sent nowhere`;
128
+ }
129
+ let hostname;
130
+ try {
131
+ hostname = new URL(url).hostname;
132
+ }
133
+ catch {
134
+ return `${role} '${url.slice(0, 200)}' is not a URL`;
135
+ }
136
+ const hosts = list[name];
137
+ if (hostAllowed(hosts, hostname))
138
+ return null;
139
+ return `${name} may be sent only to ${hosts.join(', ')}; ${hostname} (${role}) is not one of them`;
140
+ }
141
+ /**
142
+ * The call-time check: why the value of `name` may not be sent to
143
+ * `destination` now, or null. A malformed allowlist refuses.
144
+ */
145
+ export function credentialCallProblem(name, destination) {
146
+ try {
147
+ return credentialHostProblem(name, destination, 'server', credentialHosts() ?? null);
148
+ }
149
+ catch {
150
+ return `the credential host allowlist (${CREDENTIAL_HOSTS_ENV}) is malformed`;
151
+ }
152
+ }
153
+ /**
154
+ * Marks an OAuth client config (lib/tools/oauthConsent.ts) with the
155
+ * variable its client secret came from, so the consent step's code exchange
156
+ * and the refresh hook can check the token endpoint at call time. Set by
157
+ * oauthClientsFor (lib/tools/oauthTools.ts); a client built by hand carries
158
+ * none and is not checked.
159
+ */
160
+ export const CLIENT_SECRET_ENV = Symbol.for('melchizedek.clientSecretEnv');
161
+ /** The variable a client config's secret came from, when oauthClientsFor marked it. */
162
+ export function clientSecretEnvOf(client) {
163
+ const name = client[CLIENT_SECRET_ENV];
164
+ return typeof name === 'string' ? name : undefined;
165
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * lib/tools/credentialUses.ts — every static credential variable a syndicate
3
+ * YAML sends somewhere, and where (ADR 0122).
4
+ *
5
+ * A YAML sends a static credential in three places: an OpenAPI entry's
6
+ * `auth.bearer_env` or `auth.api_key.env` (to the spec's server, or
7
+ * `base_url`), and an `oauth2` block's `client_secret_env` (to its
8
+ * `token_url`; the OpenAPI entry's or the MCP server's `mcp_auth`). The
9
+ * operator's credential host allowlist (lib/tools/credentialHosts.ts) is
10
+ * checked against them when a served syndicate loads
11
+ * (`syndicateCredentialHostProblems`), and the doctor and the server binary
12
+ * name each variable no allowlist binds (`unboundCredentialEnvs`).
13
+ *
14
+ * Names and hosts only: no value is read here.
15
+ */
16
+ import type { CredentialHostAllowlist } from './credentialHosts.ts';
17
+ /** One credential variable a YAML sends, and to what. */
18
+ export interface CredentialUse {
19
+ /** The agent that holds the tools ("Parent › Child" inside a nested file). */
20
+ agent: string;
21
+ /** `openapi <spec>` or `mcp <url>`. */
22
+ tools: string;
23
+ /** The variable's name. */
24
+ env: string;
25
+ /** Where the YAML names it. */
26
+ role: 'bearer_env' | 'api_key' | 'client_secret_env';
27
+ /**
28
+ * The URLs its value goes to: the token endpoint for a client secret,
29
+ * `base_url` for an OpenAPI entry that sets one. Absent for an OpenAPI
30
+ * entry whose server is in its spec (read only when checked, by
31
+ * `syndicateCredentialHostProblems`).
32
+ */
33
+ destinations?: string[];
34
+ /** The OpenAPI entry, for its spec's servers. */
35
+ openapi?: OpenApiEntryLike;
36
+ }
37
+ /** The parts of an `oauth2` block read here. */
38
+ interface OAuth2Like {
39
+ token_url: string;
40
+ client_secret_env?: string;
41
+ }
42
+ /** The parts of an `openapi:` entry read here. */
43
+ interface OpenApiEntryLike {
44
+ spec?: string;
45
+ base_url?: string;
46
+ auth?: {
47
+ bearer_env?: string;
48
+ api_key?: {
49
+ env?: string;
50
+ };
51
+ oauth2?: OAuth2Like;
52
+ };
53
+ }
54
+ interface AgentLike {
55
+ name?: string;
56
+ openapi?: OpenApiEntryLike[];
57
+ mcp_server_url?: string;
58
+ mcp_auth?: {
59
+ oauth2?: OAuth2Like;
60
+ };
61
+ yaml_reference?: string;
62
+ }
63
+ /** The parts of a syndicate config read here. */
64
+ export interface SyndicateLike {
65
+ orchestrator?: AgentLike;
66
+ subagents?: AgentLike[];
67
+ }
68
+ /** The credential variables one agent's tools send. */
69
+ export declare function agentCredentialUses(agent: AgentLike, prefix?: string): CredentialUse[];
70
+ /**
71
+ * Every credential variable these syndicates send, orchestrators and
72
+ * subagents, following a nested `yaml_reference:` through `load` when given
73
+ * (once per file; agent names then read "Parent › Child").
74
+ */
75
+ export declare function syndicateCredentialUses(configs: readonly SyndicateLike[], load?: (ref: string) => SyndicateLike): CredentialUse[];
76
+ /**
77
+ * Why these syndicates may not be served here, one line per refusal
78
+ * (ADR 0122): each credential variable they send, against the operator's
79
+ * credential host allowlist. With none configured, nothing is refused. The
80
+ * A2A server checks every syndicate it loads to serve; the compile checks
81
+ * again, and each call once more.
82
+ */
83
+ export declare function syndicateCredentialHostProblems(configs: readonly SyndicateLike[], options?: {
84
+ load?: (ref: string) => SyndicateLike;
85
+ allowlist?: CredentialHostAllowlist | null;
86
+ }): string[];
87
+ /**
88
+ * The credential variables these uses send that no allowlist binds, sorted
89
+ * and once each: every one of them when none is configured. Names only.
90
+ */
91
+ export declare function unboundCredentialEnvs(uses: readonly CredentialUse[], allowlist: CredentialHostAllowlist | null | undefined): string[];
92
+ export {};
@@ -0,0 +1,112 @@
1
+ /**
2
+ * lib/tools/credentialUses.ts — every static credential variable a syndicate
3
+ * YAML sends somewhere, and where (ADR 0122).
4
+ *
5
+ * A YAML sends a static credential in three places: an OpenAPI entry's
6
+ * `auth.bearer_env` or `auth.api_key.env` (to the spec's server, or
7
+ * `base_url`), and an `oauth2` block's `client_secret_env` (to its
8
+ * `token_url`; the OpenAPI entry's or the MCP server's `mcp_auth`). The
9
+ * operator's credential host allowlist (lib/tools/credentialHosts.ts) is
10
+ * checked against them when a served syndicate loads
11
+ * (`syndicateCredentialHostProblems`), and the doctor and the server binary
12
+ * name each variable no allowlist binds (`unboundCredentialEnvs`).
13
+ *
14
+ * Names and hosts only: no value is read here.
15
+ */
16
+ import { resolve } from 'node:path';
17
+ import { credentialHostProblem, credentialHosts } from './credentialHosts.js';
18
+ import { openApiServers } from './openapiTools.js';
19
+ /** The credential variables one agent's tools send. */
20
+ export function agentCredentialUses(agent, prefix = '') {
21
+ const name = `${prefix}${agent.name ?? ''}`;
22
+ const out = [];
23
+ for (const entry of agent.openapi ?? []) {
24
+ const tools = `openapi ${entry?.spec ?? ''}`;
25
+ const auth = entry?.auth;
26
+ if (!auth)
27
+ continue;
28
+ const servers = entry.base_url ? { destinations: [entry.base_url] } : {};
29
+ if (auth.bearer_env)
30
+ out.push({ agent: name, tools, env: auth.bearer_env, role: 'bearer_env', ...servers, openapi: entry });
31
+ if (auth.api_key?.env)
32
+ out.push({ agent: name, tools, env: auth.api_key.env, role: 'api_key', ...servers, openapi: entry });
33
+ if (auth.oauth2?.client_secret_env)
34
+ out.push({ agent: name, tools, env: auth.oauth2.client_secret_env, role: 'client_secret_env', destinations: [auth.oauth2.token_url] });
35
+ }
36
+ const mcp = agent.mcp_auth?.oauth2;
37
+ if (mcp?.client_secret_env)
38
+ out.push({ agent: name, tools: `mcp ${agent.mcp_server_url ?? ''}`, env: mcp.client_secret_env, role: 'client_secret_env', destinations: [mcp.token_url] });
39
+ return out;
40
+ }
41
+ /**
42
+ * Every credential variable these syndicates send, orchestrators and
43
+ * subagents, following a nested `yaml_reference:` through `load` when given
44
+ * (once per file; agent names then read "Parent › Child").
45
+ */
46
+ export function syndicateCredentialUses(configs, load) {
47
+ const out = [];
48
+ const seen = new Set();
49
+ const visit = (config, prefix) => {
50
+ const agents = [config.orchestrator, ...(config.subagents ?? [])].filter((a) => !!a);
51
+ for (const agent of agents) {
52
+ if (agent.yaml_reference) {
53
+ if (!load || seen.has(agent.yaml_reference))
54
+ continue;
55
+ seen.add(agent.yaml_reference);
56
+ visit(load(agent.yaml_reference), `${prefix}${agent.name ?? agent.yaml_reference} › `);
57
+ continue;
58
+ }
59
+ out.push(...agentCredentialUses(agent, prefix));
60
+ }
61
+ };
62
+ for (const config of configs)
63
+ visit(config, '');
64
+ return out;
65
+ }
66
+ /** The URLs a use's value goes to; an OpenAPI spec that cannot be read gives none (its compile fails on its own). */
67
+ function destinationsOf(use) {
68
+ if (use.destinations)
69
+ return use.destinations;
70
+ if (!use.openapi?.spec)
71
+ return [];
72
+ try {
73
+ return openApiServers(use.openapi, resolve(process.cwd()));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
79
+ /**
80
+ * Why these syndicates may not be served here, one line per refusal
81
+ * (ADR 0122): each credential variable they send, against the operator's
82
+ * credential host allowlist. With none configured, nothing is refused. The
83
+ * A2A server checks every syndicate it loads to serve; the compile checks
84
+ * again, and each call once more.
85
+ */
86
+ export function syndicateCredentialHostProblems(configs, options = {}) {
87
+ const allowlist = options.allowlist === undefined ? credentialHosts() ?? null : options.allowlist;
88
+ if (!allowlist)
89
+ return [];
90
+ const out = new Set();
91
+ for (const use of syndicateCredentialUses(configs, options.load)) {
92
+ const role = use.role === 'client_secret_env' ? 'token_url' : 'server';
93
+ const where = `${use.agent} · ${use.tools} · ${use.role}`;
94
+ if (!Object.hasOwn(allowlist, use.env)) {
95
+ out.add(`${where}: ${credentialHostProblem(use.env, '', role, allowlist)}`);
96
+ continue;
97
+ }
98
+ for (const url of destinationsOf(use)) {
99
+ const problem = credentialHostProblem(use.env, url, role, allowlist);
100
+ if (problem)
101
+ out.add(`${where}: ${problem}`);
102
+ }
103
+ }
104
+ return [...out];
105
+ }
106
+ /**
107
+ * The credential variables these uses send that no allowlist binds, sorted
108
+ * and once each: every one of them when none is configured. Names only.
109
+ */
110
+ export function unboundCredentialEnvs(uses, allowlist) {
111
+ return [...new Set(uses.map((u) => u.env))].filter((name) => !allowlist || !Object.hasOwn(allowlist, name)).sort();
112
+ }
@@ -174,7 +174,7 @@ function bearerFetch(oauth2, token) {
174
174
  // message endpoint the server names), before a token is attached (ADR 0114).
175
175
  const destination = input instanceof Request ? input.url : String(input);
176
176
  if (oauthCallProblem(oauth2, destination))
177
- throw new ToolCredentialError('host_refused', oauth2.provider);
177
+ throw new ToolCredentialError('host_refused', oauth2.provider, 'oauth');
178
178
  const headers = new Headers(init?.headers);
179
179
  headers.set('Authorization', `Bearer ${await token(destination)}`);
180
180
  return mcpFetch(input, { ...init, headers });
@@ -38,6 +38,7 @@
38
38
  import { createHash, randomBytes } from 'node:crypto';
39
39
  import { scopeHashOf } from '../observability/audit.js';
40
40
  import { PROVIDER_NAME } from './auth.js';
41
+ import { clientSecretEnvOf, credentialCallProblem } from './credentialHosts.js';
41
42
  /**
42
43
  * Flows in this process's memory, at most `max` of them: the oldest is
43
44
  * dropped first, and an expired one whenever another is put.
@@ -192,8 +193,14 @@ export function oauthConsent(options) {
192
193
  client_id: client.clientId,
193
194
  code_verifier: flow.codeVerifier,
194
195
  });
195
- if (client.clientSecret)
196
+ if (client.clientSecret) {
197
+ // The secret goes only to a host the operator binds its variable to (ADR 0122), checked now:
198
+ // the allowlist can change after boot. Refused, nothing is sent.
199
+ const secretEnv = clientSecretEnvOf(client);
200
+ if (secretEnv && credentialCallProblem(secretEnv, client.tokenUrl))
201
+ throw new ConsentError('exchange_failed', flow.provider);
196
202
  body.set('client_secret', client.clientSecret);
203
+ }
197
204
  let json;
198
205
  try {
199
206
  const res = await doFetch(client.tokenUrl, {
@@ -38,6 +38,8 @@
38
38
  export declare const OAUTH_HOSTS_ENV = "MELCHIZEDEK_OAUTH_HOSTS";
39
39
  /** Provider name → the hosts its tokens may be sent to. */
40
40
  export type OAuthHostAllowlist = Readonly<Record<string, readonly string[]>>;
41
+ /** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
42
+ export declare function hostPatternProblem(pattern: string): string | null;
41
43
  /**
42
44
  * The allowlist, checked and normalized (hosts lowercased, duplicates
43
45
  * dropped). Throws, naming the provider and the host, on anything malformed.
@@ -39,8 +39,8 @@ import { PROVIDER_NAME } from './auth.js';
39
39
  export const OAUTH_HOSTS_ENV = 'MELCHIZEDEK_OAUTH_HOSTS';
40
40
  const LABEL = /^[a-z0-9-]{1,63}$/;
41
41
  const IPV6 = /^\[[0-9a-f:.]{2,45}\]$/;
42
- /** Why `pattern` is not a host the allowlist can hold, or null. */
43
- function hostPatternProblem(pattern) {
42
+ /** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
43
+ export function hostPatternProblem(pattern) {
44
44
  if (pattern.length > 253)
45
45
  return 'is longer than 253 characters';
46
46
  if (IPV6.test(pattern))
@@ -51,6 +51,13 @@
51
51
  * (`oauthCallProblem`). The client-credentials token endpoint passes the
52
52
  * same SSRF guard as the server it serves (ALLOW_PRIVATE_OPENAPI or
53
53
  * ALLOW_PRIVATE_MCP), and no redirect from it is followed.
54
+ *
55
+ * WHERE A CLIENT SECRET MAY GO: to the token endpoint, and, when the
56
+ * operator configures a credential host allowlist
57
+ * (lib/tools/credentialHosts.ts, ADR 0122), only to a host it binds the
58
+ * `client_secret_env` variable to: checked at compile, when the consent
59
+ * clients are built, and before each client-credentials token request,
60
+ * code exchange (lib/tools/oauthConsent.ts) and refresh.
54
61
  */
55
62
  import type { OAuthProvider } from './auth.ts';
56
63
  import type { OAuthHostAllowlist } from './oauthHosts.ts';
@@ -149,7 +156,9 @@ export declare function clientCredentialsGrant(options: ClientCredentialsOptions
149
156
  /**
150
157
  * What a tool calls for the token of one call, naming where that call goes.
151
158
  * Throws ToolCredentialError, whose message names no value: `host_refused`
152
- * when the destination is not one of the provider's hosts.
159
+ * when the destination is not one of the provider's hosts (`allowlist`
160
+ * `'oauth'`), or when a client secret's token endpoint is not bound to its
161
+ * variable (`allowlist` `'credential'`).
153
162
  */
154
163
  export type OAuthTokenSource = (ctx: ToolContext | undefined, destination: string) => Promise<string>;
155
164
  /**
@@ -51,10 +51,18 @@
51
51
  * (`oauthCallProblem`). The client-credentials token endpoint passes the
52
52
  * same SSRF guard as the server it serves (ALLOW_PRIVATE_OPENAPI or
53
53
  * ALLOW_PRIVATE_MCP), and no redirect from it is followed.
54
+ *
55
+ * WHERE A CLIENT SECRET MAY GO: to the token endpoint, and, when the
56
+ * operator configures a credential host allowlist
57
+ * (lib/tools/credentialHosts.ts, ADR 0122), only to a host it binds the
58
+ * `client_secret_env` variable to: checked at compile, when the consent
59
+ * clients are built, and before each client-credentials token request,
60
+ * code exchange (lib/tools/oauthConsent.ts) and refresh.
54
61
  */
55
62
  import { blockedHostReason, checkHost } from '../net/addressGuard.js';
56
63
  import { PROVIDER_NAME, ToolCredentialError } from './auth.js';
57
64
  import { readCredentialEnv } from './credentialEnv.js';
65
+ import { CLIENT_SECRET_ENV, clientSecretEnvOf, credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
58
66
  import { oauthHostProblem, oauthHosts } from './oauthHosts.js';
59
67
  /** An agent's declared OAuth grants, OpenAPI entries first, then its MCP server. */
60
68
  export function agentOAuthGrants(agent, prefix = '') {
@@ -264,7 +272,7 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
264
272
  /** The call-time check (ADR 0114): before any token is read or fetched. */
265
273
  const refuse = (destination) => {
266
274
  if (oauthCallProblem(oauth2, destination))
267
- throw new ToolCredentialError('host_refused', provider);
275
+ throw new ToolCredentialError('host_refused', provider, 'oauth');
268
276
  };
269
277
  if (oauth2.grant === 'authorization_code') {
270
278
  return async (ctx, destination) => {
@@ -285,6 +293,11 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
285
293
  if (blocked)
286
294
  throw new Error(`${where}: refusing token endpoint ${new URL(oauth2.token_url).hostname}: ${blocked}`);
287
295
  }
296
+ // The client secret goes only to a host the operator binds its variable to (ADR 0122).
297
+ const secretEnv = oauth2.client_secret_env;
298
+ const unbound = credentialHostProblem(secretEnv, oauth2.token_url, 'token_url');
299
+ if (unbound)
300
+ throw new Error(`${where}: ${unbound}`);
288
301
  const grant = clientCredentialsGrant({
289
302
  provider,
290
303
  tokenUrl: oauth2.token_url,
@@ -296,8 +309,11 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
296
309
  });
297
310
  return async (ctx, destination) => {
298
311
  refuse(destination);
299
- // The client secret goes to the token endpoint: held to the same rule.
312
+ // The client secret goes to the token endpoint: held to the same rule,
313
+ // and to the credential host allowlist (ADR 0122).
300
314
  refuse(oauth2.token_url);
315
+ if (credentialCallProblem(secretEnv, oauth2.token_url))
316
+ throw new ToolCredentialError('host_refused', provider, 'credential');
301
317
  return grant.token(ctx?.signal);
302
318
  };
303
319
  }
@@ -326,6 +342,10 @@ export function oauthClientsFor(configs, options = {}) {
326
342
  const hostProblems = oauthGrantHostProblems(o, [], allowlist);
327
343
  if (hostProblems.length)
328
344
  throw new Error(`${where}: ${hostProblems.join('; ')}`);
345
+ // The client secret goes to the token endpoint: a host the operator binds its variable to (ADR 0122).
346
+ const secretProblem = o.client_secret_env ? credentialHostProblem(o.client_secret_env, o.token_url, 'token_url') : null;
347
+ if (secretProblem)
348
+ throw new Error(`${where}: ${secretProblem}`);
329
349
  const sig = JSON.stringify([o.authorization_url, o.token_url, o.client_id ?? null, o.client_id_env ?? null, o.client_secret_env ?? null, [...(o.scopes ?? [])].sort(), o.authorization_params ?? {}]);
330
350
  const before = signature.get(o.provider);
331
351
  if (before !== undefined) {
@@ -341,6 +361,8 @@ export function oauthClientsFor(configs, options = {}) {
341
361
  ...(o.client_secret_env ? { clientSecret: readCredentialEnv(o.client_secret_env, where, env) } : {}),
342
362
  scopes: [...(o.scopes ?? [])],
343
363
  ...(o.authorization_params ? { authorizationParams: { ...o.authorization_params } } : {}),
364
+ // The variable the secret came from, so the exchange and the refresh check its host at call time.
365
+ ...(o.client_secret_env ? { [CLIENT_SECRET_ENV]: o.client_secret_env } : {}),
344
366
  };
345
367
  }
346
368
  return clients;
@@ -403,7 +425,11 @@ export function oauthRefreshProviders(clients, options = {}) {
403
425
  refresh: async (refreshToken, context) => {
404
426
  // A refresh token goes only to a token endpoint the allowlist binds to its provider (ADR 0114).
405
427
  if (oauthCallProblem({ provider: name, grant: 'authorization_code' }, client.tokenUrl))
406
- throw new ToolCredentialError('host_refused', name);
428
+ throw new ToolCredentialError('host_refused', name, 'oauth');
429
+ // The client secret too: only to a host the operator binds its variable to (ADR 0122).
430
+ const secretEnv = clientSecretEnvOf(client);
431
+ if (client.clientSecret && secretEnv && credentialCallProblem(secretEnv, client.tokenUrl))
432
+ throw new ToolCredentialError('host_refused', name, 'credential');
407
433
  const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: client.clientId });
408
434
  if (client.clientSecret)
409
435
  body.set('client_secret', client.clientSecret);
@@ -45,6 +45,13 @@ export declare function boundResult(result: unknown): unknown;
45
45
  * an operation the spec does not have, or a server the guard refuses.
46
46
  */
47
47
  export declare function buildOpenApiOwnTools(entry: OpenApiConfig, baseDir?: string): Promise<Tool[]>;
48
+ /**
49
+ * The server URLs one `openapi:` entry calls: its `base_url`, else its
50
+ * spec's server. Reads and parses the spec (bounded); throws as the compile
51
+ * would on a spec that cannot be read. For the load-time credential host
52
+ * check (lib/tools/credentialUses.ts, ADR 0122).
53
+ */
54
+ export declare function openApiServers(entry: OpenApiConfig, baseDir?: string): string[];
48
55
  /**
49
56
  * The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
50
57
  * Tools, each marked so require_approval can name it by its operationId.
@@ -36,6 +36,12 @@
36
36
  * instead, fetched per call: the run's user's own (authorization_code,
37
37
  * through `ctx.accessToken` and the consent pause) or the server's own
38
38
  * (client_credentials), lib/tools/oauthTools.ts, ADR 0112.
39
+ * - STATIC CREDENTIALS ONLY TO THEIR HOSTS. When the operator configures a
40
+ * credential host allowlist (MELCHIZEDEK_CREDENTIAL_HOSTS, or
41
+ * createA2AApp's credentialHosts; lib/tools/credentialHosts.ts,
42
+ * ADR 0122), a `bearer_env` or `api_key` variable is sent only to a host
43
+ * it binds that variable to: checked at compile (every server), and
44
+ * again before each call, which is refused with nothing sent.
39
45
  * - THE SSRF GUARD. Every server URL the tools will call must be http(s)
40
46
  * and pass lib/net/addressGuard.ts: its literal rules at compile time
41
47
  * (offline), and the full check with DNS before each call (a name can
@@ -67,6 +73,7 @@ import { callOperation, hostProblem } from './openapi/call.js';
67
73
  import { MAX_SPEC_BYTES, operationNamed, parseOpenApiSpec } from './openapi/parse.js';
68
74
  import { ToolCredentialError } from './auth.js';
69
75
  import { readCredentialEnv } from './credentialEnv.js';
76
+ import { credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
70
77
  import { oauthTokenSource, tokenTransportProblem } from './oauthTools.js';
71
78
  export { credentialEnvProblem } from './credentialEnv.js';
72
79
  export { namesTool, toSnake } from './openapi/parse.js';
@@ -89,11 +96,15 @@ function credentialFor(auth, spec) {
89
96
  return undefined;
90
97
  const read = (env) => readCredentialEnv(env, `openapi ${spec}`);
91
98
  if (auth.bearer_env)
92
- return { kind: 'bearer', token: read(auth.bearer_env) };
99
+ return { credential: { kind: 'bearer', token: read(auth.bearer_env) }, env: auth.bearer_env };
93
100
  if (auth.api_key)
94
- return { kind: 'api_key', in: auth.api_key.in, name: auth.api_key.name, value: read(auth.api_key.env) };
101
+ return { credential: { kind: 'api_key', in: auth.api_key.in, name: auth.api_key.name, value: read(auth.api_key.env) }, env: auth.api_key.env };
95
102
  return undefined;
96
103
  }
104
+ /** The variable a static `auth` sends (bearer_env or api_key.env), or undefined. */
105
+ function staticCredentialEnv(auth) {
106
+ return auth?.bearer_env ?? auth?.api_key?.env;
107
+ }
97
108
  /**
98
109
  * Cut an API result to MAX_RESULT_CHARS, saying so. Kept for consumers of
99
110
  * this module; the tools cut with capResult (lib/tools/tool.ts).
@@ -138,11 +149,25 @@ function readSpec(specPath, source) {
138
149
  * provider and what to do, never a value (and, for a user who has not
139
150
  * granted it, the run's consent step has already asked).
140
151
  */
141
- function operationTool(op, credential, oauth) {
152
+ function operationTool(op, held, oauth) {
153
+ const credential = held?.credential;
142
154
  const tool = {
143
155
  name: op.name,
144
156
  declaration: () => op.declaration,
145
157
  async execute(args, ctx) {
158
+ // The operator's credential host allowlist, again at each call (ADR 0122): the
159
+ // compile checked it, and it can change. Refused, nothing is sent; the
160
+ // message names the host, never the value.
161
+ if (held && credentialCallProblem(held.env, op.baseUrl)) {
162
+ let host = op.baseUrl;
163
+ try {
164
+ host = new URL(op.baseUrl).hostname;
165
+ }
166
+ catch {
167
+ // A server that is not a URL is named as written.
168
+ }
169
+ return { error: `${op.name} was not called: the operator's credential host allowlist does not let this API's credential be sent to ${host}` };
170
+ }
146
171
  let sent = credential;
147
172
  if (oauth) {
148
173
  try {
@@ -186,12 +211,17 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
186
211
  picked = all.filter((o) => o.method === 'get');
187
212
  }
188
213
  const chosen = entry.base_url ? picked.map((o) => ({ ...o, baseUrl: entry.base_url })) : picked;
214
+ const credentialEnv = staticCredentialEnv(entry.auth);
189
215
  for (const baseUrl of new Set(chosen.map((o) => o.baseUrl))) {
190
216
  if (!baseUrl)
191
217
  throw new Error(`openapi ${entry.spec}: the spec names no server; set base_url`);
192
218
  const problem = await hostProblem(baseUrl, false);
193
219
  if (problem)
194
220
  throw new Error(`openapi ${entry.spec}: ${problem}`);
221
+ // A static credential goes only to a host the operator binds its variable to (ADR 0122).
222
+ const bound = credentialEnv ? credentialHostProblem(credentialEnv, baseUrl) : null;
223
+ if (bound)
224
+ throw new Error(`openapi ${entry.spec}: ${bound}`);
195
225
  }
196
226
  let oauth;
197
227
  if (entry.auth?.oauth2 && chosen.length) {
@@ -205,6 +235,22 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
205
235
  }
206
236
  return chosen.map((o) => operationTool(o, credential, oauth));
207
237
  }
238
+ /**
239
+ * The server URLs one `openapi:` entry calls: its `base_url`, else its
240
+ * spec's server. Reads and parses the spec (bounded); throws as the compile
241
+ * would on a spec that cannot be read. For the load-time credential host
242
+ * check (lib/tools/credentialUses.ts, ADR 0122).
243
+ */
244
+ export function openApiServers(entry, baseDir = process.cwd()) {
245
+ if (entry.base_url)
246
+ return [entry.base_url];
247
+ const specPath = resolve(baseDir, entry.spec);
248
+ const ext = extname(specPath).toLowerCase();
249
+ if (!['.yaml', '.yml', '.json'].includes(ext))
250
+ throw new Error(`openapi ${entry.spec}: a spec is .yaml, .yml or .json`);
251
+ const all = parseOpenApiSpec(readSpec(specPath, entry.spec), ext === '.json' ? 'json' : 'yaml', { source: entry.spec });
252
+ return [...new Set(all.map((o) => o.baseUrl).filter((u) => u !== ''))];
253
+ }
208
254
  /**
209
255
  * The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
210
256
  * Tools, each marked so require_approval can name it by its operationId.