@nullsquare/agent-authority 0.4.1 → 0.4.3
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 +37 -12
- package/ROADMAP.md +15 -9
- package/docs/authority-extractor-conformance.md +95 -0
- package/docs/evidence.md +134 -32
- package/docs/npm-release.md +4 -2
- package/docs/task-leases.md +95 -28
- package/examples/live-github-derived-mutation.js +82 -65
- package/examples/live-google-cross-provider.js +13 -8
- package/package.json +3 -2
- package/src/authority-evidence.js +146 -0
- package/src/guard.js +8 -1
- package/src/providers/github.js +148 -18
- package/src/providers/google.js +37 -0
- package/src/task-lease.js +77 -0
|
@@ -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
|
+
}
|
package/src/guard.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createExecutionEvidence } from './authority-evidence.js';
|
|
2
|
+
|
|
1
3
|
export class AuthorityDeniedError extends Error {
|
|
2
4
|
constructor({ result, receipt }) {
|
|
3
5
|
super(result?.reason || 'action denied by Agent Authority');
|
|
@@ -25,6 +27,10 @@ export class AuthorityApprovalRequiredError extends Error {
|
|
|
25
27
|
* authority boundary returns ALLOW. A guard can use either a static mission or
|
|
26
28
|
* a TaskLease. Task leases add provenance-bound restrictions without changing
|
|
27
29
|
* the host application's credential ownership.
|
|
30
|
+
*
|
|
31
|
+
* Successful effects also return execution evidence binding the exact output
|
|
32
|
+
* hash to the ALLOW receipt. TaskLease.deriveFromEvidence() can use that record
|
|
33
|
+
* with a trusted adapter extractor so callers do not provide derived values.
|
|
28
34
|
*/
|
|
29
35
|
export class AuthorityGuard {
|
|
30
36
|
constructor({ mission, lease, runtime, onDecision } = {}) {
|
|
@@ -66,7 +72,8 @@ export class AuthorityGuard {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const output = await effect();
|
|
69
|
-
|
|
75
|
+
const evidence = createExecutionEvidence({ receipt: evaluation.receipt, output });
|
|
76
|
+
return { output, result: evaluation.result, receipt: evaluation.receipt, evidence };
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
|
package/src/providers/github.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { brokeredProviderAdapter } from '../connections.js';
|
|
2
2
|
|
|
3
|
-
const MUTATING_ACTIONS = new Set(['issue.create', 'pull_request.create', 'repo.contents.write']);
|
|
3
|
+
const MUTATING_ACTIONS = new Set(['issue.create', 'issue.comment', 'pull_request.create', 'repo.contents.write']);
|
|
4
|
+
const ISSUE_STATES = new Set(['open', 'closed', 'all']);
|
|
4
5
|
|
|
5
6
|
function required(value, name) {
|
|
6
7
|
if (value === undefined || value === null || value === '') throw new Error(`${name} is required`);
|
|
7
8
|
return value;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
function providerError(code, message) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
error.code = code;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
function repoParts(context = {}) {
|
|
11
18
|
const repository = required(context.repository, 'context.repository');
|
|
12
19
|
const [owner, repo, ...extra] = String(repository).split('/');
|
|
@@ -22,6 +29,26 @@ function encodedPath(path) {
|
|
|
22
29
|
.join('/');
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
function issueNumber(value) {
|
|
33
|
+
const number = Number(value);
|
|
34
|
+
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
35
|
+
throw providerError('invalid_issue_number', 'context.issue_number must be a positive integer');
|
|
36
|
+
}
|
|
37
|
+
return number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function issueListQuery(context = {}) {
|
|
41
|
+
const state = context.state || 'open';
|
|
42
|
+
if (!ISSUE_STATES.has(state)) {
|
|
43
|
+
throw providerError('invalid_issue_state', 'context.state must be open, closed, or all');
|
|
44
|
+
}
|
|
45
|
+
const perPage = context.per_page === undefined ? 100 : Number(context.per_page);
|
|
46
|
+
if (!Number.isSafeInteger(perPage) || perPage < 1 || perPage > 100) {
|
|
47
|
+
throw providerError('invalid_issue_page_size', 'context.per_page must be an integer between 1 and 100');
|
|
48
|
+
}
|
|
49
|
+
return new URLSearchParams({ state, per_page: String(perPage) }).toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
function buildOperation(request) {
|
|
26
53
|
const context = request.context || {};
|
|
27
54
|
const { owner, repo } = repoParts(context);
|
|
@@ -37,6 +64,9 @@ function buildOperation(request) {
|
|
|
37
64
|
return { method: 'GET', path: `${root}/contents/${encodedPath(path)}${query}` };
|
|
38
65
|
}
|
|
39
66
|
|
|
67
|
+
case 'issue.list':
|
|
68
|
+
return { method: 'GET', path: `${root}/issues?${issueListQuery(context)}` };
|
|
69
|
+
|
|
40
70
|
case 'issue.create':
|
|
41
71
|
return {
|
|
42
72
|
method: 'POST',
|
|
@@ -49,6 +79,13 @@ function buildOperation(request) {
|
|
|
49
79
|
}
|
|
50
80
|
};
|
|
51
81
|
|
|
82
|
+
case 'issue.comment':
|
|
83
|
+
return {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
path: `${root}/issues/${issueNumber(context.issue_number)}/comments`,
|
|
86
|
+
body: { body: required(context.body, 'context.body') }
|
|
87
|
+
};
|
|
88
|
+
|
|
52
89
|
case 'pull_request.create':
|
|
53
90
|
return {
|
|
54
91
|
method: 'POST',
|
|
@@ -77,11 +114,8 @@ function buildOperation(request) {
|
|
|
77
114
|
};
|
|
78
115
|
}
|
|
79
116
|
|
|
80
|
-
default:
|
|
81
|
-
|
|
82
|
-
error.code = 'unsupported_action';
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
117
|
+
default:
|
|
118
|
+
throw providerError('unsupported_action', `GitHub action ${request.action} has no provider operation mapping`);
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
121
|
|
|
@@ -94,6 +128,91 @@ function sanitizeBody(body) {
|
|
|
94
128
|
return clone;
|
|
95
129
|
}
|
|
96
130
|
|
|
131
|
+
function normalizeIssueList(request, body) {
|
|
132
|
+
if (!Array.isArray(body)) {
|
|
133
|
+
throw providerError('github_issue_list_invalid', 'GitHub issue.list response must be an array');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const issues = body.map((issue) => ({
|
|
137
|
+
number: issueNumber(issue?.number),
|
|
138
|
+
title: typeof issue?.title === 'string' ? issue.title : null,
|
|
139
|
+
is_pull_request: Boolean(issue?.pull_request)
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
const marker = request.context?.fixture_marker;
|
|
143
|
+
if (typeof marker !== 'string' || marker.trim() === '') {
|
|
144
|
+
return { issues, selected_issue_number: null, selected_issue_title: null, selected_issue_match_count: 0, selected_issue_marker: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const matches = body
|
|
148
|
+
.map((issue, index) => ({ issue, index }))
|
|
149
|
+
.filter(({ issue }) => !issue?.pull_request && typeof issue?.body === 'string' && issue.body.includes(marker));
|
|
150
|
+
|
|
151
|
+
const selected = matches.length === 1 ? issues[matches[0].index] : null;
|
|
152
|
+
return {
|
|
153
|
+
issues,
|
|
154
|
+
selected_issue_number: selected?.number || null,
|
|
155
|
+
selected_issue_title: selected?.title || null,
|
|
156
|
+
selected_issue_match_count: matches.length,
|
|
157
|
+
selected_issue_marker: marker
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizedOutput(request, response, body) {
|
|
162
|
+
const common = {
|
|
163
|
+
provider: 'github',
|
|
164
|
+
status: response.status,
|
|
165
|
+
ok: response.ok,
|
|
166
|
+
request_id: response.headers?.get?.('x-github-request-id') || null
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (request.action === 'issue.list') {
|
|
170
|
+
return { ...common, ...normalizeIssueList(request, body) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (request.action === 'issue.comment') {
|
|
174
|
+
return {
|
|
175
|
+
...common,
|
|
176
|
+
comment_id: body?.id || null,
|
|
177
|
+
html_url: body?.html_url || null,
|
|
178
|
+
issue_number: issueNumber(request.context?.issue_number)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ...common, body: sanitizeBody(body) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Reviewed authority extractor for an issue selected by the normalized
|
|
187
|
+
* issue.list mapping using the request's root-bound fixture_marker.
|
|
188
|
+
*
|
|
189
|
+
* The extractor returns a selector only. TaskLease resolves the issue number
|
|
190
|
+
* from the evidence-bound output after verifying the ALLOW receipt.
|
|
191
|
+
*/
|
|
192
|
+
export function githubIssueListSelectedNumberAuthorityExtractor({ receipt, output } = {}) {
|
|
193
|
+
if (receipt?.service !== 'github' || receipt?.action !== 'issue.list') {
|
|
194
|
+
throw providerError(
|
|
195
|
+
'trusted_extractor_operation_mismatch',
|
|
196
|
+
'GitHub issue-number authority extractor only accepts github:issue.list receipts'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (output?.provider !== 'github' || output?.selected_issue_match_count !== 1) {
|
|
200
|
+
throw providerError(
|
|
201
|
+
'trusted_extractor_output_invalid',
|
|
202
|
+
'normalized GitHub output must contain exactly one selected issue'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
issueNumber(output.selected_issue_number);
|
|
206
|
+
if (typeof output.selected_issue_marker !== 'string' || output.selected_issue_marker.trim() === '') {
|
|
207
|
+
throw providerError('trusted_extractor_output_invalid', 'normalized GitHub output is missing the selection marker');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
extractor_id: 'github.issue.list.selected-number.v1',
|
|
212
|
+
selector: 'output.selected_issue_number'
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
97
216
|
export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fetch, baseUrl = 'https://api.github.com' } = {}) {
|
|
98
217
|
if (!broker) throw new Error('credential broker is required');
|
|
99
218
|
if (typeof fetchImpl !== 'function') throw new Error('fetch implementation is required');
|
|
@@ -114,7 +233,7 @@ export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fet
|
|
|
114
233
|
authorization: `Bearer ${token}`,
|
|
115
234
|
'content-type': 'application/json',
|
|
116
235
|
'x-github-api-version': '2022-11-28',
|
|
117
|
-
'user-agent': 'nullsquare-agent-authority/0.
|
|
236
|
+
'user-agent': 'nullsquare-agent-authority/0.4'
|
|
118
237
|
},
|
|
119
238
|
body: operation.body ? JSON.stringify(operation.body) : undefined
|
|
120
239
|
});
|
|
@@ -125,25 +244,36 @@ export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fet
|
|
|
125
244
|
try { body = JSON.parse(text); } catch { /* preserve text */ }
|
|
126
245
|
}
|
|
127
246
|
|
|
128
|
-
const output = {
|
|
129
|
-
provider: 'github',
|
|
130
|
-
status: response.status,
|
|
131
|
-
ok: response.ok,
|
|
132
|
-
body: sanitizeBody(body),
|
|
133
|
-
request_id: response.headers?.get?.('x-github-request-id') || null
|
|
134
|
-
};
|
|
135
|
-
|
|
136
247
|
if (!response.ok) {
|
|
137
|
-
const
|
|
138
|
-
|
|
248
|
+
const output = {
|
|
249
|
+
provider: 'github',
|
|
250
|
+
status: response.status,
|
|
251
|
+
ok: false,
|
|
252
|
+
body: sanitizeBody(body),
|
|
253
|
+
request_id: response.headers?.get?.('x-github-request-id') || null
|
|
254
|
+
};
|
|
255
|
+
const error = providerError('provider_error', `GitHub API ${response.status}`);
|
|
139
256
|
error.provider_output = output;
|
|
140
257
|
throw error;
|
|
141
258
|
}
|
|
142
259
|
|
|
143
|
-
return
|
|
260
|
+
return normalizedOutput(request, response, body);
|
|
144
261
|
}
|
|
145
262
|
});
|
|
146
263
|
|
|
264
|
+
adapter.validateRequest = (request) => buildOperation(request);
|
|
147
265
|
adapter.isMutation = (request) => MUTATING_ACTIONS.has(request?.action);
|
|
266
|
+
adapter.authorityExtractor = (request, kind = 'opaque') => {
|
|
267
|
+
if (
|
|
268
|
+
request?.service === 'github' &&
|
|
269
|
+
request?.action === 'issue.list' &&
|
|
270
|
+
kind === 'github.issue.number' &&
|
|
271
|
+
typeof request?.context?.fixture_marker === 'string' &&
|
|
272
|
+
request.context.fixture_marker.trim() !== ''
|
|
273
|
+
) {
|
|
274
|
+
return githubIssueListSelectedNumberAuthorityExtractor;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
};
|
|
148
278
|
return adapter;
|
|
149
279
|
}
|
package/src/providers/google.js
CHANGED
|
@@ -42,6 +42,37 @@ export function gmailThreadSenderEmail(thread) {
|
|
|
42
42
|
throw providerError('gmail_sender_missing', 'Gmail thread does not contain a usable From header');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Trusted authority-extraction contract for the normalized Gmail thread output.
|
|
47
|
+
*
|
|
48
|
+
* The extractor chooses the authority-relevant selector only. It never returns
|
|
49
|
+
* the value itself; TaskLease resolves output.sender_email after verifying the
|
|
50
|
+
* guard's execution evidence. This prevents host code from substituting another
|
|
51
|
+
* email while retaining the original Gmail receipt/evidence chain.
|
|
52
|
+
*/
|
|
53
|
+
export function gmailThreadSenderAuthorityExtractor({ receipt, output } = {}) {
|
|
54
|
+
if (receipt?.service !== 'gmail' || receipt?.action !== 'thread.read') {
|
|
55
|
+
throw providerError(
|
|
56
|
+
'trusted_extractor_operation_mismatch',
|
|
57
|
+
'Gmail sender authority extractor only accepts gmail:thread.read receipts'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const raw = typeof output?.sender_email === 'string' ? output.sender_email.trim() : '';
|
|
62
|
+
const normalized = extractEmailAddress(raw);
|
|
63
|
+
if (!normalized || normalized !== raw) {
|
|
64
|
+
throw providerError(
|
|
65
|
+
'trusted_extractor_output_invalid',
|
|
66
|
+
'normalized Gmail output does not contain a canonical sender_email'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
extractor_id: 'google.gmail.thread.sender-email.v1',
|
|
72
|
+
selector: 'output.sender_email'
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
function validateSendUpdates(value) {
|
|
46
77
|
const normalized = value || 'none';
|
|
47
78
|
if (!SEND_UPDATES.has(normalized)) {
|
|
@@ -197,5 +228,11 @@ export function createGoogleProviderAdapter({
|
|
|
197
228
|
|
|
198
229
|
adapter.validateRequest = (request) => operationFor(request);
|
|
199
230
|
adapter.isMutation = (request) => MUTATING_ACTIONS.has(request?.action);
|
|
231
|
+
adapter.authorityExtractor = (request, kind = 'email.address') => {
|
|
232
|
+
if (request?.service === 'gmail' && request?.action === 'thread.read' && kind === 'email.address') {
|
|
233
|
+
return gmailThreadSenderAuthorityExtractor;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
200
237
|
return adapter;
|
|
201
238
|
}
|
package/src/task-lease.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { assertMission, createReceipt, hashObject, matchPattern } from './index.js';
|
|
3
|
+
import {
|
|
4
|
+
resolveEvidenceSelector,
|
|
5
|
+
runAuthorityExtractor,
|
|
6
|
+
verifyExecutionEvidence
|
|
7
|
+
} from './authority-evidence.js';
|
|
3
8
|
|
|
4
9
|
function authorityError(code, message) {
|
|
5
10
|
const error = new Error(message);
|
|
@@ -95,6 +100,12 @@ export class TaskLease {
|
|
|
95
100
|
return structuredClone(fact);
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Legacy host-trusted derivation path.
|
|
105
|
+
*
|
|
106
|
+
* The caller supplies both value and selector. Keep this API for compatibility,
|
|
107
|
+
* but prefer deriveFromEvidence() for authority-relevant provider outputs.
|
|
108
|
+
*/
|
|
98
109
|
derive({ fact_id, kind = 'opaque', value, from = [], receipt, selector } = {}) {
|
|
99
110
|
if (!fact_id) throw new Error('derived fact_id is required');
|
|
100
111
|
if (value === undefined) throw new Error('derived value is required');
|
|
@@ -125,6 +136,7 @@ export class TaskLease {
|
|
|
125
136
|
value,
|
|
126
137
|
provenance: {
|
|
127
138
|
type: 'derived',
|
|
139
|
+
derivation_mode: 'host-trusted',
|
|
128
140
|
from: parents,
|
|
129
141
|
task_lease_id: this.lease_id,
|
|
130
142
|
receipt_id: receipt.receipt_id,
|
|
@@ -140,6 +152,71 @@ export class TaskLease {
|
|
|
140
152
|
return structuredClone(fact);
|
|
141
153
|
}
|
|
142
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Strict derivation path for provider data.
|
|
157
|
+
*
|
|
158
|
+
* The caller cannot supply the authority value. A trusted adapter extractor
|
|
159
|
+
* identifies one normalized output selector, TaskLease resolves that selector
|
|
160
|
+
* itself, and execution evidence proves the output still matches the exact
|
|
161
|
+
* ALLOW receipt returned by guard.run().
|
|
162
|
+
*/
|
|
163
|
+
deriveFromEvidence({
|
|
164
|
+
fact_id,
|
|
165
|
+
kind = 'opaque',
|
|
166
|
+
from = [],
|
|
167
|
+
receipt,
|
|
168
|
+
evidence,
|
|
169
|
+
output,
|
|
170
|
+
extractor
|
|
171
|
+
} = {}) {
|
|
172
|
+
if (!fact_id) throw new Error('derived fact_id is required');
|
|
173
|
+
if (this.facts.has(fact_id)) throw authorityError('fact_exists', `authority fact ${fact_id} already exists`);
|
|
174
|
+
if (!receipt) throw authorityError('receipt_required', 'derived authority requires an authorized source receipt');
|
|
175
|
+
if (receipt.decision !== 'allow') throw authorityError('receipt_not_authorized', 'derived authority requires an ALLOW receipt');
|
|
176
|
+
if (receipt.mission_id !== this.mission.mission_id) {
|
|
177
|
+
throw authorityError('receipt_mission_mismatch', 'source receipt belongs to another mission');
|
|
178
|
+
}
|
|
179
|
+
if (receipt.task_lease_id !== this.lease_id) {
|
|
180
|
+
throw authorityError('receipt_lease_mismatch', 'source receipt belongs to another task lease');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parents = [...new Set(from)];
|
|
184
|
+
if (parents.length === 0) {
|
|
185
|
+
throw authorityError('parent_fact_required', 'derived authority must descend from at least one existing task authority fact');
|
|
186
|
+
}
|
|
187
|
+
for (const parentId of parents) {
|
|
188
|
+
if (!this.facts.has(parentId)) throw authorityError('parent_fact_missing', `authority fact ${parentId} does not exist`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
verifyExecutionEvidence({ receipt, output, evidence });
|
|
192
|
+
const extraction = runAuthorityExtractor({ extractor, receipt, output });
|
|
193
|
+
const value = resolveEvidenceSelector(output, extraction.selector);
|
|
194
|
+
|
|
195
|
+
const fact = {
|
|
196
|
+
fact_id,
|
|
197
|
+
kind,
|
|
198
|
+
value,
|
|
199
|
+
provenance: {
|
|
200
|
+
type: 'derived',
|
|
201
|
+
derivation_mode: 'execution-evidence-v1',
|
|
202
|
+
from: parents,
|
|
203
|
+
task_lease_id: this.lease_id,
|
|
204
|
+
receipt_id: receipt.receipt_id,
|
|
205
|
+
receipt_hash: receipt.receipt_hash,
|
|
206
|
+
source_service: receipt.service,
|
|
207
|
+
source_action: receipt.action,
|
|
208
|
+
source_request_hash: receipt.request_hash,
|
|
209
|
+
selector: extraction.selector,
|
|
210
|
+
extractor_id: extraction.extractor_id,
|
|
211
|
+
source_output_hash: evidence.output_hash,
|
|
212
|
+
execution_evidence_hash: evidence.evidence_hash
|
|
213
|
+
},
|
|
214
|
+
created_at: new Date().toISOString()
|
|
215
|
+
};
|
|
216
|
+
this.facts.set(fact_id, fact);
|
|
217
|
+
return structuredClone(fact);
|
|
218
|
+
}
|
|
219
|
+
|
|
143
220
|
bind(binding) {
|
|
144
221
|
const normalized = validateBinding(binding);
|
|
145
222
|
this.bindings.push(normalized);
|