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.
- package/CHANGELOG.ja.md +82 -0
- package/CHANGELOG.ko.md +80 -0
- package/CHANGELOG.md +225 -0
- package/INSTALL.ja.md +15 -9
- package/INSTALL.ko.md +16 -10
- package/INSTALL.md +17 -9
- package/INSTALL.zh.md +13 -8
- package/README.ja.md +17 -5
- package/README.ko.md +18 -6
- package/README.md +33 -3
- package/README.zh.md +11 -3
- package/docs/baseline-guard.md +56 -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 +646 -95
- 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 +91 -2
- package/lib/events.js +220 -0
- package/lib/index.js +111 -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 +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 {
|
|
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, 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
|
-
|
|
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 {
|
|
@@ -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
|
-
// ───
|
|
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
|
-
}
|
|
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
|
+
}
|