@secure-ai/guard 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/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/index.d.ts +279 -0
- package/dist/index.js +340 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Secure AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @secure-ai/guard
|
|
2
|
+
|
|
3
|
+
Data loss prevention for AI agents. Put a check in front of every action an
|
|
4
|
+
agent takes, before it takes it.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @secure-ai/guard
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## The one thing worth knowing
|
|
11
|
+
|
|
12
|
+
`guard` wraps a function an agent already calls, and calls it with the
|
|
13
|
+
**rewritten** arguments:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { SecureAI } from "@secure-ai/guard";
|
|
17
|
+
|
|
18
|
+
const sai = new SecureAI({ apiKey: process.env.SECURE_AI_KEY!, agent: "support-bot" });
|
|
19
|
+
|
|
20
|
+
const sendEmail = sai.guard("email.send", async (input: { to: string; body: string }) => {
|
|
21
|
+
return mailer.send(input);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await sendEmail({ to: "ana@clientfirm.com", body: "About invoice 4471…" });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The agent wrote a real address. `mailer.send` receives a stand-in. Nothing in
|
|
28
|
+
the agent's code had to check a decision, which is the point — protection that
|
|
29
|
+
depends on remembering to check it is protection that ends at the fourteenth
|
|
30
|
+
call site.
|
|
31
|
+
|
|
32
|
+
If the policy refuses the action, the wrapped function is never called and
|
|
33
|
+
`ActionBlocked` is thrown.
|
|
34
|
+
|
|
35
|
+
## Getting the real values back
|
|
36
|
+
|
|
37
|
+
Redaction is reversible with the map the inspection returned. The map is
|
|
38
|
+
returned to you and **is not stored on our side** — losing it means losing the
|
|
39
|
+
ability to restore that reply.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const verdict = await sai.inspect({ tool: "llm.complete", input: prompt });
|
|
43
|
+
const reply = await model.complete(verdict.input);
|
|
44
|
+
const readable = await sai.restore(reply, verdict.map);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Rules
|
|
48
|
+
|
|
49
|
+
Rules live on the account, not in the request. An agent cannot argue with them.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await sai.setPolicy({
|
|
53
|
+
fallback: "redact",
|
|
54
|
+
rules: [
|
|
55
|
+
{ kind: "secret", decision: "block" },
|
|
56
|
+
{ kind: "card", decision: "block", direction: "outbound" },
|
|
57
|
+
{ kind: "email", decision: "allow", tools: ["crm.*"] },
|
|
58
|
+
],
|
|
59
|
+
denyTools: ["shell.*"],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`decision` is `allow`, `redact` or `block`. When an action contains several
|
|
64
|
+
findings, the **most severe** decision wins — an action carrying a credential
|
|
65
|
+
is refused even if everything else in it was fine.
|
|
66
|
+
|
|
67
|
+
When the scanner keeps flagging something that is yours:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
await sai.allowValue("@ourcompany.com");
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
That applies to every agent on the account from the next action onward.
|
|
74
|
+
|
|
75
|
+
## The trail
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const { blocked, byKind } = await sai.summary();
|
|
79
|
+
const { events } = await sai.audit({ limit: 100 });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Records hold the **kind and location** of what was found — `card` at
|
|
83
|
+
`body.payment.number` — and never the value. There is no field on an audit
|
|
84
|
+
record that can hold one. That is deliberate: a store of every sensitive value
|
|
85
|
+
every agent touched is the thing this product exists to avoid being.
|
|
86
|
+
|
|
87
|
+
## When Secure AI is unreachable
|
|
88
|
+
|
|
89
|
+
The default is `onUnreachable: "closed"` — the action does not happen, and the
|
|
90
|
+
error propagates. That is the correct default for a security control, and it
|
|
91
|
+
does mean an outage here stops agents.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
new SecureAI({ apiKey, onUnreachable: "open" }); // availability over control
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Failing open never lets a real refusal through: a 401, 402 or quota error is an
|
|
98
|
+
answer, not an outage, and still throws.
|
|
99
|
+
|
|
100
|
+
## No code to change at all
|
|
101
|
+
|
|
102
|
+
If wrapping each tool is too invasive, hand the gateway fetch to whatever HTTP
|
|
103
|
+
client the agent already uses. Every request it makes is inspected on the way
|
|
104
|
+
out, and no call site changes:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const openai = new OpenAI({
|
|
108
|
+
apiKey: process.env.OPENAI_KEY!,
|
|
109
|
+
fetch: sai.fetch({ forwardAuth: `Bearer ${process.env.OPENAI_KEY}` }),
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Two credentials travel and they are kept apart on purpose: your Secure AI key
|
|
114
|
+
authenticates you to us and is **never** forwarded; `forwardAuth` is the
|
|
115
|
+
destination's own credential and becomes its `Authorization` header.
|
|
116
|
+
|
|
117
|
+
A refused request throws `ActionBlocked`, the same as a guarded function.
|
|
118
|
+
Pass `throwOnBlock: false` to get the 403 back instead.
|
|
119
|
+
|
|
120
|
+
The gateway will not forward to private or link-local addresses, so it cannot
|
|
121
|
+
be pointed at a cloud metadata service. Bodies it cannot read as text — an
|
|
122
|
+
image, a zip — are forwarded and the response carries
|
|
123
|
+
`X-Secure-AI-Inspected: false`, rather than a clean log implying a check that
|
|
124
|
+
did not happen.
|
|
125
|
+
|
|
126
|
+
## Runtime
|
|
127
|
+
|
|
128
|
+
Needs `fetch` and nothing else — Node 18+, Bun, Deno, Cloudflare Workers,
|
|
129
|
+
browsers. No dependencies, deliberately: this runs next to somebody's model
|
|
130
|
+
client and framework, and every dependency it adds is a version conflict it can
|
|
131
|
+
cause in a process already carrying too many.
|
|
132
|
+
|
|
133
|
+
## Also available over MCP
|
|
134
|
+
|
|
135
|
+
Agents that speak MCP can reach the same controls as tools at
|
|
136
|
+
`https://api.secureai.one/mcp` — `inspect_action`, `check_policy`,
|
|
137
|
+
`recent_activity`, plus `redact` and `restore`.
|
|
138
|
+
|
|
139
|
+
Full reference: <https://secureai.one/developers>
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Secure AI SDK — data loss prevention for AI agents.
|
|
3
|
+
*
|
|
4
|
+
* The API is four HTTP calls and anybody can use it with fetch. This exists
|
|
5
|
+
* because the shape that makes the product work is not "call an endpoint", it
|
|
6
|
+
* is "put a check in front of every action", and the difference between those
|
|
7
|
+
* two is whether somebody remembers to do it at the fourteenth call site.
|
|
8
|
+
*
|
|
9
|
+
* So the centre of this file is `guard`, which takes a function an agent
|
|
10
|
+
* already calls and returns one that cannot run without a decision:
|
|
11
|
+
*
|
|
12
|
+
* const post = guard("http.post", rawPost);
|
|
13
|
+
* await post({ url, body }); // blocked actions throw
|
|
14
|
+
*
|
|
15
|
+
* Everything else is the plumbing under it.
|
|
16
|
+
*
|
|
17
|
+
* ── No dependencies, on purpose ──
|
|
18
|
+
*
|
|
19
|
+
* This runs inside somebody's agent, next to their model client, their
|
|
20
|
+
* framework and their vendor SDKs. Every dependency it adds is a version
|
|
21
|
+
* conflict it can cause in a process that is already carrying too many, and a
|
|
22
|
+
* security tool that is awkward to install is one that gets removed. It needs
|
|
23
|
+
* fetch and nothing else: Node 18+, Bun, Deno, Cloudflare Workers, browsers.
|
|
24
|
+
*/
|
|
25
|
+
export type Decision = "allow" | "redact" | "approve" | "block";
|
|
26
|
+
export type ApprovalStatus = "pending" | "approved" | "denied" | "expired";
|
|
27
|
+
export type Direction = "outbound" | "inbound";
|
|
28
|
+
export type Kind = "secret" | "card" | "iban" | "ssn" | "govid" | "email" | "phone" | "address" | "postcode" | "name" | "host";
|
|
29
|
+
export interface Finding {
|
|
30
|
+
kind: Kind;
|
|
31
|
+
/** Where in the action it sat, e.g. "body.customer.email". */
|
|
32
|
+
path: string;
|
|
33
|
+
decision: Decision;
|
|
34
|
+
}
|
|
35
|
+
export interface Approval {
|
|
36
|
+
id: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
status: ApprovalStatus;
|
|
40
|
+
agent: string | null;
|
|
41
|
+
tool: string;
|
|
42
|
+
keyId: string;
|
|
43
|
+
findings: Finding[];
|
|
44
|
+
decidedBy?: string;
|
|
45
|
+
decidedAt?: number;
|
|
46
|
+
note?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface InspectResult<T = unknown> {
|
|
49
|
+
decision: Decision;
|
|
50
|
+
/** Set when the decision is "approve": the id to come back with once a
|
|
51
|
+
* person has decided. */
|
|
52
|
+
approvalId?: string;
|
|
53
|
+
/** When waiting stops being worth it. Milliseconds since epoch. */
|
|
54
|
+
expiresAt?: number;
|
|
55
|
+
/**
|
|
56
|
+
* The action, ready to send. Absent when the decision is "block" — there is
|
|
57
|
+
* deliberately nothing sendable in a refusal, so a caller cannot reach past
|
|
58
|
+
* the decision by accident.
|
|
59
|
+
*/
|
|
60
|
+
input?: T;
|
|
61
|
+
/** {standIn: real}. Keep it: it is the only way to turn a reply back, and
|
|
62
|
+
* it is not stored on our side. */
|
|
63
|
+
map: Record<string, string>;
|
|
64
|
+
findings: Finding[];
|
|
65
|
+
toolDenied: boolean;
|
|
66
|
+
policySource: "account" | "default" | "unreadable";
|
|
67
|
+
auditId: string;
|
|
68
|
+
}
|
|
69
|
+
export interface Rule {
|
|
70
|
+
kind: Kind;
|
|
71
|
+
decision: Decision;
|
|
72
|
+
tools?: string[];
|
|
73
|
+
direction?: Direction;
|
|
74
|
+
}
|
|
75
|
+
export interface Policy {
|
|
76
|
+
version: 1;
|
|
77
|
+
fallback: Decision;
|
|
78
|
+
rules: Rule[];
|
|
79
|
+
denyTools?: string[];
|
|
80
|
+
allow?: string[];
|
|
81
|
+
}
|
|
82
|
+
/** An action the policy refused. Thrown by a guarded function rather than
|
|
83
|
+
* returned, because a refusal is not a result the caller should be able to
|
|
84
|
+
* ignore by not reading a field. */
|
|
85
|
+
export declare class ActionBlocked extends Error {
|
|
86
|
+
readonly tool: string;
|
|
87
|
+
readonly findings: Finding[];
|
|
88
|
+
readonly toolDenied: boolean;
|
|
89
|
+
readonly auditId: string;
|
|
90
|
+
constructor(tool: string, result: InspectResult);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A person refused this action, or nobody answered in time.
|
|
94
|
+
*
|
|
95
|
+
* Distinct from ActionBlocked because the answer to it is different: a policy
|
|
96
|
+
* refusal is a rule to change, and this is a conversation to have. An agent
|
|
97
|
+
* reporting "the policy forbids this" when in fact Dave clicked no would send
|
|
98
|
+
* somebody to the wrong screen.
|
|
99
|
+
*/
|
|
100
|
+
export declare class ApprovalRefused extends Error {
|
|
101
|
+
readonly tool: string;
|
|
102
|
+
readonly approvalId: string;
|
|
103
|
+
readonly status: ApprovalStatus;
|
|
104
|
+
readonly note?: string;
|
|
105
|
+
constructor(tool: string, approvalId: string, status: ApprovalStatus, note?: string);
|
|
106
|
+
}
|
|
107
|
+
/** The API answered, and said no. Separate from ActionBlocked: this is a
|
|
108
|
+
* problem with the call, not a decision about the action. */
|
|
109
|
+
export declare class SecureAIError extends Error {
|
|
110
|
+
readonly status: number;
|
|
111
|
+
readonly code: string | null;
|
|
112
|
+
constructor(message: string, status: number, code: string | null);
|
|
113
|
+
}
|
|
114
|
+
export interface ClientOptions {
|
|
115
|
+
apiKey: string;
|
|
116
|
+
/** Overridable for staging and for tests. */
|
|
117
|
+
baseUrl?: string;
|
|
118
|
+
/** Names this agent in the audit trail, so its actions group together.
|
|
119
|
+
* Worth setting: a trail where everything is unnamed is a trail nobody
|
|
120
|
+
* can ask a question of. */
|
|
121
|
+
agent?: string;
|
|
122
|
+
/** Milliseconds before a call is abandoned. This sits in front of an
|
|
123
|
+
* agent's actions, so a hung check is a hung agent. */
|
|
124
|
+
timeoutMs?: number;
|
|
125
|
+
/** Injected for tests, and for runtimes with an unusual fetch. */
|
|
126
|
+
fetch?: typeof globalThis.fetch;
|
|
127
|
+
/**
|
|
128
|
+
* What to do when Secure AI itself cannot be reached.
|
|
129
|
+
*
|
|
130
|
+
* "closed" — the default — throws, so an action is not taken while the
|
|
131
|
+
* thing that governs it is down. That is the correct default for a security
|
|
132
|
+
* control and it does mean an outage here stops agents.
|
|
133
|
+
*
|
|
134
|
+
* "open" lets the action through unchecked. It is offered because some
|
|
135
|
+
* workloads genuinely prefer availability, and because a customer who wants
|
|
136
|
+
* it will otherwise implement it themselves with a try/catch that also
|
|
137
|
+
* swallows real refusals. Choosing it is a decision to record.
|
|
138
|
+
*/
|
|
139
|
+
onUnreachable?: "closed" | "open";
|
|
140
|
+
}
|
|
141
|
+
export declare class SecureAI {
|
|
142
|
+
private readonly apiKey;
|
|
143
|
+
private readonly baseUrl;
|
|
144
|
+
private readonly agent?;
|
|
145
|
+
private readonly timeoutMs;
|
|
146
|
+
private readonly doFetch;
|
|
147
|
+
private readonly onUnreachable;
|
|
148
|
+
constructor(options: ClientOptions);
|
|
149
|
+
private request;
|
|
150
|
+
/** Judge an action without taking it. */
|
|
151
|
+
inspect<T = unknown>(action: {
|
|
152
|
+
tool: string;
|
|
153
|
+
input: T;
|
|
154
|
+
direction?: Direction;
|
|
155
|
+
agent?: string;
|
|
156
|
+
/** Set when coming back after a person has decided. */
|
|
157
|
+
approvalId?: string;
|
|
158
|
+
}): Promise<InspectResult<T>>;
|
|
159
|
+
/** One held action. */
|
|
160
|
+
approval(id: string): Promise<Approval>;
|
|
161
|
+
/** Everything waiting, for a reviewer's screen. */
|
|
162
|
+
approvals(opts?: {
|
|
163
|
+
status?: ApprovalStatus;
|
|
164
|
+
limit?: number;
|
|
165
|
+
}): Promise<Approval[]>;
|
|
166
|
+
/**
|
|
167
|
+
* Wait for a person to decide.
|
|
168
|
+
*
|
|
169
|
+
* Polling, because it is the only mechanism that works in every runtime an
|
|
170
|
+
* agent might be in — a held connection dies to platform timeouts, and a
|
|
171
|
+
* callback needs the agent to be addressable, which a script on somebody's
|
|
172
|
+
* laptop is not.
|
|
173
|
+
*
|
|
174
|
+
* Stops at the approval's own expiry rather than running forever: the
|
|
175
|
+
* server will refuse it after that anyway, and a loop that outlives the
|
|
176
|
+
* thing it is waiting for is a hung agent.
|
|
177
|
+
*/
|
|
178
|
+
waitForApproval(id: string, opts?: {
|
|
179
|
+
pollMs?: number;
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
}): Promise<Approval>;
|
|
182
|
+
/** The rules currently in force. */
|
|
183
|
+
getPolicy(): Promise<{
|
|
184
|
+
policy: Policy;
|
|
185
|
+
source: string;
|
|
186
|
+
}>;
|
|
187
|
+
/** Replace them. */
|
|
188
|
+
setPolicy(policy: Policy | Omit<Policy, "version">): Promise<{
|
|
189
|
+
policy: Policy;
|
|
190
|
+
}>;
|
|
191
|
+
/** Stop flagging a value — a shared mailbox, your own domain. Applies to
|
|
192
|
+
* every agent on the account from the next action onwards. */
|
|
193
|
+
allowValue(value: string): Promise<{
|
|
194
|
+
policy: Policy;
|
|
195
|
+
added: string;
|
|
196
|
+
}>;
|
|
197
|
+
/** What agents on this account have been doing. Kinds and locations only;
|
|
198
|
+
* no values are stored. */
|
|
199
|
+
audit(opts?: {
|
|
200
|
+
limit?: number;
|
|
201
|
+
cursor?: string;
|
|
202
|
+
}): Promise<{
|
|
203
|
+
events: Array<{
|
|
204
|
+
id: string;
|
|
205
|
+
ts: number;
|
|
206
|
+
keyId: string;
|
|
207
|
+
agent: string | null;
|
|
208
|
+
tool: string;
|
|
209
|
+
direction: Direction;
|
|
210
|
+
decision: Decision;
|
|
211
|
+
toolDenied: boolean;
|
|
212
|
+
findings: Finding[];
|
|
213
|
+
}>;
|
|
214
|
+
cursor: string | null;
|
|
215
|
+
}>;
|
|
216
|
+
/** The counts, over a recent window. */
|
|
217
|
+
summary(opts?: {
|
|
218
|
+
limit?: number;
|
|
219
|
+
}): Promise<{
|
|
220
|
+
window: number;
|
|
221
|
+
actions: number;
|
|
222
|
+
blocked: number;
|
|
223
|
+
redacted: number;
|
|
224
|
+
allowed: number;
|
|
225
|
+
byKind: Array<{
|
|
226
|
+
kind: Kind;
|
|
227
|
+
count: number;
|
|
228
|
+
}>;
|
|
229
|
+
byTool: Array<{
|
|
230
|
+
tool: string;
|
|
231
|
+
count: number;
|
|
232
|
+
}>;
|
|
233
|
+
}>;
|
|
234
|
+
/** Put real values back into a reply, using the map from an inspection. */
|
|
235
|
+
restore(text: string, map: Record<string, string>): Promise<string>;
|
|
236
|
+
/**
|
|
237
|
+
* The point of the whole library: a function that cannot run unchecked.
|
|
238
|
+
*
|
|
239
|
+
* Wraps one tool. The returned function inspects, then calls the original
|
|
240
|
+
* with the *rewritten* arguments — so an agent that never looks at a
|
|
241
|
+
* decision still cannot send a real card number — and throws ActionBlocked
|
|
242
|
+
* when the policy refuses.
|
|
243
|
+
*
|
|
244
|
+
* The original is called with the redacted input rather than the caller's,
|
|
245
|
+
* and that is the whole mechanism. Returning a decision for the caller to
|
|
246
|
+
* check would make protection opt-in at every site, which is the thing this
|
|
247
|
+
* exists to stop.
|
|
248
|
+
*/
|
|
249
|
+
guard<A, R>(tool: string, fn: (input: A) => Promise<R> | R, opts?: {
|
|
250
|
+
direction?: Direction;
|
|
251
|
+
agent?: string;
|
|
252
|
+
/** Default true. False raises ApprovalRefused straight away instead. */
|
|
253
|
+
waitForApproval?: boolean;
|
|
254
|
+
pollMs?: number;
|
|
255
|
+
}): (input: A) => Promise<R>;
|
|
256
|
+
/**
|
|
257
|
+
* A fetch that goes through the gateway.
|
|
258
|
+
*
|
|
259
|
+
* The other integration, for code that cannot be wrapped: hand this to
|
|
260
|
+
* anything that takes a fetch — an SDK, a framework's HTTP client — and
|
|
261
|
+
* every request it makes is inspected on the way out. No call sites change
|
|
262
|
+
* at all, which is the difference between an afternoon and a sprint.
|
|
263
|
+
*
|
|
264
|
+
* const openai = new OpenAI({ fetch: sai.fetch({ forwardAuth: key }) });
|
|
265
|
+
*
|
|
266
|
+
* A blocked request throws ActionBlocked rather than returning the 403, so
|
|
267
|
+
* it fails the same way a guarded function does. A caller who would rather
|
|
268
|
+
* see the response sets `throwOnBlock: false`.
|
|
269
|
+
*/
|
|
270
|
+
fetch(opts?: {
|
|
271
|
+
/** The credential for the destination, sent as its Authorization. Ours
|
|
272
|
+
* never travels — see forwardHeaders in the Worker. */
|
|
273
|
+
forwardAuth?: string;
|
|
274
|
+
agent?: string;
|
|
275
|
+
throwOnBlock?: boolean;
|
|
276
|
+
}): typeof globalThis.fetch;
|
|
277
|
+
}
|
|
278
|
+
/** For callers who prefer a function to a class. */
|
|
279
|
+
export declare function createClient(options: ClientOptions): SecureAI;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Secure AI SDK — data loss prevention for AI agents.
|
|
3
|
+
*
|
|
4
|
+
* The API is four HTTP calls and anybody can use it with fetch. This exists
|
|
5
|
+
* because the shape that makes the product work is not "call an endpoint", it
|
|
6
|
+
* is "put a check in front of every action", and the difference between those
|
|
7
|
+
* two is whether somebody remembers to do it at the fourteenth call site.
|
|
8
|
+
*
|
|
9
|
+
* So the centre of this file is `guard`, which takes a function an agent
|
|
10
|
+
* already calls and returns one that cannot run without a decision:
|
|
11
|
+
*
|
|
12
|
+
* const post = guard("http.post", rawPost);
|
|
13
|
+
* await post({ url, body }); // blocked actions throw
|
|
14
|
+
*
|
|
15
|
+
* Everything else is the plumbing under it.
|
|
16
|
+
*
|
|
17
|
+
* ── No dependencies, on purpose ──
|
|
18
|
+
*
|
|
19
|
+
* This runs inside somebody's agent, next to their model client, their
|
|
20
|
+
* framework and their vendor SDKs. Every dependency it adds is a version
|
|
21
|
+
* conflict it can cause in a process that is already carrying too many, and a
|
|
22
|
+
* security tool that is awkward to install is one that gets removed. It needs
|
|
23
|
+
* fetch and nothing else: Node 18+, Bun, Deno, Cloudflare Workers, browsers.
|
|
24
|
+
*/
|
|
25
|
+
/** An action the policy refused. Thrown by a guarded function rather than
|
|
26
|
+
* returned, because a refusal is not a result the caller should be able to
|
|
27
|
+
* ignore by not reading a field. */
|
|
28
|
+
export class ActionBlocked extends Error {
|
|
29
|
+
tool;
|
|
30
|
+
findings;
|
|
31
|
+
toolDenied;
|
|
32
|
+
auditId;
|
|
33
|
+
constructor(tool, result) {
|
|
34
|
+
const what = result.toolDenied
|
|
35
|
+
? `the tool itself is not permitted`
|
|
36
|
+
: result.findings
|
|
37
|
+
.filter((f) => f.decision === "block")
|
|
38
|
+
.map((f) => `${f.kind} at ${f.path || "the input"}`)
|
|
39
|
+
.join(", ") || "policy";
|
|
40
|
+
super(`Secure AI refused ${tool}: ${what}.`);
|
|
41
|
+
this.name = "ActionBlocked";
|
|
42
|
+
this.tool = tool;
|
|
43
|
+
this.findings = result.findings;
|
|
44
|
+
this.toolDenied = result.toolDenied;
|
|
45
|
+
this.auditId = result.auditId;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A person refused this action, or nobody answered in time.
|
|
50
|
+
*
|
|
51
|
+
* Distinct from ActionBlocked because the answer to it is different: a policy
|
|
52
|
+
* refusal is a rule to change, and this is a conversation to have. An agent
|
|
53
|
+
* reporting "the policy forbids this" when in fact Dave clicked no would send
|
|
54
|
+
* somebody to the wrong screen.
|
|
55
|
+
*/
|
|
56
|
+
export class ApprovalRefused extends Error {
|
|
57
|
+
tool;
|
|
58
|
+
approvalId;
|
|
59
|
+
status;
|
|
60
|
+
note;
|
|
61
|
+
constructor(tool, approvalId, status, note) {
|
|
62
|
+
super(status === "expired"
|
|
63
|
+
? `Secure AI held ${tool} for approval and nobody answered before it expired.`
|
|
64
|
+
: `Secure AI held ${tool} for approval and it was refused${note ? `: ${note}` : "."}`);
|
|
65
|
+
this.name = "ApprovalRefused";
|
|
66
|
+
this.tool = tool;
|
|
67
|
+
this.approvalId = approvalId;
|
|
68
|
+
this.status = status;
|
|
69
|
+
if (note)
|
|
70
|
+
this.note = note;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The API answered, and said no. Separate from ActionBlocked: this is a
|
|
74
|
+
* problem with the call, not a decision about the action. */
|
|
75
|
+
export class SecureAIError extends Error {
|
|
76
|
+
status;
|
|
77
|
+
code;
|
|
78
|
+
constructor(message, status, code) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "SecureAIError";
|
|
81
|
+
this.status = status;
|
|
82
|
+
this.code = code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const DEFAULT_BASE = "https://api.secureai.one";
|
|
86
|
+
export class SecureAI {
|
|
87
|
+
apiKey;
|
|
88
|
+
baseUrl;
|
|
89
|
+
agent;
|
|
90
|
+
timeoutMs;
|
|
91
|
+
doFetch;
|
|
92
|
+
onUnreachable;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
if (!options?.apiKey)
|
|
95
|
+
throw new Error("SecureAI needs an apiKey.");
|
|
96
|
+
this.apiKey = options.apiKey;
|
|
97
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
98
|
+
this.agent = options.agent;
|
|
99
|
+
this.timeoutMs = options.timeoutMs ?? 5_000;
|
|
100
|
+
this.doFetch = options.fetch ?? globalThis.fetch;
|
|
101
|
+
this.onUnreachable = options.onUnreachable ?? "closed";
|
|
102
|
+
}
|
|
103
|
+
async request(method, path, body) {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
106
|
+
let res;
|
|
107
|
+
try {
|
|
108
|
+
res = await this.doFetch(`${this.baseUrl}${path}`, {
|
|
109
|
+
method,
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
},
|
|
114
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
let parsed = null;
|
|
123
|
+
try {
|
|
124
|
+
parsed = text ? JSON.parse(text) : null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Falls through to the status-based error below. A non-JSON body from
|
|
128
|
+
// this API means something in front of it answered, not the API.
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const err = parsed?.error;
|
|
132
|
+
throw new SecureAIError(err?.message ?? `Secure AI returned ${res.status}.`, res.status, err?.code ?? null);
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
/** Judge an action without taking it. */
|
|
137
|
+
async inspect(action) {
|
|
138
|
+
return this.request("POST", "/v1/inspect", {
|
|
139
|
+
tool: action.tool,
|
|
140
|
+
input: action.input,
|
|
141
|
+
direction: action.direction ?? "outbound",
|
|
142
|
+
agent: action.agent ?? this.agent,
|
|
143
|
+
...(action.approvalId ? { approvalId: action.approvalId } : {}),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** One held action. */
|
|
147
|
+
async approval(id) {
|
|
148
|
+
const body = await this.request("GET", `/v1/approvals/${encodeURIComponent(id)}`);
|
|
149
|
+
return body.approval;
|
|
150
|
+
}
|
|
151
|
+
/** Everything waiting, for a reviewer's screen. */
|
|
152
|
+
async approvals(opts) {
|
|
153
|
+
const q = new URLSearchParams();
|
|
154
|
+
if (opts?.status)
|
|
155
|
+
q.set("status", opts.status);
|
|
156
|
+
if (opts?.limit)
|
|
157
|
+
q.set("limit", String(opts.limit));
|
|
158
|
+
const qs = q.toString();
|
|
159
|
+
const body = await this.request("GET", `/v1/approvals${qs ? `?${qs}` : ""}`);
|
|
160
|
+
return body.approvals;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Wait for a person to decide.
|
|
164
|
+
*
|
|
165
|
+
* Polling, because it is the only mechanism that works in every runtime an
|
|
166
|
+
* agent might be in — a held connection dies to platform timeouts, and a
|
|
167
|
+
* callback needs the agent to be addressable, which a script on somebody's
|
|
168
|
+
* laptop is not.
|
|
169
|
+
*
|
|
170
|
+
* Stops at the approval's own expiry rather than running forever: the
|
|
171
|
+
* server will refuse it after that anyway, and a loop that outlives the
|
|
172
|
+
* thing it is waiting for is a hung agent.
|
|
173
|
+
*/
|
|
174
|
+
async waitForApproval(id, opts) {
|
|
175
|
+
const pollMs = Math.max(opts?.pollMs ?? 2_000, 250);
|
|
176
|
+
for (;;) {
|
|
177
|
+
const approval = await this.approval(id);
|
|
178
|
+
if (approval.status !== "pending")
|
|
179
|
+
return approval;
|
|
180
|
+
if (Date.now() >= approval.expiresAt)
|
|
181
|
+
return { ...approval, status: "expired" };
|
|
182
|
+
if (opts?.signal?.aborted)
|
|
183
|
+
throw new Error("Stopped waiting for approval.");
|
|
184
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** The rules currently in force. */
|
|
188
|
+
async getPolicy() {
|
|
189
|
+
return this.request("GET", "/v1/policy");
|
|
190
|
+
}
|
|
191
|
+
/** Replace them. */
|
|
192
|
+
async setPolicy(policy) {
|
|
193
|
+
return this.request("PUT", "/v1/policy", { policy });
|
|
194
|
+
}
|
|
195
|
+
/** Stop flagging a value — a shared mailbox, your own domain. Applies to
|
|
196
|
+
* every agent on the account from the next action onwards. */
|
|
197
|
+
async allowValue(value) {
|
|
198
|
+
return this.request("POST", "/v1/policy/allow", { value });
|
|
199
|
+
}
|
|
200
|
+
/** What agents on this account have been doing. Kinds and locations only;
|
|
201
|
+
* no values are stored. */
|
|
202
|
+
async audit(opts) {
|
|
203
|
+
const q = new URLSearchParams();
|
|
204
|
+
if (opts?.limit)
|
|
205
|
+
q.set("limit", String(opts.limit));
|
|
206
|
+
if (opts?.cursor)
|
|
207
|
+
q.set("cursor", opts.cursor);
|
|
208
|
+
const qs = q.toString();
|
|
209
|
+
return this.request("GET", `/v1/audit${qs ? `?${qs}` : ""}`);
|
|
210
|
+
}
|
|
211
|
+
/** The counts, over a recent window. */
|
|
212
|
+
async summary(opts) {
|
|
213
|
+
const qs = opts?.limit ? `?limit=${opts.limit}` : "";
|
|
214
|
+
return this.request("GET", `/v1/audit/summary${qs}`);
|
|
215
|
+
}
|
|
216
|
+
/** Put real values back into a reply, using the map from an inspection. */
|
|
217
|
+
async restore(text, map) {
|
|
218
|
+
const out = await this.request("POST", "/v1/restore", { text, map });
|
|
219
|
+
return out.text;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The point of the whole library: a function that cannot run unchecked.
|
|
223
|
+
*
|
|
224
|
+
* Wraps one tool. The returned function inspects, then calls the original
|
|
225
|
+
* with the *rewritten* arguments — so an agent that never looks at a
|
|
226
|
+
* decision still cannot send a real card number — and throws ActionBlocked
|
|
227
|
+
* when the policy refuses.
|
|
228
|
+
*
|
|
229
|
+
* The original is called with the redacted input rather than the caller's,
|
|
230
|
+
* and that is the whole mechanism. Returning a decision for the caller to
|
|
231
|
+
* check would make protection opt-in at every site, which is the thing this
|
|
232
|
+
* exists to stop.
|
|
233
|
+
*/
|
|
234
|
+
guard(tool, fn, opts) {
|
|
235
|
+
return async (input) => {
|
|
236
|
+
let verdict;
|
|
237
|
+
try {
|
|
238
|
+
verdict = await this.inspect({
|
|
239
|
+
tool,
|
|
240
|
+
input,
|
|
241
|
+
direction: opts?.direction,
|
|
242
|
+
agent: opts?.agent,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
// A refusal by the API — no key, no subscription, over quota — is a
|
|
247
|
+
// real answer and must not be treated as an outage.
|
|
248
|
+
if (err instanceof SecureAIError)
|
|
249
|
+
throw err;
|
|
250
|
+
if (this.onUnreachable === "open")
|
|
251
|
+
return await fn(input);
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
if (verdict.decision === "block")
|
|
255
|
+
throw new ActionBlocked(tool, verdict);
|
|
256
|
+
/*
|
|
257
|
+
* Held for a person.
|
|
258
|
+
*
|
|
259
|
+
* Waits by default, because the alternative — returning something the
|
|
260
|
+
* caller has to notice and handle — puts the agent author in charge of
|
|
261
|
+
* whether approval is enforced, which is the same mistake as returning
|
|
262
|
+
* a verdict instead of calling through. Set waitForApproval: false to
|
|
263
|
+
* get an ApprovalRefused immediately and handle it yourself.
|
|
264
|
+
*/
|
|
265
|
+
if (verdict.decision === "approve") {
|
|
266
|
+
const id = verdict.approvalId ?? "";
|
|
267
|
+
if (!id)
|
|
268
|
+
throw new ActionBlocked(tool, verdict);
|
|
269
|
+
if (opts?.waitForApproval === false) {
|
|
270
|
+
throw new ApprovalRefused(tool, id, "pending");
|
|
271
|
+
}
|
|
272
|
+
const decided = await this.waitForApproval(id, { pollMs: opts?.pollMs });
|
|
273
|
+
if (decided.status !== "approved") {
|
|
274
|
+
throw new ApprovalRefused(tool, id, decided.status, decided.note);
|
|
275
|
+
}
|
|
276
|
+
// Back with the id. The server re-checks the shape of what is being
|
|
277
|
+
// sent, so a yes cannot be spent on a different action.
|
|
278
|
+
const after = await this.inspect({
|
|
279
|
+
tool, input, direction: opts?.direction, agent: opts?.agent, approvalId: id,
|
|
280
|
+
});
|
|
281
|
+
if (after.decision === "block")
|
|
282
|
+
throw new ActionBlocked(tool, after);
|
|
283
|
+
return await fn((after.input ?? input));
|
|
284
|
+
}
|
|
285
|
+
// input is present whenever the decision is not block; the fallback is
|
|
286
|
+
// belt and braces against a future field being dropped.
|
|
287
|
+
return await fn((verdict.input ?? input));
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A fetch that goes through the gateway.
|
|
292
|
+
*
|
|
293
|
+
* The other integration, for code that cannot be wrapped: hand this to
|
|
294
|
+
* anything that takes a fetch — an SDK, a framework's HTTP client — and
|
|
295
|
+
* every request it makes is inspected on the way out. No call sites change
|
|
296
|
+
* at all, which is the difference between an afternoon and a sprint.
|
|
297
|
+
*
|
|
298
|
+
* const openai = new OpenAI({ fetch: sai.fetch({ forwardAuth: key }) });
|
|
299
|
+
*
|
|
300
|
+
* A blocked request throws ActionBlocked rather than returning the 403, so
|
|
301
|
+
* it fails the same way a guarded function does. A caller who would rather
|
|
302
|
+
* see the response sets `throwOnBlock: false`.
|
|
303
|
+
*/
|
|
304
|
+
fetch(opts) {
|
|
305
|
+
const throwOnBlock = opts?.throwOnBlock ?? true;
|
|
306
|
+
return (async (input, init = {}) => {
|
|
307
|
+
const target = typeof input === "string" || input instanceof URL
|
|
308
|
+
? String(input)
|
|
309
|
+
: input.url;
|
|
310
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
311
|
+
headers.set("Authorization", `Bearer ${this.apiKey}`);
|
|
312
|
+
headers.set("X-Secure-AI-Target", target);
|
|
313
|
+
if (opts?.forwardAuth)
|
|
314
|
+
headers.set("X-Secure-AI-Forward-Authorization", opts.forwardAuth);
|
|
315
|
+
const named = opts?.agent ?? this.agent;
|
|
316
|
+
if (named)
|
|
317
|
+
headers.set("X-Secure-AI-Agent", named);
|
|
318
|
+
const res = await this.doFetch(`${this.baseUrl}/v1/gateway`, {
|
|
319
|
+
...init,
|
|
320
|
+
method: init.method ?? (input instanceof Request ? input.method : "GET"),
|
|
321
|
+
headers,
|
|
322
|
+
});
|
|
323
|
+
if (throwOnBlock && res.status === 403) {
|
|
324
|
+
const body = await res.clone().json().catch(() => null);
|
|
325
|
+
if (body?.error?.code === "blocked_by_policy") {
|
|
326
|
+
throw new ActionBlocked(target, {
|
|
327
|
+
decision: "block", map: {}, findings: [], toolDenied: false,
|
|
328
|
+
policySource: "account",
|
|
329
|
+
auditId: res.headers.get("X-Secure-AI-Audit-Id") ?? "",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return res;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** For callers who prefer a function to a class. */
|
|
338
|
+
export function createClient(options) {
|
|
339
|
+
return new SecureAI(options);
|
|
340
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@secure-ai/guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Data loss prevention for AI agents. Inspect what an agent is about to do, before it does it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dlp",
|
|
26
|
+
"ai-agents",
|
|
27
|
+
"security",
|
|
28
|
+
"redaction",
|
|
29
|
+
"mcp",
|
|
30
|
+
"audit"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://secureai.one/developers",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"prepublishOnly": "npm run build",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"typescript": "^5.6.0",
|
|
41
|
+
"vitest": "^4.1.10"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/secureaione-jpg/secure-ai-sdk.git",
|
|
46
|
+
"directory": "typescript"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/secureaione-jpg/secure-ai-sdk/issues"
|
|
50
|
+
}
|
|
51
|
+
}
|