@nullsquare/agent-authority 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/ROADMAP.md +38 -15
- package/docs/durable-task-leases.md +336 -0
- package/docs/npm-release.md +10 -3
- package/docs/transport-invariance.md +153 -0
- package/package.json +3 -2
- package/src/durable-task-lease.js +185 -0
- package/src/execution.js +34 -3
- package/src/mcp-gateway.js +35 -12
- package/src/mcp-remote.js +5 -1
- package/src/mcp-server.js +3 -0
- package/src/storage.js +298 -9
- package/src/task-lease.js +243 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Task Lease transport invariance
|
|
2
|
+
|
|
3
|
+
Agent Authority treats SDK calls, MCP calls and brokered provider execution as execution paths, not separate authority models.
|
|
4
|
+
|
|
5
|
+
The property under test is:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
same Task Lease + same established authority fact
|
|
9
|
+
|
|
|
10
|
+
+----------+----------+
|
|
11
|
+
| | |
|
|
12
|
+
direct MCP brokered
|
|
13
|
+
guard.run() gateway provider
|
|
14
|
+
| | |
|
|
15
|
+
+----------+----------+
|
|
16
|
+
|
|
|
17
|
+
same authority
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Changing the transport must not broaden task authority.
|
|
21
|
+
|
|
22
|
+
## Executable transport proof
|
|
23
|
+
|
|
24
|
+
`test/transport-invariance.test.js` creates one Task Lease and one derived authority fact.
|
|
25
|
+
|
|
26
|
+
The fact is first established from a brokered provider result using the strict evidence path:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Task Lease root
|
|
30
|
+
|
|
|
31
|
+
v
|
|
32
|
+
brokered item.discover
|
|
33
|
+
|
|
|
34
|
+
+--> Task-Lease ALLOW receipt
|
|
35
|
+
+--> exact output hash evidence
|
|
36
|
+
|
|
|
37
|
+
v
|
|
38
|
+
reviewed test extractor
|
|
39
|
+
|
|
|
40
|
+
v
|
|
41
|
+
deriveFromEvidence()
|
|
42
|
+
|
|
|
43
|
+
v
|
|
44
|
+
fact:selected-item = alpha
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That exact Task Lease and fact are then used through three execution paths:
|
|
48
|
+
|
|
49
|
+
1. ordinary in-process `guard.run()`;
|
|
50
|
+
2. `MissionMcpGateway` configured with the Task Lease;
|
|
51
|
+
3. `ExecutingAuthorityRuntime.executeTaskLease()` with a brokered provider adapter.
|
|
52
|
+
|
|
53
|
+
For `item = alpha`, every path allows execution.
|
|
54
|
+
|
|
55
|
+
For `item = beta`, every path returns the same task-level authority delta:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
authority_delta_required
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The blocked direct callback, MCP upstream call and brokered provider operation all remain unexecuted.
|
|
62
|
+
|
|
63
|
+
After the Task Lease is completed, all three paths return:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
task_lease_completed
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Again, no blocked execution reaches the host callback or provider boundary.
|
|
70
|
+
|
|
71
|
+
## Broker behavior
|
|
72
|
+
|
|
73
|
+
`ExecutingAuthorityRuntime.executeTaskLease()` evaluates the Task Lease before adapter readiness or provider execution.
|
|
74
|
+
|
|
75
|
+
A lease-level `require_approval` result is returned as-is. Brokered execution does not consume a mission-level one-time approval to bypass the narrower Task Lease. Applying an explicitly approved authority delta back into a live Task Lease is separate roadmap work.
|
|
76
|
+
|
|
77
|
+
Successful brokered Task Lease execution returns execution evidence bound to the Task-Lease receipt and exact provider output, so strict derived authority can originate from brokered execution as well as from `guard.run()`.
|
|
78
|
+
|
|
79
|
+
## MCP behavior
|
|
80
|
+
|
|
81
|
+
`MissionMcpGateway` remains backward-compatible with Mission-only use, but now accepts exactly one authority source:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
mission OR lease
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
When configured with a Task Lease, each tool call is evaluated through that lease before `upstream.callTool()` can run.
|
|
88
|
+
|
|
89
|
+
MCP result metadata includes:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
io.nullsquare.agent-authority/decision
|
|
93
|
+
io.nullsquare.agent-authority/code
|
|
94
|
+
io.nullsquare.agent-authority/receipt_hash
|
|
95
|
+
io.nullsquare.agent-authority/task_lease_id
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The remote MCP handler and loopback proxy can pass the same Task Lease into the gateway.
|
|
99
|
+
|
|
100
|
+
## AI SDK harness proof
|
|
101
|
+
|
|
102
|
+
`test/integrations/ai-sdk.integration.mjs` drives the current Vercel AI SDK `ToolLoopAgent` with the output of `protectAiSdkTools()` as its executable tool set.
|
|
103
|
+
|
|
104
|
+
The positive path asks the model to use the task-bound GitHub issue. The protected tool executes exactly once.
|
|
105
|
+
|
|
106
|
+
The same real agent loop then exercises three adversarial paths:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
unrelated issue
|
|
110
|
+
-> AuthorityApprovalRequiredError
|
|
111
|
+
-> authority_delta_required
|
|
112
|
+
-> underlying effect count remains 0
|
|
113
|
+
|
|
114
|
+
executable tool with no Agent Authority mapping
|
|
115
|
+
-> UnmappedAiSdkToolError
|
|
116
|
+
-> ai_sdk_tool_unmapped
|
|
117
|
+
-> underlying effect count remains 0
|
|
118
|
+
|
|
119
|
+
completed Task Lease
|
|
120
|
+
-> AuthorityDeniedError
|
|
121
|
+
-> task_lease_completed
|
|
122
|
+
-> underlying effect count remains 0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The AI SDK represents tool execution failures in the generated result as `tool-error` content parts rather than requiring `agent.generate()` itself to reject. The proof therefore checks both sides of the boundary: the harness records the exact Agent Authority error and the protected underlying side effect never runs.
|
|
126
|
+
|
|
127
|
+
This matters because the model is not calling the wrapped function directly in these cases. The real `ToolLoopAgent` selects and invokes the tool through its normal tool loop, and the Agent Authority wrapper remains the executable boundary.
|
|
128
|
+
|
|
129
|
+
## What this proves
|
|
130
|
+
|
|
131
|
+
- Task-Lease narrowing is not specific to the direct SDK guard;
|
|
132
|
+
- MCP cannot silently fall back to Mission-only authority when explicitly configured with a Task Lease;
|
|
133
|
+
- brokered provider execution enforces the same Task Lease before credential-backed execution;
|
|
134
|
+
- one evidence-derived fact can constrain direct SDK, MCP and brokered execution;
|
|
135
|
+
- task completion invalidates the same authority across those execution paths;
|
|
136
|
+
- broker credentials may remain connected after task authority disappears;
|
|
137
|
+
- a configured Vercel AI SDK `ToolLoopAgent` whose executable tool set is passed through `protectAiSdkTools()` cannot use its normal tool path to bypass Task-Lease narrowing;
|
|
138
|
+
- executable AI SDK tools without an Agent Authority request mapping fail closed before their underlying effect executes.
|
|
139
|
+
|
|
140
|
+
## Boundary of the claim
|
|
141
|
+
|
|
142
|
+
This is an execution-boundary guarantee, not hostile-host containment.
|
|
143
|
+
|
|
144
|
+
It does **not** prove that a malicious application host cannot deliberately give the model another unwrapped tool, direct provider credential, shell, network client or other execution channel outside Agent Authority.
|
|
145
|
+
|
|
146
|
+
It also does not yet prove that:
|
|
147
|
+
|
|
148
|
+
- Task Lease state survives process restart;
|
|
149
|
+
- the same lease can be serialized and safely recovered across separate processes or hosts;
|
|
150
|
+
- an approved authority delta is durably applied back into a running lease;
|
|
151
|
+
- provider outputs are cryptographically attested by providers.
|
|
152
|
+
|
|
153
|
+
M4 is complete for configured Agent Authority execution boundaries: direct SDK, MCP, brokered execution and the Vercel AI SDK `ToolLoopAgent` protected-tool path now preserve task authority without silent expansion. Durability, cross-process recovery and hostile-host containment are separate problems.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nullsquare/agent-authority",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Task-bounded authority runtime for AI agents: give agents tasks, not standing account permissions.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test": "node --test test/*.test.js",
|
|
42
42
|
"test:ai-sdk": "node --test test/integrations/ai-sdk.integration.mjs",
|
|
43
43
|
"test:coverage": "node --experimental-test-coverage --test test/*.test.js",
|
|
44
|
-
"check:syntax": "node --check src/index.js && node --check src/authority-evidence.js && node --check src/connections.js && node --check src/execution.js && node --check src/providers/github.js && node --check src/providers/google.js && node --check src/storage.js && node --check src/runtime-env.js && node --check src/sdk.js && node --check src/server.js && node --check src/cli.js && node --check src/agent-auth.js && node --check src/approvals.js && node --check src/idempotency.js && node --check src/keys.js && node --check src/harness-bridge.js && node --check src/guard.js && node --check src/task-lease.js && node --check src/mcp-gateway.js && node --check src/mcp-remote.js && node --check src/mcp-server.js && node --check src/integrations/ai-sdk.js && node --check examples/validation-mcp-upstream.js && node --check examples/direct-guard.js && node --check examples/task-lease-demo.js && node --check examples/live-github-task-lease.js && node --check examples/live-github-derived-mutation.js && node --check examples/live-google-cross-provider.js",
|
|
44
|
+
"check:syntax": "node --check src/index.js && node --check src/authority-evidence.js && node --check src/connections.js && node --check src/execution.js && node --check src/providers/github.js && node --check src/providers/google.js && node --check src/storage.js && node --check src/durable-task-lease.js && node --check src/runtime-env.js && node --check src/sdk.js && node --check src/server.js && node --check src/cli.js && node --check src/agent-auth.js && node --check src/approvals.js && node --check src/idempotency.js && node --check src/keys.js && node --check src/harness-bridge.js && node --check src/guard.js && node --check src/task-lease.js && node --check src/mcp-gateway.js && node --check src/mcp-remote.js && node --check src/mcp-server.js && node --check src/integrations/ai-sdk.js && node --check examples/validation-mcp-upstream.js && node --check examples/direct-guard.js && node --check examples/task-lease-demo.js && node --check examples/live-github-task-lease.js && node --check examples/live-github-derived-mutation.js && node --check examples/live-google-cross-provider.js",
|
|
45
45
|
"check:package": "npm pack --dry-run",
|
|
46
46
|
"check": "npm run check:syntax && npm test && npm run demo:task-lease && npm run check:package"
|
|
47
47
|
},
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"./approvals": "./src/approvals.js",
|
|
63
63
|
"./authority-evidence": "./src/authority-evidence.js",
|
|
64
64
|
"./connections": "./src/connections.js",
|
|
65
|
+
"./durable-task-lease": "./src/durable-task-lease.js",
|
|
65
66
|
"./execution": "./src/execution.js",
|
|
66
67
|
"./guard": "./src/guard.js",
|
|
67
68
|
"./harness-bridge": "./src/harness-bridge.js",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { TaskLease } from './task-lease.js';
|
|
2
|
+
|
|
3
|
+
function sessionError(code, message, details = {}) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.code = code;
|
|
6
|
+
Object.assign(error, details);
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function assertStore(store) {
|
|
11
|
+
if (!store || typeof store !== 'object') {
|
|
12
|
+
throw new Error('durable Task Lease store is required');
|
|
13
|
+
}
|
|
14
|
+
for (const method of ['save', 'load', 'transact']) {
|
|
15
|
+
if (typeof store[method] !== 'function') {
|
|
16
|
+
throw new Error(`durable Task Lease store must implement ${method}()`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertLease(lease) {
|
|
23
|
+
if (!(lease instanceof TaskLease)) throw new Error('TaskLease instance is required');
|
|
24
|
+
return lease;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A small stateful facade over JsonFileTaskLeaseStore-style transactional
|
|
29
|
+
* persistence.
|
|
30
|
+
*
|
|
31
|
+
* The session never exposes its mutable TaskLease instance. Reads come from the
|
|
32
|
+
* cached recovered lease, explicit refresh() reloads authenticated state, and
|
|
33
|
+
* evaluate() refreshes before every security decision so another worker's
|
|
34
|
+
* completion or narrowing is observed before the next guarded effect.
|
|
35
|
+
*
|
|
36
|
+
* Mutations use optimistic compare-and-swap against the session's current lease
|
|
37
|
+
* hash. A stale session receives task_lease_state_conflict and must refresh and
|
|
38
|
+
* reconsider the intended authority mutation; semantic mutations are never
|
|
39
|
+
* silently replayed against a newer authority state.
|
|
40
|
+
*/
|
|
41
|
+
export class DurableTaskLeaseSession {
|
|
42
|
+
constructor({ store, mission, lease_id, lease, lease_hash } = {}) {
|
|
43
|
+
this.store = assertStore(store);
|
|
44
|
+
if (!mission || typeof mission !== 'object') throw new Error('mission is required');
|
|
45
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
46
|
+
assertLease(lease);
|
|
47
|
+
if (lease.lease_id !== lease_id || lease.mission.mission_id !== mission.mission_id) {
|
|
48
|
+
throw sessionError('durable_task_lease_identity_mismatch', 'session lease identity does not match mission and lease_id');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._mission = structuredClone(mission);
|
|
52
|
+
this.lease_id = lease_id;
|
|
53
|
+
this._lease = lease;
|
|
54
|
+
this._leaseHash = lease_hash || lease.hash();
|
|
55
|
+
if (this._leaseHash !== lease.hash()) {
|
|
56
|
+
throw sessionError('durable_task_lease_hash_mismatch', 'session lease hash does not match recovered Task Lease state');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get mission() {
|
|
61
|
+
return structuredClone(this._mission);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get status() {
|
|
65
|
+
return this._lease.status;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get expires_at() {
|
|
69
|
+
return this._lease.expires_at;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get completed_at() {
|
|
73
|
+
return this._lease.completed_at;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get completion_reason() {
|
|
77
|
+
return this._lease.completion_reason;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
hash() {
|
|
81
|
+
return this._leaseHash;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
snapshot() {
|
|
85
|
+
return structuredClone(this._lease.snapshot());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fact(factId) {
|
|
89
|
+
return this._lease.fact(factId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
listFacts() {
|
|
93
|
+
return this._lease.listFacts();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
refresh() {
|
|
97
|
+
const lease = this.store.load({
|
|
98
|
+
mission: this._mission,
|
|
99
|
+
lease_id: this.lease_id
|
|
100
|
+
});
|
|
101
|
+
if (!lease) {
|
|
102
|
+
throw sessionError(
|
|
103
|
+
'task_lease_state_missing',
|
|
104
|
+
`task lease ${this.lease_id} has no durable state`,
|
|
105
|
+
{ lease_id: this.lease_id }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
this._lease = lease;
|
|
109
|
+
this._leaseHash = lease.hash();
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
evaluate(runtime, request, now = new Date()) {
|
|
114
|
+
this.refresh();
|
|
115
|
+
return this._lease.evaluate(runtime, request, now);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_commit(mutator) {
|
|
119
|
+
const result = this.store.transact({
|
|
120
|
+
mission: this._mission,
|
|
121
|
+
lease_id: this.lease_id,
|
|
122
|
+
expected_lease_hash: this._leaseHash,
|
|
123
|
+
mutate: mutator
|
|
124
|
+
});
|
|
125
|
+
this._lease = result.lease;
|
|
126
|
+
this._leaseHash = result.lease_hash;
|
|
127
|
+
return result.value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addRoot(options) {
|
|
131
|
+
return this._commit((lease) => lease.addRoot(options));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
derive(options) {
|
|
135
|
+
return this._commit((lease) => lease.derive(options));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
deriveFromEvidence(options) {
|
|
139
|
+
return this._commit((lease) => lease.deriveFromEvidence(options));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
bind(binding) {
|
|
143
|
+
return this._commit((lease) => lease.bind(binding));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
complete(reason = 'task completed') {
|
|
147
|
+
return this._commit((lease) => lease.complete(reason));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createDurableTaskLeaseSession({ store, lease } = {}) {
|
|
152
|
+
assertStore(store);
|
|
153
|
+
assertLease(lease);
|
|
154
|
+
const mission = structuredClone(lease.mission);
|
|
155
|
+
const saved = store.save(lease);
|
|
156
|
+
const recovered = store.load({ mission, lease_id: lease.lease_id });
|
|
157
|
+
if (!recovered) {
|
|
158
|
+
throw sessionError('task_lease_state_missing', `task lease ${lease.lease_id} was not recoverable after creation`);
|
|
159
|
+
}
|
|
160
|
+
return new DurableTaskLeaseSession({
|
|
161
|
+
store,
|
|
162
|
+
mission,
|
|
163
|
+
lease_id: lease.lease_id,
|
|
164
|
+
lease: recovered,
|
|
165
|
+
lease_hash: saved.lease_hash
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function openDurableTaskLeaseSession({ store, mission, lease_id } = {}) {
|
|
170
|
+
assertStore(store);
|
|
171
|
+
if (!mission || typeof mission !== 'object') throw new Error('mission is required');
|
|
172
|
+
if (!lease_id) throw new Error('lease_id is required');
|
|
173
|
+
const missionSnapshot = structuredClone(mission);
|
|
174
|
+
const lease = store.load({ mission: missionSnapshot, lease_id });
|
|
175
|
+
if (!lease) {
|
|
176
|
+
throw sessionError('task_lease_state_missing', `task lease ${lease_id} has no durable state`, { lease_id });
|
|
177
|
+
}
|
|
178
|
+
return new DurableTaskLeaseSession({
|
|
179
|
+
store,
|
|
180
|
+
mission: missionSnapshot,
|
|
181
|
+
lease_id,
|
|
182
|
+
lease,
|
|
183
|
+
lease_hash: lease.hash()
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/execution.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthorityRuntime, createReceipt } from './index.js';
|
|
2
|
+
import { createExecutionEvidence } from './authority-evidence.js';
|
|
2
3
|
|
|
3
4
|
function executionFailure(mission, request, code, reason, extra = {}) {
|
|
4
5
|
const result = { decision: 'deny', code, reason, ...extra };
|
|
@@ -115,10 +116,39 @@ export class ExecutingAuthorityRuntime extends AuthorityRuntime {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Execute through the broker while preserving Task Lease narrowing.
|
|
121
|
+
*
|
|
122
|
+
* A lease-level REQUIRE_APPROVAL is returned before adapter readiness or
|
|
123
|
+
* provider execution. It is not converted into a mission-level one-time
|
|
124
|
+
* approval because applying an authority delta back into a live lease is a
|
|
125
|
+
* separate, not-yet-implemented capability.
|
|
126
|
+
*/
|
|
127
|
+
async executeTaskLease(lease, request) {
|
|
128
|
+
if (!lease || typeof lease.evaluate !== 'function' || !lease.mission) {
|
|
129
|
+
throw new Error('task lease with mission and evaluate() is required');
|
|
130
|
+
}
|
|
131
|
+
return this.execute(lease.mission, request, { lease });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async execute(missionInput, request, { lease = null } = {}) {
|
|
135
|
+
if (lease && lease.mission?.mission_id !== missionInput?.mission_id) {
|
|
136
|
+
throw new Error('task lease mission does not match execution mission');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let evaluation = lease
|
|
140
|
+
? lease.evaluate(this, request)
|
|
141
|
+
: this.evaluate(missionInput, request);
|
|
142
|
+
|
|
120
143
|
if (evaluation.result.decision === 'deny') return { ...evaluation, output: null };
|
|
121
144
|
|
|
145
|
+
// A Task Lease is the narrowest authority object. Do not let brokered
|
|
146
|
+
// execution reinterpret a lease-level authority delta as a broader mission
|
|
147
|
+
// approval. This keeps broker behavior aligned with guard.run() and MCP.
|
|
148
|
+
if (lease && evaluation.result.decision !== 'allow') {
|
|
149
|
+
return { ...evaluation, output: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
122
152
|
const adapter = this.adapters.resolve(request.service);
|
|
123
153
|
if (!adapter) {
|
|
124
154
|
return executionFailure(missionInput, request, 'adapter_unavailable', `no adapter is registered for ${request.service}`);
|
|
@@ -148,6 +178,7 @@ export class ExecutingAuthorityRuntime extends AuthorityRuntime {
|
|
|
148
178
|
|
|
149
179
|
try {
|
|
150
180
|
const output = await adapter.execute({ mission: missionInput, request });
|
|
181
|
+
const evidence = createExecutionEvidence({ receipt: evaluation.receipt, output });
|
|
151
182
|
let usage = null;
|
|
152
183
|
if (budgetCheck && !budgetCheck.result) {
|
|
153
184
|
const spent = this.usage.record(missionInput.mission_id, budgetCheck.currency, budgetCheck.amount);
|
|
@@ -160,7 +191,7 @@ export class ExecutingAuthorityRuntime extends AuthorityRuntime {
|
|
|
160
191
|
if (executionRecord && this.executions) {
|
|
161
192
|
this.executions.complete({ mission: missionInput, request, receipt_id: evaluation.receipt?.receipt_id || null });
|
|
162
193
|
}
|
|
163
|
-
return { ...evaluation, output, usage, execution: executionRecord || null };
|
|
194
|
+
return { ...evaluation, output, evidence, usage, execution: executionRecord || null };
|
|
164
195
|
} catch (error) {
|
|
165
196
|
if (executionRecord && this.executions) {
|
|
166
197
|
this.executions.uncertain({ mission: missionInput, request, error_code: error.code || 'provider_error' });
|
package/src/mcp-gateway.js
CHANGED
|
@@ -31,6 +31,15 @@ export function isDeclaredReadOnlyTool(tool) {
|
|
|
31
31
|
return tool?.annotations?.readOnlyHint === true;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function authorityMeta(evaluation = {}) {
|
|
35
|
+
return {
|
|
36
|
+
'io.nullsquare.agent-authority/decision': evaluation.result?.decision || 'deny',
|
|
37
|
+
'io.nullsquare.agent-authority/code': evaluation.result?.code || null,
|
|
38
|
+
'io.nullsquare.agent-authority/receipt_hash': evaluation.receipt?.receipt_hash || null,
|
|
39
|
+
'io.nullsquare.agent-authority/task_lease_id': evaluation.receipt?.task_lease_id || null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function deniedToolResult(result, extra = {}) {
|
|
35
44
|
return {
|
|
36
45
|
content: [{
|
|
@@ -47,32 +56,43 @@ function deniedToolResult(result, extra = {}) {
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
/**
|
|
50
|
-
* Small
|
|
59
|
+
* Small policy gateway for MCP tools.
|
|
51
60
|
*
|
|
52
61
|
* The upstream object only needs two methods:
|
|
53
62
|
* listTools(params?) -> { tools: [...] }
|
|
54
63
|
* callTool(params) -> MCP CallToolResult
|
|
55
64
|
*
|
|
56
|
-
*
|
|
65
|
+
* The gateway accepts either a Mission or a Task Lease. When a Task Lease is
|
|
66
|
+
* supplied, every MCP tool call is evaluated through that exact lease before
|
|
67
|
+
* the upstream callback can run. This keeps transport changes from bypassing
|
|
68
|
+
* task-level narrowing.
|
|
69
|
+
*
|
|
70
|
+
* The gateway still defaults to read-only enforcement. A tool is considered
|
|
57
71
|
* read-only only when its MCP annotations explicitly set readOnlyHint=true.
|
|
58
|
-
* Write support
|
|
59
|
-
* is intentionally not inferred from tool names.
|
|
72
|
+
* Write support must be enabled deliberately; it is never inferred from names.
|
|
60
73
|
*/
|
|
61
74
|
export class MissionMcpGateway {
|
|
62
75
|
constructor({
|
|
63
76
|
mission,
|
|
77
|
+
lease,
|
|
64
78
|
runtime,
|
|
65
79
|
upstream,
|
|
66
80
|
service = 'mcp:upstream',
|
|
67
81
|
readOnly = true,
|
|
68
82
|
contextMapper = contextFromToolArguments
|
|
69
83
|
} = {}) {
|
|
70
|
-
if (
|
|
84
|
+
if ((mission && lease) || (!mission && !lease)) {
|
|
85
|
+
throw new Error('provide exactly one of mission or lease');
|
|
86
|
+
}
|
|
87
|
+
if (lease && (typeof lease.evaluate !== 'function' || !lease.mission)) {
|
|
88
|
+
throw new Error('lease must provide mission and evaluate(runtime, request)');
|
|
89
|
+
}
|
|
71
90
|
if (!runtime || typeof runtime.evaluate !== 'function') throw new Error('authority runtime is required');
|
|
72
91
|
if (!upstream || typeof upstream.listTools !== 'function' || typeof upstream.callTool !== 'function') {
|
|
73
92
|
throw new Error('upstream MCP client must implement listTools() and callTool()');
|
|
74
93
|
}
|
|
75
|
-
this.mission = mission;
|
|
94
|
+
this.mission = mission || lease.mission;
|
|
95
|
+
this.lease = lease || null;
|
|
76
96
|
this.runtime = runtime;
|
|
77
97
|
this.upstream = upstream;
|
|
78
98
|
this.service = service;
|
|
@@ -81,6 +101,12 @@ export class MissionMcpGateway {
|
|
|
81
101
|
this.tools = new Map();
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
evaluate(request) {
|
|
105
|
+
return this.lease
|
|
106
|
+
? this.lease.evaluate(this.runtime, request)
|
|
107
|
+
: this.runtime.evaluate(this.mission, request);
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
async refreshTools(params = undefined) {
|
|
85
111
|
const listed = await this.upstream.listTools(params);
|
|
86
112
|
for (const tool of listed.tools || []) this.tools.set(tool.name, tool);
|
|
@@ -122,11 +148,9 @@ export class MissionMcpGateway {
|
|
|
122
148
|
action: mcpToolAction(toolName),
|
|
123
149
|
context
|
|
124
150
|
};
|
|
125
|
-
const evaluation = this.
|
|
151
|
+
const evaluation = this.evaluate(request);
|
|
126
152
|
if (evaluation.result.decision !== 'allow') {
|
|
127
|
-
return deniedToolResult(evaluation.result,
|
|
128
|
-
'io.nullsquare.agent-authority/receipt_hash': evaluation.receipt?.receipt_hash || null
|
|
129
|
-
});
|
|
153
|
+
return deniedToolResult(evaluation.result, authorityMeta(evaluation));
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
const output = await this.upstream.callTool(params);
|
|
@@ -134,8 +158,7 @@ export class MissionMcpGateway {
|
|
|
134
158
|
...output,
|
|
135
159
|
_meta: {
|
|
136
160
|
...(output?._meta || {}),
|
|
137
|
-
|
|
138
|
-
'io.nullsquare.agent-authority/receipt_hash': evaluation.receipt?.receipt_hash || null
|
|
161
|
+
...authorityMeta(evaluation)
|
|
139
162
|
}
|
|
140
163
|
};
|
|
141
164
|
}
|
package/src/mcp-remote.js
CHANGED
|
@@ -54,6 +54,7 @@ export class RemoteMcpUpstream {
|
|
|
54
54
|
|
|
55
55
|
export function createMcpGatewayHandler({
|
|
56
56
|
mission,
|
|
57
|
+
lease,
|
|
57
58
|
runtime,
|
|
58
59
|
upstream,
|
|
59
60
|
upstreamUrl,
|
|
@@ -63,6 +64,7 @@ export function createMcpGatewayHandler({
|
|
|
63
64
|
const resolvedUpstream = upstream || new RemoteMcpUpstream({ url: upstreamUrl });
|
|
64
65
|
const gateway = new MissionMcpGateway({
|
|
65
66
|
mission,
|
|
67
|
+
lease,
|
|
66
68
|
runtime,
|
|
67
69
|
upstream: resolvedUpstream,
|
|
68
70
|
service,
|
|
@@ -74,7 +76,9 @@ export function createMcpGatewayHandler({
|
|
|
74
76
|
{
|
|
75
77
|
name: 'agent-authority-gateway',
|
|
76
78
|
version: '0.3.0',
|
|
77
|
-
description:
|
|
79
|
+
description: lease
|
|
80
|
+
? 'Task-Lease-aware policy gateway for MCP tools'
|
|
81
|
+
: 'Mission-aware policy gateway for MCP tools'
|
|
78
82
|
},
|
|
79
83
|
{ capabilities: { tools: {} } }
|
|
80
84
|
);
|
package/src/mcp-server.js
CHANGED
|
@@ -30,6 +30,7 @@ function sendJson(res, status, value) {
|
|
|
30
30
|
*/
|
|
31
31
|
export function createMcpProxyServer({
|
|
32
32
|
mission,
|
|
33
|
+
lease,
|
|
33
34
|
runtime,
|
|
34
35
|
upstream,
|
|
35
36
|
upstreamUrl,
|
|
@@ -43,6 +44,7 @@ export function createMcpProxyServer({
|
|
|
43
44
|
|
|
44
45
|
const gateway = createMcpGatewayHandler({
|
|
45
46
|
mission,
|
|
47
|
+
lease,
|
|
46
48
|
runtime,
|
|
47
49
|
upstream,
|
|
48
50
|
upstreamUrl,
|
|
@@ -64,6 +66,7 @@ export function createMcpProxyServer({
|
|
|
64
66
|
ok: true,
|
|
65
67
|
service: 'agent-authority-mcp-gateway',
|
|
66
68
|
mode: 'read-only',
|
|
69
|
+
authority: lease ? 'task-lease' : 'mission',
|
|
67
70
|
upstream: upstreamUrl || 'injected'
|
|
68
71
|
});
|
|
69
72
|
}
|