openclaw-skill-pinti 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/SKILL.md +105 -0
- package/dist/before-spend-action.d.ts +10 -0
- package/dist/before-spend-action.js +55 -0
- package/dist/check-spend.d.ts +8 -0
- package/dist/check-spend.js +52 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +30 -0
- package/dist/guarded-executor.d.ts +8 -0
- package/dist/guarded-executor.js +55 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +219 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.js +2 -0
- package/package.json +31 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pinti-spend-policy
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
description: Spend policy enforcement for AI agents. Evaluate every payment against PINTI policies before executing.
|
|
5
|
+
author: pinti
|
|
6
|
+
category: policy
|
|
7
|
+
tags:
|
|
8
|
+
- spend
|
|
9
|
+
- policy
|
|
10
|
+
- guard
|
|
11
|
+
- payments
|
|
12
|
+
- compliance
|
|
13
|
+
env:
|
|
14
|
+
- name: PINTI_API_KEY
|
|
15
|
+
required: true
|
|
16
|
+
description: Your PINTI API key (starts with pinti_)
|
|
17
|
+
- name: PINTI_URL
|
|
18
|
+
required: false
|
|
19
|
+
description: PINTI API base URL (defaults to https://pinti.ai)
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# PINTI Spend Policy Skill
|
|
23
|
+
|
|
24
|
+
Add spend policy enforcement to any OpenClaw agent. PINTI evaluates every payment against your configured policies and returns one of three decisions:
|
|
25
|
+
|
|
26
|
+
- **ALLOW** — proceed with the payment (includes a Spend Authorization Token)
|
|
27
|
+
- **DENY** — block the payment
|
|
28
|
+
- **REQUIRE_APPROVAL** — pause and wait for human approval
|
|
29
|
+
|
|
30
|
+
## Why Use This
|
|
31
|
+
|
|
32
|
+
AI agents that spend money need guardrails. PINTI sits between your agent's intent to pay and the actual payment execution:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Agent → PINTI (policy check) → Payment rail (Stripe, Privacy.com, crypto, etc.)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Works with **any payment method** — PINTI doesn't handle payments, it decides whether they should happen.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { beforeSpendAction } from "openclaw-skill-pinti";
|
|
44
|
+
|
|
45
|
+
const check = await beforeSpendAction({
|
|
46
|
+
agentId: "my-agent",
|
|
47
|
+
amountMinor: 5000, // $50.00
|
|
48
|
+
unit: "USD",
|
|
49
|
+
merchant: "openai.com",
|
|
50
|
+
category: "api",
|
|
51
|
+
reason: "API credits top-up",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (check.action === "continue") {
|
|
55
|
+
await executePayment(); // your payment logic
|
|
56
|
+
} else {
|
|
57
|
+
console.log("Blocked or paused:", check.reason);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
### beforeSpendAction(input) → SpendActionResult
|
|
64
|
+
|
|
65
|
+
Hook pattern. Call before any spend action. Returns an action directive.
|
|
66
|
+
|
|
67
|
+
| Action | PINTI Decision | What to Do |
|
|
68
|
+
|------------|--------------------|--------------------------------------|
|
|
69
|
+
| `continue` | ALLOW | Proceed with the spend action |
|
|
70
|
+
| `block` | DENY (or error) | Stop. Do not execute. |
|
|
71
|
+
| `pause` | REQUIRE_APPROVAL | Wait for human approval. |
|
|
72
|
+
|
|
73
|
+
### guardedExecutor(input, executeFn) → GuardedExecutorResult
|
|
74
|
+
|
|
75
|
+
Wraps an action function with a PINTI check. The function only runs if PINTI allows it.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { guardedExecutor } from "openclaw-skill-pinti";
|
|
79
|
+
|
|
80
|
+
const result = await guardedExecutor(
|
|
81
|
+
{ agentId: "my-agent", amountMinor: 3000, unit: "USD", merchant: "openai.com", category: "api", reason: "Credits" },
|
|
82
|
+
async () => {
|
|
83
|
+
return await stripe.paymentIntents.create({ amount: 3000, currency: "usd" });
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (result.executed) {
|
|
88
|
+
console.log("Payment created:", result.result);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### checkSpend(input) → CheckSpendResult
|
|
93
|
+
|
|
94
|
+
Direct evaluation. Returns status without throwing.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { checkSpend } from "openclaw-skill-pinti";
|
|
98
|
+
|
|
99
|
+
const result = await checkSpend({ agentId: "agent-1", amountMinor: 10000, unit: "USD", merchant: "aws.amazon.com", category: "infra", reason: "Compute" });
|
|
100
|
+
// result.status: "allowed" | "denied" | "needs_approval"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Fail-Closed
|
|
104
|
+
|
|
105
|
+
On API errors or network failures, all functions default to **block/denied**. Agents never spend money when the policy check fails.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SpendInput, SpendActionResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Call before any spend action. Returns an action directive:
|
|
4
|
+
* - "continue" — PINTI says ALLOW. Proceed with the payment.
|
|
5
|
+
* - "block" — PINTI says DENY (or an error occurred). Stop.
|
|
6
|
+
* - "pause" — PINTI says REQUIRE_APPROVAL. Wait for human approval.
|
|
7
|
+
*
|
|
8
|
+
* On API errors, defaults to "block" (fail-closed).
|
|
9
|
+
*/
|
|
10
|
+
export declare function beforeSpendAction(input: SpendInput): Promise<SpendActionResult>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.beforeSpendAction = beforeSpendAction;
|
|
4
|
+
const guard_1 = require("@pinti/guard");
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
/**
|
|
7
|
+
* Call before any spend action. Returns an action directive:
|
|
8
|
+
* - "continue" — PINTI says ALLOW. Proceed with the payment.
|
|
9
|
+
* - "block" — PINTI says DENY (or an error occurred). Stop.
|
|
10
|
+
* - "pause" — PINTI says REQUIRE_APPROVAL. Wait for human approval.
|
|
11
|
+
*
|
|
12
|
+
* On API errors, defaults to "block" (fail-closed).
|
|
13
|
+
*/
|
|
14
|
+
async function beforeSpendAction(input) {
|
|
15
|
+
try {
|
|
16
|
+
const client = (0, client_1.getClient)();
|
|
17
|
+
const result = await client.authorize({
|
|
18
|
+
agentId: input.agentId,
|
|
19
|
+
amountMinor: input.amountMinor,
|
|
20
|
+
currency: input.unit,
|
|
21
|
+
merchant: input.merchant,
|
|
22
|
+
category: input.category,
|
|
23
|
+
reason: input.reason,
|
|
24
|
+
metadata: input.metadata,
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
action: "continue",
|
|
28
|
+
reason: result.decisionReason,
|
|
29
|
+
spendRequestId: result.spendRequestId,
|
|
30
|
+
sat: result.sat,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err instanceof guard_1.SpendDeniedError) {
|
|
35
|
+
return {
|
|
36
|
+
action: "block",
|
|
37
|
+
reason: err.decisionReason,
|
|
38
|
+
spendRequestId: err.spendRequestId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (err instanceof guard_1.ApprovalRequiredError) {
|
|
42
|
+
return {
|
|
43
|
+
action: "pause",
|
|
44
|
+
reason: err.decisionReason,
|
|
45
|
+
spendRequestId: err.spendRequestId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Unknown error — fail closed
|
|
49
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
50
|
+
return {
|
|
51
|
+
action: "block",
|
|
52
|
+
reason: `Policy check failed: ${message}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpendInput, CheckSpendResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Direct spend evaluation. Returns the policy decision without
|
|
4
|
+
* throwing errors — useful when the hook pattern doesn't fit.
|
|
5
|
+
*
|
|
6
|
+
* On API errors, defaults to "denied" (fail-closed).
|
|
7
|
+
*/
|
|
8
|
+
export declare function checkSpend(input: SpendInput): Promise<CheckSpendResult>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkSpend = checkSpend;
|
|
4
|
+
const guard_1 = require("@pinti/guard");
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
/**
|
|
7
|
+
* Direct spend evaluation. Returns the policy decision without
|
|
8
|
+
* throwing errors — useful when the hook pattern doesn't fit.
|
|
9
|
+
*
|
|
10
|
+
* On API errors, defaults to "denied" (fail-closed).
|
|
11
|
+
*/
|
|
12
|
+
async function checkSpend(input) {
|
|
13
|
+
try {
|
|
14
|
+
const client = (0, client_1.getClient)();
|
|
15
|
+
const result = await client.authorize({
|
|
16
|
+
agentId: input.agentId,
|
|
17
|
+
amountMinor: input.amountMinor,
|
|
18
|
+
currency: input.unit,
|
|
19
|
+
merchant: input.merchant,
|
|
20
|
+
category: input.category,
|
|
21
|
+
reason: input.reason,
|
|
22
|
+
metadata: input.metadata,
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
status: "allowed",
|
|
26
|
+
reason: result.decisionReason,
|
|
27
|
+
spendRequestId: result.spendRequestId,
|
|
28
|
+
sat: result.sat,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof guard_1.SpendDeniedError) {
|
|
33
|
+
return {
|
|
34
|
+
status: "denied",
|
|
35
|
+
reason: err.decisionReason,
|
|
36
|
+
spendRequestId: err.spendRequestId,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof guard_1.ApprovalRequiredError) {
|
|
40
|
+
return {
|
|
41
|
+
status: "needs_approval",
|
|
42
|
+
reason: err.decisionReason,
|
|
43
|
+
spendRequestId: err.spendRequestId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
47
|
+
return {
|
|
48
|
+
status: "denied",
|
|
49
|
+
reason: `Policy check failed: ${message}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PintiGuard } from "@pinti/guard";
|
|
2
|
+
/**
|
|
3
|
+
* Returns a singleton PintiGuard instance configured from environment variables.
|
|
4
|
+
*
|
|
5
|
+
* Env vars:
|
|
6
|
+
* PINTI_API_KEY — required
|
|
7
|
+
* PINTI_URL — optional, defaults to https://pinti.ai
|
|
8
|
+
*/
|
|
9
|
+
export declare function getClient(): PintiGuard;
|
|
10
|
+
/** @internal Reset client singleton — for testing only. */
|
|
11
|
+
export declare function _resetClient(): void;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getClient = getClient;
|
|
4
|
+
exports._resetClient = _resetClient;
|
|
5
|
+
const guard_1 = require("@pinti/guard");
|
|
6
|
+
let _client = null;
|
|
7
|
+
/**
|
|
8
|
+
* Returns a singleton PintiGuard instance configured from environment variables.
|
|
9
|
+
*
|
|
10
|
+
* Env vars:
|
|
11
|
+
* PINTI_API_KEY — required
|
|
12
|
+
* PINTI_URL — optional, defaults to https://pinti.ai
|
|
13
|
+
*/
|
|
14
|
+
function getClient() {
|
|
15
|
+
if (_client)
|
|
16
|
+
return _client;
|
|
17
|
+
const apiKey = process.env.PINTI_API_KEY;
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new Error("openclaw-skill-pinti: PINTI_API_KEY environment variable is required");
|
|
20
|
+
}
|
|
21
|
+
_client = new guard_1.PintiGuard({
|
|
22
|
+
apiKey,
|
|
23
|
+
baseUrl: process.env.PINTI_URL,
|
|
24
|
+
});
|
|
25
|
+
return _client;
|
|
26
|
+
}
|
|
27
|
+
/** @internal Reset client singleton — for testing only. */
|
|
28
|
+
function _resetClient() {
|
|
29
|
+
_client = null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpendInput, GuardedExecutorResult } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Wraps an action function with a PINTI policy check.
|
|
4
|
+
* The action only executes if PINTI returns ALLOW.
|
|
5
|
+
*
|
|
6
|
+
* On API errors, defaults to not executing (fail-closed).
|
|
7
|
+
*/
|
|
8
|
+
export declare function guardedExecutor<T = unknown>(input: SpendInput, execute: () => Promise<T>): Promise<GuardedExecutorResult<T>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.guardedExecutor = guardedExecutor;
|
|
4
|
+
const guard_1 = require("@pinti/guard");
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
/**
|
|
7
|
+
* Wraps an action function with a PINTI policy check.
|
|
8
|
+
* The action only executes if PINTI returns ALLOW.
|
|
9
|
+
*
|
|
10
|
+
* On API errors, defaults to not executing (fail-closed).
|
|
11
|
+
*/
|
|
12
|
+
async function guardedExecutor(input, execute) {
|
|
13
|
+
try {
|
|
14
|
+
const client = (0, client_1.getClient)();
|
|
15
|
+
const auth = await client.authorize({
|
|
16
|
+
agentId: input.agentId,
|
|
17
|
+
amountMinor: input.amountMinor,
|
|
18
|
+
currency: input.unit,
|
|
19
|
+
merchant: input.merchant,
|
|
20
|
+
category: input.category,
|
|
21
|
+
reason: input.reason,
|
|
22
|
+
metadata: input.metadata,
|
|
23
|
+
});
|
|
24
|
+
// ALLOW — execute the action
|
|
25
|
+
const result = await execute();
|
|
26
|
+
return {
|
|
27
|
+
executed: true,
|
|
28
|
+
result,
|
|
29
|
+
reason: auth.decisionReason,
|
|
30
|
+
spendRequestId: auth.spendRequestId,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err instanceof guard_1.SpendDeniedError) {
|
|
35
|
+
return {
|
|
36
|
+
executed: false,
|
|
37
|
+
reason: err.decisionReason,
|
|
38
|
+
spendRequestId: err.spendRequestId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (err instanceof guard_1.ApprovalRequiredError) {
|
|
42
|
+
return {
|
|
43
|
+
executed: false,
|
|
44
|
+
reason: err.decisionReason,
|
|
45
|
+
spendRequestId: err.spendRequestId,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Unknown error — fail closed
|
|
49
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
50
|
+
return {
|
|
51
|
+
executed: false,
|
|
52
|
+
reason: `Policy check failed: ${message}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkSpend = exports.guardedExecutor = exports.beforeSpendAction = void 0;
|
|
4
|
+
var before_spend_action_1 = require("./before-spend-action");
|
|
5
|
+
Object.defineProperty(exports, "beforeSpendAction", { enumerable: true, get: function () { return before_spend_action_1.beforeSpendAction; } });
|
|
6
|
+
var guarded_executor_1 = require("./guarded-executor");
|
|
7
|
+
Object.defineProperty(exports, "guardedExecutor", { enumerable: true, get: function () { return guarded_executor_1.guardedExecutor; } });
|
|
8
|
+
var check_spend_1 = require("./check-spend");
|
|
9
|
+
Object.defineProperty(exports, "checkSpend", { enumerable: true, get: function () { return check_spend_1.checkSpend; } });
|
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_test_1 = require("node:test");
|
|
7
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
8
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
9
|
+
const before_spend_action_1 = require("./before-spend-action");
|
|
10
|
+
const guarded_executor_1 = require("./guarded-executor");
|
|
11
|
+
const check_spend_1 = require("./check-spend");
|
|
12
|
+
// ----- Mock PINTI API Server -----
|
|
13
|
+
let server;
|
|
14
|
+
let port;
|
|
15
|
+
// Controls what the mock server returns
|
|
16
|
+
let mockResponse = {
|
|
17
|
+
status: 200,
|
|
18
|
+
body: {},
|
|
19
|
+
};
|
|
20
|
+
function setMockResponse(status, body) {
|
|
21
|
+
mockResponse = { status, body };
|
|
22
|
+
}
|
|
23
|
+
(0, node_test_1.before)(async () => {
|
|
24
|
+
server = node_http_1.default.createServer((req, res) => {
|
|
25
|
+
let rawBody = "";
|
|
26
|
+
req.on("data", (chunk) => (rawBody += chunk));
|
|
27
|
+
req.on("end", () => {
|
|
28
|
+
// Verify API key header
|
|
29
|
+
const apiKey = req.headers["x-api-key"];
|
|
30
|
+
if (apiKey !== "test_key") {
|
|
31
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(mockResponse.status, {
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
});
|
|
38
|
+
res.end(JSON.stringify(mockResponse.body));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
await new Promise((resolve) => {
|
|
42
|
+
server.listen(0, () => {
|
|
43
|
+
const addr = server.address();
|
|
44
|
+
port = typeof addr === "object" && addr ? addr.port : 0;
|
|
45
|
+
// Configure env vars BEFORE any client is created
|
|
46
|
+
process.env.PINTI_API_KEY = "test_key";
|
|
47
|
+
process.env.PINTI_URL = `http://localhost:${port}`;
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
(0, node_test_1.after)(() => {
|
|
53
|
+
server.close();
|
|
54
|
+
});
|
|
55
|
+
// ----- beforeSpendAction tests -----
|
|
56
|
+
(0, node_test_1.describe)("beforeSpendAction", () => {
|
|
57
|
+
const input = {
|
|
58
|
+
agentId: "test-agent",
|
|
59
|
+
amountMinor: 5000,
|
|
60
|
+
unit: "USD",
|
|
61
|
+
merchant: "openai.com",
|
|
62
|
+
category: "api",
|
|
63
|
+
reason: "Test spend",
|
|
64
|
+
};
|
|
65
|
+
(0, node_test_1.it)("returns 'continue' on ALLOW", async () => {
|
|
66
|
+
setMockResponse(200, {
|
|
67
|
+
decision: "ALLOW",
|
|
68
|
+
decisionReason: "Within policy limits",
|
|
69
|
+
spendRequestId: "sr_123",
|
|
70
|
+
sat: "eyJhbGciOi...",
|
|
71
|
+
});
|
|
72
|
+
const result = await (0, before_spend_action_1.beforeSpendAction)(input);
|
|
73
|
+
strict_1.default.equal(result.action, "continue");
|
|
74
|
+
strict_1.default.equal(result.reason, "Within policy limits");
|
|
75
|
+
strict_1.default.equal(result.spendRequestId, "sr_123");
|
|
76
|
+
strict_1.default.equal(result.sat, "eyJhbGciOi...");
|
|
77
|
+
});
|
|
78
|
+
(0, node_test_1.it)("returns 'block' on DENY", async () => {
|
|
79
|
+
setMockResponse(200, {
|
|
80
|
+
decision: "DENY",
|
|
81
|
+
decisionReason: "Exceeds daily limit",
|
|
82
|
+
spendRequestId: "sr_456",
|
|
83
|
+
});
|
|
84
|
+
const result = await (0, before_spend_action_1.beforeSpendAction)(input);
|
|
85
|
+
strict_1.default.equal(result.action, "block");
|
|
86
|
+
strict_1.default.equal(result.reason, "Exceeds daily limit");
|
|
87
|
+
strict_1.default.equal(result.spendRequestId, "sr_456");
|
|
88
|
+
strict_1.default.equal(result.sat, undefined);
|
|
89
|
+
});
|
|
90
|
+
(0, node_test_1.it)("returns 'pause' on REQUIRE_APPROVAL", async () => {
|
|
91
|
+
setMockResponse(200, {
|
|
92
|
+
decision: "REQUIRE_APPROVAL",
|
|
93
|
+
decisionReason: "Amount exceeds auto-approve threshold",
|
|
94
|
+
spendRequestId: "sr_789",
|
|
95
|
+
});
|
|
96
|
+
const result = await (0, before_spend_action_1.beforeSpendAction)(input);
|
|
97
|
+
strict_1.default.equal(result.action, "pause");
|
|
98
|
+
strict_1.default.equal(result.reason, "Amount exceeds auto-approve threshold");
|
|
99
|
+
strict_1.default.equal(result.spendRequestId, "sr_789");
|
|
100
|
+
});
|
|
101
|
+
(0, node_test_1.it)("returns 'block' on network error (fail-closed)", async () => {
|
|
102
|
+
// Point to a port that doesn't exist
|
|
103
|
+
const origUrl = process.env.PINTI_URL;
|
|
104
|
+
process.env.PINTI_URL = "http://localhost:1";
|
|
105
|
+
// Force new client by clearing the singleton
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
107
|
+
const clientModule = require("./client");
|
|
108
|
+
clientModule._resetClient?.();
|
|
109
|
+
const result = await (0, before_spend_action_1.beforeSpendAction)(input);
|
|
110
|
+
strict_1.default.equal(result.action, "block");
|
|
111
|
+
strict_1.default.ok(result.reason.includes("Policy check failed"));
|
|
112
|
+
// Restore
|
|
113
|
+
process.env.PINTI_URL = origUrl;
|
|
114
|
+
clientModule._resetClient?.();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ----- guardedExecutor tests -----
|
|
118
|
+
(0, node_test_1.describe)("guardedExecutor", () => {
|
|
119
|
+
const input = {
|
|
120
|
+
agentId: "test-agent",
|
|
121
|
+
amountMinor: 3000,
|
|
122
|
+
unit: "USD",
|
|
123
|
+
merchant: "stripe.com",
|
|
124
|
+
category: "api",
|
|
125
|
+
reason: "Payment processing",
|
|
126
|
+
};
|
|
127
|
+
(0, node_test_1.it)("executes callback on ALLOW", async () => {
|
|
128
|
+
setMockResponse(200, {
|
|
129
|
+
decision: "ALLOW",
|
|
130
|
+
decisionReason: "Allowed",
|
|
131
|
+
spendRequestId: "sr_exec_1",
|
|
132
|
+
sat: "token123",
|
|
133
|
+
});
|
|
134
|
+
let callbackRan = false;
|
|
135
|
+
const result = await (0, guarded_executor_1.guardedExecutor)(input, async () => {
|
|
136
|
+
callbackRan = true;
|
|
137
|
+
return { paymentId: "pi_abc" };
|
|
138
|
+
});
|
|
139
|
+
strict_1.default.equal(result.executed, true);
|
|
140
|
+
strict_1.default.equal(callbackRan, true);
|
|
141
|
+
strict_1.default.deepEqual(result.result, { paymentId: "pi_abc" });
|
|
142
|
+
strict_1.default.equal(result.reason, "Allowed");
|
|
143
|
+
strict_1.default.equal(result.spendRequestId, "sr_exec_1");
|
|
144
|
+
});
|
|
145
|
+
(0, node_test_1.it)("does NOT execute callback on DENY", async () => {
|
|
146
|
+
setMockResponse(200, {
|
|
147
|
+
decision: "DENY",
|
|
148
|
+
decisionReason: "Budget exhausted",
|
|
149
|
+
spendRequestId: "sr_exec_2",
|
|
150
|
+
});
|
|
151
|
+
let callbackRan = false;
|
|
152
|
+
const result = await (0, guarded_executor_1.guardedExecutor)(input, async () => {
|
|
153
|
+
callbackRan = true;
|
|
154
|
+
return "should not run";
|
|
155
|
+
});
|
|
156
|
+
strict_1.default.equal(result.executed, false);
|
|
157
|
+
strict_1.default.equal(callbackRan, false);
|
|
158
|
+
strict_1.default.equal(result.result, undefined);
|
|
159
|
+
strict_1.default.equal(result.reason, "Budget exhausted");
|
|
160
|
+
});
|
|
161
|
+
(0, node_test_1.it)("does NOT execute callback on REQUIRE_APPROVAL", async () => {
|
|
162
|
+
setMockResponse(200, {
|
|
163
|
+
decision: "REQUIRE_APPROVAL",
|
|
164
|
+
decisionReason: "Needs manager sign-off",
|
|
165
|
+
spendRequestId: "sr_exec_3",
|
|
166
|
+
});
|
|
167
|
+
let callbackRan = false;
|
|
168
|
+
const result = await (0, guarded_executor_1.guardedExecutor)(input, async () => {
|
|
169
|
+
callbackRan = true;
|
|
170
|
+
return "nope";
|
|
171
|
+
});
|
|
172
|
+
strict_1.default.equal(result.executed, false);
|
|
173
|
+
strict_1.default.equal(callbackRan, false);
|
|
174
|
+
strict_1.default.equal(result.reason, "Needs manager sign-off");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// ----- checkSpend tests -----
|
|
178
|
+
(0, node_test_1.describe)("checkSpend", () => {
|
|
179
|
+
const input = {
|
|
180
|
+
agentId: "agent-1",
|
|
181
|
+
amountMinor: 10000,
|
|
182
|
+
unit: "USD",
|
|
183
|
+
merchant: "aws.amazon.com",
|
|
184
|
+
category: "infra",
|
|
185
|
+
reason: "Compute provisioning",
|
|
186
|
+
};
|
|
187
|
+
(0, node_test_1.it)("returns 'allowed' on ALLOW", async () => {
|
|
188
|
+
setMockResponse(200, {
|
|
189
|
+
decision: "ALLOW",
|
|
190
|
+
decisionReason: "OK",
|
|
191
|
+
spendRequestId: "sr_chk_1",
|
|
192
|
+
sat: "sat_token",
|
|
193
|
+
});
|
|
194
|
+
const result = await (0, check_spend_1.checkSpend)(input);
|
|
195
|
+
strict_1.default.equal(result.status, "allowed");
|
|
196
|
+
strict_1.default.equal(result.reason, "OK");
|
|
197
|
+
strict_1.default.equal(result.sat, "sat_token");
|
|
198
|
+
});
|
|
199
|
+
(0, node_test_1.it)("returns 'denied' on DENY", async () => {
|
|
200
|
+
setMockResponse(200, {
|
|
201
|
+
decision: "DENY",
|
|
202
|
+
decisionReason: "Blocked by policy",
|
|
203
|
+
spendRequestId: "sr_chk_2",
|
|
204
|
+
});
|
|
205
|
+
const result = await (0, check_spend_1.checkSpend)(input);
|
|
206
|
+
strict_1.default.equal(result.status, "denied");
|
|
207
|
+
strict_1.default.equal(result.reason, "Blocked by policy");
|
|
208
|
+
});
|
|
209
|
+
(0, node_test_1.it)("returns 'needs_approval' on REQUIRE_APPROVAL", async () => {
|
|
210
|
+
setMockResponse(200, {
|
|
211
|
+
decision: "REQUIRE_APPROVAL",
|
|
212
|
+
decisionReason: "High amount",
|
|
213
|
+
spendRequestId: "sr_chk_3",
|
|
214
|
+
});
|
|
215
|
+
const result = await (0, check_spend_1.checkSpend)(input);
|
|
216
|
+
strict_1.default.equal(result.status, "needs_approval");
|
|
217
|
+
strict_1.default.equal(result.reason, "High amount");
|
|
218
|
+
});
|
|
219
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Input for all spend evaluation functions. */
|
|
2
|
+
export interface SpendInput {
|
|
3
|
+
/** Unique identifier of the agent making the spend request. */
|
|
4
|
+
agentId: string;
|
|
5
|
+
/** Amount in minor units (e.g. 4200 = $42.00). */
|
|
6
|
+
amountMinor: number;
|
|
7
|
+
/** Currency/unit code (e.g. "USD", "EUR"). */
|
|
8
|
+
unit: string;
|
|
9
|
+
/** Merchant or service receiving the payment. */
|
|
10
|
+
merchant: string;
|
|
11
|
+
/** Spend category (e.g. "api", "infra", "saas"). */
|
|
12
|
+
category: string;
|
|
13
|
+
/** Human-readable reason for the spend. */
|
|
14
|
+
reason: string;
|
|
15
|
+
/** Optional metadata key-value pairs. */
|
|
16
|
+
metadata?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
/** Result of beforeSpendAction(). */
|
|
19
|
+
export interface SpendActionResult {
|
|
20
|
+
/** Action directive for the agent. */
|
|
21
|
+
action: "continue" | "block" | "pause";
|
|
22
|
+
/** Explanation of the decision. */
|
|
23
|
+
reason: string;
|
|
24
|
+
/** PINTI spend request ID for tracking. */
|
|
25
|
+
spendRequestId?: string;
|
|
26
|
+
/** Spend Authorization Token — present only on "continue". */
|
|
27
|
+
sat?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Result of guardedExecutor(). */
|
|
30
|
+
export interface GuardedExecutorResult<T = unknown> {
|
|
31
|
+
/** Whether the action callback was executed. */
|
|
32
|
+
executed: boolean;
|
|
33
|
+
/** Return value of the action callback (only if executed). */
|
|
34
|
+
result?: T;
|
|
35
|
+
/** Explanation of the decision. */
|
|
36
|
+
reason: string;
|
|
37
|
+
/** PINTI spend request ID for tracking. */
|
|
38
|
+
spendRequestId?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Result of checkSpend(). */
|
|
41
|
+
export interface CheckSpendResult {
|
|
42
|
+
/** Policy evaluation status. */
|
|
43
|
+
status: "allowed" | "denied" | "needs_approval";
|
|
44
|
+
/** Explanation of the decision. */
|
|
45
|
+
reason: string;
|
|
46
|
+
/** PINTI spend request ID for tracking. */
|
|
47
|
+
spendRequestId?: string;
|
|
48
|
+
/** Spend Authorization Token — present only on "allowed". */
|
|
49
|
+
sat?: string;
|
|
50
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-skill-pinti",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw skill for PINTI — spend policy enforcement for AI agents",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": ["dist", "SKILL.md"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "npm run build && node --test dist/test.js",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"openclaw",
|
|
15
|
+
"skill",
|
|
16
|
+
"pinti",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"spend",
|
|
20
|
+
"policy",
|
|
21
|
+
"guard"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@pinti/guard": ">=0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@pinti/guard": "^0.1.0",
|
|
29
|
+
"typescript": "^5.5.0"
|
|
30
|
+
}
|
|
31
|
+
}
|