@nullsquare/agent-authority 0.4.0 → 0.4.2
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 +15 -3
- package/ROADMAP.md +19 -11
- package/docs/announcement-draft.md +7 -1
- package/docs/assets/agent-authority-cover-editorial-v2.png +0 -0
- package/docs/evidence.md +154 -2
- package/docs/live-google-validation.md +100 -0
- package/docs/npm-release.md +13 -1
- package/docs/task-leases.md +95 -28
- package/examples/live-google-cross-provider.js +261 -0
- package/package.json +6 -3
- package/src/authority-evidence.js +146 -0
- package/src/guard.js +8 -1
- package/src/providers/google.js +238 -0
- package/src/task-lease.js +77 -0
- package/docs/assets/agent-authority-cover.svg +0 -41
package/docs/task-leases.md
CHANGED
|
@@ -32,6 +32,12 @@ Human-approved task
|
|
|
32
32
|
authority roots
|
|
33
33
|
|
|
|
34
34
|
authorized execution
|
|
35
|
+
|
|
|
36
|
+
+--> ALLOW receipt
|
|
37
|
+
+--> exact output hash evidence
|
|
38
|
+
|
|
|
39
|
+
v
|
|
40
|
+
trusted adapter extractor
|
|
35
41
|
|
|
|
36
42
|
v
|
|
37
43
|
derived facts
|
|
@@ -64,7 +70,60 @@ Examples:
|
|
|
64
70
|
- customer ID discovered from an authorized support-ticket lookup;
|
|
65
71
|
- order ID discovered from an authorized customer lookup.
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
A derived fact must reference an `ALLOW` receipt from the same Task Lease and at least one parent authority fact.
|
|
74
|
+
|
|
75
|
+
Agent Authority now exposes two derivation modes:
|
|
76
|
+
|
|
77
|
+
- `derive()` — compatibility path where the trusted host supplies both `value` and `selector`;
|
|
78
|
+
- `deriveFromEvidence()` — stricter path where the host does **not** supply the authority value.
|
|
79
|
+
|
|
80
|
+
For provider-derived authority, prefer `deriveFromEvidence()`.
|
|
81
|
+
|
|
82
|
+
### Execution evidence
|
|
83
|
+
|
|
84
|
+
After an allowed `guard.run()` effect succeeds, the guard returns:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
{
|
|
88
|
+
output,
|
|
89
|
+
receipt,
|
|
90
|
+
evidence
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The execution-evidence record binds:
|
|
95
|
+
|
|
96
|
+
- the receipt ID and receipt hash;
|
|
97
|
+
- mission and Task Lease identity;
|
|
98
|
+
- service and action;
|
|
99
|
+
- request hash;
|
|
100
|
+
- a hash of the exact returned output.
|
|
101
|
+
|
|
102
|
+
If downstream code changes the output and tries to reuse the original evidence, derivation fails with `evidence_output_mismatch`.
|
|
103
|
+
|
|
104
|
+
This is an integrity mechanism inside the trusted host/runtime boundary. It is **not** provider-signed remote attestation.
|
|
105
|
+
|
|
106
|
+
### Trusted adapter extractor
|
|
107
|
+
|
|
108
|
+
The adapter extractor identifies which normalized provider-output field may become authority.
|
|
109
|
+
|
|
110
|
+
For Gmail, the current extractor accepts only a `gmail:thread.read` receipt and selects:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
output.sender_email
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The extractor returns only an ID and selector. It does not return the authority value.
|
|
117
|
+
|
|
118
|
+
Task Lease resolves the selector itself after checking the execution evidence. This prevents ordinary host code from doing this:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
Gmail returned customer@example.com
|
|
122
|
+
host claims attacker@example.com
|
|
123
|
+
while reusing the original Gmail receipt
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The stricter path rejects output/evidence substitution rather than recording the host's claimed value.
|
|
68
127
|
|
|
69
128
|
### Binding
|
|
70
129
|
|
|
@@ -74,7 +133,7 @@ A binding narrows an otherwise permitted action to the exact value held by an au
|
|
|
74
133
|
{
|
|
75
134
|
service: 'calendar',
|
|
76
135
|
action: 'event.create',
|
|
77
|
-
context_field: '
|
|
136
|
+
context_field: 'attendee_email',
|
|
78
137
|
fact_id: 'fact:requester-email'
|
|
79
138
|
}
|
|
80
139
|
```
|
|
@@ -87,7 +146,7 @@ If it has value `customer@example.com`, this request can proceed:
|
|
|
87
146
|
{
|
|
88
147
|
service: 'calendar',
|
|
89
148
|
action: 'event.create',
|
|
90
|
-
context: {
|
|
149
|
+
context: { attendee_email: 'customer@example.com' }
|
|
91
150
|
}
|
|
92
151
|
```
|
|
93
152
|
|
|
@@ -97,7 +156,7 @@ This request does not proceed automatically:
|
|
|
97
156
|
{
|
|
98
157
|
service: 'calendar',
|
|
99
158
|
action: 'event.create',
|
|
100
|
-
context: {
|
|
159
|
+
context: { attendee_email: 'other@example.com' }
|
|
101
160
|
}
|
|
102
161
|
```
|
|
103
162
|
|
|
@@ -126,12 +185,13 @@ lease authority <= mission authority
|
|
|
126
185
|
|
|
127
186
|
Authority may stay the same or shrink. It must never grow silently.
|
|
128
187
|
|
|
129
|
-
##
|
|
188
|
+
## Evidence-verified Gmail -> Calendar example
|
|
130
189
|
|
|
131
190
|
```js
|
|
132
191
|
import { AuthorityRuntime } from '@nullsquare/agent-authority';
|
|
133
192
|
import { createTaskLease } from '@nullsquare/agent-authority/task-lease';
|
|
134
193
|
import { createTaskLeaseGuard } from '@nullsquare/agent-authority/guard';
|
|
194
|
+
import { gmailThreadSenderAuthorityExtractor } from '@nullsquare/agent-authority/providers/google';
|
|
135
195
|
|
|
136
196
|
const lease = createTaskLease({
|
|
137
197
|
mission,
|
|
@@ -143,7 +203,7 @@ const lease = createTaskLease({
|
|
|
143
203
|
{
|
|
144
204
|
service: 'calendar',
|
|
145
205
|
action: 'event.create',
|
|
146
|
-
context_field: '
|
|
206
|
+
context_field: 'attendee_email',
|
|
147
207
|
fact_id: 'fact:sender-email'
|
|
148
208
|
}
|
|
149
209
|
]
|
|
@@ -157,30 +217,29 @@ const guard = createTaskLeaseGuard({
|
|
|
157
217
|
const read = await guard.run({
|
|
158
218
|
service: 'gmail',
|
|
159
219
|
action: 'thread.read',
|
|
160
|
-
context: {
|
|
220
|
+
context: { thread_id: 'thread:demo-91' }
|
|
161
221
|
}, () => gmail.readThread('thread:demo-91'));
|
|
162
222
|
|
|
163
|
-
lease.
|
|
223
|
+
const senderFact = lease.deriveFromEvidence({
|
|
164
224
|
fact_id: 'fact:sender-email',
|
|
165
225
|
kind: 'email.address',
|
|
166
|
-
value: read.output.sender,
|
|
167
226
|
from: ['fact:thread'],
|
|
168
227
|
receipt: read.receipt,
|
|
169
|
-
|
|
228
|
+
evidence: read.evidence,
|
|
229
|
+
output: read.output,
|
|
230
|
+
extractor: gmailThreadSenderAuthorityExtractor
|
|
170
231
|
});
|
|
171
232
|
|
|
172
233
|
await guard.run({
|
|
173
234
|
service: 'calendar',
|
|
174
235
|
action: 'event.create',
|
|
175
|
-
context: {
|
|
176
|
-
}, () => calendar.createEvent({ attendee:
|
|
236
|
+
context: { attendee_email: senderFact.value }
|
|
237
|
+
}, () => calendar.createEvent({ attendee: senderFact.value }));
|
|
177
238
|
```
|
|
178
239
|
|
|
179
|
-
|
|
240
|
+
Notice that `deriveFromEvidence()` has no `value` argument. The fact value comes from the exact output already bound to the authorized read.
|
|
180
241
|
|
|
181
|
-
|
|
182
|
-
npm run demo:task-lease
|
|
183
|
-
```
|
|
242
|
+
The older `derive()` API remains available for integrations that have not adopted the evidence contract yet. Facts created through that API record `derivation_mode: host-trusted` so audit code can distinguish the weaker path.
|
|
184
243
|
|
|
185
244
|
## Task completion
|
|
186
245
|
|
|
@@ -207,36 +266,44 @@ requested new resource
|
|
|
207
266
|
authority_delta_required
|
|
208
267
|
```
|
|
209
268
|
|
|
210
|
-
The existing approval store can handle the human decision. Automatically applying approved deltas to a live Task Lease is a later milestone;
|
|
269
|
+
The existing approval store can handle the human decision. Automatically applying approved deltas to a live Task Lease is a later milestone; the current implementation deliberately stops at the safe enforcement signal.
|
|
211
270
|
|
|
212
271
|
## Current security properties
|
|
213
272
|
|
|
214
|
-
The
|
|
273
|
+
The implementation tests that:
|
|
215
274
|
|
|
216
275
|
- a bound action cannot run before its fact exists;
|
|
217
|
-
- derived facts require an `ALLOW` receipt;
|
|
218
|
-
- the receipt must belong to the same mission;
|
|
276
|
+
- derived facts require an `ALLOW` receipt from the same mission and Task Lease;
|
|
219
277
|
- explicit mission denies cannot be overridden by lease bindings;
|
|
220
278
|
- an exact derived resource can execute;
|
|
221
279
|
- a different resource becomes an authority delta and the effect does not run;
|
|
222
280
|
- completed and expired leases stop execution;
|
|
223
|
-
- Task Lease receipts include the lease ID and lease hash
|
|
281
|
+
- Task Lease receipts include the lease ID and lease hash;
|
|
282
|
+
- successful guarded effects produce output-bound execution evidence;
|
|
283
|
+
- `deriveFromEvidence()` ignores any caller-supplied `value` and resolves the trusted selector itself;
|
|
284
|
+
- modified provider output is rejected;
|
|
285
|
+
- modified execution evidence is rejected;
|
|
286
|
+
- execution evidence cannot be replayed under another receipt or Task Lease;
|
|
287
|
+
- the Gmail extractor rejects the wrong service/action;
|
|
288
|
+
- dangerous selector paths such as `__proto__` fail closed.
|
|
224
289
|
|
|
225
290
|
## Current limitations
|
|
226
291
|
|
|
227
292
|
This is still a validation implementation.
|
|
228
293
|
|
|
229
|
-
1. **
|
|
230
|
-
2. **
|
|
231
|
-
3. **
|
|
232
|
-
4. **
|
|
233
|
-
5. **
|
|
294
|
+
1. **Trusted host/adapter boundary:** execution evidence is produced by Agent Authority around the host effect, not signed by Gmail, Calendar, or another provider. A malicious host that can bypass or replace Agent Authority remains outside the guarantee.
|
|
295
|
+
2. **Provider attestation:** output hashes prove consistency with what the guarded effect returned; they do not cryptographically prove what the remote provider emitted on the wire.
|
|
296
|
+
3. **Source invalidation:** a source resource changing later does not yet invalidate already-derived facts automatically.
|
|
297
|
+
4. **In-memory lease state:** TaskLease instances are currently process-local. Durable lease persistence/recovery is not implemented yet.
|
|
298
|
+
5. **Top-level binding fields:** bindings target top-level request context fields. A general nested policy language is intentionally deferred.
|
|
299
|
+
6. **Step-up application:** authority deltas are surfaced but approved deltas are not yet automatically applied back into the lease.
|
|
300
|
+
7. **Adapter semantics:** each provider still needs a reviewed operation -> authority-field mapping. The Google sender extractor is the first concrete contract.
|
|
234
301
|
|
|
235
|
-
These constraints are deliberate. The
|
|
302
|
+
These constraints are deliberate. The project should improve the evidence contract from real provider cases rather than build a universal semantic policy language.
|
|
236
303
|
|
|
237
304
|
## Validation target
|
|
238
305
|
|
|
239
|
-
The product thesis is validated when the same Task Lease can safely govern a real multi-step workflow across more than one execution transport, for example:
|
|
306
|
+
The longer-term product thesis is validated when the same Task Lease can safely govern a real multi-step workflow across more than one execution transport, for example:
|
|
240
307
|
|
|
241
308
|
```text
|
|
242
309
|
one human task
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { AuthorityRuntime } from '../src/index.js';
|
|
2
|
+
import {
|
|
3
|
+
AuthorityApprovalRequiredError,
|
|
4
|
+
AuthorityDeniedError,
|
|
5
|
+
createTaskLeaseGuard
|
|
6
|
+
} from '../src/guard.js';
|
|
7
|
+
import {
|
|
8
|
+
gmailThreadSenderAuthorityExtractor,
|
|
9
|
+
gmailThreadSenderEmail
|
|
10
|
+
} from '../src/providers/google.js';
|
|
11
|
+
import { createTaskLease } from '../src/task-lease.js';
|
|
12
|
+
|
|
13
|
+
const token = process.env.GOOGLE_ACCESS_TOKEN;
|
|
14
|
+
const threadId = process.env.AA_GOOGLE_GMAIL_THREAD_ID;
|
|
15
|
+
const calendarId = process.env.AA_GOOGLE_CALENDAR_ID || 'primary';
|
|
16
|
+
const expectedSender = process.env.AA_GOOGLE_EXPECTED_SENDER?.trim().toLowerCase() || null;
|
|
17
|
+
|
|
18
|
+
if (!token) throw new Error('GOOGLE_ACCESS_TOKEN is required for live Google validation');
|
|
19
|
+
if (!threadId) throw new Error('AA_GOOGLE_GMAIL_THREAD_ID is required for live Google validation');
|
|
20
|
+
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const startTime = new Date(now + 10 * 60_000).toISOString();
|
|
23
|
+
const endTime = new Date(now + 25 * 60_000).toISOString();
|
|
24
|
+
const marker = `Agent Authority live Google validation ${new Date(now).toISOString()}`;
|
|
25
|
+
|
|
26
|
+
const mission = {
|
|
27
|
+
version: '0.1',
|
|
28
|
+
mission_id: `mission:live-google-cross-provider:${now}`,
|
|
29
|
+
principal: { id: 'user:google-validation' },
|
|
30
|
+
agent: { id: 'agent:google-cross-provider-validation' },
|
|
31
|
+
objective: 'Read one authorized Gmail thread and create one Calendar event only for the sender discovered from that thread',
|
|
32
|
+
resources: [
|
|
33
|
+
{
|
|
34
|
+
service: 'gmail',
|
|
35
|
+
allow: ['thread.read'],
|
|
36
|
+
deny: ['message.send', 'message.delete', 'thread.delete'],
|
|
37
|
+
constraints: { thread_id: [threadId] }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
service: 'calendar',
|
|
41
|
+
allow: ['event.create'],
|
|
42
|
+
deny: ['event.delete', 'calendar.delete'],
|
|
43
|
+
constraints: { calendar_id: [calendarId] }
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
constraints: { expires_at: new Date(now + 15 * 60_000).toISOString() }
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const lease = createTaskLease({
|
|
50
|
+
mission,
|
|
51
|
+
request: 'Schedule one temporary validation event with the sender in the approved Gmail thread',
|
|
52
|
+
roots: [
|
|
53
|
+
{
|
|
54
|
+
fact_id: 'fact:gmail-thread',
|
|
55
|
+
kind: 'gmail.thread',
|
|
56
|
+
value: threadId,
|
|
57
|
+
source: 'validation-task'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
fact_id: 'fact:calendar',
|
|
61
|
+
kind: 'google.calendar',
|
|
62
|
+
value: calendarId,
|
|
63
|
+
source: 'validation-task'
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
bindings: [
|
|
67
|
+
{
|
|
68
|
+
service: 'gmail',
|
|
69
|
+
action: 'thread.read',
|
|
70
|
+
context_field: 'thread_id',
|
|
71
|
+
fact_id: 'fact:gmail-thread'
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
service: 'calendar',
|
|
75
|
+
action: 'event.create',
|
|
76
|
+
context_field: 'calendar_id',
|
|
77
|
+
fact_id: 'fact:calendar'
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
service: 'calendar',
|
|
81
|
+
action: 'event.create',
|
|
82
|
+
context_field: 'attendee_email',
|
|
83
|
+
fact_id: 'fact:sender-email'
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
expires_at: new Date(now + 10 * 60_000).toISOString()
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const guard = createTaskLeaseGuard({ lease, runtime: new AuthorityRuntime() });
|
|
90
|
+
const providerHeaders = {
|
|
91
|
+
accept: 'application/json',
|
|
92
|
+
authorization: `Bearer ${token}`,
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
'user-agent': 'agent-authority-live-google-validation/0.4'
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let providerReadCalls = 0;
|
|
98
|
+
let providerMutationCalls = 0;
|
|
99
|
+
let cleanupCalls = 0;
|
|
100
|
+
let createdEventId = null;
|
|
101
|
+
|
|
102
|
+
async function googleResponse(url, options = {}) {
|
|
103
|
+
const response = await fetch(url, {
|
|
104
|
+
...options,
|
|
105
|
+
headers: { ...providerHeaders, ...(options.headers || {}) }
|
|
106
|
+
});
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
let body = null;
|
|
109
|
+
if (text) {
|
|
110
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new Error(`Google API ${response.status}: ${typeof body === 'string' ? body.slice(0, 300) : JSON.stringify(body).slice(0, 300)}`);
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readAuthorizedThread() {
|
|
119
|
+
return guard.run(
|
|
120
|
+
{
|
|
121
|
+
service: 'gmail',
|
|
122
|
+
action: 'thread.read',
|
|
123
|
+
context: { thread_id: threadId }
|
|
124
|
+
},
|
|
125
|
+
async () => {
|
|
126
|
+
providerReadCalls += 1;
|
|
127
|
+
const query = new URLSearchParams({ format: 'metadata' });
|
|
128
|
+
query.append('metadataHeaders', 'From');
|
|
129
|
+
const thread = await googleResponse(
|
|
130
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/threads/${encodeURIComponent(threadId)}?${query}`
|
|
131
|
+
);
|
|
132
|
+
const sender = gmailThreadSenderEmail(thread);
|
|
133
|
+
return {
|
|
134
|
+
provider: 'gmail',
|
|
135
|
+
thread_id: thread.id,
|
|
136
|
+
sender_email: sender.email,
|
|
137
|
+
sender_raw: sender.raw,
|
|
138
|
+
sender_message_id: sender.message_id,
|
|
139
|
+
message_count: Array.isArray(thread.messages) ? thread.messages.length : 0
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function createCalendarEvent(attendeeEmail, suffix) {
|
|
146
|
+
const request = {
|
|
147
|
+
service: 'calendar',
|
|
148
|
+
action: 'event.create',
|
|
149
|
+
context: {
|
|
150
|
+
calendar_id: calendarId,
|
|
151
|
+
attendee_email: attendeeEmail,
|
|
152
|
+
start_time: startTime,
|
|
153
|
+
end_time: endTime,
|
|
154
|
+
summary: `${marker} — ${suffix}`,
|
|
155
|
+
transparency: 'transparent',
|
|
156
|
+
visibility: 'private',
|
|
157
|
+
send_updates: 'none'
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return guard.run(request, async () => {
|
|
162
|
+
providerMutationCalls += 1;
|
|
163
|
+
const event = await googleResponse(
|
|
164
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?sendUpdates=none`,
|
|
165
|
+
{
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
summary: request.context.summary,
|
|
169
|
+
description: 'Temporary event created by the Agent Authority live Gmail → Calendar validation. Cleanup deletes it after the proof.',
|
|
170
|
+
start: { dateTime: startTime },
|
|
171
|
+
end: { dateTime: endTime },
|
|
172
|
+
attendees: [{ email: attendeeEmail }],
|
|
173
|
+
transparency: 'transparent',
|
|
174
|
+
visibility: 'private'
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
return { event_id: event.id, html_link: event.htmlLink || null };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function cleanupEvent(eventId) {
|
|
183
|
+
cleanupCalls += 1;
|
|
184
|
+
const response = await fetch(
|
|
185
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}?sendUpdates=none`,
|
|
186
|
+
{ method: 'DELETE', headers: providerHeaders }
|
|
187
|
+
);
|
|
188
|
+
if (!response.ok && response.status !== 404) {
|
|
189
|
+
const body = await response.text();
|
|
190
|
+
throw new Error(`Google Calendar cleanup ${response.status}: ${body.slice(0, 300)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
console.log(`Task root Gmail thread: ${threadId}`);
|
|
196
|
+
console.log(`Task root Calendar: ${calendarId}`);
|
|
197
|
+
console.log('1. Read the approved Gmail thread through the Task Lease');
|
|
198
|
+
const discovered = await readAuthorizedThread();
|
|
199
|
+
console.log(` ALLOW -> Gmail returned sender ${discovered.output.sender_email}`);
|
|
200
|
+
|
|
201
|
+
if (expectedSender && discovered.output.sender_email !== expectedSender) {
|
|
202
|
+
throw new Error(`expected sender ${expectedSender}, got ${discovered.output.sender_email}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const senderFact = lease.deriveFromEvidence({
|
|
206
|
+
fact_id: 'fact:sender-email',
|
|
207
|
+
kind: 'email.address',
|
|
208
|
+
from: ['fact:gmail-thread'],
|
|
209
|
+
receipt: discovered.receipt,
|
|
210
|
+
evidence: discovered.evidence,
|
|
211
|
+
output: discovered.output,
|
|
212
|
+
extractor: gmailThreadSenderAuthorityExtractor
|
|
213
|
+
});
|
|
214
|
+
console.log(`2. Evidence-verified authority -> Calendar attendee ${senderFact.value}`);
|
|
215
|
+
|
|
216
|
+
const allowed = await createCalendarEvent(senderFact.value, 'authorized');
|
|
217
|
+
createdEventId = allowed.output.event_id;
|
|
218
|
+
console.log(`3. ALLOW -> real Calendar event mutation executed (${createdEventId})`);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
await createCalendarEvent('blocked@example.invalid', 'must-never-run');
|
|
222
|
+
throw new Error('unrelated attendee mutation unexpectedly executed');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!(error instanceof AuthorityApprovalRequiredError) || error.code !== 'authority_delta_required') {
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
console.log('4. STEP-UP -> unrelated attendee blocked before Calendar provider mutation');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (providerMutationCalls !== 1) {
|
|
231
|
+
throw new Error(`expected exactly one task-side Calendar mutation before completion, got ${providerMutationCalls}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lease.complete('live Gmail to Calendar validation complete');
|
|
235
|
+
try {
|
|
236
|
+
await createCalendarEvent(senderFact.value, 'must-not-run-after-completion');
|
|
237
|
+
throw new Error('post-completion Calendar mutation unexpectedly executed');
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (!(error instanceof AuthorityDeniedError) || error.code !== 'task_lease_completed') {
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
console.log('5. DENY -> post-completion Calendar mutation blocked');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (providerReadCalls !== 1) {
|
|
246
|
+
throw new Error(`expected exactly one Gmail provider read, got ${providerReadCalls}`);
|
|
247
|
+
}
|
|
248
|
+
if (providerMutationCalls !== 1) {
|
|
249
|
+
throw new Error(`expected exactly one Calendar provider mutation after blocked attempts, got ${providerMutationCalls}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log('PASS -> a sender discovered from real Gmail became evidence-verified exact authority for one real Calendar mutation');
|
|
253
|
+
console.log('PASS -> unrelated and post-completion attempts caused zero additional Calendar provider mutations');
|
|
254
|
+
} finally {
|
|
255
|
+
if (createdEventId) {
|
|
256
|
+
await cleanupEvent(createdEventId);
|
|
257
|
+
console.log(`Cleanup -> deleted temporary Calendar event ${createdEventId} outside the agent authority proof`);
|
|
258
|
+
}
|
|
259
|
+
console.log(`Provider calls observed before cleanup: gmail_reads=${providerReadCalls}, calendar_task_mutations=${providerMutationCalls}`);
|
|
260
|
+
console.log(`Harness cleanup calls: ${cleanupCalls}`);
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nullsquare/agent-authority",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
"demo:task-lease": "node examples/task-lease-demo.js",
|
|
36
36
|
"demo:live-github": "node examples/live-github-task-lease.js",
|
|
37
37
|
"demo:live-derived-github": "node examples/live-github-derived-mutation.js",
|
|
38
|
+
"demo:live-google": "node examples/live-google-cross-provider.js",
|
|
38
39
|
"demo:mcp-upstream": "node examples/validation-mcp-upstream.js",
|
|
39
40
|
"demo:guard": "node examples/direct-guard.js",
|
|
40
41
|
"test": "node --test test/*.test.js",
|
|
41
42
|
"test:ai-sdk": "node --test test/integrations/ai-sdk.integration.mjs",
|
|
42
43
|
"test:coverage": "node --experimental-test-coverage --test test/*.test.js",
|
|
43
|
-
"check:syntax": "node --check src/index.js && node --check src/connections.js && node --check src/execution.js && node --check src/providers/github.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",
|
|
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
45
|
"check:package": "npm pack --dry-run",
|
|
45
46
|
"check": "npm run check:syntax && npm test && npm run demo:task-lease && npm run check:package"
|
|
46
47
|
},
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
".": "./src/index.js",
|
|
60
61
|
"./agent-auth": "./src/agent-auth.js",
|
|
61
62
|
"./approvals": "./src/approvals.js",
|
|
63
|
+
"./authority-evidence": "./src/authority-evidence.js",
|
|
62
64
|
"./connections": "./src/connections.js",
|
|
63
65
|
"./execution": "./src/execution.js",
|
|
64
66
|
"./guard": "./src/guard.js",
|
|
@@ -71,7 +73,8 @@
|
|
|
71
73
|
"./sdk": "./src/sdk.js",
|
|
72
74
|
"./storage": "./src/storage.js",
|
|
73
75
|
"./task-lease": "./src/task-lease.js",
|
|
74
|
-
"./providers/github": "./src/providers/github.js"
|
|
76
|
+
"./providers/github": "./src/providers/github.js",
|
|
77
|
+
"./providers/google": "./src/providers/google.js"
|
|
75
78
|
},
|
|
76
79
|
"files": ["src", "docs", "examples", "README.md", "LICENSE", "SECURITY.md", "ROADMAP.md", "CONTRIBUTING.md"],
|
|
77
80
|
"repository": { "type": "git", "url": "git+https://github.com/Null-Square/agent-authority.git" },
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { hashObject } from './index.js';
|
|
2
|
+
|
|
3
|
+
const FORBIDDEN_SELECTOR_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
4
|
+
|
|
5
|
+
function evidenceError(code, message) {
|
|
6
|
+
const error = new Error(message);
|
|
7
|
+
error.code = code;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requireReceipt(receipt) {
|
|
12
|
+
if (!receipt?.receipt_id) throw evidenceError('evidence_receipt_required', 'execution evidence requires a receipt');
|
|
13
|
+
if (!receipt?.receipt_hash) throw evidenceError('evidence_receipt_hash_required', 'execution evidence requires a receipt hash');
|
|
14
|
+
if (receipt.decision !== 'allow') {
|
|
15
|
+
throw evidenceError('evidence_receipt_not_authorized', 'execution evidence can only be created for an ALLOW receipt');
|
|
16
|
+
}
|
|
17
|
+
return receipt;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unsignedEvidence(evidence = {}) {
|
|
21
|
+
const { evidence_hash: _evidenceHash, ...unsigned } = evidence;
|
|
22
|
+
return unsigned;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hashExecutionOutput(output) {
|
|
26
|
+
return hashObject({
|
|
27
|
+
output_type: output === null ? 'null' : typeof output,
|
|
28
|
+
output
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bind the exact output returned by an authorized effect to its decision receipt.
|
|
34
|
+
*
|
|
35
|
+
* This is integrity evidence, not remote attestation. It prevents downstream code
|
|
36
|
+
* from silently swapping the output object while still claiming the original
|
|
37
|
+
* Agent Authority receipt as provenance.
|
|
38
|
+
*/
|
|
39
|
+
export function createExecutionEvidence({ receipt, output } = {}) {
|
|
40
|
+
requireReceipt(receipt);
|
|
41
|
+
const evidence = {
|
|
42
|
+
version: '0.1',
|
|
43
|
+
type: 'execution-output',
|
|
44
|
+
receipt_id: receipt.receipt_id,
|
|
45
|
+
receipt_hash: receipt.receipt_hash,
|
|
46
|
+
mission_id: receipt.mission_id,
|
|
47
|
+
task_lease_id: receipt.task_lease_id || null,
|
|
48
|
+
service: receipt.service,
|
|
49
|
+
action: receipt.action,
|
|
50
|
+
request_hash: receipt.request_hash,
|
|
51
|
+
output_hash: hashExecutionOutput(output)
|
|
52
|
+
};
|
|
53
|
+
return { ...evidence, evidence_hash: hashObject(evidence) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyExecutionEvidence({ receipt, output, evidence } = {}) {
|
|
57
|
+
requireReceipt(receipt);
|
|
58
|
+
if (!evidence || typeof evidence !== 'object') {
|
|
59
|
+
throw evidenceError('execution_evidence_required', 'derived authority requires execution evidence');
|
|
60
|
+
}
|
|
61
|
+
if (evidence.type !== 'execution-output' || evidence.version !== '0.1') {
|
|
62
|
+
throw evidenceError('execution_evidence_invalid', 'unsupported execution evidence format');
|
|
63
|
+
}
|
|
64
|
+
if (hashObject(unsignedEvidence(evidence)) !== evidence.evidence_hash) {
|
|
65
|
+
throw evidenceError('execution_evidence_tampered', 'execution evidence hash does not match its contents');
|
|
66
|
+
}
|
|
67
|
+
if (evidence.receipt_id !== receipt.receipt_id || evidence.receipt_hash !== receipt.receipt_hash) {
|
|
68
|
+
throw evidenceError('evidence_receipt_mismatch', 'execution evidence belongs to another receipt');
|
|
69
|
+
}
|
|
70
|
+
if (evidence.mission_id !== receipt.mission_id) {
|
|
71
|
+
throw evidenceError('evidence_mission_mismatch', 'execution evidence belongs to another mission');
|
|
72
|
+
}
|
|
73
|
+
if ((evidence.task_lease_id || null) !== (receipt.task_lease_id || null)) {
|
|
74
|
+
throw evidenceError('evidence_lease_mismatch', 'execution evidence belongs to another task lease');
|
|
75
|
+
}
|
|
76
|
+
if (evidence.service !== receipt.service || evidence.action !== receipt.action) {
|
|
77
|
+
throw evidenceError('evidence_operation_mismatch', 'execution evidence operation does not match its receipt');
|
|
78
|
+
}
|
|
79
|
+
if (evidence.request_hash !== receipt.request_hash) {
|
|
80
|
+
throw evidenceError('evidence_request_mismatch', 'execution evidence request does not match its receipt');
|
|
81
|
+
}
|
|
82
|
+
if (hashExecutionOutput(output) !== evidence.output_hash) {
|
|
83
|
+
throw evidenceError('evidence_output_mismatch', 'provider output no longer matches the authorized execution evidence');
|
|
84
|
+
}
|
|
85
|
+
return evidence;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveEvidenceSelector(output, selector) {
|
|
89
|
+
if (typeof selector !== 'string' || selector.trim() === '') {
|
|
90
|
+
throw evidenceError('selector_required', 'trusted extractor must provide a selector');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let normalized = selector.trim();
|
|
94
|
+
if (normalized === 'output') return output;
|
|
95
|
+
if (normalized.startsWith('output.')) normalized = normalized.slice('output.'.length);
|
|
96
|
+
|
|
97
|
+
const segments = normalized.split('.');
|
|
98
|
+
if (segments.length === 0 || segments.some((segment) => !segment || FORBIDDEN_SELECTOR_SEGMENTS.has(segment))) {
|
|
99
|
+
throw evidenceError('selector_invalid', 'trusted extractor selector contains an invalid path segment');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let current = output;
|
|
103
|
+
for (const segment of segments) {
|
|
104
|
+
if (current === null || current === undefined || typeof current !== 'object') {
|
|
105
|
+
throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} does not resolve against provider output`);
|
|
106
|
+
}
|
|
107
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
108
|
+
throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} does not resolve against provider output`);
|
|
109
|
+
}
|
|
110
|
+
current = current[segment];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (current === undefined) {
|
|
114
|
+
throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} resolved to undefined`);
|
|
115
|
+
}
|
|
116
|
+
return current;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Execute the small trusted-adapter extraction contract.
|
|
121
|
+
*
|
|
122
|
+
* Extractors choose which already-normalized output field is authority-relevant;
|
|
123
|
+
* they do not supply the value. TaskLease resolves the selector itself so the
|
|
124
|
+
* caller cannot substitute a different value while keeping the same evidence.
|
|
125
|
+
*/
|
|
126
|
+
export function runAuthorityExtractor({ extractor, receipt, output } = {}) {
|
|
127
|
+
if (typeof extractor !== 'function') {
|
|
128
|
+
throw evidenceError('trusted_extractor_required', 'deriveFromEvidence requires a trusted adapter extractor');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const descriptor = extractor({ receipt, output: structuredClone(output) });
|
|
132
|
+
if (!descriptor || typeof descriptor !== 'object') {
|
|
133
|
+
throw evidenceError('trusted_extractor_invalid', 'trusted adapter extractor must return a descriptor');
|
|
134
|
+
}
|
|
135
|
+
if (typeof descriptor.extractor_id !== 'string' || descriptor.extractor_id.trim() === '') {
|
|
136
|
+
throw evidenceError('trusted_extractor_id_required', 'trusted adapter extractor must provide extractor_id');
|
|
137
|
+
}
|
|
138
|
+
if (typeof descriptor.selector !== 'string' || descriptor.selector.trim() === '') {
|
|
139
|
+
throw evidenceError('selector_required', 'trusted adapter extractor must provide selector');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
extractor_id: descriptor.extractor_id.trim(),
|
|
144
|
+
selector: descriptor.selector.trim()
|
|
145
|
+
};
|
|
146
|
+
}
|