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
package/lib/rule.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Rule vocabulary for dsh-perm-gate: parsing a `permissions` YAML document into
3
+ * a validated config, compiling globs, and first-match evaluation. Every
4
+ * function is pure — no filesystem/clock/process state — so parsing, matching
5
+ * and the failure modes are unit-testable and replayable.
6
+ *
7
+ * The chain is split into `deny` / `allow` / `ask` lists. deny is evaluated
8
+ * first (blacklist priority); within each action list, first match wins. When
9
+ * nothing matches, `defaultAction` applies.
10
+ */
11
+ import { parse } from 'yaml';
12
+ import { compileGlob, hashText } from './compiler.js';
13
+ const VALID_ACTIONS = ['allow', 'ask', 'deny'];
14
+ const PATH_CANDIDATE_KEYS = [
15
+ 'path', 'paths', 'file', 'files', 'file_path', 'dir', 'directory', 'directories',
16
+ 'cwd', 'workspace', 'root', 'target', 'targets', 'output',
17
+ ];
18
+ const URL_CANDIDATE_KEYS = ['url', 'urls', 'uri', 'endpoint', 'remote', 'repo', 'repository'];
19
+ const COMMAND_CANDIDATE_KEYS = ['command', 'cmd', 'script', 'command_line', 'commandLine'];
20
+ export class RuleError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = 'RuleError';
24
+ }
25
+ }
26
+ function isRecord(v) {
27
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
28
+ }
29
+ function stringList(v, at) {
30
+ if (v === undefined)
31
+ return [];
32
+ const list = typeof v === 'string' ? [v] : v;
33
+ if (!Array.isArray(list))
34
+ throw new RuleError(`${at} must be a string or list of strings`);
35
+ return list.map((item, i) => {
36
+ if (typeof item !== 'string' || item.length === 0)
37
+ throw new RuleError(`${at}[${i}] must be a non-empty string`);
38
+ return item;
39
+ });
40
+ }
41
+ function actionOf(v, at) {
42
+ if (typeof v !== 'string' || !VALID_ACTIONS.includes(v)) {
43
+ throw new RuleError(`${at} action must be one of ${VALID_ACTIONS.join('|')}`);
44
+ }
45
+ return v;
46
+ }
47
+ /** Parse a raw YAML permissions document; malformed files fail loud at load. */
48
+ export function parsePermissionsDocument(text) {
49
+ let raw;
50
+ try {
51
+ raw = parse(text);
52
+ }
53
+ catch (error) {
54
+ throw new RuleError(`invalid YAML: ${String(error)}`);
55
+ }
56
+ if (raw === null || raw === undefined) {
57
+ return { defaultAction: 'ask', deny: [], allow: [], ask: [] };
58
+ }
59
+ if (!isRecord(raw))
60
+ throw new RuleError('permissions document must be a mapping');
61
+ const root = isRecord(raw.permissions) ? raw.permissions : raw;
62
+ const unknown = Object.keys(root).filter((k) => !VALID_ACTIONS.includes(k) && k !== 'defaultAction');
63
+ if (unknown.length > 0) {
64
+ throw new RuleError(`unknown permissions field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
65
+ }
66
+ const defaultAction = root.defaultAction === undefined ? 'ask' : actionOf(root.defaultAction, 'defaultAction');
67
+ const parseList = (key, at) => {
68
+ const arr = root[key];
69
+ if (arr === undefined)
70
+ return [];
71
+ if (!Array.isArray(arr))
72
+ throw new RuleError(`${at}.${key} must be a list`);
73
+ return arr.map((item, i) => parseRuleEntry(item, key, `${at}.${key}[${i}]`));
74
+ };
75
+ return {
76
+ defaultAction,
77
+ deny: parseList('deny', 'permissions'),
78
+ allow: parseList('allow', 'permissions'),
79
+ ask: parseList('ask', 'permissions'),
80
+ };
81
+ }
82
+ function parseRuleEntry(raw, action, at) {
83
+ if (!isRecord(raw))
84
+ throw new RuleError(`${at} must be a mapping`);
85
+ const unknown = Object.keys(raw).filter((k) => !['tools', 'command', 'args', 'paths', 'action', 'reason', 'enabled'].includes(k));
86
+ if (unknown.length > 0) {
87
+ throw new RuleError(`${at} unknown field${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => JSON.stringify(k)).join(', ')}`);
88
+ }
89
+ if (raw.action !== undefined && actionOf(raw.action, `${at}.action`) !== action) {
90
+ throw new RuleError(`${at} action conflicts with its list`);
91
+ }
92
+ const reason = raw.reason;
93
+ if (reason !== undefined && (typeof reason !== 'string' || reason.trim().length === 0)) {
94
+ throw new RuleError(`${at}.reason must be a non-empty string`);
95
+ }
96
+ return {
97
+ tools: stringList(raw.tools, `${at}.tools`),
98
+ command: stringList(raw.command, `${at}.command`),
99
+ args: stringList(raw.args, `${at}.args`),
100
+ paths: stringList(raw.paths, `${at}.paths`),
101
+ action,
102
+ reason: reason === undefined ? `${action}` : reason,
103
+ enabled: raw.enabled === undefined ? true : typeof raw.enabled === 'boolean' ? raw.enabled : (() => { throw new RuleError(`${at}.enabled must be a boolean`); })(),
104
+ };
105
+ }
106
+ function compilePatternList(patterns, at, opts) {
107
+ const stars = opts.maxGlobStars ?? 2;
108
+ const ci = opts.caseInsensitivePaths ?? false;
109
+ const desc = at.includes('paths');
110
+ const list = [];
111
+ for (const p of patterns) {
112
+ list.push(compileGlob(p, { segments: desc, maxStars: stars }));
113
+ }
114
+ // Case-insensitive path patterns: recompile with `i`.
115
+ if (ci && desc) {
116
+ return list.map((c) => ({ source: c.source, re: new RegExp(c.re.source, 'ui') }));
117
+ }
118
+ return list;
119
+ }
120
+ /** Compile one command entry (`word` or `word#flag`). */
121
+ function compileCommand(entry, opts) {
122
+ const hash = entry.lastIndexOf('#');
123
+ let word;
124
+ let flag;
125
+ if (hash > 0 && hash < entry.length - 1) {
126
+ word = entry.slice(0, hash);
127
+ const f = entry.slice(hash + 1);
128
+ if (f === 'recursive' || f === 'force')
129
+ flag = f;
130
+ }
131
+ else {
132
+ word = entry;
133
+ }
134
+ return { word: compileGlob(word, { segments: false, maxStars: opts.maxGlobStars ?? 2 }), flag };
135
+ }
136
+ /** Compile a validated document into hot-path rules. */
137
+ export function compileDocument(doc, opts = {}) {
138
+ const comp = (list, action, offset) => list.map((entry, i) => ({
139
+ index: offset + i,
140
+ action,
141
+ reason: entry.reason,
142
+ enabled: entry.enabled,
143
+ tools: compilePatternList(entry.tools, 'tools', opts),
144
+ command: entry.command.map((c) => compileCommand(c, opts)),
145
+ args: compilePatternList(entry.args, 'args', opts),
146
+ paths: compilePatternList(entry.paths, 'paths', opts),
147
+ source: entry,
148
+ }));
149
+ return {
150
+ defaultAction: doc.defaultAction,
151
+ deny: comp(doc.deny, 'deny', 0),
152
+ allow: comp(doc.allow, 'allow', doc.deny.length),
153
+ ask: comp(doc.ask, 'ask', doc.deny.length + doc.allow.length),
154
+ caseInsensitivePaths: opts.caseInsensitivePaths ?? false,
155
+ };
156
+ }
157
+ /** SHA-256 hash of the raw document (compile-cache key without recompiling). */
158
+ export function documentHash(text) {
159
+ return hashText(text);
160
+ }
161
+ // Candidate extraction (shared by evaluate + path rules).
162
+ export function extractPathCandidates(args) {
163
+ const out = [];
164
+ const walk = (node, depth) => {
165
+ if (depth > 8)
166
+ return;
167
+ if (Array.isArray(node)) {
168
+ for (const e of node)
169
+ walk(e, depth + 1);
170
+ return;
171
+ }
172
+ if (!isRecord(node))
173
+ return;
174
+ for (const [k, v] of Object.entries(node)) {
175
+ if (PATH_CANDIDATE_KEYS.includes(k)) {
176
+ if (typeof v === 'string')
177
+ out.push(v);
178
+ else if (Array.isArray(v))
179
+ for (const e of v)
180
+ if (typeof e === 'string')
181
+ out.push(e);
182
+ }
183
+ walk(v, depth + 1);
184
+ }
185
+ };
186
+ walk(args, 0);
187
+ return out;
188
+ }
189
+ export function extractUrlCandidates(args) {
190
+ const out = [];
191
+ const urlRe = /https?:\/\/[^\s"'<>)\]\\]+/g;
192
+ const walk = (node, depth) => {
193
+ if (depth > 8)
194
+ return;
195
+ if (Array.isArray(node)) {
196
+ for (const e of node)
197
+ walk(e, depth + 1);
198
+ return;
199
+ }
200
+ if (!isRecord(node))
201
+ return;
202
+ for (const [k, v] of Object.entries(node)) {
203
+ if (URL_CANDIDATE_KEYS.includes(k)) {
204
+ if (typeof v === 'string')
205
+ out.push(v);
206
+ else if (Array.isArray(v))
207
+ for (const e of v)
208
+ if (typeof e === 'string')
209
+ out.push(e);
210
+ }
211
+ if (COMMAND_CANDIDATE_KEYS.includes(k) && typeof v === 'string') {
212
+ for (const m of v.matchAll(urlRe))
213
+ out.push(m[0]);
214
+ }
215
+ walk(v, depth + 1);
216
+ }
217
+ };
218
+ walk(args, 0);
219
+ return out;
220
+ }
@@ -0,0 +1,146 @@
1
+ import { type AuditEntry } from './audit.js';
2
+ import { type PermGateConfig, type PermissiveStrategies } from './config.js';
3
+ import type { ClassifierConfig } from './classifier.js';
4
+ import { EventLog } from './events.js';
5
+ import { RiskLearning } from './learning.js';
6
+ import { type RiskRequest, type RiskVerdict } from './risk.js';
7
+ import { type CompiledRuleset } from './rule.js';
8
+ export interface PreToolDecisionLike {
9
+ kind: 'deny' | 'ask';
10
+ reason: string;
11
+ }
12
+ export interface ToolExecutionLike {
13
+ readonly name: string;
14
+ readonly arguments: Record<string, unknown>;
15
+ readonly commandText?: string;
16
+ readonly cwd?: string;
17
+ readonly sessionId?: string;
18
+ readonly parentAuthorized?: boolean;
19
+ readonly signal?: AbortSignal;
20
+ }
21
+ export interface PermGateRuntimeOptions extends PermGateConfig {
22
+ readonly onRulesChanged?: (ruleset: CompiledRuleset) => void;
23
+ readonly now?: () => number;
24
+ /**
25
+ * Test/direct-use injection replacing the HTTP risk grader. Return
26
+ * `unresolved` (or omit this hook) to stay on the human seam — fail-closed.
27
+ */
28
+ readonly riskHook?: (req: RiskRequest) => Promise<RiskVerdict>;
29
+ /** Optional live source of the Permissive tier (e.g. a DSH settings namespace). */
30
+ readonly readPermissive?: () => PermissiveState;
31
+ /** Optional live source of the llmAssist classifier setup. */
32
+ readonly readClassifyConfig?: () => ClassifierConfig;
33
+ /**
34
+ * Optional live source of the verdict-learning state. When unset, the static
35
+ * `riskLearning` / `riskThreshold` options apply.
36
+ */
37
+ readonly readRiskLearning?: () => RiskLearningState;
38
+ /** Persistence path for verdict learning; `undefined` keeps it in memory. */
39
+ readonly learningFile?: string;
40
+ /** Persistence path for the decision-event feed; `undefined` disables it. */
41
+ readonly eventsFile?: string;
42
+ }
43
+ export type CallDecision = 'allow' | 'deny' | 'ask';
44
+ /** The effective Permissive tier state read per tool call. */
45
+ export interface PermissiveState {
46
+ readonly enabled: boolean;
47
+ readonly strategies: PermissiveStrategies;
48
+ }
49
+ /** The effective verdict-learning state read per decision. */
50
+ export interface RiskLearningState {
51
+ readonly enabled: boolean;
52
+ readonly threshold: number;
53
+ }
54
+ export declare class PermGateRuntime {
55
+ private readonly options;
56
+ private ruleset;
57
+ private readonly grants;
58
+ private readonly artifacts;
59
+ private readonly audit;
60
+ private readonly cache;
61
+ private readonly permissiveEnabled;
62
+ private readonly strategies;
63
+ private readonly learning;
64
+ private readonly events?;
65
+ /** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
66
+ private readonly pending;
67
+ constructor(options?: PermGateRuntimeOptions);
68
+ private compileInline;
69
+ /** Reload rules from disk (HMR); on failure keeps the previous rules. */
70
+ reload(): boolean;
71
+ get auditEntries(): readonly AuditEntry[];
72
+ grantCount(): number;
73
+ get defaultAction(): string;
74
+ ruleCount(): number;
75
+ /** Whether the independent Permissive tier is active (single front switch). */
76
+ get permissive(): boolean;
77
+ /** Backend approval strategy booleans active when Permissive is on. */
78
+ get permissiveStrategies(): Readonly<PermissiveStrategies>;
79
+ artifactCount(): number;
80
+ /** The decision-event feed (undefined when event recording is disabled). */
81
+ get eventLog(): EventLog | undefined;
82
+ /** Learning candidates currently awaiting settlement (diagnostics/tests). */
83
+ pendingCount(): number;
84
+ /** Deep read-only view of the learning state (diagnostics/tests). */
85
+ learningSnapshot(): ReturnType<RiskLearning['snapshot']>;
86
+ private ctxFor;
87
+ private liveRiskLearning;
88
+ private livePermissive;
89
+ private recordEvent;
90
+ /**
91
+ * Decide one tool call. Returns a decision to veto (`deny`/`ask`) or
92
+ * undefined to delegate via `next()` (allow / passthrough). Always records an
93
+ * audit entry and a decision event. Grant consumption happens inside
94
+ * {@link decide}; a call that targets a different fingerprint than any minted
95
+ * grant no-ops. An `ask` verdict may afterwards be refined by
96
+ * {@link refineAsk} (LLM risk grading + verdict learning) before it reaches
97
+ * the human seam.
98
+ */
99
+ decideExecution(exec: ToolExecutionLike): PreToolDecisionLike | undefined;
100
+ /**
101
+ * Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
102
+ * outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
103
+ * rule-allow to `ask` — which the caller may then refine via
104
+ * {@link refineAsk}. `trustAutoAllow` is the baseline middle-tier auto-allow
105
+ * and needs no modulation here; llmAssist refinement lives in
106
+ * {@link refineAsk} because it is asynchronous.
107
+ */
108
+ private applyPermissive;
109
+ /**
110
+ * Refine an `ask` decision with the configured LLM risk grader
111
+ * (`llmAssist` strategy). Returns `undefined` to allow (delegate via
112
+ * `next()`), or the decision to keep vetoing with. Every uncertainty path —
113
+ * strategy off, no classifier setup, transport failure, off-protocol output —
114
+ * keeps the original ask (fail-closed). Only a `neutral` risk under an
115
+ * enabled learning store registers a pending candidate; its confirmation is
116
+ * settled by {@link settleExecution} when the call actually executes.
117
+ */
118
+ refineAsk(exec: ToolExecutionLike, decision: PreToolDecisionLike): Promise<PreToolDecisionLike | undefined>;
119
+ /**
120
+ * Settle a learning candidate after the call finished (`tools/result`).
121
+ * A result arriving for a registered pending candidate means the human
122
+ * approved it and it actually executed — record one confirmation.
123
+ */
124
+ settleExecution(exec: ToolExecutionLike): void;
125
+ /** Mint a precise session grant bound to a canonical call fingerprint. */
126
+ grant(exec: ToolExecutionLike, maxUses: number, ttlMs: number): void;
127
+ /**
128
+ * Grant "repeat this call for the current session" (the always-confirm panel's
129
+ * first extended allow button): mint a bounded session grant for the exact
130
+ * call so an identical re-run this session passes without asking again.
131
+ */
132
+ approveRepeat(exec: ToolExecutionLike, maxUses?: number, ttlMs?: number): void;
133
+ /**
134
+ * Grant "allow every occurrence of this command" (the always-confirm panel's
135
+ * second extended allow button): persist the command into the rules file's
136
+ * `allow` whitelist and reload. Returns the reload result.
137
+ */
138
+ approveAllowEverywhere(commandWord: string, reason?: string): boolean;
139
+ /** Read-only view of the current allow-list command patterns (whitelist). */
140
+ allowlist(): readonly string[];
141
+ /**
142
+ * Replace the whitelist with exactly the given command patterns and reload.
143
+ * Returns the reload result (false when no rulesFile or the write failed).
144
+ */
145
+ setAllowlist(patterns: readonly string[]): boolean;
146
+ }