@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.
- package/CONTRIBUTING.md +93 -0
- package/LICENSE +201 -0
- package/README.md +390 -0
- package/ROADMAP.md +149 -0
- package/SECURITY.md +116 -0
- package/docs/account-connections.md +173 -0
- package/docs/announcement-draft.md +13 -0
- package/docs/architecture.md +106 -0
- package/docs/assets/agent-authority-cover.svg +41 -0
- package/docs/clear-path.md +53 -0
- package/docs/cli.md +130 -0
- package/docs/evidence.md +143 -0
- package/docs/harness-bridge-mode.md +136 -0
- package/docs/harness-integration.md +223 -0
- package/docs/integration-contract.md +132 -0
- package/docs/integrations/vercel-ai-sdk.md +161 -0
- package/docs/launch-checklist.md +29 -0
- package/docs/npm-release.md +19 -0
- package/docs/openclaw-integration.md +97 -0
- package/docs/package-consumer-validation.md +18 -0
- package/docs/release-candidate-status.md +3 -0
- package/docs/release-guardrails.md +8 -0
- package/docs/release-notes-v0.4.md +26 -0
- package/docs/release-scope.md +3 -0
- package/docs/ship-criteria.md +3 -0
- package/docs/task-leases.md +253 -0
- package/docs/validation.md +124 -0
- package/examples/demo.js +19 -0
- package/examples/direct-guard.js +50 -0
- package/examples/harness-managed-connectors.js +72 -0
- package/examples/live-github-derived-mutation.js +208 -0
- package/examples/live-github-task-lease.js +80 -0
- package/examples/mission.json +20 -0
- package/examples/missions/chatgpt-web-validation.json +33 -0
- package/examples/openclaw-tool-wrapper.js +49 -0
- package/examples/task-lease-demo.js +98 -0
- package/examples/validation-mcp-upstream.js +112 -0
- package/package.json +80 -0
- package/src/agent-auth.js +135 -0
- package/src/approvals.js +157 -0
- package/src/cli.js +335 -0
- package/src/connections.js +203 -0
- package/src/execution.js +174 -0
- package/src/guard.js +79 -0
- package/src/harness-bridge.js +131 -0
- package/src/idempotency.js +118 -0
- package/src/index.js +291 -0
- package/src/integrations/ai-sdk.js +59 -0
- package/src/keys.js +15 -0
- package/src/mcp-gateway.js +142 -0
- package/src/mcp-remote.js +102 -0
- package/src/mcp-server.js +102 -0
- package/src/providers/github.js +149 -0
- package/src/runtime-env.js +53 -0
- package/src/sdk.js +75 -0
- package/src/server.js +146 -0
- package/src/storage.js +213 -0
- package/src/task-lease.js +266 -0
package/src/storage.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function authorityHome(env = process.env) {
|
|
7
|
+
return resolve(env.AGENT_AUTHORITY_HOME || join(homedir(), '.agent-authority'));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ensureDir(path) {
|
|
11
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
12
|
+
try { chmodSync(path, 0o700); } catch {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readJson(path, fallback) {
|
|
16
|
+
if (!existsSync(path)) return fallback;
|
|
17
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function atomicJson(path, value, mode = 0o600) {
|
|
21
|
+
ensureDir(dirname(path));
|
|
22
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
23
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
|
|
24
|
+
try { chmodSync(tmp, mode); } catch {}
|
|
25
|
+
renameSync(tmp, path);
|
|
26
|
+
try { chmodSync(path, mode); } catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defaultConfig(home = authorityHome()) {
|
|
30
|
+
return {
|
|
31
|
+
version: 1,
|
|
32
|
+
principal_id: 'user:local',
|
|
33
|
+
server: { host: '127.0.0.1', port: 8787 },
|
|
34
|
+
paths: {
|
|
35
|
+
connections: join(home, 'state', 'connections.json'),
|
|
36
|
+
secrets: join(home, 'vault', 'secrets.enc.json'),
|
|
37
|
+
master_key: join(home, 'vault', 'master.key'),
|
|
38
|
+
revocations: join(home, 'state', 'revocations.json'),
|
|
39
|
+
usage: join(home, 'state', 'usage.json'),
|
|
40
|
+
receipts: join(home, 'receipts')
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ensureAuthorityHome({ home = authorityHome(), principal_id } = {}) {
|
|
46
|
+
ensureDir(home);
|
|
47
|
+
ensureDir(join(home, 'state'));
|
|
48
|
+
ensureDir(join(home, 'vault'));
|
|
49
|
+
ensureDir(join(home, 'missions'));
|
|
50
|
+
ensureDir(join(home, 'receipts'));
|
|
51
|
+
const configPath = join(home, 'config.json');
|
|
52
|
+
if (!existsSync(configPath)) {
|
|
53
|
+
const config = defaultConfig(home);
|
|
54
|
+
if (principal_id) config.principal_id = principal_id;
|
|
55
|
+
atomicJson(configPath, config, 0o600);
|
|
56
|
+
}
|
|
57
|
+
return { home, configPath };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadConfig({ home = authorityHome() } = {}) {
|
|
61
|
+
const { configPath } = ensureAuthorityHome({ home });
|
|
62
|
+
const stored = readJson(configPath, {});
|
|
63
|
+
const defaults = defaultConfig(home);
|
|
64
|
+
return {
|
|
65
|
+
...defaults,
|
|
66
|
+
...stored,
|
|
67
|
+
server: { ...defaults.server, ...(stored.server || {}) },
|
|
68
|
+
paths: { ...defaults.paths, ...(stored.paths || {}) }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveConfig(config, { home = authorityHome() } = {}) {
|
|
73
|
+
ensureAuthorityHome({ home });
|
|
74
|
+
atomicJson(join(home, 'config.json'), config, 0o600);
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function connectionKey(principalId, service, accountId = 'default') {
|
|
79
|
+
return `${principalId}\u0000${service}\u0000${accountId}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class JsonFileConnectionRegistry {
|
|
83
|
+
constructor(path) { this.path = path; ensureDir(dirname(path)); }
|
|
84
|
+
all() { return readJson(this.path, {}); }
|
|
85
|
+
write(value) { atomicJson(this.path, value, 0o600); }
|
|
86
|
+
|
|
87
|
+
connect({ principal_id, service, account_id = 'default', auth_kind, credential_ref, scopes = [], metadata = {} }) {
|
|
88
|
+
if (!principal_id || !service || !auth_kind || !credential_ref) throw new Error('principal_id, service, auth_kind, and credential_ref are required');
|
|
89
|
+
const all = this.all();
|
|
90
|
+
const k = connectionKey(principal_id, service, account_id);
|
|
91
|
+
const previous = all[k];
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
const connection = {
|
|
94
|
+
connection_id: previous?.connection_id || `connection:${randomUUID()}`,
|
|
95
|
+
principal_id,
|
|
96
|
+
service,
|
|
97
|
+
account_id,
|
|
98
|
+
auth_kind,
|
|
99
|
+
credential_ref,
|
|
100
|
+
scopes: [...new Set(scopes)],
|
|
101
|
+
metadata,
|
|
102
|
+
status: 'active',
|
|
103
|
+
connected_at: previous?.connected_at || now,
|
|
104
|
+
updated_at: now
|
|
105
|
+
};
|
|
106
|
+
all[k] = connection;
|
|
107
|
+
this.write(all);
|
|
108
|
+
return { ...connection };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get({ principal_id, service, account_id = 'default' }) {
|
|
112
|
+
const value = this.all()[connectionKey(principal_id, service, account_id)];
|
|
113
|
+
return value ? { ...value } : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
list(principal_id) {
|
|
117
|
+
return Object.values(this.all()).filter((c) => !principal_id || c.principal_id === principal_id).map((c) => ({ ...c }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
disconnect({ principal_id, service, account_id = 'default' }) {
|
|
121
|
+
const all = this.all();
|
|
122
|
+
const k = connectionKey(principal_id, service, account_id);
|
|
123
|
+
if (!all[k]) return null;
|
|
124
|
+
all[k] = { ...all[k], status: 'revoked', updated_at: new Date().toISOString() };
|
|
125
|
+
this.write(all);
|
|
126
|
+
return { ...all[k] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class EncryptedFileSecretStore {
|
|
131
|
+
constructor({ path, keyPath }) {
|
|
132
|
+
this.path = path;
|
|
133
|
+
this.keyPath = keyPath;
|
|
134
|
+
ensureDir(dirname(path));
|
|
135
|
+
ensureDir(dirname(keyPath));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
key() {
|
|
139
|
+
if (!existsSync(this.keyPath)) {
|
|
140
|
+
writeFileSync(this.keyPath, randomBytes(32), { mode: 0o600 });
|
|
141
|
+
try { chmodSync(this.keyPath, 0o600); } catch {}
|
|
142
|
+
}
|
|
143
|
+
const key = readFileSync(this.keyPath);
|
|
144
|
+
if (key.length !== 32) throw new Error('Agent Authority master key is invalid');
|
|
145
|
+
return key;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
all() { return readJson(this.path, {}); }
|
|
149
|
+
write(value) { atomicJson(this.path, value, 0o600); }
|
|
150
|
+
|
|
151
|
+
put(value) {
|
|
152
|
+
const ref = `secret:${randomUUID()}`;
|
|
153
|
+
const iv = randomBytes(12);
|
|
154
|
+
const cipher = createCipheriv('aes-256-gcm', this.key(), iv);
|
|
155
|
+
const plaintext = Buffer.from(JSON.stringify(value));
|
|
156
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
157
|
+
const tag = cipher.getAuthTag();
|
|
158
|
+
const all = this.all();
|
|
159
|
+
all[ref] = { v: 1, alg: 'A256GCM', iv: iv.toString('base64'), tag: tag.toString('base64'), ciphertext: ciphertext.toString('base64') };
|
|
160
|
+
this.write(all);
|
|
161
|
+
return ref;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get(ref) {
|
|
165
|
+
const record = this.all()[ref];
|
|
166
|
+
if (!record) throw new Error('credential secret is unavailable');
|
|
167
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key(), Buffer.from(record.iv, 'base64'));
|
|
168
|
+
decipher.setAuthTag(Buffer.from(record.tag, 'base64'));
|
|
169
|
+
const plaintext = Buffer.concat([decipher.update(Buffer.from(record.ciphertext, 'base64')), decipher.final()]);
|
|
170
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
delete(ref) {
|
|
174
|
+
const all = this.all();
|
|
175
|
+
if (!all[ref]) return false;
|
|
176
|
+
delete all[ref];
|
|
177
|
+
this.write(all);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class JsonFileRevocationStore {
|
|
183
|
+
constructor(path) { this.path = path; ensureDir(dirname(path)); }
|
|
184
|
+
all() { return readJson(this.path, {}); }
|
|
185
|
+
revoke(missionId, reason = 'revoked by principal') {
|
|
186
|
+
const all = this.all();
|
|
187
|
+
const record = { reason, revoked_at: new Date().toISOString() };
|
|
188
|
+
all[missionId] = record;
|
|
189
|
+
atomicJson(this.path, all, 0o600);
|
|
190
|
+
return record;
|
|
191
|
+
}
|
|
192
|
+
get(missionId) { return this.all()[missionId] || null; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class JsonFileUsageLedger {
|
|
196
|
+
constructor(path) { this.path = path; ensureDir(dirname(path)); }
|
|
197
|
+
all() { return readJson(this.path, {}); }
|
|
198
|
+
key(missionId, currency) { return `${missionId}\u0000${currency || 'UNSPECIFIED'}`; }
|
|
199
|
+
spent(missionId, currency) { return Number(this.all()[this.key(missionId, currency)] || 0); }
|
|
200
|
+
record(missionId, currency, amount) {
|
|
201
|
+
const all = this.all();
|
|
202
|
+
const k = this.key(missionId, currency);
|
|
203
|
+
all[k] = Number(all[k] || 0) + Number(amount);
|
|
204
|
+
atomicJson(this.path, all, 0o600);
|
|
205
|
+
return all[k];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function writeReceipt(receiptsDir, receipt) {
|
|
210
|
+
ensureDir(receiptsDir);
|
|
211
|
+
const safe = String(receipt.receipt_id || randomUUID()).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
212
|
+
atomicJson(join(receiptsDir, `${safe}.json`), receipt, 0o600);
|
|
213
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { assertMission, createReceipt, hashObject, matchPattern } from './index.js';
|
|
3
|
+
|
|
4
|
+
function authorityError(code, message) {
|
|
5
|
+
const error = new Error(message);
|
|
6
|
+
error.code = code;
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function deny(code, reason, extra = {}) {
|
|
11
|
+
return { decision: 'deny', code, reason, ...extra };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function approval(code, reason, extra = {}) {
|
|
15
|
+
return { decision: 'require_approval', code, reason, ...extra };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sameValue(a, b) {
|
|
19
|
+
return hashObject(a) === hashObject(b);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateBinding(binding) {
|
|
23
|
+
if (!binding?.service) throw new Error('binding.service is required');
|
|
24
|
+
if (!binding?.action) throw new Error('binding.action is required');
|
|
25
|
+
if (!binding?.context_field) throw new Error('binding.context_field is required');
|
|
26
|
+
if (!binding?.fact_id) throw new Error('binding.fact_id is required');
|
|
27
|
+
return {
|
|
28
|
+
service: binding.service,
|
|
29
|
+
action: binding.action,
|
|
30
|
+
context_field: binding.context_field,
|
|
31
|
+
fact_id: binding.fact_id
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function taskReceipt(lease, request, result) {
|
|
36
|
+
const base = createReceipt({ mission: lease.mission, request, result });
|
|
37
|
+
const { receipt_hash: _oldHash, ...unsigned } = base;
|
|
38
|
+
const receipt = {
|
|
39
|
+
...unsigned,
|
|
40
|
+
task_lease_id: lease.lease_id,
|
|
41
|
+
task_lease_hash: lease.hash()
|
|
42
|
+
};
|
|
43
|
+
return { ...receipt, receipt_hash: hashObject(receipt) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A TaskLease adds temporary, provenance-bound resource restrictions on top of
|
|
48
|
+
* an existing mission. It never grants an action that the mission itself does
|
|
49
|
+
* not already authorize.
|
|
50
|
+
*
|
|
51
|
+
* Roots are values explicitly approved at task entry. Derived facts must be
|
|
52
|
+
* anchored to an ALLOW receipt produced inside the same lease and to at least
|
|
53
|
+
* one existing authority fact. This lets authority follow resources discovered
|
|
54
|
+
* during authorized execution without becoming a standing wildcard permission.
|
|
55
|
+
*/
|
|
56
|
+
export class TaskLease {
|
|
57
|
+
constructor({
|
|
58
|
+
mission,
|
|
59
|
+
lease_id = `lease:${randomUUID()}`,
|
|
60
|
+
request = null,
|
|
61
|
+
roots = [],
|
|
62
|
+
bindings = [],
|
|
63
|
+
expires_at = null,
|
|
64
|
+
created_at = new Date().toISOString()
|
|
65
|
+
} = {}) {
|
|
66
|
+
this.mission = assertMission(mission);
|
|
67
|
+
this.lease_id = lease_id;
|
|
68
|
+
this.request = request;
|
|
69
|
+
this.created_at = created_at;
|
|
70
|
+
this.expires_at = expires_at;
|
|
71
|
+
this.status = 'active';
|
|
72
|
+
this.completed_at = null;
|
|
73
|
+
this.completion_reason = null;
|
|
74
|
+
this.facts = new Map();
|
|
75
|
+
this.bindings = bindings.map(validateBinding);
|
|
76
|
+
|
|
77
|
+
for (const root of roots) this.addRoot(root);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
addRoot({ fact_id, kind = 'opaque', value, source = 'human' } = {}) {
|
|
81
|
+
if (!fact_id) throw new Error('root fact_id is required');
|
|
82
|
+
if (value === undefined) throw new Error('root value is required');
|
|
83
|
+
if (this.facts.has(fact_id)) throw authorityError('fact_exists', `authority fact ${fact_id} already exists`);
|
|
84
|
+
const fact = {
|
|
85
|
+
fact_id,
|
|
86
|
+
kind,
|
|
87
|
+
value,
|
|
88
|
+
provenance: {
|
|
89
|
+
type: 'root',
|
|
90
|
+
source
|
|
91
|
+
},
|
|
92
|
+
created_at: new Date().toISOString()
|
|
93
|
+
};
|
|
94
|
+
this.facts.set(fact_id, fact);
|
|
95
|
+
return structuredClone(fact);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
derive({ fact_id, kind = 'opaque', value, from = [], receipt, selector } = {}) {
|
|
99
|
+
if (!fact_id) throw new Error('derived fact_id is required');
|
|
100
|
+
if (value === undefined) throw new Error('derived value is required');
|
|
101
|
+
if (this.facts.has(fact_id)) throw authorityError('fact_exists', `authority fact ${fact_id} already exists`);
|
|
102
|
+
if (!receipt) throw authorityError('receipt_required', 'derived authority requires an authorized source receipt');
|
|
103
|
+
if (receipt.decision !== 'allow') throw authorityError('receipt_not_authorized', 'derived authority requires an ALLOW receipt');
|
|
104
|
+
if (receipt.mission_id !== this.mission.mission_id) {
|
|
105
|
+
throw authorityError('receipt_mission_mismatch', 'source receipt belongs to another mission');
|
|
106
|
+
}
|
|
107
|
+
if (receipt.task_lease_id !== this.lease_id) {
|
|
108
|
+
throw authorityError('receipt_lease_mismatch', 'source receipt belongs to another task lease');
|
|
109
|
+
}
|
|
110
|
+
if (typeof selector !== 'string' || selector.trim() === '') {
|
|
111
|
+
throw authorityError('selector_required', 'derived authority must record the trusted output selector used to obtain the value');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parents = [...new Set(from)];
|
|
115
|
+
if (parents.length === 0) {
|
|
116
|
+
throw authorityError('parent_fact_required', 'derived authority must descend from at least one existing task authority fact');
|
|
117
|
+
}
|
|
118
|
+
for (const parentId of parents) {
|
|
119
|
+
if (!this.facts.has(parentId)) throw authorityError('parent_fact_missing', `authority fact ${parentId} does not exist`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fact = {
|
|
123
|
+
fact_id,
|
|
124
|
+
kind,
|
|
125
|
+
value,
|
|
126
|
+
provenance: {
|
|
127
|
+
type: 'derived',
|
|
128
|
+
from: parents,
|
|
129
|
+
task_lease_id: this.lease_id,
|
|
130
|
+
receipt_id: receipt.receipt_id,
|
|
131
|
+
receipt_hash: receipt.receipt_hash,
|
|
132
|
+
source_service: receipt.service,
|
|
133
|
+
source_action: receipt.action,
|
|
134
|
+
source_request_hash: receipt.request_hash,
|
|
135
|
+
selector: selector.trim()
|
|
136
|
+
},
|
|
137
|
+
created_at: new Date().toISOString()
|
|
138
|
+
};
|
|
139
|
+
this.facts.set(fact_id, fact);
|
|
140
|
+
return structuredClone(fact);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
bind(binding) {
|
|
144
|
+
const normalized = validateBinding(binding);
|
|
145
|
+
this.bindings.push(normalized);
|
|
146
|
+
return { ...normalized };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fact(factId) {
|
|
150
|
+
const fact = this.facts.get(factId);
|
|
151
|
+
return fact ? structuredClone(fact) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
listFacts() {
|
|
155
|
+
return [...this.facts.values()].map((fact) => structuredClone(fact));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
complete(reason = 'task completed') {
|
|
159
|
+
if (this.status === 'completed') return this.snapshot();
|
|
160
|
+
this.status = 'completed';
|
|
161
|
+
this.completed_at = new Date().toISOString();
|
|
162
|
+
this.completion_reason = reason;
|
|
163
|
+
return this.snapshot();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
snapshot() {
|
|
167
|
+
return {
|
|
168
|
+
version: '0.1',
|
|
169
|
+
lease_id: this.lease_id,
|
|
170
|
+
mission_id: this.mission.mission_id,
|
|
171
|
+
principal_id: this.mission.principal.id,
|
|
172
|
+
agent_id: this.mission.agent.id,
|
|
173
|
+
request: this.request,
|
|
174
|
+
status: this.status,
|
|
175
|
+
created_at: this.created_at,
|
|
176
|
+
expires_at: this.expires_at,
|
|
177
|
+
completed_at: this.completed_at,
|
|
178
|
+
completion_reason: this.completion_reason,
|
|
179
|
+
bindings: this.bindings.map((binding) => ({ ...binding })),
|
|
180
|
+
facts: this.listFacts()
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
hash() {
|
|
185
|
+
return hashObject(this.snapshot());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
matchingBindings(request = {}) {
|
|
189
|
+
return this.bindings.filter((binding) =>
|
|
190
|
+
matchPattern(binding.service, request.service) && matchPattern(binding.action, request.action)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
evaluate(runtime, request, now = new Date()) {
|
|
195
|
+
if (!runtime || typeof runtime.evaluate !== 'function') throw new Error('authority runtime is required');
|
|
196
|
+
|
|
197
|
+
if (this.status !== 'active') {
|
|
198
|
+
const result = deny('task_lease_completed', this.completion_reason || 'task lease has completed');
|
|
199
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (this.expires_at) {
|
|
203
|
+
const expires = new Date(this.expires_at);
|
|
204
|
+
if (Number.isNaN(expires.getTime())) {
|
|
205
|
+
const result = deny('invalid_task_lease_expiry', 'task lease expiry is not a valid date');
|
|
206
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
207
|
+
}
|
|
208
|
+
if (now >= expires) {
|
|
209
|
+
const result = deny('task_lease_expired', `task lease expired at ${this.expires_at}`);
|
|
210
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The mission remains the ceiling. A task lease can only narrow an action
|
|
215
|
+
// that the mission already permits; it can never override DENY or approval.
|
|
216
|
+
const base = runtime.evaluate(this.mission, request, now);
|
|
217
|
+
if (base.result.decision !== 'allow') {
|
|
218
|
+
return { result: base.result, receipt: taskReceipt(this, request, base.result) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const binding of this.matchingBindings(request)) {
|
|
222
|
+
const fact = this.facts.get(binding.fact_id);
|
|
223
|
+
if (!fact) {
|
|
224
|
+
const result = deny(
|
|
225
|
+
'authority_fact_unresolved',
|
|
226
|
+
`task has not established authority fact ${binding.fact_id}`,
|
|
227
|
+
{ binding }
|
|
228
|
+
);
|
|
229
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const actual = request?.context?.[binding.context_field];
|
|
233
|
+
if (actual === undefined || actual === null) {
|
|
234
|
+
const result = deny(
|
|
235
|
+
'authority_context_missing',
|
|
236
|
+
`request context.${binding.context_field} is required by task authority`,
|
|
237
|
+
{ binding }
|
|
238
|
+
);
|
|
239
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!sameValue(actual, fact.value)) {
|
|
243
|
+
const result = approval(
|
|
244
|
+
'authority_delta_required',
|
|
245
|
+
`request context.${binding.context_field} is outside the task's derived authority`,
|
|
246
|
+
{
|
|
247
|
+
authority_delta: {
|
|
248
|
+
service: request.service,
|
|
249
|
+
action: request.action,
|
|
250
|
+
context_field: binding.context_field,
|
|
251
|
+
requested_value: actual,
|
|
252
|
+
current_fact_id: fact.fact_id
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
return { result, receipt: taskReceipt(this, request, result) };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { result: base.result, receipt: taskReceipt(this, request, base.result) };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createTaskLease(options) {
|
|
265
|
+
return new TaskLease(options);
|
|
266
|
+
}
|