dsh-perm-gate 0.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.ja.md +62 -0
  2. package/CHANGELOG.ko.md +60 -0
  3. package/CHANGELOG.md +62 -0
  4. package/INSTALL.ja.md +152 -0
  5. package/INSTALL.ko.md +151 -0
  6. package/INSTALL.md +154 -0
  7. package/INSTALL.zh.md +147 -0
  8. package/LICENSE +21 -0
  9. package/README.ja.md +197 -0
  10. package/README.ko.md +195 -0
  11. package/README.md +209 -0
  12. package/README.zh.md +144 -0
  13. package/assets/.gitkeep +0 -0
  14. package/assets/README.md +5 -0
  15. package/cordis.patch.yml +35 -0
  16. package/examples/permissions.example.yaml +20 -0
  17. package/lib/allowlist.d.ts +19 -0
  18. package/lib/allowlist.js +97 -0
  19. package/lib/audit.d.ts +45 -0
  20. package/lib/audit.js +34 -0
  21. package/lib/classifier.d.ts +74 -0
  22. package/lib/classifier.js +129 -0
  23. package/lib/cli.d.ts +2 -0
  24. package/lib/cli.js +67 -0
  25. package/lib/client.js +859 -0
  26. package/lib/client.js.map +1 -0
  27. package/lib/compiler.d.ts +30 -0
  28. package/lib/compiler.js +126 -0
  29. package/lib/config.d.ts +69 -0
  30. package/lib/config.js +64 -0
  31. package/lib/engine.d.ts +17 -0
  32. package/lib/engine.js +85 -0
  33. package/lib/evaluate.d.ts +20 -0
  34. package/lib/evaluate.js +94 -0
  35. package/lib/events.d.ts +56 -0
  36. package/lib/events.js +129 -0
  37. package/lib/grant.d.ts +60 -0
  38. package/lib/grant.js +121 -0
  39. package/lib/index.d.ts +17 -0
  40. package/lib/index.js +140 -0
  41. package/lib/learning.d.ts +58 -0
  42. package/lib/learning.js +181 -0
  43. package/lib/path.d.ts +47 -0
  44. package/lib/path.js +94 -0
  45. package/lib/risk.d.ts +58 -0
  46. package/lib/risk.js +100 -0
  47. package/lib/rule.d.ts +64 -0
  48. package/lib/rule.js +220 -0
  49. package/lib/runtime.d.ts +146 -0
  50. package/lib/runtime.js +350 -0
  51. package/lib/shell.d.ts +38 -0
  52. package/lib/shell.js +176 -0
  53. package/package.json +120 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Verdict learning for dsh-perm-gate's `llmAssist` strategy.
3
+ *
4
+ * When the risk grader reports a `neutral` risk and the call goes to the human
5
+ * seam, a confirmation is learned once the call actually executes (observed via
6
+ * the host's `tools/result` settlement). After `threshold` confirmations of the
7
+ * same `tool|category` key AND a fingerprint match against a previously
8
+ * confirmed sample, the gate may auto-allow the same operation — and only that
9
+ * operation, never a wider class.
10
+ *
11
+ * State persists as a plugin-owned JSON file. The user's YAML rules file is the
12
+ * deterministic, human-owned layer and is never written here; all I/O errors
13
+ * degrade to in-memory state so learning can never break gating (worst case
14
+ * the gate simply keeps asking — still fail-closed).
15
+ */
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { dirname } from 'node:path';
18
+ const DEFAULT_THRESHOLD = 3;
19
+ const DEFAULT_MAX_SAMPLES = 10;
20
+ /** Shell tool names whose first argument word is the operation handle. */
21
+ const SHELL_TOOLS = new Set(['bash', 'pwsh', 'sh', 'cmd', 'powershell']);
22
+ /** Structured arg keys carrying the operation target. */
23
+ const TARGET_KEYS = ['file_path', 'filePath', 'path', 'file', 'filename', 'target', 'url'];
24
+ function basename(p) {
25
+ const s = p.replaceAll('\\', '/');
26
+ const base = s.slice(s.lastIndexOf('/') + 1).trim();
27
+ return base;
28
+ }
29
+ /**
30
+ * Derive a stable, precise operation fingerprint from structured arguments:
31
+ * shell calls → `word|target-basename`; file-arg tools → the target basename;
32
+ * everything else → the tool name alone. Two calls share a fingerprint only
33
+ * when they perform the same kind of write against the same-named target.
34
+ */
35
+ export function operationFingerprint(tool, args, commandText) {
36
+ const command = typeof args.command === 'string'
37
+ ? args.command
38
+ : (SHELL_TOOLS.has(tool) && typeof commandText === 'string' ? commandText : undefined);
39
+ if (command !== undefined) {
40
+ const word = command.trim().split(/\s+/)[0] ?? '';
41
+ // Target position convention: the last non-flag token (`npm install foo`,
42
+ // `rm -rf dist`, `git push origin main` → main). Nothing target-like → word only.
43
+ let target = '';
44
+ const tokens = command.split(/\s+/);
45
+ for (let i = tokens.length - 1; i >= 1; i -= 1) {
46
+ const token = tokens[i];
47
+ if (token.startsWith('-') || token.length < 2)
48
+ continue;
49
+ target = basename(token);
50
+ if (target !== '')
51
+ break;
52
+ }
53
+ return target !== '' ? `${word}|${target}` : word;
54
+ }
55
+ for (const key of TARGET_KEYS) {
56
+ const v = args[key];
57
+ if (typeof v === 'string' && v.trim() !== '') {
58
+ const base = basename(v);
59
+ if (base !== '')
60
+ return base;
61
+ }
62
+ }
63
+ return tool;
64
+ }
65
+ /** The learning key for one call: `tool|category` (only neutral learns today). */
66
+ export function learnKey(tool, category) {
67
+ return `${tool}|${category}`;
68
+ }
69
+ function emptyDoc() {
70
+ return { version: 1, confirmed: {}, samples: {} };
71
+ }
72
+ function sanitizeDoc(raw) {
73
+ if (typeof raw !== 'object' || raw === null)
74
+ return emptyDoc();
75
+ const doc = emptyDoc();
76
+ const confirmed = raw['confirmed'];
77
+ if (typeof confirmed === 'object' && confirmed !== null) {
78
+ for (const [k, v] of Object.entries(confirmed)) {
79
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
80
+ doc.confirmed[k] = Math.floor(v);
81
+ }
82
+ }
83
+ const samples = raw['samples'];
84
+ if (typeof samples === 'object' && samples !== null) {
85
+ for (const [k, v] of Object.entries(samples)) {
86
+ if (!Array.isArray(v))
87
+ continue;
88
+ const list = [];
89
+ for (const s of v) {
90
+ if (typeof s !== 'object' || s === null)
91
+ continue;
92
+ const fp = s['fp'];
93
+ if (typeof fp !== 'string' || fp === '')
94
+ continue;
95
+ const ctx = typeof s['ctx'] === 'string' ? s['ctx'] : '';
96
+ const at = typeof s['at'] === 'number' ? s['at'] : 0;
97
+ list.push({ fp, ctx: ctx.slice(0, 200), at });
98
+ }
99
+ if (list.length > 0)
100
+ doc.samples[k] = list;
101
+ }
102
+ }
103
+ return doc;
104
+ }
105
+ export class RiskLearning {
106
+ filePath;
107
+ options;
108
+ doc = emptyDoc();
109
+ loaded = false;
110
+ constructor(
111
+ /** Persistence path; `undefined` keeps state in memory only. */
112
+ filePath, options = {}) {
113
+ this.filePath = filePath;
114
+ this.options = options;
115
+ }
116
+ get threshold() {
117
+ const t = this.options.threshold;
118
+ return (typeof t === 'function' ? t() : t) ?? DEFAULT_THRESHOLD;
119
+ }
120
+ load() {
121
+ if (!this.loaded) {
122
+ this.loaded = true;
123
+ if (this.filePath !== undefined) {
124
+ try {
125
+ this.doc = sanitizeDoc(JSON.parse(readFileSync(this.filePath, 'utf8')));
126
+ }
127
+ catch {
128
+ this.doc = emptyDoc(); // missing/corrupt file: start clean, never throw
129
+ }
130
+ }
131
+ }
132
+ return this.doc;
133
+ }
134
+ persist() {
135
+ if (this.filePath === undefined)
136
+ return;
137
+ try {
138
+ mkdirSync(dirname(this.filePath), { recursive: true });
139
+ writeFileSync(this.filePath, JSON.stringify(this.doc, null, 2) + '\n', 'utf8');
140
+ }
141
+ catch {
142
+ // Unwritable location: keep the in-memory state; learning silently degrades.
143
+ }
144
+ }
145
+ /** Human confirmations recorded so far for one key. */
146
+ count(key) {
147
+ return this.load().confirmed[key] ?? 0;
148
+ }
149
+ /** Whether one key already holds a confirmed sample with this fingerprint. */
150
+ hasSample(key, fp) {
151
+ return this.load().samples[key]?.some((s) => s.fp === fp) ?? false;
152
+ }
153
+ /**
154
+ * The learning gate: auto-allow only when the key reached the threshold AND
155
+ * this exact fingerprint was among the human-confirmed samples.
156
+ */
157
+ shouldAutoAllow(key, fp) {
158
+ return this.count(key) >= this.threshold && this.hasSample(key, fp);
159
+ }
160
+ /** Record one human confirmation: bump the count and store the sample (bounded). */
161
+ confirm(key, fp, ctx) {
162
+ const doc = this.load();
163
+ doc.confirmed[key] = (doc.confirmed[key] ?? 0) + 1;
164
+ const max = this.options.maxSamples ?? DEFAULT_MAX_SAMPLES;
165
+ const list = (doc.samples[key] ?? []).filter((s) => s.fp !== fp);
166
+ list.push({ fp, ctx: ctx.slice(0, 200), at: (this.options.now ?? Date.now)() });
167
+ doc.samples[key] = list.slice(-max);
168
+ this.persist();
169
+ }
170
+ /** Drop all learning state (counts and samples) and persist the empty doc. */
171
+ reset() {
172
+ this.doc = emptyDoc();
173
+ this.loaded = true;
174
+ this.persist();
175
+ }
176
+ /** Read-only deep view (for tests and diagnostics). */
177
+ snapshot() {
178
+ const doc = this.load();
179
+ return JSON.parse(JSON.stringify(doc));
180
+ }
181
+ }
package/lib/path.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Path containment and normalization for dsh-perm-gate, plus a session-scoped
3
+ * artifact registry that tracks files a session created so deletion of
4
+ * same-session artifacts can be cleaned up without widening authority.
5
+ *
6
+ * Containment helpers are pure string operators (testable); the registry is
7
+ * the one stateful piece and stays in-process for a session.
8
+ */
9
+ export declare function toPosix(p: string): string;
10
+ /**
11
+ * Normalize one candidate path against the workspace root to a
12
+ * workspace-relative posix path. Candidates OUTSIDE the root yield `''`.
13
+ * `caseInsensitive` (Windows default) compares drive/root prefixes case-insensitively.
14
+ */
15
+ export declare function normalizeWorkspacePath(cwd: string, candidate: string, caseInsensitive?: boolean): string;
16
+ export declare function isWithin(base: string, target: string): boolean;
17
+ /** Sensitive credential-ish path roots that reads/writes should route to review. */
18
+ export declare function isSensitivePath(p: string): boolean;
19
+ /** Root-level / DSH_HOME / home/system paths that must never be mutated. */
20
+ export declare function isProtectedDestructiveTarget(p: string, dshHome?: string): boolean;
21
+ export interface ArtifactRecord {
22
+ readonly device: string;
23
+ readonly inode: string;
24
+ readonly birthMs: number;
25
+ readonly kind: 'file' | 'dir';
26
+ readonly cwd: string;
27
+ }
28
+ export interface ArtifactRead {
29
+ /** device:inode for files; '' when unknown. */
30
+ readonly id: string;
31
+ readonly kind: 'file' | 'dir';
32
+ readonly tracked: boolean;
33
+ }
34
+ /**
35
+ * Tracks files a session created so their exact cleanup can be authorized
36
+ * without widening authority to pre-existing data. Keyed by `device:inode`.
37
+ */
38
+ export declare class ArtifactRegistry {
39
+ private readonly byId;
40
+ register(id: string, kind: 'file' | 'dir', cwd: string): void;
41
+ /** Whether a known id was created during this session. */
42
+ known(id: string): ArtifactRead | undefined;
43
+ unregister(id: string): void;
44
+ snapshot(): number;
45
+ /** Build a stable identity string for a file system entry when given device+inode. */
46
+ static identity(device: number | string, inode: number | string): string;
47
+ }
package/lib/path.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Path containment and normalization for dsh-perm-gate, plus a session-scoped
3
+ * artifact registry that tracks files a session created so deletion of
4
+ * same-session artifacts can be cleaned up without widening authority.
5
+ *
6
+ * Containment helpers are pure string operators (testable); the registry is
7
+ * the one stateful piece and stays in-process for a session.
8
+ */
9
+ // --- Pure path helpers -------------------------------------------------------
10
+ export function toPosix(p) {
11
+ return p.replaceAll('\\', '/');
12
+ }
13
+ /**
14
+ * Normalize one candidate path against the workspace root to a
15
+ * workspace-relative posix path. Candidates OUTSIDE the root yield `''`.
16
+ * `caseInsensitive` (Windows default) compares drive/root prefixes case-insensitively.
17
+ */
18
+ export function normalizeWorkspacePath(cwd, candidate, caseInsensitive = false) {
19
+ const root = toPosix(cwd).replace(/\/+$/, '');
20
+ const raw = toPosix(candidate);
21
+ if (raw.length === 0)
22
+ return '';
23
+ let path = raw;
24
+ while (path.startsWith('./'))
25
+ path = path.slice(2);
26
+ if (/^[A-Za-z]:\//.test(path)) {
27
+ if (!/^[A-Za-z]:\//.test(root))
28
+ return '';
29
+ if (path.slice(0, 2).toLowerCase() !== root.slice(0, 2).toLowerCase())
30
+ return '';
31
+ return relFromRoot(root, path, path.slice(2), root.slice(2), caseInsensitive);
32
+ }
33
+ if (path.startsWith('/')) {
34
+ return relFromRoot(root, path, path, root, caseInsensitive);
35
+ }
36
+ return path;
37
+ }
38
+ function relFromRoot(root, path, rest, rootRest, ci) {
39
+ const eq = (a, b) => (ci ? a.toLowerCase() === b.toLowerCase() : a === b);
40
+ if (rootRest.length === 0)
41
+ return rest.slice(1);
42
+ if (eq(path, root))
43
+ return '';
44
+ return (ci ? rest.toLowerCase() : rest).startsWith((ci ? rootRest.toLowerCase() + '/' : rootRest + '/'))
45
+ ? rest.slice(rootRest.length + 1)
46
+ : '';
47
+ }
48
+ export function isWithin(base, target) {
49
+ const b = toPosix(base).replace(/\/+$/, '');
50
+ const t = toPosix(target);
51
+ return t === b || t.startsWith(b + '/');
52
+ }
53
+ /** Sensitive credential-ish path roots that reads/writes should route to review. */
54
+ export function isSensitivePath(p) {
55
+ return /(?:^|[\\/])(?:\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.config[\\/]gh|\.docker)(?:[\\/]|$)|(?:^|[\\/])(?:id_rsa|id_ed25519|credentials\.yaml|config\.json|\.env)(?:$|[.\\/])/i.test(p);
56
+ }
57
+ /** Root-level / DSH_HOME / home/system paths that must never be mutated. */
58
+ export function isProtectedDestructiveTarget(p, dshHome) {
59
+ const posix = toPosix(p).toLowerCase();
60
+ const guards = ['/', '/home', '/root', '/usr', '/etc', '/bin', '/sbin', '/boot', '/var'];
61
+ for (const g of guards) {
62
+ if (posix === g || posix.startsWith(g + '/'))
63
+ return true;
64
+ }
65
+ if (dshHome !== undefined && (posix === toPosix(dshHome).toLowerCase() || posix.startsWith(toPosix(dshHome).toLowerCase() + '/')))
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Tracks files a session created so their exact cleanup can be authorized
71
+ * without widening authority to pre-existing data. Keyed by `device:inode`.
72
+ */
73
+ export class ArtifactRegistry {
74
+ byId = new Map();
75
+ register(id, kind, cwd) {
76
+ if (id !== '')
77
+ this.byId.set(id, { cwd, kind });
78
+ }
79
+ /** Whether a known id was created during this session. */
80
+ known(id) {
81
+ const r = this.byId.get(id);
82
+ return r === undefined ? undefined : { id, kind: r.kind, tracked: true };
83
+ }
84
+ unregister(id) {
85
+ this.byId.delete(id);
86
+ }
87
+ snapshot() {
88
+ return this.byId.size;
89
+ }
90
+ /** Build a stable identity string for a file system entry when given device+inode. */
91
+ static identity(device, inode) {
92
+ return `${String(device)}:${String(inode)}`;
93
+ }
94
+ }
package/lib/risk.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Risk-category verdict protocol for dsh-perm-gate's `llmAssist` strategy.
3
+ *
4
+ * Layers on the same custom OpenAI-compatible endpoint as `classifier.ts` and
5
+ * asks the LLM to grade ONE tool call as either safe or risky with a category:
6
+ *
7
+ * safe → the gate may auto-allow the ask
8
+ * risky:<hard category> → deletion / credential / remote / system / bulk —
9
+ * ALWAYS routed to the human, never auto-allowed
10
+ * risky:neutral → no hard-risk signal but not clearly safe; the
11
+ * verdict-learning path may auto-allow after
12
+ * enough human confirmations
13
+ * unresolved → transport failure, timeout, or a protocol
14
+ * violation — the caller must keep the ask
15
+ * (fail-closed) and must NOT learn from it
16
+ *
17
+ * The protocol, prompt wording, and parser are this repo's own; the design
18
+ * (hard categories vs. a neutral confirm-count zone) follows the approach
19
+ * demonstrated by dsh-approval-gate.
20
+ */
21
+ import { type ClassifierConfig } from './classifier.js';
22
+ /** Risk categories the LLM may report for a risky verdict. */
23
+ export type RiskCategory = 'deletion' | 'credential' | 'remote' | 'system' | 'bulk' | 'neutral';
24
+ /**
25
+ * Categories that always route to the human seam regardless of learning state
26
+ * or any later LLM opinion. Order-stable for prompts and event payloads.
27
+ */
28
+ export declare const HARD_RISK_CATEGORIES: readonly RiskCategory[];
29
+ /** Whether a category must never be auto-allowed. Unknown categories are hard too (fail-closed). */
30
+ export declare function isHardRisk(category: string): boolean;
31
+ /**
32
+ * A graded verdict for one tool call. `unresolved` covers every failure mode —
33
+ * the caller must treat it exactly like "no classifier": keep the ask, learn nothing.
34
+ */
35
+ export type RiskVerdict = {
36
+ kind: 'safe';
37
+ reason?: string;
38
+ } | {
39
+ kind: 'risky';
40
+ category: RiskCategory;
41
+ reason?: string;
42
+ } | {
43
+ kind: 'unresolved';
44
+ };
45
+ /** The bounded call graded by {@link classifyRisk}. */
46
+ export interface RiskRequest {
47
+ readonly tool: string;
48
+ readonly args: unknown;
49
+ /** The deterministic reason the call reached the LLM-assist seam. */
50
+ readonly reason: string;
51
+ }
52
+ /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
53
+ export declare function parseRiskVerdict(content: string): RiskVerdict | undefined;
54
+ /**
55
+ * Grade one tool call with the configured LLM. Up to two attempts (one retry);
56
+ * any failure, timeout, or protocol violation resolves `unresolved` — never throws.
57
+ */
58
+ export declare function classifyRisk(cfg: ClassifierConfig, req: RiskRequest, nowFetch?: typeof fetch): Promise<RiskVerdict>;
package/lib/risk.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Risk-category verdict protocol for dsh-perm-gate's `llmAssist` strategy.
3
+ *
4
+ * Layers on the same custom OpenAI-compatible endpoint as `classifier.ts` and
5
+ * asks the LLM to grade ONE tool call as either safe or risky with a category:
6
+ *
7
+ * safe → the gate may auto-allow the ask
8
+ * risky:<hard category> → deletion / credential / remote / system / bulk —
9
+ * ALWAYS routed to the human, never auto-allowed
10
+ * risky:neutral → no hard-risk signal but not clearly safe; the
11
+ * verdict-learning path may auto-allow after
12
+ * enough human confirmations
13
+ * unresolved → transport failure, timeout, or a protocol
14
+ * violation — the caller must keep the ask
15
+ * (fail-closed) and must NOT learn from it
16
+ *
17
+ * The protocol, prompt wording, and parser are this repo's own; the design
18
+ * (hard categories vs. a neutral confirm-count zone) follows the approach
19
+ * demonstrated by dsh-approval-gate.
20
+ */
21
+ import { chatCompletion, withLlmRetry } from './classifier.js';
22
+ /**
23
+ * Categories that always route to the human seam regardless of learning state
24
+ * or any later LLM opinion. Order-stable for prompts and event payloads.
25
+ */
26
+ export const HARD_RISK_CATEGORIES = ['deletion', 'credential', 'remote', 'system', 'bulk'];
27
+ const RISK_CATEGORIES = [...HARD_RISK_CATEGORIES, 'neutral'];
28
+ /** Whether a category must never be auto-allowed. Unknown categories are hard too (fail-closed). */
29
+ export function isHardRisk(category) {
30
+ if (HARD_RISK_CATEGORIES.includes(category))
31
+ return true;
32
+ // An unknown category means the verdict is unreliable — treat it as hard.
33
+ return !RISK_CATEGORIES.includes(category);
34
+ }
35
+ const RISK_SYSTEM_PROMPT = [
36
+ 'You are the risk grader inside a permission gate for a coding agent.',
37
+ 'You receive one tool call (tool name, arguments, and the deterministic reason it needs human-grade review).',
38
+ 'Grade whether executing it could cause irreversible damage or touch sensitive resources.',
39
+ '',
40
+ 'Reply with ONLY one JSON object and nothing else:',
41
+ ' {"risk":"safe","reason":"short"}',
42
+ ' {"risk":"risky","category":"<category>","reason":"short"}',
43
+ '',
44
+ 'category must be exactly one of:',
45
+ ' deletion — deletes or overwrites data that cannot be regenerated',
46
+ ' credential — creates/modifies/exposes credentials, keys, tokens, auth config',
47
+ ' remote — affects remote systems, production, databases, messaging, billing, publishing',
48
+ ' system — system-level paths or configuration (/etc, /usr, boot items), shutdown/reboot',
49
+ ' bulk — mass overwrite of many files, formatting, dd-style irreversible writes',
50
+ ' neutral — none of the above clearly applies (ordinary edits outside the obvious scope, or you are unsure but see no hard-risk sign)',
51
+ '',
52
+ 'Rules:',
53
+ '- safe is only for reversible, in-scope operations that touch no sensitive resource.',
54
+ '- When unsure between safe and neutral, answer neutral; never guess safe.',
55
+ '- Judge the operation content, not merely its location.',
56
+ ].join('\n');
57
+ /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
58
+ export function parseRiskVerdict(content) {
59
+ const trimmed = content.trim();
60
+ try {
61
+ const parsed = JSON.parse(trimmed);
62
+ const reason = typeof parsed.reason === 'string' ? parsed.reason.slice(0, 200) : undefined;
63
+ if (parsed.risk === 'safe')
64
+ return { kind: 'safe', reason };
65
+ if (parsed.risk === 'risky') {
66
+ const category = typeof parsed.category === 'string' ? parsed.category.toLowerCase() : '';
67
+ if (RISK_CATEGORIES.includes(category)) {
68
+ return { kind: 'risky', category: category, reason };
69
+ }
70
+ return undefined; // off-protocol category: unreliable, do not decide on it
71
+ }
72
+ return undefined;
73
+ }
74
+ catch {
75
+ // Lenient fallback for models that ignore response_format: bare keyword scan.
76
+ // A bare "risky" carries no trustworthy category, so it stays unresolved —
77
+ // the caller keeps the human ask rather than guessing a category.
78
+ if (/\brisky\b/i.test(trimmed))
79
+ return undefined;
80
+ if (/\bsafe\b/i.test(trimmed))
81
+ return { kind: 'safe' };
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Grade one tool call with the configured LLM. Up to two attempts (one retry);
87
+ * any failure, timeout, or protocol violation resolves `unresolved` — never throws.
88
+ */
89
+ export async function classifyRisk(cfg, req, nowFetch = fetch) {
90
+ const user = JSON.stringify({
91
+ tool: req.tool,
92
+ args: req.args ?? {},
93
+ reason: req.reason,
94
+ instruction: 'Reply strictly as one JSON object: {"risk":"safe"} or {"risk":"risky","category":"..."} plus a short "reason".',
95
+ });
96
+ const content = await withLlmRetry(() => chatCompletion(cfg, RISK_SYSTEM_PROMPT, user, nowFetch).then((r) => (r.ok ? r.content : undefined)));
97
+ if (content === undefined)
98
+ return { kind: 'unresolved' };
99
+ return parseRiskVerdict(content) ?? { kind: 'unresolved' };
100
+ }
package/lib/rule.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { type CompiledPattern } from './compiler.js';
2
+ export type RuleAction = 'allow' | 'ask' | 'deny';
3
+ /** A parsed, shape-validated permissions document (patterns not yet compiled). */
4
+ export interface PermissionsDoc {
5
+ readonly defaultAction: RuleAction;
6
+ readonly deny: RuleEntryDoc[];
7
+ readonly allow: RuleEntryDoc[];
8
+ readonly ask: RuleEntryDoc[];
9
+ }
10
+ /** One parsed rule: match dimensions plus the action, reason, and metadata. */
11
+ export interface RuleEntryDoc {
12
+ /** Tool-name globs; empty = every tool. */
13
+ readonly tools: string[];
14
+ /**
15
+ * Shell command-word patterns. An entry `word` or `word#flag` where flag is
16
+ * `recursive`/`force`. Empty = this rule does not constrain on command.
17
+ */
18
+ readonly command: string[];
19
+ /** Argument/value token globs (file paths, urls, command args); empty = no constraint. */
20
+ readonly args: string[];
21
+ /** Workspace-relative path globs; empty = no constraint. */
22
+ readonly paths: string[];
23
+ readonly action: RuleAction;
24
+ readonly reason: string;
25
+ readonly enabled: boolean;
26
+ }
27
+ export interface CompiledRuleEntry {
28
+ readonly index: number;
29
+ readonly action: RuleAction;
30
+ readonly reason: string;
31
+ readonly enabled: boolean;
32
+ readonly tools: readonly CompiledPattern[];
33
+ /** Command specs: { word glob, flag? }. */
34
+ readonly command: readonly CommandSpec[];
35
+ readonly args: readonly CompiledPattern[];
36
+ readonly paths: readonly CompiledPattern[];
37
+ readonly source: RuleEntryDoc;
38
+ }
39
+ export interface CommandSpec {
40
+ readonly word: CompiledPattern;
41
+ readonly flag?: 'recursive' | 'force';
42
+ }
43
+ export interface CompiledRuleset {
44
+ readonly defaultAction: RuleAction;
45
+ readonly deny: readonly CompiledRuleEntry[];
46
+ readonly allow: readonly CompiledRuleEntry[];
47
+ readonly ask: readonly CompiledRuleEntry[];
48
+ readonly caseInsensitivePaths: boolean;
49
+ }
50
+ export interface CompileOptions {
51
+ readonly maxGlobStars?: number;
52
+ readonly caseInsensitivePaths?: boolean;
53
+ }
54
+ export declare class RuleError extends Error {
55
+ constructor(message: string);
56
+ }
57
+ /** Parse a raw YAML permissions document; malformed files fail loud at load. */
58
+ export declare function parsePermissionsDocument(text: string): PermissionsDoc;
59
+ /** Compile a validated document into hot-path rules. */
60
+ export declare function compileDocument(doc: PermissionsDoc, opts?: CompileOptions): CompiledRuleset;
61
+ /** SHA-256 hash of the raw document (compile-cache key without recompiling). */
62
+ export declare function documentHash(text: string): string;
63
+ export declare function extractPathCandidates(args: Record<string, unknown>): string[];
64
+ export declare function extractUrlCandidates(args: Record<string, unknown>): string[];