dsh-perm-gate 0.2.0 → 0.2.1-beta.2

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/lib/config.d.ts CHANGED
@@ -1,9 +1,22 @@
1
- /**
2
- * Plugin configuration for dsh-perm-gate, defined with Schemastery so the DSH
3
- * loader validates and fills defaults before `apply`. Invalid values fail loud.
4
- */
5
1
  import z from '@deepseek-ai/schemastery';
6
2
  import type { RuleAction } from './rule.js';
3
+ /**
4
+ * The DSH home directory: an explicit `dshHome`, else `$DSH_HOME`, else
5
+ * `~/.dsh`. A profile entry that omits `config` must still get a writable data
6
+ * home — otherwise the event feed, snapshots and learning store silently stay
7
+ * disabled (no log, no review page).
8
+ */
9
+ export declare function resolveDshHome(configured?: string): string;
10
+ /** This plugin's data directory (`<dshHome>/perm-gate`), always defined. */
11
+ export declare function resolveDataDir(configured?: string): string;
12
+ /**
13
+ * The permissions document the gate loads. An explicit `rulesFile` wins; unset
14
+ * falls back to `<dataDir>/rules.yml`, so a rules file the user drops in the
15
+ * plugin's own data directory is loaded without also declaring the path in the
16
+ * composition entry. A missing file still yields an empty ruleset (the gate then
17
+ * applies `defaultAction`), so the fallback never fails the plugin load.
18
+ */
19
+ export declare function resolveRulesFile(configured: string | undefined, dataDir: string): string;
7
20
  export interface PermGateConfig {
8
21
  /** Absolute or `./`-relative path to the YAML permissions document. */
9
22
  readonly rulesFile?: string;
@@ -26,6 +39,12 @@ export interface PermGateConfig {
26
39
  * toward auto-allowing the exact same operation later. Default false.
27
40
  */
28
41
  readonly riskLearning?: boolean;
42
+ /** Learning sedimentation switch: threshold-reached samples become deterministic auto-allows. Default true (while learning is on). */
43
+ readonly riskSediment?: boolean;
44
+ /** llmAssist receiver source: an OpenAI-compatible endpoint (`custom`) or the DSH host `llm` service (`host`). Default custom. */
45
+ readonly classifierSource?: 'custom' | 'host';
46
+ /** Optional provider override for the host receiver (empty = the session's current model group). */
47
+ readonly classifierProvider?: string;
29
48
  /** Confirmations required before a neutral-risk re-run may auto-allow. Default 3. */
30
49
  readonly riskThreshold?: number;
31
50
  /** Persistence path for verdict learning; defaults to `<dshHome>/perm-gate/learning.json`. */
@@ -44,11 +63,43 @@ export interface PermGateConfig {
44
63
  readonly permissive?: boolean;
45
64
  /** Backend approval strategies; combinable. Front-end exposes only `permissive` itself. */
46
65
  readonly permissiveStrategies?: Partial<PermissiveStrategies>;
66
+ /**
67
+ * Session permission presets in which this gate is active at all.
68
+ *
69
+ * The gate owns an independent tier, so it must not overrule a tier the user
70
+ * selected instead. Outside this scope the gate stands down completely — no
71
+ * allow, no ask, no deny, no P0 hard-deny, no deny-keyword veto — and the
72
+ * selected tier's own policy governs the call. That matters because
73
+ * `danger-full-access` is defined as "full access without approval prompts":
74
+ * there the DSH approval seam rejects every request before any answerer runs,
75
+ * so a forwarded ask could only ever fail with `the user rejected tool ...`,
76
+ * and a hard-deny would silently contradict the tier the user chose.
77
+ *
78
+ * Default `['permissive']` (the tier this plugin adds). `['*']` makes the gate
79
+ * global again, including its hard-deny layer.
80
+ */
81
+ readonly gatePresets?: string[];
47
82
  /**
48
83
  * Editable whitelist (allow-list command patterns), mirrored to the rules
49
84
  * file's `allow` section. Optional; edit from the settings card as a list.
50
85
  */
51
86
  readonly allowlist?: string[];
87
+ /**
88
+ * Editable deny-keyword blacklist (preset: dsh-approval-gate's inherited
89
+ * `DEFAULT_DENY_KEYWORDS`). Unset applies the preset; an explicit array
90
+ * (possibly empty) replaces it. Editable from the settings card as a list.
91
+ */
92
+ readonly denyKeywords?: string[];
93
+ /**
94
+ * Extra tool names classified as read-only/internal and therefore auto-allowed.
95
+ *
96
+ * The gate already auto-allows DSH's own read-only and session-local tools
97
+ * (reads, searches, memory/goal/taskboard/job management, UI and todo state);
98
+ * this list extends that classification for third-party read-only tools.
99
+ * P0 hard-deny and the deny-keyword layer still run before it, so the list can
100
+ * never widen authority for a destructive or credential-bearing call.
101
+ */
102
+ readonly autoAllowTools?: string[];
52
103
  }
53
104
  /** Backend combinable approval strategies for the Permissive tier (all opt-in). */
54
105
  export interface PermissiveStrategies {
@@ -60,10 +111,18 @@ export interface PermissiveStrategies {
60
111
  readonly llmAssist: boolean;
61
112
  }
62
113
  export declare const DEFAULT_PERMISSIVE_STRATEGIES: Readonly<PermissiveStrategies>;
114
+ /**
115
+ * Presets in which the gate is active. It owns the `permissive` tier; `'*'`
116
+ * makes it global (every preset, including the hard-deny layer).
117
+ */
118
+ export declare const DEFAULT_GATE_PRESETS: readonly string[];
119
+ /** Normalize the gate scope: an unset/empty list means the default. */
120
+ export declare function resolveGatePresets(configured?: readonly string[]): readonly string[];
63
121
  /** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
64
122
  export declare function resolvePermissiveStrategies(bag?: Partial<PermissiveStrategies>): PermissiveStrategies;
65
123
  export declare const Config: z<PermGateConfig>;
66
124
  export type ResolvedPermGateConfig = Required<Pick<PermGateConfig, 'caseInsensitivePaths' | 'grantTtlMs' | 'grantMaxUses' | 'permissive' | 'riskTimeoutMs' | 'riskLearning' | 'riskThreshold'>> & Pick<PermGateConfig, 'rulesFile' | 'dshHome' | 'defaultAction' | 'classifierEnabled' | 'classifierEndpoint' | 'classifierModel' | 'classifierApiKey' | 'learningFile' | 'eventsFile'> & {
67
125
  readonly permissiveStrategies: PermissiveStrategies;
126
+ readonly gatePresets: readonly string[];
68
127
  };
69
128
  export declare function resolveConfig(config?: PermGateConfig): ResolvedPermGateConfig;
package/lib/config.js CHANGED
@@ -2,12 +2,53 @@
2
2
  * Plugin configuration for dsh-perm-gate, defined with Schemastery so the DSH
3
3
  * loader validates and fills defaults before `apply`. Invalid values fail loud.
4
4
  */
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
5
7
  import z from '@deepseek-ai/schemastery';
8
+ /**
9
+ * The DSH home directory: an explicit `dshHome`, else `$DSH_HOME`, else
10
+ * `~/.dsh`. A profile entry that omits `config` must still get a writable data
11
+ * home — otherwise the event feed, snapshots and learning store silently stay
12
+ * disabled (no log, no review page).
13
+ */
14
+ export function resolveDshHome(configured) {
15
+ if (typeof configured === 'string' && configured !== '')
16
+ return configured;
17
+ const env = process.env.DSH_HOME;
18
+ if (typeof env === 'string' && env !== '')
19
+ return env;
20
+ return join(homedir(), '.dsh');
21
+ }
22
+ /** This plugin's data directory (`<dshHome>/perm-gate`), always defined. */
23
+ export function resolveDataDir(configured) {
24
+ return join(resolveDshHome(configured), 'perm-gate');
25
+ }
26
+ /**
27
+ * The permissions document the gate loads. An explicit `rulesFile` wins; unset
28
+ * falls back to `<dataDir>/rules.yml`, so a rules file the user drops in the
29
+ * plugin's own data directory is loaded without also declaring the path in the
30
+ * composition entry. A missing file still yields an empty ruleset (the gate then
31
+ * applies `defaultAction`), so the fallback never fails the plugin load.
32
+ */
33
+ export function resolveRulesFile(configured, dataDir) {
34
+ return typeof configured === 'string' && configured !== '' ? configured : join(dataDir, 'rules.yml');
35
+ }
6
36
  export const DEFAULT_PERMISSIVE_STRATEGIES = {
7
37
  trustAutoAllow: true,
8
38
  alwaysConfirm: false,
9
39
  llmAssist: false,
10
40
  };
41
+ /**
42
+ * Presets in which the gate is active. It owns the `permissive` tier; `'*'`
43
+ * makes it global (every preset, including the hard-deny layer).
44
+ */
45
+ export const DEFAULT_GATE_PRESETS = ['permissive'];
46
+ /** Normalize the gate scope: an unset/empty list means the default. */
47
+ export function resolveGatePresets(configured) {
48
+ if (configured === undefined || configured.length === 0)
49
+ return DEFAULT_GATE_PRESETS;
50
+ return configured.filter((name) => typeof name === 'string' && name !== '');
51
+ }
11
52
  /** Normalize a backend strategy bag to fully-specified booleans (backend-part combinable). */
12
53
  export function resolvePermissiveStrategies(bag = {}) {
13
54
  return {
@@ -26,19 +67,25 @@ export const Config = z.object({
26
67
  classifierModel: z.string().default('deepseek-chat'),
27
68
  classifierApiKey: z.string(),
28
69
  riskTimeoutMs: z.number().min(1000).default(20_000),
29
- riskLearning: z.boolean().default(false),
30
- riskThreshold: z.number().min(1).max(10).default(3),
70
+ riskLearning: z.boolean().default(true),
71
+ riskSediment: z.boolean().default(true),
72
+ classifierSource: z.union(['custom', 'host']).default('custom'),
73
+ classifierProvider: z.string(),
74
+ riskThreshold: z.number().min(1).max(10).default(1),
31
75
  learningFile: z.string(),
32
76
  eventsFile: z.string(),
33
77
  grantTtlMs: z.number().min(1).default(5 * 60_000),
34
78
  grantMaxUses: z.number().min(1).default(1),
35
79
  permissive: z.boolean().default(false),
80
+ gatePresets: z.array(z.string()),
36
81
  permissiveStrategies: z.object({
37
82
  trustAutoAllow: z.boolean().default(true),
38
83
  alwaysConfirm: z.boolean().default(false),
39
84
  llmAssist: z.boolean().default(false),
40
85
  }),
41
86
  allowlist: z.array(z.string()),
87
+ denyKeywords: z.array(z.string()),
88
+ autoAllowTools: z.array(z.string()),
42
89
  });
43
90
  export function resolveConfig(config = {}) {
44
91
  const parsed = Config(config);
@@ -59,6 +106,7 @@ export function resolveConfig(config = {}) {
59
106
  grantTtlMs: parsed.grantTtlMs ?? 5 * 60_000,
60
107
  grantMaxUses: parsed.grantMaxUses ?? 1,
61
108
  permissive: parsed.permissive ?? false,
109
+ gatePresets: resolveGatePresets(parsed.gatePresets),
62
110
  permissiveStrategies: resolvePermissiveStrategies(parsed.permissiveStrategies),
63
111
  };
64
112
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Preset deny keywords inherited from dsh-approval-gate's `DEFAULT_DENY_KEYWORDS`
3
+ * (the preset blacklist layer of its pipeline). One keyword per entry, matched
4
+ * case-insensitively on word boundaries against the call's command text and its
5
+ * scalar arguments — document bodies are skipped — so a keyword can no longer
6
+ * veto an unrelated identifier or file text that merely contains it.
7
+ *
8
+ * Pure data shared by the node half (deny-keyword pre-layer) and the client
9
+ * bundle (the settings card's preset tags), so it must stay import-free.
10
+ */
11
+ /** The preset blacklist; the gate applies it whenever the namespace carries no override. */
12
+ export declare const DEFAULT_DENY_KEYWORDS: readonly string[];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Preset deny keywords inherited from dsh-approval-gate's `DEFAULT_DENY_KEYWORDS`
3
+ * (the preset blacklist layer of its pipeline). One keyword per entry, matched
4
+ * case-insensitively on word boundaries against the call's command text and its
5
+ * scalar arguments — document bodies are skipped — so a keyword can no longer
6
+ * veto an unrelated identifier or file text that merely contains it.
7
+ *
8
+ * Pure data shared by the node half (deny-keyword pre-layer) and the client
9
+ * bundle (the settings card's preset tags), so it must stay import-free.
10
+ */
11
+ /** The preset blacklist; the gate applies it whenever the namespace carries no override. */
12
+ export const DEFAULT_DENY_KEYWORDS = [
13
+ 'rm -rf', 'rm -fr', 'rm -r -f', 'rm --recursive --force',
14
+ 'push --force', 'force-push', 'force push', 'drop table', 'drop database',
15
+ 'mkfs', 'mkfs.ext', 'format', 'shutdown', 'reboot', 'dd of=',
16
+ 'delete from', 'truncate table', 'truncate ', 'terraform destroy', 'revoke',
17
+ '清空数据库', '删除数据库', '格式化', 'sudo rm', 'chmod 777 /',
18
+ 'git reset --hard', 'git clean -fd', 'docker rm', 'docker system prune',
19
+ ];
package/lib/engine.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { type Decision, type ToolCallContext } from './evaluate.js';
2
2
  export type EngineStage = 'hard-deny' | 'grant' | 'rule' | 'default' | 'ask' | 'classifier';
3
+ /** The built-in read-only / internal classification, for diagnostics and docs. */
4
+ export declare const AUTO_ALLOW_TOOLS: ReadonlySet<string>;
5
+ /**
6
+ * Argument keys that carry a document body rather than the operation itself.
7
+ * A file's text is not the operation: writing a document that *mentions* a
8
+ * token, a private key, or `credentials.yaml` must not hard-deny the write, and
9
+ * the deny-keyword layer already skips these keys. The path/command arguments
10
+ * that actually describe the operation are still scanned.
11
+ */
12
+ export declare const CONTENT_ARG_KEYS: ReadonlySet<string>;
3
13
  /**
4
14
  * Deterministic P0 hard-deny reason, or undefined when the call may proceed to
5
15
  * later stages.
@@ -14,4 +24,6 @@ export interface FinalDecision extends Decision {
14
24
  /** Pure P0–P2 + P4 decision; P3 handled by the caller before falling back to ask. */
15
25
  export declare function decide(ctx: ToolCallContext, ruleset: {
16
26
  decide: (c: ToolCallContext) => Decision;
17
- }, grants: GrantResolver): FinalDecision;
27
+ }, grants: GrantResolver,
28
+ /** Extra tool names the user classified as safe (`autoAllowTools`). */
29
+ autoAllowExtra?: ReadonlySet<string>): FinalDecision;
package/lib/engine.js CHANGED
@@ -11,18 +11,86 @@
11
11
  import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
12
12
  import { isForceDeletion, isRecursiveDeletion, decomposeShellCommand } from './shell.js';
13
13
  const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
14
- const READ_TOOLS = new Set(['read', 'read_image', 'grep', 'glob', 'ls', 'lsp']);
15
- function serialized(args) {
16
- try {
17
- return JSON.stringify(args);
18
- }
19
- catch {
14
+ /**
15
+ * Read-only tools that can never modify the workspace or execute anything. P0
16
+ * still protects sensitive path reads outside the workspace root, and the
17
+ * deny-keyword layer still runs first, so a search whose query contains a
18
+ * blacklisted phrase is still vetoed.
19
+ */
20
+ const READ_TOOLS = new Set([
21
+ 'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
22
+ // Read-only network / media queries: no write, no exec.
23
+ 'web_search', 'modlens_read_image',
24
+ ]);
25
+ /** DSH internal coordination / management tools — not workspace-modifying. */
26
+ const INTERNAL_TOOLS = new Set([
27
+ // AgentTeams coordination
28
+ 'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member',
29
+ 'agent_teams_delete', 'agent_teams_edit_plan', 'agent_teams_reassign_task',
30
+ 'agent_teams_claim_task', 'agent_teams_create_task', 'agent_teams_send_message',
31
+ 'agent_teams_approve', 'agent_teams_resume',
32
+ // AgentTeams status reads
33
+ 'agent_teams_status', 'agent_teams_update_task',
34
+ // DSH session/memory/goal/taskboard management
35
+ 'conversation_search',
36
+ 'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
37
+ 'get_goal', 'update_goal', 'create_goal',
38
+ 'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
39
+ 'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
40
+ 'taskboard_relate',
41
+ // Job/agent management
42
+ 'job_list', 'job_output', 'job_kill',
43
+ 'list_agents', 'interrupt_agent', 'send_message',
44
+ // Session-local UI / plan / todo state (no workspace write, no execution)
45
+ 'todo_write', 'render_ui', 'validate_dsh_ui', 'ask_user_question', 'exit_plan_mode',
46
+ // Background / planning delegation (the delegated work is gated separately)
47
+ 'subagent', 'subagent_fork', 'ralph', 'workflow', 'skill',
48
+ ]);
49
+ /** No user-configured extra auto-allow names. */
50
+ const NO_EXTRA_AUTO_ALLOW = new Set();
51
+ /** The built-in read-only / internal classification, for diagnostics and docs. */
52
+ export const AUTO_ALLOW_TOOLS = new Set([...READ_TOOLS, ...INTERNAL_TOOLS]);
53
+ /**
54
+ * Argument keys that carry a document body rather than the operation itself.
55
+ * A file's text is not the operation: writing a document that *mentions* a
56
+ * token, a private key, or `credentials.yaml` must not hard-deny the write, and
57
+ * the deny-keyword layer already skips these keys. The path/command arguments
58
+ * that actually describe the operation are still scanned.
59
+ */
60
+ export const CONTENT_ARG_KEYS = new Set([
61
+ 'content', 'contents', 'text', 'body', 'message', 'prompt',
62
+ 'new_string', 'old_string', 'newText', 'oldText', 'file_text', 'fileText',
63
+ 'diff', 'patch', 'replacement', 'snippet',
64
+ ]);
65
+ /** The call's non-body text: the operation, not any document it carries. */
66
+ function operationText(args, depth = 0) {
67
+ if (depth > 6)
20
68
  return '';
69
+ const parts = [];
70
+ for (const [key, value] of Object.entries(args)) {
71
+ if (typeof value === 'string') {
72
+ if (!CONTENT_ARG_KEYS.has(key))
73
+ parts.push(value);
74
+ continue;
75
+ }
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) {
78
+ if (typeof item === 'string')
79
+ parts.push(item);
80
+ else if (item !== null && typeof item === 'object')
81
+ parts.push(operationText(item, depth + 1));
82
+ }
83
+ continue;
84
+ }
85
+ if (value !== null && typeof value === 'object') {
86
+ parts.push(operationText(value, depth + 1));
87
+ }
21
88
  }
89
+ return parts.join(' ');
22
90
  }
23
91
  function containsCredentialMaterial(args) {
24
92
  return /(?:BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|\b(?:sk|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{8,}\b|Bearer\s+[A-Za-z0-9._~+\/-]{8,}|\.ssh[\\/](?:id_|config)|credentials\.yaml)/i
25
- .test(serialized(args));
93
+ .test(operationText(args));
26
94
  }
27
95
  function pathArgument(args) {
28
96
  for (const key of ['file_path', 'path', 'cwd', 'workdir']) {
@@ -73,10 +141,19 @@ export function hardDenyReason(ctx) {
73
141
  return undefined;
74
142
  }
75
143
  /** Pure P0–P2 + P4 decision; P3 handled by the caller before falling back to ask. */
76
- export function decide(ctx, ruleset, grants) {
144
+ export function decide(ctx, ruleset, grants,
145
+ /** Extra tool names the user classified as safe (`autoAllowTools`). */
146
+ autoAllowExtra = NO_EXTRA_AUTO_ALLOW) {
77
147
  const hard = hardDenyReason(ctx);
78
148
  if (hard !== undefined)
79
149
  return { action: 'deny', reason: `[hard-deny] ${hard}`, stage: 'hard-deny', ruleIndex: undefined };
150
+ // Auto-allow read-only tools and DSH internal coordination/management tools:
151
+ // they cannot modify the workspace or execute a command, so asking a human
152
+ // about each one is pure noise. P0 hard-deny already protects sensitive-path
153
+ // reads and credential material, and the deny-keyword layer runs even earlier.
154
+ if (READ_TOOLS.has(ctx.tool) || INTERNAL_TOOLS.has(ctx.tool) || autoAllowExtra.has(ctx.tool)) {
155
+ return { action: 'allow', reason: 'read-only internal tool', stage: 'grant', ruleIndex: undefined };
156
+ }
80
157
  if (grants(ctx.tool, ctx.args) === 'allow') {
81
158
  return { action: 'allow', reason: 'covered by session grant', stage: 'grant', ruleIndex: undefined };
82
159
  }
package/lib/evaluate.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface ToolCallContext {
5
5
  readonly args: Record<string, unknown>;
6
6
  readonly commandText?: string;
7
7
  readonly cwd: string;
8
+ /** Workspace root; used by the write-path override to decide inside vs outside. */
8
9
  readonly home?: string;
9
10
  readonly dshHome?: string;
10
11
  readonly caseInsensitive?: boolean;
@@ -14,6 +15,15 @@ export interface Decision {
14
15
  readonly reason: string;
15
16
  readonly ruleIndex: number | undefined;
16
17
  }
18
+ /**
19
+ * Tool names that execute a shell command string. `shell` and `terminal` are
20
+ * DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
21
+ * interactive variant), and `pwsh` / `bash` / `sh` / `cmd` / `powershell` cover
22
+ * the platform-specific aliases. Missing `shell` here silently disables the
23
+ * command-content inspection below for the primary tool — every `git push`,
24
+ * `chmod`, redirect and `tee` would fall through to `defaultAction`.
25
+ */
26
+ export declare const SHELL_TOOLS: Set<string>;
17
27
  /** Whether one compiled rule matches the given call context. */
18
28
  export declare function ruleMatches(rule: CompiledRuleEntry, ctx: ToolCallContext, commands: readonly SimpleCommand[], pathCands: readonly string[], urlCands: readonly string[]): boolean;
19
29
  /** Compute the deterministic first-match decision (P2 chain). */
package/lib/evaluate.js CHANGED
@@ -6,7 +6,15 @@
6
6
  import { normalizeWorkspacePath } from './path.js';
7
7
  import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
8
8
  import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
9
- const SHELL_TOOLS = new Set(['bash', 'pwsh', 'sh', 'cmd', 'powershell']);
9
+ /**
10
+ * Tool names that execute a shell command string. `shell` and `terminal` are
11
+ * DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
12
+ * interactive variant), and `pwsh` / `bash` / `sh` / `cmd` / `powershell` cover
13
+ * the platform-specific aliases. Missing `shell` here silently disables the
14
+ * command-content inspection below for the primary tool — every `git push`,
15
+ * `chmod`, redirect and `tee` would fall through to `defaultAction`.
16
+ */
17
+ export const SHELL_TOOLS = new Set(['shell', 'terminal', 'bash', 'pwsh', 'sh', 'cmd', 'powershell']);
10
18
  /** Whether one compiled rule matches the given call context. */
11
19
  export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
12
20
  if (!rule.enabled)
@@ -58,11 +66,84 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
58
66
  }
59
67
  return true;
60
68
  }
69
+ const WRITE_TOOLS = new Set(['write', 'edit']);
70
+ /**
71
+ * Shell write/execution patterns. When NONE of these match, the command is
72
+ * treated as read-only and follows the defaultAction (typically `allow`).
73
+ *
74
+ * The deny-keyword layer catches dangerous phrases (rm -rf, dd, mkfs …) before
75
+ * this function runs, so this list only covers "mildly suspicious" writes that
76
+ * aren't dangerous enough for the preset blacklist.
77
+ */
78
+ const SHELL_WRITE_PATTERNS = [
79
+ /(?:^|\s)(?:mv|cp|install|mkdir|touch|chmod|chown)\b/i,
80
+ // Redirect into a file: `> f`, `>> f`, `>f`. The leading character class
81
+ // excludes the stderr idioms a read-only command uses (`2>&1`, `>&2`,
82
+ // `2>/dev/null`, `1>&2`), and the trailing class requires an actual target.
83
+ /(?:^|[^0-9&])>>?\s*[^\s&|]/,
84
+ // `tee` always writes its target; `curl`/`wget` only when they write a file.
85
+ /(?:^|\s)tee\b/i,
86
+ /(?:^|\s)(?:curl|wget)\b.*?(?:\s-[oO]\b|>)/i,
87
+ /(?:^|\s)(?:git|npm|pnpm|yarn|bun)\s+(?:add|commit|push|rebase|reset|rm|stash|checkout|tag)\b/i,
88
+ /(?:^|\s)(?:docker|podman|kubectl)\s+(?:rm|stop|kill|exec|cp)\b/i,
89
+ /(?:^|\s)(?:gh)\s+(?:pr\s+create|issue\s+create|repo\s+create)\b/i,
90
+ /(?:^|\s)(?:terraform|ansible)\s+(?:apply|destroy|provision)\b/i,
91
+ /(?:^|\s)(?:sed|awk)\s+['"]?-[is]/,
92
+ ];
93
+ /** Whether a shell command text contains a write or destructive pattern. */
94
+ function isWriteCommand(commandText) {
95
+ return SHELL_WRITE_PATTERNS.some((p) => typeof p === 'string' ? commandText.includes(p) : p.test(commandText));
96
+ }
97
+ /**
98
+ * Default decision for write/edit and shell tools when no rule matches.
99
+ *
100
+ * - `write` / `edit`: workspace-internal paths follow `defaultAction`
101
+ * (typically `allow`); any target outside the workspace escalates to `ask`
102
+ * for content review. This is a hard safety rule that cannot be overridden
103
+ * by an explicit allow rule.
104
+ * - `shell` / `pwsh` / `terminal`: read-only commands (git status, python -c
105
+ * fetch, curl without redirect, etc.) follow `defaultAction`; commands that
106
+ * contain a write/execution pattern escalate to `ask` for review.
107
+ */
108
+ function pathAndCommandDefault(ctx, pathCandidates, defaultAction) {
109
+ if (defaultAction !== 'allow')
110
+ return defaultAction;
111
+ // write/edit: path-aware (outside workspace → ask)
112
+ if (WRITE_TOOLS.has(ctx.tool)) {
113
+ const home = ctx.home;
114
+ if (home !== undefined && home !== '') {
115
+ for (const candidate of pathCandidates) {
116
+ if (normalizeWorkspacePath(home, candidate, ctx.caseInsensitive) === '')
117
+ return 'ask';
118
+ }
119
+ }
120
+ return defaultAction;
121
+ }
122
+ // shell/pwsh/terminal: command-content-aware (write pattern → ask)
123
+ if (SHELL_TOOLS.has(ctx.tool)) {
124
+ const commandText = commandTextOf(ctx);
125
+ if (commandText !== undefined && isWriteCommand(commandText))
126
+ return 'ask';
127
+ }
128
+ return defaultAction;
129
+ }
130
+ /**
131
+ * Command text of a shell call: the explicit `commandText` field when set,
132
+ * otherwise `args.command`. The runtime populates `commandText` in
133
+ * `PermGateRuntime.ctxFor`, but `decideRules` is exported and also called with
134
+ * a hand-built context (tests, embedders); deriving here keeps every caller on
135
+ * the same contract instead of silently treating a write command as read-only.
136
+ */
137
+ function commandTextOf(ctx) {
138
+ if (typeof ctx.commandText === 'string')
139
+ return ctx.commandText;
140
+ const raw = ctx.args?.['command'];
141
+ return typeof raw === 'string' ? raw : undefined;
142
+ }
61
143
  /** Compute the deterministic first-match decision (P2 chain). */
62
144
  export function decideRules(ruleset, ctx) {
63
- const commands = ctx.commandText !== undefined && SHELL_TOOLS.has(ctx.tool)
64
- ? decomposeSafe(ctx.commandText)
65
- : [];
145
+ const shellText = SHELL_TOOLS.has(ctx.tool) ? commandTextOf(ctx) : undefined;
146
+ const commands = shellText !== undefined ? decomposeSafe(shellText) : [];
66
147
  const pathCands = extractPathCandidates(ctx.args);
67
148
  const urlCands = extractUrlCandidates(ctx.args);
68
149
  const scan = (list, _action) => {
@@ -76,13 +157,22 @@ export function decideRules(ruleset, ctx) {
76
157
  const deny = scan(ruleset.deny, 'deny');
77
158
  if (deny !== undefined)
78
159
  return deny;
79
- const allow = scan(ruleset.allow, 'allow');
80
- if (allow !== undefined)
81
- return allow;
82
- const ask = scan(ruleset.ask, 'ask');
83
- if (ask !== undefined)
84
- return ask;
85
- return { action: ruleset.defaultAction, reason: `no rule matched; default action`, ruleIndex: undefined };
160
+ // Path/command-aware default: write/edit tools check the target path,
161
+ // shell/pwsh/terminal tools check the command for write patterns.
162
+ // Outside safety checks → ask; inside or read-only → defaultAction.
163
+ let action = ruleset.defaultAction;
164
+ if (WRITE_TOOLS.has(ctx.tool) || SHELL_TOOLS.has(ctx.tool)) {
165
+ action = pathAndCommandDefault(ctx, pathCands, action);
166
+ }
167
+ if (action === 'allow' || action === 'ask') {
168
+ const allow = scan(ruleset.allow, 'allow');
169
+ if (allow !== undefined)
170
+ return allow;
171
+ const ask = scan(ruleset.ask, 'ask');
172
+ if (ask !== undefined)
173
+ return ask;
174
+ }
175
+ return { action, reason: `no rule matched; default action`, ruleIndex: undefined };
86
176
  }
87
177
  function decomposeSafe(text) {
88
178
  try {