@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.
@@ -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
+ }
@@ -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";
@@ -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";
@@ -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
+ }
@@ -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 {};