hedera-curb 0.1.0 → 0.4.1
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 +39 -21
- package/dist/audit.d.ts +7 -2
- package/dist/audit.js +19 -5
- package/dist/build-hooks.d.ts +7 -6
- package/dist/build-hooks.js +9 -8
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +62 -0
- package/dist/create-curb.d.ts +38 -0
- package/dist/create-curb.js +38 -0
- package/dist/deps.d.ts +2 -2
- package/dist/extract.d.ts +6 -3
- package/dist/extract.js +11 -5
- package/dist/hooks/curb-audit-hook.d.ts +1 -1
- package/dist/hooks/curb-audit-hook.js +8 -6
- package/dist/index.d.ts +14 -11
- package/dist/index.js +14 -11
- package/dist/policies/approval-tier.d.ts +1 -1
- package/dist/policies/approval-tier.js +4 -4
- package/dist/policies/counterparty-allowlist.d.ts +1 -1
- package/dist/policies/counterparty-allowlist.js +4 -4
- package/dist/policies/spend-limit.d.ts +6 -2
- package/dist/policies/spend-limit.js +27 -12
- package/dist/policy-registry.d.ts +35 -0
- package/dist/policy-registry.js +96 -0
- package/dist/store.d.ts +19 -3
- package/dist/store.js +33 -5
- package/dist/tools.d.ts +6 -0
- package/dist/tools.js +10 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hedera-curb
|
|
2
2
|
|
|
3
|
-
**Verifiable spend-control for Hedera AI agents.** Drop-in [Hedera Agent Kit](https://github.com/hashgraph/hedera-agent-kit-js) hooks that govern every payment your agent makes — per-task &
|
|
3
|
+
**Verifiable spend-control for Hedera AI agents.** Drop-in [Hedera Agent Kit](https://github.com/hashgraph/hedera-agent-kit-js) hooks that govern every payment your agent makes — per-task & rolling-24h budgets, a counterparty allowlist, and single-use human approval — and write every decision immutably to the Hedera Consensus Service.
|
|
4
4
|
|
|
5
5
|
> ProveAI proves the model; Curb proves the spend.
|
|
6
6
|
|
|
@@ -10,50 +10,68 @@
|
|
|
10
10
|
npm i hedera-curb @hashgraph/hedera-agent-kit @hiero-ledger/sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## Quickstart — two lines
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
|
-
import {
|
|
17
|
-
import { AgentMode } from '@hashgraph/hedera-agent-kit';
|
|
18
|
-
import { coreAccountPlugin } from '@hashgraph/hedera-agent-kit/plugins';
|
|
19
|
-
import { HederaAIToolkit } from '@hashgraph/hedera-agent-kit-ai-sdk';
|
|
16
|
+
import { createCurb } from 'hedera-curb';
|
|
20
17
|
|
|
21
|
-
const
|
|
22
|
-
|
|
18
|
+
const curb = await createCurb({ client, agentAccountId }); // auto-creates audit + HCS-2 policy topics
|
|
19
|
+
new HederaAIToolkit({ client, configuration: { context: { hooks: curb.hooks } } });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`createCurb` defaults the store to in-memory, auto-provisions the audit topic and an HCS-2 policy registry (owner-only writes), publishes the starting policy, and returns `{ hooks, config, store, auditTopicId, policyTopicId }`. Override anything:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
await createCurb({ client, agentAccountId, perDay: 25, store: myRedisStore, auditTopicId, allowlist });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Or provision from the terminal (no code)
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
```bash
|
|
31
|
+
npx hedera-curb init --network testnet
|
|
32
|
+
# ✓ Audit topic: 0.0.x ✓ Policy topic: 0.0.y (HCS-2, owner-only)
|
|
33
|
+
```
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
### Advanced: wire it yourself
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { buildCurbHooks, InMemoryCurbStore } from 'hedera-curb';
|
|
39
|
+
const store = new InMemoryCurbStore();
|
|
40
|
+
store.allowAccount(AGENT_ID, PROVIDER_ID);
|
|
41
|
+
const cfg = { agentAccountId: AGENT_ID, auditTopicId: TOPIC_ID, currency: 'HBAR' as const, perTask: 10, perDay: 25, autoUnder: 3, approveUnder: 10 };
|
|
42
|
+
// context: { hooks: buildCurbHooks({ cfg, store }) }
|
|
32
43
|
```
|
|
33
44
|
|
|
34
|
-
Now every
|
|
45
|
+
Now every governed transfer passes the budget, allowlist, and approval policies **before** it executes, and every decision lands on your HCS audit topic.
|
|
35
46
|
|
|
36
47
|
## Production storage
|
|
37
48
|
|
|
38
|
-
`InMemoryCurbStore` is for tests / single-instance apps. For production, implement the
|
|
49
|
+
`InMemoryCurbStore` is for tests / single-instance apps. For production, implement the `CurbStore` interface against Redis, Postgres, etc. Spend is a rolling-24h window split into **committed** (executed) and short-lived **holds** (atomic reservations) so two concurrent payments can't both slip under the cap:
|
|
39
50
|
|
|
40
51
|
```ts
|
|
41
52
|
interface CurbStore {
|
|
42
53
|
isAllowed(agent, account): Promise<boolean>;
|
|
43
|
-
getDailySpend(agent): Promise<number>;
|
|
54
|
+
getDailySpend(agent): Promise<number>; // committed (executed) spend
|
|
44
55
|
incrDailySpend(agent, amount): Promise<void>;
|
|
56
|
+
tryReserveSpend(agent, key, amount, cap): Promise<boolean>; // atomic hold; idempotent per key
|
|
57
|
+
commitSpend(agent, key, amount): Promise<void>; // hold → committed on execution
|
|
45
58
|
getApproval(requestId): Promise<'pending' | 'approved' | 'rejected' | null>;
|
|
46
59
|
setApproval(requestId, status, meta?): Promise<void>;
|
|
47
60
|
consumeApproval(requestId): Promise<void>;
|
|
48
61
|
}
|
|
49
62
|
```
|
|
50
63
|
|
|
64
|
+
## Beyond budgets
|
|
65
|
+
|
|
66
|
+
The [full project](https://github.com/Madhav-Gupta-28/Curb) is a trust ladder: off-chain hooks (this package) → HCS audit + an HCS-2 **versioned policy registry** → an on-chain **`CurbVault`** contract that enforces caps + allowlist *in Hedera consensus* → non-custodial allowance / scheduled-transaction approvals. Pick how much to trust the server.
|
|
67
|
+
|
|
51
68
|
## Exports
|
|
52
69
|
|
|
53
|
-
- `
|
|
54
|
-
- `CurbAuditHook` — an immutable HCS record per settled payment.
|
|
70
|
+
- `createCurb(opts)` — one-call setup (topics, store, hooks).
|
|
55
71
|
- `buildCurbHooks(deps)` — the ordered policy + audit stack for `context.hooks`.
|
|
56
|
-
- `
|
|
72
|
+
- `SpendLimitPolicy`, `CounterpartyAllowlistPolicy`, `ApprovalTierPolicy`, `CurbAuditHook`.
|
|
73
|
+
- `createAuditTopic`, `createPolicyRegistry`, `publishPolicyVersion`, `readPolicyVersions`, `currentPolicy` — HCS-2 policy versioning.
|
|
74
|
+
- `CurbStore` + `InMemoryCurbStore`, `extractPayment`, `paymentKey`, `writeRecord`, and the `CurbConfig` / `CurbRecord` types.
|
|
57
75
|
|
|
58
76
|
## License
|
|
59
77
|
|
package/dist/audit.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Client } from '@hiero-ledger/sdk';
|
|
2
|
-
import type { CurbConfig } from './config';
|
|
3
|
-
import type { CurbRecord } from './records';
|
|
2
|
+
import type { CurbConfig } from './config.js';
|
|
3
|
+
import type { CurbRecord } from './records.js';
|
|
4
|
+
/**
|
|
5
|
+
* Create an immutable HCS audit topic. No admin key (config can't change) and no submit key
|
|
6
|
+
* (records come from whichever client runs the hooks; junk writes are filtered on read by decode).
|
|
7
|
+
*/
|
|
8
|
+
export declare function createAuditTopic(client: Client, memo?: string): Promise<string>;
|
|
4
9
|
/** Append an immutable record to the Curb audit topic on HCS. */
|
|
5
10
|
export declare function writeRecord(client: Client, cfg: CurbConfig, partial: Omit<CurbRecord, 'v' | 'agent' | 'ts'>): Promise<CurbRecord>;
|
package/dist/audit.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import { TopicMessageSubmitTransaction } from '@hiero-ledger/sdk';
|
|
1
|
+
import { TopicCreateTransaction, TopicMessageSubmitTransaction } from '@hiero-ledger/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Create an immutable HCS audit topic. No admin key (config can't change) and no submit key
|
|
4
|
+
* (records come from whichever client runs the hooks; junk writes are filtered on read by decode).
|
|
5
|
+
*/
|
|
6
|
+
export async function createAuditTopic(client, memo = 'curb-audit') {
|
|
7
|
+
const receipt = await (await new TopicCreateTransaction().setTopicMemo(memo).execute(client)).getReceipt(client);
|
|
8
|
+
return receipt.topicId.toString();
|
|
9
|
+
}
|
|
2
10
|
/** Append an immutable record to the Curb audit topic on HCS. */
|
|
3
11
|
export async function writeRecord(client, cfg, partial) {
|
|
4
12
|
const rec = { v: 1, agent: cfg.agentAccountId, ts: Date.now(), ...partial };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
try {
|
|
14
|
+
await new TopicMessageSubmitTransaction({
|
|
15
|
+
topicId: cfg.auditTopicId,
|
|
16
|
+
message: JSON.stringify(rec),
|
|
17
|
+
}).execute(client);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
// never let a transient HCS hiccup break enforcement — but surface it, don't drop silently.
|
|
21
|
+
console.error(`[curb] audit write failed (${rec.type}/${rec.reason ?? ''}):`, e.message);
|
|
22
|
+
}
|
|
9
23
|
return rec;
|
|
10
24
|
}
|
package/dist/build-hooks.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { RejectToolPolicy } from '@hashgraph/hedera-agent-kit/policies';
|
|
2
2
|
import { HcsAuditTrailHook } from '@hashgraph/hedera-agent-kit/hooks';
|
|
3
|
-
import { SpendLimitPolicy } from './policies/spend-limit';
|
|
4
|
-
import { CounterpartyAllowlistPolicy } from './policies/counterparty-allowlist';
|
|
5
|
-
import { ApprovalTierPolicy } from './policies/approval-tier';
|
|
6
|
-
import { CurbAuditHook } from './hooks/curb-audit-hook';
|
|
7
|
-
import type { CurbDeps } from './deps';
|
|
3
|
+
import { SpendLimitPolicy } from './policies/spend-limit.js';
|
|
4
|
+
import { CounterpartyAllowlistPolicy } from './policies/counterparty-allowlist.js';
|
|
5
|
+
import { ApprovalTierPolicy } from './policies/approval-tier.js';
|
|
6
|
+
import { CurbAuditHook } from './hooks/curb-audit-hook.js';
|
|
7
|
+
import type { CurbDeps } from './deps.js';
|
|
8
8
|
/**
|
|
9
9
|
* The ordered Curb policy + audit stack, ready to drop into `configuration.context.hooks`.
|
|
10
|
-
* Hard-disables
|
|
10
|
+
* Hard-disables account-mutating / allowance-granting tools, then governs every transfer
|
|
11
|
+
* (direct and allowance-based) through the budget, allowlist, and approval policies.
|
|
11
12
|
*/
|
|
12
13
|
export declare function buildCurbHooks(d: CurbDeps): (SpendLimitPolicy | CounterpartyAllowlistPolicy | ApprovalTierPolicy | CurbAuditHook | RejectToolPolicy | HcsAuditTrailHook)[];
|
package/dist/build-hooks.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { RejectToolPolicy } from '@hashgraph/hedera-agent-kit/policies';
|
|
2
2
|
import { HcsAuditTrailHook } from '@hashgraph/hedera-agent-kit/hooks';
|
|
3
3
|
import { coreAccountPluginToolNames } from '@hashgraph/hedera-agent-kit/plugins';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { GOVERNED_TRANSFER_TOOLS } from './tools.js';
|
|
5
|
+
import { SpendLimitPolicy } from './policies/spend-limit.js';
|
|
6
|
+
import { CounterpartyAllowlistPolicy } from './policies/counterparty-allowlist.js';
|
|
7
|
+
import { ApprovalTierPolicy } from './policies/approval-tier.js';
|
|
8
|
+
import { CurbAuditHook } from './hooks/curb-audit-hook.js';
|
|
8
9
|
/**
|
|
9
10
|
* The ordered Curb policy + audit stack, ready to drop into `configuration.context.hooks`.
|
|
10
|
-
* Hard-disables
|
|
11
|
+
* Hard-disables account-mutating / allowance-granting tools, then governs every transfer
|
|
12
|
+
* (direct and allowance-based) through the budget, allowlist, and approval policies.
|
|
11
13
|
*/
|
|
12
14
|
export function buildCurbHooks(d) {
|
|
13
15
|
return [
|
|
@@ -15,14 +17,13 @@ export function buildCurbHooks(d) {
|
|
|
15
17
|
coreAccountPluginToolNames.CREATE_ACCOUNT_TOOL,
|
|
16
18
|
coreAccountPluginToolNames.DELETE_ACCOUNT_TOOL,
|
|
17
19
|
coreAccountPluginToolNames.UPDATE_ACCOUNT_TOOL,
|
|
18
|
-
coreAccountPluginToolNames.APPROVE_HBAR_ALLOWANCE_TOOL,
|
|
20
|
+
coreAccountPluginToolNames.APPROVE_HBAR_ALLOWANCE_TOOL, // only the user grants allowances, never the agent
|
|
19
21
|
coreAccountPluginToolNames.DELETE_HBAR_ALLOWANCE_TOOL,
|
|
20
|
-
coreAccountPluginToolNames.TRANSFER_HBAR_WITH_ALLOWANCE_TOOL,
|
|
21
22
|
]),
|
|
22
23
|
new CounterpartyAllowlistPolicy(d),
|
|
23
24
|
new SpendLimitPolicy(d),
|
|
24
25
|
new ApprovalTierPolicy(d),
|
|
25
26
|
new CurbAuditHook(d),
|
|
26
|
-
new HcsAuditTrailHook(
|
|
27
|
+
new HcsAuditTrailHook(GOVERNED_TRANSFER_TOOLS, d.cfg.auditTopicId),
|
|
27
28
|
];
|
|
28
29
|
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Provisions Curb's on-chain topics without touching the Agent Kit, so it runs anywhere the SDK is present.
|
|
3
|
+
import { Client, PrivateKey } from '@hiero-ledger/sdk';
|
|
4
|
+
// explicit .js extensions so the compiled CLI runs under pure Node ESM (the bin entrypoint)
|
|
5
|
+
import { createAuditTopic } from './audit.js';
|
|
6
|
+
import { createPolicyRegistry, publishPolicyVersion } from './policy-registry.js';
|
|
7
|
+
import { DEFAULT_CONFIG } from './config.js';
|
|
8
|
+
const flag = (name) => {
|
|
9
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
10
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
11
|
+
};
|
|
12
|
+
function parseKey(s) {
|
|
13
|
+
for (const parse of [PrivateKey.fromStringECDSA, PrivateKey.fromStringED25519, PrivateKey.fromStringDer]) {
|
|
14
|
+
try {
|
|
15
|
+
return parse(s);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* try next encoding */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw new Error('could not parse the private key (expected ECDSA / ED25519 / DER hex)');
|
|
22
|
+
}
|
|
23
|
+
async function init() {
|
|
24
|
+
const network = (flag('network') ?? process.env.HEDERA_NETWORK ?? 'testnet').toLowerCase();
|
|
25
|
+
const account = flag('account') ?? process.env.HEDERA_OPERATOR_ID ?? process.env.ACCOUNT_ID;
|
|
26
|
+
const key = flag('key') ?? process.env.HEDERA_OPERATOR_KEY ?? process.env.PRIVATE_KEY;
|
|
27
|
+
const agent = flag('agent') ?? process.env.AGENT_ACCOUNT_ID ?? account;
|
|
28
|
+
if (!account || !key) {
|
|
29
|
+
console.error('Missing credentials. Pass --account and --key, or set HEDERA_OPERATOR_ID / HEDERA_OPERATOR_KEY.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
console.log(`Provisioning Curb on ${network}…`);
|
|
33
|
+
const client = Client.forName(network).setOperator(account, parseKey(key));
|
|
34
|
+
const auditTopicId = await createAuditTopic(client);
|
|
35
|
+
const policyTopicId = await createPolicyRegistry(client);
|
|
36
|
+
const config = {
|
|
37
|
+
agentAccountId: agent,
|
|
38
|
+
auditTopicId,
|
|
39
|
+
...DEFAULT_CONFIG,
|
|
40
|
+
perTask: Number(flag('per-task') ?? DEFAULT_CONFIG.perTask),
|
|
41
|
+
perDay: Number(flag('per-day') ?? DEFAULT_CONFIG.perDay),
|
|
42
|
+
};
|
|
43
|
+
await publishPolicyVersion(client, config, policyTopicId, 'initial policy').catch(() => { });
|
|
44
|
+
console.log(`\n✓ Audit topic: ${auditTopicId}`);
|
|
45
|
+
console.log(`✓ Policy topic: ${policyTopicId} (HCS-2, owner-only writes)`);
|
|
46
|
+
console.log('\nAdd these to your .env:\n');
|
|
47
|
+
console.log(`CURB_AUDIT_TOPIC_ID=${auditTopicId}`);
|
|
48
|
+
console.log(`CURB_POLICY_TOPIC_ID=${policyTopicId}`);
|
|
49
|
+
console.log('\nDone — drop `createCurb({ client, agentAccountId }).hooks` into the Agent Kit and you are governed.');
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
const cmd = process.argv[2];
|
|
53
|
+
if (cmd === 'init') {
|
|
54
|
+
init().catch((e) => {
|
|
55
|
+
console.error(String(e?.message ?? e));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log('Usage: hedera-curb init [--network testnet] [--account 0.0.x] [--key <priv>] [--agent 0.0.y] [--per-task 10] [--per-day 25]');
|
|
61
|
+
process.exit(cmd ? 1 : 0);
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Client } from '@hiero-ledger/sdk';
|
|
2
|
+
import { buildCurbHooks } from './build-hooks.js';
|
|
3
|
+
import { type CurbStore } from './store.js';
|
|
4
|
+
import { type CurbConfig, type Currency } from './config.js';
|
|
5
|
+
export interface CreateCurbOptions {
|
|
6
|
+
/** Operator client — used to create topics and write the audit trail. */
|
|
7
|
+
client: Client;
|
|
8
|
+
/** The agent's Hedera account id (the governed / bounded-allowance account). */
|
|
9
|
+
agentAccountId: string;
|
|
10
|
+
/** Bring your own store (Redis, Postgres, …). Defaults to an in-memory store. */
|
|
11
|
+
store?: CurbStore;
|
|
12
|
+
/** Reuse an existing audit topic; omit to auto-create one. */
|
|
13
|
+
auditTopicId?: string;
|
|
14
|
+
/** Reuse an existing HCS-2 policy registry; omit to auto-create one; pass `false` to skip versioning. */
|
|
15
|
+
policyTopicId?: string | false;
|
|
16
|
+
currency?: Currency;
|
|
17
|
+
perTask?: number;
|
|
18
|
+
perDay?: number;
|
|
19
|
+
autoUnder?: number;
|
|
20
|
+
approveUnder?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface Curb {
|
|
23
|
+
/** Drop straight into the Agent Kit's `configuration.context.hooks`. */
|
|
24
|
+
hooks: ReturnType<typeof buildCurbHooks>;
|
|
25
|
+
config: CurbConfig;
|
|
26
|
+
store: CurbStore;
|
|
27
|
+
auditTopicId: string;
|
|
28
|
+
policyTopicId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One-call setup for Curb. Auto-creates the audit topic (and an HCS-2 policy registry), defaults the
|
|
32
|
+
* store to in-memory, publishes the initial policy version, and returns ready-to-use Agent Kit hooks.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const curb = await createCurb({ client, agentAccountId, perDay: 25 });
|
|
36
|
+
* new HederaAIToolkit({ client, configuration: { context: { hooks: curb.hooks } } });
|
|
37
|
+
*/
|
|
38
|
+
export declare function createCurb(opts: CreateCurbOptions): Promise<Curb>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { buildCurbHooks } from './build-hooks.js';
|
|
2
|
+
import { InMemoryCurbStore } from './store.js';
|
|
3
|
+
import { createAuditTopic } from './audit.js';
|
|
4
|
+
import { createPolicyRegistry, publishPolicyVersion } from './policy-registry.js';
|
|
5
|
+
import { DEFAULT_CONFIG } from './config.js';
|
|
6
|
+
/**
|
|
7
|
+
* One-call setup for Curb. Auto-creates the audit topic (and an HCS-2 policy registry), defaults the
|
|
8
|
+
* store to in-memory, publishes the initial policy version, and returns ready-to-use Agent Kit hooks.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const curb = await createCurb({ client, agentAccountId, perDay: 25 });
|
|
12
|
+
* new HederaAIToolkit({ client, configuration: { context: { hooks: curb.hooks } } });
|
|
13
|
+
*/
|
|
14
|
+
export async function createCurb(opts) {
|
|
15
|
+
const store = opts.store ?? new InMemoryCurbStore();
|
|
16
|
+
const auditTopicId = opts.auditTopicId ?? (await createAuditTopic(opts.client));
|
|
17
|
+
const autoPolicy = opts.policyTopicId === undefined;
|
|
18
|
+
const policyTopicId = opts.policyTopicId === false ? undefined : (opts.policyTopicId ?? (await createPolicyRegistry(opts.client)));
|
|
19
|
+
const config = {
|
|
20
|
+
agentAccountId: opts.agentAccountId,
|
|
21
|
+
auditTopicId,
|
|
22
|
+
currency: opts.currency ?? DEFAULT_CONFIG.currency,
|
|
23
|
+
perTask: opts.perTask ?? DEFAULT_CONFIG.perTask,
|
|
24
|
+
perDay: opts.perDay ?? DEFAULT_CONFIG.perDay,
|
|
25
|
+
autoUnder: opts.autoUnder ?? DEFAULT_CONFIG.autoUnder,
|
|
26
|
+
approveUnder: opts.approveUnder ?? DEFAULT_CONFIG.approveUnder,
|
|
27
|
+
};
|
|
28
|
+
// seed the registry with the live caps so the on-chain policy is authoritative from day one
|
|
29
|
+
if (policyTopicId && autoPolicy) {
|
|
30
|
+
try {
|
|
31
|
+
await publishPolicyVersion(opts.client, config, policyTopicId, 'initial policy');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* non-fatal: the registry exists; versions can be published later */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { hooks: buildCurbHooks({ cfg: config, store }), config, store, auditTopicId, policyTopicId };
|
|
38
|
+
}
|
package/dist/deps.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CurbConfig } from './config';
|
|
2
|
-
import type { CurbStore } from './store';
|
|
1
|
+
import type { CurbConfig } from './config.js';
|
|
2
|
+
import type { CurbStore } from './store.js';
|
|
3
3
|
/** Everything every Curb policy and hook needs at construction time. */
|
|
4
4
|
export interface CurbDeps {
|
|
5
5
|
cfg: CurbConfig;
|
package/dist/extract.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
/** Deterministic key for a payment (agent + recipient + amount) — used for spend holds and approvals. */
|
|
2
|
+
export declare function paymentKey(agent: string, recipient: string, amount: number): string;
|
|
1
3
|
export interface PaymentIntent {
|
|
2
4
|
amount: number;
|
|
3
5
|
currency: 'HBAR' | 'USDC';
|
|
4
6
|
recipients: string[];
|
|
5
7
|
}
|
|
6
8
|
/**
|
|
7
|
-
* Pull the payment amount + recipients out of a
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Pull the payment amount + recipients out of a governed transfer's normalised params.
|
|
10
|
+
* Both `transfer_hbar` and `transfer_hbar_with_allowance` normalise recipients to
|
|
11
|
+
* `{ hbarTransfers: [{ accountId, amount }] }` (the sender/owner is a separate negative entry).
|
|
12
|
+
* We keep only the positive credits, which equal the amount actually spent.
|
|
10
13
|
*/
|
|
11
14
|
export declare function extractPayment(method: string, normalised: any): PaymentIntent | null;
|
package/dist/extract.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import { Hbar } from '@hiero-ledger/sdk';
|
|
2
|
-
import {
|
|
3
|
+
import { GOVERNED_TRANSFER_TOOLS } from './tools.js';
|
|
4
|
+
/** Deterministic key for a payment (agent + recipient + amount) — used for spend holds and approvals. */
|
|
5
|
+
export function paymentKey(agent, recipient, amount) {
|
|
6
|
+
return crypto.createHash('sha256').update(`${agent}:${recipient}:${amount}`).digest('hex').slice(0, 16);
|
|
7
|
+
}
|
|
3
8
|
function hbarToNumber(a) {
|
|
4
9
|
if (a instanceof Hbar)
|
|
5
10
|
return a.toBigNumber().toNumber();
|
|
@@ -10,12 +15,13 @@ function hbarToNumber(a) {
|
|
|
10
15
|
return Number(a?.toString?.() ?? 0);
|
|
11
16
|
}
|
|
12
17
|
/**
|
|
13
|
-
* Pull the payment amount + recipients out of a
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Pull the payment amount + recipients out of a governed transfer's normalised params.
|
|
19
|
+
* Both `transfer_hbar` and `transfer_hbar_with_allowance` normalise recipients to
|
|
20
|
+
* `{ hbarTransfers: [{ accountId, amount }] }` (the sender/owner is a separate negative entry).
|
|
21
|
+
* We keep only the positive credits, which equal the amount actually spent.
|
|
16
22
|
*/
|
|
17
23
|
export function extractPayment(method, normalised) {
|
|
18
|
-
if (method
|
|
24
|
+
if (GOVERNED_TRANSFER_TOOLS.includes(method)) {
|
|
19
25
|
const transfers = normalised?.hbarTransfers ?? [];
|
|
20
26
|
const credits = transfers.filter((t) => hbarToNumber(t.amount) > 0);
|
|
21
27
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AbstractHook, type PostSecondaryActionParams } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import type { CurbDeps } from '../deps';
|
|
2
|
+
import type { CurbDeps } from '../deps.js';
|
|
3
3
|
/** Records every settled payment on HCS and advances the durable daily-spend counter. */
|
|
4
4
|
export declare class CurbAuditHook extends AbstractHook {
|
|
5
5
|
private d;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AbstractHook } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import {
|
|
3
|
-
import { extractPayment } from '../extract';
|
|
4
|
-
import { writeRecord } from '../audit';
|
|
2
|
+
import { GOVERNED_TRANSFER_TOOLS } from '../tools.js';
|
|
3
|
+
import { extractPayment, paymentKey } from '../extract.js';
|
|
4
|
+
import { writeRecord } from '../audit.js';
|
|
5
5
|
// `toolResult` is typed `any` in the kit; these are the observed shapes for tx id.
|
|
6
6
|
function extractTxId(toolResult) {
|
|
7
7
|
return (toolResult?.raw?.transactionId ??
|
|
@@ -15,7 +15,7 @@ export class CurbAuditHook extends AbstractHook {
|
|
|
15
15
|
d;
|
|
16
16
|
name = 'curb.audit';
|
|
17
17
|
description = 'Records every settled payment on HCS';
|
|
18
|
-
relevantTools =
|
|
18
|
+
relevantTools = GOVERNED_TRANSFER_TOOLS;
|
|
19
19
|
constructor(d) {
|
|
20
20
|
super();
|
|
21
21
|
this.d = d;
|
|
@@ -34,7 +34,9 @@ export class CurbAuditHook extends AbstractHook {
|
|
|
34
34
|
allowed: true,
|
|
35
35
|
reason: 'settled',
|
|
36
36
|
});
|
|
37
|
-
if (intent?.amount)
|
|
38
|
-
|
|
37
|
+
if (intent?.amount) {
|
|
38
|
+
const key = paymentKey(this.d.cfg.agentAccountId, intent.recipients[0], intent.amount);
|
|
39
|
+
await this.d.store.commitSpend(this.d.cfg.agentAccountId, key, intent.amount);
|
|
40
|
+
}
|
|
39
41
|
}
|
|
40
42
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
export * from './config';
|
|
2
|
-
export * from './records';
|
|
3
|
-
export * from './store';
|
|
4
|
-
export * from './deps';
|
|
5
|
-
export * from './
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
8
|
-
export * from './
|
|
9
|
-
export * from './policies/
|
|
10
|
-
export * from './
|
|
11
|
-
export * from './
|
|
1
|
+
export * from './config.js';
|
|
2
|
+
export * from './records.js';
|
|
3
|
+
export * from './store.js';
|
|
4
|
+
export * from './deps.js';
|
|
5
|
+
export * from './tools.js';
|
|
6
|
+
export * from './extract.js';
|
|
7
|
+
export * from './audit.js';
|
|
8
|
+
export * from './policy-registry.js';
|
|
9
|
+
export * from './policies/spend-limit.js';
|
|
10
|
+
export * from './policies/counterparty-allowlist.js';
|
|
11
|
+
export * from './policies/approval-tier.js';
|
|
12
|
+
export * from './hooks/curb-audit-hook.js';
|
|
13
|
+
export * from './build-hooks.js';
|
|
14
|
+
export * from './create-curb.js';
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
export * from './config';
|
|
2
|
-
export * from './records';
|
|
3
|
-
export * from './store';
|
|
4
|
-
export * from './deps';
|
|
5
|
-
export * from './
|
|
6
|
-
export * from './
|
|
7
|
-
export * from './
|
|
8
|
-
export * from './
|
|
9
|
-
export * from './policies/
|
|
10
|
-
export * from './
|
|
11
|
-
export * from './
|
|
1
|
+
export * from './config.js';
|
|
2
|
+
export * from './records.js';
|
|
3
|
+
export * from './store.js';
|
|
4
|
+
export * from './deps.js';
|
|
5
|
+
export * from './tools.js';
|
|
6
|
+
export * from './extract.js';
|
|
7
|
+
export * from './audit.js';
|
|
8
|
+
export * from './policy-registry.js';
|
|
9
|
+
export * from './policies/spend-limit.js';
|
|
10
|
+
export * from './policies/counterparty-allowlist.js';
|
|
11
|
+
export * from './policies/approval-tier.js';
|
|
12
|
+
export * from './hooks/curb-audit-hook.js';
|
|
13
|
+
export * from './build-hooks.js';
|
|
14
|
+
export * from './create-curb.js';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { AbstractPolicy } from '@hashgraph/hedera-agent-kit';
|
|
3
|
-
import {
|
|
4
|
-
import { extractPayment } from '../extract';
|
|
5
|
-
import { writeRecord } from '../audit';
|
|
3
|
+
import { GOVERNED_TRANSFER_TOOLS } from '../tools.js';
|
|
4
|
+
import { extractPayment } from '../extract.js';
|
|
5
|
+
import { writeRecord } from '../audit.js';
|
|
6
6
|
/**
|
|
7
7
|
* Tiered human-in-the-loop:
|
|
8
8
|
* - below `autoUnder` → auto-approve
|
|
@@ -13,7 +13,7 @@ export class ApprovalTierPolicy extends AbstractPolicy {
|
|
|
13
13
|
d;
|
|
14
14
|
name = 'curb.approval-tier';
|
|
15
15
|
description = 'Requires single-use human approval for larger payments';
|
|
16
|
-
relevantTools =
|
|
16
|
+
relevantTools = GOVERNED_TRANSFER_TOOLS;
|
|
17
17
|
constructor(d) {
|
|
18
18
|
super();
|
|
19
19
|
this.d = d;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AbstractPolicy, type PostParamsNormalizationParams } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import type { CurbDeps } from '../deps';
|
|
2
|
+
import type { CurbDeps } from '../deps.js';
|
|
3
3
|
/** Blocks payments to any account not on the allowlist. */
|
|
4
4
|
export declare class CounterpartyAllowlistPolicy extends AbstractPolicy {
|
|
5
5
|
private d;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { AbstractPolicy } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import {
|
|
3
|
-
import { extractPayment } from '../extract';
|
|
4
|
-
import { writeRecord } from '../audit';
|
|
2
|
+
import { GOVERNED_TRANSFER_TOOLS } from '../tools.js';
|
|
3
|
+
import { extractPayment } from '../extract.js';
|
|
4
|
+
import { writeRecord } from '../audit.js';
|
|
5
5
|
/** Blocks payments to any account not on the allowlist. */
|
|
6
6
|
export class CounterpartyAllowlistPolicy extends AbstractPolicy {
|
|
7
7
|
d;
|
|
8
8
|
name = 'curb.allowlist';
|
|
9
9
|
description = 'Blocks payments to accounts not on the allowlist';
|
|
10
|
-
relevantTools =
|
|
10
|
+
relevantTools = GOVERNED_TRANSFER_TOOLS;
|
|
11
11
|
constructor(d) {
|
|
12
12
|
super();
|
|
13
13
|
this.d = d;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { AbstractPolicy, type PostParamsNormalizationParams } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import type { CurbDeps } from '../deps';
|
|
3
|
-
/**
|
|
2
|
+
import type { CurbDeps } from '../deps.js';
|
|
3
|
+
/**
|
|
4
|
+
* Blocks a payment that would breach the per-task or rolling daily budget.
|
|
5
|
+
* The daily check is an **atomic reserve** (a short-lived hold) so two concurrent payments can't both
|
|
6
|
+
* slip under the cap; the hold is committed on execution by {@link CurbAuditHook}.
|
|
7
|
+
*/
|
|
4
8
|
export declare class SpendLimitPolicy extends AbstractPolicy {
|
|
5
9
|
private d;
|
|
6
10
|
name: string;
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { AbstractPolicy } from '@hashgraph/hedera-agent-kit';
|
|
2
|
-
import {
|
|
3
|
-
import { extractPayment } from '../extract';
|
|
4
|
-
import { writeRecord } from '../audit';
|
|
5
|
-
/**
|
|
2
|
+
import { GOVERNED_TRANSFER_TOOLS } from '../tools.js';
|
|
3
|
+
import { extractPayment, paymentKey } from '../extract.js';
|
|
4
|
+
import { writeRecord } from '../audit.js';
|
|
5
|
+
/**
|
|
6
|
+
* Blocks a payment that would breach the per-task or rolling daily budget.
|
|
7
|
+
* The daily check is an **atomic reserve** (a short-lived hold) so two concurrent payments can't both
|
|
8
|
+
* slip under the cap; the hold is committed on execution by {@link CurbAuditHook}.
|
|
9
|
+
*/
|
|
6
10
|
export class SpendLimitPolicy extends AbstractPolicy {
|
|
7
11
|
d;
|
|
8
12
|
name = 'curb.spend-limit';
|
|
9
13
|
description = 'Blocks a payment that would exceed the per-task or rolling daily budget';
|
|
10
|
-
relevantTools =
|
|
14
|
+
relevantTools = GOVERNED_TRANSFER_TOOLS;
|
|
11
15
|
constructor(d) {
|
|
12
16
|
super();
|
|
13
17
|
this.d = d;
|
|
@@ -17,10 +21,21 @@ export class SpendLimitPolicy extends AbstractPolicy {
|
|
|
17
21
|
if (!intent)
|
|
18
22
|
return false;
|
|
19
23
|
const { cfg, store } = this.d;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
if (intent.amount > cfg.perTask) {
|
|
25
|
+
await writeRecord(params.client, cfg, {
|
|
26
|
+
type: 'decision',
|
|
27
|
+
policy: this.name,
|
|
28
|
+
method,
|
|
29
|
+
amount: intent.amount,
|
|
30
|
+
currency: intent.currency,
|
|
31
|
+
counterparty: intent.recipients[0],
|
|
32
|
+
allowed: false,
|
|
33
|
+
reason: 'per_task_exceeded',
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
const key = paymentKey(cfg.agentAccountId, intent.recipients[0], intent.amount);
|
|
38
|
+
const reserved = await store.tryReserveSpend(cfg.agentAccountId, key, intent.amount, cfg.perDay);
|
|
24
39
|
await writeRecord(params.client, cfg, {
|
|
25
40
|
type: 'decision',
|
|
26
41
|
policy: this.name,
|
|
@@ -28,9 +43,9 @@ export class SpendLimitPolicy extends AbstractPolicy {
|
|
|
28
43
|
amount: intent.amount,
|
|
29
44
|
currency: intent.currency,
|
|
30
45
|
counterparty: intent.recipients[0],
|
|
31
|
-
allowed:
|
|
32
|
-
reason:
|
|
46
|
+
allowed: reserved,
|
|
47
|
+
reason: reserved ? 'within_budget' : 'per_day_exceeded',
|
|
33
48
|
});
|
|
34
|
-
return
|
|
49
|
+
return !reserved;
|
|
35
50
|
}
|
|
36
51
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Client } from '@hiero-ledger/sdk';
|
|
2
|
+
import type { CurbConfig } from './config.js';
|
|
3
|
+
/** The policy fields that are versioned on-chain. */
|
|
4
|
+
export interface PolicySnapshot {
|
|
5
|
+
currency: string;
|
|
6
|
+
perTask: number;
|
|
7
|
+
perDay: number;
|
|
8
|
+
autoUnder: number;
|
|
9
|
+
approveUnder: number;
|
|
10
|
+
}
|
|
11
|
+
export interface PolicyVersion extends PolicySnapshot {
|
|
12
|
+
/** 1-based version number, in consensus order. */
|
|
13
|
+
version: number;
|
|
14
|
+
reason: string;
|
|
15
|
+
/** Publisher wall-clock (ms) at publish time. */
|
|
16
|
+
ts: number;
|
|
17
|
+
sequenceNumber: number;
|
|
18
|
+
consensusTimestamp: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create an HCS-2 indexed registry topic for Curb policy versions. The submit key is set to the
|
|
22
|
+
* operator's key so ONLY the owner can publish policy versions — otherwise anyone could submit a
|
|
23
|
+
* fake policy (e.g. huge caps) that `resolveConfig` would adopt. Returns the topic id.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createPolicyRegistry(client: Client, ttl?: number): Promise<string>;
|
|
26
|
+
/** Publish the current config as a new immutable policy version on the registry. */
|
|
27
|
+
export declare function publishPolicyVersion(client: Client, cfg: CurbConfig, registryTopicId: string, reason: string): Promise<PolicySnapshot>;
|
|
28
|
+
/**
|
|
29
|
+
* Read the full policy version history from the HCS-2 registry via the mirror node (consensus order).
|
|
30
|
+
* Pass `trustedPayer` (the owner's account id) to only trust versions the owner published — a
|
|
31
|
+
* defense-in-depth against policy injection even on a topic without a submit key.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readPolicyVersions(registryTopicId: string, mirrorBaseUrl: string, trustedPayer?: string): Promise<PolicyVersion[]>;
|
|
34
|
+
/** The current (latest) policy version on the registry, or null if empty. */
|
|
35
|
+
export declare function currentPolicy(registryTopicId: string, mirrorBaseUrl: string, trustedPayer?: string): Promise<PolicyVersion | null>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { TopicCreateTransaction, TopicMessageSubmitTransaction } from '@hiero-ledger/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* HCS-2 policy versioning for Curb.
|
|
4
|
+
*
|
|
5
|
+
* Curb's spending policy (caps + tiers) is published to an HCS-2 *indexed* registry topic, so every
|
|
6
|
+
* change is an immutable, consensus-timestamped version. Anyone can replay the topic to audit exactly
|
|
7
|
+
* what the limits were at any point and why they changed — the policy itself becomes verifiable, not
|
|
8
|
+
* just the payments it governs.
|
|
9
|
+
*
|
|
10
|
+
* Per HCS-2: topic memo `hcs-2:{registryType}:{ttl}` (0 = indexed, keep all records); each version is a
|
|
11
|
+
* `register` op whose `metadata` carries the policy inline as a data URI and `t_id` references the
|
|
12
|
+
* audit topic the policy governs.
|
|
13
|
+
*/
|
|
14
|
+
const HCS2 = 'hcs-2';
|
|
15
|
+
const snapshot = (cfg) => ({
|
|
16
|
+
currency: cfg.currency,
|
|
17
|
+
perTask: cfg.perTask,
|
|
18
|
+
perDay: cfg.perDay,
|
|
19
|
+
autoUnder: cfg.autoUnder,
|
|
20
|
+
approveUnder: cfg.approveUnder,
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* Create an HCS-2 indexed registry topic for Curb policy versions. The submit key is set to the
|
|
24
|
+
* operator's key so ONLY the owner can publish policy versions — otherwise anyone could submit a
|
|
25
|
+
* fake policy (e.g. huge caps) that `resolveConfig` would adopt. Returns the topic id.
|
|
26
|
+
*/
|
|
27
|
+
export async function createPolicyRegistry(client, ttl = 86400) {
|
|
28
|
+
const tx = new TopicCreateTransaction().setTopicMemo(`${HCS2}:0:${ttl}`);
|
|
29
|
+
const submitKey = client.operatorPublicKey;
|
|
30
|
+
if (submitKey)
|
|
31
|
+
tx.setSubmitKey(submitKey);
|
|
32
|
+
const receipt = await (await tx.execute(client)).getReceipt(client);
|
|
33
|
+
return receipt.topicId.toString();
|
|
34
|
+
}
|
|
35
|
+
/** Publish the current config as a new immutable policy version on the registry. */
|
|
36
|
+
export async function publishPolicyVersion(client, cfg, registryTopicId, reason) {
|
|
37
|
+
const snap = snapshot(cfg);
|
|
38
|
+
const payload = { ...snap, reason, ts: Date.now() };
|
|
39
|
+
const metadata = `data:application/json;base64,${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
|
|
40
|
+
const message = { p: HCS2, op: 'register', t_id: cfg.auditTopicId, metadata, m: reason };
|
|
41
|
+
await new TopicMessageSubmitTransaction({
|
|
42
|
+
topicId: registryTopicId,
|
|
43
|
+
message: JSON.stringify(message),
|
|
44
|
+
}).execute(client);
|
|
45
|
+
return snap;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read the full policy version history from the HCS-2 registry via the mirror node (consensus order).
|
|
49
|
+
* Pass `trustedPayer` (the owner's account id) to only trust versions the owner published — a
|
|
50
|
+
* defense-in-depth against policy injection even on a topic without a submit key.
|
|
51
|
+
*/
|
|
52
|
+
export async function readPolicyVersions(registryTopicId, mirrorBaseUrl, trustedPayer) {
|
|
53
|
+
const versions = [];
|
|
54
|
+
let url = `${mirrorBaseUrl}/topics/${registryTopicId}/messages?order=asc&limit=100`;
|
|
55
|
+
let n = 0;
|
|
56
|
+
while (url) {
|
|
57
|
+
const res = await fetch(url);
|
|
58
|
+
if (!res.ok)
|
|
59
|
+
break;
|
|
60
|
+
const json = await res.json();
|
|
61
|
+
for (const m of (json.messages ?? [])) {
|
|
62
|
+
if (trustedPayer && m.payer_account_id !== trustedPayer)
|
|
63
|
+
continue; // ignore non-owner versions
|
|
64
|
+
try {
|
|
65
|
+
const msg = JSON.parse(Buffer.from(m.message, 'base64').toString('utf8'));
|
|
66
|
+
if (msg.p !== HCS2 || msg.op !== 'register' || typeof msg.metadata !== 'string')
|
|
67
|
+
continue;
|
|
68
|
+
const b64 = msg.metadata.startsWith('data:') ? msg.metadata.split(',')[1] : msg.metadata;
|
|
69
|
+
const p = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
70
|
+
versions.push({
|
|
71
|
+
version: ++n,
|
|
72
|
+
currency: p.currency,
|
|
73
|
+
perTask: p.perTask,
|
|
74
|
+
perDay: p.perDay,
|
|
75
|
+
autoUnder: p.autoUnder,
|
|
76
|
+
approveUnder: p.approveUnder,
|
|
77
|
+
reason: p.reason ?? msg.m ?? '',
|
|
78
|
+
ts: p.ts ?? 0,
|
|
79
|
+
sequenceNumber: m.sequence_number,
|
|
80
|
+
consensusTimestamp: m.consensus_timestamp,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* skip malformed entries */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const next = json.links?.next;
|
|
88
|
+
url = next ? new URL(next, mirrorBaseUrl).toString() : null;
|
|
89
|
+
}
|
|
90
|
+
return versions;
|
|
91
|
+
}
|
|
92
|
+
/** The current (latest) policy version on the registry, or null if empty. */
|
|
93
|
+
export async function currentPolicy(registryTopicId, mirrorBaseUrl, trustedPayer) {
|
|
94
|
+
const all = await readPolicyVersions(registryTopicId, mirrorBaseUrl, trustedPayer);
|
|
95
|
+
return all.length ? all[all.length - 1] : null;
|
|
96
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -7,12 +7,24 @@ export interface ApprovalMeta {
|
|
|
7
7
|
/**
|
|
8
8
|
* The operational state Curb needs at runtime. Bring your own implementation
|
|
9
9
|
* (Redis, Postgres, …) or use {@link InMemoryCurbStore} for tests / single-process apps.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* Spend is a rolling 24h window (matching the on-chain CurbVault) split into two parts:
|
|
12
|
+
* - **committed** — actually-executed payments; this is what {@link getDailySpend} returns and what
|
|
13
|
+
* `/verify` reconciles against the HCS audit log.
|
|
14
|
+
* - **holds** — short-lived in-flight reservations placed atomically by the policy so two concurrent
|
|
15
|
+
* payments can't both pass the cap check (the TOCTOU race). A hold becomes committed on execution,
|
|
16
|
+
* or expires on its own if the payment never lands.
|
|
11
17
|
*/
|
|
12
18
|
export interface CurbStore {
|
|
13
19
|
isAllowed(agent: string, account: string): Promise<boolean>;
|
|
20
|
+
/** Committed (executed) spend in the rolling window. */
|
|
14
21
|
getDailySpend(agent: string): Promise<number>;
|
|
22
|
+
/** Add to committed spend directly (e.g. seeding/migration). */
|
|
15
23
|
incrDailySpend(agent: string, amount: number): Promise<void>;
|
|
24
|
+
/** Atomically reserve a hold if committed + holds + amount ≤ cap. Idempotent per `key`. */
|
|
25
|
+
tryReserveSpend(agent: string, key: string, amount: number, cap: number): Promise<boolean>;
|
|
26
|
+
/** Convert a hold into committed spend once the payment executes. */
|
|
27
|
+
commitSpend(agent: string, key: string, amount: number): Promise<void>;
|
|
16
28
|
getApproval(requestId: string): Promise<ApprovalStatus | null>;
|
|
17
29
|
setApproval(requestId: string, status: ApprovalStatus, meta?: ApprovalMeta): Promise<void>;
|
|
18
30
|
consumeApproval(requestId: string): Promise<void>;
|
|
@@ -20,14 +32,18 @@ export interface CurbStore {
|
|
|
20
32
|
/** Zero-dependency in-memory store — great for tests and single-instance deployments. */
|
|
21
33
|
export declare class InMemoryCurbStore implements CurbStore {
|
|
22
34
|
private allow;
|
|
23
|
-
private
|
|
35
|
+
private committed;
|
|
36
|
+
private holds;
|
|
24
37
|
private approvals;
|
|
25
|
-
private day;
|
|
26
38
|
/** Seed an allowed counterparty. */
|
|
27
39
|
allowAccount(agent: string, account: string): void;
|
|
40
|
+
private pruneCommitted;
|
|
41
|
+
private pruneHolds;
|
|
28
42
|
isAllowed(agent: string, account: string): Promise<boolean>;
|
|
29
43
|
getDailySpend(agent: string): Promise<number>;
|
|
30
44
|
incrDailySpend(agent: string, amount: number): Promise<void>;
|
|
45
|
+
tryReserveSpend(agent: string, key: string, amount: number, cap: number): Promise<boolean>;
|
|
46
|
+
commitSpend(agent: string, key: string, amount: number): Promise<void>;
|
|
31
47
|
getApproval(requestId: string): Promise<ApprovalStatus | null>;
|
|
32
48
|
setApproval(requestId: string, status: ApprovalStatus): Promise<void>;
|
|
33
49
|
consumeApproval(requestId: string): Promise<void>;
|
package/dist/store.js
CHANGED
|
@@ -1,22 +1,50 @@
|
|
|
1
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
const HOLD_TTL_MS = 2 * 60 * 1000; // an in-flight payment resolves well within 2 min
|
|
1
3
|
/** Zero-dependency in-memory store — great for tests and single-instance deployments. */
|
|
2
4
|
export class InMemoryCurbStore {
|
|
3
5
|
allow = new Set();
|
|
4
|
-
|
|
6
|
+
committed = new Map();
|
|
7
|
+
holds = new Map();
|
|
5
8
|
approvals = new Map();
|
|
6
|
-
day = () => new Date().toISOString().slice(0, 10);
|
|
7
9
|
/** Seed an allowed counterparty. */
|
|
8
10
|
allowAccount(agent, account) {
|
|
9
11
|
this.allow.add(`${agent}:${account}`);
|
|
10
12
|
}
|
|
13
|
+
pruneCommitted(agent) {
|
|
14
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
15
|
+
const arr = (this.committed.get(agent) ?? []).filter((e) => e.ts > cutoff);
|
|
16
|
+
this.committed.set(agent, arr);
|
|
17
|
+
return arr;
|
|
18
|
+
}
|
|
19
|
+
pruneHolds(agent) {
|
|
20
|
+
const cutoff = Date.now() - HOLD_TTL_MS;
|
|
21
|
+
const arr = (this.holds.get(agent) ?? []).filter((e) => e.ts > cutoff);
|
|
22
|
+
this.holds.set(agent, arr);
|
|
23
|
+
return arr;
|
|
24
|
+
}
|
|
11
25
|
async isAllowed(agent, account) {
|
|
12
26
|
return this.allow.has(`${agent}:${account}`);
|
|
13
27
|
}
|
|
14
28
|
async getDailySpend(agent) {
|
|
15
|
-
return this.
|
|
29
|
+
return this.pruneCommitted(agent).reduce((s, e) => s + e.amount, 0);
|
|
16
30
|
}
|
|
17
31
|
async incrDailySpend(agent, amount) {
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
this.pruneCommitted(agent).push({ ts: Date.now(), amount });
|
|
33
|
+
}
|
|
34
|
+
async tryReserveSpend(agent, key, amount, cap) {
|
|
35
|
+
const committed = this.pruneCommitted(agent).reduce((s, e) => s + e.amount, 0);
|
|
36
|
+
const holds = this.pruneHolds(agent);
|
|
37
|
+
if (holds.some((h) => h.key === key))
|
|
38
|
+
return true; // idempotent — same payment re-checked
|
|
39
|
+
const held = holds.reduce((s, h) => s + h.amount, 0);
|
|
40
|
+
if (committed + held + amount > cap)
|
|
41
|
+
return false;
|
|
42
|
+
holds.push({ ts: Date.now(), key, amount });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
async commitSpend(agent, key, amount) {
|
|
46
|
+
this.pruneCommitted(agent).push({ ts: Date.now(), amount });
|
|
47
|
+
this.holds.set(agent, (this.holds.get(agent) ?? []).filter((h) => h.key !== key));
|
|
20
48
|
}
|
|
21
49
|
async getApproval(requestId) {
|
|
22
50
|
return this.approvals.get(requestId) ?? null;
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value-moving tools Curb governs:
|
|
3
|
+
* - `transfer_hbar` (custodial: agent spends its own bounded account)
|
|
4
|
+
* - `transfer_hbar_with_allowance` (non-custodial: agent spends the user's account, capped by an allowance)
|
|
5
|
+
*/
|
|
6
|
+
export declare const GOVERNED_TRANSFER_TOOLS: string[];
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { coreAccountPluginToolNames } from '@hashgraph/hedera-agent-kit/plugins';
|
|
2
|
+
/**
|
|
3
|
+
* The value-moving tools Curb governs:
|
|
4
|
+
* - `transfer_hbar` (custodial: agent spends its own bounded account)
|
|
5
|
+
* - `transfer_hbar_with_allowance` (non-custodial: agent spends the user's account, capped by an allowance)
|
|
6
|
+
*/
|
|
7
|
+
export const GOVERNED_TRANSFER_TOOLS = [
|
|
8
|
+
coreAccountPluginToolNames.TRANSFER_HBAR_TOOL,
|
|
9
|
+
coreAccountPluginToolNames.TRANSFER_HBAR_WITH_ALLOWANCE_TOOL,
|
|
10
|
+
];
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hedera-curb",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Verifiable spend-control policies for Hedera AI agents — drop-in Hedera Agent Kit hooks for budgets, allowlists, human approval, and an immutable HCS audit trail.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"module": "./dist/index.js",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
|
+
"bin": {
|
|
11
|
+
"hedera-curb": "./dist/cli.js"
|
|
12
|
+
},
|
|
10
13
|
"exports": {
|
|
11
14
|
".": {
|
|
12
15
|
"types": "./dist/index.d.ts",
|
|
13
16
|
"import": "./dist/index.js"
|
|
14
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
15
19
|
},
|
|
16
20
|
"files": ["dist", "README.md"],
|
|
17
21
|
"sideEffects": false,
|