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
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermGateRuntime: the host-facing gate. Holds a compiled ruleset, a
|
|
3
|
+
* session-scoped grant registry, an audit mirror, the verdict-learning store,
|
|
4
|
+
* and the event feed. `apply` (index.ts) wires it onto the `tools/pre-execute`
|
|
5
|
+
* waterfall and settles learning on `tools/result`.
|
|
6
|
+
*
|
|
7
|
+
* It is constructed either by the cordis `apply` (from config) or directly in
|
|
8
|
+
* tests with a plain ruleset, so the whole decision path is exercised without a
|
|
9
|
+
* live harness.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { makeEntry, MemoryAuditMirror } from './audit.js';
|
|
13
|
+
import { resolvePermissiveStrategies } from './config.js';
|
|
14
|
+
import { appendAllowCommand, listAllowCommands, replaceAllowCommands } from './allowlist.js';
|
|
15
|
+
import { decide } from './engine.js';
|
|
16
|
+
import { EventLog } from './events.js';
|
|
17
|
+
import { decideRules } from './evaluate.js';
|
|
18
|
+
import { canonicalizeCall, GrantRegistry } from './grant.js';
|
|
19
|
+
import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
|
|
20
|
+
import { ArtifactRegistry } from './path.js';
|
|
21
|
+
import { classifyRisk } from './risk.js';
|
|
22
|
+
import { compileDocument, documentHash, parsePermissionsDocument } from './rule.js';
|
|
23
|
+
/** Upper bound on in-flight learning candidates awaiting `tools/result`. */
|
|
24
|
+
const PENDING_CAP = 100;
|
|
25
|
+
export class PermGateRuntime {
|
|
26
|
+
options;
|
|
27
|
+
ruleset;
|
|
28
|
+
grants;
|
|
29
|
+
artifacts = new ArtifactRegistry();
|
|
30
|
+
audit = new MemoryAuditMirror();
|
|
31
|
+
cache = new Map(); // hash -> compiled
|
|
32
|
+
permissiveEnabled;
|
|
33
|
+
strategies;
|
|
34
|
+
learning;
|
|
35
|
+
events;
|
|
36
|
+
/** Learning candidates awaiting human approval + execution (call fingerprint → candidate). */
|
|
37
|
+
pending = new Map();
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.grants = new GrantRegistry('__session__', options.now);
|
|
41
|
+
this.permissiveEnabled = options.permissive ?? false;
|
|
42
|
+
this.strategies = resolvePermissiveStrategies(options.permissiveStrategies);
|
|
43
|
+
this.learning = new RiskLearning(options.learningFile, {
|
|
44
|
+
threshold: () => this.liveRiskLearning().threshold,
|
|
45
|
+
now: options.now,
|
|
46
|
+
});
|
|
47
|
+
this.events = options.eventsFile !== undefined ? new EventLog(options.eventsFile, options.now) : undefined;
|
|
48
|
+
this.ruleset = this.compileInline(options);
|
|
49
|
+
}
|
|
50
|
+
compileInline(options) {
|
|
51
|
+
const text = options.rulesFile !== undefined ? readFileSafe(options.rulesFile) : '';
|
|
52
|
+
const empty = {
|
|
53
|
+
defaultAction: options.defaultAction ?? 'ask',
|
|
54
|
+
deny: [], allow: [], ask: [],
|
|
55
|
+
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
56
|
+
};
|
|
57
|
+
if (text === '')
|
|
58
|
+
return empty;
|
|
59
|
+
const hash = documentHash(text);
|
|
60
|
+
const hit = this.cache.get(hash);
|
|
61
|
+
if (hit !== undefined)
|
|
62
|
+
return hit;
|
|
63
|
+
const doc = parsePermissionsDocument(text);
|
|
64
|
+
const compiled = compileDocument(doc, {
|
|
65
|
+
maxGlobStars: 2,
|
|
66
|
+
caseInsensitivePaths: options.caseInsensitivePaths ?? true,
|
|
67
|
+
});
|
|
68
|
+
this.cache.set(hash, compiled);
|
|
69
|
+
return compiled;
|
|
70
|
+
}
|
|
71
|
+
/** Reload rules from disk (HMR); on failure keeps the previous rules. */
|
|
72
|
+
reload() {
|
|
73
|
+
try {
|
|
74
|
+
const next = this.compileInline(this.options);
|
|
75
|
+
this.ruleset = next;
|
|
76
|
+
this.options.onRulesChanged?.(next);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false; // keep previous rules; never crash the host
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
get auditEntries() {
|
|
84
|
+
return this.audit.entries;
|
|
85
|
+
}
|
|
86
|
+
grantCount() {
|
|
87
|
+
return this.grants.size;
|
|
88
|
+
}
|
|
89
|
+
get defaultAction() {
|
|
90
|
+
return this.ruleset.defaultAction;
|
|
91
|
+
}
|
|
92
|
+
ruleCount() {
|
|
93
|
+
return this.ruleset.deny.length + this.ruleset.allow.length + this.ruleset.ask.length;
|
|
94
|
+
}
|
|
95
|
+
/** Whether the independent Permissive tier is active (single front switch). */
|
|
96
|
+
get permissive() {
|
|
97
|
+
return this.permissiveEnabled;
|
|
98
|
+
}
|
|
99
|
+
/** Backend approval strategy booleans active when Permissive is on. */
|
|
100
|
+
get permissiveStrategies() {
|
|
101
|
+
return this.strategies;
|
|
102
|
+
}
|
|
103
|
+
artifactCount() {
|
|
104
|
+
return this.artifacts.snapshot();
|
|
105
|
+
}
|
|
106
|
+
/** The decision-event feed (undefined when event recording is disabled). */
|
|
107
|
+
get eventLog() {
|
|
108
|
+
return this.events;
|
|
109
|
+
}
|
|
110
|
+
/** Learning candidates currently awaiting settlement (diagnostics/tests). */
|
|
111
|
+
pendingCount() {
|
|
112
|
+
return this.pending.size;
|
|
113
|
+
}
|
|
114
|
+
/** Deep read-only view of the learning state (diagnostics/tests). */
|
|
115
|
+
learningSnapshot() {
|
|
116
|
+
return this.learning.snapshot();
|
|
117
|
+
}
|
|
118
|
+
ctxFor(exec) {
|
|
119
|
+
return {
|
|
120
|
+
tool: exec.name,
|
|
121
|
+
args: exec.arguments ?? {},
|
|
122
|
+
commandText: exec.commandText ?? (typeof exec.arguments.command === 'string' ? exec.arguments.command : undefined),
|
|
123
|
+
cwd: exec.cwd ?? process.cwd(),
|
|
124
|
+
caseInsensitive: this.options.caseInsensitivePaths ?? true,
|
|
125
|
+
dshHome: this.options.dshHome,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
liveRiskLearning() {
|
|
129
|
+
const read = this.options.readRiskLearning;
|
|
130
|
+
if (read !== undefined)
|
|
131
|
+
return read();
|
|
132
|
+
return { enabled: this.options.riskLearning ?? false, threshold: this.options.riskThreshold ?? 3 };
|
|
133
|
+
}
|
|
134
|
+
livePermissive() {
|
|
135
|
+
return this.options.readPermissive !== undefined
|
|
136
|
+
? this.options.readPermissive()
|
|
137
|
+
: { enabled: this.permissiveEnabled, strategies: this.strategies };
|
|
138
|
+
}
|
|
139
|
+
recordEvent(exec, kind, reason, risk) {
|
|
140
|
+
this.events?.append({ sessionId: exec.sessionId, tool: exec.name, kind, risk, reason });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decide one tool call. Returns a decision to veto (`deny`/`ask`) or
|
|
144
|
+
* undefined to delegate via `next()` (allow / passthrough). Always records an
|
|
145
|
+
* audit entry and a decision event. Grant consumption happens inside
|
|
146
|
+
* {@link decide}; a call that targets a different fingerprint than any minted
|
|
147
|
+
* grant no-ops. An `ask` verdict may afterwards be refined by
|
|
148
|
+
* {@link refineAsk} (LLM risk grading + verdict learning) before it reaches
|
|
149
|
+
* the human seam.
|
|
150
|
+
*/
|
|
151
|
+
decideExecution(exec) {
|
|
152
|
+
const ctx = this.ctxFor(exec);
|
|
153
|
+
const callId = randomId();
|
|
154
|
+
const grantResolver = (tool, args) => {
|
|
155
|
+
if (exec.parentAuthorized === false)
|
|
156
|
+
return 'no-match';
|
|
157
|
+
return this.grants.decide(tool, args);
|
|
158
|
+
};
|
|
159
|
+
const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver);
|
|
160
|
+
const decision = this.applyPermissive(raw);
|
|
161
|
+
let outcome;
|
|
162
|
+
let source;
|
|
163
|
+
switch (decision.stage) {
|
|
164
|
+
case 'hard-deny':
|
|
165
|
+
outcome = 'deny';
|
|
166
|
+
source = 'hard-deny';
|
|
167
|
+
break;
|
|
168
|
+
case 'grant':
|
|
169
|
+
outcome = 'allow';
|
|
170
|
+
source = 'grant';
|
|
171
|
+
break;
|
|
172
|
+
case 'rule':
|
|
173
|
+
outcome = decision.action === 'ask' ? 'ask' : decision.action;
|
|
174
|
+
source = 'rule';
|
|
175
|
+
break;
|
|
176
|
+
case 'classifier':
|
|
177
|
+
outcome = decision.action;
|
|
178
|
+
source = 'classifier';
|
|
179
|
+
break;
|
|
180
|
+
case 'default':
|
|
181
|
+
case 'ask':
|
|
182
|
+
outcome = decision.action;
|
|
183
|
+
source = decision.reason.startsWith('permissive') ? 'permissive' : 'default';
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
this.audit.append(makeEntry({ callId, tool: exec.name, outcome, source, reason: decision.reason, at: Date.now() }));
|
|
187
|
+
this.recordEvent(exec, outcome === 'allow' ? 'auto' : outcome === 'deny' ? 'deny' : 'ask', decision.reason);
|
|
188
|
+
if (decision.action === 'deny')
|
|
189
|
+
return { kind: 'deny', reason: decision.reason };
|
|
190
|
+
if (decision.action === 'ask')
|
|
191
|
+
return { kind: 'ask', reason: decision.reason };
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Apply the Permissive tier to a raw decision. Hard-deny and minted-grant
|
|
196
|
+
* outcomes are never touched (fail-closed). `alwaysConfirm` escalates a
|
|
197
|
+
* rule-allow to `ask` — which the caller may then refine via
|
|
198
|
+
* {@link refineAsk}. `trustAutoAllow` is the baseline middle-tier auto-allow
|
|
199
|
+
* and needs no modulation here; llmAssist refinement lives in
|
|
200
|
+
* {@link refineAsk} because it is asynchronous.
|
|
201
|
+
*/
|
|
202
|
+
applyPermissive(raw) {
|
|
203
|
+
const permissive = this.livePermissive();
|
|
204
|
+
if (!permissive.enabled)
|
|
205
|
+
return raw;
|
|
206
|
+
const { strategies: str } = permissive;
|
|
207
|
+
if (str.alwaysConfirm && raw.action === 'allow' && raw.stage === 'rule') {
|
|
208
|
+
return { action: 'ask', reason: 'permissive always-confirm escalated rule-allow to ask', stage: 'default', ruleIndex: raw.ruleIndex };
|
|
209
|
+
}
|
|
210
|
+
return raw;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Refine an `ask` decision with the configured LLM risk grader
|
|
214
|
+
* (`llmAssist` strategy). Returns `undefined` to allow (delegate via
|
|
215
|
+
* `next()`), or the decision to keep vetoing with. Every uncertainty path —
|
|
216
|
+
* strategy off, no classifier setup, transport failure, off-protocol output —
|
|
217
|
+
* keeps the original ask (fail-closed). Only a `neutral` risk under an
|
|
218
|
+
* enabled learning store registers a pending candidate; its confirmation is
|
|
219
|
+
* settled by {@link settleExecution} when the call actually executes.
|
|
220
|
+
*/
|
|
221
|
+
async refineAsk(exec, decision) {
|
|
222
|
+
const permissive = this.livePermissive();
|
|
223
|
+
if (!permissive.enabled || !permissive.strategies.llmAssist)
|
|
224
|
+
return decision;
|
|
225
|
+
if (decision.kind !== 'ask')
|
|
226
|
+
return decision;
|
|
227
|
+
// One live read of the classifier setup. Without a fully configured custom
|
|
228
|
+
// endpoint the ask goes straight to the human seam (fail-closed) — unless a
|
|
229
|
+
// risk hook is injected (tests / direct embedding replace the HTTP grader).
|
|
230
|
+
const cfg = this.options.readClassifyConfig?.();
|
|
231
|
+
if (this.options.riskHook === undefined
|
|
232
|
+
&& (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
|
|
233
|
+
return decision;
|
|
234
|
+
}
|
|
235
|
+
const req = { tool: exec.name, args: exec.arguments ?? {}, reason: decision.reason };
|
|
236
|
+
const risk = this.options.riskHook !== undefined
|
|
237
|
+
? await this.options.riskHook(req)
|
|
238
|
+
: await classifyRisk(cfg ?? {}, req);
|
|
239
|
+
if (risk.kind === 'safe') {
|
|
240
|
+
const reason = `llm-assist risk: safe${risk.reason === undefined ? '' : ` (${risk.reason})`}`;
|
|
241
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
242
|
+
this.recordEvent(exec, 'auto', reason, 'safe');
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
if (risk.kind === 'risky') {
|
|
246
|
+
if (risk.category !== 'neutral') {
|
|
247
|
+
// Hard category (or an off-protocol one, graded hard): always the human
|
|
248
|
+
// seam — never auto-allowed, never learned.
|
|
249
|
+
const reason = `${decision.reason} [llm-assist risky:${risk.category} → human]`;
|
|
250
|
+
return { kind: 'ask', reason };
|
|
251
|
+
}
|
|
252
|
+
const learning = this.liveRiskLearning();
|
|
253
|
+
const key = learnKey(exec.name, risk.category);
|
|
254
|
+
const fp = operationFingerprint(exec.name, exec.arguments ?? {}, exec.commandText);
|
|
255
|
+
if (learning.enabled && this.learning.shouldAutoAllow(key, fp)) {
|
|
256
|
+
const reason = `llm-assist learned allow (${key}, ${fp})`;
|
|
257
|
+
this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
|
|
258
|
+
this.recordEvent(exec, 'auto', reason, risk.category);
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
if (learning.enabled) {
|
|
262
|
+
if (this.pending.size >= PENDING_CAP) {
|
|
263
|
+
const oldest = this.pending.keys().next();
|
|
264
|
+
if (!oldest.done)
|
|
265
|
+
this.pending.delete(oldest.value);
|
|
266
|
+
}
|
|
267
|
+
this.pending.set(canonicalizeCall(exec.name, exec.arguments ?? {}), { key, fp, ctx: decision.reason });
|
|
268
|
+
}
|
|
269
|
+
return { kind: 'ask', reason: `${decision.reason} [llm-assist risky:neutral]` };
|
|
270
|
+
}
|
|
271
|
+
// unresolved: keep the ask, learn nothing.
|
|
272
|
+
return decision;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Settle a learning candidate after the call finished (`tools/result`).
|
|
276
|
+
* A result arriving for a registered pending candidate means the human
|
|
277
|
+
* approved it and it actually executed — record one confirmation.
|
|
278
|
+
*/
|
|
279
|
+
settleExecution(exec) {
|
|
280
|
+
const callKey = canonicalizeCall(exec.name, exec.arguments ?? {});
|
|
281
|
+
const candidate = this.pending.get(callKey);
|
|
282
|
+
if (candidate === undefined)
|
|
283
|
+
return;
|
|
284
|
+
this.pending.delete(callKey);
|
|
285
|
+
if (!this.liveRiskLearning().enabled)
|
|
286
|
+
return;
|
|
287
|
+
this.learning.confirm(candidate.key, candidate.fp, candidate.ctx);
|
|
288
|
+
this.recordEvent(exec, 'learned', `confirmed ${candidate.key} (${candidate.fp})`, 'neutral');
|
|
289
|
+
}
|
|
290
|
+
/** Mint a precise session grant bound to a canonical call fingerprint. */
|
|
291
|
+
grant(exec, maxUses, ttlMs) {
|
|
292
|
+
const args = exec.arguments ?? {};
|
|
293
|
+
this.grants.mint({
|
|
294
|
+
tool: exec.name,
|
|
295
|
+
fingerprint: canonicalizeCall(exec.name, args),
|
|
296
|
+
decidedBy: 'human',
|
|
297
|
+
parentAuthorized: exec.parentAuthorized !== false,
|
|
298
|
+
ttlMs,
|
|
299
|
+
maxUses,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Grant "repeat this call for the current session" (the always-confirm panel's
|
|
304
|
+
* first extended allow button): mint a bounded session grant for the exact
|
|
305
|
+
* call so an identical re-run this session passes without asking again.
|
|
306
|
+
*/
|
|
307
|
+
approveRepeat(exec, maxUses = this.options.grantMaxUses ?? 3, ttlMs = this.options.grantTtlMs ?? 5 * 60_000) {
|
|
308
|
+
this.grant(exec, maxUses, ttlMs);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Grant "allow every occurrence of this command" (the always-confirm panel's
|
|
312
|
+
* second extended allow button): persist the command into the rules file's
|
|
313
|
+
* `allow` whitelist and reload. Returns the reload result.
|
|
314
|
+
*/
|
|
315
|
+
approveAllowEverywhere(commandWord, reason = 'permissive allow-everywhere') {
|
|
316
|
+
if (!this.options.rulesFile)
|
|
317
|
+
return false;
|
|
318
|
+
if (!appendAllowCommand(this.options.rulesFile, commandWord, reason))
|
|
319
|
+
return false;
|
|
320
|
+
return this.reload();
|
|
321
|
+
}
|
|
322
|
+
/** Read-only view of the current allow-list command patterns (whitelist). */
|
|
323
|
+
allowlist() {
|
|
324
|
+
if (!this.options.rulesFile)
|
|
325
|
+
return [];
|
|
326
|
+
return listAllowCommands(this.options.rulesFile);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Replace the whitelist with exactly the given command patterns and reload.
|
|
330
|
+
* Returns the reload result (false when no rulesFile or the write failed).
|
|
331
|
+
*/
|
|
332
|
+
setAllowlist(patterns) {
|
|
333
|
+
if (!this.options.rulesFile)
|
|
334
|
+
return false;
|
|
335
|
+
if (!replaceAllowCommands(this.options.rulesFile, patterns))
|
|
336
|
+
return false;
|
|
337
|
+
return this.reload();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function readFileSafe(p) {
|
|
341
|
+
try {
|
|
342
|
+
return readFileSync(p, 'utf8');
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return '';
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function randomId() {
|
|
349
|
+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
350
|
+
}
|
package/lib/shell.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell command decomposition for dsh-perm-gate.
|
|
3
|
+
*
|
|
4
|
+
* Turns a raw command-string argument into a list of SIMPLE commands: each
|
|
5
|
+
* simple command is a `command` word plus `args` and `redirect` tokens. It
|
|
6
|
+
* recognizes pipelines/`&&`/`;` at the top level, strips common prefix wrappers
|
|
7
|
+
* (`env`, `sudo`, `command`, literal-prefix `\`), and RECURSIVELY descends into
|
|
8
|
+
* `sh -c "..."` / `bash -c "..."` / `powershell -Command "..."` so that
|
|
9
|
+
* `sh -c "rm -rf /"` matches the same way as a bare `rm -rf /`.
|
|
10
|
+
*
|
|
11
|
+
* Pure: no filesystem/process state.
|
|
12
|
+
*/
|
|
13
|
+
export interface SimpleCommand {
|
|
14
|
+
/** The command word (env/prefix wrappers stripped, `\,` backtick un-escaped). */
|
|
15
|
+
readonly command: string;
|
|
16
|
+
/** Argument tokens (redirect targets are NOT part of `args`). */
|
|
17
|
+
readonly args: readonly string[];
|
|
18
|
+
/** Redirect targets (`>`, `>>`, `<`) — treated as argument-token candidates too. */
|
|
19
|
+
readonly redirects: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export type CmdFlag = 'recursive' | 'force';
|
|
22
|
+
/**
|
|
23
|
+
* Tokenize a shell string into tokens, honoring single/double quotes and a few
|
|
24
|
+
* common escapes. Missing/EOF quotes throw so the caller can route an
|
|
25
|
+
* undecidable command to `ask` rather than guessing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function tokenizeShell(text: string): string[];
|
|
28
|
+
/**
|
|
29
|
+
* Recursively decompose a command string into simple commands, descending into
|
|
30
|
+
* `-c`/`-Command` inline scripts. Throws on undecidable syntax (unclosed quote);
|
|
31
|
+
* callers route that to `ask`, never `allow`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function decomposeShellCommand(text: string): {
|
|
34
|
+
commands: SimpleCommand[];
|
|
35
|
+
};
|
|
36
|
+
/** Whether a simple command's args carry a `recursive` deletion modifier. */
|
|
37
|
+
export declare function isRecursiveDeletion(cmd: SimpleCommand): boolean;
|
|
38
|
+
export declare function isForceDeletion(cmd: SimpleCommand): boolean;
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const WRAPPER_WORDS = new Set(['env', 'sudo', 'command', 'nohup', 'xargs']);
|
|
2
|
+
const COMMAND_WORDS = new Set(['sh', 'bash', 'zsh', 'dash', 'busybox', 'pwsh', 'powershell', 'cmd']);
|
|
3
|
+
/**
|
|
4
|
+
* Tokenize a shell string into tokens, honoring single/double quotes and a few
|
|
5
|
+
* common escapes. Missing/EOF quotes throw so the caller can route an
|
|
6
|
+
* undecidable command to `ask` rather than guessing.
|
|
7
|
+
*/
|
|
8
|
+
export function tokenizeShell(text) {
|
|
9
|
+
const tokens = [];
|
|
10
|
+
let cur = '';
|
|
11
|
+
let quote = null;
|
|
12
|
+
let i = 0;
|
|
13
|
+
const n = text.length;
|
|
14
|
+
while (i < n) {
|
|
15
|
+
const ch = text[i];
|
|
16
|
+
if (quote === "'") {
|
|
17
|
+
if (ch === "'") {
|
|
18
|
+
quote = null;
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
cur += ch;
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (quote === '"') {
|
|
27
|
+
if (ch === '"') {
|
|
28
|
+
quote = null;
|
|
29
|
+
i += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '\\' && i + 1 < n) {
|
|
33
|
+
cur += text[i + 1];
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
cur += ch;
|
|
38
|
+
i += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === "'" || ch === '"') {
|
|
42
|
+
quote = ch;
|
|
43
|
+
i += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '\\' && i + 1 < n) {
|
|
47
|
+
cur += text[i + 1];
|
|
48
|
+
i += 2;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (/\s/.test(ch)) {
|
|
52
|
+
if (cur.length > 0) {
|
|
53
|
+
tokens.push(cur);
|
|
54
|
+
cur = '';
|
|
55
|
+
}
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
cur += ch;
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
if (quote !== null) {
|
|
63
|
+
throw new Error('unterminated quote in command string');
|
|
64
|
+
}
|
|
65
|
+
if (cur.length > 0)
|
|
66
|
+
tokens.push(cur);
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
/** Split into top-level simple-command token groups on `|`/`&&`/`;`/`||`. */
|
|
70
|
+
function splitPipeline(tokens) {
|
|
71
|
+
const groups = [];
|
|
72
|
+
let cur = [];
|
|
73
|
+
for (const tok of tokens) {
|
|
74
|
+
if (tok === '|' || tok === '&&' || tok === ';' || tok === '||') {
|
|
75
|
+
if (cur.length > 0)
|
|
76
|
+
groups.push(cur);
|
|
77
|
+
cur = [];
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
cur.push(tok);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (cur.length > 0)
|
|
84
|
+
groups.push(cur);
|
|
85
|
+
return groups;
|
|
86
|
+
}
|
|
87
|
+
function splitRedirects(tokens) {
|
|
88
|
+
const args = [];
|
|
89
|
+
const redirects = [];
|
|
90
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
91
|
+
const t = tokens[i];
|
|
92
|
+
if (t === '>' || t === '>>' || t === '<' || t === '1>' || t === '2>' || /^\d+>[>&]?$/.test(t)) {
|
|
93
|
+
if (i + 1 < tokens.length)
|
|
94
|
+
redirects.push(tokens[i + 1]);
|
|
95
|
+
i += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
args.push(t);
|
|
99
|
+
}
|
|
100
|
+
return { args, redirects };
|
|
101
|
+
}
|
|
102
|
+
/** Strip prefix wrappers (`env A=1`, `sudo`, `command`, leading `\`) from a command token list. */
|
|
103
|
+
function stripWrappers(args) {
|
|
104
|
+
let list = [...args];
|
|
105
|
+
let guard = 0;
|
|
106
|
+
while (list.length > 0 && guard++ < 8) {
|
|
107
|
+
let head = list[0];
|
|
108
|
+
if (head.startsWith('\\'))
|
|
109
|
+
head = head.slice(1);
|
|
110
|
+
if (WRAPPER_WORDS.has(head)) {
|
|
111
|
+
// Drop the wrapper and any `K=V` right after it.
|
|
112
|
+
list = list.slice(1);
|
|
113
|
+
while (list.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(list[0]))
|
|
114
|
+
list = list.slice(1);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
return list;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Recursively decompose a command string into simple commands, descending into
|
|
123
|
+
* `-c`/`-Command` inline scripts. Throws on undecidable syntax (unclosed quote);
|
|
124
|
+
* callers route that to `ask`, never `allow`.
|
|
125
|
+
*/
|
|
126
|
+
export function decomposeShellCommand(text) {
|
|
127
|
+
const tokens = tokenizeShell(text);
|
|
128
|
+
const groups = splitPipeline(tokens);
|
|
129
|
+
const commands = [];
|
|
130
|
+
for (const group of groups) {
|
|
131
|
+
collectSimpleCommand(group, commands);
|
|
132
|
+
}
|
|
133
|
+
return { commands };
|
|
134
|
+
}
|
|
135
|
+
function collectSimpleCommand(tokens, out) {
|
|
136
|
+
const stripped = stripWrappers(tokens);
|
|
137
|
+
if (stripped.length === 0)
|
|
138
|
+
return;
|
|
139
|
+
let command = stripped[0];
|
|
140
|
+
const rest = stripped.slice(1);
|
|
141
|
+
if (command.startsWith('\\'))
|
|
142
|
+
command = command.slice(1);
|
|
143
|
+
command = command.replace(/`/g, '');
|
|
144
|
+
// Inline interpreter (-c / -Command): recurse into the script token.
|
|
145
|
+
const flagIndex = rest.findIndex((t) => t === '-c' || t === '--command' || /^-Command$/i.test(t));
|
|
146
|
+
if (COMMAND_WORDS.has(command) && flagIndex >= 0) {
|
|
147
|
+
const script = rest[flagIndex + 1];
|
|
148
|
+
if (typeof script === 'string') {
|
|
149
|
+
const nested = new Set();
|
|
150
|
+
try {
|
|
151
|
+
for (const sc of decomposeShellCommand(script).commands) {
|
|
152
|
+
// Flatten; dedupe exact same command word + token signature.
|
|
153
|
+
const key = `${sc.command}:${sc.args.join('\x00')}:${sc.redirects.join('\x00')}`;
|
|
154
|
+
if (nested.has(key))
|
|
155
|
+
continue;
|
|
156
|
+
nested.add(key);
|
|
157
|
+
out.push(sc);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
out.push({ command: command.toLowerCase(), args: rest, redirects: [] });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const { args, redirects } = splitRedirects(rest);
|
|
168
|
+
out.push({ command: command.toLowerCase(), args, redirects });
|
|
169
|
+
}
|
|
170
|
+
/** Whether a simple command's args carry a `recursive` deletion modifier. */
|
|
171
|
+
export function isRecursiveDeletion(cmd) {
|
|
172
|
+
return cmd.args.some((token) => /^-.*r/i.test(token) || /^-.*rf/i.test(token) || /^--(?:recursive|-?force)$/i.test(token) || token === 'recursive');
|
|
173
|
+
}
|
|
174
|
+
export function isForceDeletion(cmd) {
|
|
175
|
+
return cmd.args.some((token) => /^-.*f/i.test(token) || /^--force$/i.test(token) || token === 'force');
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-perm-gate",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH permission-gate: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"cordis.patch.yml",
|
|
9
|
+
"assets",
|
|
10
|
+
"examples",
|
|
11
|
+
"README.md",
|
|
12
|
+
"README.zh.md",
|
|
13
|
+
"README.ja.md",
|
|
14
|
+
"README.ko.md",
|
|
15
|
+
"INSTALL.md",
|
|
16
|
+
"INSTALL.zh.md",
|
|
17
|
+
"INSTALL.ja.md",
|
|
18
|
+
"INSTALL.ko.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"CHANGELOG.ja.md",
|
|
21
|
+
"CHANGELOG.ko.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"types": "lib/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./lib/index.d.ts",
|
|
29
|
+
"default": "./lib/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./engine": {
|
|
32
|
+
"types": "./lib/engine.d.ts",
|
|
33
|
+
"default": "./lib/engine.js"
|
|
34
|
+
},
|
|
35
|
+
"./shell": {
|
|
36
|
+
"types": "./lib/shell.d.ts",
|
|
37
|
+
"default": "./lib/shell.js"
|
|
38
|
+
},
|
|
39
|
+
"./grant": {
|
|
40
|
+
"types": "./lib/grant.d.ts",
|
|
41
|
+
"default": "./lib/grant.js"
|
|
42
|
+
},
|
|
43
|
+
"./classifier": {
|
|
44
|
+
"types": "./lib/classifier.d.ts",
|
|
45
|
+
"default": "./lib/classifier.js"
|
|
46
|
+
},
|
|
47
|
+
"./allowlist": {
|
|
48
|
+
"types": "./lib/allowlist.d.ts",
|
|
49
|
+
"default": "./lib/allowlist.js"
|
|
50
|
+
},
|
|
51
|
+
"./client": {
|
|
52
|
+
"default": "./lib/client.js"
|
|
53
|
+
},
|
|
54
|
+
"./package.json": "./package.json"
|
|
55
|
+
},
|
|
56
|
+
"bin": {
|
|
57
|
+
"dsh-perm-gate": "./lib/cli.js"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@deepseek-ai/schemastery": "^3.18.0",
|
|
67
|
+
"yaml": "^2.5.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
72
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
75
|
+
"@eslint/js": "^10.0.1",
|
|
76
|
+
"@types/node": "^22.0.0",
|
|
77
|
+
"@types/react": "~18.3.1",
|
|
78
|
+
"eslint": "^10.9.1",
|
|
79
|
+
"react": "^18.3.1",
|
|
80
|
+
"tsdown": "^0.22.14",
|
|
81
|
+
"typescript": "^5.6.0",
|
|
82
|
+
"typescript-eslint": "^8.69.0",
|
|
83
|
+
"vitest": "^3.0.0"
|
|
84
|
+
},
|
|
85
|
+
"scripts": {
|
|
86
|
+
"build": "tsc -p tsconfig.build.json && tsdown",
|
|
87
|
+
"prepublishOnly": "npm run build",
|
|
88
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
89
|
+
"lint": "eslint src test tsdown.config.ts eslint.config.mjs",
|
|
90
|
+
"test": "vitest run"
|
|
91
|
+
},
|
|
92
|
+
"license": "MIT",
|
|
93
|
+
"keywords": [
|
|
94
|
+
"dsh",
|
|
95
|
+
"dsh-plugin",
|
|
96
|
+
"deepseek-harness",
|
|
97
|
+
"permission",
|
|
98
|
+
"permission-gate",
|
|
99
|
+
"allowlist",
|
|
100
|
+
"denylist",
|
|
101
|
+
"sandbox",
|
|
102
|
+
"permissive",
|
|
103
|
+
"approval",
|
|
104
|
+
"ai-safety"
|
|
105
|
+
],
|
|
106
|
+
"dsh": {
|
|
107
|
+
"bundle": {
|
|
108
|
+
"patch": "./cordis.patch.yml"
|
|
109
|
+
},
|
|
110
|
+
"client": {
|
|
111
|
+
"inject": [
|
|
112
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
113
|
+
"@deepseek-ai/dsh-client-locale",
|
|
114
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
115
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
116
|
+
],
|
|
117
|
+
"platform": "web"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|