dsh-perm-gate 2.1.2 → 4.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 (57) hide show
  1. package/CHANGELOG.ja.md +82 -0
  2. package/CHANGELOG.ko.md +80 -0
  3. package/CHANGELOG.md +225 -0
  4. package/INSTALL.ja.md +15 -9
  5. package/INSTALL.ko.md +16 -10
  6. package/INSTALL.md +17 -9
  7. package/INSTALL.zh.md +13 -8
  8. package/README.ja.md +17 -5
  9. package/README.ko.md +18 -6
  10. package/README.md +33 -3
  11. package/README.zh.md +11 -3
  12. package/docs/baseline-guard.md +56 -0
  13. package/docs/rules-format.en.md +294 -0
  14. package/docs/rules-format.md +279 -0
  15. package/docs/ui-gap-analysis-and-branch-permissions.md +338 -0
  16. package/dsh.plugin.json +2 -2
  17. package/examples/permissions.example.yaml +16 -1
  18. package/lib/allowlist.d.ts +27 -0
  19. package/lib/allowlist.js +103 -1
  20. package/lib/cli.js +11 -14
  21. package/lib/client.js +646 -95
  22. package/lib/client.js.map +1 -1
  23. package/lib/command-dispatcher.d.ts +22 -0
  24. package/lib/command-dispatcher.js +97 -0
  25. package/lib/command-semantics.d.ts +62 -0
  26. package/lib/command-semantics.js +6 -0
  27. package/lib/compiler.d.ts +11 -0
  28. package/lib/compiler.js +16 -0
  29. package/lib/config.d.ts +73 -12
  30. package/lib/config.js +56 -14
  31. package/lib/dry-run.d.ts +87 -0
  32. package/lib/dry-run.js +110 -0
  33. package/lib/engine.js +52 -2
  34. package/lib/evaluate.js +76 -2
  35. package/lib/events.d.ts +91 -2
  36. package/lib/events.js +220 -0
  37. package/lib/index.js +111 -26
  38. package/lib/parsers/git.d.ts +43 -0
  39. package/lib/parsers/git.js +404 -0
  40. package/lib/parsers/shell-cmds.d.ts +32 -0
  41. package/lib/parsers/shell-cmds.js +293 -0
  42. package/lib/preset.d.ts +13 -0
  43. package/lib/preset.js +17 -0
  44. package/lib/risk.d.ts +17 -3
  45. package/lib/risk.js +17 -3
  46. package/lib/rule-chain.js +13 -22
  47. package/lib/rule-dims.d.ts +35 -0
  48. package/lib/rule-dims.js +36 -0
  49. package/lib/rule.d.ts +36 -1
  50. package/lib/rule.js +56 -8
  51. package/lib/rules-view.d.ts +52 -0
  52. package/lib/rules-view.js +126 -0
  53. package/lib/runtime.d.ts +121 -7
  54. package/lib/runtime.js +204 -25
  55. package/lib/shell.d.ts +14 -0
  56. package/lib/shell.js +17 -0
  57. package/package.json +13 -5
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, registerStreamRoute } 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 {
@@ -457,6 +520,11 @@ export function apply(ctx, config = {}) {
457
520
  const offEvents = registerEventsRoute(webServer, runtime.eventLog);
458
521
  if (offEvents !== undefined)
459
522
  ctx.effect(() => () => { offEvents(); }, 'dsh-perm-gate: events route');
523
+ // Push plane: the browser half follows decisions over one SSE connection
524
+ // instead of re-reading the event log on a timer.
525
+ const offStream = registerStreamRoute(webServer, runtime.eventLog);
526
+ if (offStream !== undefined)
527
+ ctx.effect(() => () => { offStream(); }, 'dsh-perm-gate: stream route');
460
528
  }
461
529
  // Review-page plane (diff / revert / snapshot stats / snapshot clear): the
462
530
  // approval-history view's file chips and snapshot bar drive these.
@@ -488,6 +556,36 @@ export function apply(ctx, config = {}) {
488
556
  });
489
557
  if (offNetwork !== undefined)
490
558
  ctx.effect(() => () => { offNetwork(); }, 'dsh-perm-gate: network route');
559
+ // Rule test (dry-run): evaluate one would-be call against the ruleset the
560
+ // gate currently has loaded, and report both the effective verdict and the
561
+ // rule layer's own answer. Read-only — the route has no write form, so
562
+ // testing a rule can never change it. It runs against the LIVE runtime
563
+ // rather than a fresh one so the panel tests the rules in force, not a
564
+ // re-derivation of them (a fresh runtime would also re-resolve the chain
565
+ // from a different root and could silently answer about a different file).
566
+ const offDryRun = registerDryRunRoute(webServer, {
567
+ run: (request) => runDryRun({
568
+ tool: request.tool,
569
+ args: request.args,
570
+ ...(request.permissive !== undefined ? { permissive: request.permissive } : {}),
571
+ }, runtime),
572
+ });
573
+ if (offDryRun !== undefined)
574
+ ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
575
+ // Permissions view: the document the gate is actually loading, shown so
576
+ // the panel is never editing rules it cannot display. Read-only, and
577
+ // resolved exactly as the gate resolves it — settings first, the file the
578
+ // fallback resolves to otherwise.
579
+ const offRules = registerRulesRoute(webServer, {
580
+ view: () => {
581
+ const doc = rulesFromSettings();
582
+ if (isRulesConfigured(doc))
583
+ return readRulesViewFromSettings(doc);
584
+ return readRulesView(rulesFilePath);
585
+ },
586
+ });
587
+ if (offRules !== undefined)
588
+ ctx.effect(() => () => { offRules(); }, 'dsh-perm-gate: rules route');
491
589
  // Receiver projection for the settings card (provider/model catalog is
492
590
  // potentially slow to enumerate — cached briefly).
493
591
  let receiverCache = null;
@@ -633,23 +731,10 @@ export function apply(ctx, config = {}) {
633
731
  networkRefs.snapshot = () => networkLifecycle.snapshot();
634
732
  if (networkRefs.appliedKey === undefined)
635
733
  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
- }
734
+ // ─── Rule hot reload ────────────────────────────────────────────────
735
+ // The file watcher (chokidar) is gone: the settings namespace is the primary
736
+ // rules store and its `scope.watch` recompiles the gate on every change
737
+ // (see the RULES_NAMESPACE registration above). A rules FILE edit is no
738
+ // longer picked up mid-session — the settings document is the source to edit.
654
739
  return runtime;
655
740
  }
@@ -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
+ }
@@ -0,0 +1,404 @@
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
+ /** Known protected branch patterns */
17
+ const PROTECTED_BRANCHES = /^(main|master|production|release|stable)$/i;
18
+ /** Known remote names (common defaults) */
19
+ const KNOWN_REMOTES = /^(origin|upstream|fork|origin-\w+)$/i;
20
+ /** Flags that increase destructiveness */
21
+ const DESTRUCTIVE_FLAGS = new Set([
22
+ '--force', '-f',
23
+ '--hard',
24
+ '--delete', '-d', '-D',
25
+ '--amend',
26
+ '--no-backup',
27
+ '-rf',
28
+ '--clean',
29
+ '--reset',
30
+ ]);
31
+ export class GitParser {
32
+ name = 'git';
33
+ priority = 10;
34
+ canParse(command) {
35
+ const trimmed = command.trim();
36
+ // Direct git command
37
+ if (/^git\s+/.test(trimmed))
38
+ return true;
39
+ // Shell-wrapped: sh -c "git ...", bash -c git ..., etc.
40
+ if (/(?:sh|bash|pwsh|powershell)(?:\s+-c\s+['"]?)?.*?\bgit\s+/.test(trimmed))
41
+ return true;
42
+ return false;
43
+ }
44
+ parse(command) {
45
+ const trimmed = command.trim();
46
+ // Extract the git command portion (strip shell wrappers)
47
+ const gitMatch = trimmed.match(/(?:sh|bash|pwsh|powershell)(?:\s+-c\s+['"]?)?(\s+git\s+.+)/);
48
+ const gitPart = gitMatch ? gitMatch[1].trim() : trimmed;
49
+ // Parse git arguments
50
+ const parts = this.parseGitArgs(gitPart);
51
+ if (parts.length < 2)
52
+ return null;
53
+ // Skip 'git' keyword if present
54
+ const startIndex = parts[0] === 'git' ? 1 : 0;
55
+ if (parts.length - startIndex < 1)
56
+ return null;
57
+ const subcommand = parts[startIndex]?.toLowerCase();
58
+ // Get rest of arguments
59
+ const args = parts.slice(startIndex + 1);
60
+ return this.analyzeSubcommand(subcommand, args, trimmed);
61
+ }
62
+ analyzeSubcommand(subcommand, args, rawCommand) {
63
+ const flags = this.extractFlags(args);
64
+ const hasForce = flags.includes('--force') || flags.includes('-f') || flags.includes('--force-with-lease');
65
+ const hasDelete = flags.includes('--delete') || flags.includes('-d') || flags.includes('-D');
66
+ const hasHard = flags.includes('--hard');
67
+ let operation;
68
+ let destructiveness = 1;
69
+ let targets = [];
70
+ let branch;
71
+ let remote;
72
+ let gitOperation = true;
73
+ let targetsSharedBranch = false;
74
+ switch (subcommand) {
75
+ case 'push': {
76
+ remote = this.extractRemote(args);
77
+ branch = this.extractBranch(args);
78
+ targets = [branch, remote].filter(Boolean);
79
+ targetsSharedBranch = this.isProtectedBranch(branch);
80
+ if (hasForce || flags.includes('--force-with-lease')) {
81
+ operation = 'force-push';
82
+ destructiveness = targetsSharedBranch ? 5 : 4;
83
+ }
84
+ else if (hasDelete) {
85
+ // 删除远端分支。`hasDelete` 此前只在 `branch` 分支被读取,push 侧从未
86
+ // 使用——于是 `git push --delete origin main` 落进上面的 else,被判成
87
+ // 普通 push(dest=4)、`DESTRUCTIVENESS_MAP` 里的 `'push-delete': 5`
88
+ // 是**死条目**。删远端分支与 force push 同级:都不可逆地改动共享历史。
89
+ operation = 'push-delete';
90
+ destructiveness = targetsSharedBranch ? 5 : 4;
91
+ }
92
+ else {
93
+ operation = 'push';
94
+ destructiveness = targetsSharedBranch ? 4 : 3;
95
+ }
96
+ break;
97
+ }
98
+ case 'reset': {
99
+ if (hasHard) {
100
+ operation = 'reset-hard';
101
+ destructiveness = 5;
102
+ }
103
+ else if (flags.includes('--soft')) {
104
+ operation = 'reset-soft';
105
+ destructiveness = 2;
106
+ }
107
+ else {
108
+ operation = 'reset-mixed';
109
+ destructiveness = 4;
110
+ }
111
+ targets = this.extractRefs(args);
112
+ break;
113
+ }
114
+ case 'branch': {
115
+ if (hasDelete) {
116
+ const forceDelete = flags.includes('-D');
117
+ branch = this.extractBranchName(args);
118
+ targets = [branch].filter(Boolean);
119
+ targetsSharedBranch = this.isProtectedBranch(branch);
120
+ operation = forceDelete ? 'branch-delete-force' : 'branch-delete';
121
+ destructiveness = targetsSharedBranch ? 5 : (forceDelete ? 3 : 2);
122
+ }
123
+ else if (flags.includes('-m') || flags.includes('--move')) {
124
+ operation = 'branch-rename';
125
+ destructiveness = 2;
126
+ targets = this.extractRefs(args);
127
+ }
128
+ else {
129
+ operation = 'branch-create';
130
+ destructiveness = 3;
131
+ branch = this.extractBranchName(args);
132
+ targets = [branch].filter(Boolean);
133
+ }
134
+ break;
135
+ }
136
+ case 'checkout':
137
+ case 'switch': {
138
+ const isNewBranch = flags.includes('-b') || flags.includes('--create');
139
+ branch = this.extractBranchName(args);
140
+ targets = [branch].filter(Boolean);
141
+ operation = isNewBranch ? 'checkout-new-branch' : 'checkout';
142
+ destructiveness = isNewBranch ? 3 : 2;
143
+ break;
144
+ }
145
+ case 'merge': {
146
+ targets = this.extractRefs(args);
147
+ if (flags.includes('--ff-only')) {
148
+ operation = 'merge-ff-only';
149
+ destructiveness = 3;
150
+ }
151
+ else {
152
+ operation = 'merge';
153
+ destructiveness = 3;
154
+ }
155
+ break;
156
+ }
157
+ case 'rebase': {
158
+ targets = this.extractRefs(args);
159
+ if (flags.includes('-i') || flags.includes('--interactive')) {
160
+ operation = 'rebase-interactive';
161
+ destructiveness = 4;
162
+ }
163
+ else {
164
+ operation = 'rebase';
165
+ destructiveness = 3;
166
+ }
167
+ break;
168
+ }
169
+ case 'clean': {
170
+ if (hasForce || flags.includes('-f')) {
171
+ operation = 'clean-force';
172
+ destructiveness = 5;
173
+ }
174
+ else {
175
+ operation = 'clean';
176
+ destructiveness = 3;
177
+ }
178
+ targets = this.extractRefs(args);
179
+ break;
180
+ }
181
+ case 'cherry-pick': {
182
+ operation = 'cherry-pick';
183
+ destructiveness = 3;
184
+ targets = this.extractRefs(args);
185
+ break;
186
+ }
187
+ case 'revert': {
188
+ operation = 'revert';
189
+ destructiveness = 3;
190
+ targets = this.extractRefs(args);
191
+ break;
192
+ }
193
+ case 'tag': {
194
+ if (hasDelete) {
195
+ operation = 'tag-delete';
196
+ destructiveness = 2;
197
+ targets = this.extractRefs(args);
198
+ }
199
+ else {
200
+ operation = 'tag-create';
201
+ destructiveness = 1;
202
+ targets = this.extractRefs(args);
203
+ }
204
+ break;
205
+ }
206
+ case 'stash': {
207
+ const stashOp = args[0]?.toLowerCase();
208
+ if (stashOp === 'pop' || stashOp === 'apply') {
209
+ operation = 'stash-pop';
210
+ destructiveness = 2;
211
+ }
212
+ else if (stashOp === 'drop') {
213
+ operation = 'stash-drop';
214
+ destructiveness = 2;
215
+ }
216
+ else {
217
+ operation = 'stash-create';
218
+ destructiveness = 1;
219
+ }
220
+ break;
221
+ }
222
+ case 'fetch': {
223
+ remote = this.extractRemote(args);
224
+ targets = [remote].filter(Boolean);
225
+ operation = 'fetch';
226
+ destructiveness = 1;
227
+ break;
228
+ }
229
+ case 'pull': {
230
+ remote = this.extractRemote(args);
231
+ branch = this.extractBranch(args);
232
+ targets = [remote, branch].filter(Boolean);
233
+ operation = 'pull';
234
+ destructiveness = 2;
235
+ break;
236
+ }
237
+ case 'clone': {
238
+ operation = 'clone';
239
+ destructiveness = 1;
240
+ targets = this.extractRefs(args);
241
+ break;
242
+ }
243
+ case 'status':
244
+ case 'log':
245
+ case 'diff':
246
+ case 'show':
247
+ case 'blame':
248
+ case 'ls-files':
249
+ case 'rev-parse':
250
+ case 'describe': {
251
+ operation = subcommand;
252
+ destructiveness = 1;
253
+ break;
254
+ }
255
+ default: {
256
+ // Unknown git subcommand
257
+ operation = subcommand;
258
+ destructiveness = 1;
259
+ gitOperation = true;
260
+ break;
261
+ }
262
+ }
263
+ // Detect destructive flags
264
+ const destructiveFlags = hasForce || hasDelete || hasHard ||
265
+ flags.some(f => DESTRUCTIVE_FLAGS.has(f));
266
+ return {
267
+ family: 'git',
268
+ subcommand,
269
+ operation,
270
+ destructiveness: destructiveness,
271
+ destructiveFlags,
272
+ targets,
273
+ flags,
274
+ requiresConfirmation: destructiveness >= 4,
275
+ compound: false,
276
+ gitOperation,
277
+ branch,
278
+ remote,
279
+ targetsSharedBranch,
280
+ rawCommand: rawCommand,
281
+ };
282
+ }
283
+ parseGitArgs(command) {
284
+ const args = [];
285
+ let current = '';
286
+ let inQuote = false;
287
+ let quoteChar = '';
288
+ let escape = false;
289
+ for (const char of command) {
290
+ if (escape) {
291
+ current += char;
292
+ escape = false;
293
+ continue;
294
+ }
295
+ if (char === '\\') {
296
+ escape = true;
297
+ continue;
298
+ }
299
+ if (inQuote) {
300
+ if (char === quoteChar) {
301
+ inQuote = false;
302
+ continue;
303
+ }
304
+ current += char;
305
+ continue;
306
+ }
307
+ if (char === '"' || char === "'") {
308
+ inQuote = true;
309
+ quoteChar = char;
310
+ continue;
311
+ }
312
+ if (/\s/.test(char)) {
313
+ if (current) {
314
+ args.push(current);
315
+ current = '';
316
+ }
317
+ continue;
318
+ }
319
+ current += char;
320
+ }
321
+ if (current)
322
+ args.push(current);
323
+ return args;
324
+ }
325
+ extractFlags(args) {
326
+ const flags = [];
327
+ for (const arg of args) {
328
+ if (arg.startsWith('-')) {
329
+ // Normalize long flags to lowercase, but preserve case for short flags
330
+ // to distinguish between -d (delete) and -D (force delete)
331
+ if (arg.startsWith('--')) {
332
+ flags.push(arg.toLowerCase());
333
+ }
334
+ else {
335
+ // Short flags: -f, -d, -D, etc. - preserve case
336
+ flags.push(arg);
337
+ }
338
+ }
339
+ }
340
+ return flags;
341
+ }
342
+ extractRemote(args) {
343
+ // First positional arg is usually remote
344
+ for (const arg of args) {
345
+ if (!arg.startsWith('-')) {
346
+ if (KNOWN_REMOTES.test(arg)) {
347
+ return arg;
348
+ }
349
+ // Could be a URL or path
350
+ if (arg.includes('/') || arg.includes('.')) {
351
+ return arg;
352
+ }
353
+ }
354
+ }
355
+ return undefined;
356
+ }
357
+ extractBranch(args) {
358
+ // Branch is usually the last positional arg or after remote
359
+ const positionalArgs = args.filter(a => !a.startsWith('-'));
360
+ if (positionalArgs.length > 1) {
361
+ // Could be "origin main" or "origin:main"
362
+ const last = positionalArgs[positionalArgs.length - 1];
363
+ // Handle refspec (e.g., HEAD:main)
364
+ if (last.includes(':')) {
365
+ return last.split(':')[1];
366
+ }
367
+ return last;
368
+ }
369
+ return positionalArgs[0];
370
+ }
371
+ extractBranchName(args) {
372
+ // For branch create/checkout, find the branch name
373
+ const positionalArgs = args.filter(a => !a.startsWith('-'));
374
+ // Skip -b flag value if present
375
+ for (let i = 0; i < args.length; i++) {
376
+ if (args[i] === '-b' || args[i] === '--create') {
377
+ return args[i + 1];
378
+ }
379
+ }
380
+ return positionalArgs[positionalArgs.length - 1];
381
+ }
382
+ extractRefs(args) {
383
+ return args.filter(a => !a.startsWith('-') && !KNOWN_REMOTES.test(a));
384
+ }
385
+ /**
386
+ * 目标是否落在保护分支上。
387
+ *
388
+ * **只剥已知的 ref 前缀,不对任意 `/` 取末段。** 早先的写法是
389
+ * `branch.split('/').pop()`,于是:
390
+ * - `refs/heads/main` → `main` ✅ 期望如此
391
+ * - `backup/main` → `main` ❌ **误判**(它是含斜杠的普通分支名)
392
+ * - `feat/release` → `release` ❌ **误判**
393
+ *
394
+ * 误判方向不是"更安全"。这条判据喂给 P0 硬拒——而 P0 是**不可配置撤销**的
395
+ * (见 engine.ts 顶部:"deterministic, monotonic, never negotiated")。
396
+ * 漏判还有关键词层与 LLM 层兜底,误判则是"合法工作流被永久挡住且无出口"。
397
+ */
398
+ isProtectedBranch(branch) {
399
+ if (!branch)
400
+ return false;
401
+ const cleanBranch = branch.replace(/^refs\/(?:heads|tags|remotes\/[^/]+)\//, '');
402
+ return PROTECTED_BRANCHES.test(cleanBranch);
403
+ }
404
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shell dangerous commands parser - rm, mv, cp, chmod, dd, diskpart, etc.
3
+ * P1 priority - detects destructive filesystem operations.
4
+ *
5
+ * Handles:
6
+ * - rm -rf / recursive deletion
7
+ * - mv overwriting existing files
8
+ * - cp -r with destructive patterns
9
+ * - chmod/chown with system directories
10
+ * - dd writing to disk
11
+ * - diskpart on Windows
12
+ * - format disk operations
13
+ * - PowerShell Remove-Item with dangerous patterns
14
+ */
15
+ import type { CommandParser, CommandSemantics } from '../command-semantics.js';
16
+ export declare class ShellDangerousParser implements CommandParser {
17
+ readonly name = "shell-dangerous";
18
+ readonly priority = 20;
19
+ canParse(command: string): boolean;
20
+ parse(command: string): CommandSemantics | null;
21
+ private extractBaseName;
22
+ private parseRm;
23
+ private parseMv;
24
+ private parseCp;
25
+ private parseChmod;
26
+ private parseDd;
27
+ private parseDiskpart;
28
+ private parsePowerShellRemove;
29
+ private parseArchive;
30
+ private extractArgs;
31
+ private extractFlags;
32
+ }