dsh-perm-gate 2.0.0 → 2.1.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/README.ja.md CHANGED
@@ -26,8 +26,8 @@
26
26
  >
27
27
  > | DSH バージョン | ブランチ | バージョン | npm タグ |
28
28
  > | --- | --- | --- | --- |
29
- > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` / `@dsh-0.1.1` |
30
- > | 0.1.2-alpha.1+(0.1.5-rc.2を含む) | `main` | `2.x` | `@latest` / `@2.x` / `@dsh-0.1.2` |
29
+ > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
30
+ > | 0.1.2-alpha.1+(0.1.5-rc.2を含む) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` は範囲) |
31
31
  >
32
32
  > バージョン系列は **DSH ライン**を追います(`1.x` = DSH ≤ 0.1.1、`2.x` = DSH 0.1.2+)。
33
33
  > メジャー同士が互いを遮断するため、`^1.x` のインストールが `2.x` を解決することは
package/README.ko.md CHANGED
@@ -26,8 +26,8 @@
26
26
  >
27
27
  > | DSH 버전 | 브랜치 | 버전 | npm 태그 |
28
28
  > | --- | --- | --- | --- |
29
- > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` / `@dsh-0.1.1` |
30
- > | 0.1.2-alpha.1+ (0.1.5-rc.2 포함) | `main` | `2.x` | `@latest` / `@2.x` / `@dsh-0.1.2` |
29
+ > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
30
+ > | 0.1.2-alpha.1+ (0.1.5-rc.2 포함) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x`는 범위) |
31
31
  >
32
32
  > 버전 계열은 **DSH 라인**을 따릅니다(`1.x` = DSH ≤ 0.1.1, `2.x` = DSH 0.1.2+). 메이저
33
33
  > 버전이 서로를 차단하므로 `^1.x` 설치가 `2.x`를 해석하는 일은 없고 그 반대도
package/README.md CHANGED
@@ -25,8 +25,8 @@
25
25
  >
26
26
  > | DSH version | Branch | Version | npm tag |
27
27
  > | --- | --- | --- | --- |
28
- > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` / `@dsh-0.1.1` |
29
- > | 0.1.2-alpha.1+ (incl. 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@2.x` / `@dsh-0.1.2` |
28
+ > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
29
+ > | 0.1.2-alpha.1+ (incl. 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` is a range) |
30
30
  >
31
31
  > The series number tracks the **DSH line** (`1.x` = DSH ≤ 0.1.1, `2.x` = DSH 0.1.2+),
32
32
  > and the majors fence each other: a `^1.x` install never resolves a `2.x` release and
package/README.zh.md CHANGED
@@ -24,8 +24,8 @@
24
24
  >
25
25
  > | DSH 版本 | 分支 | 版本号 | npm 标签 |
26
26
  > | --- | --- | --- | --- |
27
- > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` / `@dsh-0.1.1` |
28
- > | 0.1.2-alpha.1+(含 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@2.x` / `@dsh-0.1.2` |
27
+ > | 0.1.0-rc.7 ~ 0.1.1-rc.x | `legacy` | `1.x` | `@legacy` |
28
+ > | 0.1.2-alpha.1+(含 0.1.5-rc.2) | `main` | `2.x` | `@latest` / `@dsh-0.1.2` (`@2.x` 是范围) |
29
29
  >
30
30
  > 版本序列号跟的是 **DSH 线**(`1.x` = DSH ≤ 0.1.1,`2.x` = DSH 0.1.2+),两条大版本互
31
31
  > 相隔离:锁在 `^1.x` 的安装绝不会解析到 `2.x`,反之亦然。`engines.dsh` 表达同样的
package/cordis.patch.yml CHANGED
@@ -14,8 +14,9 @@
14
14
  # stays `permissive`: it is the key `gatePresets` matches and the value the
15
15
  # permission projection reports, so only the label text is user-facing.
16
16
  #
17
- # The tier carries no icon. The built-in glyphs the composer draws are keyed by
18
- # the three built-in values, and a plugin tier deliberately has none.
17
+ # The tier now carries a shield+eye icon in the composer glyph map (patched
18
+ # into dsh-client-ui-conversation). The permissive glyph sits alongside the
19
+ # three built-in ones so both the trigger button and the dropdown rows show it.
19
20
  #
20
21
  # The DSH bundle patch REPLACES the whole `permission.config.presets` map (it
21
22
  # does not merge per key), so this file MUST restate every tier that should
package/lib/risk.js CHANGED
@@ -43,9 +43,9 @@ const RISK_SYSTEM_PROMPT = [
43
43
  ' {"risk":"risky","category":"<category>","reason":"short"}',
44
44
  '',
45
45
  'category must be exactly one of:',
46
- ' deletion — deletes or overwrites data that cannot be regenerated',
46
+ ' deletion — deletes or overwrites data that cannot be regenerated (IMPORTANT: see exclusion rules below)',
47
47
  ' credential — creates/modifies/exposes credentials, keys, tokens, auth config',
48
- ' remote — affects remote systems, production, databases, messaging, billing, publishing',
48
+ ' remote — WRITES to or MODIFIES remote systems (git push, git deploy, kubectl apply, terraform apply, production database writes, messaging, billing, publishing). Must have a write/modify/delete effect on the remote side.',
49
49
  ' system — system-level paths or configuration (/etc, /usr, boot items), shutdown/reboot',
50
50
  ' bulk — mass overwrite of many files, formatting, dd-style irreversible writes',
51
51
  ' neutral — none of the above clearly applies (ordinary edits outside the obvious scope, or you are unsure but see no hard-risk sign)',
@@ -54,6 +54,17 @@ const RISK_SYSTEM_PROMPT = [
54
54
  '- safe is only for reversible, in-scope operations that touch no sensitive resource.',
55
55
  '- When unsure between safe and neutral, answer neutral; never guess safe.',
56
56
  '- Judge the operation content, not merely its location.',
57
+ '',
58
+ 'DELETION exclusion rules (these override the deletion category):',
59
+ '- Cleanup of temporary files, build outputs, or failed clone directories is SAFE — even if using rm/rmdir/Remove-Item. Look for temp paths: paths containing "temp", "tmp", "test-clone", "node_modules", ".cache", "dist", "build", or numbered/timestamped directories.',
60
+ '- Removal of session-local artifacts (files the session itself created) is SAFE.',
61
+ '- rm -rf on node_modules, .next, __pycache__, .pytest_cache, .git, dist, build is SAFE — these are always regenerable.',
62
+ '- Deletion inside the workspace that targets non-existent or already-empty paths is SAFE (idempotent cleanup).',
63
+ '- Only flag as deletion when the target is: (a) user data with no backup, (b) configuration files, (c) database records, (d) files outside the workspace that were not created by this session, or (e) a destructive rm -rf on a directory containing unique, irreplaceable content.',
64
+ '',
65
+ 'READ-ONLY remote operations are safe: git fetch, git pull, git clone, git log, git diff, git status, git show, npm install (read-only), pip install (read-only), apt-get update, curl/wget (read, no write flag).',
66
+ '- Operations that only READ from remote systems without modifying them are safe, even if they involve network traffic.',
67
+ '- Only flag as remote when the operation WRITES, DEPLOYS, PUSHES, or DELETES on the remote side.',
57
68
  ].join('\n');
58
69
  /** Parse one assistant message into a verdict; anything off-protocol → undefined (caller retries/fails). */
59
70
  export function parseRiskVerdict(content) {
@@ -92,9 +103,21 @@ export async function classifyRisk(cfg, req, nowFetch = fetch) {
92
103
  }
93
104
  /** The bounded user prompt for one risk grading (shared by every transport). */
94
105
  export function riskUserText(req) {
106
+ // Enrich the prompt with full path context so the LLM can judge
107
+ // whether a deletion targets a temporary/build artifact vs real data.
108
+ const raw = req.args ?? {};
109
+ const argsRecord = (typeof raw === 'object' && raw !== null ? raw : {});
110
+ const enriched = { ...argsRecord };
111
+ // Surface the working directory if present (helps LLM see temp paths).
112
+ if (typeof enriched.cwd === 'string' && enriched.cwd !== '') {
113
+ enriched.__cwd = enriched.cwd;
114
+ }
115
+ if (typeof enriched.workdir === 'string' && enriched.workdir !== '') {
116
+ enriched.__cwd = enriched.workdir;
117
+ }
95
118
  return JSON.stringify({
96
119
  tool: req.tool,
97
- args: req.args ?? {},
120
+ args: enriched,
98
121
  reason: req.reason,
99
122
  instruction: 'Reply strictly as one JSON object: {"risk":"safe"} or {"risk":"risky","category":"..."} plus a short "reason".',
100
123
  });
package/lib/runtime.js CHANGED
@@ -18,6 +18,7 @@ import { decideRules } from './evaluate.js';
18
18
  import { canonicalizeCall, GrantRegistry } from './grant.js';
19
19
  import { learnKey, operationFingerprint, RiskLearning } from './learning.js';
20
20
  import { ArtifactRegistry } from './path.js';
21
+ import { decomposeShellCommand } from './shell.js';
21
22
  import { classifyRisk, classifyRiskWith } from './risk.js';
22
23
  import { completeViaHost, DEFAULT_HOST_MODEL } from './host-llm.js';
23
24
  import { chatCompletion } from './classifier.js';
@@ -99,6 +100,70 @@ const CLEARED_CALL_TTL_MS = 5 * 60_000;
99
100
  * unrecognized wording fails closed (the request is forwarded to the human).
100
101
  */
101
102
  const SANDBOX_ESCALATION_REASON = /^escalate sandbox to (workspace-write|danger-full-access): /;
103
+ // --- Deterministic cleanup pre-screen ----------------------------------------
104
+ // Pattern for shell deletion commands (rm, rmdir, Remove-Item) with flags.
105
+ const SHELL_DELETE_RE = /^(?:rm|rmdir|Remove-Item)\b/i;
106
+ // Temp/build artifact directory name patterns (case-insensitive match on each path segment).
107
+ const REGENERABLE_SEGMENT = /(?:^|[\\/])(?:temp|tmp|test[-_]clone|node_modules|\.cache|dist|build|__pycache__|\.pytest_cache|\.git|\.next|\.nuxt|coverage|\.tox|\.venv|venv|env|\.mypy_cache|\.parcel-cache)(?:[\\/]|$)/i;
108
+ // Workspace-relative path tokens that are always regenerable.
109
+ const REGENERABLE_TOKENS = /^(?:node_modules|\.next|__pycache__|\.cache|dist|build|\.git|coverage|\.pytest_cache|\.tox|\.venv|venv|env)$/i;
110
+ /**
111
+ * Deterministic pre-screen for deletion commands. Returns a reason string when
112
+ * the deletion is clearly safe (targeting a regenerable/temp artifact inside the
113
+ * workspace), or undefined when the LLM should decide.
114
+ *
115
+ * This runs BEFORE the LLM risk grader to avoid the LLM's unconditional
116
+ * "Remove-Item → risky:deletion" bias. The LLM is still the fallback for
117
+ * ambiguous cases.
118
+ */
119
+ function isCleanupSafe(exec) {
120
+ const tool = exec.name;
121
+ const args = exec.arguments ?? {};
122
+ // Only applies to shell/pwsh tools.
123
+ if (!/^(?:pwsh|bash|sh|cmd|shell|terminal)$/i.test(tool))
124
+ return undefined;
125
+ const commandText = typeof args.command === 'string' ? args.command : undefined;
126
+ if (commandText === undefined)
127
+ return undefined;
128
+ let commands = [];
129
+ try {
130
+ commands = decomposeShellCommand(commandText).commands;
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ const cwd = typeof args.cwd === 'string' ? args.cwd : cwdOf(exec) ?? '';
136
+ const posixCwd = cwd.replace(/\\/g, '/').toLowerCase();
137
+ for (const cmd of commands) {
138
+ if (!SHELL_DELETE_RE.test(cmd.command))
139
+ continue;
140
+ // Collect all non-flag argument tokens as potential targets.
141
+ const targets = cmd.args.filter((a) => !a.startsWith('-'));
142
+ if (targets.length === 0)
143
+ continue;
144
+ let allSafe = true;
145
+ for (const target of targets) {
146
+ const posixTarget = target.replace(/\\/g, '/').toLowerCase();
147
+ // Resolve relative to cwd.
148
+ const fullPath = posixTarget.startsWith('/') || /^[a-z]:\//i.test(posixTarget)
149
+ ? posixTarget
150
+ : posixCwd !== '' ? `${posixCwd}/${posixTarget}` : posixTarget;
151
+ // Check if the target path contains a regenerable segment.
152
+ if (REGENERABLE_SEGMENT.test(fullPath))
153
+ continue;
154
+ // Check bare token (e.g. "node_modules" without path separators).
155
+ if (REGENERABLE_TOKENS.test(target))
156
+ continue;
157
+ // Not clearly regenerable — let the LLM decide.
158
+ allSafe = false;
159
+ break;
160
+ }
161
+ if (allSafe) {
162
+ return `deletion targets regenerable/temp artifacts: ${targets.join(', ')}`;
163
+ }
164
+ }
165
+ return undefined;
166
+ }
102
167
  export class PermGateRuntime {
103
168
  options;
104
169
  ruleset;
@@ -538,6 +603,19 @@ export class PermGateRuntime {
538
603
  };
539
604
  const raw = decide(ctx, { decide: (c) => decideRules(this.ruleset, c) }, grantResolver, this.autoAllowExtra);
540
605
  const decision = this.applyPermissive(raw);
606
+ // Deterministic cleanup pre-screen: if a deletion command targets a
607
+ // regenerable/temporary artifact inside the workspace, allow it directly.
608
+ // This runs BEFORE the ask/passthrough logic so it works even under
609
+ // `approval: never` (where refineAsk is never called).
610
+ if (decision.action === 'ask') {
611
+ const cleanupSafe = isCleanupSafe(exec);
612
+ if (cleanupSafe !== undefined) {
613
+ const reason = `cleanup-safe: ${cleanupSafe}`;
614
+ this.audit.append(makeEntry({ callId, tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
615
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
616
+ return undefined; // passthrough (allow)
617
+ }
618
+ }
541
619
  let outcome;
542
620
  let source;
543
621
  switch (decision.stage) {
@@ -644,6 +722,19 @@ export class PermGateRuntime {
644
722
  return undefined;
645
723
  }
646
724
  }
725
+ // Defensive cleanup pre-screen: normally this is handled in decideExecution
726
+ // before refineAsk is ever called, but refineAsk is public and may be called
727
+ // directly by tests or future embedders. Keep the same deterministic check
728
+ // here as a safety net — identical logic to decideExecution's pre-screen.
729
+ const cleanupSafe = isCleanupSafe(exec);
730
+ if (cleanupSafe !== undefined) {
731
+ const reason = `cleanup-safe: ${cleanupSafe}`;
732
+ this.audit.append(makeEntry({ callId: randomId(), tool: exec.name, outcome: 'allow', source: 'classifier', reason, at: Date.now() }));
733
+ this.recordEvent(exec, 'auto', reason, { risk: 'safe', verdict: 'cleanup-safe', category: 'safe' });
734
+ this.untrackAsk(exec);
735
+ this.clearCall(exec, reason, 'cleanup-safe', 'classifier');
736
+ return undefined;
737
+ }
647
738
  if (!permissive.strategies.llmAssist)
648
739
  return decision;
649
740
  // One live read of the classifier setup. Without a usable receiver (a
@@ -656,7 +747,14 @@ export class PermGateRuntime {
656
747
  && (cfg?.endpoint === undefined || cfg?.endpoint === '' || cfg?.model === undefined || cfg?.model === '')) {
657
748
  return decision;
658
749
  }
659
- const req = { tool: exec.name, args: exec.arguments ?? {}, reason: decision.reason };
750
+ // Inject cwd into the risk request so the LLM can see the full path context
751
+ // (e.g. that a Remove-Item targets a temp directory, not user data).
752
+ const riskArgs = { ...(exec.arguments ?? {}) };
753
+ const cwd = cwdOf(exec);
754
+ if (typeof cwd === 'string' && cwd !== '' && typeof riskArgs.cwd !== 'string') {
755
+ riskArgs.cwd = cwd;
756
+ }
757
+ const req = { tool: exec.name, args: riskArgs, reason: decision.reason };
660
758
  let risk;
661
759
  if (this.options.riskHook !== undefined) {
662
760
  risk = await this.options.riskHook(req);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "DSH permission-gate for the DeepSeek Harness 0.1.2+ line: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
5
5
  "type": "module",
6
6
  "files": [