@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/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors thrown by OKedClient.approve() so callers can tell *why* a
|
|
3
|
+
* request failed and apply the right policy:
|
|
4
|
+
*
|
|
5
|
+
* - OKedAuthError -> bad/missing API key. Always deny. Never an
|
|
6
|
+
* outage, so degraded-mode does NOT apply.
|
|
7
|
+
* - OKedBackendUnreachableError -> connect failure, timeout, or 5xx. This is
|
|
8
|
+
* the outage case degradedDecision() handles.
|
|
9
|
+
*
|
|
10
|
+
* An explicit user *deny* is NOT an error: approve() returns normally with
|
|
11
|
+
* { approved: false, decision: "denied" } and must always be honored.
|
|
12
|
+
*/
|
|
13
|
+
export declare class OKedAuthError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
constructor(message: string, status?: number);
|
|
16
|
+
}
|
|
17
|
+
export declare class OKedBackendUnreachableError extends Error {
|
|
18
|
+
readonly cause?: unknown;
|
|
19
|
+
constructor(message: string, cause?: unknown);
|
|
20
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors thrown by OKedClient.approve() so callers can tell *why* a
|
|
3
|
+
* request failed and apply the right policy:
|
|
4
|
+
*
|
|
5
|
+
* - OKedAuthError -> bad/missing API key. Always deny. Never an
|
|
6
|
+
* outage, so degraded-mode does NOT apply.
|
|
7
|
+
* - OKedBackendUnreachableError -> connect failure, timeout, or 5xx. This is
|
|
8
|
+
* the outage case degradedDecision() handles.
|
|
9
|
+
*
|
|
10
|
+
* An explicit user *deny* is NOT an error: approve() returns normally with
|
|
11
|
+
* { approved: false, decision: "denied" } and must always be honored.
|
|
12
|
+
*/
|
|
13
|
+
export class OKedAuthError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
constructor(message, status = 401) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "OKedAuthError";
|
|
18
|
+
this.status = status;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class OKedBackendUnreachableError extends Error {
|
|
22
|
+
cause;
|
|
23
|
+
constructor(message, cause) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "OKedBackendUnreachableError";
|
|
26
|
+
this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ApprovalRequest, ApprovalResponse, OKedConfig } from "./types.js";
|
|
2
|
+
import type { Rule } from "./rules.js";
|
|
3
|
+
export declare class OKedClient {
|
|
4
|
+
private config;
|
|
5
|
+
constructor(config?: Partial<OKedConfig>);
|
|
6
|
+
get strictFailClosed(): boolean;
|
|
7
|
+
get apiKey(): string;
|
|
8
|
+
get backendUrl(): string;
|
|
9
|
+
approve(request: ApprovalRequest): Promise<ApprovalResponse>;
|
|
10
|
+
/**
|
|
11
|
+
* User rules for local escalation checks, cached on disk (TTL
|
|
12
|
+
* `rulesCacheTtlMs`, default 5 min). The Claude Code hook is a fresh
|
|
13
|
+
* process per call, so caching must be on disk. Never throws: a failed
|
|
14
|
+
* fetch falls back to the last cached value (even if stale), else `[]`.
|
|
15
|
+
*/
|
|
16
|
+
getRules(): Promise<Rule[]>;
|
|
17
|
+
ping(): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Presence ping so the backend knows this install is still alive — feeds
|
|
20
|
+
* retention analytics, including users who only ever run safe/warning
|
|
21
|
+
* actions (which otherwise never reach the backend). Throttled on disk to
|
|
22
|
+
* `heartbeatIntervalMs` (default once/day), keyed per backend+key. Never
|
|
23
|
+
* throws: a missing key, a throttled call, or a failed request are all
|
|
24
|
+
* silent no-ops, so it is safe to await on the hook's hot path. The stamp is
|
|
25
|
+
* only advanced on a successful send, so a transient outage just re-pings
|
|
26
|
+
* next call.
|
|
27
|
+
*/
|
|
28
|
+
heartbeat(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export { OKedAuthError, OKedBackendUnreachableError } from "./errors.js";
|
|
31
|
+
export { TIER_ORDER, degradedDecision } from "./degraded.js";
|
|
32
|
+
export { classify } from "./classify.js";
|
|
33
|
+
export { describe, describeFields } from "./describe.js";
|
|
34
|
+
export { applyRules } from "./rules.js";
|
|
35
|
+
export type { Rule, RuleMatch, RuleAction, RuleDecision, FieldOp, } from "./rules.js";
|
|
36
|
+
export type { Rendered } from "./describe.js";
|
|
37
|
+
export { CLASSIFIER_VERSION } from "./kinds.js";
|
|
38
|
+
export type { OperationKind } from "./kinds.js";
|
|
39
|
+
export { loadOKedConfig, OKED_CONFIG_PATH } from "./config.js";
|
|
40
|
+
export type { PersistedConfig } from "./config.js";
|
|
41
|
+
export type { RiskTier, ApprovalRequest, ApprovalResponse, OKedConfig, HookInput, HookOutput, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { loadOKedConfig, OKED_RULES_CACHE_PATH, OKED_HEARTBEAT_PATH } from "./config.js";
|
|
4
|
+
import { OKedAuthError, OKedBackendUnreachableError } from "./errors.js";
|
|
5
|
+
import { hostname } from "os";
|
|
6
|
+
function envStrictFailClosed() {
|
|
7
|
+
const raw = process.env.OKED_STRICT_FAIL_CLOSED;
|
|
8
|
+
if (raw === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
return raw === "1" || raw.toLowerCase() === "true";
|
|
11
|
+
}
|
|
12
|
+
function envRulesCacheTtlMs() {
|
|
13
|
+
const raw = process.env.OKED_RULES_CACHE_TTL; // seconds
|
|
14
|
+
if (raw === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const n = Number(raw);
|
|
17
|
+
return Number.isFinite(n) && n >= 0 ? n * 1000 : undefined;
|
|
18
|
+
}
|
|
19
|
+
function envHeartbeatIntervalMs() {
|
|
20
|
+
const raw = process.env.OKED_HEARTBEAT_INTERVAL; // seconds
|
|
21
|
+
if (raw === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const n = Number(raw);
|
|
24
|
+
return Number.isFinite(n) && n >= 0 ? n * 1000 : undefined;
|
|
25
|
+
}
|
|
26
|
+
// Small stable key so different keys/backends don't collide in the shared
|
|
27
|
+
// cache file, without storing the API key itself.
|
|
28
|
+
function rulesCacheKey(backendUrl, apiKey) {
|
|
29
|
+
const s = `${backendUrl}|${apiKey}`;
|
|
30
|
+
let h = 5381;
|
|
31
|
+
for (let i = 0; i < s.length; i++)
|
|
32
|
+
h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
33
|
+
return h.toString(36);
|
|
34
|
+
}
|
|
35
|
+
function readRulesCache() {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(OKED_RULES_CACHE_PATH, "utf-8"));
|
|
38
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function writeRulesCache(cache) {
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(dirname(OKED_RULES_CACHE_PATH), { recursive: true });
|
|
47
|
+
writeFileSync(OKED_RULES_CACHE_PATH, JSON.stringify(cache));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Best-effort; a non-writable home dir just means no cross-call caching.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readHeartbeatStamps() {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(OKED_HEARTBEAT_PATH, "utf-8"));
|
|
56
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function writeHeartbeatStamps(stamps) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(OKED_HEARTBEAT_PATH), { recursive: true });
|
|
65
|
+
writeFileSync(OKED_HEARTBEAT_PATH, JSON.stringify(stamps));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Best-effort; a non-writable home dir just means we re-ping next call.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class OKedClient {
|
|
72
|
+
config;
|
|
73
|
+
constructor(config) {
|
|
74
|
+
const persisted = loadOKedConfig();
|
|
75
|
+
this.config = {
|
|
76
|
+
apiKey: config?.apiKey || process.env.OKED_API_KEY || persisted.apiKey || "",
|
|
77
|
+
backendUrl: config?.backendUrl ||
|
|
78
|
+
process.env.OKED_BACKEND_URL ||
|
|
79
|
+
persisted.backendUrl ||
|
|
80
|
+
"https://api.oked.ai",
|
|
81
|
+
timeout: config?.timeout || 300_000,
|
|
82
|
+
// Precedence: constructor > env > ~/.oked/config.json > default false.
|
|
83
|
+
strictFailClosed: config?.strictFailClosed ??
|
|
84
|
+
envStrictFailClosed() ??
|
|
85
|
+
persisted.strictFailClosed ??
|
|
86
|
+
false,
|
|
87
|
+
rulesCacheTtlMs: config?.rulesCacheTtlMs ??
|
|
88
|
+
envRulesCacheTtlMs() ??
|
|
89
|
+
(typeof persisted.rulesCacheTtlSeconds === "number"
|
|
90
|
+
? persisted.rulesCacheTtlSeconds * 1000
|
|
91
|
+
: undefined) ??
|
|
92
|
+
300_000,
|
|
93
|
+
heartbeatIntervalMs: config?.heartbeatIntervalMs ??
|
|
94
|
+
envHeartbeatIntervalMs() ??
|
|
95
|
+
(typeof persisted.heartbeatIntervalSeconds === "number"
|
|
96
|
+
? persisted.heartbeatIntervalSeconds * 1000
|
|
97
|
+
: undefined) ??
|
|
98
|
+
86_400_000,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
get strictFailClosed() {
|
|
102
|
+
return this.config.strictFailClosed;
|
|
103
|
+
}
|
|
104
|
+
get apiKey() {
|
|
105
|
+
return this.config.apiKey;
|
|
106
|
+
}
|
|
107
|
+
get backendUrl() {
|
|
108
|
+
return this.config.backendUrl;
|
|
109
|
+
}
|
|
110
|
+
async approve(request) {
|
|
111
|
+
const signal = AbortSignal.timeout(this.config.timeout);
|
|
112
|
+
const doFetch = () => fetch(`${this.config.backendUrl}/api/v1/approve`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
action: request.action,
|
|
120
|
+
description: request.description,
|
|
121
|
+
tier: request.tier,
|
|
122
|
+
fields: request.fields,
|
|
123
|
+
metadata: {
|
|
124
|
+
tool_input: request.tool_input,
|
|
125
|
+
session_id: request.session_id,
|
|
126
|
+
cwd: request.cwd,
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
signal,
|
|
130
|
+
});
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
try {
|
|
134
|
+
res = await doFetch();
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (isRetriableConnectError(err)) {
|
|
138
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
139
|
+
res = await doFetch();
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
// Network failure or the request-level timeout firing (AbortError).
|
|
148
|
+
// Both mean we never got a decision from the backend -> treat as an
|
|
149
|
+
// outage so degraded-mode can apply.
|
|
150
|
+
throw new OKedBackendUnreachableError(err instanceof Error ? `OKed backend unreachable: ${err.message}` : "OKed backend unreachable", err);
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const body = await res.text().catch(() => "");
|
|
154
|
+
if (res.status === 401 || res.status === 403) {
|
|
155
|
+
throw new OKedAuthError(`OKed backend error ${res.status}: ${body}`, res.status);
|
|
156
|
+
}
|
|
157
|
+
if (res.status >= 500) {
|
|
158
|
+
throw new OKedBackendUnreachableError(`OKed backend error ${res.status}: ${body}`);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`OKed backend error ${res.status}: ${body}`);
|
|
161
|
+
}
|
|
162
|
+
const result = (await res.json());
|
|
163
|
+
return {
|
|
164
|
+
approved: result.decision === "approved",
|
|
165
|
+
approval_id: result.approval_id,
|
|
166
|
+
decision: result.decision,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* User rules for local escalation checks, cached on disk (TTL
|
|
171
|
+
* `rulesCacheTtlMs`, default 5 min). The Claude Code hook is a fresh
|
|
172
|
+
* process per call, so caching must be on disk. Never throws: a failed
|
|
173
|
+
* fetch falls back to the last cached value (even if stale), else `[]`.
|
|
174
|
+
*/
|
|
175
|
+
async getRules() {
|
|
176
|
+
if (!this.config.apiKey)
|
|
177
|
+
return [];
|
|
178
|
+
const key = rulesCacheKey(this.config.backendUrl, this.config.apiKey);
|
|
179
|
+
const cache = readRulesCache();
|
|
180
|
+
const entry = cache[key];
|
|
181
|
+
if (entry && Date.now() - entry.at < this.config.rulesCacheTtlMs) {
|
|
182
|
+
return entry.rules;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(`${this.config.backendUrl}/api/v1/rules`, {
|
|
186
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` },
|
|
187
|
+
signal: AbortSignal.timeout(5000),
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
return entry?.rules ?? [];
|
|
191
|
+
const rules = (await res.json());
|
|
192
|
+
if (!Array.isArray(rules))
|
|
193
|
+
return entry?.rules ?? [];
|
|
194
|
+
writeRulesCache({ ...cache, [key]: { at: Date.now(), rules } });
|
|
195
|
+
return rules;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return entry?.rules ?? [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async ping() {
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetch(`${this.config.backendUrl}/health`, {
|
|
204
|
+
signal: AbortSignal.timeout(3000),
|
|
205
|
+
});
|
|
206
|
+
return res.ok;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Presence ping so the backend knows this install is still alive — feeds
|
|
214
|
+
* retention analytics, including users who only ever run safe/warning
|
|
215
|
+
* actions (which otherwise never reach the backend). Throttled on disk to
|
|
216
|
+
* `heartbeatIntervalMs` (default once/day), keyed per backend+key. Never
|
|
217
|
+
* throws: a missing key, a throttled call, or a failed request are all
|
|
218
|
+
* silent no-ops, so it is safe to await on the hook's hot path. The stamp is
|
|
219
|
+
* only advanced on a successful send, so a transient outage just re-pings
|
|
220
|
+
* next call.
|
|
221
|
+
*/
|
|
222
|
+
async heartbeat() {
|
|
223
|
+
if (!this.config.apiKey)
|
|
224
|
+
return;
|
|
225
|
+
const key = rulesCacheKey(this.config.backendUrl, this.config.apiKey);
|
|
226
|
+
const stamps = readHeartbeatStamps();
|
|
227
|
+
const last = stamps[key];
|
|
228
|
+
if (typeof last === "number" && Date.now() - last < this.config.heartbeatIntervalMs) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch(`${this.config.backendUrl}/api/v1/heartbeat`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({ hostname: hostname() }),
|
|
239
|
+
signal: AbortSignal.timeout(3000),
|
|
240
|
+
});
|
|
241
|
+
if (res.ok) {
|
|
242
|
+
writeHeartbeatStamps({ ...stamps, [key]: Date.now() });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Best-effort presence signal; never block or break the agent.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function isRetriableConnectError(err) {
|
|
251
|
+
if (!(err instanceof Error))
|
|
252
|
+
return false;
|
|
253
|
+
if (err.name === "AbortError")
|
|
254
|
+
return false;
|
|
255
|
+
const code = err.cause?.code;
|
|
256
|
+
return (code === "ECONNREFUSED" ||
|
|
257
|
+
code === "ENOTFOUND" ||
|
|
258
|
+
code === "EAI_AGAIN" ||
|
|
259
|
+
code === "ETIMEDOUT" ||
|
|
260
|
+
code === "UND_ERR_CONNECT_TIMEOUT");
|
|
261
|
+
}
|
|
262
|
+
export { OKedAuthError, OKedBackendUnreachableError } from "./errors.js";
|
|
263
|
+
export { TIER_ORDER, degradedDecision } from "./degraded.js";
|
|
264
|
+
export { classify } from "./classify.js";
|
|
265
|
+
export { describe, describeFields } from "./describe.js";
|
|
266
|
+
export { applyRules } from "./rules.js";
|
|
267
|
+
export { CLASSIFIER_VERSION } from "./kinds.js";
|
|
268
|
+
export { loadOKedConfig, OKED_CONFIG_PATH } from "./config.js";
|
package/dist/kinds.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable taxonomy of operations the SDK recognizes. Used as the
|
|
3
|
+
* `operation_kind` analytics column on approval_classifications.
|
|
4
|
+
*
|
|
5
|
+
* Bump CLASSIFIER_VERSION when you add/remove kinds or change how a
|
|
6
|
+
* pattern maps to a kind, so historical rows stay attributable to the
|
|
7
|
+
* classifier that produced them.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CLASSIFIER_VERSION = "v2";
|
|
10
|
+
export type OperationKind = "file_create" | "file_append" | "file_edit" | "file_touch" | "file_copy" | "file_move" | "file_delete" | "sql_drop" | "sql_truncate" | "sql_alter" | "sql_create" | "sql_delete_rows" | "sql_delete_all_rows" | "sql_update_rows" | "sql_update_every_row" | "sql_insert" | "sql_query" | "git_push" | "git_force_push" | "git_reset_hard" | "git_clean" | "git_checkout" | "git_restore" | "git_commit" | "git_pr_create" | "ssh_remote" | "http_get" | "http_post" | "http_put" | "http_delete" | "http_pipe_to_shell" | "docker_up" | "docker_down" | "docker_rm" | "docker_rmi" | "docker_prune" | "npm_install" | "npm_run" | "npm_test" | "npm_publish" | "npm_unpublish" | "npx_deploy" | "kill_process" | "sudo" | "chmod_777" | "email_send" | "email_delete" | "email_purge" | "payment_charge" | "payment_create" | "chat_message" | "mcp_delete" | "mcp_update" | "mcp_create" | "mcp_post" | "mcp_publish" | "mcp_send" | "mcp_submit_form" | "mcp_fill" | "mcp_submit" | "mcp_query" | "agent_launch" | "shell_pipeline" | "unknown_bash" | "unknown_tool";
|
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable taxonomy of operations the SDK recognizes. Used as the
|
|
3
|
+
* `operation_kind` analytics column on approval_classifications.
|
|
4
|
+
*
|
|
5
|
+
* Bump CLASSIFIER_VERSION when you add/remove kinds or change how a
|
|
6
|
+
* pattern maps to a kind, so historical rows stay attributable to the
|
|
7
|
+
* classifier that produced them.
|
|
8
|
+
*/
|
|
9
|
+
export const CLASSIFIER_VERSION = "v2";
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-defined classification rules. Pure evaluation — no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Rules are an ordered list per user; the SDK evaluates top-to-bottom and
|
|
5
|
+
* stops at the first match. This matches Gmail's "filter messages like
|
|
6
|
+
* this" model: the order IS the policy. No baked-in "most restrictive
|
|
7
|
+
* wins" — if the user wants deny to beat approve, they put deny first.
|
|
8
|
+
*
|
|
9
|
+
* Matchers reference the fields produced by `describeFields()` (Title,
|
|
10
|
+
* Target, Body, Kind), plus `cwd` from the hook input. This keeps the
|
|
11
|
+
* matcher vocabulary aligned with what the user sees in the approval
|
|
12
|
+
* card; they don't need to know that bash uses `command` while Write
|
|
13
|
+
* uses `file_path`.
|
|
14
|
+
*
|
|
15
|
+
* This module is intentionally I/O-free. Fetching/caching rules from the
|
|
16
|
+
* backend lives in `OKedClient`; wiring `applyRules()` into the hook
|
|
17
|
+
* lives in `@oked/claude-code`. Keeping evaluation pure makes the unit
|
|
18
|
+
* tests trivial and the hot path deterministic.
|
|
19
|
+
*/
|
|
20
|
+
import type { RiskTier } from "./types.js";
|
|
21
|
+
/**
|
|
22
|
+
* Operator + value for a single matched field. `is_empty` is the only
|
|
23
|
+
* op that doesn't take a value (it matches when the field is missing or
|
|
24
|
+
* the empty string).
|
|
25
|
+
*/
|
|
26
|
+
export type FieldOp = {
|
|
27
|
+
op: "equals";
|
|
28
|
+
value: string;
|
|
29
|
+
} | {
|
|
30
|
+
op: "contains";
|
|
31
|
+
value: string;
|
|
32
|
+
} | {
|
|
33
|
+
op: "starts_with";
|
|
34
|
+
value: string;
|
|
35
|
+
} | {
|
|
36
|
+
op: "matches";
|
|
37
|
+
value: string;
|
|
38
|
+
} | {
|
|
39
|
+
op: "is_empty";
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Match clause. All present field-ops must pass (AND). Absent fields
|
|
43
|
+
* are ignored — a rule with only `{ kind: { op: "equals", value: "X" } }`
|
|
44
|
+
* matches any action whose Kind is X, regardless of Title/Target/etc.
|
|
45
|
+
*/
|
|
46
|
+
export interface RuleMatch {
|
|
47
|
+
kind?: FieldOp;
|
|
48
|
+
title?: FieldOp;
|
|
49
|
+
target?: FieldOp;
|
|
50
|
+
body?: FieldOp;
|
|
51
|
+
cwd?: FieldOp;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Action the rule takes when its match clause is satisfied.
|
|
55
|
+
*
|
|
56
|
+
* - `auto_approve` / `auto_deny`: skip the network round-trip entirely.
|
|
57
|
+
* The hook synthesizes the decision locally and logs `appliedRuleId`.
|
|
58
|
+
* - `set_tier`: still go to the backend for human approval, but with the
|
|
59
|
+
* user-specified tier. Used both to escalate (e.g. production matches
|
|
60
|
+
* → high_stakes) and to reduce (e.g. trusted npm scripts → warning).
|
|
61
|
+
*/
|
|
62
|
+
export type RuleAction = "auto_approve" | "auto_deny" | "set_tier";
|
|
63
|
+
export interface Rule {
|
|
64
|
+
id: string;
|
|
65
|
+
match: RuleMatch;
|
|
66
|
+
action: RuleAction;
|
|
67
|
+
/** Required when `action === "set_tier"`; ignored otherwise. */
|
|
68
|
+
tier?: RiskTier;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Verdict from `applyRules()`. `outcome === "ask"` means proceed to the
|
|
72
|
+
* normal backend approval flow (with `tier` possibly overridden by a
|
|
73
|
+
* `set_tier` rule). `auto_approve` / `auto_deny` mean the hook can
|
|
74
|
+
* decide locally without a network call.
|
|
75
|
+
*/
|
|
76
|
+
export interface RuleDecision {
|
|
77
|
+
outcome: "ask" | "auto_approve" | "auto_deny";
|
|
78
|
+
/** Final tier after any `set_tier` override. */
|
|
79
|
+
tier: RiskTier;
|
|
80
|
+
/** Set when a rule fired. */
|
|
81
|
+
appliedRuleId?: string;
|
|
82
|
+
/** Set when `set_tier` changed the tier from the classifier default. */
|
|
83
|
+
tierOverriddenFrom?: RiskTier;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Evaluate user rules against a classified action. Rules are assumed to
|
|
87
|
+
* be sorted by user-defined position (top = highest priority). The first
|
|
88
|
+
* rule whose match clause is satisfied produces the verdict; evaluation
|
|
89
|
+
* stops there. If no rule matches, returns the classifier default with
|
|
90
|
+
* `outcome: "ask"`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function applyRules(classified: {
|
|
93
|
+
tier: RiskTier;
|
|
94
|
+
fields: Record<string, string>;
|
|
95
|
+
}, rules: Rule[], ctx: {
|
|
96
|
+
cwd: string;
|
|
97
|
+
}): RuleDecision;
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-defined classification rules. Pure evaluation — no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Rules are an ordered list per user; the SDK evaluates top-to-bottom and
|
|
5
|
+
* stops at the first match. This matches Gmail's "filter messages like
|
|
6
|
+
* this" model: the order IS the policy. No baked-in "most restrictive
|
|
7
|
+
* wins" — if the user wants deny to beat approve, they put deny first.
|
|
8
|
+
*
|
|
9
|
+
* Matchers reference the fields produced by `describeFields()` (Title,
|
|
10
|
+
* Target, Body, Kind), plus `cwd` from the hook input. This keeps the
|
|
11
|
+
* matcher vocabulary aligned with what the user sees in the approval
|
|
12
|
+
* card; they don't need to know that bash uses `command` while Write
|
|
13
|
+
* uses `file_path`.
|
|
14
|
+
*
|
|
15
|
+
* This module is intentionally I/O-free. Fetching/caching rules from the
|
|
16
|
+
* backend lives in `OKedClient`; wiring `applyRules()` into the hook
|
|
17
|
+
* lives in `@oked/claude-code`. Keeping evaluation pure makes the unit
|
|
18
|
+
* tests trivial and the hot path deterministic.
|
|
19
|
+
*/
|
|
20
|
+
/** Internal: map rule-match field name → `describeFields` output key. */
|
|
21
|
+
const FIELD_KEY = {
|
|
22
|
+
kind: "Kind",
|
|
23
|
+
title: "Title",
|
|
24
|
+
target: "Target",
|
|
25
|
+
body: "Body",
|
|
26
|
+
};
|
|
27
|
+
function evalFieldOp(op, value) {
|
|
28
|
+
switch (op.op) {
|
|
29
|
+
case "is_empty":
|
|
30
|
+
return !value;
|
|
31
|
+
case "equals":
|
|
32
|
+
return value === op.value;
|
|
33
|
+
case "contains":
|
|
34
|
+
return value !== undefined && value.includes(op.value);
|
|
35
|
+
case "starts_with":
|
|
36
|
+
return value !== undefined && value.startsWith(op.value);
|
|
37
|
+
case "matches":
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
return false;
|
|
40
|
+
try {
|
|
41
|
+
return new RegExp(op.value).test(value);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Invalid user-supplied regex never throws into the hot path —
|
|
45
|
+
// it just fails to match. The dashboard validates regexes at
|
|
46
|
+
// create time; this is a belt-and-braces defense.
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function ruleMatches(rule, fields, cwd) {
|
|
52
|
+
const m = rule.match;
|
|
53
|
+
if (m.kind && !evalFieldOp(m.kind, fields[FIELD_KEY.kind]))
|
|
54
|
+
return false;
|
|
55
|
+
if (m.title && !evalFieldOp(m.title, fields[FIELD_KEY.title]))
|
|
56
|
+
return false;
|
|
57
|
+
if (m.target && !evalFieldOp(m.target, fields[FIELD_KEY.target]))
|
|
58
|
+
return false;
|
|
59
|
+
if (m.body && !evalFieldOp(m.body, fields[FIELD_KEY.body]))
|
|
60
|
+
return false;
|
|
61
|
+
if (m.cwd && !evalFieldOp(m.cwd, cwd))
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Evaluate user rules against a classified action. Rules are assumed to
|
|
67
|
+
* be sorted by user-defined position (top = highest priority). The first
|
|
68
|
+
* rule whose match clause is satisfied produces the verdict; evaluation
|
|
69
|
+
* stops there. If no rule matches, returns the classifier default with
|
|
70
|
+
* `outcome: "ask"`.
|
|
71
|
+
*/
|
|
72
|
+
export function applyRules(classified, rules, ctx) {
|
|
73
|
+
for (const rule of rules) {
|
|
74
|
+
if (!ruleMatches(rule, classified.fields, ctx.cwd))
|
|
75
|
+
continue;
|
|
76
|
+
switch (rule.action) {
|
|
77
|
+
case "auto_approve":
|
|
78
|
+
return {
|
|
79
|
+
outcome: "auto_approve",
|
|
80
|
+
tier: classified.tier,
|
|
81
|
+
appliedRuleId: rule.id,
|
|
82
|
+
};
|
|
83
|
+
case "auto_deny":
|
|
84
|
+
return {
|
|
85
|
+
outcome: "auto_deny",
|
|
86
|
+
tier: classified.tier,
|
|
87
|
+
appliedRuleId: rule.id,
|
|
88
|
+
};
|
|
89
|
+
case "set_tier": {
|
|
90
|
+
// A set_tier rule with no `tier` is malformed; ignore it and
|
|
91
|
+
// keep evaluating. The dashboard prevents creating such rules;
|
|
92
|
+
// this guards against bad backend payloads.
|
|
93
|
+
if (!rule.tier)
|
|
94
|
+
continue;
|
|
95
|
+
return {
|
|
96
|
+
outcome: "ask",
|
|
97
|
+
tier: rule.tier,
|
|
98
|
+
appliedRuleId: rule.id,
|
|
99
|
+
tierOverriddenFrom: rule.tier !== classified.tier ? classified.tier : undefined,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { outcome: "ask", tier: classified.tier };
|
|
105
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type RiskTier = "safe" | "warning" | "review" | "high_stakes";
|
|
2
|
+
export interface ApprovalRequest {
|
|
3
|
+
action: string;
|
|
4
|
+
description: string;
|
|
5
|
+
tier: RiskTier;
|
|
6
|
+
fields?: Record<string, string>;
|
|
7
|
+
tool_input?: unknown;
|
|
8
|
+
session_id?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ApprovalResponse {
|
|
12
|
+
approved: boolean;
|
|
13
|
+
approval_id: string;
|
|
14
|
+
decision: "approved" | "denied" | "timeout";
|
|
15
|
+
}
|
|
16
|
+
export interface OKedConfig {
|
|
17
|
+
apiKey: string;
|
|
18
|
+
backendUrl: string;
|
|
19
|
+
timeout: number;
|
|
20
|
+
/**
|
|
21
|
+
* When true, an unreachable backend hard-denies every sensitive action
|
|
22
|
+
* (the original fail-safe behavior). When false (default), an unreachable
|
|
23
|
+
* backend degrades to "allow" for non-high-stakes tiers so a single outage
|
|
24
|
+
* does not mass-abort every user's agent. `high_stakes` always denies on
|
|
25
|
+
* outage regardless of this flag. See degradedDecision().
|
|
26
|
+
*/
|
|
27
|
+
strictFailClosed: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* TTL (ms) for the on-disk cache of user rules used by the hook's local
|
|
30
|
+
* escalation check. The Claude Code hook is a fresh process per tool call,
|
|
31
|
+
* so the cache lives on disk; a longer TTL means fewer fetches at the cost
|
|
32
|
+
* of slower rule propagation. Default 300_000 (5 min).
|
|
33
|
+
*/
|
|
34
|
+
rulesCacheTtlMs: number;
|
|
35
|
+
/**
|
|
36
|
+
* Min interval (ms) between heartbeats. The hook fires a lightweight ping so
|
|
37
|
+
* the backend knows the install is still alive (feeds retention analytics)
|
|
38
|
+
* even for users who only run safe/warning actions. Throttled on disk
|
|
39
|
+
* (~/.oked/heartbeat.json) because the hook is a fresh process per call.
|
|
40
|
+
* Default 86_400_000 (once per day).
|
|
41
|
+
*/
|
|
42
|
+
heartbeatIntervalMs: number;
|
|
43
|
+
}
|
|
44
|
+
export interface HookInput {
|
|
45
|
+
session_id: string;
|
|
46
|
+
transcript_path?: string;
|
|
47
|
+
cwd: string;
|
|
48
|
+
permission_mode?: string;
|
|
49
|
+
hook_event_name: string;
|
|
50
|
+
tool_name: string;
|
|
51
|
+
tool_input: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
export interface HookOutput {
|
|
54
|
+
hookSpecificOutput: {
|
|
55
|
+
hookEventName: "PreToolUse";
|
|
56
|
+
permissionDecision: "allow" | "deny" | "ask";
|
|
57
|
+
permissionDecisionReason?: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|