dsh-perm-gate 2.1.0 → 2.6.1
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 +108 -0
- package/CHANGELOG.ko.md +104 -0
- package/CHANGELOG.md +347 -0
- package/INSTALL.ja.md +84 -8
- package/INSTALL.ko.md +80 -9
- package/INSTALL.md +83 -8
- package/INSTALL.zh.md +70 -7
- package/README.ja.md +130 -16
- package/README.ko.md +128 -17
- package/README.md +179 -25
- package/README.zh.md +121 -12
- package/assets/allow1.png +0 -0
- package/assets/allow2.png +0 -0
- package/assets/gateoff.png +0 -0
- package/assets/llms.png +0 -0
- package/assets/permlist.png +0 -0
- package/assets/setting-head.png +0 -0
- package/assets/setting-rules.png +0 -0
- package/cordis.patch.yml +37 -3
- package/docs/baseline-guard.md +56 -0
- package/docs/plan-rules-to-settings.md +217 -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 +22 -0
- package/examples/permissions.example.yaml +16 -1
- package/lib/agent-identity.d.ts +43 -0
- package/lib/agent-identity.js +95 -0
- package/lib/allowlist.d.ts +27 -0
- package/lib/allowlist.js +103 -1
- package/lib/cli.js +11 -14
- package/lib/client.js +598 -6
- 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 +50 -0
- package/lib/compiler.js +122 -0
- package/lib/config.d.ts +161 -7
- package/lib/config.js +78 -11
- package/lib/dry-run.d.ts +87 -0
- package/lib/dry-run.js +110 -0
- package/lib/engine.js +61 -4
- package/lib/evaluate.d.ts +9 -0
- package/lib/evaluate.js +264 -1
- package/lib/events.d.ts +69 -2
- package/lib/events.js +123 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +320 -11
- package/lib/network-lifecycle.d.ts +107 -0
- package/lib/network-lifecycle.js +180 -0
- package/lib/network.d.ts +109 -0
- package/lib/network.js +196 -0
- 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/proxy.d.ts +160 -0
- package/lib/proxy.js +529 -0
- package/lib/risk.d.ts +17 -3
- package/lib/risk.js +19 -6
- package/lib/rule-chain.d.ts +39 -0
- package/lib/rule-chain.js +213 -0
- package/lib/rule-dims.d.ts +174 -0
- package/lib/rule-dims.js +230 -0
- package/lib/rule.d.ts +60 -0
- package/lib/rule.js +69 -7
- package/lib/rules-view.d.ts +52 -0
- package/lib/rules-view.js +126 -0
- package/lib/runtime.d.ts +208 -7
- package/lib/runtime.js +433 -27
- package/lib/session-sweep.d.ts +45 -0
- package/lib/session-sweep.js +153 -0
- package/lib/shadow.d.ts +34 -0
- package/lib/shadow.js +143 -0
- package/lib/shell.d.ts +14 -0
- package/lib/shell.js +17 -0
- package/lib/watch.d.ts +42 -0
- package/lib/watch.js +170 -0
- package/package.json +26 -3
- package/scripts/patch-permission-glyph.mjs +392 -0
package/lib/dry-run.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RuleAction } from './rule.js';
|
|
2
|
+
import { PermGateRuntime } from './runtime.js';
|
|
3
|
+
export interface DryRunInput {
|
|
4
|
+
/** Tool name to evaluate, e.g. `shell`, `read`, `write`. */
|
|
5
|
+
readonly tool: string;
|
|
6
|
+
/** The tool call's arguments; `command` is what the shell dimensions read. */
|
|
7
|
+
readonly args?: Record<string, unknown>;
|
|
8
|
+
/** Rules file to load. Unset = the default chain (cwd upward, then the data home). */
|
|
9
|
+
readonly rulesFile?: string;
|
|
10
|
+
/** Working directory the call is evaluated in. Defaults to `process.cwd()`. */
|
|
11
|
+
readonly cwd?: string;
|
|
12
|
+
/** Evaluate with the independent Permissive tier on. */
|
|
13
|
+
readonly permissive?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Also run the **host-facing** decision path and report its own view.
|
|
16
|
+
*
|
|
17
|
+
* That path appends audit entries, records decision events, tracks asks and
|
|
18
|
+
* clears call clearances — so it is opt-in, and a read-only caller must never
|
|
19
|
+
* request it against a live runtime. Only the CLI asks for it, to keep its
|
|
20
|
+
* historic output byte for byte.
|
|
21
|
+
*/
|
|
22
|
+
readonly hostView?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The host-facing decision path's own view (`decideExecution`) and its audit count. */
|
|
25
|
+
export interface DryRunHostView {
|
|
26
|
+
readonly verdict: RuleAction;
|
|
27
|
+
/** The waterfall's reason, or the historic `(default/passthrough)` placeholder. */
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
readonly audited: number;
|
|
30
|
+
}
|
|
31
|
+
/** The P2 rule chain's own verdict, with the rule that produced it. */
|
|
32
|
+
export interface DryRunRuleLayer {
|
|
33
|
+
readonly action: RuleAction;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
readonly ruleIndex?: number;
|
|
36
|
+
/**
|
|
37
|
+
* The dimensions the matched rule *constrains* — not "the dimension that
|
|
38
|
+
* caused the match". Dimensions are ANDed and an empty one is no constraint at
|
|
39
|
+
* all, so a multi-dimension rule cannot be attributed to a single dimension
|
|
40
|
+
* without lying about which one did the work.
|
|
41
|
+
*/
|
|
42
|
+
readonly matchedDimensions: readonly string[];
|
|
43
|
+
/** The raw rule entry as written in the YAML, for display. */
|
|
44
|
+
readonly source?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
export interface DryRunResult {
|
|
47
|
+
readonly tool: string;
|
|
48
|
+
readonly rulesFile?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The **policy** verdict: what the chain decides for this call, independent of
|
|
51
|
+
* the session a caller does not have. See {@link PermGateRuntime.explainCall}
|
|
52
|
+
* for why the session layers are excluded rather than guessed.
|
|
53
|
+
*/
|
|
54
|
+
readonly verdict: RuleAction;
|
|
55
|
+
/** Why, in the chain's own words — never a bare placeholder. */
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
/** Which layer produced `verdict`. */
|
|
58
|
+
readonly source: string;
|
|
59
|
+
readonly defaultAction: RuleAction;
|
|
60
|
+
readonly ruleCount: number;
|
|
61
|
+
readonly permissive: boolean;
|
|
62
|
+
readonly ruleLayer: DryRunRuleLayer;
|
|
63
|
+
/** Present only when `input.hostView` was set. */
|
|
64
|
+
readonly host?: DryRunHostView;
|
|
65
|
+
}
|
|
66
|
+
export interface DryRunOptions {
|
|
67
|
+
readonly rulesFile?: string;
|
|
68
|
+
readonly permissive?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Workspace root for the rule CHAIN resolution (cwd upward, then the data
|
|
71
|
+
* home). `undefined` keeps the runtime default (`process.cwd()`).
|
|
72
|
+
*
|
|
73
|
+
* The CLI deliberately does not pass its `--cwd` here: that flag scopes the
|
|
74
|
+
* evaluated call, and feeding it into chain resolution as well would change
|
|
75
|
+
* what an existing `--cwd` invocation resolves. A host caller (the rule-test
|
|
76
|
+
* route) does pass its session workspace, because for a live session "which
|
|
77
|
+
* rules file applies" is exactly the question being asked.
|
|
78
|
+
*/
|
|
79
|
+
readonly cwd?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the standalone runtime a dry-run evaluates against. Exported because
|
|
83
|
+
* `--list` needs the loaded ruleset without deciding anything.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createDryRunRuntime(options?: DryRunOptions): PermGateRuntime;
|
|
86
|
+
/** Evaluate one call against a rules file and report the verdict. */
|
|
87
|
+
export declare function runDryRun(input: DryRunInput, runtime?: PermGateRuntime): DryRunResult;
|
package/lib/dry-run.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule dry-run: evaluate one would-be tool call against a rules file and report
|
|
3
|
+
* the verdict in a shape both the CLI and the settings-card "rule test" panel can
|
|
4
|
+
* use.
|
|
5
|
+
*
|
|
6
|
+
* The evaluator itself lives in {@link PermGateRuntime}; this module only owns
|
|
7
|
+
* the standalone construction (no session, no audit feed, no event log, no
|
|
8
|
+
* persistence paths) and the reporting shape. Nothing here writes to disk or to
|
|
9
|
+
* the live gate — the runtime it builds is thrown away when the call returns.
|
|
10
|
+
*
|
|
11
|
+
* Two verdicts are reported, and they are not the same thing:
|
|
12
|
+
*
|
|
13
|
+
* - `verdict` is the *effective* result of the whole chain the live gate runs
|
|
14
|
+
* (P0 hard-deny → P1 grant → P2 rules → P3 classifier → P4 ask), minus the
|
|
15
|
+
* session-scoped state a dry-run has none of. This is what the user sees.
|
|
16
|
+
* - `ruleLayer` is what the `permissions` chain decides *on its own*. Only this
|
|
17
|
+
* layer can name a rule, so `ruleIndex` belongs to it. A P0 hard-deny or a
|
|
18
|
+
* preset deny-keyword fires before it and reports no index; when the two
|
|
19
|
+
* differ, that difference is the answer, not an error.
|
|
20
|
+
*/
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { PermGateRuntime } from './runtime.js';
|
|
23
|
+
/**
|
|
24
|
+
* The eleven match dimensions, in the order `docs/rules-format.md` documents
|
|
25
|
+
* them. Used purely for reporting which dimensions a matched rule constrains.
|
|
26
|
+
*/
|
|
27
|
+
const DIMENSION_KEYS = [
|
|
28
|
+
'tools',
|
|
29
|
+
'command',
|
|
30
|
+
'args',
|
|
31
|
+
'paths',
|
|
32
|
+
'params',
|
|
33
|
+
'absent',
|
|
34
|
+
'agents',
|
|
35
|
+
'when',
|
|
36
|
+
'argv',
|
|
37
|
+
'network',
|
|
38
|
+
'branch',
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Build the standalone runtime a dry-run evaluates against. Exported because
|
|
42
|
+
* `--list` needs the loaded ruleset without deciding anything.
|
|
43
|
+
*/
|
|
44
|
+
export function createDryRunRuntime(options = {}) {
|
|
45
|
+
return new PermGateRuntime({
|
|
46
|
+
rulesFile: options.rulesFile,
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
caseInsensitivePaths: true,
|
|
49
|
+
permissive: options.permissive || undefined,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Which of the eleven dimensions this rule actually constrains. */
|
|
53
|
+
function constrainedDimensions(source) {
|
|
54
|
+
return DIMENSION_KEYS.filter((key) => isConstrained(source[key]));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A dimension constrains when it carries at least one condition. Every shape the
|
|
58
|
+
* parser produces is covered: pattern lists, key→value maps, condition objects.
|
|
59
|
+
*/
|
|
60
|
+
function isConstrained(value) {
|
|
61
|
+
if (value === undefined || value === null)
|
|
62
|
+
return false;
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return value.length > 0;
|
|
65
|
+
if (typeof value === 'object')
|
|
66
|
+
return Object.keys(value).length > 0;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Evaluate one call against a rules file and report the verdict. */
|
|
70
|
+
export function runDryRun(input, runtime) {
|
|
71
|
+
const rulesFile = input.rulesFile ? resolve(input.rulesFile) : undefined;
|
|
72
|
+
const gate = runtime ?? createDryRunRuntime({ rulesFile, permissive: input.permissive, cwd: input.cwd });
|
|
73
|
+
const exec = {
|
|
74
|
+
name: input.tool,
|
|
75
|
+
arguments: input.args ?? {},
|
|
76
|
+
cwd: input.cwd ? resolve(input.cwd) : process.cwd(),
|
|
77
|
+
};
|
|
78
|
+
// Pure policy first (the panel's answer); the side-effecting host view only
|
|
79
|
+
// when explicitly asked for, and it must run last so its audit count is its own.
|
|
80
|
+
const policy = gate.explainCall(exec);
|
|
81
|
+
const layer = gate.explainRules(exec);
|
|
82
|
+
const source = layer.rule?.source;
|
|
83
|
+
let host;
|
|
84
|
+
if (input.hostView === true) {
|
|
85
|
+
const decision = gate.decideExecution(exec);
|
|
86
|
+
host = {
|
|
87
|
+
verdict: decision === undefined ? 'allow' : decision.kind,
|
|
88
|
+
reason: decision?.reason ?? '(default/passthrough)',
|
|
89
|
+
audited: gate.auditEntries.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
tool: input.tool,
|
|
94
|
+
rulesFile,
|
|
95
|
+
verdict: policy.action,
|
|
96
|
+
reason: policy.reason,
|
|
97
|
+
source: policy.source,
|
|
98
|
+
defaultAction: layer.defaultAction,
|
|
99
|
+
ruleCount: gate.ruleCount(),
|
|
100
|
+
permissive: gate.permissive,
|
|
101
|
+
ruleLayer: {
|
|
102
|
+
action: layer.action,
|
|
103
|
+
reason: layer.reason,
|
|
104
|
+
ruleIndex: layer.ruleIndex,
|
|
105
|
+
matchedDimensions: source === undefined ? [] : constrainedDimensions(source),
|
|
106
|
+
source,
|
|
107
|
+
},
|
|
108
|
+
...(host !== undefined ? { host } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
package/lib/engine.js
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* Strictly fail-closed: P0 wins over every later stage.
|
|
10
10
|
*/
|
|
11
11
|
import { isProtectedDestructiveTarget, isSensitivePath, isWithin } from './path.js';
|
|
12
|
+
// SHELL_TOOLS 是**值的导入**:这份名单必须与 evaluate.ts / runtime.ts 共用同一个
|
|
13
|
+
// 事实源。此处曾经自带一份 `/(?:bash|pwsh|sh|cmd)$/` 正则,漏掉了 `shell` /
|
|
14
|
+
// `terminal` / `powershell`——于是 P0 的 shell 检查对 DSH 的**主 shell 工具**
|
|
15
|
+
// 完全失效(实测:同一句 `echo x > /etc/passwd`,bash 被拦截,shell 放行)。
|
|
16
|
+
// 同名事实的第二份副本必然腐烂,这里不再保留副本。
|
|
17
|
+
import { SHELL_TOOLS } from './evaluate.js';
|
|
12
18
|
import { isForceDeletion, isRecursiveDeletion, decomposeShellCommand } from './shell.js';
|
|
19
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
13
20
|
const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe|unlink|rmdir|reset)(?:$|[_-]|recursive)/i;
|
|
14
21
|
/**
|
|
15
22
|
* Read-only tools that can never modify the workspace or execute anything. P0
|
|
@@ -19,8 +26,13 @@ const DESTRUCTIVE_TOOL = /(?:^|[_-])(?:delete|remove|rm|destroy|erase|purge|wipe
|
|
|
19
26
|
*/
|
|
20
27
|
const READ_TOOLS = new Set([
|
|
21
28
|
'read', 'read_image', 'grep', 'glob', 'ls', 'lsp',
|
|
22
|
-
//
|
|
29
|
+
// Search / lookup: every one of these only READS. `web_search` and
|
|
30
|
+
// `modlens_read_image` also reach the network, but through DSH's own client
|
|
31
|
+
// rather than a subprocess — see the network note below.
|
|
23
32
|
'web_search', 'modlens_read_image',
|
|
33
|
+
'advanced_search', 'platform_search', 'free_search_test',
|
|
34
|
+
// Session-scoped reads: they disclose already-recorded state, never mutate.
|
|
35
|
+
'context_compression_retrieve', 'memory_search_graph', 'memory_expand_graph_node',
|
|
24
36
|
]);
|
|
25
37
|
/** DSH internal coordination / management tools — not workspace-modifying. */
|
|
26
38
|
const INTERNAL_TOOLS = new Set([
|
|
@@ -31,9 +43,11 @@ const INTERNAL_TOOLS = new Set([
|
|
|
31
43
|
'agent_teams_approve', 'agent_teams_resume',
|
|
32
44
|
// AgentTeams status reads
|
|
33
45
|
'agent_teams_status', 'agent_teams_update_task',
|
|
34
|
-
// DSH session/memory/goal/taskboard management
|
|
46
|
+
// DSH session/memory/goal/taskboard management. The memory store is the
|
|
47
|
+
// agent's own state, not the workspace: writing it is not a workspace write.
|
|
35
48
|
'conversation_search',
|
|
36
49
|
'memory_add', 'memory_delete', 'memory_read_scene', 'memory_search',
|
|
50
|
+
'memory_import', 'memory_ruminate', 'memory_ruminate_cancel', 'memory_ruminate_status',
|
|
37
51
|
'get_goal', 'update_goal', 'create_goal',
|
|
38
52
|
'taskboard_get', 'taskboard_list', 'taskboard_claim', 'taskboard_block',
|
|
39
53
|
'taskboard_comment', 'taskboard_release_claim', 'taskboard_submit_review',
|
|
@@ -100,6 +114,42 @@ function pathArgument(args) {
|
|
|
100
114
|
}
|
|
101
115
|
return undefined;
|
|
102
116
|
}
|
|
117
|
+
/** 廉价前缀过滤:绝大多数 shell 调用不是 push,先过一遍正则再动用解析器。 */
|
|
118
|
+
const GIT_PUSH_TEXT = /\bgit\s+push\b/;
|
|
119
|
+
/**
|
|
120
|
+
* 远端历史改写:`git push` 强制覆盖或删除**保护分支**。
|
|
121
|
+
*
|
|
122
|
+
* **「只升不降」是形状约束,不是口头约定**:返回值只有 `string | undefined`,
|
|
123
|
+
* 调用方把 `string` 读作 deny、`undefined` 读作「继续走后面的阶段」。本函数
|
|
124
|
+
* 结构上**无法**产生 allow,所以挂在 P0 上永远不会放宽门的判定。
|
|
125
|
+
*
|
|
126
|
+
* 刻意**不**覆盖(仍交给关键词层 / 规则层 / LLM 层):
|
|
127
|
+
* - 非保护分支上的 force push(如 `origin feat/x`)——P0 不可撤销,得给工作流留口子;
|
|
128
|
+
* - 没写分支名的 `git push --force`——解析不出目标就不做断言;
|
|
129
|
+
* - 普通 push。
|
|
130
|
+
*
|
|
131
|
+
* 这正是本插件补不上的那一段:`DEFAULT_DENY_KEYWORDS` 里的 `'push --force'` 是
|
|
132
|
+
* **扁平的、可被命名空间覆盖掉**的,且完全不看分支;P0 这一层则是不可协商的底。
|
|
133
|
+
*/
|
|
134
|
+
function gitRemoteRewriteReason(segments) {
|
|
135
|
+
for (const seg of segments) {
|
|
136
|
+
const text = [seg.command, ...seg.args].join(' ');
|
|
137
|
+
if (!GIT_PUSH_TEXT.test(text))
|
|
138
|
+
continue;
|
|
139
|
+
const result = dispatchCommand(text);
|
|
140
|
+
if (result === null)
|
|
141
|
+
continue;
|
|
142
|
+
const s = result.semantics;
|
|
143
|
+
if (s.operation !== 'force-push' && s.operation !== 'push-delete')
|
|
144
|
+
continue;
|
|
145
|
+
if (!s.targetsSharedBranch)
|
|
146
|
+
continue;
|
|
147
|
+
const what = s.operation === 'push-delete' ? 'delete' : 'force-overwrite';
|
|
148
|
+
const remote = s.remote === undefined ? '' : ` (remote ${s.remote})`;
|
|
149
|
+
return `git push would ${what} protected branch: ${s.branch}${remote}`;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
103
153
|
/**
|
|
104
154
|
* Deterministic P0 hard-deny reason, or undefined when the call may proceed to
|
|
105
155
|
* later stages.
|
|
@@ -108,8 +158,7 @@ export function hardDenyReason(ctx) {
|
|
|
108
158
|
const args = ctx.args;
|
|
109
159
|
if (containsCredentialMaterial(args))
|
|
110
160
|
return 'call contains credential or private-key material';
|
|
111
|
-
|
|
112
|
-
if (shell && typeof args.command === 'string') {
|
|
161
|
+
if (SHELL_TOOLS.has(ctx.tool) && typeof args.command === 'string') {
|
|
113
162
|
let commands = [];
|
|
114
163
|
try {
|
|
115
164
|
commands = decomposeShellCommand(args.command).commands;
|
|
@@ -126,8 +175,16 @@ export function hardDenyReason(ctx) {
|
|
|
126
175
|
if (redir !== undefined)
|
|
127
176
|
return `shell redirect targets protected path: ${redir}`;
|
|
128
177
|
}
|
|
178
|
+
const rewrite = gitRemoteRewriteReason(commands);
|
|
179
|
+
if (rewrite !== undefined)
|
|
180
|
+
return rewrite;
|
|
129
181
|
}
|
|
130
182
|
const path = pathArgument(args);
|
|
183
|
+
// 注意:这里**刻意不**用 SHELL_TOOLS。本判据读的是 `pathArgument`(file_path /
|
|
184
|
+
// path / cwd / **workdir**),它是"被改写的路径"而不是"要执行的命令"。shell 工具的
|
|
185
|
+
// 真目标在 command 里,由上面的命令级检查负责;若把 shell 工具也塞进来,
|
|
186
|
+
// `shell({ command: 'ls', workdir: '/etc' })` 这种纯读操作会因为 workdir 落在受保护
|
|
187
|
+
// 前缀而被 P0 永久挡住——那是过度拦截,而且 P0 撤销不了。
|
|
131
188
|
if (path !== undefined && (DESTRUCTIVE_TOOL.test(ctx.tool) || ctx.tool === 'bash' || ctx.tool === 'pwsh' || ctx.tool === 'write' || ctx.tool === 'edit')) {
|
|
132
189
|
if (isProtectedDestructiveTarget(path, ctx.dshHome))
|
|
133
190
|
return `mutation targets protected path: ${path}`;
|
package/lib/evaluate.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ export interface ToolCallContext {
|
|
|
9
9
|
readonly home?: string;
|
|
10
10
|
readonly dshHome?: string;
|
|
11
11
|
readonly caseInsensitive?: boolean;
|
|
12
|
+
/** Agent identity candidates from session context. */
|
|
13
|
+
readonly agentCandidates?: readonly string[];
|
|
14
|
+
/** Network request info (for network dimension matching). */
|
|
15
|
+
readonly network?: {
|
|
16
|
+
readonly domain?: string;
|
|
17
|
+
readonly ip?: string;
|
|
18
|
+
readonly port?: number;
|
|
19
|
+
readonly scheme?: string;
|
|
20
|
+
};
|
|
12
21
|
}
|
|
13
22
|
export interface Decision {
|
|
14
23
|
readonly action: RuleAction;
|
package/lib/evaluate.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { normalizeWorkspacePath } from './path.js';
|
|
7
7
|
import { extractPathCandidates, extractUrlCandidates, } from './rule.js';
|
|
8
|
-
import { decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
8
|
+
import { commandArgv, decomposeShellCommand, isForceDeletion, isRecursiveDeletion } from './shell.js';
|
|
9
|
+
import { compileCidr, compilePortSpec, compileDomainPattern } from './compiler.js';
|
|
10
|
+
import { dispatchCommand } from './command-dispatcher.js';
|
|
9
11
|
/**
|
|
10
12
|
* Tool names that execute a shell command string. `shell` and `terminal` are
|
|
11
13
|
* DSH's own names (the shipped tool roster uses `shell`; `terminal` is the
|
|
@@ -64,6 +66,56 @@ export function ruleMatches(rule, ctx, commands, pathCands, urlCands) {
|
|
|
64
66
|
if (!rel.some((p) => rule.paths.some((g) => g.re.test(p))))
|
|
65
67
|
return false;
|
|
66
68
|
}
|
|
69
|
+
// ─── New dimensions ────────────────────────────────────────────────────
|
|
70
|
+
// params dimension: AND over keys; each key's value must match at least one pattern (OR).
|
|
71
|
+
if (rule.params.length > 0) {
|
|
72
|
+
if (!matchParams(rule.params, ctx.args))
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// absent dimension: all listed keys must be absent from args.
|
|
76
|
+
if (rule.absent.length > 0) {
|
|
77
|
+
for (const key of rule.absent) {
|
|
78
|
+
if (key in ctx.args)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// agents dimension: at least one candidate must match an entry.
|
|
83
|
+
if (rule.agents.length > 0) {
|
|
84
|
+
const candidates = ctx.agentCandidates ?? [];
|
|
85
|
+
if (candidates.length === 0)
|
|
86
|
+
return false; // fail-closed
|
|
87
|
+
if (!candidates.some((c) => rule.agents.some((a) => a === c || matchAgentPattern(a, c))))
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
// when dimension: all conditions must be satisfied.
|
|
91
|
+
if (rule.when !== undefined) {
|
|
92
|
+
if (!matchWhen(rule.when))
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
// argv dimension: pipeline patterns.
|
|
96
|
+
if (rule.argv?.pipeline !== undefined && rule.argv.pipeline.length > 0) {
|
|
97
|
+
if (commands.length === 0)
|
|
98
|
+
return false;
|
|
99
|
+
// Join the FULL argv of each simple command: `c.command` alone is just the
|
|
100
|
+
// command word, so `curl https://x | sh` would read as `curl|sh` and the
|
|
101
|
+
// documented `curl|sh` pattern could never see its own subject.
|
|
102
|
+
const pipelineText = commands.map((c) => commandArgv(c)).join('|');
|
|
103
|
+
const compiled = rule.argv.pipeline.map((p) => compileGlobForMatch(p));
|
|
104
|
+
if (!compiled.some((g) => g.test(pipelineText)))
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
// network dimension: all present sub-dimensions must match.
|
|
108
|
+
if (rule.network !== undefined) {
|
|
109
|
+
if (!matchNetwork(rule.network, ctx.network))
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// branch dimension: git semantics only; non-git commands satisfy no branch
|
|
113
|
+
// dimension (per sub-dimension above). Never throws — a parser is pure, and
|
|
114
|
+
// the dispatcher falls back to `family: 'unknown'` rather than raising.
|
|
115
|
+
if (rule.branch !== undefined) {
|
|
116
|
+
if (!matchBranch(rule.branch, commands))
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
67
119
|
return true;
|
|
68
120
|
}
|
|
69
121
|
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
@@ -182,3 +234,214 @@ function decomposeSafe(text) {
|
|
|
182
234
|
return [];
|
|
183
235
|
}
|
|
184
236
|
}
|
|
237
|
+
// ─── New dimension matchers ────────────────────────────────────────────────
|
|
238
|
+
/** Import compileGlob locally to avoid circular dependency with compiler.ts. */
|
|
239
|
+
import { compileGlob } from './compiler.js';
|
|
240
|
+
/** Compile a glob pattern to a RegExp for ad-hoc matching. */
|
|
241
|
+
function compileGlobForMatch(pattern) {
|
|
242
|
+
return compileGlob(pattern, { segments: false }).re;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Match the params dimension: AND over keys, OR within each key's patterns.
|
|
246
|
+
* A negated pattern means the value must NOT match.
|
|
247
|
+
*/
|
|
248
|
+
function matchParams(params, args) {
|
|
249
|
+
for (const cond of params) {
|
|
250
|
+
const value = resolveNestedKey(args, cond.key);
|
|
251
|
+
if (cond.patterns.length === 0) {
|
|
252
|
+
// No patterns → key must exist (any value).
|
|
253
|
+
if (value === undefined)
|
|
254
|
+
return false;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (value === undefined)
|
|
258
|
+
return false;
|
|
259
|
+
const strValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
260
|
+
const compiled = cond.patterns.map((p) => {
|
|
261
|
+
if (p.startsWith('!') && p.length > 1) {
|
|
262
|
+
return { negated: true, re: compileGlobForMatch(p.slice(1)) };
|
|
263
|
+
}
|
|
264
|
+
return { negated: false, re: compileGlobForMatch(p) };
|
|
265
|
+
});
|
|
266
|
+
if (cond.negated) {
|
|
267
|
+
// Negated: value must NOT match the sole pattern.
|
|
268
|
+
if (compiled[0].re.test(strValue))
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// Normal: value must match at least one pattern (OR).
|
|
273
|
+
if (!compiled.some((c) => !c.negated && c.re.test(strValue)))
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve a dotted key path from a nested object.
|
|
281
|
+
* e.g. `resolveNestedKey({flags: {mode: 'prod'}}, 'flags.mode')` → `'prod'`
|
|
282
|
+
*/
|
|
283
|
+
function resolveNestedKey(obj, key) {
|
|
284
|
+
const parts = key.split('.');
|
|
285
|
+
let current = obj;
|
|
286
|
+
for (const part of parts) {
|
|
287
|
+
if (current === null || current === undefined || typeof current !== 'object')
|
|
288
|
+
return undefined;
|
|
289
|
+
current = current[part];
|
|
290
|
+
}
|
|
291
|
+
return current;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Match an agent pattern against an identity candidate.
|
|
295
|
+
* Supports exact match and glob patterns.
|
|
296
|
+
*/
|
|
297
|
+
function matchAgentPattern(pattern, candidate) {
|
|
298
|
+
if (pattern === candidate)
|
|
299
|
+
return true;
|
|
300
|
+
// Glob patterns: compile and test.
|
|
301
|
+
if (/[*?[\]]/.test(pattern)) {
|
|
302
|
+
return compileGlobForMatch(pattern).test(candidate);
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Match the when dimension: all conditions must be satisfied.
|
|
308
|
+
*/
|
|
309
|
+
function matchWhen(when) {
|
|
310
|
+
// Platform check.
|
|
311
|
+
if (when.platform !== undefined && when.platform.length > 0) {
|
|
312
|
+
if (!when.platform.includes(process.platform))
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
// Env var checks.
|
|
316
|
+
if (when.env !== undefined) {
|
|
317
|
+
for (const [varName, allowed] of Object.entries(when.env)) {
|
|
318
|
+
const actual = process.env[varName];
|
|
319
|
+
if (actual === undefined)
|
|
320
|
+
return false;
|
|
321
|
+
if (!allowed.includes(actual))
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Node version (reserved — skip for now).
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Match the network dimension: all present sub-dimensions must match.
|
|
330
|
+
* Within a sub-dimension, entries are OR.
|
|
331
|
+
*/
|
|
332
|
+
function matchNetwork(network, ctx) {
|
|
333
|
+
if (ctx === undefined)
|
|
334
|
+
return false;
|
|
335
|
+
// domains
|
|
336
|
+
if (network.domains !== undefined && network.domains.length > 0) {
|
|
337
|
+
if (ctx.domain === undefined)
|
|
338
|
+
return false;
|
|
339
|
+
const matchers = network.domains.map((d) => compileDomainPattern(d));
|
|
340
|
+
if (!matchers.some((m) => m.re.test(ctx.domain)))
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
// ips
|
|
344
|
+
if (network.ips !== undefined && network.ips.length > 0) {
|
|
345
|
+
if (ctx.ip === undefined)
|
|
346
|
+
return false;
|
|
347
|
+
const isCidr = (s) => s.includes('/');
|
|
348
|
+
const cidrs = network.ips.filter(isCidr);
|
|
349
|
+
const literals = network.ips.filter((s) => !isCidr(s));
|
|
350
|
+
const ipMatch = literals.includes(ctx.ip) || cidrs.some((c) => {
|
|
351
|
+
try {
|
|
352
|
+
return compileCidr(c)(ctx.ip);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
if (!ipMatch)
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
// ports
|
|
362
|
+
if (network.ports !== undefined && network.ports.length > 0) {
|
|
363
|
+
if (ctx.port === undefined)
|
|
364
|
+
return false;
|
|
365
|
+
const matchers = network.ports.map((p) => {
|
|
366
|
+
try {
|
|
367
|
+
return compilePortSpec(p);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return (_) => false;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
if (!matchers.some((m) => m(ctx.port)))
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
// schemes
|
|
377
|
+
if (network.schemes !== undefined && network.schemes.length > 0) {
|
|
378
|
+
if (ctx.scheme === undefined)
|
|
379
|
+
return false;
|
|
380
|
+
if (!network.schemes.includes(ctx.scheme))
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Per-call memo for command classification.
|
|
387
|
+
*
|
|
388
|
+
* `ruleMatches` runs once per rule, so a rule chain with several `branch`
|
|
389
|
+
* rules would otherwise re-parse the same command each time. `dispatchCommand`
|
|
390
|
+
* is pure, so caching its result per `SimpleCommand` object is safe; the
|
|
391
|
+
* WeakMap keeps no references once the command array is dropped.
|
|
392
|
+
*/
|
|
393
|
+
const commandSemanticsCache = new WeakMap();
|
|
394
|
+
function semanticsOf(cmd) {
|
|
395
|
+
const cached = commandSemanticsCache.get(cmd);
|
|
396
|
+
if (cached !== undefined)
|
|
397
|
+
return cached;
|
|
398
|
+
// The parser needs the WHOLE argv: `cmd.command` carries only the command
|
|
399
|
+
// word, so `git push --force origin main` would arrive as `git` and classify
|
|
400
|
+
// as `family: unknown` (no branch, no remote, no flags).
|
|
401
|
+
const result = dispatchCommand(commandArgv(cmd));
|
|
402
|
+
commandSemanticsCache.set(cmd, result);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Branch dimension matching (git semantics only).
|
|
407
|
+
*
|
|
408
|
+
* Candidates come from the shared command dispatcher, so `refspec` forms
|
|
409
|
+
* (`HEAD:main`) are already split and a remote name is never mistaken for a
|
|
410
|
+
* branch name. Sub-dimensions are AND (each present one must match); within a
|
|
411
|
+
* sub-dimension the entries are OR.
|
|
412
|
+
*
|
|
413
|
+
* **Non-git commands satisfy no branch dimension**: `dispatchCommand` reports
|
|
414
|
+
* `family: 'unknown'` for them rather than throwing, so a rule that names a
|
|
415
|
+
* branch simply does not match `rm -rf`, `npm install` and friends. That is
|
|
416
|
+
* deliberate — the write-path/blacklist layers cover non-git commands.
|
|
417
|
+
*
|
|
418
|
+
* `shared` is the parser's STATIC protected-branch rule (main / master /
|
|
419
|
+
* production / release / stable, see `src/parsers/git.ts`). No git subprocess
|
|
420
|
+
* ever runs on the decision path.
|
|
421
|
+
*/
|
|
422
|
+
function matchBranch(branch, commands) {
|
|
423
|
+
if (commands.length === 0)
|
|
424
|
+
return false;
|
|
425
|
+
const branches = [];
|
|
426
|
+
const remotes = [];
|
|
427
|
+
let anyShared = false;
|
|
428
|
+
for (const cmd of commands) {
|
|
429
|
+
const result = semanticsOf(cmd);
|
|
430
|
+
if (result === null || result.semantics.family !== 'git')
|
|
431
|
+
continue;
|
|
432
|
+
const { branch: name, remote, targetsSharedBranch } = result.semantics;
|
|
433
|
+
if (targetsSharedBranch)
|
|
434
|
+
anyShared = true;
|
|
435
|
+
if (typeof name === 'string' && name.length > 0)
|
|
436
|
+
branches.push(name);
|
|
437
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
438
|
+
remotes.push(remote);
|
|
439
|
+
}
|
|
440
|
+
if (branch.shared && !anyShared)
|
|
441
|
+
return false;
|
|
442
|
+
if (branch.target.length > 0 && !branches.some((b) => branch.target.some((g) => g.re.test(b))))
|
|
443
|
+
return false;
|
|
444
|
+
if (branch.remote.length > 0 && !remotes.some((r) => branch.remote.some((g) => g.re.test(r))))
|
|
445
|
+
return false;
|
|
446
|
+
return true;
|
|
447
|
+
}
|