@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 +21 -0
- package/README.md +107 -0
- package/dist/classify.d.ts +17 -0
- package/dist/classify.js +454 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +36 -0
- package/dist/degraded.d.ts +19 -0
- package/dist/degraded.js +25 -0
- package/dist/describe.d.ts +23 -0
- package/dist/describe.js +899 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +28 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +268 -0
- package/dist/kinds.d.ts +10 -0
- package/dist/kinds.js +9 -0
- package/dist/rules.d.ts +97 -0
- package/dist/rules.js +105 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/package.json +51 -0
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
|
+
[](https://www.npmjs.com/package/@oked/sdk)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](./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[];
|
package/dist/classify.js
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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";
|