dsh-perm-gate 2.1.2 → 2.6.1
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 +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +236 -0
- package/INSTALL.ja.md +41 -21
- package/INSTALL.ko.md +37 -19
- package/INSTALL.md +46 -23
- package/INSTALL.zh.md +33 -17
- package/README.ja.md +52 -12
- package/README.ko.md +53 -13
- package/README.md +68 -10
- package/README.zh.md +41 -10
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +11 -8
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -0
- package/docs/rules-format.en.md +294 -0
- package/docs/rules-format.md +279 -0
- package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
- package/dsh.plugin.json +2 -2
- package/examples/permissions.example.yaml +16 -1
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +484 -6
- package/lib/client.js.map +1 -1
- package/lib/command-dispatcher.d.ts +22 -0
- package/lib/command-dispatcher.js +97 -0
- package/lib/command-semantics.d.ts +62 -0
- package/lib/command-semantics.js +6 -0
- package/lib/compiler.d.ts +11 -0
- package/lib/compiler.js +16 -0
- package/lib/config.d.ts +73 -12
- package/lib/config.js +56 -14
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +52 -2
- package/lib/evaluate.js +76 -2
- package/lib/events.d.ts +56 -2
- package/lib/events.js +92 -0
- package/lib/index.js +106 -26
- package/lib/parsers/git.d.ts +43 -0
- package/lib/parsers/git.js +404 -0
- package/lib/parsers/shell-cmds.d.ts +32 -0
- package/lib/parsers/shell-cmds.js +293 -0
- package/lib/preset.d.ts +13 -0
- package/lib/preset.js +17 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +17 -3
- package/lib/rule-chain.js +13 -22
- package/lib/rule-dims.d.ts +35 -0
- package/lib/rule-dims.js +36 -0
- package/lib/rule.d.ts +36 -1
- package/lib/rule.js +56 -8
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +121 -7
- package/lib/runtime.js +204 -25
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/package.json +19 -2
- package/scripts/patch-permission-glyph.mjs +225 -42
package/lib/engine.js
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
11
|
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
+
// SHELL_TOOLS 是**值的导入**:这份名单必须与 evaluate.ts / runtime.ts 共用同一个
|
|
13
|
+
// 事实源。此处曾经自带一份 `/(?:bash|pwsh|sh|cmd)$/` 正则,漏掉了 `shell` /
|
|
14
|
+
// `terminal` / `powershell`——于是 P0 的 shell 检查对 DSH 的**主 shell 工具**
|
|
15
|
+
// 完全失效(实测:同一句 `echo x > /etc/passwd`,bash 被拦截,shell 放行)。
|
|
16
|
+
// 同名事实的第二份副本必然腐烂,这里不再保留副本。
|
|
17
|
+
import { SHELL_TOOLS } from './evaluate.js';
|
|
12
18
|
import { isForceDeletion, isRecursiveDeletion, decomposeShellCommand } from './shell.js';
|
|
19
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
13
20
|
const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
|
|
14
21
|
/**
|
|
15
22
|
* Read-only tools that can never modify the workspace or execute anything. P0
|
|
@@ -107,6 +114,42 @@ function pathArgument(args) {
|
|
|
107
114
|
}
|
|
108
115
|
return undefined;
|
|
109
116
|
}
|
|
117
|
+
/** 廉价前缀过滤:绝大多数 shell 调用不是 push,先过一遍正则再动用解析器。 */
|
|
118
|
+
const GIT_PUSH_TEXT = /\bgit\s+push\b/;
|
|
119
|
+
/**
|
|
120
|
+
* 远端历史改写:`git push` 强制覆盖或删除**保护分支**。
|
|
121
|
+
*
|
|
122
|
+
* **「只升不降」是形状约束,不是口头约定**:返回值只有 `string | undefined`,
|
|
123
|
+
* 调用方把 `string` 读作 deny、`undefined` 读作「继续走后面的阶段」。本函数
|
|
124
|
+
* 结构上**无法**产生 allow,所以挂在 P0 上永远不会放宽门的判定。
|
|
125
|
+
*
|
|
126
|
+
* 刻意**不**覆盖(仍交给关键词层 / 规则层 / LLM 层):
|
|
127
|
+
* - 非保护分支上的 force push(如 `origin feat/x`)——P0 不可撤销,得给工作流留口子;
|
|
128
|
+
* - 没写分支名的 `git push --force`——解析不出目标就不做断言;
|
|
129
|
+
* - 普通 push。
|
|
130
|
+
*
|
|
131
|
+
* 这正是本插件补不上的那一段:`DEFAULT_DENY_KEYWORDS` 里的 `'push --force'` 是
|
|
132
|
+
* **扁平的、可被命名空间覆盖掉**的,且完全不看分支;P0 这一层则是不可协商的底。
|
|
133
|
+
*/
|
|
134
|
+
function gitRemoteRewriteReason(segments) {
|
|
135
|
+
for (const seg of segments) {
|
|
136
|
+
const text = [seg.command, ...seg.args].join(' ');
|
|
137
|
+
if (!GIT_PUSH_TEXT.test(text))
|
|
138
|
+
continue;
|
|
139
|
+
const result = dispatchCommand(text);
|
|
140
|
+
if (result === null)
|
|
141
|
+
continue;
|
|
142
|
+
const s = result.semantics;
|
|
143
|
+
if (s.operation !== 'force-push' && s.operation !== 'push-delete')
|
|
144
|
+
continue;
|
|
145
|
+
if (!s.targetsSharedBranch)
|
|
146
|
+
continue;
|
|
147
|
+
const what = s.operation === 'push-delete' ? 'delete' : 'force-overwrite';
|
|
148
|
+
const remote = s.remote === undefined ? '' : ` (remote ${s.remote})`;
|
|
149
|
+
return `git push would ${what} protected branch: ${s.branch}${remote}`;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
110
153
|
/**
|
|
111
154
|
* Deterministic P0 hard-deny reason, or undefined when the call may proceed to
|
|
112
155
|
* later stages.
|
|
@@ -115,8 +158,7 @@ export function hardDenyReason(ctx) {
|
|
|
115
158
|
const args = ctx.args;
|
|
116
159
|
if (containsCredentialMaterial(args))
|
|
117
160
|
return 'call contains credential or private-key material';
|
|
118
|
-
|
|
119
|
-
if (shell && typeof args.command === 'string') {
|
|
161
|
+
if (SHELL_TOOLS.has(ctx.tool) && typeof args.command === 'string') {
|
|
120
162
|
let commands = [];
|
|
121
163
|
try {
|
|
122
164
|
commands = decomposeShellCommand(args.command).commands;
|
|
@@ -133,8 +175,16 @@ export function hardDenyReason(ctx) {
|
|
|
133
175
|
if (redir !== undefined)
|
|
134
176
|
return `shell redirect targets protected path: ${redir}`;
|
|
135
177
|
}
|
|
178
|
+
const rewrite = gitRemoteRewriteReason(commands);
|
|
179
|
+
if (rewrite !== undefined)
|
|
180
|
+
return rewrite;
|
|
136
181
|
}
|
|
137
182
|
const path = pathArgument(args);
|
|
183
|
+
// 注意:这里**刻意不**用 SHELL_TOOLS。本判据读的是 `pathArgument`(file_path /
|
|
184
|
+
// path / cwd / **workdir**),它是"被改写的路径"而不是"要执行的命令"。shell 工具的
|
|
185
|
+
// 真目标在 command 里,由上面的命令级检查负责;若把 shell 工具也塞进来,
|
|
186
|
+
// `shell({ command: 'ls', workdir: '/etc' })` 这种纯读操作会因为 workdir 落在受保护
|
|
187
|
+
// 前缀而被 P0 永久挡住——那是过度拦截,而且 P0 撤销不了。
|
|
138
188
|
if (path !== undefined && (DESTRUCTIVE_TOOL.test(ctx.tool) || ctx.tool === 'bash' || ctx.tool === 'pwsh' || ctx.tool === 'write' || ctx.tool === 'edit')) {
|
|
139
189
|
if (isProtectedDestructiveTarget(path, ctx.dshHome))
|
|
140
190
|
return `mutation targets protected path: ${path}`;
|
package/lib/evaluate.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { normalizeWorkspacePath } from './path.js';
|
|
7
7
|
import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
|
|
8
|
-
import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
8
|
+
import { commandArgv, decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
9
9
|
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
10
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
10
11
|
/**
|
|
11
12
|
* Tool names that execute a shell command string. `shell` and `terminal` are
|
|
12
13
|
* DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
|
|
@@ -95,7 +96,10 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
95
96
|
if (rule.argv?.pipeline !== undefined && rule.argv.pipeline.length > 0) {
|
|
96
97
|
if (commands.length === 0)
|
|
97
98
|
return false;
|
|
98
|
-
|
|
99
|
+
// Join the FULL argv of each simple command: `c.command` alone is just the
|
|
100
|
+
// command word, so `curl https://x | sh` would read as `curl|sh` and the
|
|
101
|
+
// documented `curl|sh` pattern could never see its own subject.
|
|
102
|
+
const pipelineText = commands.map((c) => commandArgv(c)).join('|');
|
|
99
103
|
const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
|
|
100
104
|
if (!compiled.some((g) => g.test(pipelineText)))
|
|
101
105
|
return false;
|
|
@@ -105,6 +109,13 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
105
109
|
if (!matchNetwork(rule.network, ctx.network))
|
|
106
110
|
return false;
|
|
107
111
|
}
|
|
112
|
+
// branch dimension: git semantics only; non-git commands satisfy no branch
|
|
113
|
+
// dimension (per sub-dimension above). Never throws — a parser is pure, and
|
|
114
|
+
// the dispatcher falls back to `family: 'unknown'` rather than raising.
|
|
115
|
+
if (rule.branch !== undefined) {
|
|
116
|
+
if (!matchBranch(rule.branch, commands))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
108
119
|
return true;
|
|
109
120
|
}
|
|
110
121
|
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
@@ -371,3 +382,66 @@ function matchNetwork(network, ctx) {
|
|
|
371
382
|
}
|
|
372
383
|
return true;
|
|
373
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Per-call memo for command classification.
|
|
387
|
+
*
|
|
388
|
+
* `ruleMatches` runs once per rule, so a rule chain with several `branch`
|
|
389
|
+
* rules would otherwise re-parse the same command each time. `dispatchCommand`
|
|
390
|
+
* is pure, so caching its result per `SimpleCommand` object is safe; the
|
|
391
|
+
* WeakMap keeps no references once the command array is dropped.
|
|
392
|
+
*/
|
|
393
|
+
const commandSemanticsCache = new WeakMap();
|
|
394
|
+
function semanticsOf(cmd) {
|
|
395
|
+
const cached = commandSemanticsCache.get(cmd);
|
|
396
|
+
if (cached !== undefined)
|
|
397
|
+
return cached;
|
|
398
|
+
// The parser needs the WHOLE argv: `cmd.command` carries only the command
|
|
399
|
+
// word, so `git push --force origin main` would arrive as `git` and classify
|
|
400
|
+
// as `family: unknown` (no branch, no remote, no flags).
|
|
401
|
+
const result = dispatchCommand(commandArgv(cmd));
|
|
402
|
+
commandSemanticsCache.set(cmd, result);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Branch dimension matching (git semantics only).
|
|
407
|
+
*
|
|
408
|
+
* Candidates come from the shared command dispatcher, so `refspec` forms
|
|
409
|
+
* (`HEAD:main`) are already split and a remote name is never mistaken for a
|
|
410
|
+
* branch name. Sub-dimensions are AND (each present one must match); within a
|
|
411
|
+
* sub-dimension the entries are OR.
|
|
412
|
+
*
|
|
413
|
+
* **Non-git commands satisfy no branch dimension**: `dispatchCommand` reports
|
|
414
|
+
* `family: 'unknown'` for them rather than throwing, so a rule that names a
|
|
415
|
+
* branch simply does not match `rm -rf`, `npm install` and friends. That is
|
|
416
|
+
* deliberate — the write-path/blacklist layers cover non-git commands.
|
|
417
|
+
*
|
|
418
|
+
* `shared` is the parser's STATIC protected-branch rule (main / master /
|
|
419
|
+
* production / release / stable, see `src/parsers/git.ts`). No git subprocess
|
|
420
|
+
* ever runs on the decision path.
|
|
421
|
+
*/
|
|
422
|
+
function matchBranch(branch, commands) {
|
|
423
|
+
if (commands.length === 0)
|
|
424
|
+
return false;
|
|
425
|
+
const branches = [];
|
|
426
|
+
const remotes = [];
|
|
427
|
+
let anyShared = false;
|
|
428
|
+
for (const cmd of commands) {
|
|
429
|
+
const result = semanticsOf(cmd);
|
|
430
|
+
if (result === null || result.semantics.family !== 'git')
|
|
431
|
+
continue;
|
|
432
|
+
const { branch: name, remote, targetsSharedBranch } = result.semantics;
|
|
433
|
+
if (targetsSharedBranch)
|
|
434
|
+
anyShared = true;
|
|
435
|
+
if (typeof name === 'string' && name.length > 0)
|
|
436
|
+
branches.push(name);
|
|
437
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
438
|
+
remotes.push(remote);
|
|
439
|
+
}
|
|
440
|
+
if (branch.shared && !anyShared)
|
|
441
|
+
return false;
|
|
442
|
+
if (branch.target.length > 0 && !branches.some((b) => branch.target.some((g) => g.re.test(b))))
|
|
443
|
+
return false;
|
|
444
|
+
if (branch.remote.length > 0 && !remotes.some((r) => branch.remote.some((g) => g.re.test(r))))
|
|
445
|
+
return false;
|
|
446
|
+
return true;
|
|
447
|
+
}
|
package/lib/events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RulesView } from './rules-view.js';
|
|
1
2
|
/** One gate decision event. `kind` drives the client notice strip styling. */
|
|
2
3
|
export interface GateEvent {
|
|
3
4
|
/** Monotonic id (resumed from the file across restarts). */
|
|
@@ -8,9 +9,11 @@ export interface GateEvent {
|
|
|
8
9
|
/**
|
|
9
10
|
* auto = allowed (rule/grant/llm), ask = routed to the human, deny = vetoed,
|
|
10
11
|
* learned = confirmation settled, manual-* = the human's terminal answer to an
|
|
11
|
-
* ask (approved / rejected / cancelled)
|
|
12
|
+
* ask (approved / rejected / cancelled), stand-down = the gate itself is
|
|
13
|
+
* inactive in this session's permission preset and therefore decided nothing
|
|
14
|
+
* (see {@link GateEvent.verdict} `stand-down`).
|
|
12
15
|
*/
|
|
13
|
-
readonly kind: 'auto' | 'ask' | 'deny' | 'learned' | 'manual-approved' | 'manual-rejected' | 'manual-cancelled';
|
|
16
|
+
readonly kind: 'auto' | 'ask' | 'deny' | 'learned' | 'manual-approved' | 'manual-rejected' | 'manual-cancelled' | 'stand-down';
|
|
14
17
|
/** Risk category when an LLM verdict contributed to the decision. */
|
|
15
18
|
readonly risk?: string;
|
|
16
19
|
/** Truncated decision reason. */
|
|
@@ -133,6 +136,8 @@ export declare const DIFF_ROUTE = "/api/dsh-perm-gate/diff";
|
|
|
133
136
|
export declare const REVERT_ROUTE = "/api/dsh-perm-gate/revert";
|
|
134
137
|
export declare const SNAPSHOTS_STATS_ROUTE = "/api/dsh-perm-gate/snapshots-stats";
|
|
135
138
|
export declare const SNAPSHOTS_CLEAR_ROUTE = "/api/dsh-perm-gate/snapshots-clear";
|
|
139
|
+
export declare const DRY_RUN_ROUTE = "/api/dsh-perm-gate/dry-run";
|
|
140
|
+
export declare const RULES_ROUTE = "/api/dsh-perm-gate/rules";
|
|
136
141
|
/**
|
|
137
142
|
* Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
|
|
138
143
|
* service. Returns whether the route was registered (false when the service is
|
|
@@ -196,6 +201,55 @@ export interface LearningRouteProvider {
|
|
|
196
201
|
threshold(): number;
|
|
197
202
|
reset(key: string, fp?: string): void;
|
|
198
203
|
}
|
|
204
|
+
/** One rule-test request: the call to evaluate, nothing else. */
|
|
205
|
+
export interface DryRunRequest {
|
|
206
|
+
readonly tool: string;
|
|
207
|
+
readonly args: Record<string, unknown>;
|
|
208
|
+
readonly permissive?: boolean;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The rule-test face the settings card calls. `run` evaluates one call against
|
|
212
|
+
* the ruleset the gate currently has loaded and returns the report from
|
|
213
|
+
* `dryRunResult`. It must not change any state — see {@link registerDryRunRoute}.
|
|
214
|
+
*/
|
|
215
|
+
export interface DryRunRouteProvider {
|
|
216
|
+
run(request: DryRunRequest): unknown;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Register `POST /api/dsh-perm-gate/dry-run` — the rule-test panel's endpoint.
|
|
220
|
+
* Body `{ tool, args?, permissive? }`; the response carries the effective
|
|
221
|
+
* verdict plus the rule layer's own answer.
|
|
222
|
+
*
|
|
223
|
+
* **Read-only by construction.** Unlike `/learning` (GET reads, POST resets), this
|
|
224
|
+
* route has no write form: it never edits rules, grants, learning state or the
|
|
225
|
+
* settings namespace, and the provider it delegates to evaluates against a
|
|
226
|
+
* throwaway or live read path only. Testing a rule must not be able to change
|
|
227
|
+
* the ruleset — otherwise "test it first" would itself be the risky action.
|
|
228
|
+
*
|
|
229
|
+
* Returns whether the route was registered.
|
|
230
|
+
*/
|
|
231
|
+
export declare function registerDryRunRoute(server: unknown, provider: DryRunRouteProvider): (() => void) | undefined;
|
|
232
|
+
/**
|
|
233
|
+
* The permissions-file face the settings card calls. `view` reads the rules file
|
|
234
|
+
* the gate is loading; it must never write it.
|
|
235
|
+
*/
|
|
236
|
+
export interface RulesRouteProvider {
|
|
237
|
+
view(): RulesView;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Register `GET /api/dsh-perm-gate/rules` — the panel's view of the permissions
|
|
241
|
+
* YAML the gate actually loads.
|
|
242
|
+
*
|
|
243
|
+
* **Read-only by construction**, like {@link registerDryRunRoute}: there is no
|
|
244
|
+
* write form. The card could already *edit* this file — the allowlist section
|
|
245
|
+
* appends and replaces patterns — but had no way to display it, so an operator
|
|
246
|
+
* changing rules through the panel could not see the document being changed.
|
|
247
|
+
* Reading must not be the risky act, and a viewer that could write would make
|
|
248
|
+
* "let me just look at the rules" a mutation.
|
|
249
|
+
*
|
|
250
|
+
* Returns whether the route was registered.
|
|
251
|
+
*/
|
|
252
|
+
export declare function registerRulesRoute(server: unknown, provider: RulesRouteProvider): (() => void) | undefined;
|
|
199
253
|
/**
|
|
200
254
|
* Register the learning-store routes on the webServer service:
|
|
201
255
|
* `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
|
package/lib/events.js
CHANGED
|
@@ -357,6 +357,8 @@ export const DIFF_ROUTE = '/api/dsh-perm-gate/diff';
|
|
|
357
357
|
export const REVERT_ROUTE = '/api/dsh-perm-gate/revert';
|
|
358
358
|
export const SNAPSHOTS_STATS_ROUTE = '/api/dsh-perm-gate/snapshots-stats';
|
|
359
359
|
export const SNAPSHOTS_CLEAR_ROUTE = '/api/dsh-perm-gate/snapshots-clear';
|
|
360
|
+
export const DRY_RUN_ROUTE = '/api/dsh-perm-gate/dry-run';
|
|
361
|
+
export const RULES_ROUTE = '/api/dsh-perm-gate/rules';
|
|
360
362
|
/**
|
|
361
363
|
* Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
|
|
362
364
|
* service. Returns whether the route was registered (false when the service is
|
|
@@ -682,6 +684,96 @@ export function registerNetworkRoute(server, provider) {
|
|
|
682
684
|
},
|
|
683
685
|
});
|
|
684
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Register `POST /api/dsh-perm-gate/dry-run` — the rule-test panel's endpoint.
|
|
689
|
+
* Body `{ tool, args?, permissive? }`; the response carries the effective
|
|
690
|
+
* verdict plus the rule layer's own answer.
|
|
691
|
+
*
|
|
692
|
+
* **Read-only by construction.** Unlike `/learning` (GET reads, POST resets), this
|
|
693
|
+
* route has no write form: it never edits rules, grants, learning state or the
|
|
694
|
+
* settings namespace, and the provider it delegates to evaluates against a
|
|
695
|
+
* throwaway or live read path only. Testing a rule must not be able to change
|
|
696
|
+
* the ruleset — otherwise "test it first" would itself be the risky action.
|
|
697
|
+
*
|
|
698
|
+
* Returns whether the route was registered.
|
|
699
|
+
*/
|
|
700
|
+
export function registerDryRunRoute(server, provider) {
|
|
701
|
+
const ws = routeServer(server);
|
|
702
|
+
if (ws === undefined)
|
|
703
|
+
return undefined;
|
|
704
|
+
return ws.register({
|
|
705
|
+
kind: 'exact',
|
|
706
|
+
path: DRY_RUN_ROUTE,
|
|
707
|
+
handler: (rawReq, rawRes) => {
|
|
708
|
+
const req = rawReq;
|
|
709
|
+
const res = rawRes;
|
|
710
|
+
if (req.method !== 'POST') {
|
|
711
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
void (async () => {
|
|
715
|
+
try {
|
|
716
|
+
const parsed = await readBody(req);
|
|
717
|
+
const tool = parsed.tool;
|
|
718
|
+
if (typeof tool !== 'string' || tool === '') {
|
|
719
|
+
json(res, 400, { ok: false, error: 'missing tool' });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const args = parsed.args;
|
|
723
|
+
if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
|
|
724
|
+
json(res, 400, { ok: false, error: 'args must be an object' });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const result = provider.run({
|
|
728
|
+
tool,
|
|
729
|
+
args: (args ?? {}),
|
|
730
|
+
...(typeof parsed.permissive === 'boolean' ? { permissive: parsed.permissive } : {}),
|
|
731
|
+
});
|
|
732
|
+
json(res, 200, { ok: true, result });
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
json(res, 500, { ok: false, error: String(e?.message ?? e) });
|
|
736
|
+
}
|
|
737
|
+
})();
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Register `GET /api/dsh-perm-gate/rules` — the panel's view of the permissions
|
|
743
|
+
* YAML the gate actually loads.
|
|
744
|
+
*
|
|
745
|
+
* **Read-only by construction**, like {@link registerDryRunRoute}: there is no
|
|
746
|
+
* write form. The card could already *edit* this file — the allowlist section
|
|
747
|
+
* appends and replaces patterns — but had no way to display it, so an operator
|
|
748
|
+
* changing rules through the panel could not see the document being changed.
|
|
749
|
+
* Reading must not be the risky act, and a viewer that could write would make
|
|
750
|
+
* "let me just look at the rules" a mutation.
|
|
751
|
+
*
|
|
752
|
+
* Returns whether the route was registered.
|
|
753
|
+
*/
|
|
754
|
+
export function registerRulesRoute(server, provider) {
|
|
755
|
+
const ws = routeServer(server);
|
|
756
|
+
if (ws === undefined)
|
|
757
|
+
return undefined;
|
|
758
|
+
return ws.register({
|
|
759
|
+
kind: 'exact',
|
|
760
|
+
path: RULES_ROUTE,
|
|
761
|
+
handler: (rawReq, rawRes) => {
|
|
762
|
+
const req = rawReq;
|
|
763
|
+
const res = rawRes;
|
|
764
|
+
if (req.method !== 'GET') {
|
|
765
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
json(res, 200, { ok: true, result: provider.view() });
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
json(res, 500, { ok: false, error: String(e?.message ?? e) });
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
}
|
|
685
777
|
/**
|
|
686
778
|
* Register the learning-store routes on the webServer service:
|
|
687
779
|
* `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { Config, RULES_NAMESPACE, RulesSchema, isRulesConfigured, readRulesFromSettings, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
|
|
5
|
+
import { appendAllowToSettings, replaceAllowInSettings } from './allowlist.js';
|
|
6
|
+
import { runDryRun } from './dry-run.js';
|
|
7
|
+
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
|
|
5
8
|
import { buildReceiverInfo } from './receiver-info.js';
|
|
9
|
+
import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
|
|
6
10
|
import { PermGateRuntime } from './runtime.js';
|
|
7
11
|
import { classifySessions, sweepSessionData } from './session-sweep.js';
|
|
8
12
|
import { decideNetworkTarget } from './network.js';
|
|
9
13
|
import { NetworkLifecycle } from './network-lifecycle.js';
|
|
10
|
-
import { RuleWatcher } from './watch.js';
|
|
11
14
|
export const name = 'dsh-perm-gate';
|
|
12
15
|
/**
|
|
13
16
|
* The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
|
|
@@ -182,6 +185,37 @@ export function apply(ctx, config = {}) {
|
|
|
182
185
|
// read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
|
|
183
186
|
// entry that omits `config` still records events, snapshots and learning.
|
|
184
187
|
const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
|
|
188
|
+
// ─── Rules sources (dual-source: settings first, rulesFile fallback) ──
|
|
189
|
+
// The `dsh-perm-gate-rules` settings namespace is the primary rules store —
|
|
190
|
+
// the framework owns it, so the gate loads with no rules file on disk. A
|
|
191
|
+
// namespace still at bare defaults never shadows the file, and the first
|
|
192
|
+
// settings write seeds the file document along (implicit migration). Both
|
|
193
|
+
// references are filled in by the settings registration below; until then
|
|
194
|
+
// the file paths run exactly as before.
|
|
195
|
+
const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
|
|
196
|
+
let rulesFromSettings = () => ({});
|
|
197
|
+
const rulesScopeRef = { scope: undefined };
|
|
198
|
+
/** The raw file document, for seeding the namespace on its first write. */
|
|
199
|
+
const fileSeedDoc = () => {
|
|
200
|
+
try {
|
|
201
|
+
const text = readFileSync(rulesFilePath, 'utf8');
|
|
202
|
+
if (text.trim() === '')
|
|
203
|
+
return undefined;
|
|
204
|
+
return parse(text);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const settingsWrite = (write, fallback) => {
|
|
211
|
+
const scope = rulesScopeRef.scope;
|
|
212
|
+
if (scope === undefined)
|
|
213
|
+
return fallback();
|
|
214
|
+
void write(scope, fileSeedDoc()).catch((e) => {
|
|
215
|
+
console.warn('[dsh-perm-gate] settings rules write failed:', e);
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
185
219
|
// Host model-group services (typed minimally; supplied by the dsh runtime
|
|
186
220
|
// through the loader `inject` — dsh-approval-gate demonstrates the same
|
|
187
221
|
// contract). The llm service backs the `host` receiver; agentDefaultModel
|
|
@@ -322,8 +356,19 @@ export function apply(ctx, config = {}) {
|
|
|
322
356
|
: join(dataDir, 'learning.json'),
|
|
323
357
|
// The rules document lives in the plugin's data dir by default, so a
|
|
324
358
|
// `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
|
|
325
|
-
// also having to declare `rulesFile` in the composition entry.
|
|
326
|
-
|
|
359
|
+
// also having to declare `rulesFile` in the composition entry. It is the
|
|
360
|
+
// FALLBACK source: a configured `dsh-perm-gate-rules` namespace wins.
|
|
361
|
+
rulesFile: rulesFilePath,
|
|
362
|
+
// Settings-first rules: the live `dsh-perm-gate-rules` document when the
|
|
363
|
+
// namespace carries a real configuration, else undefined (file fallback).
|
|
364
|
+
readRulesDocument: () => readRulesFromSettings({ get: () => rulesFromSettings() }),
|
|
365
|
+
// Settings-backed allowlist writes; a falsy verdict (settings service
|
|
366
|
+
// absent) makes the runtime fall back to its own rules-file paths, and the
|
|
367
|
+
// namespace's watch triggers the reload once a write lands.
|
|
368
|
+
allowlistWriter: {
|
|
369
|
+
append: (pattern, reason) => settingsWrite((scope, seed) => appendAllowToSettings(scope, pattern, reason, seed), () => false),
|
|
370
|
+
replace: (patterns, reason) => settingsWrite((scope, seed) => replaceAllowInSettings(scope, patterns, reason, seed), () => false),
|
|
371
|
+
},
|
|
327
372
|
// The whole gate is scoped to the presets that opt into it (default: the
|
|
328
373
|
// `permissive` tier this plugin adds); elsewhere it stands down entirely.
|
|
329
374
|
gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
|
|
@@ -414,15 +459,17 @@ export function apply(ctx, config = {}) {
|
|
|
414
459
|
},
|
|
415
460
|
onChange: () => { },
|
|
416
461
|
onScope: (scope) => {
|
|
417
|
-
// Seed the editable whitelist from the rules
|
|
418
|
-
// carries no override yet, so the card shows the current allow
|
|
462
|
+
// Seed the editable whitelist from the rules source only when the
|
|
463
|
+
// namespace carries no override yet, so the card shows the current allow
|
|
464
|
+
// patterns.
|
|
419
465
|
const surface = asSurface(scope.get());
|
|
420
466
|
if (surface !== undefined && !Array.isArray(surface.allowlist)) {
|
|
421
467
|
const patterns = runtime.allowlist();
|
|
422
468
|
if (patterns.length > 0)
|
|
423
469
|
void scope.update({ allowlist: patterns.slice() });
|
|
424
470
|
}
|
|
425
|
-
// Card edits -> namespace ->
|
|
471
|
+
// Card edits -> namespace -> rules source (settings via the runtime's
|
|
472
|
+
// writer, else the rules file mirror) + reload.
|
|
426
473
|
scope.watch(() => {
|
|
427
474
|
const next = asSurface(scope.get());
|
|
428
475
|
if (next !== undefined && Array.isArray(next.allowlist)) {
|
|
@@ -442,6 +489,22 @@ export function apply(ctx, config = {}) {
|
|
|
442
489
|
});
|
|
443
490
|
},
|
|
444
491
|
});
|
|
492
|
+
// The rules namespace: the primary rules store (JSON twin of rules.yml).
|
|
493
|
+
// Every change recompiles the gate — the settings half of the hot reload
|
|
494
|
+
// that used to be a chokidar file watcher. `onScope` hands the live scope to
|
|
495
|
+
// the allowlist writers above; the seed entry is `{}`, so an untouched
|
|
496
|
+
// namespace reads as "not configured" and the file paths stay in force.
|
|
497
|
+
installSettingsSection(ctx, RULES_NAMESPACE, RulesSchema, {}, {
|
|
498
|
+
setSource: (source) => {
|
|
499
|
+
rulesFromSettings = source;
|
|
500
|
+
},
|
|
501
|
+
onChange: () => {
|
|
502
|
+
runtime.reload();
|
|
503
|
+
},
|
|
504
|
+
onScope: (scope) => {
|
|
505
|
+
rulesScopeRef.scope = scope;
|
|
506
|
+
},
|
|
507
|
+
});
|
|
445
508
|
// Event feed HTTP API (best effort): the dsh webServer service exposes the
|
|
446
509
|
// JSONL decision events to the browser half; without it events stay on disk.
|
|
447
510
|
try {
|
|
@@ -488,6 +551,36 @@ export function apply(ctx, config = {}) {
|
|
|
488
551
|
});
|
|
489
552
|
if (offNetwork !== undefined)
|
|
490
553
|
ctx.effect(() => () => { offNetwork(); }, 'dsh-perm-gate: network route');
|
|
554
|
+
// Rule test (dry-run): evaluate one would-be call against the ruleset the
|
|
555
|
+
// gate currently has loaded, and report both the effective verdict and the
|
|
556
|
+
// rule layer's own answer. Read-only — the route has no write form, so
|
|
557
|
+
// testing a rule can never change it. It runs against the LIVE runtime
|
|
558
|
+
// rather than a fresh one so the panel tests the rules in force, not a
|
|
559
|
+
// re-derivation of them (a fresh runtime would also re-resolve the chain
|
|
560
|
+
// from a different root and could silently answer about a different file).
|
|
561
|
+
const offDryRun = registerDryRunRoute(webServer, {
|
|
562
|
+
run: (request) => runDryRun({
|
|
563
|
+
tool: request.tool,
|
|
564
|
+
args: request.args,
|
|
565
|
+
...(request.permissive !== undefined ? { permissive: request.permissive } : {}),
|
|
566
|
+
}, runtime),
|
|
567
|
+
});
|
|
568
|
+
if (offDryRun !== undefined)
|
|
569
|
+
ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
|
|
570
|
+
// Permissions view: the document the gate is actually loading, shown so
|
|
571
|
+
// the panel is never editing rules it cannot display. Read-only, and
|
|
572
|
+
// resolved exactly as the gate resolves it — settings first, the file the
|
|
573
|
+
// fallback resolves to otherwise.
|
|
574
|
+
const offRules = registerRulesRoute(webServer, {
|
|
575
|
+
view: () => {
|
|
576
|
+
const doc = rulesFromSettings();
|
|
577
|
+
if (isRulesConfigured(doc))
|
|
578
|
+
return readRulesViewFromSettings(doc);
|
|
579
|
+
return readRulesView(rulesFilePath);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
if (offRules !== undefined)
|
|
583
|
+
ctx.effect(() => () => { offRules(); }, 'dsh-perm-gate: rules route');
|
|
491
584
|
// Receiver projection for the settings card (provider/model catalog is
|
|
492
585
|
// potentially slow to enumerate — cached briefly).
|
|
493
586
|
let receiverCache = null;
|
|
@@ -633,23 +726,10 @@ export function apply(ctx, config = {}) {
|
|
|
633
726
|
networkRefs.snapshot = () => networkLifecycle.snapshot();
|
|
634
727
|
if (networkRefs.appliedKey === undefined)
|
|
635
728
|
networkRefs.appliedKey = networkKeyOf(current());
|
|
636
|
-
// ───
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
debounceMs: watchDebounceMs,
|
|
642
|
-
logger: { warn: (msg) => console.warn(msg) },
|
|
643
|
-
});
|
|
644
|
-
// Watch the effective rules file for the current workspace.
|
|
645
|
-
const rulesFile = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
|
|
646
|
-
const cwd = typeof config.cwd === 'string' ? config.cwd : process.cwd();
|
|
647
|
-
ruleWatcher.watch(cwd, [rulesFile], () => {
|
|
648
|
-
runtime.reload();
|
|
649
|
-
});
|
|
650
|
-
ctx.effect(() => () => {
|
|
651
|
-
ruleWatcher.closeAll();
|
|
652
|
-
}, 'dsh-perm-gate: rule watcher');
|
|
653
|
-
}
|
|
729
|
+
// ─── Rule hot reload ────────────────────────────────────────────────
|
|
730
|
+
// The file watcher (chokidar) is gone: the settings namespace is the primary
|
|
731
|
+
// rules store and its `scope.watch` recompiles the gate on every change
|
|
732
|
+
// (see the RULES_NAMESPACE registration above). A rules FILE edit is no
|
|
733
|
+
// longer picked up mid-session — the settings document is the source to edit.
|
|
654
734
|
return runtime;
|
|
655
735
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git command parser - extracts subcommand, branch, remote, flags, destructiveness.
|
|
3
|
+
* P0 priority - most critical for branch permissions.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - git push/force push detection
|
|
7
|
+
* - git branch operations (delete, rename)
|
|
8
|
+
* - git checkout/switch with branch names
|
|
9
|
+
* - git merge/rebase
|
|
10
|
+
* - git reset (soft/hard/mixed)
|
|
11
|
+
* - git clean (destructive file deletion)
|
|
12
|
+
* - git stash operations
|
|
13
|
+
* - git refspec parsing (HEAD:main)
|
|
14
|
+
* - Remote vs branch name disambiguation
|
|
15
|
+
*/
|
|
16
|
+
import type { CommandParser, CommandSemantics } from '../command-semantics.js';
|
|
17
|
+
export declare class GitParser implements CommandParser {
|
|
18
|
+
readonly name = "git";
|
|
19
|
+
readonly priority = 10;
|
|
20
|
+
canParse(command: string): boolean;
|
|
21
|
+
parse(command: string): CommandSemantics | null;
|
|
22
|
+
private analyzeSubcommand;
|
|
23
|
+
private parseGitArgs;
|
|
24
|
+
private extractFlags;
|
|
25
|
+
private extractRemote;
|
|
26
|
+
private extractBranch;
|
|
27
|
+
private extractBranchName;
|
|
28
|
+
private extractRefs;
|
|
29
|
+
/**
|
|
30
|
+
* 目标是否落在保护分支上。
|
|
31
|
+
*
|
|
32
|
+
* **只剥已知的 ref 前缀,不对任意 `/` 取末段。** 早先的写法是
|
|
33
|
+
* `branch.split('/').pop()`,于是:
|
|
34
|
+
* - `refs/heads/main` → `main` ✅ 期望如此
|
|
35
|
+
* - `backup/main` → `main` ❌ **误判**(它是含斜杠的普通分支名)
|
|
36
|
+
* - `feat/release` → `release` ❌ **误判**
|
|
37
|
+
*
|
|
38
|
+
* 误判方向不是"更安全"。这条判据喂给 P0 硬拒——而 P0 是**不可配置撤销**的
|
|
39
|
+
* (见 engine.ts 顶部:"deterministic, monotonic, never negotiated")。
|
|
40
|
+
* 漏判还有关键词层与 LLM 层兜底,误判则是"合法工作流被永久挡住且无出口"。
|
|
41
|
+
*/
|
|
42
|
+
private isProtectedBranch;
|
|
43
|
+
}
|