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.
- package/CHANGELOG.ja.md +62 -0
- package/CHANGELOG.ko.md +60 -0
- package/CHANGELOG.md +62 -0
- package/INSTALL.ja.md +152 -0
- package/INSTALL.ko.md +151 -0
- package/INSTALL.md +154 -0
- package/INSTALL.zh.md +147 -0
- package/LICENSE +21 -0
- package/README.ja.md +197 -0
- package/README.ko.md +195 -0
- package/README.md +209 -0
- package/README.zh.md +144 -0
- package/assets/.gitkeep +0 -0
- package/assets/README.md +5 -0
- package/cordis.patch.yml +35 -0
- package/examples/permissions.example.yaml +20 -0
- package/lib/allowlist.d.ts +19 -0
- package/lib/allowlist.js +97 -0
- package/lib/audit.d.ts +45 -0
- package/lib/audit.js +34 -0
- package/lib/classifier.d.ts +74 -0
- package/lib/classifier.js +129 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +67 -0
- package/lib/client.js +859 -0
- package/lib/client.js.map +1 -0
- package/lib/compiler.d.ts +30 -0
- package/lib/compiler.js +126 -0
- package/lib/config.d.ts +69 -0
- package/lib/config.js +64 -0
- package/lib/engine.d.ts +17 -0
- package/lib/engine.js +85 -0
- package/lib/evaluate.d.ts +20 -0
- package/lib/evaluate.js +94 -0
- package/lib/events.d.ts +56 -0
- package/lib/events.js +129 -0
- package/lib/grant.d.ts +60 -0
- package/lib/grant.js +121 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +140 -0
- package/lib/learning.d.ts +58 -0
- package/lib/learning.js +181 -0
- package/lib/path.d.ts +47 -0
- package/lib/path.js +94 -0
- package/lib/risk.d.ts +58 -0
- package/lib/risk.js +100 -0
- package/lib/rule.d.ts +64 -0
- package/lib/rule.js +220 -0
- package/lib/runtime.d.ts +146 -0
- package/lib/runtime.js +350 -0
- package/lib/shell.d.ts +38 -0
- package/lib/shell.js +176 -0
- package/package.json +120 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-assist classifier for dsh-perm-gate's `llmAssist` strategy.
|
|
3
|
+
*
|
|
4
|
+
* Calls an OpenAI-compatible `/chat/completions` endpoint (the user may point it
|
|
5
|
+
* at any custom API via `classifierEndpoint` / `classifierModel` / `classifierApiKey`)
|
|
6
|
+
* and asks for a structured verdict on one tool call. Strictly fail-closed: any
|
|
7
|
+
* transport, parsing, or schema error returns `ask` so the human seam stays
|
|
8
|
+
* authoritative — an LLM can widen to `allow`/`deny` but never silence the review.
|
|
9
|
+
*
|
|
10
|
+
* The transport layer (`chatCompletion`) is shared with `risk.ts`, which layers
|
|
11
|
+
* the risk-category protocol on top of the same custom-endpoint setup.
|
|
12
|
+
*/
|
|
13
|
+
export type ClassifyVerdict = 'allow' | 'deny' | 'ask';
|
|
14
|
+
/** Setup for one classifier call. */
|
|
15
|
+
export interface ClassifierConfig {
|
|
16
|
+
/** OpenAI-compatible base URL, e.g. `https://api.openai.com/v1` (or any custom gateway). */
|
|
17
|
+
endpoint?: string;
|
|
18
|
+
/** The chat model id to invoke. */
|
|
19
|
+
model?: string;
|
|
20
|
+
/** Bearer token / API key for the endpoint; use the session provider when absent. */
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
/** Abort after this many milliseconds. Default 30s. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/** The call being judged (bounded/redacted before it leaves the host). */
|
|
26
|
+
export interface ClassifyRequest {
|
|
27
|
+
readonly tool: string;
|
|
28
|
+
/** Redacted/bounded argument snapshot (secrets already stripped upstream). */
|
|
29
|
+
readonly args: unknown;
|
|
30
|
+
/** The deterministic reason the call reached the LLM-assist seam. */
|
|
31
|
+
readonly reason: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a user-entered OpenAI-compatible base URL into the
|
|
36
|
+
* `/chat/completions` POST URL. Accepts both the bare base
|
|
37
|
+
* (`https://api.example.com/v1`) and a fully pasted path
|
|
38
|
+
* (`https://api.example.com/v1/chat/completions`) — pasting the complete URL
|
|
39
|
+
* is the most common custom-endpoint mistake and must not 404.
|
|
40
|
+
*/
|
|
41
|
+
export declare function chatCompletionsUrl(endpoint: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* One OpenAI-compatible `/chat/completions` POST with an abort timeout.
|
|
44
|
+
* Transport-only: resolves `{ ok: true, content }` with the assistant text, or
|
|
45
|
+
* `{ ok: false }` on any non-2xx response, network failure, abort, or malformed
|
|
46
|
+
* payload. Never throws.
|
|
47
|
+
*
|
|
48
|
+
* Custom-endpoint hardening: when the gateway rejects the request shape
|
|
49
|
+
* (400/404/415/422 — typically an unsupported `response_format`), one lenient
|
|
50
|
+
* retry runs WITHOUT `response_format` so plain OpenAI-compatible gateways
|
|
51
|
+
* (ollama / llama.cpp / one-api style) still work.
|
|
52
|
+
*/
|
|
53
|
+
export declare function chatCompletion(cfg: ClassifierConfig, system: string, user: string, nowFetch?: typeof fetch): Promise<{
|
|
54
|
+
ok: true;
|
|
55
|
+
content: string;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
}>;
|
|
59
|
+
/** A bounded single-shot task result; `undefined` means "try again". */
|
|
60
|
+
type RetryTask<T> = () => Promise<T | undefined>;
|
|
61
|
+
/**
|
|
62
|
+
* Run `task` up to `attempts` times (default 2 = one initial try + one retry),
|
|
63
|
+
* re-running whenever it resolves `undefined` or rejects. Never throws.
|
|
64
|
+
*/
|
|
65
|
+
export declare function withLlmRetry<T>(task: RetryTask<T>, attempts?: number): Promise<T | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Ask the configured LLM whether one tool call should proceed.
|
|
68
|
+
* @param cfg - endpoint/model/timeout setup.
|
|
69
|
+
* @param req - the bounded call to judge.
|
|
70
|
+
* @param nowFetch - injectable fetch (defaults to global fetch) for tests.
|
|
71
|
+
* @returns `allow` / `deny`, or `ask` on any error or uncertainty (fail-closed).
|
|
72
|
+
*/
|
|
73
|
+
export declare function classifyWithLLM(cfg: ClassifierConfig, req: ClassifyRequest, nowFetch?: typeof fetch): Promise<ClassifyVerdict>;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a user-entered OpenAI-compatible base URL into the
|
|
4
|
+
* `/chat/completions` POST URL. Accepts both the bare base
|
|
5
|
+
* (`https://api.example.com/v1`) and a fully pasted path
|
|
6
|
+
* (`https://api.example.com/v1/chat/completions`) — pasting the complete URL
|
|
7
|
+
* is the most common custom-endpoint mistake and must not 404.
|
|
8
|
+
*/
|
|
9
|
+
export function chatCompletionsUrl(endpoint) {
|
|
10
|
+
const base = endpoint.trim().replace(/\/+$/, '');
|
|
11
|
+
return /\/chat\/completions$/.test(base) ? base : `${base}/chat/completions`;
|
|
12
|
+
}
|
|
13
|
+
/** Status codes that mean "the request shape was rejected" (not auth/network) — worth one lenient retry. */
|
|
14
|
+
const SHAPE_REJECT_STATUS = new Set([400, 404, 415, 422]);
|
|
15
|
+
async function postChat(url, headers, body, signal, nowFetch) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await nowFetch(url, { method: 'POST', headers, signal, body: JSON.stringify(body) });
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return { ok: false, status: res.status };
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
if (text.trim() === '')
|
|
22
|
+
return { ok: false };
|
|
23
|
+
// Custom models may answer with plain text instead of the JSON envelope;
|
|
24
|
+
// pass the raw text through — the verdict parser's keyword fallback judges it.
|
|
25
|
+
let content = text;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(text);
|
|
28
|
+
const inner = parsed.choices?.[0]?.message?.content;
|
|
29
|
+
if (typeof inner === 'string')
|
|
30
|
+
content = inner;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// not the OpenAI envelope: keep the raw body as content
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, content };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ok: false }; // network failure / abort
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One OpenAI-compatible `/chat/completions` POST with an abort timeout.
|
|
43
|
+
* Transport-only: resolves `{ ok: true, content }` with the assistant text, or
|
|
44
|
+
* `{ ok: false }` on any non-2xx response, network failure, abort, or malformed
|
|
45
|
+
* payload. Never throws.
|
|
46
|
+
*
|
|
47
|
+
* Custom-endpoint hardening: when the gateway rejects the request shape
|
|
48
|
+
* (400/404/415/422 — typically an unsupported `response_format`), one lenient
|
|
49
|
+
* retry runs WITHOUT `response_format` so plain OpenAI-compatible gateways
|
|
50
|
+
* (ollama / llama.cpp / one-api style) still work.
|
|
51
|
+
*/
|
|
52
|
+
export async function chatCompletion(cfg, system, user, nowFetch = fetch) {
|
|
53
|
+
const endpoint = cfg.endpoint;
|
|
54
|
+
const model = cfg.model;
|
|
55
|
+
if (!endpoint || !model)
|
|
56
|
+
return { ok: false };
|
|
57
|
+
const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
|
+
const url = chatCompletionsUrl(endpoint);
|
|
59
|
+
const controller = new AbortController();
|
|
60
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
61
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
62
|
+
if (cfg.apiKey)
|
|
63
|
+
headers.Authorization = `Bearer ${cfg.apiKey}`;
|
|
64
|
+
const messages = [
|
|
65
|
+
{ role: 'system', content: system },
|
|
66
|
+
{ role: 'user', content: user },
|
|
67
|
+
];
|
|
68
|
+
try {
|
|
69
|
+
const first = await postChat(url, headers, {
|
|
70
|
+
model, temperature: 0, response_format: { type: 'json_object' }, messages,
|
|
71
|
+
}, controller.signal, nowFetch);
|
|
72
|
+
if (first.ok)
|
|
73
|
+
return first;
|
|
74
|
+
if (first.status === undefined || !SHAPE_REJECT_STATUS.has(first.status))
|
|
75
|
+
return { ok: false };
|
|
76
|
+
const second = await postChat(url, headers, { model, temperature: 0, messages }, controller.signal, nowFetch);
|
|
77
|
+
return second.ok ? second : { ok: false };
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Run `task` up to `attempts` times (default 2 = one initial try + one retry),
|
|
85
|
+
* re-running whenever it resolves `undefined` or rejects. Never throws.
|
|
86
|
+
*/
|
|
87
|
+
export async function withLlmRetry(task, attempts = 2) {
|
|
88
|
+
for (let i = 0; i < Math.max(1, attempts); i += 1) {
|
|
89
|
+
try {
|
|
90
|
+
const result = await task();
|
|
91
|
+
if (result !== undefined)
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// fall through to the next attempt
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Ask the configured LLM whether one tool call should proceed.
|
|
102
|
+
* @param cfg - endpoint/model/timeout setup.
|
|
103
|
+
* @param req - the bounded call to judge.
|
|
104
|
+
* @param nowFetch - injectable fetch (defaults to global fetch) for tests.
|
|
105
|
+
* @returns `allow` / `deny`, or `ask` on any error or uncertainty (fail-closed).
|
|
106
|
+
*/
|
|
107
|
+
export async function classifyWithLLM(cfg, req, nowFetch = fetch) {
|
|
108
|
+
const system = 'You are a permission classifier for a coding agent. Judge one tool call and reply with ONLY JSON ' +
|
|
109
|
+
'{"verdict":"allow"|"deny"|"ask","reason":"short"}. allow only for clearly safe, in-scope operations; ' +
|
|
110
|
+
'deny for destructive, credential, or exfiltration-adjacent operations; ask on any doubt. Never reveal secrets.';
|
|
111
|
+
const user = JSON.stringify({
|
|
112
|
+
tool: req.tool,
|
|
113
|
+
args: req.args ?? {},
|
|
114
|
+
reason: req.reason,
|
|
115
|
+
instruction: 'Reply strictly as JSON: {"verdict":"allow"|"deny"|"ask","reason":"..."}',
|
|
116
|
+
});
|
|
117
|
+
const content = await withLlmRetry(() => chatCompletion(cfg, system, user, nowFetch).then((r) => (r.ok ? r.content : undefined)));
|
|
118
|
+
if (content === undefined)
|
|
119
|
+
return 'ask';
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(content);
|
|
122
|
+
if (parsed.verdict === 'allow' || parsed.verdict === 'deny')
|
|
123
|
+
return parsed.verdict;
|
|
124
|
+
return 'ask';
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return 'ask'; // malformed JSON — fail closed
|
|
128
|
+
}
|
|
129
|
+
}
|
package/lib/cli.d.ts
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-perm-gate CLI — evaluate one tool call against a rules file (dry-run),
|
|
4
|
+
* or print the loaded rules. Useful for testing rule files without a harness.
|
|
5
|
+
*
|
|
6
|
+
* dsh-perm-gate --rules permissions.yaml --tool bash \
|
|
7
|
+
* --args '{"command":"rm -rf bin"}'
|
|
8
|
+
* dsh-perm-gate --rules permissions.yaml --list
|
|
9
|
+
* dsh-perm-gate --permissive --tool bash --args '{"command":"pnpm install"}'
|
|
10
|
+
*/
|
|
11
|
+
import { parseArgs } from 'node:util';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { PermGateRuntime } from './runtime.js';
|
|
14
|
+
function main() {
|
|
15
|
+
const { values } = parseArgs({
|
|
16
|
+
options: {
|
|
17
|
+
rules: { type: 'string' },
|
|
18
|
+
tool: { type: 'string' },
|
|
19
|
+
args: { type: 'string' },
|
|
20
|
+
cwd: { type: 'string' },
|
|
21
|
+
list: { type: 'boolean', default: false },
|
|
22
|
+
permissive: { type: 'boolean', default: false },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const rulesFile = values.rules ? resolve(values.rules) : undefined;
|
|
26
|
+
const runtime = new PermGateRuntime({
|
|
27
|
+
rulesFile,
|
|
28
|
+
caseInsensitivePaths: true,
|
|
29
|
+
permissive: values.permissive || undefined,
|
|
30
|
+
});
|
|
31
|
+
if (values.list) {
|
|
32
|
+
process.stdout.write(JSON.stringify({
|
|
33
|
+
rulesFile,
|
|
34
|
+
defaultAction: runtime.defaultAction,
|
|
35
|
+
ruleCount: runtime.ruleCount(),
|
|
36
|
+
permissive: runtime.permissive,
|
|
37
|
+
permissiveStrategies: runtime.permissiveStrategies,
|
|
38
|
+
}, null, 2) + '\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const tool = values.tool;
|
|
42
|
+
if (!tool) {
|
|
43
|
+
process.stderr.write('usage: --tool <name> [--args <json>] [--rules <file>]\n');
|
|
44
|
+
process.exitCode = 2;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let args;
|
|
48
|
+
try {
|
|
49
|
+
args = values.args ? JSON.parse(values.args) : {};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
process.stderr.write('--args must be valid JSON\n');
|
|
53
|
+
process.exitCode = 2;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const decision = runtime.decideExecution({
|
|
57
|
+
name: tool,
|
|
58
|
+
arguments: args,
|
|
59
|
+
cwd: values.cwd ? resolve(values.cwd) : process.cwd(),
|
|
60
|
+
});
|
|
61
|
+
process.stdout.write(JSON.stringify({
|
|
62
|
+
decision: decision === undefined ? 'allow' : decision.kind,
|
|
63
|
+
reason: decision?.reason ?? '(default/passthrough)',
|
|
64
|
+
audited: runtime.auditEntries.length,
|
|
65
|
+
}, null, 2) + '\n');
|
|
66
|
+
}
|
|
67
|
+
main();
|