@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 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>
@@ -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
+ }