@kyaki/agents 0.1.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/dist/approvals.d.ts +35 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +93 -0
- package/dist/approvals.js.map +1 -0
- package/dist/auditor.d.ts +104 -0
- package/dist/auditor.d.ts.map +1 -0
- package/dist/auditor.js +250 -0
- package/dist/auditor.js.map +1 -0
- package/dist/continuous-auditor.d.ts +38 -0
- package/dist/continuous-auditor.d.ts.map +1 -0
- package/dist/continuous-auditor.js +52 -0
- package/dist/continuous-auditor.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/market.d.ts +10 -0
- package/dist/market.d.ts.map +1 -0
- package/dist/market.js +10 -0
- package/dist/market.js.map +1 -0
- package/dist/procurer.d.ts +54 -0
- package/dist/procurer.d.ts.map +1 -0
- package/dist/procurer.js +142 -0
- package/dist/procurer.js.map +1 -0
- package/dist/steward.d.ts +89 -0
- package/dist/steward.d.ts.map +1 -0
- package/dist/steward.js +121 -0
- package/dist/steward.js.map +1 -0
- package/dist/treasury.d.ts +134 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +346 -0
- package/dist/treasury.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +34 -0
- package/src/approvals.ts +111 -0
- package/src/auditor.ts +342 -0
- package/src/continuous-auditor.ts +68 -0
- package/src/index.ts +31 -0
- package/src/market.ts +11 -0
- package/src/procurer.ts +183 -0
- package/src/steward.ts +168 -0
- package/src/treasury.ts +458 -0
- package/src/types.ts +57 -0
package/src/auditor.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auditor.ts — The Auditor: the third agent on the Mandate OS, and the only
|
|
3
|
+
* one that never spends.
|
|
4
|
+
*
|
|
5
|
+
* Where the Procurer pushes money outward and the Treasurer moves the balance
|
|
6
|
+
* sheet, the Auditor *proves the whole fleet stayed inside its authority*. It
|
|
7
|
+
* is the agent that operationalizes the project's central claim — "the books
|
|
8
|
+
* are self-auditing" — by turning it into a concrete, signed artifact.
|
|
9
|
+
*
|
|
10
|
+
* It composes existing rings; it invents no new kernel primitive:
|
|
11
|
+
* L0 AuditLog.verifyChain — is the record itself tamper-free?
|
|
12
|
+
* L0 verifyTransaction — does each committed spend STILL verify,
|
|
13
|
+
* re-run against state as of its own commit?
|
|
14
|
+
* L1 OrgSpendLedger semantics — did aggregate exposure breach the org cap?
|
|
15
|
+
* L1 AutonomyLadder.demote — punish the agent the instant a provable
|
|
16
|
+
* cryptographic anomaly is found.
|
|
17
|
+
*
|
|
18
|
+
* The audit produces a `KyaAuditAttestation`: an Ed25519 signature by the
|
|
19
|
+
* auditor's own key over the canonical set of findings + the chain head. Like
|
|
20
|
+
* a mandate, an intent, or a CFO approval, the audit-of-record is a verifiable
|
|
21
|
+
* object — not a database row asserting "trust me."
|
|
22
|
+
*
|
|
23
|
+
* Reconciliation principle (inherited from the Procurer's savings report): if
|
|
24
|
+
* the hash chain does not verify, the committed record cannot be trusted, so
|
|
25
|
+
* the report says so loudly rather than quietly reporting clean.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
canonicalBytes, fromBase58, publicKeyFromDid, sign, toBase58, verifySignature,
|
|
29
|
+
verifyTransaction, MemorySpendStore,
|
|
30
|
+
type AgentKeys, type AuditEvent, type AuditLog, type SpendMandate,
|
|
31
|
+
type TransactionIntent, type VerificationContext,
|
|
32
|
+
} from '@kyaki/core';
|
|
33
|
+
import { AutonomyLadder, type SpendPolicy } from '@kyaki/policy';
|
|
34
|
+
|
|
35
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
36
|
+
|
|
37
|
+
/** Kernel deny-reasons that prove cryptographic misbehavior by a named agent
|
|
38
|
+
* key — these, and only these, cost the agent a rung on the autonomy ladder. */
|
|
39
|
+
const SECURITY_REASONS = new Set([
|
|
40
|
+
'MANDATE_SIGNATURE_INVALID',
|
|
41
|
+
'INTENT_SIGNATURE_INVALID',
|
|
42
|
+
'AGENT_MISMATCH',
|
|
43
|
+
'MANDATE_ID_MISMATCH',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export type Severity = 'info' | 'warning' | 'violation' | 'critical';
|
|
47
|
+
|
|
48
|
+
export interface Finding {
|
|
49
|
+
code: string;
|
|
50
|
+
severity: Severity;
|
|
51
|
+
detail: string;
|
|
52
|
+
agentDid?: string;
|
|
53
|
+
mandateId?: string;
|
|
54
|
+
intentId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A committed spend, presented to the Auditor as the verifiable pair the
|
|
58
|
+
* kernel originally checked: the agent's signed intent + the principal's
|
|
59
|
+
* signed mandate it named. */
|
|
60
|
+
export interface CommittedSpend {
|
|
61
|
+
intent: TransactionIntent;
|
|
62
|
+
mandate: SpendMandate;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A revocation event with a timestamp. The live RevocationStore is timeless
|
|
66
|
+
* (boolean); for forensic "did the spend happen AFTER revocation?" the
|
|
67
|
+
* Auditor needs the revocation's `at`. */
|
|
68
|
+
export interface RevocationRecord {
|
|
69
|
+
mandateId: string;
|
|
70
|
+
at: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AuditAttestation {
|
|
74
|
+
type: 'KyaAuditAttestation';
|
|
75
|
+
auditor: string;
|
|
76
|
+
chainHead: string;
|
|
77
|
+
at: string;
|
|
78
|
+
findings: Finding[];
|
|
79
|
+
signature: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AuditReport {
|
|
83
|
+
at: string;
|
|
84
|
+
chainValid: boolean;
|
|
85
|
+
clean: boolean;
|
|
86
|
+
findings: Finding[];
|
|
87
|
+
summary: Record<Severity, number>;
|
|
88
|
+
reconciledMandates: number;
|
|
89
|
+
reconciledSpends: number;
|
|
90
|
+
demoted: string[];
|
|
91
|
+
attestation: AuditAttestation;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AuditInput {
|
|
95
|
+
committed: CommittedSpend[];
|
|
96
|
+
revocations?: RevocationRecord[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AuditorConfig {
|
|
100
|
+
auditorKeys: AgentKeys;
|
|
101
|
+
/** The hash chain under examination (the fleet's audit-of-record). */
|
|
102
|
+
audit: AuditLog;
|
|
103
|
+
/** Org policy — only `orgLimits.dailyCap` is consulted, for aggregate exposure. */
|
|
104
|
+
policy?: SpendPolicy;
|
|
105
|
+
/** When present, agents caught in a security anomaly are demoted. */
|
|
106
|
+
ladder?: AutonomyLadder;
|
|
107
|
+
/** Where the Auditor anchors its OWN result, so the audit is itself audited. */
|
|
108
|
+
oversight?: AuditLog;
|
|
109
|
+
/** Chain event types that denote a committed spend, for chain↔object
|
|
110
|
+
* reconciliation. Defaults to the Procurer's commit events; pass the
|
|
111
|
+
* Treasurer's (or any agent's) commit event types to widen coverage. */
|
|
112
|
+
committedEventTypes?: string[];
|
|
113
|
+
now?: Date;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const GENESIS = '0'.repeat(64);
|
|
117
|
+
const DEFAULT_COMMIT_TYPES = ['procurement.purchased', 'approval.executed'];
|
|
118
|
+
|
|
119
|
+
const EMPTY_SUMMARY = (): Record<Severity, number> => ({ info: 0, warning: 0, violation: 0, critical: 0 });
|
|
120
|
+
|
|
121
|
+
/** Recompute and check an attestation's signature. Tampering with any finding,
|
|
122
|
+
* the chain head, or the timestamp invalidates it — exactly like a mandate. */
|
|
123
|
+
export function verifyAuditAttestation(att: AuditAttestation): boolean {
|
|
124
|
+
try {
|
|
125
|
+
const { signature, ...doc } = att;
|
|
126
|
+
const publicKey = publicKeyFromDid(att.auditor);
|
|
127
|
+
return verifySignature(fromBase58(signature), canonicalBytes(doc), publicKey);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class AuditorAgent {
|
|
134
|
+
constructor(private readonly cfg: AuditorConfig) {}
|
|
135
|
+
|
|
136
|
+
private now(): Date { return this.cfg.now ?? new Date(); }
|
|
137
|
+
|
|
138
|
+
/** The intent id a commit-class chain event points at (txnId or intentId). */
|
|
139
|
+
private intentIdOf(event: AuditEvent): string | undefined {
|
|
140
|
+
return (event as { txnId?: string; intentId?: string }).txnId
|
|
141
|
+
?? (event as { intentId?: string }).intentId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private commitTypes(): Set<string> {
|
|
145
|
+
return new Set(this.cfg.committedEventTypes ?? DEFAULT_COMMIT_TYPES);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async audit(input: AuditInput): Promise<AuditReport> {
|
|
149
|
+
const at = this.now().toISOString();
|
|
150
|
+
const findings: Finding[] = [];
|
|
151
|
+
const revokedAt = new Map<string, number>();
|
|
152
|
+
for (const r of input.revocations ?? []) revokedAt.set(r.mandateId, Date.parse(r.at));
|
|
153
|
+
|
|
154
|
+
// 1. Chain integrity. If the record is forged, nothing built on it is trustworthy.
|
|
155
|
+
const chainValid = this.cfg.audit.verifyChain();
|
|
156
|
+
if (!chainValid) {
|
|
157
|
+
findings.push({
|
|
158
|
+
code: 'CHAIN_BROKEN', severity: 'critical',
|
|
159
|
+
detail: 'Audit hash chain failed verification — the committed record has been tampered with; downstream reconciliation is not trustworthy.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Order committed spends by their own timestamp so per-mandate cumulative
|
|
164
|
+
// and velocity re-checks see only what preceded each spend.
|
|
165
|
+
const ordered = input.committed
|
|
166
|
+
.map((c, i) => ({ c, i, t: Date.parse(c.intent.timestamp) }))
|
|
167
|
+
.sort((a, b) => (a.t - b.t) || (a.i - b.i));
|
|
168
|
+
|
|
169
|
+
const mandates = new Set<string>();
|
|
170
|
+
const nonceSeen = new Map<string, string>(); // nonce -> first intentId
|
|
171
|
+
const intentIdSeen = new Map<string, number>();
|
|
172
|
+
|
|
173
|
+
// 2. Per-spend re-verification, AS OF the spend's own commit time.
|
|
174
|
+
for (let pos = 0; pos < ordered.length; pos++) {
|
|
175
|
+
const { c } = ordered[pos]!;
|
|
176
|
+
const { intent, mandate } = c;
|
|
177
|
+
mandates.add(mandate.id);
|
|
178
|
+
|
|
179
|
+
// Reconstruct mandate state as it stood just before this spend: every
|
|
180
|
+
// strictly-earlier committed spend on the SAME mandate (ordered is a
|
|
181
|
+
// stable timestamp sort, so positions < pos are exactly those).
|
|
182
|
+
const spendTime = new Date(Date.parse(intent.timestamp));
|
|
183
|
+
const spend = new MemorySpendStore();
|
|
184
|
+
for (let k = 0; k < pos; k++) {
|
|
185
|
+
const prior = ordered[k]!.c.intent;
|
|
186
|
+
if (prior.mandateId !== intent.mandateId) continue;
|
|
187
|
+
await spend.record(prior.mandateId, prior.amount, prior.id, new Date(ordered[k]!.t));
|
|
188
|
+
}
|
|
189
|
+
const ctx: VerificationContext = { spend, now: spendTime };
|
|
190
|
+
const res = await verifyTransaction(mandate, intent, ctx);
|
|
191
|
+
if (res.decision === 'deny') {
|
|
192
|
+
for (const reason of res.reasons) {
|
|
193
|
+
findings.push({
|
|
194
|
+
code: reason,
|
|
195
|
+
severity: SECURITY_REASONS.has(reason) ? 'critical' : 'violation',
|
|
196
|
+
detail: `Committed spend fails re-verification as of its own timestamp: ${reason}. The kernel should never have allowed it.`,
|
|
197
|
+
agentDid: intent.agent, mandateId: mandate.id, intentId: intent.id,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2b. Authority that has since lapsed (the spend was fine; the mandate is now dead).
|
|
203
|
+
if (Date.parse(mandate.validUntil) < this.now().getTime()) {
|
|
204
|
+
findings.push({
|
|
205
|
+
code: 'AUTHORITY_LAPSED', severity: 'warning',
|
|
206
|
+
detail: 'The mandate behind a committed spend has since expired. The spend was authorized when made; the authority is no longer live.',
|
|
207
|
+
agentDid: intent.agent, mandateId: mandate.id, intentId: intent.id,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2c. Spend AFTER revocation — a real breach.
|
|
212
|
+
const rev = revokedAt.get(mandate.id);
|
|
213
|
+
if (rev !== undefined && Date.parse(intent.timestamp) >= rev) {
|
|
214
|
+
findings.push({
|
|
215
|
+
code: 'SPEND_AFTER_REVOCATION', severity: 'critical',
|
|
216
|
+
detail: 'A spend committed at or after the mandate was revoked.',
|
|
217
|
+
agentDid: intent.agent, mandateId: mandate.id, intentId: intent.id,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 3. Ledger-level duplicate detection: replay / double-spend that slipped in.
|
|
222
|
+
const priorNonce = nonceSeen.get(intent.nonce);
|
|
223
|
+
if (priorNonce && priorNonce !== intent.id) {
|
|
224
|
+
findings.push({
|
|
225
|
+
code: 'REPLAY_IN_LEDGER', severity: 'critical',
|
|
226
|
+
detail: `Duplicate nonce across committed intents (${priorNonce} and ${intent.id}) — a replay reached the ledger.`,
|
|
227
|
+
agentDid: intent.agent, mandateId: mandate.id, intentId: intent.id,
|
|
228
|
+
});
|
|
229
|
+
} else if (!priorNonce) {
|
|
230
|
+
nonceSeen.set(intent.nonce, intent.id);
|
|
231
|
+
}
|
|
232
|
+
intentIdSeen.set(intent.id, (intentIdSeen.get(intent.id) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const [intentId, count] of intentIdSeen) {
|
|
236
|
+
if (count > 1) {
|
|
237
|
+
findings.push({
|
|
238
|
+
code: 'DUPLICATE_INTENT_ID', severity: 'critical',
|
|
239
|
+
detail: `Intent id ${intentId} appears ${count}× in the committed ledger.`,
|
|
240
|
+
intentId,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. Aggregate org exposure (cross-mandate) within the rolling day.
|
|
246
|
+
const dailyCap = this.cfg.policy?.orgLimits?.dailyCap;
|
|
247
|
+
if (dailyCap !== undefined) {
|
|
248
|
+
const tNow = this.now().getTime();
|
|
249
|
+
const spentToday = input.committed
|
|
250
|
+
.filter((c) => tNow - Date.parse(c.intent.timestamp) < DAY_MS)
|
|
251
|
+
.reduce((s, c) => s + c.intent.amount, 0);
|
|
252
|
+
if (spentToday > dailyCap) {
|
|
253
|
+
findings.push({
|
|
254
|
+
code: 'ORG_EXPOSURE_BREACH', severity: 'violation',
|
|
255
|
+
detail: `Aggregate spend in the last 24h (${spentToday}) exceeds the org daily cap (${dailyCap}).`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. Coverage: the chain and the signed objects must reconcile, both ways.
|
|
261
|
+
// (Only meaningful if the chain verifies in the first place.)
|
|
262
|
+
if (chainValid) {
|
|
263
|
+
const commitTypes = this.commitTypes();
|
|
264
|
+
const chainIntentIds = new Set<string>();
|
|
265
|
+
for (const e of this.cfg.audit.entries()) {
|
|
266
|
+
if (!commitTypes.has(e.event.type)) continue;
|
|
267
|
+
const id = this.intentIdOf(e.event);
|
|
268
|
+
if (id) chainIntentIds.add(id);
|
|
269
|
+
}
|
|
270
|
+
const committedIds = new Set(input.committed.map((c) => c.intent.id));
|
|
271
|
+
|
|
272
|
+
for (const c of input.committed) {
|
|
273
|
+
if (!chainIntentIds.has(c.intent.id)) {
|
|
274
|
+
findings.push({
|
|
275
|
+
code: 'UNRECORDED_SPEND', severity: 'critical',
|
|
276
|
+
detail: 'A signed, committed intent has no matching entry in the audit chain — money moved with no record.',
|
|
277
|
+
agentDid: c.intent.agent, mandateId: c.intent.mandateId, intentId: c.intent.id,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const id of chainIntentIds) {
|
|
282
|
+
if (!committedIds.has(id)) {
|
|
283
|
+
findings.push({
|
|
284
|
+
code: 'PHANTOM_ENTRY', severity: 'critical',
|
|
285
|
+
detail: 'A commit entry in the audit chain references an intent with no backing signed object — a record with no provable spend.',
|
|
286
|
+
intentId: id,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 6. Enforcement: demote every agent provably caught in a security anomaly,
|
|
293
|
+
// once per audit (asymmetric, instant — the ladder's whole purpose).
|
|
294
|
+
const offenders = new Set<string>();
|
|
295
|
+
for (const f of findings) {
|
|
296
|
+
const isSecurity = SECURITY_REASONS.has(f.code) || f.code === 'SPEND_AFTER_REVOCATION' || f.code === 'REPLAY_IN_LEDGER';
|
|
297
|
+
if (isSecurity && f.agentDid) offenders.add(f.agentDid);
|
|
298
|
+
}
|
|
299
|
+
const demoted: string[] = [];
|
|
300
|
+
if (this.cfg.ladder) {
|
|
301
|
+
for (const agentDid of offenders) {
|
|
302
|
+
this.cfg.ladder.record(agentDid, 'security_anomaly');
|
|
303
|
+
demoted.push(agentDid);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 7. Tally, sign the attestation, and anchor the audit-of-the-audit.
|
|
308
|
+
const summary = EMPTY_SUMMARY();
|
|
309
|
+
for (const f of findings) summary[f.severity] += 1;
|
|
310
|
+
const clean = summary.violation === 0 && summary.critical === 0;
|
|
311
|
+
|
|
312
|
+
const entries = this.cfg.audit.entries();
|
|
313
|
+
const chainHead = entries.length > 0 ? entries[entries.length - 1]!.hash : GENESIS;
|
|
314
|
+
const attestation = this.signAttestation(chainHead, at, findings);
|
|
315
|
+
|
|
316
|
+
this.cfg.oversight?.append({
|
|
317
|
+
type: 'audit.completed',
|
|
318
|
+
auditor: this.cfg.auditorKeys.did,
|
|
319
|
+
chainHead, chainValid, clean,
|
|
320
|
+
findingCount: findings.length,
|
|
321
|
+
summary,
|
|
322
|
+
demoted,
|
|
323
|
+
} as AuditEvent, this.now());
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
at, chainValid, clean, findings, summary,
|
|
327
|
+
reconciledMandates: mandates.size,
|
|
328
|
+
reconciledSpends: input.committed.length,
|
|
329
|
+
demoted, attestation,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private signAttestation(chainHead: string, at: string, findings: Finding[]): AuditAttestation {
|
|
334
|
+
const doc = {
|
|
335
|
+
type: 'KyaAuditAttestation' as const,
|
|
336
|
+
auditor: this.cfg.auditorKeys.did,
|
|
337
|
+
chainHead, at, findings,
|
|
338
|
+
};
|
|
339
|
+
const signature = toBase58(sign(canonicalBytes(doc), this.cfg.auditorKeys.privateKey));
|
|
340
|
+
return { ...doc, signature };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* continuous-auditor.ts — the operator surface around the Auditor.
|
|
3
|
+
*
|
|
4
|
+
* The Auditor answers one question per call. An operator (or the CFO inbox)
|
|
5
|
+
* wants the *running* answer: has the fleet ever drifted, how long has it been
|
|
6
|
+
* clean, and what is the tamper-evident history of every attestation it signed?
|
|
7
|
+
*
|
|
8
|
+
* This wraps AuditorAgent into a run-on-demand loop — drive it from a cron, a
|
|
9
|
+
* queue, or a `while` loop — and folds each pass into a running record. The
|
|
10
|
+
* `oversight` AuditLog shared via config anchors every run into a hash chain,
|
|
11
|
+
* so the audit history is itself audited. Each signed attestation is a portable
|
|
12
|
+
* object the human-governance ring can ingest and verify offline.
|
|
13
|
+
*/
|
|
14
|
+
import { AuditorAgent, type AuditAttestation, type AuditInput, type AuditReport, type AuditorConfig } from './auditor.js';
|
|
15
|
+
|
|
16
|
+
export interface AuditHealth {
|
|
17
|
+
runs: number;
|
|
18
|
+
/** Has ANY pass come back non-clean? Monitors latch on this. */
|
|
19
|
+
everDirty: boolean;
|
|
20
|
+
/** Consecutive clean passes ending at the latest run. */
|
|
21
|
+
cleanStreak: number;
|
|
22
|
+
/** Findings in the most recent pass. */
|
|
23
|
+
openFindings: number;
|
|
24
|
+
lastAuditAt?: string;
|
|
25
|
+
lastAttestation?: AuditAttestation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ContinuousAuditor {
|
|
29
|
+
private readonly auditor: AuditorAgent;
|
|
30
|
+
private readonly reports: AuditReport[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(cfg: AuditorConfig) {
|
|
33
|
+
this.auditor = new AuditorAgent(cfg);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Run one audit pass and fold it into the running record. */
|
|
37
|
+
async run(input: AuditInput): Promise<AuditReport> {
|
|
38
|
+
const report = await this.auditor.audit(input);
|
|
39
|
+
this.reports.push(report);
|
|
40
|
+
return report;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every signed attestation, in run order — the audit-of-record stream. */
|
|
44
|
+
attestations(): AuditAttestation[] {
|
|
45
|
+
return this.reports.map((r) => r.attestation);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
history(): readonly AuditReport[] {
|
|
49
|
+
return this.reports;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A one-glance operational summary for a dashboard or the CFO inbox. */
|
|
53
|
+
health(): AuditHealth {
|
|
54
|
+
let cleanStreak = 0;
|
|
55
|
+
for (let i = this.reports.length - 1; i >= 0; i--) {
|
|
56
|
+
if (!this.reports[i]!.clean) break;
|
|
57
|
+
cleanStreak += 1;
|
|
58
|
+
}
|
|
59
|
+
const last = this.reports[this.reports.length - 1];
|
|
60
|
+
return {
|
|
61
|
+
runs: this.reports.length,
|
|
62
|
+
everDirty: this.reports.some((r) => !r.clean),
|
|
63
|
+
cleanStreak,
|
|
64
|
+
openFindings: last ? last.findings.length : 0,
|
|
65
|
+
...(last ? { lastAuditAt: last.at, lastAttestation: last.attestation } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kyaki/agents — L2 Agent Runtime.
|
|
3
|
+
*
|
|
4
|
+
* Two spenders, a watcher, and a steward:
|
|
5
|
+
* ProcurerAgent — sources compliant vendors, spends outward, proves savings.
|
|
6
|
+
* Treasurer — manages the balance sheet: obligations, FX sourcing, sweeps.
|
|
7
|
+
* AuditorAgent — spends nothing; proves the fleet stayed inside authority
|
|
8
|
+
* and signs a verifiable audit-of-record.
|
|
9
|
+
* Steward — acts on the PRINCIPAL's key: issues mandates from policy and
|
|
10
|
+
* revokes them when the Auditor proves an agent misbehaved.
|
|
11
|
+
*/
|
|
12
|
+
export type { ApprovalRequest, Market, OutcomeStatus, ProcurementNeed, ProcurementOutcome, SkippedOffer, VendorOffer } from './types.js';
|
|
13
|
+
export { MockMarket } from './market.js';
|
|
14
|
+
export { ApprovalInbox, signApproval, verifyApproval, type SignedApproval } from './approvals.js';
|
|
15
|
+
export { ProcurerAgent, type ProcurerConfig } from './procurer.js';
|
|
16
|
+
export {
|
|
17
|
+
Treasurer, StaticFX, TreasuryApprovalInbox,
|
|
18
|
+
type Account, type AccountType, type ActionKind, type ActionStatus,
|
|
19
|
+
type CashPosition, type FXProvider, type Obligation, type PendingMovement,
|
|
20
|
+
type MovementStatus, type TreasurerConfig, type TreasuryAction,
|
|
21
|
+
} from './treasury.js';
|
|
22
|
+
export {
|
|
23
|
+
AuditorAgent, verifyAuditAttestation,
|
|
24
|
+
type AuditAttestation, type AuditInput, type AuditReport, type AuditorConfig,
|
|
25
|
+
type CommittedSpend, type Finding, type RevocationRecord, type Severity,
|
|
26
|
+
} from './auditor.js';
|
|
27
|
+
export { ContinuousAuditor, type AuditHealth } from './continuous-auditor.js';
|
|
28
|
+
export {
|
|
29
|
+
Steward, verifyGovernanceAttestation,
|
|
30
|
+
type AuthorityAction, type AuthorityActionKind, type GovernanceAttestation, type StewardConfig,
|
|
31
|
+
} from './steward.js';
|
package/src/market.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* market.ts — Mock RFQ market for demos and tests.
|
|
3
|
+
*/
|
|
4
|
+
import type { Market, VendorOffer } from './types.js';
|
|
5
|
+
|
|
6
|
+
export class MockMarket implements Market {
|
|
7
|
+
constructor(private readonly book: Record<string, VendorOffer[]>) {}
|
|
8
|
+
offersFor(needId: string): VendorOffer[] {
|
|
9
|
+
return [...(this.book[needId] ?? [])];
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/procurer.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* procurer.ts — The Procurer: first autonomous agent on the Mandate OS.
|
|
3
|
+
* RFQ across vendors, COMPLIANCE-FILTERED sourcing (a cheaper off-policy
|
|
4
|
+
* vendor is refused, not bought), tri-state execution through the policy
|
|
5
|
+
* engine, signed-approval workflow, and a savings report where every claimed
|
|
6
|
+
* saving resolves to a signed transaction id in the hash-chained audit log.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createTransactionIntent, type AgentKeys, type AuditLog, type SpendMandate,
|
|
10
|
+
type TransactionIntent, type VerificationContext,
|
|
11
|
+
} from '@kyaki/core';
|
|
12
|
+
import {
|
|
13
|
+
evaluateWithPolicy, type AutonomyLadder, type OrgSpendLedger, type SpendPolicy,
|
|
14
|
+
} from '@kyaki/policy';
|
|
15
|
+
import { verifyApproval, type ApprovalInbox, type SignedApproval } from './approvals.js';
|
|
16
|
+
import type { CommittedSpend } from './auditor.js';
|
|
17
|
+
import type { Market, ProcurementNeed, ProcurementOutcome, SkippedOffer, VendorOffer } from './types.js';
|
|
18
|
+
|
|
19
|
+
export interface ProcurerConfig {
|
|
20
|
+
agentKeys: AgentKeys;
|
|
21
|
+
mandate: SpendMandate;
|
|
22
|
+
approvalAbove?: number;
|
|
23
|
+
policy: SpendPolicy;
|
|
24
|
+
ladder?: AutonomyLadder;
|
|
25
|
+
orgLedger?: OrgSpendLedger;
|
|
26
|
+
kernelCtx: VerificationContext;
|
|
27
|
+
market: Market;
|
|
28
|
+
approvals: ApprovalInbox;
|
|
29
|
+
audit?: AuditLog;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PurchaseLine {
|
|
33
|
+
needId: string;
|
|
34
|
+
vendor: string;
|
|
35
|
+
paid: number;
|
|
36
|
+
baseline: number;
|
|
37
|
+
txnId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ProcurerAgent {
|
|
41
|
+
private purchases: PurchaseLine[] = [];
|
|
42
|
+
private committed: CommittedSpend[] = [];
|
|
43
|
+
|
|
44
|
+
constructor(private readonly cfg: ProcurerConfig) {}
|
|
45
|
+
|
|
46
|
+
private scope() { return this.cfg.mandate.credentialSubject.scope; }
|
|
47
|
+
|
|
48
|
+
private complianceFailure(offer: VendorOffer, need: ProcurementNeed): string | null {
|
|
49
|
+
const scope = this.scope();
|
|
50
|
+
if (offer.currency !== scope.currency) return 'CURRENCY_MISMATCH';
|
|
51
|
+
if (scope.merchantAllowlist && scope.merchantAllowlist.length > 0 && !scope.merchantAllowlist.includes(offer.vendor)) {
|
|
52
|
+
return 'MERCHANT_NOT_ALLOWED';
|
|
53
|
+
}
|
|
54
|
+
if (offer.price > scope.maxPerTransaction) return 'AMOUNT_EXCEEDS_PER_TXN_CAP';
|
|
55
|
+
if (need.maxBudget !== undefined && offer.price > need.maxBudget) return 'OVER_NEED_BUDGET';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private intentFor(offer: VendorOffer, need: ProcurementNeed): TransactionIntent {
|
|
60
|
+
return createTransactionIntent({
|
|
61
|
+
agent: this.cfg.agentKeys,
|
|
62
|
+
mandateId: this.cfg.mandate.id,
|
|
63
|
+
merchant: offer.vendor,
|
|
64
|
+
amount: offer.price,
|
|
65
|
+
currency: offer.currency,
|
|
66
|
+
...(need.category ? { category: need.category } : {}),
|
|
67
|
+
description: need.description,
|
|
68
|
+
...(this.cfg.kernelCtx.now ? { now: this.cfg.kernelCtx.now } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async run(needs: ProcurementNeed[]): Promise<ProcurementOutcome[]> {
|
|
73
|
+
const outcomes: ProcurementOutcome[] = [];
|
|
74
|
+
for (const need of needs) outcomes.push(await this.source(need));
|
|
75
|
+
return outcomes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async source(need: ProcurementNeed): Promise<ProcurementOutcome> {
|
|
79
|
+
const audit = this.cfg.audit;
|
|
80
|
+
const offers = [...(await this.cfg.market.offersFor(need.id))].sort((a, b) => a.price - b.price);
|
|
81
|
+
|
|
82
|
+
const compliant: VendorOffer[] = [];
|
|
83
|
+
const skipped: SkippedOffer[] = [];
|
|
84
|
+
for (const offer of offers) {
|
|
85
|
+
const failure = this.complianceFailure(offer, need);
|
|
86
|
+
if (failure === null) compliant.push(offer);
|
|
87
|
+
else skipped.push({ vendor: offer.vendor, price: offer.price, reason: failure });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (compliant.length === 0) {
|
|
91
|
+
audit?.append({ type: 'procurement.refused', needId: need.id, description: need.description, skipped });
|
|
92
|
+
return { need, status: 'no_compliant_vendor', skippedNonCompliant: skipped };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const best = compliant[0]!;
|
|
96
|
+
const baseline = compliant[compliant.length - 1]!.price;
|
|
97
|
+
const temptations = skipped.filter((s) => s.price < best.price);
|
|
98
|
+
|
|
99
|
+
const intent = this.intentFor(best, need);
|
|
100
|
+
const result = await evaluateWithPolicy({
|
|
101
|
+
policy: this.cfg.policy, mandate: this.cfg.mandate, intent,
|
|
102
|
+
...(this.cfg.approvalAbove !== undefined ? { approvalAbove: this.cfg.approvalAbove } : {}),
|
|
103
|
+
...(this.cfg.ladder ? { ladder: this.cfg.ladder } : {}),
|
|
104
|
+
...(this.cfg.orgLedger ? { orgLedger: this.cfg.orgLedger } : {}),
|
|
105
|
+
kernelCtx: this.cfg.kernelCtx,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (result.decision === 'allow') {
|
|
109
|
+
this.purchases.push({ needId: need.id, vendor: best.vendor, paid: best.price, baseline, txnId: intent.id });
|
|
110
|
+
this.committed.push({ intent, mandate: this.cfg.mandate });
|
|
111
|
+
audit?.append({ type: 'procurement.purchased', needId: need.id, vendor: best.vendor, amount: best.price, currency: best.currency, txnId: intent.id });
|
|
112
|
+
return { need, status: 'purchased', offer: best, txnId: intent.id, ...(temptations.length > 0 ? { skippedNonCompliant: temptations } : {}) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (result.decision === 'escalate') {
|
|
116
|
+
const request = this.cfg.approvals.submit({ intent, mandate: this.cfg.mandate, need, offer: best, reason: result.policy.reasons.join(',') });
|
|
117
|
+
audit?.append({ type: 'procurement.escalated', needId: need.id, vendor: best.vendor, amount: best.price, approvalId: request.id, intentId: intent.id });
|
|
118
|
+
return { need, status: 'pending_approval', offer: best, approvalId: request.id, ...(temptations.length > 0 ? { skippedNonCompliant: temptations } : {}) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
audit?.append({ type: 'procurement.deferred', needId: need.id, vendor: best.vendor, amount: best.price, reasons: [...result.kernel.reasons, ...result.policy.reasons] });
|
|
122
|
+
return { need, status: 'deferred', offer: best, reasons: [...result.kernel.reasons, ...result.policy.reasons] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async executeApproved(approvalId: string, approval: SignedApproval): Promise<ProcurementOutcome> {
|
|
126
|
+
const request = this.cfg.approvals.get(approvalId);
|
|
127
|
+
if (!request) throw new Error(`No approval request ${approvalId}`);
|
|
128
|
+
|
|
129
|
+
if (!verifyApproval(approval) || approval.intentId !== request.intent.id) {
|
|
130
|
+
return { need: request.need, status: 'deferred', reasons: ['APPROVAL_SIGNATURE_INVALID'] };
|
|
131
|
+
}
|
|
132
|
+
if (!this.cfg.approvals.markExecuted(approvalId)) {
|
|
133
|
+
return { need: request.need, status: 'deferred', reasons: ['ALREADY_EXECUTED'] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await evaluateWithPolicy({
|
|
137
|
+
policy: this.cfg.policy, mandate: this.cfg.mandate, intent: request.intent,
|
|
138
|
+
...(this.cfg.approvalAbove !== undefined ? { approvalAbove: this.cfg.approvalAbove } : {}),
|
|
139
|
+
humanApproval: approval,
|
|
140
|
+
...(this.cfg.ladder ? { ladder: this.cfg.ladder } : {}),
|
|
141
|
+
...(this.cfg.orgLedger ? { orgLedger: this.cfg.orgLedger } : {}),
|
|
142
|
+
kernelCtx: this.cfg.kernelCtx,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (result.decision !== 'allow') {
|
|
146
|
+
this.cfg.audit?.append({ type: 'approval.execution_denied', approvalId, intentId: request.intent.id, reasons: [...result.kernel.reasons, ...result.policy.reasons] });
|
|
147
|
+
return { need: request.need, status: 'deferred', offer: request.offer, reasons: [...result.kernel.reasons, ...result.policy.reasons] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const offers = [...(await this.cfg.market.offersFor(request.need.id))]
|
|
151
|
+
.filter((o) => this.complianceFailure(o, request.need) === null)
|
|
152
|
+
.sort((a, b) => a.price - b.price);
|
|
153
|
+
const baseline = offers.length > 0 ? offers[offers.length - 1]!.price : request.offer.price;
|
|
154
|
+
|
|
155
|
+
this.purchases.push({ needId: request.need.id, vendor: request.offer.vendor, paid: request.offer.price, baseline, txnId: request.intent.id });
|
|
156
|
+
this.committed.push({ intent: request.intent, mandate: this.cfg.mandate });
|
|
157
|
+
this.cfg.audit?.append({ type: 'approval.executed', approvalId, intentId: request.intent.id, approvedBy: approval.approvedBy, approvalSignature: approval.signature, amount: request.offer.price, vendor: request.offer.vendor });
|
|
158
|
+
return { need: request.need, status: 'purchased', offer: request.offer, txnId: request.intent.id };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The signed {intent, mandate} pairs this agent actually committed — the
|
|
162
|
+
* Auditor's input. Surfacing them lets L3 oversight reconcile real output. */
|
|
163
|
+
committedSpends(): CommittedSpend[] {
|
|
164
|
+
return [...this.committed];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
savingsReport(): { lines: PurchaseLine[]; totalSaved: number; totalPaid: number; chainValid: boolean } {
|
|
168
|
+
const audit = this.cfg.audit;
|
|
169
|
+
const chainValid = audit ? audit.verifyChain() : false;
|
|
170
|
+
const provenTxnIds = new Set(
|
|
171
|
+
(audit?.entries() ?? [])
|
|
172
|
+
.filter((e) => e.event.type === 'procurement.purchased' || e.event.type === 'approval.executed')
|
|
173
|
+
.map((e) => (e.event as { txnId?: string; intentId?: string }).txnId ?? (e.event as { intentId?: string }).intentId)
|
|
174
|
+
);
|
|
175
|
+
const lines = this.purchases.filter((p) => chainValid && provenTxnIds.has(p.txnId));
|
|
176
|
+
return {
|
|
177
|
+
lines,
|
|
178
|
+
totalSaved: lines.reduce((s, l) => s + (l.baseline - l.paid), 0),
|
|
179
|
+
totalPaid: lines.reduce((s, l) => s + l.paid, 0),
|
|
180
|
+
chainValid,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|