dsh-perm-gate 2.1.2 → 3.0.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 (58) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +210 -0
  4. package/INSTALL.ja.md +14 -8
  5. package/INSTALL.ko.md +15 -9
  6. package/INSTALL.md +16 -8
  7. package/INSTALL.zh.md +12 -7
  8. package/README.ja.md +19 -6
  9. package/README.ko.md +20 -7
  10. package/README.md +35 -4
  11. package/README.zh.md +13 -4
  12. package/docs/baseline-guard.md +56 -0
  13. package/docs/plan-rules-to-settings.md +217 -0
  14. package/docs/rules-format.en.md +294 -0
  15. package/docs/rules-format.md +279 -0
  16. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  17. package/dsh.plugin.json +1 -1
  18. package/examples/permissions.example.yaml +16 -1
  19. package/lib/allowlist.d.ts +27 -0
  20. package/lib/allowlist.js +103 -1
  21. package/lib/cli.js +11 -14
  22. package/lib/client.js +484 -6
  23. package/lib/client.js.map +1 -1
  24. package/lib/command-dispatcher.d.ts +22 -0
  25. package/lib/command-dispatcher.js +97 -0
  26. package/lib/command-semantics.d.ts +62 -0
  27. package/lib/command-semantics.js +6 -0
  28. package/lib/compiler.d.ts +11 -0
  29. package/lib/compiler.js +16 -0
  30. package/lib/config.d.ts +73 -12
  31. package/lib/config.js +56 -14
  32. package/lib/dry-run.d.ts +87 -0
  33. package/lib/dry-run.js +110 -0
  34. package/lib/engine.js +52 -2
  35. package/lib/evaluate.js +76 -2
  36. package/lib/events.d.ts +56 -2
  37. package/lib/events.js +92 -0
  38. package/lib/index.js +106 -26
  39. package/lib/parsers/git.d.ts +43 -0
  40. package/lib/parsers/git.js +404 -0
  41. package/lib/parsers/shell-cmds.d.ts +32 -0
  42. package/lib/parsers/shell-cmds.js +293 -0
  43. package/lib/preset.d.ts +13 -0
  44. package/lib/preset.js +17 -0
  45. package/lib/risk.d.ts +17 -3
  46. package/lib/risk.js +17 -3
  47. package/lib/rule-chain.js +13 -22
  48. package/lib/rule-dims.d.ts +35 -0
  49. package/lib/rule-dims.js +36 -0
  50. package/lib/rule.d.ts +36 -1
  51. package/lib/rule.js +56 -8
  52. package/lib/rules-view.d.ts +52 -0
  53. package/lib/rules-view.js +126 -0
  54. package/lib/runtime.d.ts +121 -7
  55. package/lib/runtime.js +204 -25
  56. package/lib/shell.d.ts +14 -0
  57. package/lib/shell.js +17 -0
  58. package/package.json +8 -1
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
- const shell = /^(?:bash|pwsh|sh|cmd)$/.test(ctx.tool);
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
- const pipelineText = commands.map((c) => c.command).join('|');
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 { Config, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
4
- import { registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes } from './events.js';
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
- rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
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 file only when the namespace
418
- // carries no override yet, so the card shows the current allow patterns.
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 -> rulesFile (mirror + reload).
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
- // ─── Hot reload watcher (Phase 3, T3.6) ────────────────────────────
637
- const watchEnabled = typeof config.watch === 'boolean' ? config.watch : true;
638
- if (watchEnabled) {
639
- const watchDebounceMs = typeof config.watchDebounceMs === 'number' ? config.watchDebounceMs : 300;
640
- const ruleWatcher = new RuleWatcher({
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
+ }