@nullsquare/agent-authority 0.4.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 (58) hide show
  1. package/CONTRIBUTING.md +93 -0
  2. package/LICENSE +201 -0
  3. package/README.md +390 -0
  4. package/ROADMAP.md +149 -0
  5. package/SECURITY.md +116 -0
  6. package/docs/account-connections.md +173 -0
  7. package/docs/announcement-draft.md +13 -0
  8. package/docs/architecture.md +106 -0
  9. package/docs/assets/agent-authority-cover.svg +41 -0
  10. package/docs/clear-path.md +53 -0
  11. package/docs/cli.md +130 -0
  12. package/docs/evidence.md +143 -0
  13. package/docs/harness-bridge-mode.md +136 -0
  14. package/docs/harness-integration.md +223 -0
  15. package/docs/integration-contract.md +132 -0
  16. package/docs/integrations/vercel-ai-sdk.md +161 -0
  17. package/docs/launch-checklist.md +29 -0
  18. package/docs/npm-release.md +19 -0
  19. package/docs/openclaw-integration.md +97 -0
  20. package/docs/package-consumer-validation.md +18 -0
  21. package/docs/release-candidate-status.md +3 -0
  22. package/docs/release-guardrails.md +8 -0
  23. package/docs/release-notes-v0.4.md +26 -0
  24. package/docs/release-scope.md +3 -0
  25. package/docs/ship-criteria.md +3 -0
  26. package/docs/task-leases.md +253 -0
  27. package/docs/validation.md +124 -0
  28. package/examples/demo.js +19 -0
  29. package/examples/direct-guard.js +50 -0
  30. package/examples/harness-managed-connectors.js +72 -0
  31. package/examples/live-github-derived-mutation.js +208 -0
  32. package/examples/live-github-task-lease.js +80 -0
  33. package/examples/mission.json +20 -0
  34. package/examples/missions/chatgpt-web-validation.json +33 -0
  35. package/examples/openclaw-tool-wrapper.js +49 -0
  36. package/examples/task-lease-demo.js +98 -0
  37. package/examples/validation-mcp-upstream.js +112 -0
  38. package/package.json +80 -0
  39. package/src/agent-auth.js +135 -0
  40. package/src/approvals.js +157 -0
  41. package/src/cli.js +335 -0
  42. package/src/connections.js +203 -0
  43. package/src/execution.js +174 -0
  44. package/src/guard.js +79 -0
  45. package/src/harness-bridge.js +131 -0
  46. package/src/idempotency.js +118 -0
  47. package/src/index.js +291 -0
  48. package/src/integrations/ai-sdk.js +59 -0
  49. package/src/keys.js +15 -0
  50. package/src/mcp-gateway.js +142 -0
  51. package/src/mcp-remote.js +102 -0
  52. package/src/mcp-server.js +102 -0
  53. package/src/providers/github.js +149 -0
  54. package/src/runtime-env.js +53 -0
  55. package/src/sdk.js +75 -0
  56. package/src/server.js +146 -0
  57. package/src/storage.js +213 -0
  58. package/src/task-lease.js +266 -0
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { hashObject } from './index.js';
5
+
6
+ function ensureDir(path) {
7
+ mkdirSync(path, { recursive: true, mode: 0o700 });
8
+ try { chmodSync(path, 0o700); } catch {}
9
+ }
10
+
11
+ function readJson(path) {
12
+ if (!existsSync(path)) return {};
13
+ return JSON.parse(readFileSync(path, 'utf8'));
14
+ }
15
+
16
+ function atomicJson(path, value) {
17
+ ensureDir(dirname(path));
18
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
19
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
20
+ try { chmodSync(tmp, 0o600); } catch {}
21
+ renameSync(tmp, path);
22
+ try { chmodSync(path, 0o600); } catch {}
23
+ }
24
+
25
+ function guardError(code, message, record = null) {
26
+ const error = new Error(message);
27
+ error.code = code;
28
+ error.execution_record = record;
29
+ return error;
30
+ }
31
+
32
+ function fingerprintRequest(request = {}) {
33
+ const { approval_id, ...rest } = request;
34
+ return rest;
35
+ }
36
+
37
+ export function executionFingerprint(mission, request) {
38
+ return hashObject({ mission_hash: hashObject(mission), request: fingerprintRequest(request) });
39
+ }
40
+
41
+ export class JsonFileExecutionGuard {
42
+ constructor(path) {
43
+ this.path = path;
44
+ ensureDir(dirname(path));
45
+ }
46
+
47
+ all() { return readJson(this.path); }
48
+ write(value) { atomicJson(this.path, value); }
49
+ key(missionId, idempotencyKey) { return `${missionId}\u0000${idempotencyKey}`; }
50
+
51
+ begin({ mission, request, now = Date.now() }) {
52
+ const idempotencyKey = request?.idempotency_key;
53
+ if (!idempotencyKey) throw guardError('idempotency_key_required', 'mutating actions require request.idempotency_key');
54
+ if (String(idempotencyKey).length > 200) throw guardError('invalid_idempotency_key', 'idempotency key is too long');
55
+
56
+ const all = this.all();
57
+ const key = this.key(mission.mission_id, idempotencyKey);
58
+ const fingerprint = executionFingerprint(mission, request);
59
+ const existing = all[key];
60
+ if (existing) {
61
+ if (existing.fingerprint !== fingerprint) {
62
+ throw guardError('idempotency_conflict', 'idempotency key was already used for a different request', existing);
63
+ }
64
+ throw guardError(
65
+ existing.status === 'succeeded' ? 'duplicate_execution' : 'execution_already_started',
66
+ `idempotent execution is already ${existing.status}`,
67
+ existing
68
+ );
69
+ }
70
+
71
+ const record = {
72
+ execution_id: `execution:${randomUUID()}`,
73
+ mission_id: mission.mission_id,
74
+ agent_id: mission.agent.id,
75
+ idempotency_key: String(idempotencyKey),
76
+ fingerprint,
77
+ service: request.service,
78
+ action: request.action,
79
+ status: 'in_progress',
80
+ started_at: new Date(Number(now)).toISOString(),
81
+ completed_at: null,
82
+ receipt_id: null,
83
+ error_code: null
84
+ };
85
+ all[key] = record;
86
+ this.write(all);
87
+ return { ...record };
88
+ }
89
+
90
+ complete({ mission, request, receipt_id = null, now = Date.now() }) {
91
+ return this.finish({ mission, request, status: 'succeeded', receipt_id, now });
92
+ }
93
+
94
+ uncertain({ mission, request, error_code = 'execution_uncertain', now = Date.now() }) {
95
+ return this.finish({ mission, request, status: 'uncertain', error_code, now });
96
+ }
97
+
98
+ finish({ mission, request, status, receipt_id = null, error_code = null, now = Date.now() }) {
99
+ const all = this.all();
100
+ const key = this.key(mission.mission_id, request.idempotency_key);
101
+ const record = all[key];
102
+ if (!record) throw guardError('execution_record_missing', 'execution guard record is missing');
103
+ record.status = status;
104
+ record.completed_at = new Date(Number(now)).toISOString();
105
+ record.receipt_id = receipt_id;
106
+ record.error_code = error_code;
107
+ all[key] = record;
108
+ this.write(all);
109
+ return { ...record };
110
+ }
111
+
112
+ list({ mission_id, status } = {}) {
113
+ return Object.values(this.all())
114
+ .filter((record) => !mission_id || record.mission_id === mission_id)
115
+ .filter((record) => !status || record.status === status)
116
+ .map((record) => ({ ...record }));
117
+ }
118
+ }
package/src/index.js ADDED
@@ -0,0 +1,291 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ function stable(value) {
4
+ if (Array.isArray(value)) return value.map(stable);
5
+ if (value && typeof value === 'object') {
6
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function hashObject(value) {
12
+ return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
13
+ }
14
+
15
+ export function matchPattern(pattern, value) {
16
+ if (typeof pattern !== 'string' || typeof value !== 'string') return false;
17
+ if (pattern === '*' || pattern === value) return true;
18
+ if (pattern.endsWith('*')) return value.startsWith(pattern.slice(0, -1));
19
+ return false;
20
+ }
21
+
22
+ function anyMatch(patterns, value) {
23
+ return patterns.some((pattern) => matchPattern(pattern, value));
24
+ }
25
+
26
+ function asPatterns(value) {
27
+ if (Array.isArray(value)) return value;
28
+ return value === undefined || value === null ? [] : [String(value)];
29
+ }
30
+
31
+ function contextConstraintFailure(constraints = {}, context = {}) {
32
+ for (const [field, allowed] of Object.entries(constraints)) {
33
+ if (context[field] === undefined || context[field] === null) {
34
+ return { code: 'resource_context_missing', reason: `request context.${field} is required by mission policy` };
35
+ }
36
+ const value = String(context[field]);
37
+ if (!anyMatch(asPatterns(allowed), value)) {
38
+ return { code: 'resource_constraint_mismatch', reason: `request context.${field} is outside mission authority` };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function patternCovers(parentPattern, childPattern) {
45
+ if (parentPattern === '*') return true;
46
+ if (parentPattern === childPattern) return true;
47
+ if (typeof parentPattern === 'string' && parentPattern.endsWith('*')) {
48
+ return String(childPattern).startsWith(parentPattern.slice(0, -1));
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function constraintsAreAttenuated(parentConstraints = {}, childConstraints = {}) {
54
+ for (const [field, parentAllowedRaw] of Object.entries(parentConstraints)) {
55
+ const childAllowedRaw = childConstraints[field];
56
+ if (childAllowedRaw === undefined) return false;
57
+ const parentAllowed = asPatterns(parentAllowedRaw);
58
+ const childAllowed = asPatterns(childAllowedRaw);
59
+ if (childAllowed.length === 0) return false;
60
+ for (const childPattern of childAllowed) {
61
+ if (!parentAllowed.some((parentPattern) => patternCovers(parentPattern, childPattern))) return false;
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+
67
+ export function validateMission(mission) {
68
+ const errors = [];
69
+ if (!mission || typeof mission !== 'object') return { ok: false, errors: ['mission must be an object'] };
70
+ if (!mission.version) errors.push('version is required');
71
+ if (!mission.mission_id) errors.push('mission_id is required');
72
+ if (!mission.principal?.id) errors.push('principal.id is required');
73
+ if (!mission.agent?.id) errors.push('agent.id is required');
74
+ if (!mission.objective) errors.push('objective is required');
75
+ if (!Array.isArray(mission.resources) || mission.resources.length === 0) {
76
+ errors.push('resources must contain at least one service');
77
+ } else {
78
+ mission.resources.forEach((resource, index) => {
79
+ if (!resource?.service) errors.push(`resources[${index}].service is required`);
80
+ if (!Array.isArray(resource?.allow)) errors.push(`resources[${index}].allow must be an array`);
81
+ if (resource?.deny !== undefined && !Array.isArray(resource.deny)) errors.push(`resources[${index}].deny must be an array`);
82
+ if (resource?.constraints !== undefined && (typeof resource.constraints !== 'object' || Array.isArray(resource.constraints))) {
83
+ errors.push(`resources[${index}].constraints must be an object`);
84
+ }
85
+ });
86
+ }
87
+ return { ok: errors.length === 0, errors };
88
+ }
89
+
90
+ export function assertMission(mission) {
91
+ const result = validateMission(mission);
92
+ if (!result.ok) throw new Error(`Invalid mission: ${result.errors.join('; ')}`);
93
+ return mission;
94
+ }
95
+
96
+ function deny(code, reason) {
97
+ return { decision: 'deny', code, reason };
98
+ }
99
+
100
+ export function evaluateMissionPolicy(missionInput, request, now = new Date()) {
101
+ const mission = assertMission(missionInput);
102
+ const { service, action, context = {} } = request || {};
103
+ if (!service || !action) return deny('invalid_request', 'service and action are required');
104
+
105
+ const expiresAt = mission.constraints?.expires_at;
106
+ if (expiresAt) {
107
+ const parsedExpiry = new Date(expiresAt);
108
+ if (Number.isNaN(parsedExpiry.getTime())) return deny('invalid_mission_expiry', 'mission expiry is not a valid date');
109
+ if (now >= parsedExpiry) return deny('mission_expired', `mission expired at ${expiresAt}`);
110
+ }
111
+
112
+ const depth = request.delegation_depth ?? mission.delegation?.depth ?? 0;
113
+ const maxDepth = mission.constraints?.max_delegation_depth;
114
+ if (maxDepth !== undefined && depth > maxDepth) return deny('delegation_depth_exceeded', `delegation depth ${depth} exceeds ${maxDepth}`);
115
+
116
+ const budget = mission.constraints?.budget;
117
+ if (budget && context.amount !== undefined) {
118
+ const amount = Number(context.amount);
119
+ if (!Number.isFinite(amount) || amount < 0) return deny('invalid_amount', 'request amount must be a non-negative number');
120
+ if (context.currency && context.currency !== budget.currency) return deny('budget_currency_mismatch', 'request currency does not match mission budget');
121
+ if (amount > Number(budget.amount)) return deny('budget_exceeded', `requested amount ${context.amount} exceeds mission cap ${budget.amount}`);
122
+ }
123
+
124
+ const candidates = mission.resources.filter((item) => matchPattern(item.service, service));
125
+ if (candidates.length === 0) return deny('service_not_authorized', `${service} is outside this mission`);
126
+
127
+ const explicitDeny = candidates.find((resource) => anyMatch(resource.deny || [], action));
128
+ if (explicitDeny) return deny('explicit_deny', `${service}:${action} is explicitly denied`);
129
+
130
+ const actionCandidates = candidates.filter((resource) => anyMatch(resource.allow || [], action));
131
+ if (actionCandidates.length === 0) return deny('action_not_authorized', `${service}:${action} is not allowed by this mission`);
132
+
133
+ let matchedResource = null;
134
+ let lastConstraintFailure = null;
135
+ for (const resource of actionCandidates) {
136
+ const failure = contextConstraintFailure(resource.constraints || {}, context);
137
+ if (!failure) {
138
+ matchedResource = resource;
139
+ break;
140
+ }
141
+ lastConstraintFailure = failure;
142
+ }
143
+
144
+ if (!matchedResource) {
145
+ return deny(
146
+ lastConstraintFailure?.code || 'resource_constraint_mismatch',
147
+ lastConstraintFailure?.reason || 'request resource is outside mission authority'
148
+ );
149
+ }
150
+
151
+ const approval = (mission.approvals || []).find((rule) => {
152
+ const m = rule.match || {};
153
+ if (m.service && !matchPattern(m.service, service)) return false;
154
+ if (m.action && !matchPattern(m.action, action)) return false;
155
+ if (m.amount_gt !== undefined && !(Number(context.amount) > Number(m.amount_gt))) return false;
156
+ if (m.context && contextConstraintFailure(m.context, context)) return false;
157
+ return rule.required;
158
+ });
159
+
160
+ if (approval) {
161
+ return {
162
+ decision: 'require_approval',
163
+ reason: approval.reason || 'human approval required by mission policy',
164
+ rule: approval,
165
+ matched_resource: matchedResource.service
166
+ };
167
+ }
168
+
169
+ return {
170
+ decision: 'allow',
171
+ reason: 'authorized by mission policy',
172
+ matched_resource: matchedResource.service
173
+ };
174
+ }
175
+
176
+ export function createReceipt({ mission, request, result, parent_receipt_id = null }) {
177
+ const receipt = {
178
+ receipt_id: `receipt:${randomUUID()}`,
179
+ version: '0.1',
180
+ timestamp: new Date().toISOString(),
181
+ mission_id: mission.mission_id,
182
+ principal_id: mission.principal.id,
183
+ agent_id: mission.agent.id,
184
+ service: request.service,
185
+ action: request.action,
186
+ decision: result.decision,
187
+ reason: result.reason,
188
+ request_hash: hashObject(request),
189
+ mission_hash: hashObject(mission),
190
+ parent_receipt_id
191
+ };
192
+ return { ...receipt, receipt_hash: hashObject(receipt) };
193
+ }
194
+
195
+ export class InMemoryRevocationStore {
196
+ constructor() { this.revoked = new Map(); }
197
+ revoke(missionId, reason = 'revoked by principal') {
198
+ const record = { reason, revoked_at: new Date().toISOString() };
199
+ this.revoked.set(missionId, record);
200
+ return record;
201
+ }
202
+ get(missionId) { return this.revoked.get(missionId) || null; }
203
+ }
204
+
205
+ export class AdapterRegistry {
206
+ constructor() { this.adapters = []; }
207
+ register(adapter) { this.adapters.push(adapter); return this; }
208
+ resolve(service) { return this.adapters.find((adapter) => adapter.supports(service)); }
209
+ }
210
+
211
+ export function descriptorAdapter(kind, services) {
212
+ return {
213
+ kind,
214
+ supports(service) { return services.some((pattern) => matchPattern(pattern, service)); },
215
+ async prepare({ mission, request }) {
216
+ return {
217
+ kind,
218
+ service: request.service,
219
+ action: request.action,
220
+ mission_id: mission.mission_id,
221
+ note: `${kind} adapter descriptor only; production credential exchange is not implemented yet`
222
+ };
223
+ }
224
+ };
225
+ }
226
+
227
+ export class AuthorityRuntime {
228
+ constructor({ adapters = new AdapterRegistry(), revocations = new InMemoryRevocationStore() } = {}) {
229
+ this.adapters = adapters;
230
+ this.revocations = revocations;
231
+ }
232
+
233
+ revoke(missionId, reason) { return this.revocations.revoke(missionId, reason); }
234
+
235
+ evaluate(missionInput, request, now = new Date()) {
236
+ const mission = assertMission(missionInput);
237
+ const revoked = this.revocations.get(mission.mission_id);
238
+ const result = revoked ? deny('mission_revoked', revoked.reason) : evaluateMissionPolicy(mission, request, now);
239
+ return { result, receipt: createReceipt({ mission, request, result }) };
240
+ }
241
+
242
+ async prepare(missionInput, request, now = new Date()) {
243
+ const evaluation = this.evaluate(missionInput, request, now);
244
+ if (evaluation.result.decision !== 'allow') return evaluation;
245
+ const adapter = this.adapters.resolve(request.service);
246
+ if (!adapter) return { ...evaluation, dispatch: null };
247
+ return { ...evaluation, dispatch: await adapter.prepare({ mission: missionInput, request }) };
248
+ }
249
+ }
250
+
251
+ export function deriveMission(parentInput, childSpec) {
252
+ const parent = assertMission(parentInput);
253
+ const parentDepth = parent.delegation?.depth ?? 0;
254
+ const maxDepth = parent.constraints?.max_delegation_depth ?? 0;
255
+ if (parentDepth >= maxDepth) throw new Error('parent mission cannot delegate further');
256
+ if (!childSpec?.agent?.id) throw new Error('childSpec.agent.id is required');
257
+ if (!Array.isArray(childSpec.resources) || childSpec.resources.length === 0) throw new Error('childSpec.resources are required');
258
+
259
+ for (const childResource of childSpec.resources) {
260
+ const parentResources = parent.resources.filter((r) => matchPattern(r.service, childResource.service));
261
+ if (parentResources.length === 0) throw new Error(`child service ${childResource.service} is not authorized by parent`);
262
+
263
+ const compatibleParent = parentResources.find((parentResource) => {
264
+ for (const childAllow of childResource.allow || []) {
265
+ if ((parentResource.deny || []).some((d) => matchPattern(d, childAllow))) return false;
266
+ if (!(parentResource.allow || []).some((a) => matchPattern(a, childAllow))) return false;
267
+ }
268
+ return constraintsAreAttenuated(parentResource.constraints || {}, childResource.constraints || {});
269
+ });
270
+
271
+ if (!compatibleParent) throw new Error(`child authority for ${childResource.service} expands parent authority`);
272
+ }
273
+
274
+ return assertMission({
275
+ version: parent.version,
276
+ mission_id: childSpec.mission_id || `mission:${randomUUID()}`,
277
+ principal: parent.principal,
278
+ agent: childSpec.agent,
279
+ objective: childSpec.objective || `Delegated task under ${parent.mission_id}`,
280
+ resources: childSpec.resources,
281
+ constraints: {
282
+ ...childSpec.constraints,
283
+ max_delegation_depth: Math.min(
284
+ childSpec.constraints?.max_delegation_depth ?? 0,
285
+ Math.max(0, maxDepth - parentDepth - 1)
286
+ )
287
+ },
288
+ approvals: childSpec.approvals || parent.approvals || [],
289
+ delegation: { parent_mission_id: parent.mission_id, depth: parentDepth + 1 }
290
+ });
291
+ }
@@ -0,0 +1,59 @@
1
+ export class UnmappedAiSdkToolError extends Error {
2
+ constructor(toolName) {
3
+ super(`AI SDK tool ${toolName} has execute() but no Agent Authority request mapping`);
4
+ this.name = 'UnmappedAiSdkToolError';
5
+ this.code = 'ai_sdk_tool_unmapped';
6
+ this.tool_name = toolName;
7
+ }
8
+ }
9
+
10
+ function assertGuard(guard) {
11
+ if (!guard || typeof guard.run !== 'function') {
12
+ throw new Error('Agent Authority guard with run(request, effect) is required');
13
+ }
14
+ }
15
+
16
+ function assertRequest(toolName, request) {
17
+ if (!request || typeof request !== 'object') {
18
+ throw new Error(`request mapper for AI SDK tool ${toolName} must return an authority request`);
19
+ }
20
+ if (!request.service || !request.action) {
21
+ throw new Error(`authority request for AI SDK tool ${toolName} requires service and action`);
22
+ }
23
+ return request;
24
+ }
25
+
26
+ /**
27
+ * Wrap Vercel AI SDK Tool objects without changing their schemas, descriptions,
28
+ * approval metadata, or the surrounding ToolLoopAgent architecture.
29
+ *
30
+ * Every executable tool must have a request mapper. Missing mappings fail
31
+ * closed at execution time rather than silently bypassing Agent Authority.
32
+ */
33
+ export function protectAiSdkTools({ tools, guard, requests = {} } = {}) {
34
+ assertGuard(guard);
35
+ if (!tools || typeof tools !== 'object' || Array.isArray(tools)) {
36
+ throw new Error('AI SDK tools object is required');
37
+ }
38
+ if (!requests || typeof requests !== 'object' || Array.isArray(requests)) {
39
+ throw new Error('AI SDK request mappings must be an object');
40
+ }
41
+
42
+ return Object.fromEntries(Object.entries(tools).map(([toolName, tool]) => {
43
+ if (!tool || typeof tool !== 'object') return [toolName, tool];
44
+ if (typeof tool.execute !== 'function') return [toolName, tool];
45
+
46
+ const originalExecute = tool.execute;
47
+ const mapper = requests[toolName];
48
+
49
+ return [toolName, {
50
+ ...tool,
51
+ execute: async (input, options) => {
52
+ if (typeof mapper !== 'function') throw new UnmappedAiSdkToolError(toolName);
53
+ const request = assertRequest(toolName, await mapper(input, options));
54
+ const { output } = await guard.run(request, () => originalExecute.call(tool, input, options));
55
+ return output;
56
+ }
57
+ }];
58
+ }));
59
+ }
package/src/keys.js ADDED
@@ -0,0 +1,15 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ export function readOrCreateSecretKey(path, bytes = 32) {
6
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
7
+ try { chmodSync(dirname(path), 0o700); } catch {}
8
+ if (!existsSync(path)) {
9
+ writeFileSync(path, randomBytes(bytes), { mode: 0o600 });
10
+ try { chmodSync(path, 0o600); } catch {}
11
+ }
12
+ const key = readFileSync(path);
13
+ if (key.length !== bytes) throw new Error(`secret key at ${path} must contain exactly ${bytes} bytes`);
14
+ return key;
15
+ }
@@ -0,0 +1,142 @@
1
+ import { matchPattern } from './index.js';
2
+
3
+ function anyMatch(patterns = [], value) {
4
+ return patterns.some((pattern) => matchPattern(pattern, value));
5
+ }
6
+
7
+ export function mcpToolAction(toolName) {
8
+ if (!toolName) throw new Error('tool name is required');
9
+ return `tool.${toolName}`;
10
+ }
11
+
12
+ export function contextFromToolArguments(toolName, args = {}) {
13
+ const context = { tool_name: toolName };
14
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return context;
15
+
16
+ for (const [key, value] of Object.entries(args)) {
17
+ if (['string', 'number', 'boolean'].includes(typeof value)) context[key] = value;
18
+ }
19
+ return context;
20
+ }
21
+
22
+ export function toolPotentiallyVisible(mission, service, toolName) {
23
+ const action = mcpToolAction(toolName);
24
+ const resources = (mission?.resources || []).filter((resource) => matchPattern(resource.service, service));
25
+ if (!resources.length) return false;
26
+ if (resources.some((resource) => anyMatch(resource.deny || [], action))) return false;
27
+ return resources.some((resource) => anyMatch(resource.allow || [], action));
28
+ }
29
+
30
+ export function isDeclaredReadOnlyTool(tool) {
31
+ return tool?.annotations?.readOnlyHint === true;
32
+ }
33
+
34
+ function deniedToolResult(result, extra = {}) {
35
+ return {
36
+ content: [{
37
+ type: 'text',
38
+ text: `Agent Authority blocked this tool call: ${result.reason || result.code || 'not authorized'}`
39
+ }],
40
+ isError: true,
41
+ _meta: {
42
+ 'io.nullsquare.agent-authority/decision': result.decision || 'deny',
43
+ 'io.nullsquare.agent-authority/code': result.code || 'not_authorized',
44
+ ...extra
45
+ }
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Small, transport-neutral policy gateway for MCP tools.
51
+ *
52
+ * The upstream object only needs two methods:
53
+ * listTools(params?) -> { tools: [...] }
54
+ * callTool(params) -> MCP CallToolResult
55
+ *
56
+ * v0.1 deliberately defaults to read-only enforcement. A tool is considered
57
+ * read-only only when its MCP annotations explicitly set readOnlyHint=true.
58
+ * Write support belongs behind the existing approval + idempotency runtime and
59
+ * is intentionally not inferred from tool names.
60
+ */
61
+ export class MissionMcpGateway {
62
+ constructor({
63
+ mission,
64
+ runtime,
65
+ upstream,
66
+ service = 'mcp:upstream',
67
+ readOnly = true,
68
+ contextMapper = contextFromToolArguments
69
+ } = {}) {
70
+ if (!mission) throw new Error('mission is required');
71
+ if (!runtime || typeof runtime.evaluate !== 'function') throw new Error('authority runtime is required');
72
+ if (!upstream || typeof upstream.listTools !== 'function' || typeof upstream.callTool !== 'function') {
73
+ throw new Error('upstream MCP client must implement listTools() and callTool()');
74
+ }
75
+ this.mission = mission;
76
+ this.runtime = runtime;
77
+ this.upstream = upstream;
78
+ this.service = service;
79
+ this.readOnly = readOnly;
80
+ this.contextMapper = contextMapper;
81
+ this.tools = new Map();
82
+ }
83
+
84
+ async refreshTools(params = undefined) {
85
+ const listed = await this.upstream.listTools(params);
86
+ for (const tool of listed.tools || []) this.tools.set(tool.name, tool);
87
+ return listed;
88
+ }
89
+
90
+ async listTools(params = undefined) {
91
+ const listed = await this.refreshTools(params);
92
+ const tools = (listed.tools || []).filter((tool) => {
93
+ if (!toolPotentiallyVisible(this.mission, this.service, tool.name)) return false;
94
+ if (this.readOnly && !isDeclaredReadOnlyTool(tool)) return false;
95
+ return true;
96
+ });
97
+ return { ...listed, tools };
98
+ }
99
+
100
+ async toolDefinition(toolName) {
101
+ if (!this.tools.has(toolName)) await this.refreshTools();
102
+ return this.tools.get(toolName) || null;
103
+ }
104
+
105
+ async callTool(params = {}) {
106
+ const toolName = params.name;
107
+ if (!toolName) return deniedToolResult({ decision: 'deny', code: 'invalid_request', reason: 'tool name is required' });
108
+
109
+ const tool = await this.toolDefinition(toolName);
110
+ if (!tool) return deniedToolResult({ decision: 'deny', code: 'tool_not_found', reason: `upstream tool ${toolName} was not found` });
111
+ if (this.readOnly && !isDeclaredReadOnlyTool(tool)) {
112
+ return deniedToolResult({
113
+ decision: 'deny',
114
+ code: 'mcp_write_disabled',
115
+ reason: `${toolName} is not explicitly declared read-only by the upstream MCP server`
116
+ });
117
+ }
118
+
119
+ const context = this.contextMapper(toolName, params.arguments || {});
120
+ const request = {
121
+ service: this.service,
122
+ action: mcpToolAction(toolName),
123
+ context
124
+ };
125
+ const evaluation = this.runtime.evaluate(this.mission, request);
126
+ if (evaluation.result.decision !== 'allow') {
127
+ return deniedToolResult(evaluation.result, {
128
+ 'io.nullsquare.agent-authority/receipt_hash': evaluation.receipt?.receipt_hash || null
129
+ });
130
+ }
131
+
132
+ const output = await this.upstream.callTool(params);
133
+ return {
134
+ ...output,
135
+ _meta: {
136
+ ...(output?._meta || {}),
137
+ 'io.nullsquare.agent-authority/decision': 'allow',
138
+ 'io.nullsquare.agent-authority/receipt_hash': evaluation.receipt?.receipt_hash || null
139
+ }
140
+ };
141
+ }
142
+ }