@oked/ai-sdk 0.0.0-stage → 0.6.21
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 +201 -0
- package/README.md +185 -2
- package/dist/gate.d.ts +14 -0
- package/dist/gate.js +219 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +212 -0
- package/dist/messages.d.ts +14 -0
- package/dist/messages.js +53 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.js +1 -0
- package/package.json +65 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by the Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding any notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,186 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oked/ai-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@oked/ai-sdk)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
OKed for the [Vercel AI SDK](https://ai-sdk.dev) (`ai`). Risky tool calls wait for a person, in Telegram or on the [web dashboard](https://oked.ai/dashboard), before `execute` runs. The iPhone app is coming soon. Questions: [contact@oked.ai](mailto:contact@oked.ai).
|
|
7
|
+
|
|
8
|
+
AI SDK 7 decides this on the call, through `toolApproval`. `okedToolApproval()` waits on `approve()` from [`@oked/sdk`](../sdk) and answers in that same call. A denial or a timeout comes back as `{ type: "denied", reason }`. The AI SDK does not run the tool, and the reason is the `execution-denied` result the model reads.
|
|
9
|
+
|
|
10
|
+
This package does not return `user-approval`. That status ends the generation and leaves the tool runnable until some later request answers it. Forgetting that second step, or answering it in code, would run the tool with no OKed decision.
|
|
11
|
+
|
|
12
|
+
`withOked()` and `okedTools()` wrap `execute` instead. Use them when the gate has to live on the tool object, or on AI SDK 5 and 6 (those versions have `needsApproval`, which is the same two-step pause). Do not set `toolApproval` on a tool you already wrapped. The person would be asked twice.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install @oked/ai-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`ai` is an optional peer dependency (`>=5.0.0`). You already have it if you are calling `generateText`, `streamText`, or `ToolLoopAgent`. `okedToolApproval()` needs AI SDK 7. The `execute` wrappers also work on AI SDK 5 and 6. Node.js 18 or newer.
|
|
21
|
+
|
|
22
|
+
This package has no pairing command. Provide a key in either place:
|
|
23
|
+
|
|
24
|
+
- `OKED_API_KEY` in the environment, or
|
|
25
|
+
- `~/.oked/config.json` (`{ "apiKey": "ok_..." }`)
|
|
26
|
+
|
|
27
|
+
Create a key from the [dashboard](https://oked.ai/dashboard) after you [sign up](https://oked.ai/signup), or run `oked init --key-only` from [`@oked/claude-code`](../claude-code).
|
|
28
|
+
|
|
29
|
+
Each request sends `source: "ai-sdk"` and `client: "@oked/ai-sdk@<version>"`. Both are part of the receipt subject.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
Pass `okedToolApproval()` as `toolApproval`. Every tool on that call is classified. `safe` and `warning` run immediately. `review` and `high_stakes` wait. The default wait is 630 seconds, so the backend's 10 minute window is the one that answers. A timeout denies.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { generateText, isStepCount, tool } from "ai";
|
|
37
|
+
import { okedToolApproval } from "@oked/ai-sdk";
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
|
|
40
|
+
const runCommand = tool({
|
|
41
|
+
description: "Run a shell command",
|
|
42
|
+
inputSchema: z.object({
|
|
43
|
+
command: z.string(),
|
|
44
|
+
}),
|
|
45
|
+
execute: async ({ command }) => {
|
|
46
|
+
// Runs only after OKed has approved a sensitive command.
|
|
47
|
+
return { command, exitCode: 0 };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result = await generateText({
|
|
52
|
+
model, // your AI SDK model
|
|
53
|
+
tools: { runCommand },
|
|
54
|
+
toolApproval: okedToolApproval(),
|
|
55
|
+
stopWhen: isStepCount(5),
|
|
56
|
+
instructions: "When OKed denies a tool, do not retry that action. Ask the user how to proceed.",
|
|
57
|
+
prompt: "Delete the old export in /var/data",
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`streamText` takes the same `toolApproval` option. The stream stays open while a person decides, then continues.
|
|
62
|
+
|
|
63
|
+
## Agent
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { ToolLoopAgent, isStepCount } from "ai";
|
|
67
|
+
import { okedToolApproval } from "@oked/ai-sdk";
|
|
68
|
+
|
|
69
|
+
const agent = new ToolLoopAgent({
|
|
70
|
+
model,
|
|
71
|
+
instructions: "When OKed denies a tool, do not retry that action. Ask the user how to proceed.",
|
|
72
|
+
tools: { runCommand },
|
|
73
|
+
toolApproval: okedToolApproval(),
|
|
74
|
+
stopWhen: isStepCount(10),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = await agent.generate({
|
|
78
|
+
prompt: "Remove /var/data/old-export",
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`agent.stream()` uses the same constructor option.
|
|
83
|
+
|
|
84
|
+
## One tool, or a tools object
|
|
85
|
+
|
|
86
|
+
`okedToolApprovalFor(name)` is the per-tool form. A tool you leave off the map is not gated, so prefer the function above unless you mean to opt out.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
toolApproval: {
|
|
90
|
+
runCommand: okedToolApprovalFor("runCommand"),
|
|
91
|
+
readFile: "not-applicable",
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Wrap `execute` when you want the gate on the tool itself:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { withOked, okedTools } from "@oked/ai-sdk";
|
|
99
|
+
|
|
100
|
+
const runCommand = withOked(
|
|
101
|
+
tool({
|
|
102
|
+
description: "Run a shell command",
|
|
103
|
+
inputSchema: z.object({ command: z.string() }),
|
|
104
|
+
execute: async ({ command }) => ({ command, exitCode: 0 }),
|
|
105
|
+
}),
|
|
106
|
+
{ name: "runCommand" },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const tools = okedTools({ runCommand, readFile });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`name` is the key you put on the tools object. `withOked` returns a new tool and does not change the original. On approve, `execute` is called with the same input and the same options object, including `toolCallId` and `abortSignal`. On deny, timeout, or error, `execute` is not called.
|
|
113
|
+
|
|
114
|
+
The wrapper returns this object, which the model sees as the tool result:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
{
|
|
118
|
+
oked: "denied",
|
|
119
|
+
decision: "denied" | "timeout" | "error",
|
|
120
|
+
approvalId: string | null,
|
|
121
|
+
tier: "safe" | "warning" | "review" | "high_stakes",
|
|
122
|
+
reason: string
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`isOkedDenial(value)` checks that shape. If the tool defines `outputSchema`, the wrapper throws `OkedDeniedError` instead, with the same reason as `message`, so schema validation cannot replace the reason with a validation error.
|
|
127
|
+
|
|
128
|
+
A tool with no `execute` (a provider-executed tool, or a client-side tool) cannot be stopped from this package. `okedTools` copies it through and logs that it is not gated. `withOked` throws at setup time. The AI SDK's own `toolApproval` has the same limit: it only covers tools the SDK executes locally.
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
Same ideas as the other integrations. Omit these to use the defaults.
|
|
133
|
+
|
|
134
|
+
| Option | Default | What it does |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `apiKey` | `OKED_API_KEY`, then `~/.oked/config.json` | Used when you do not pass `client`. |
|
|
137
|
+
| `timeoutMs` | `630000` | How long to wait. A timeout denies. Used when you do not pass `client`. |
|
|
138
|
+
| `minTier` | `"review"` (`OKED_MIN_TIER`) | Lowest tier that waits. `high_stakes` asks only for that tier. |
|
|
139
|
+
| `alwaysApprove` | none | Tool names that always wait, including names the classifier calls safe. |
|
|
140
|
+
| `alwaysAllow` | none | Tool names that always run. Wins over `alwaysApprove`. Exact, case-sensitive. |
|
|
141
|
+
| `strictFailClosed` | `false` (`OKED_STRICT_FAIL_CLOSED=1`) | When true, an unreachable backend denies every sensitive tool. When false, it denies `high_stakes` only. |
|
|
142
|
+
| `cwd` | `process.cwd()` | Recorded on the approval. |
|
|
143
|
+
| `sessionId` | the AI SDK tool call id | Receipt session. Set it to group a whole conversation. |
|
|
144
|
+
| `client` | a new `OKedClient` | Pass an existing client, or a test double. |
|
|
145
|
+
| `log` | stderr, prefixed with `[OKed]` | Receives the message with no prefix. |
|
|
146
|
+
|
|
147
|
+
`safe` and `warning` do not wait, even if `minTier` is lower, unless `alwaysApprove` lists the tool or a saved rule matches. A matching rule sends the call to `approve()` so the rule is applied on the server and the receipt records it. If the rules lookup fails, `safe` and `warning` still run. `review` and `high_stakes` do not depend on that lookup.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
toolApproval: okedToolApproval({
|
|
151
|
+
minTier: "high_stakes",
|
|
152
|
+
alwaysApprove: ["sendEmail"],
|
|
153
|
+
alwaysAllow: ["readFile"],
|
|
154
|
+
cwd: process.cwd(),
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Behavior
|
|
159
|
+
|
|
160
|
+
| Situation | What the model gets | Does `execute` run? |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `safe`, or `warning` with no matching rule | nothing extra (`toolApproval` returns `undefined`) | yes |
|
|
163
|
+
| Person approves | the tool's own result. The transcript also records `{ type: "approved" }`. | yes |
|
|
164
|
+
| Person denies | `{ type: "denied", reason }` and an `execution-denied` tool result | no |
|
|
165
|
+
| Approval window expires (`timeout`) | the same denial shape. The reason says nobody responded. | no |
|
|
166
|
+
| No API key, invalid key, bad receipt, HTTP error, unexpected error | a denial that tells the model not to retry | no |
|
|
167
|
+
| Backend unreachable, `high_stakes` or `strictFailClosed` | a denial | no |
|
|
168
|
+
| Backend unreachable, lower sensitive tier, strict mode off | the tool runs. Stderr says it was allowed degraded. | yes |
|
|
169
|
+
|
|
170
|
+
An explicit denial is final. A timeout is a denial. Nothing in those paths fails open.
|
|
171
|
+
|
|
172
|
+
Add a line to your instructions: "When OKed denies a tool, do not retry that action." The reason already says not to retry. Models still retry a bare tool error more often than a sentence that tells them to stop.
|
|
173
|
+
|
|
174
|
+
## Timeouts
|
|
175
|
+
|
|
176
|
+
The client waits 630 seconds (`DEFAULT_APPROVAL_TIMEOUT_MS`). That is 30 seconds past the 10 minute window, so the backend's `timeout` answer is the one that comes back, and the tool is denied.
|
|
177
|
+
|
|
178
|
+
Set the server's max duration, and any AI SDK `timeout` or `timeout.stepMs`, above that wait. Those budgets include the tool step. A platform that kills the process during the wait cannot deliver the denial, because the process is gone. `timeoutMs` on this package shortens the OKed wait itself. That shorter wait still denies. It does not let the tool run.
|
|
179
|
+
|
|
180
|
+
## Receipts
|
|
181
|
+
|
|
182
|
+
`approve()` checks the signed receipt before an approval counts. A receipt that does not verify denies the tool. An approval with no receipt denies too, which is the default since 0.6.0. A deny or a timeout that arrives without a receipt stays a deny. See [docs/receipts.md](../../docs/receipts.md).
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
[Apache-2.0](./LICENSE)
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OkedAiOptions, OkedGateResult } from "./types.js";
|
|
2
|
+
export { unexpectedMessage } from "./messages.js";
|
|
3
|
+
export interface GateCall {
|
|
4
|
+
toolName: string;
|
|
5
|
+
input: unknown;
|
|
6
|
+
toolCallId?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Classify one call and, when the tier needs a person, wait on `approve()`.
|
|
10
|
+
* Safe and warning calls return a local allow unless a rule matches or
|
|
11
|
+
* `alwaysApprove` lists the tool. The returned denial is final: callers must
|
|
12
|
+
* not run the tool.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createGate(options?: OkedAiOptions): (call: GateCall) => Promise<OkedGateResult>;
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { OKedClient, OKedAuthError, OKedBackendUnreachableError, OKedHttpError, OKedReceiptError, OKedTooManyPendingError, TIER_ORDER, applyRules, classify, degradedDecision, describe, describeFields, lookupPrTitle, } from "@oked/sdk";
|
|
5
|
+
import { approvedMessage, authMessage, classifierMessage, deniedMessage, httpMessage, malformedMessage, missingNameMessage, noKeyMessage, pendingMessage, receiptMessage, timeoutMessage, unexpectedMessage, unreachableMessage, } from "./messages.js";
|
|
6
|
+
export { unexpectedMessage } from "./messages.js";
|
|
7
|
+
const APPROVAL_TIERS = new Set(["review", "high_stakes"]);
|
|
8
|
+
const CLIENT_ID = (() => {
|
|
9
|
+
try {
|
|
10
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
11
|
+
return `@oked/ai-sdk@${JSON.parse(readFileSync(pkgPath, "utf-8")).version}`;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return "@oked/ai-sdk";
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
function defaultLog(message) {
|
|
18
|
+
process.stderr.write(`[OKed] ${message}\n`);
|
|
19
|
+
}
|
|
20
|
+
function asToolInput(input) {
|
|
21
|
+
if (input !== null && typeof input === "object" && !Array.isArray(input)) {
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
if (input === undefined)
|
|
25
|
+
return {};
|
|
26
|
+
return { value: input };
|
|
27
|
+
}
|
|
28
|
+
function resolveMinTier(options) {
|
|
29
|
+
if (options.minTier && options.minTier in TIER_ORDER)
|
|
30
|
+
return options.minTier;
|
|
31
|
+
const env = process.env.OKED_MIN_TIER;
|
|
32
|
+
if (env && env in TIER_ORDER)
|
|
33
|
+
return env;
|
|
34
|
+
return "review";
|
|
35
|
+
}
|
|
36
|
+
function isStrict(options, client) {
|
|
37
|
+
if (typeof options.strictFailClosed === "boolean")
|
|
38
|
+
return options.strictFailClosed;
|
|
39
|
+
return client.strictFailClosed === true;
|
|
40
|
+
}
|
|
41
|
+
function resolveClient(options) {
|
|
42
|
+
if (options.client)
|
|
43
|
+
return options.client;
|
|
44
|
+
const timeout = typeof options.timeoutMs === "number" &&
|
|
45
|
+
Number.isFinite(options.timeoutMs) &&
|
|
46
|
+
options.timeoutMs > 0
|
|
47
|
+
? options.timeoutMs
|
|
48
|
+
: undefined;
|
|
49
|
+
return new OKedClient({
|
|
50
|
+
...(options.apiKey ? { apiKey: options.apiKey } : {}),
|
|
51
|
+
...(timeout !== undefined ? { timeout } : {}),
|
|
52
|
+
...(typeof options.strictFailClosed === "boolean"
|
|
53
|
+
? { strictFailClosed: options.strictFailClosed }
|
|
54
|
+
: {}),
|
|
55
|
+
client: CLIENT_ID,
|
|
56
|
+
source: options.source ?? "ai-sdk",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function deny(decision, approvalId, reason, tier) {
|
|
60
|
+
return { action: "deny", decision, approvalId, reason, tier };
|
|
61
|
+
}
|
|
62
|
+
function localAllow() {
|
|
63
|
+
return { action: "allow", recorded: false };
|
|
64
|
+
}
|
|
65
|
+
function approvalIdOf(result) {
|
|
66
|
+
return typeof result.approval_id === "string" ? result.approval_id : null;
|
|
67
|
+
}
|
|
68
|
+
function mapApproval(result, tier) {
|
|
69
|
+
if (!result || typeof result.approved !== "boolean") {
|
|
70
|
+
return deny("error", null, malformedMessage(), tier);
|
|
71
|
+
}
|
|
72
|
+
const approvalId = approvalIdOf(result);
|
|
73
|
+
if (result.approved) {
|
|
74
|
+
return { action: "allow", recorded: true, reason: approvedMessage(approvalId) };
|
|
75
|
+
}
|
|
76
|
+
if (result.decision === "timeout") {
|
|
77
|
+
return deny("timeout", approvalId, timeoutMessage(approvalId), tier);
|
|
78
|
+
}
|
|
79
|
+
return deny("denied", approvalId, deniedMessage(approvalId), tier);
|
|
80
|
+
}
|
|
81
|
+
function mapError(err, tier, strict) {
|
|
82
|
+
if (err instanceof OKedReceiptError) {
|
|
83
|
+
return deny("error", null, receiptMessage(err.problems), tier);
|
|
84
|
+
}
|
|
85
|
+
if (err instanceof OKedAuthError) {
|
|
86
|
+
return deny("error", null, authMessage(), tier);
|
|
87
|
+
}
|
|
88
|
+
if (err instanceof OKedTooManyPendingError) {
|
|
89
|
+
return deny("error", null, pendingMessage(err.message), tier);
|
|
90
|
+
}
|
|
91
|
+
if (err instanceof OKedHttpError) {
|
|
92
|
+
return deny("error", null, httpMessage(err.status, err.detail), tier);
|
|
93
|
+
}
|
|
94
|
+
if (err instanceof OKedBackendUnreachableError) {
|
|
95
|
+
const decision = degradedDecision(tier, { strictFailClosed: strict });
|
|
96
|
+
if (decision === "allow")
|
|
97
|
+
return localAllow();
|
|
98
|
+
const why = strict ? "strict fail-closed" : "high-stakes";
|
|
99
|
+
return deny("error", null, unreachableMessage(tier, why), tier);
|
|
100
|
+
}
|
|
101
|
+
return deny("error", null, unexpectedMessage(err), tier);
|
|
102
|
+
}
|
|
103
|
+
async function rulesWantBackend(client, tier, toolName, toolInput, cwd) {
|
|
104
|
+
if (!client.apiKey || !client.getRules)
|
|
105
|
+
return false;
|
|
106
|
+
try {
|
|
107
|
+
const rules = await client.getRules();
|
|
108
|
+
if (!Array.isArray(rules) || rules.length === 0)
|
|
109
|
+
return false;
|
|
110
|
+
const fields = describeFields(toolName, toolInput, { cwd }) ?? {};
|
|
111
|
+
return Boolean(applyRules({ tier, fields }, rules, { cwd }).appliedRuleId);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// A failed rules lookup must not block a safe or warning call.
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function summaryOf(toolName, toolInput) {
|
|
119
|
+
const raw = toolInput.command ?? toolInput.cmd ?? toolInput.file_path ?? toolInput.path ?? toolName;
|
|
120
|
+
return typeof raw === "string" && raw ? raw : toolName;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Classify one call and, when the tier needs a person, wait on `approve()`.
|
|
124
|
+
* Safe and warning calls return a local allow unless a rule matches or
|
|
125
|
+
* `alwaysApprove` lists the tool. The returned denial is final: callers must
|
|
126
|
+
* not run the tool.
|
|
127
|
+
*/
|
|
128
|
+
export function createGate(options = {}) {
|
|
129
|
+
const client = resolveClient(options);
|
|
130
|
+
const log = options.log ?? defaultLog;
|
|
131
|
+
const cwd = options.cwd ?? process.cwd();
|
|
132
|
+
return async (call) => {
|
|
133
|
+
const toolName = typeof call.toolName === "string" ? call.toolName.trim() : "";
|
|
134
|
+
if (!toolName) {
|
|
135
|
+
return deny("error", null, missingNameMessage(), "review");
|
|
136
|
+
}
|
|
137
|
+
if (client.apiKey && client.heartbeat) {
|
|
138
|
+
try {
|
|
139
|
+
await client.heartbeat();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Presence only. A failure must not change the decision.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const toolInput = asToolInput(call.input);
|
|
146
|
+
const sessionId = options.sessionId || call.toolCallId || undefined;
|
|
147
|
+
if (options.alwaysAllow?.includes(toolName)) {
|
|
148
|
+
return localAllow();
|
|
149
|
+
}
|
|
150
|
+
let tier;
|
|
151
|
+
try {
|
|
152
|
+
tier = classify(toolName, toolInput);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
log(`Classifier error on ${toolName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
156
|
+
return deny("error", null, classifierMessage(), "review");
|
|
157
|
+
}
|
|
158
|
+
let forceAsk = false;
|
|
159
|
+
if (options.alwaysApprove?.includes(toolName)) {
|
|
160
|
+
if (TIER_ORDER[tier] < TIER_ORDER.review)
|
|
161
|
+
tier = "review";
|
|
162
|
+
forceAsk = true;
|
|
163
|
+
}
|
|
164
|
+
if (!forceAsk && (tier === "safe" || tier === "warning")) {
|
|
165
|
+
const matched = await rulesWantBackend(client, tier, toolName, toolInput, cwd);
|
|
166
|
+
if (!matched) {
|
|
167
|
+
if (tier === "warning") {
|
|
168
|
+
log(`WARNING ${toolName} ${summaryOf(toolName, toolInput)} allowed (inside project)`);
|
|
169
|
+
}
|
|
170
|
+
return localAllow();
|
|
171
|
+
}
|
|
172
|
+
forceAsk = true;
|
|
173
|
+
}
|
|
174
|
+
const minTier = resolveMinTier(options);
|
|
175
|
+
if (!forceAsk && (TIER_ORDER[tier] < TIER_ORDER[minTier] || !APPROVAL_TIERS.has(tier))) {
|
|
176
|
+
return localAllow();
|
|
177
|
+
}
|
|
178
|
+
if (!client.apiKey) {
|
|
179
|
+
log(`No API key. Denying ${toolName}.`);
|
|
180
|
+
return deny("error", null, noKeyMessage(), tier);
|
|
181
|
+
}
|
|
182
|
+
let fields = describeFields(toolName, toolInput, { cwd }) ?? undefined;
|
|
183
|
+
const prTitle = await lookupPrTitle(fields);
|
|
184
|
+
if (prTitle)
|
|
185
|
+
fields = describeFields(toolName, toolInput, { cwd, prTitle }) ?? undefined;
|
|
186
|
+
const description = describe(toolName, toolInput, prTitle ? { cwd, prTitle } : { cwd });
|
|
187
|
+
log(`Requesting approval for ${toolName} (${tier}). Check Telegram or the dashboard.`);
|
|
188
|
+
const strict = isStrict(options, client);
|
|
189
|
+
let outcome;
|
|
190
|
+
try {
|
|
191
|
+
const result = await client.approve({
|
|
192
|
+
action: toolName,
|
|
193
|
+
description,
|
|
194
|
+
tier,
|
|
195
|
+
fields,
|
|
196
|
+
tool_input: toolInput,
|
|
197
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
198
|
+
cwd,
|
|
199
|
+
});
|
|
200
|
+
outcome = mapApproval(result, tier);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (err instanceof OKedBackendUnreachableError) {
|
|
204
|
+
const decision = degradedDecision(tier, { strictFailClosed: strict });
|
|
205
|
+
if (decision === "allow") {
|
|
206
|
+
log(`Backend unreachable. ${toolName} (${tier}) allowed (degraded, non-high-stakes).`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
outcome = mapError(err, tier, strict);
|
|
210
|
+
}
|
|
211
|
+
if (outcome.action === "allow" && outcome.recorded) {
|
|
212
|
+
log(`${outcome.reason} for ${toolName}`);
|
|
213
|
+
}
|
|
214
|
+
else if (outcome.action === "deny") {
|
|
215
|
+
log(`Blocking ${toolName}: ${outcome.reason}`);
|
|
216
|
+
}
|
|
217
|
+
return outcome;
|
|
218
|
+
};
|
|
219
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { OkedAiOptions, OkedDenial, OkedSingleToolApproval, OkedToolApprovalFunction, ToolExecute } from "./types.js";
|
|
2
|
+
import type { RiskTier } from "@oked/sdk";
|
|
3
|
+
export type { OkedAiOptions, OkedDenial, OkedGateClient, OkedGateResult, OkedSingleToolApproval, OkedToolApprovalArgs, OkedToolApprovalFunction, OkedToolApprovalResult, OkedToolCall, } from "./types.js";
|
|
4
|
+
export type { RiskTier } from "@oked/sdk";
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a wrapped tool has an `outputSchema` and the call is denied.
|
|
7
|
+
* The AI SDK turns the error into a tool error, so the schema does not
|
|
8
|
+
* replace the reason with a validation failure. `message` is the reason
|
|
9
|
+
* the model should read.
|
|
10
|
+
*/
|
|
11
|
+
export declare class OkedDeniedError extends Error {
|
|
12
|
+
readonly oked: "denied";
|
|
13
|
+
readonly decision: OkedDenial["decision"];
|
|
14
|
+
readonly approvalId: string | null;
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
readonly tier: RiskTier;
|
|
17
|
+
constructor(denial: OkedDenial);
|
|
18
|
+
}
|
|
19
|
+
export declare function isOkedDenial(value: unknown): value is OkedDenial;
|
|
20
|
+
/**
|
|
21
|
+
* `toolApproval` callback for every tool on a `generateText`, `streamText`,
|
|
22
|
+
* or `ToolLoopAgent` call.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* await generateText({
|
|
26
|
+
* model,
|
|
27
|
+
* tools,
|
|
28
|
+
* toolApproval: okedToolApproval(),
|
|
29
|
+
* prompt,
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* A sensitive call waits for a person. Approve returns `{ type: "approved" }`
|
|
34
|
+
* and the AI SDK runs `execute`. Deny or timeout returns `{ type: "denied", reason }`
|
|
35
|
+
* and `execute` does not run. `safe` and `warning` return `undefined` so the
|
|
36
|
+
* tool runs with no approval metadata, unless a rule or `alwaysApprove` says
|
|
37
|
+
* otherwise.
|
|
38
|
+
*/
|
|
39
|
+
export declare function okedToolApproval(options?: OkedAiOptions): OkedToolApprovalFunction;
|
|
40
|
+
/**
|
|
41
|
+
* Per-tool `toolApproval` entry. Tools you leave off the map are not gated.
|
|
42
|
+
* Prefer {@link okedToolApproval} so every tool is classified.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* toolApproval: { runCommand: okedToolApprovalFor("runCommand") }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function okedToolApprovalFor(toolName: string, options?: OkedAiOptions): OkedSingleToolApproval;
|
|
49
|
+
/**
|
|
50
|
+
* Wrap one tool's `execute`. The original tool is not modified.
|
|
51
|
+
* `name` is the tool name the model uses (the key in your tools object).
|
|
52
|
+
*
|
|
53
|
+
* On approve, `execute` is called with the same input and the same options
|
|
54
|
+
* object. On deny, timeout, or error, `execute` is not called. The return
|
|
55
|
+
* value is an {@link OkedDenial} unless the tool defines `outputSchema`, in
|
|
56
|
+
* which case {@link OkedDeniedError} is thrown so schema validation cannot
|
|
57
|
+
* hide the reason.
|
|
58
|
+
*/
|
|
59
|
+
export declare function withOked<T extends {
|
|
60
|
+
execute?: ToolExecute;
|
|
61
|
+
}>(toolDef: T, options: OkedAiOptions & {
|
|
62
|
+
name: string;
|
|
63
|
+
}): T;
|
|
64
|
+
/**
|
|
65
|
+
* Wrap every tool in a tools object that has `execute`. Tools without
|
|
66
|
+
* `execute` are copied through and a log line says they are not gated.
|
|
67
|
+
* One client is shared across the set.
|
|
68
|
+
*/
|
|
69
|
+
export declare function okedTools<T extends Record<string, {
|
|
70
|
+
execute?: ToolExecute;
|
|
71
|
+
} | undefined>>(tools: T, options?: OkedAiOptions): T;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @oked/ai-sdk. OKed for the Vercel AI SDK.
|
|
3
|
+
*
|
|
4
|
+
* AI SDK 7 asks for approval through `toolApproval` on `generateText`,
|
|
5
|
+
* `streamText`, and `ToolLoopAgent`. `okedToolApproval()` awaits
|
|
6
|
+
* `OKedClient.approve()` and returns `approved` or `denied` in that same
|
|
7
|
+
* call. A denial or a timeout is `{ type: "denied", reason }`. The AI SDK
|
|
8
|
+
* then skips `execute` and shows the reason to the model.
|
|
9
|
+
*
|
|
10
|
+
* `user-approval` is intentionally unused. That status ends the generation
|
|
11
|
+
* and leaves the tool runnable until some later call answers it.
|
|
12
|
+
*
|
|
13
|
+
* `withOked` and `okedTools` wrap `execute` for one tool or a tools object.
|
|
14
|
+
* Use those when the gate has to travel with the tool, or on AI SDK 5 and 6,
|
|
15
|
+
* which have no `toolApproval`. Do not also set `toolApproval` on a wrapped
|
|
16
|
+
* tool: the person would be asked twice.
|
|
17
|
+
*
|
|
18
|
+
* Denied, timed out, and failed approvals do not run the tool.
|
|
19
|
+
*/
|
|
20
|
+
import { createGate, unexpectedMessage } from "./gate.js";
|
|
21
|
+
const TIERS = new Set(["safe", "warning", "review", "high_stakes"]);
|
|
22
|
+
/**
|
|
23
|
+
* Thrown when a wrapped tool has an `outputSchema` and the call is denied.
|
|
24
|
+
* The AI SDK turns the error into a tool error, so the schema does not
|
|
25
|
+
* replace the reason with a validation failure. `message` is the reason
|
|
26
|
+
* the model should read.
|
|
27
|
+
*/
|
|
28
|
+
export class OkedDeniedError extends Error {
|
|
29
|
+
oked = "denied";
|
|
30
|
+
decision;
|
|
31
|
+
approvalId;
|
|
32
|
+
reason;
|
|
33
|
+
tier;
|
|
34
|
+
constructor(denial) {
|
|
35
|
+
super(denial.reason);
|
|
36
|
+
this.name = "OkedDeniedError";
|
|
37
|
+
this.decision = denial.decision;
|
|
38
|
+
this.approvalId = denial.approvalId;
|
|
39
|
+
this.reason = denial.reason;
|
|
40
|
+
this.tier = denial.tier;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function isOkedDenial(value) {
|
|
44
|
+
if (!value || typeof value !== "object")
|
|
45
|
+
return false;
|
|
46
|
+
const v = value;
|
|
47
|
+
return (v.oked === "denied" &&
|
|
48
|
+
(v.decision === "denied" || v.decision === "timeout" || v.decision === "error") &&
|
|
49
|
+
typeof v.reason === "string" &&
|
|
50
|
+
(typeof v.approvalId === "string" || v.approvalId === null) &&
|
|
51
|
+
typeof v.tier === "string" &&
|
|
52
|
+
TIERS.has(v.tier));
|
|
53
|
+
}
|
|
54
|
+
function toDenial(result) {
|
|
55
|
+
return {
|
|
56
|
+
oked: "denied",
|
|
57
|
+
decision: result.decision,
|
|
58
|
+
approvalId: result.approvalId,
|
|
59
|
+
reason: result.reason,
|
|
60
|
+
tier: result.tier,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Map a gate result onto an AI SDK 7 `toolApproval` status. */
|
|
64
|
+
function toToolApprovalStatus(result) {
|
|
65
|
+
if (result.action === "deny")
|
|
66
|
+
return { type: "denied", reason: result.reason };
|
|
67
|
+
if (result.recorded)
|
|
68
|
+
return { type: "approved", reason: result.reason };
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function readInput(toolCall) {
|
|
72
|
+
if (toolCall.input !== undefined)
|
|
73
|
+
return toolCall.input;
|
|
74
|
+
return toolCall.args;
|
|
75
|
+
}
|
|
76
|
+
async function gated(gate, call) {
|
|
77
|
+
try {
|
|
78
|
+
return await gate(call);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return {
|
|
82
|
+
action: "deny",
|
|
83
|
+
decision: "error",
|
|
84
|
+
approvalId: null,
|
|
85
|
+
reason: unexpectedMessage(err),
|
|
86
|
+
tier: "review",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* `toolApproval` callback for every tool on a `generateText`, `streamText`,
|
|
92
|
+
* or `ToolLoopAgent` call.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* await generateText({
|
|
96
|
+
* model,
|
|
97
|
+
* tools,
|
|
98
|
+
* toolApproval: okedToolApproval(),
|
|
99
|
+
* prompt,
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* A sensitive call waits for a person. Approve returns `{ type: "approved" }`
|
|
104
|
+
* and the AI SDK runs `execute`. Deny or timeout returns `{ type: "denied", reason }`
|
|
105
|
+
* and `execute` does not run. `safe` and `warning` return `undefined` so the
|
|
106
|
+
* tool runs with no approval metadata, unless a rule or `alwaysApprove` says
|
|
107
|
+
* otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export function okedToolApproval(options = {}) {
|
|
110
|
+
const gate = createGate(options);
|
|
111
|
+
return async (args = {}) => {
|
|
112
|
+
const toolCall = args.toolCall;
|
|
113
|
+
const result = await gated(gate, {
|
|
114
|
+
toolName: typeof toolCall?.toolName === "string" ? toolCall.toolName : "",
|
|
115
|
+
input: toolCall ? readInput(toolCall) : undefined,
|
|
116
|
+
toolCallId: typeof toolCall?.toolCallId === "string" ? toolCall.toolCallId : undefined,
|
|
117
|
+
});
|
|
118
|
+
return toToolApprovalStatus(result);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Per-tool `toolApproval` entry. Tools you leave off the map are not gated.
|
|
123
|
+
* Prefer {@link okedToolApproval} so every tool is classified.
|
|
124
|
+
*
|
|
125
|
+
* ```ts
|
|
126
|
+
* toolApproval: { runCommand: okedToolApprovalFor("runCommand") }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function okedToolApprovalFor(toolName, options = {}) {
|
|
130
|
+
const name = toolName.trim();
|
|
131
|
+
const gate = createGate(options);
|
|
132
|
+
return async (input, execOptions) => {
|
|
133
|
+
const result = await gated(gate, {
|
|
134
|
+
toolName: name,
|
|
135
|
+
input,
|
|
136
|
+
toolCallId: typeof execOptions?.toolCallId === "string" ? execOptions.toolCallId : undefined,
|
|
137
|
+
});
|
|
138
|
+
return toToolApprovalStatus(result);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function hasOutputSchema(toolDef) {
|
|
142
|
+
return "outputSchema" in toolDef && toolDef.outputSchema != null;
|
|
143
|
+
}
|
|
144
|
+
function wrapExecute(toolDef, name, gate) {
|
|
145
|
+
const execute = toolDef.execute;
|
|
146
|
+
if (typeof execute !== "function") {
|
|
147
|
+
throw new Error(`OKed: tool "${name}" has no execute function, so a wrapper cannot stop it. ` +
|
|
148
|
+
"Provider-executed tools are not gated. Use okedToolApproval() for tools the AI SDK executes locally.");
|
|
149
|
+
}
|
|
150
|
+
const validateOutput = hasOutputSchema(toolDef);
|
|
151
|
+
const copy = { ...toolDef };
|
|
152
|
+
delete copy.needsApproval;
|
|
153
|
+
copy.execute = async (input, execOptions) => {
|
|
154
|
+
const toolCallId = execOptions && typeof execOptions === "object"
|
|
155
|
+
? execOptions.toolCallId
|
|
156
|
+
: undefined;
|
|
157
|
+
const result = await gated(gate, {
|
|
158
|
+
toolName: name,
|
|
159
|
+
input,
|
|
160
|
+
toolCallId: typeof toolCallId === "string" ? toolCallId : undefined,
|
|
161
|
+
});
|
|
162
|
+
if (result.action === "allow") {
|
|
163
|
+
return await execute.call(toolDef, input, execOptions);
|
|
164
|
+
}
|
|
165
|
+
const denial = toDenial(result);
|
|
166
|
+
if (validateOutput)
|
|
167
|
+
throw new OkedDeniedError(denial);
|
|
168
|
+
return denial;
|
|
169
|
+
};
|
|
170
|
+
return copy;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Wrap one tool's `execute`. The original tool is not modified.
|
|
174
|
+
* `name` is the tool name the model uses (the key in your tools object).
|
|
175
|
+
*
|
|
176
|
+
* On approve, `execute` is called with the same input and the same options
|
|
177
|
+
* object. On deny, timeout, or error, `execute` is not called. The return
|
|
178
|
+
* value is an {@link OkedDenial} unless the tool defines `outputSchema`, in
|
|
179
|
+
* which case {@link OkedDeniedError} is thrown so schema validation cannot
|
|
180
|
+
* hide the reason.
|
|
181
|
+
*/
|
|
182
|
+
export function withOked(toolDef, options) {
|
|
183
|
+
const name = options.name?.trim() ?? "";
|
|
184
|
+
if (!name)
|
|
185
|
+
throw new Error("OKed: withOked requires a tool name.");
|
|
186
|
+
if (!toolDef || typeof toolDef.execute !== "function") {
|
|
187
|
+
throw new Error(`OKed: tool "${name}" has no execute function, so a wrapper cannot stop it. ` +
|
|
188
|
+
"Provider-executed tools are not gated. Use okedToolApproval() for tools the AI SDK executes locally.");
|
|
189
|
+
}
|
|
190
|
+
return wrapExecute(toolDef, name, createGate(options));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Wrap every tool in a tools object that has `execute`. Tools without
|
|
194
|
+
* `execute` are copied through and a log line says they are not gated.
|
|
195
|
+
* One client is shared across the set.
|
|
196
|
+
*/
|
|
197
|
+
export function okedTools(tools, options = {}) {
|
|
198
|
+
const gate = createGate(options);
|
|
199
|
+
const log = options.log ?? ((message) => process.stderr.write(`[OKed] ${message}\n`));
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const name of Object.keys(tools)) {
|
|
202
|
+
const toolDef = tools[name];
|
|
203
|
+
if (!toolDef || typeof toolDef !== "object" || typeof toolDef.execute !== "function") {
|
|
204
|
+
log(`tool "${name}" has no execute function, so it is not gated. ` +
|
|
205
|
+
"Provider-executed tools run on the provider and OKed cannot stop them.");
|
|
206
|
+
out[name] = toolDef;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
out[name] = wrapExecute(toolDef, name, gate);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function idSuffix(id: string | null | undefined): string;
|
|
2
|
+
export declare function deniedMessage(id: string | null): string;
|
|
3
|
+
export declare function timeoutMessage(id: string | null): string;
|
|
4
|
+
export declare function noKeyMessage(): string;
|
|
5
|
+
export declare function authMessage(): string;
|
|
6
|
+
export declare function receiptMessage(problems: readonly string[]): string;
|
|
7
|
+
export declare function pendingMessage(detail: string): string;
|
|
8
|
+
export declare function httpMessage(status: number, detail: string): string;
|
|
9
|
+
export declare function unreachableMessage(tier: string, why: string): string;
|
|
10
|
+
export declare function unexpectedMessage(err: unknown): string;
|
|
11
|
+
export declare function malformedMessage(): string;
|
|
12
|
+
export declare function classifierMessage(): string;
|
|
13
|
+
export declare function missingNameMessage(): string;
|
|
14
|
+
export declare function approvedMessage(id: string | null): string;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const STOP = "Do NOT retry the same action. Stop and ask the user what to do instead.";
|
|
2
|
+
export function idSuffix(id) {
|
|
3
|
+
return id ? ` (approval ${id})` : "";
|
|
4
|
+
}
|
|
5
|
+
export function deniedMessage(id) {
|
|
6
|
+
return `USER DENIED this action via OKed${idSuffix(id)}. This is a final human decision. ${STOP}`;
|
|
7
|
+
}
|
|
8
|
+
export function timeoutMessage(id) {
|
|
9
|
+
return (`USER did not respond to this action via OKed${idSuffix(id)}. ` +
|
|
10
|
+
`The approval window expired, so the action was not run. ${STOP}`);
|
|
11
|
+
}
|
|
12
|
+
export function noKeyMessage() {
|
|
13
|
+
return ("OKed: no API key is configured, so this action was not run. " +
|
|
14
|
+
"Set OKED_API_KEY, or save an apiKey in ~/.oked/config.json. " +
|
|
15
|
+
"Create a key from the dashboard at https://oked.ai/dashboard, " +
|
|
16
|
+
"or run `oked init --key-only` from @oked/claude-code. " +
|
|
17
|
+
STOP);
|
|
18
|
+
}
|
|
19
|
+
export function authMessage() {
|
|
20
|
+
return ("OKed: invalid API key (configuration error, not a transient failure). " +
|
|
21
|
+
STOP);
|
|
22
|
+
}
|
|
23
|
+
export function receiptMessage(problems) {
|
|
24
|
+
const detail = problems.length > 0 ? problems.join(", ") : "invalid";
|
|
25
|
+
return (`OKed: the approval receipt did not verify (${detail}), so this action was not run. ` +
|
|
26
|
+
STOP);
|
|
27
|
+
}
|
|
28
|
+
export function pendingMessage(detail) {
|
|
29
|
+
return `${detail} ${STOP}`;
|
|
30
|
+
}
|
|
31
|
+
export function httpMessage(status, detail) {
|
|
32
|
+
const extra = detail ? `: ${detail}` : "";
|
|
33
|
+
return `OKed: backend error HTTP ${status}${extra} (fail-safe deny). ${STOP}`;
|
|
34
|
+
}
|
|
35
|
+
export function unreachableMessage(tier, why) {
|
|
36
|
+
return (`OKed backend unreachable. This ${tier} action was not run (${why}, fail-safe). ${STOP}`);
|
|
37
|
+
}
|
|
38
|
+
export function unexpectedMessage(err) {
|
|
39
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
40
|
+
return `OKed: unexpected error (fail-safe deny): ${msg}. ${STOP}`;
|
|
41
|
+
}
|
|
42
|
+
export function malformedMessage() {
|
|
43
|
+
return `OKed: the approval response was malformed, so this action was not run. ${STOP}`;
|
|
44
|
+
}
|
|
45
|
+
export function classifierMessage() {
|
|
46
|
+
return `OKed: the classifier failed, so this action was not run (fail-safe deny). ${STOP}`;
|
|
47
|
+
}
|
|
48
|
+
export function missingNameMessage() {
|
|
49
|
+
return `OKed: the tool call had no name, so it was not run. ${STOP}`;
|
|
50
|
+
}
|
|
51
|
+
export function approvedMessage(id) {
|
|
52
|
+
return `Approved via OKed${idSuffix(id)}`;
|
|
53
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ApprovalRequest, ApprovalResponse, RiskTier, Rule } from "@oked/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Enough of `OKedClient` for the gate. Pass `new OKedClient()` or a test double.
|
|
4
|
+
* `apiKey` and `timeoutMs` on {@link OkedAiOptions} apply only when `client` is omitted.
|
|
5
|
+
*/
|
|
6
|
+
export interface OkedGateClient {
|
|
7
|
+
readonly apiKey: string;
|
|
8
|
+
readonly strictFailClosed?: boolean;
|
|
9
|
+
approve(request: ApprovalRequest): Promise<ApprovalResponse>;
|
|
10
|
+
getRules?(): Promise<Rule[]>;
|
|
11
|
+
heartbeat?(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface OkedAiOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Client to call. When omitted, a new `OKedClient` is created from
|
|
16
|
+
* `OKED_API_KEY` or `~/.oked/config.json`.
|
|
17
|
+
*/
|
|
18
|
+
client?: OkedGateClient;
|
|
19
|
+
/** Used only when `client` is omitted. */
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
/**
|
|
22
|
+
* How long to wait for a person, in milliseconds. Used only when `client`
|
|
23
|
+
* is omitted. Default is the SDK wait, 630 seconds. A timeout denies.
|
|
24
|
+
*/
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* When true, an unreachable backend denies every sensitive tool. When false,
|
|
28
|
+
* it denies `high_stakes` only. Overrides `client.strictFailClosed` when set.
|
|
29
|
+
* Env: `OKED_STRICT_FAIL_CLOSED=1`.
|
|
30
|
+
*/
|
|
31
|
+
strictFailClosed?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Lowest tier that waits for a person. Default `review` (env `OKED_MIN_TIER`).
|
|
34
|
+
* `safe` and `warning` still run locally unless `alwaysApprove` lists the
|
|
35
|
+
* tool or a saved rule matches.
|
|
36
|
+
*/
|
|
37
|
+
minTier?: RiskTier;
|
|
38
|
+
/** Tool names that always wait for a person, whatever the classifier says. */
|
|
39
|
+
alwaysApprove?: readonly string[];
|
|
40
|
+
/** Tool names that always run, with no approval request. Wins over `alwaysApprove`. */
|
|
41
|
+
alwaysAllow?: readonly string[];
|
|
42
|
+
/** Working directory recorded on the approval. Default `process.cwd()`. */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Receipt session id. When omitted, the AI SDK tool call id is used so
|
|
46
|
+
* each call can be told apart.
|
|
47
|
+
*/
|
|
48
|
+
sessionId?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Receipt subject source. Default `ai-sdk`. Used only when `client` is omitted.
|
|
51
|
+
*/
|
|
52
|
+
source?: string;
|
|
53
|
+
/** Line logger. Default writes `[OKed] ...` to stderr. The message has no prefix. */
|
|
54
|
+
log?: (message: string) => void;
|
|
55
|
+
}
|
|
56
|
+
/** What the gate decided. Local allows are not recorded as approvals. */
|
|
57
|
+
export type OkedGateResult = {
|
|
58
|
+
action: "allow";
|
|
59
|
+
recorded: false;
|
|
60
|
+
} | {
|
|
61
|
+
action: "allow";
|
|
62
|
+
recorded: true;
|
|
63
|
+
reason: string;
|
|
64
|
+
} | {
|
|
65
|
+
action: "deny";
|
|
66
|
+
decision: "denied" | "timeout" | "error";
|
|
67
|
+
approvalId: string | null;
|
|
68
|
+
reason: string;
|
|
69
|
+
tier: RiskTier;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Value returned from a wrapped `execute` when the tool did not run.
|
|
73
|
+
* `reason` is the text to show the model.
|
|
74
|
+
*/
|
|
75
|
+
export interface OkedDenial {
|
|
76
|
+
oked: "denied";
|
|
77
|
+
decision: "denied" | "timeout" | "error";
|
|
78
|
+
approvalId: string | null;
|
|
79
|
+
reason: string;
|
|
80
|
+
tier: RiskTier;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Subset of an AI SDK tool call. AI SDK 7 uses `input`. `args` is accepted
|
|
84
|
+
* when `input` is missing.
|
|
85
|
+
*/
|
|
86
|
+
export interface OkedToolCall {
|
|
87
|
+
toolName: string;
|
|
88
|
+
input?: unknown;
|
|
89
|
+
args?: unknown;
|
|
90
|
+
toolCallId?: string;
|
|
91
|
+
dynamic?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Arguments of an AI SDK 7 `GenericToolApprovalFunction`. Extra fields the
|
|
95
|
+
* SDK passes (`tools`, `messages`, `runtimeContext`) are ignored.
|
|
96
|
+
*/
|
|
97
|
+
export interface OkedToolApprovalArgs {
|
|
98
|
+
toolCall?: OkedToolCall;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Status object AI SDK 7 accepts from `toolApproval`. This package never
|
|
102
|
+
* returns `user-approval`: that ends the generation and leaves the tool
|
|
103
|
+
* runnable on the next call.
|
|
104
|
+
*/
|
|
105
|
+
export type OkedToolApprovalResult = undefined | {
|
|
106
|
+
type: "approved";
|
|
107
|
+
reason?: string;
|
|
108
|
+
} | {
|
|
109
|
+
type: "denied";
|
|
110
|
+
reason: string;
|
|
111
|
+
};
|
|
112
|
+
export type OkedToolApprovalFunction = (args: OkedToolApprovalArgs) => Promise<OkedToolApprovalResult>;
|
|
113
|
+
/** Per-tool `toolApproval` callback. The tool name is bound by the caller. */
|
|
114
|
+
export type OkedSingleToolApproval = (input: unknown, options?: {
|
|
115
|
+
toolCallId?: string;
|
|
116
|
+
}) => Promise<OkedToolApprovalResult>;
|
|
117
|
+
export type ToolExecute = (input: unknown, options?: unknown) => unknown;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oked/ai-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.21",
|
|
4
|
+
"description": "OKed for the Vercel AI SDK. Risky tool calls wait for a person in Telegram or on the web dashboard before they run.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prebuild": "npm run build --workspace=@oked/sdk",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"test": "npm run build && node --test src/gate.test.mjs",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"ai": ">=5.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"ai": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@oked/sdk": "0.6.21"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.13.10",
|
|
39
|
+
"typescript": "^5.6.0"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"oked",
|
|
43
|
+
"ai-agent",
|
|
44
|
+
"approval",
|
|
45
|
+
"human-in-the-loop",
|
|
46
|
+
"hooks",
|
|
47
|
+
"ai-sdk",
|
|
48
|
+
"vercel",
|
|
49
|
+
"tool-calling"
|
|
50
|
+
],
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/oked-ai/oked-sdk.git",
|
|
54
|
+
"directory": "packages/ai-sdk"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/oked-ai/oked-sdk/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://oked.ai",
|
|
60
|
+
"license": "Apache-2.0",
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=18"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|