@oked/sdk 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 OKed
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,107 @@
1
+ # @oked/sdk
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@oked/sdk.svg)](https://www.npmjs.com/package/@oked/sdk)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
5
+ [![Types: included](https://img.shields.io/npm/types/@oked/sdk.svg)](./dist/index.d.ts)
6
+
7
+ Core library - programmatic approval API for AI agents. Sends sensitive actions to the OKed backend, waits for a human decision, and resolves only when the user approves or denies.
8
+
9
+ Use this package directly from any Node.js agent (OpenAI SDK, LangChain, custom). For drop-in integrations, see [`@oked/claude-code`](../claude-code) or [`@oked/openclaw`](../openclaw).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @oked/sdk
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```ts
20
+ import { OKedClient } from "@oked/sdk";
21
+
22
+ const oked = new OKedClient({
23
+ apiKey: process.env.OKED_API_KEY,
24
+ });
25
+
26
+ const result = await oked.approve({
27
+ action: "deploy",
28
+ description: "Deploy release 2026.04.07 to production",
29
+ tier: "high_stakes",
30
+ session_id: "deploy-123",
31
+ cwd: process.cwd(),
32
+ });
33
+
34
+ if (!result.approved) {
35
+ throw new Error(`Blocked by OKed: ${result.decision}`);
36
+ }
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ Pass to `new OKedClient(config)`:
42
+
43
+ | Key | Type | Default | Description |
44
+ |---|---|---|---|
45
+ | `apiKey` | `string` | `OKED_API_KEY` env | Your OKed API key. Required. |
46
+ | `backendUrl` | `string` | `https://api.oked.ai` | Override the OKed backend URL. |
47
+ | `timeout` | `number` | `300000` | Per-approval timeout in ms. |
48
+ | `strictFailClosed` | `boolean` | `false` | When true, backend outages deny every tier. When false, outages deny only `high_stakes` and allow lower tiers. |
49
+
50
+ ## API
51
+
52
+ ### `approve(request)`
53
+
54
+ Request an approval for a sensitive action. Resolves when the user responds, the request times out, or the backend denies.
55
+
56
+ Request fields:
57
+
58
+ | Field | Type | Required | Description |
59
+ |---|---|---|---|
60
+ | `action` | `string` | yes | Short action identifier (e.g. `"deploy"`, `"send_email"`). |
61
+ | `description` | `string` | yes | Human-readable summary shown in the approval UI. |
62
+ | `tier` | `"safe" \| "warning" \| "review" \| "high_stakes"` | yes | Risk tier. Use `classify()` if unsure. |
63
+ | `tool_input` | `unknown` | no | Raw tool arguments, included in the audit log. |
64
+ | `session_id` | `string` | no | Groups related approvals under one session. |
65
+ | `cwd` | `string` | no | Working directory, shown in the UI for context. |
66
+
67
+ Response fields:
68
+
69
+ | Field | Type | Description |
70
+ |---|---|---|
71
+ | `approved` | `boolean` | `true` only when the user approved. |
72
+ | `approval_id` | `string` | Opaque id for logs / audit. |
73
+ | `decision` | `"approved" \| "denied" \| "timeout"` | Exact outcome. |
74
+
75
+ ### `ping()`
76
+
77
+ Returns `true` when the backend is reachable. Use for startup health checks.
78
+
79
+ ### Helpers
80
+
81
+ - `classify(input)` - classifies a shell command or tool call into a risk tier.
82
+ - `describe(input)` - generates a human-readable description for the approval UI.
83
+
84
+ Full type definitions ship with the package (`dist/index.d.ts`).
85
+
86
+ ## Environment
87
+
88
+ | Var | Required | Description |
89
+ |---|---|---|
90
+ | `OKED_API_KEY` | yes, unless passed in code | Your OKed API key. |
91
+ | `OKED_BACKEND_URL` | no | Override the hosted backend URL. |
92
+ | `OKED_STRICT_FAIL_CLOSED` | no | Set to `1` or `true` to deny every sensitive action when the backend is unreachable. |
93
+
94
+ ## Degraded-mode behavior
95
+
96
+ Explicit user denials return `{ approved: false }` and should always be treated as final. Invalid API keys throw `OKedAuthError` and should deny. If the backend is unreachable, `OKedBackendUnreachableError` lets integrations apply degraded mode: `high_stakes` denies, while lower tiers may proceed unless `strictFailClosed` is enabled. Unexpected errors should be treated as deny.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ npm install
102
+ npm run build
103
+ ```
104
+
105
+ ## License
106
+
107
+ [MIT](./LICENSE)
@@ -0,0 +1,17 @@
1
+ import type { RiskTier } from "./types.js";
2
+ export declare function classify(toolName: string, toolInput: Record<string, unknown>): RiskTier;
3
+ export type ShellWriteKind = "create" | "append" | "edit" | "touch" | "copy" | "move";
4
+ export interface ShellWriteOp {
5
+ kind: ShellWriteKind;
6
+ target: string;
7
+ source?: string;
8
+ content?: string;
9
+ }
10
+ /**
11
+ * Detects shell idioms that mutate the filesystem. Used by classify (to
12
+ * choose tier) and describe (to render the operation as a Create/Append/
13
+ * Copy/etc. sentence rather than as a shell command).
14
+ *
15
+ * Skips /dev/null and bare-digit FD duplicates (2>&1).
16
+ */
17
+ export declare function extractShellWriteOps(command: string): ShellWriteOp[];
@@ -0,0 +1,454 @@
1
+ import path from "path";
2
+ import { findSqlInCommand } from "./describe.js";
3
+ import { TIER_ORDER } from "./degraded.js";
4
+ // Tier 1 - safe: auto-allow, no notification (Read, Glob, ls, git status, etc.)
5
+ // Tier 2 - warning: terminal log only, no push (Write/Edit inside project dir)
6
+ // Tier 3 - review: push notification required (Write/Edit outside project, unknown bash)
7
+ // Tier 4 - high_stakes: push + number matching (rm -rf, git push, DROP TABLE, etc.)
8
+ // Tools that are always safe (read-only, no side effects)
9
+ const SAFE_TOOLS = new Set([
10
+ "Read",
11
+ "Glob",
12
+ "Grep",
13
+ "WebSearch",
14
+ "TodoRead",
15
+ "TaskGet",
16
+ "TaskList",
17
+ ]);
18
+ // Read-only tools from non-Claude-Code agents (OpenClaw `read`/`list`,
19
+ // Codex `read_file`, etc.). Claude Code's own Read/Glob/Grep are covered by
20
+ // SAFE_TOOLS above; this catches the same read-only operations under other
21
+ // agents' (often lowercase) names so they don't fall through to `review`.
22
+ const SAFE_TOOL_ALIASES = new Set([
23
+ "read",
24
+ "read_file",
25
+ "readfile",
26
+ "view",
27
+ "cat",
28
+ "list",
29
+ "list_files",
30
+ "listfiles",
31
+ "ls",
32
+ "glob",
33
+ "grep",
34
+ "search",
35
+ "search_files",
36
+ "find",
37
+ ]);
38
+ // Tools that are always review-tier risk (modify local files)
39
+ const REVIEW_TOOLS = new Set([]);
40
+ // Tools that are always high stakes
41
+ const HIGH_STAKES_TOOLS = new Set([]);
42
+ // Bash commands classified as safe (read-only, informational)
43
+ const SAFE_COMMANDS = [
44
+ /^ls\b/,
45
+ /^pwd$/,
46
+ /^echo\b/,
47
+ /^cat\b/,
48
+ /^head\b/,
49
+ /^tail\b/,
50
+ /^wc\b/,
51
+ /^date$/,
52
+ /^whoami$/,
53
+ /^which\b/,
54
+ /^type\b/,
55
+ /^file\b/,
56
+ /^stat\b/,
57
+ /^du\b/,
58
+ /^df\b/,
59
+ /^uname\b/,
60
+ /^env$/,
61
+ /^printenv\b/,
62
+ /^node\s+(-v|--version)/,
63
+ /^npm\s+(list|ls|--version|-v|view|info|outdated|audit)\b/,
64
+ /^npx\s+-v/,
65
+ /^git\s+(status|log|diff|branch|remote|show|tag|stash list)\b/,
66
+ /^docker\s+(ps|images|inspect|logs)\b/,
67
+ /^docker\s+compose\s+(ps|logs)\b/,
68
+ /^tree\b/,
69
+ /^find\b/,
70
+ /^grep\b/,
71
+ /^rg\b/,
72
+ /^fd\b/,
73
+ /^jq\b/,
74
+ /^curl\s+-s.*\|\s*(jq|python|node)/, // curl piped to parser (usually read-only)
75
+ /^curl\b/, // curl without -X defaults to GET (high-stakes patterns checked first)
76
+ // himalaya (email CLI) read-only ops. Listing/reading mail or folder state
77
+ // never mutates anything remotely. Only `message send|reply|forward` and
78
+ // `message delete` / `folder delete|expunge|purge` matter; those land in
79
+ // the review/high-stakes paths.
80
+ /^himalaya\s+(account|folder|envelope|message\s+(?:read|export|search|copy|move)|attachment\s+(?:download|list)|template|search)\b/,
81
+ ];
82
+ // Bash commands classified as high stakes (destructive, irreversible, external)
83
+ const HIGH_STAKES_COMMANDS = [
84
+ /\brm\b/,
85
+ /\brm\b\s+(?:-[^\s]*[rf][^\s]*\s+)*-[^\s]*[rf][^\s]*\b/,
86
+ /\brm\s+--recursive\b/,
87
+ /\brm\b.*\s+\//, // rm with absolute path
88
+ /\brmdir\b/,
89
+ /\btrash\b/,
90
+ /\btrash-put\b/,
91
+ /\bgit\s+push\b/,
92
+ /\bgit\s+reset\s+--hard\b/,
93
+ /\bgit\s+clean\s+-f/,
94
+ /\bgit\s+checkout\s+--\s+\./,
95
+ /\bgit\s+restore\s+--staged\s+\./,
96
+ /\bDROP\s+(TABLE|DATABASE|INDEX|VIEW)\b/i,
97
+ /\bDELETE\s+FROM\b/i,
98
+ /\bTRUNCATE\b/i,
99
+ /\bdocker\s+(rm|rmi|system\s+prune)\b/,
100
+ /\bdocker\s+compose\s+down\b/,
101
+ /\bkill\b/,
102
+ /\bpkill\b/,
103
+ /\bkillall\b/,
104
+ /\bchmod\s+777\b/,
105
+ /\bcurl\s+.*-X\s*(DELETE|PUT|POST|PATCH)\b/i,
106
+ /\bcurl\s+.*--request\s*(DELETE|PUT|POST|PATCH)\b/i,
107
+ // curl flags that send a request body (POST/PUT/PATCH) without an explicit -X
108
+ /\bcurl\b.*\s-d[\s=]/,
109
+ /\bcurl\b.*\s--data(-raw|-binary|-urlencode|-ascii)?[\s=]/,
110
+ /\bcurl\b.*\s-F[\s=]/,
111
+ /\bcurl\b.*\s--form[\s=]/,
112
+ /\bcurl\b.*\s(-T|--upload-file)[\s=]/,
113
+ /\bcurl\b.*\s--json[\s=]/,
114
+ /\bwget\s+.*\|\s*(bash|sh|zsh)\b/,
115
+ /\bcurl\s+.*\|\s*(bash|sh|zsh)\b/,
116
+ /\bnpm\s+publish\b/,
117
+ /\bnpm\s+unpublish\b/,
118
+ /\bnpx\s+.*\s+deploy\b/,
119
+ // ssh to a remote host. Effects on the remote side cannot be undone from
120
+ // here, so treat every interactive/remote-exec ssh as high_stakes. Matches
121
+ // `ssh user@host ...` and `ssh -i key.pem ubuntu@1.2.3.4 ...`. The
122
+ // \bssh\s+ prefix (whitespace required) excludes `ssh-keygen`/`ssh-add`/
123
+ // `ssh-keyscan` which are local and reversible.
124
+ /\bssh\s+(?:\S+\s+)*\S+@\S+/,
125
+ // himalaya destructive ops. message delete + folder delete/expunge/purge
126
+ // wipe mail from the server irreversibly; account delete wipes local config.
127
+ /\bhimalaya\s+message\s+delete\b/,
128
+ /\bhimalaya\s+folder\s+(delete|expunge|purge)\b/,
129
+ /\bhimalaya\s+account\s+delete\b/,
130
+ ];
131
+ // Ephemeral filesystem locations. Writes here have no lasting effect on
132
+ // their own — what matters is whatever subsequent command CONSUMES the file
133
+ // (e.g. `himalaya message send < /tmp/draft.eml`). Without this carve-out,
134
+ // every multi-step skill that drafts a temp file generates two approval
135
+ // prompts (the temp write + the real send) instead of one.
136
+ const EPHEMERAL_PATH_RE = /^(?:\/tmp\/|\/var\/tmp\/|\/private\/tmp\/|[A-Za-z]:[\\/](?:Windows[\\/]Temp|Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp)[\\/])/i;
137
+ function isEphemeralPath(filePath) {
138
+ if (!filePath)
139
+ return false;
140
+ return EPHEMERAL_PATH_RE.test(filePath);
141
+ }
142
+ function isInsideProject(filePath) {
143
+ if (!filePath)
144
+ return false;
145
+ try {
146
+ const projectRoot = path.resolve(process.cwd());
147
+ const resolved = path.resolve(filePath);
148
+ const relative = path.relative(projectRoot, resolved);
149
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ export function classify(toolName, toolInput) {
156
+ // Check tool-level classification first
157
+ if (SAFE_TOOLS.has(toolName))
158
+ return "safe";
159
+ if (SAFE_TOOL_ALIASES.has(toolName.toLowerCase()))
160
+ return "safe";
161
+ if (HIGH_STAKES_TOOLS.has(toolName))
162
+ return "high_stakes";
163
+ if (REVIEW_TOOLS.has(toolName))
164
+ return "review";
165
+ // File-editing tools: warning if inside project or an ephemeral temp dir,
166
+ // review otherwise. Temp-dir writes are "warning" because the file itself
167
+ // can't do harm — only what consumes it can.
168
+ if (toolName === "Write" || toolName === "Edit" || toolName === "NotebookEdit") {
169
+ const filePath = toolInput.file_path;
170
+ if (isEphemeralPath(filePath) || isInsideProject(filePath))
171
+ return "warning";
172
+ return "review";
173
+ }
174
+ // Agent tool - review (spawns subagent, not directly destructive)
175
+ if (toolName === "Agent")
176
+ return "review";
177
+ // Bash commands need deeper analysis
178
+ if (toolName === "Bash") {
179
+ return classifyBashCommand(toolInput.command);
180
+ }
181
+ // MCP tools: mcp__<server>__<tool>
182
+ if (toolName.startsWith("mcp__")) {
183
+ return classifyMcpTool(toolName);
184
+ }
185
+ // Shell-exec-style tools from other agents (Codex CLI's `exec`, OpenClaw,
186
+ // generic `run`/`run_command`/`shell`). Detect by signature: a string
187
+ // command/cmd field. Route through bash classification.
188
+ const shellCommand = (toolInput.command ?? toolInput.cmd);
189
+ if (typeof shellCommand === "string") {
190
+ return classifyBashCommand(shellCommand);
191
+ }
192
+ // File-write-style tools from other agents (OpenClaw `write`, generic
193
+ // `create_file`, `file_write`). Detect by signature: string path field
194
+ // alongside string content/data field. Treat like Write.
195
+ const writePath = (toolInput.file_path ?? toolInput.path);
196
+ const writeContent = (toolInput.content ?? toolInput.data ?? toolInput.body);
197
+ if (typeof writePath === "string" && typeof writeContent === "string") {
198
+ if (isEphemeralPath(writePath) || isInsideProject(writePath))
199
+ return "warning";
200
+ return "review";
201
+ }
202
+ // Unknown tool - default to review (require approval)
203
+ return "review";
204
+ }
205
+ function maxTier(a, b) {
206
+ return TIER_ORDER[a] >= TIER_ORDER[b] ? a : b;
207
+ }
208
+ /** Split a shell command on top-level pipe characters, ignoring `||` and
209
+ * pipes inside quoted strings. Returns trimmed segments. */
210
+ function splitOnPipe(cmd) {
211
+ const out = [];
212
+ let cur = "";
213
+ let quote = null;
214
+ for (let i = 0; i < cmd.length; i++) {
215
+ const ch = cmd[i];
216
+ if (quote) {
217
+ cur += ch;
218
+ if (ch === quote)
219
+ quote = null;
220
+ }
221
+ else if (ch === '"' || ch === "'") {
222
+ cur += ch;
223
+ quote = ch;
224
+ }
225
+ else if (ch === "|" && cmd[i + 1] !== "|" && cmd[i - 1] !== "|") {
226
+ out.push(cur.trim());
227
+ cur = "";
228
+ }
229
+ else {
230
+ cur += ch;
231
+ }
232
+ }
233
+ if (cur.trim())
234
+ out.push(cur.trim());
235
+ return out;
236
+ }
237
+ function classifyBashCommand(command) {
238
+ if (!command)
239
+ return "safe";
240
+ const trimmed = command.trim();
241
+ // Pipelines: classify each stage and take the highest tier. Without this,
242
+ // `cat /tmp/draft.eml | himalaya message send` would match `^cat\b` first
243
+ // and silently allow the email send. The right-hand stage is what matters.
244
+ // Only split when there are 2+ stages so single commands don't recurse.
245
+ const stages = splitOnPipe(trimmed);
246
+ if (stages.length > 1) {
247
+ return stages.reduce((worst, stage) => maxTier(worst, classifyBashCommand(stage)), "safe");
248
+ }
249
+ // sudo: classify based on the inner command, not sudo itself
250
+ if (/^sudo\s/.test(trimmed)) {
251
+ const inner = trimmed.replace(/^sudo\s+/, "");
252
+ for (const pattern of HIGH_STAKES_COMMANDS) {
253
+ if (pattern.test(inner))
254
+ return "high_stakes";
255
+ }
256
+ return "review";
257
+ }
258
+ // SQL hidden inside an interpreter wrapper (python -c, node -e, heredoc),
259
+ // a DB CLI (psql -c, sqlite3 db "...", mysql -e), or at the top of the
260
+ // command. Severity comes from the statement, not the wrapper.
261
+ const sql = findSqlInCommand(trimmed);
262
+ if (sql)
263
+ return classifySqlSeverity(sql);
264
+ // Check high stakes first (most restrictive wins)
265
+ for (const pattern of HIGH_STAKES_COMMANDS) {
266
+ if (pattern.test(trimmed))
267
+ return "high_stakes";
268
+ }
269
+ // File-mutating shell patterns. Content-creation idioms (echo > X, tee,
270
+ // dd of=, touch, sed -i, heredoc) require approval — they're exactly the
271
+ // bypass route from a denied Write. cp/mv just rearrange existing bytes
272
+ // and stay safe. Writes to ephemeral temp dirs (/tmp, %TEMP%) downgrade
273
+ // to warning: the temp file alone can't do harm, only what consumes it.
274
+ const ops = extractShellWriteOps(trimmed);
275
+ if (ops.length > 0) {
276
+ const creates = ops.filter((o) => o.kind !== "copy" && o.kind !== "move");
277
+ if (creates.length > 0) {
278
+ if (creates.every((o) => isEphemeralPath(o.target)))
279
+ return "warning";
280
+ return "review";
281
+ }
282
+ return "review";
283
+ }
284
+ // Check safe patterns
285
+ for (const pattern of SAFE_COMMANDS) {
286
+ if (pattern.test(trimmed))
287
+ return "safe";
288
+ }
289
+ // Default: review (require approval for unknown commands)
290
+ return "review";
291
+ }
292
+ function classifySqlSeverity(sql) {
293
+ if (/\bDROP\s+(TABLE|DATABASE|INDEX|VIEW)\b/i.test(sql))
294
+ return "high_stakes";
295
+ if (/\bTRUNCATE\b/i.test(sql))
296
+ return "high_stakes";
297
+ if (/\bDELETE\s+FROM\b/i.test(sql))
298
+ return "high_stakes";
299
+ if (/\bUPDATE\s+\w+\s+SET\b/i.test(sql) && !/\bWHERE\b/i.test(sql))
300
+ return "high_stakes";
301
+ if (/\bCREATE\s+(TABLE|INDEX|VIEW)\b/i.test(sql))
302
+ return "warning";
303
+ if (/^\s*SELECT\b/i.test(sql))
304
+ return "safe";
305
+ if (/^\s*(EXPLAIN|SHOW|DESCRIBE|DESC)\b/i.test(sql))
306
+ return "safe";
307
+ // SQLite dot-commands (.tables, .schema, .dump, etc.) — safe unless they mutate
308
+ if (/^\s*\./.test(sql) && !/^\s*\.(import|read|restore)\b/i.test(sql))
309
+ return "safe";
310
+ return "review";
311
+ }
312
+ /**
313
+ * Detects shell idioms that mutate the filesystem. Used by classify (to
314
+ * choose tier) and describe (to render the operation as a Create/Append/
315
+ * Copy/etc. sentence rather than as a shell command).
316
+ *
317
+ * Skips /dev/null and bare-digit FD duplicates (2>&1).
318
+ */
319
+ export function extractShellWriteOps(command) {
320
+ const cmd = command.trim();
321
+ const ops = [];
322
+ // Output redirects: > path, >> path, &> path, 2> path. Try to pull the
323
+ // literal content when the LHS is echo/printf.
324
+ const redirRe = /(?:^|[^>])([12]?>>?|&>>?)\s*([^\s>|&;]+)/g;
325
+ for (const m of cmd.matchAll(redirRe)) {
326
+ const op = m[1];
327
+ const target = unquote(m[2]);
328
+ if (!target || /^\d+$/.test(target) || isDevNullish(target))
329
+ continue;
330
+ const append = op === ">>" || op === "&>>";
331
+ const content = extractEchoContent(cmd);
332
+ ops.push({ kind: append ? "append" : "create", target, content });
333
+ }
334
+ // tee [-a] path
335
+ const teeM = cmd.match(/\btee\b\s+(-[aA]\s+)?([^\s|;&]+)/);
336
+ if (teeM) {
337
+ const target = unquote(teeM[2]);
338
+ if (target && !isDevNullish(target) && !target.startsWith("-")) {
339
+ ops.push({ kind: teeM[1] ? "append" : "create", target });
340
+ }
341
+ }
342
+ // cp src dest
343
+ const cpM = cmd.match(/^\s*cp\b\s+(.+)$/);
344
+ if (cpM) {
345
+ const args = splitArgs(cpM[1]).filter((a) => !a.startsWith("-"));
346
+ if (args.length >= 2) {
347
+ ops.push({ kind: "copy", target: unquote(args[args.length - 1]), source: unquote(args[0]) });
348
+ }
349
+ }
350
+ // mv src dest
351
+ const mvM = cmd.match(/^\s*mv\b\s+(.+)$/);
352
+ if (mvM) {
353
+ const args = splitArgs(mvM[1]).filter((a) => !a.startsWith("-"));
354
+ if (args.length >= 2) {
355
+ ops.push({ kind: "move", target: unquote(args[args.length - 1]), source: unquote(args[0]) });
356
+ }
357
+ }
358
+ // sed -i
359
+ if (/\bsed\b/.test(cmd) && /-i(?:\.\w+)?\b/.test(cmd)) {
360
+ const sedM = cmd.match(/^\s*sed\b\s+(.+)$/);
361
+ if (sedM) {
362
+ const args = splitArgs(sedM[1]);
363
+ let scriptSeen = false;
364
+ for (const a of args) {
365
+ if (a.startsWith("-"))
366
+ continue;
367
+ if (!scriptSeen) {
368
+ scriptSeen = true;
369
+ continue;
370
+ }
371
+ ops.push({ kind: "edit", target: unquote(a) });
372
+ }
373
+ }
374
+ }
375
+ // dd of=path
376
+ const ddRe = /\bdd\b[^|;&]*\bof=([^\s|;&]+)/g;
377
+ for (const m of cmd.matchAll(ddRe)) {
378
+ const target = unquote(m[1]);
379
+ if (target && !isDevNullish(target))
380
+ ops.push({ kind: "create", target });
381
+ }
382
+ // touch path1 path2...
383
+ const touchM = cmd.match(/^\s*touch\b\s+(.+)$/);
384
+ if (touchM) {
385
+ for (const a of splitArgs(touchM[1])) {
386
+ if (!a.startsWith("-"))
387
+ ops.push({ kind: "touch", target: unquote(a) });
388
+ }
389
+ }
390
+ return ops;
391
+ }
392
+ function extractEchoContent(cmd) {
393
+ // echo [-neE] "content" > path / printf "content" > path
394
+ const m = cmd.match(/^\s*(?:echo|printf)\b\s+(?:-[neE]+\s+)?(.+?)\s*(?:[12]?>>?|&>>?)/);
395
+ if (!m)
396
+ return undefined;
397
+ const raw = m[1].trim();
398
+ if (!raw)
399
+ return undefined;
400
+ return unquote(raw);
401
+ }
402
+ function unquote(s) {
403
+ return s.replace(/^['"]|['"]$/g, "");
404
+ }
405
+ function isDevNullish(p) {
406
+ return p === "/dev/null" || p === "/dev/stdout" || p === "/dev/stderr";
407
+ }
408
+ function splitArgs(s) {
409
+ // Simple whitespace split honoring single/double quoted strings.
410
+ const out = [];
411
+ let cur = "";
412
+ let quote = null;
413
+ for (const ch of s) {
414
+ if (quote) {
415
+ if (ch === quote)
416
+ quote = null;
417
+ else
418
+ cur += ch;
419
+ }
420
+ else if (ch === '"' || ch === "'") {
421
+ quote = ch;
422
+ }
423
+ else if (/\s/.test(ch)) {
424
+ if (cur) {
425
+ out.push(cur);
426
+ cur = "";
427
+ }
428
+ }
429
+ else {
430
+ cur += ch;
431
+ }
432
+ }
433
+ if (cur)
434
+ out.push(cur);
435
+ return out;
436
+ }
437
+ function classifyMcpTool(toolName) {
438
+ const parts = toolName.split("__");
439
+ const tool = parts[parts.length - 1] || "";
440
+ if (tool.startsWith("list_") || tool.startsWith("get_") || tool.startsWith("search_")) {
441
+ return "safe";
442
+ }
443
+ if (tool.endsWith("_status") || tool.endsWith("_info") || tool.endsWith("_count") ||
444
+ tool.endsWith("_exists") || tool.endsWith("_version") || tool.endsWith("_health")) {
445
+ return "safe";
446
+ }
447
+ if (tool.startsWith("delete_") || tool.startsWith("drop_") || tool.startsWith("remove_")) {
448
+ return "high_stakes";
449
+ }
450
+ if (tool.startsWith("send_") || tool.startsWith("create_") || tool.startsWith("update_")) {
451
+ return "review";
452
+ }
453
+ return "review";
454
+ }
@@ -0,0 +1,18 @@
1
+ export interface PersistedConfig {
2
+ apiKey?: string;
3
+ backendUrl?: string;
4
+ strictFailClosed?: boolean;
5
+ /** TTL (seconds) for the on-disk rules cache. See OKedConfig.rulesCacheTtlMs. */
6
+ rulesCacheTtlSeconds?: number;
7
+ /** Min interval (seconds) between heartbeats. See OKedConfig.heartbeatIntervalMs. */
8
+ heartbeatIntervalSeconds?: number;
9
+ }
10
+ export declare const OKED_CONFIG_PATH: string;
11
+ export declare const OKED_RULES_CACHE_PATH: string;
12
+ export declare const OKED_HEARTBEAT_PATH: string;
13
+ /**
14
+ * Read ~/.oked/config.json if present. Returns {} on any error (missing file,
15
+ * malformed JSON, no home dir). Callers should treat this as a best-effort
16
+ * lookup behind explicit arguments and environment variables.
17
+ */
18
+ export declare function loadOKedConfig(): PersistedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ export const OKED_CONFIG_PATH = join(homedir(), ".oked", "config.json");
5
+ export const OKED_RULES_CACHE_PATH = join(homedir(), ".oked", "rules-cache.json");
6
+ export const OKED_HEARTBEAT_PATH = join(homedir(), ".oked", "heartbeat.json");
7
+ /**
8
+ * Read ~/.oked/config.json if present. Returns {} on any error (missing file,
9
+ * malformed JSON, no home dir). Callers should treat this as a best-effort
10
+ * lookup behind explicit arguments and environment variables.
11
+ */
12
+ export function loadOKedConfig() {
13
+ try {
14
+ const raw = readFileSync(OKED_CONFIG_PATH, "utf-8");
15
+ const parsed = JSON.parse(raw);
16
+ if (!parsed || typeof parsed !== "object")
17
+ return {};
18
+ const out = {};
19
+ if (typeof parsed.apiKey === "string" && parsed.apiKey)
20
+ out.apiKey = parsed.apiKey;
21
+ if (typeof parsed.backendUrl === "string" && parsed.backendUrl)
22
+ out.backendUrl = parsed.backendUrl;
23
+ if (typeof parsed.strictFailClosed === "boolean")
24
+ out.strictFailClosed = parsed.strictFailClosed;
25
+ if (typeof parsed.rulesCacheTtlSeconds === "number" && parsed.rulesCacheTtlSeconds >= 0) {
26
+ out.rulesCacheTtlSeconds = parsed.rulesCacheTtlSeconds;
27
+ }
28
+ if (typeof parsed.heartbeatIntervalSeconds === "number" && parsed.heartbeatIntervalSeconds >= 0) {
29
+ out.heartbeatIntervalSeconds = parsed.heartbeatIntervalSeconds;
30
+ }
31
+ return out;
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ import type { RiskTier } from "./types.js";
2
+ /** Severity ordering for risk tiers. Higher = more dangerous. */
3
+ export declare const TIER_ORDER: Record<RiskTier, number>;
4
+ /**
5
+ * Decide what to do with a sensitive action when the OKed backend is
6
+ * unreachable (NOT when the user explicitly denied — that is always honored,
7
+ * and NOT for auth errors — those always deny).
8
+ *
9
+ * strictFailClosed === true -> "deny" (original fail-safe: deny everything)
10
+ * otherwise -> "deny" iff tier is high_stakes,
11
+ * "allow" for every lower tier.
12
+ *
13
+ * Rationale: a single backend outage should not mass-abort every user's
14
+ * agent, but an irreversible action (rm -rf, payments, drops, force-push)
15
+ * must never slip through unsupervised because of a network blip.
16
+ */
17
+ export declare function degradedDecision(tier: RiskTier, opts: {
18
+ strictFailClosed?: boolean;
19
+ }): "allow" | "deny";