dsh-bash-guard 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +193 -2
- package/client.js +386 -0
- package/cordis.patch.yml +6 -0
- package/lib/classify.js +327 -0
- package/lib/index.js +131 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lloydzhou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,194 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">DSH Bash Guard</h1>
|
|
2
|
+
<p align="center"><strong>给 Agent 工作空间,把权限留在你手里。</strong></p>
|
|
3
|
+
<p align="center">4 个范围 × 3 种权限。12 个开关,一个原生菜单。</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
7
|
+
<img alt="DSH Web plugin" src="https://img.shields.io/badge/DSH-Web_plugin-555">
|
|
8
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-policy_classifier-3178C6?logo=typescript&logoColor=white">
|
|
9
|
+
<img alt="Preview" src="https://img.shields.io/badge/status-preview-orange">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="#快速开始">快速开始</a> ·
|
|
14
|
+
<a href="#权限怎么读">权限模型</a> ·
|
|
15
|
+
<a href="#安全边界">安全边界</a> ·
|
|
16
|
+
<a href="#来源与致谢">来源与致谢</a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
**DSH Bash Guard 是 DeepSeek Harness 的独立 Web 插件。** 它把 Bash Agent / Bash Guard 的四位权限思路带进 DSH 原生权限菜单,让你按范围控制工具操作,而不必在“只读”和“全部放开”之间二选一。
|
|
20
|
+
|
|
21
|
+
不改宿主源码,不增加一排数字预设,也不用记斜杠命令。展开 `bash-guard 自定义`,直接调整系统、外部、网络和工作区的读、写、执行权限。
|
|
22
|
+
|
|
23
|
+
> **它是额外的工具权限检查,不是沙箱替代品。** 即使设为 `7777`,DSH 原生沙箱和审批仍然适用。
|
|
24
|
+
|
|
25
|
+
## 为什么使用它
|
|
26
|
+
|
|
27
|
+
- **权限看得见。** 四行三列,12 个独立开关,支持全部 4096 种组合。
|
|
28
|
+
- **原生菜单,不打断工作。** 保留原生权限选项、图标和风险确认;修改时菜单保持展开。
|
|
29
|
+
- **从单格到整行。** 点一个格子切换一个权限位;点 scope 名称全选或清空这一行。
|
|
30
|
+
- **配置随会话记住。** 同一 session ID 重开可恢复;切回原生预设停用 Guard,但保留上次数字。
|
|
31
|
+
- **有依据的拒绝。** 支持的操作按 `required` 与 `allowed` 比较,拒绝时说明所需权限位。
|
|
32
|
+
|
|
33
|
+
## 快速开始
|
|
34
|
+
|
|
35
|
+
### 从源码安装
|
|
36
|
+
|
|
37
|
+
需要 Git、Node.js / npm,以及支持插件的 DSH。当前版本针对 DSH Web 集成验证,尚未声明跨宿主版本兼容。
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/lloydzhou/dsh-bash-guard.git
|
|
41
|
+
cd dsh-bash-guard
|
|
42
|
+
npm ci
|
|
43
|
+
npm run build
|
|
44
|
+
|
|
45
|
+
dsh plugin --profile web add .
|
|
46
|
+
dsh --profile web
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
在输入框附近打开权限菜单,选择 **bash-guard 自定义**。
|
|
50
|
+
|
|
51
|
+
目录安装采用链接。更新插件后重新构建;宿主入口或插件配置变化时,重启对应的 DSH 实例。
|
|
52
|
+
|
|
53
|
+
### npm 发布状态
|
|
54
|
+
|
|
55
|
+
包名为 `dsh-bash-guard`。本仓库已准备 npm 打包配置,但本说明不表示该包已经发布;发布前请使用上面的源码安装方式。
|
|
56
|
+
|
|
57
|
+
## 在菜单中操作
|
|
58
|
+
|
|
59
|
+
| 入口 | 行为 |
|
|
60
|
+
| :--- | :--- |
|
|
61
|
+
| 单个权限格 | 独立切换该范围的读、写或执行位 |
|
|
62
|
+
| 系统 / 外部 / 网络 / 工作区 | 已全选则清空该行,否则全选该行 |
|
|
63
|
+
| 全选 | 当前不是 `7777` 时显示,点击将全部 Guard 位设为 `7777` |
|
|
64
|
+
| 默认 | 当前是 `7777` 时显示,点击恢复 `0477` |
|
|
65
|
+
| 原生权限预设 | 停用 Guard,保留记忆的四位值 |
|
|
66
|
+
|
|
67
|
+
方向键在矩阵中移动焦点,不修改权限;空格切换当前按钮,Escape 关闭菜单。状态由服务端确认,保存时不插入提示行或改变菜单高度。
|
|
68
|
+
|
|
69
|
+
**当前默认值有两种用途:** 新会话的初始记忆值是 `0467`,且 Guard 默认不激活;菜单“默认”按钮的恢复值是 `0477`。已有会话不会因升级自动重置。
|
|
70
|
+
|
|
71
|
+
## 权限怎么读
|
|
72
|
+
|
|
73
|
+
借鉴 Unix `rwx` 位的表示法,但这是**工具操作策略**,不是文件系统的 `chmod` 权限。
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
模式: 0 4 6 7
|
|
77
|
+
范围: 系统 外部 网络 工作区
|
|
78
|
+
system external network workspace
|
|
79
|
+
|
|
80
|
+
每位:读 = 4,写 = 2,执行 = 1
|
|
81
|
+
组合:按位相加,例如 6 = 4 + 2 = 读 + 写
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| 数字 | 读 | 写 | 执行 |
|
|
85
|
+
| :---: | :---: | :---: | :---: |
|
|
86
|
+
| `0` | 否 | 否 | 否 |
|
|
87
|
+
| `1` | 否 | 否 | 是 |
|
|
88
|
+
| `2` | 否 | 是 | 否 |
|
|
89
|
+
| `3` | 否 | 是 | 是 |
|
|
90
|
+
| `4` | 是 | 否 | 否 |
|
|
91
|
+
| `5` | 是 | 否 | 是 |
|
|
92
|
+
| `6` | 是 | 是 | 否 |
|
|
93
|
+
| `7` | 是 | 是 | 是 |
|
|
94
|
+
|
|
95
|
+
### 四个范围
|
|
96
|
+
|
|
97
|
+
| Scope | 含义 |
|
|
98
|
+
| :--- | :--- |
|
|
99
|
+
| 系统 | 系统路径及敏感路径,例如 `/etc`、`/usr`、`.ssh`、凭据或密钥相关路径;敏感路径即使位于项目内,也可能归入系统范围 |
|
|
100
|
+
| 外部 | 工作区外、不属于系统范围的普通路径 |
|
|
101
|
+
| 网络 | 被分类器识别的网络读取、写入及远程执行操作 |
|
|
102
|
+
| 工作区 | 项目内文件与命令操作;分类器还将直接的可信临时路径纳入这一范围 |
|
|
103
|
+
|
|
104
|
+
路径分类是词法判断。`/tmp` 等可信路径的特殊规则、敏感名称规则和命令分类,见 [分类器源码](src/classify.ts)。这不是对实际文件系统归属的完整证明。
|
|
105
|
+
|
|
106
|
+
### 几个容易记住的组合
|
|
107
|
+
|
|
108
|
+
| 模式 | 表达的策略 |
|
|
109
|
+
| :---: | :--- |
|
|
110
|
+
| `0467` | 系统关闭;外部只读;网络读写;工作区全部 |
|
|
111
|
+
| `0477` | 与上项相比,增加网络执行位;菜单“默认”的恢复值 |
|
|
112
|
+
| `4447` | 系统、外部、网络只读;工作区全部 |
|
|
113
|
+
| `0007` | 仅开放工作区的三个权限位 |
|
|
114
|
+
| `7777` | Guard 全部位开放,**不等于原生完全权限** |
|
|
115
|
+
|
|
116
|
+
模式不是安全等级排序。请按需要开启具体位,不要仅因为数字更大而认为它更合适。
|
|
117
|
+
|
|
118
|
+
### 操作如何判定
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
Agent 工具调用
|
|
122
|
+
│
|
|
123
|
+
├─ Guard 未激活 / 工具未接管 ─────────► 原生工具路径
|
|
124
|
+
│
|
|
125
|
+
└─ Guard 已激活且支持该工具
|
|
126
|
+
├─ 累计需要的权限位 required
|
|
127
|
+
├─ 不满足 allowed ──────────────► BASH_GUARD_DENIED
|
|
128
|
+
└─ 全部满足 ────────────────────► 原生工具、沙箱与审批
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
判定规则是 `(allowed & required) === required`,不是比较数字大小。复合命令会累计多个范围的要求,不会只取一个“最高”范围。
|
|
132
|
+
|
|
133
|
+
例如,同样执行 `cat /etc/hosts`:
|
|
134
|
+
|
|
135
|
+
- `0467` 缺少系统读取位,拒绝,`required=4000`。
|
|
136
|
+
- `4467` 满足 Guard 的系统读取要求,之后仍需通过原生执行路径。
|
|
137
|
+
|
|
138
|
+
这组场景已在实际 DSH Web 会话验证:前者被拒绝,后者在工具原始结果中返回文件内容。
|
|
139
|
+
|
|
140
|
+
当前分类器接管 `bash`,以及 `read`、`read_image`、`grep`、`glob`、`ls`、`write`、`edit` 等已识别工具名称。读取工具按目标路径检查读权限,写入工具检查写权限。未接管的工具继续走宿主原有策略。
|
|
141
|
+
|
|
142
|
+
## 安全边界
|
|
143
|
+
|
|
144
|
+
请在使用前了解这些限制:
|
|
145
|
+
|
|
146
|
+
- **不是不可绕过的隔离层。** 分类器是保守词法扫描器,不是完整 Bash AST,也无法证明任意脚本或程序的运行时行为。
|
|
147
|
+
- **不提高宿主权限。** 激活时选择原生 `workspace-write` 预设;Guard 放行不绕过底层沙箱和审批。该预设的具体行为由宿主配置决定。
|
|
148
|
+
- **不覆盖所有工具。** 未识别的工具不会自动被本插件拦截,仍依赖宿主策略。
|
|
149
|
+
- **不保证符号链接与动态路径隔离。** 实际文件系统、网络和子进程隔离仍由宿主负责。
|
|
150
|
+
- **不保证子代理继承。** 跨代理任务不能假定自动沿用父会话的 Guard 配置。
|
|
151
|
+
- **不是 Bash Guard Rust 运行时。** 本插件没有集成其 Jev 决策层、Claude/Codex Hook 或审计日志功能,也不宣称相同的安全保证。
|
|
152
|
+
|
|
153
|
+
需要报告问题?请附上脱敏后的命令、四位模式、预期与实际结果,在 [Issues](https://github.com/lloydzhou/dsh-bash-guard/issues) 中反馈。不要提交凭据或私有文件内容。
|
|
154
|
+
|
|
155
|
+
## 会话与兼容性
|
|
156
|
+
|
|
157
|
+
配置保存在 DSH 插件数据域 `bash_guard`,按 session ID 记录模式、激活状态和修订号。写入先持久化再确认;同会话写入串行,修订号防止过时编辑覆盖新状态。
|
|
158
|
+
|
|
159
|
+
这是插件侧存储,**不是原生会话 JSONL 的一部分**。导出、分叉和历史回退不会自动携带或回放配置。
|
|
160
|
+
|
|
161
|
+
客户端通过独立 RPC 读写,不使用命令轮询或额外会话写句柄。兼容基线为 DSH `0.2.0-rc.2`,不支持更早的宿主。服务端直接依赖官方 npm 包 `@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2`,不内置或修改宿主协议源码。后续版本沿官方接口适配;协议依赖固定到已验证版本,升级时通过回归测试再更新,不承诺任意未来版本自动兼容。
|
|
162
|
+
|
|
163
|
+
## 开发
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm ci
|
|
167
|
+
npm test
|
|
168
|
+
npm run build
|
|
169
|
+
npm pack --dry-run
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- [服务端入口](src/index.ts):持久化、RPC、原生预设联动及工具拦截。
|
|
173
|
+
- [权限分类器](src/classify.ts):命令与路径所需权限。
|
|
174
|
+
- [客户端集成](src/client.js):原生菜单与状态同步。
|
|
175
|
+
- [矩阵编辑器](src/GuardEditor.js):单格、整行及全局开关。
|
|
176
|
+
- [测试](tests/):分类器、RPC、原生菜单和矩阵交互。
|
|
177
|
+
|
|
178
|
+
当前回归测试共 174 项,包括全部 4096 种模式的客户端校验。测试覆盖不代表对任意 Shell 程序的安全证明。npm 打包前会自动构建;构建产物与依赖目录不提交到 Git。
|
|
179
|
+
|
|
180
|
+
## 来源与致谢
|
|
181
|
+
|
|
182
|
+
这个项目属于同一条探索路线:让 coding agent 能完成工作,同时让操作权限可表达、可检查。
|
|
183
|
+
|
|
184
|
+
| 项目 | 与本项目的关系 |
|
|
185
|
+
| :--- | :--- |
|
|
186
|
+
| [lloydzhou/bash-agent](https://github.com/lloydzhou/bash-agent) | 最小化 AI coding agent 运行时;本插件的四范围、四位八进制权限模型与命令分类思路参考其 [Bash 工具权限策略](https://github.com/lloydzhou/bash-agent/blob/main/docs/bash-tool-policy.md) |
|
|
187
|
+
| [lloydzhou/bash-guard](https://github.com/lloydzhou/bash-guard) | 面向 Claude Code / Codex 的独立安全门;本插件借鉴其显式权限、执行前检查和可解释拒绝的方向,针对 DSH 重新实现集成 |
|
|
188
|
+
| DeepSeek Harness / Cordis | 提供插件生命周期、原生权限 UI、数据域和 RPC 接入基础 |
|
|
189
|
+
|
|
190
|
+
**相同的权限表达,不同的宿主集成。** 请以本仓库实现与限制为准,不要直接套用其他项目的安装命令、环境变量或安全承诺。
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
[MIT](LICENSE) © lloydzhou。官方协议与其他依赖遵循各自的许可证。
|
package/client.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({id:'dsh-bash-guard',factory:(hostRequire)=>{
|
|
2
|
+
const factories={"./client.js": (module,exports,require) => {
|
|
3
|
+
"use strict";
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.guardRemoteContribution = exports.inject = void 0;
|
|
6
|
+
exports.commandResult = commandResult;
|
|
7
|
+
exports.decodeGuardState = decodeGuardState;
|
|
8
|
+
exports.createGuardTransport = createGuardTransport;
|
|
9
|
+
exports.createPermissionControl = createPermissionControl;
|
|
10
|
+
exports.apply = apply;
|
|
11
|
+
const GuardEditor_js_1 = require("./GuardEditor.js");
|
|
12
|
+
// A missing guard service must never hide the host's native permission choices.
|
|
13
|
+
exports.inject = ['sessions', 'slots', 'remote', 'remote.permissionPresets'];
|
|
14
|
+
function commandResult(reply) {
|
|
15
|
+
if (!reply?.ok)
|
|
16
|
+
throw new Error(reply?.error?.message || '权限请求失败');
|
|
17
|
+
if (!reply.value?.matched)
|
|
18
|
+
throw new Error('宿主未提供此权限命令');
|
|
19
|
+
const result = reply.value.result;
|
|
20
|
+
if (result?.kind === 'error')
|
|
21
|
+
throw new Error(result.text || '权限请求失败');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
function decodeGuardState(reply) {
|
|
25
|
+
if (!reply?.ok)
|
|
26
|
+
throw new Error(reply?.error?.message || 'Bash Guard 不可用');
|
|
27
|
+
const state = reply.value;
|
|
28
|
+
if (!/^[0-7]{4}$(?![\s\S])/.test(state?.mode) || typeof state.active !== 'boolean' || !Number.isSafeInteger(state.revision) || state.revision < 0) {
|
|
29
|
+
throw new Error('Bash Guard 返回了无效状态');
|
|
30
|
+
}
|
|
31
|
+
return { mode: state.mode, active: state.active, revision: state.revision };
|
|
32
|
+
}
|
|
33
|
+
function createGuardTransport(remote, sessionId, sessionFor) {
|
|
34
|
+
const guard = () => {
|
|
35
|
+
if (!remote.bashGuard)
|
|
36
|
+
throw new Error('Bash Guard 不可用');
|
|
37
|
+
return remote.bashGuard;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
get: async () => decodeGuardState(await guard().get(sessionId)),
|
|
41
|
+
write: async (action, state, mode) => decodeGuardState(await guard().update(sessionId, {
|
|
42
|
+
action, revision: state.revision, ...(mode === undefined ? {} : { mode }),
|
|
43
|
+
})),
|
|
44
|
+
// Deliberately independent of guard reads, readiness, and failures.
|
|
45
|
+
preset: async (id) => {
|
|
46
|
+
const live = sessionFor();
|
|
47
|
+
if (!live)
|
|
48
|
+
throw new Error('会话尚未就绪,请稍后重试');
|
|
49
|
+
return commandResult(await live.command(`/permission ${id}`));
|
|
50
|
+
},
|
|
51
|
+
catalog: async () => {
|
|
52
|
+
const reply = await remote.permissionPresets.catalog();
|
|
53
|
+
if (!reply?.ok)
|
|
54
|
+
throw new Error(reply?.error?.message || '权限列表不可用');
|
|
55
|
+
if (!Array.isArray(reply.value?.options))
|
|
56
|
+
throw new Error('权限列表无效');
|
|
57
|
+
return reply.value;
|
|
58
|
+
},
|
|
59
|
+
subscribeCatalog: callback => remote.$on?.('permission-presets/catalog-changed', callback),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const copy = {
|
|
63
|
+
'preset.readOnly': '仅可查看', 'preset.workspaceWrite': '工作区内修改', 'preset.fullAccess': '完全权限',
|
|
64
|
+
'close': '关闭', 'confirm.cancel': '取消',
|
|
65
|
+
'confirm.title': '确认启用完全权限?',
|
|
66
|
+
'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
|
67
|
+
'confirm.acknowledge': '我已了解风险,并愿意继续', 'confirm.enable': '启用完全权限',
|
|
68
|
+
'auto.label': 'Auto review', 'auto.badge': 'EXP',
|
|
69
|
+
'auto.description': '无沙箱运行;每次原生工具调用和 PTC 内层调用前由同一模型进行实验性审查。',
|
|
70
|
+
'auto.confirm.title': '确认启用 Auto review(实验)?',
|
|
71
|
+
'auto.confirm.description': 'Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。',
|
|
72
|
+
'auto.confirm.acknowledge': '我已了解这些风险,并愿意继续', 'auto.confirm.enable': '启用 Auto review',
|
|
73
|
+
};
|
|
74
|
+
const labels = { 'read-only': ['preset.readOnly', 'Read Only'], 'workspace-write': ['preset.workspaceWrite', 'Workspace Write'], 'danger-full-access': ['preset.fullAccess', 'Full access'] };
|
|
75
|
+
function createPermissionControl(React, primitives) {
|
|
76
|
+
const h = React.createElement;
|
|
77
|
+
const { Menu, RiskConfirmation, PermissionIconReadOnlyRegular, PermissionIconWorkspaceWriteRegular, PermissionIconFullAccessRegular, IconChevronDownOutlineRegular, IconSettingsOutlineRegular } = primitives;
|
|
78
|
+
const icons = { 'read-only': PermissionIconReadOnlyRegular, 'workspace-write': PermissionIconWorkspaceWriteRegular, 'danger-full-access': PermissionIconFullAccessRegular, 'bash-guard': IconSettingsOutlineRegular };
|
|
79
|
+
const GuardEditor = (0, GuardEditor_js_1.createGuardEditor)(React, primitives.IconCheckOutlineRegular);
|
|
80
|
+
return function PermissionControl({ sessionId, transport, locked, useProjection, t = key => copy[key] }) {
|
|
81
|
+
const selection = useProjection('permissions');
|
|
82
|
+
const [state, setState] = React.useState(null);
|
|
83
|
+
const [catalog, setCatalog] = React.useState(null);
|
|
84
|
+
const [open, setOpen] = React.useState(false);
|
|
85
|
+
const [pending, setPending] = React.useState(false);
|
|
86
|
+
const [error, setError] = React.useState('');
|
|
87
|
+
const [nativeError, setNativeError] = React.useState('');
|
|
88
|
+
const [confirmation, setConfirmation] = React.useState(null);
|
|
89
|
+
const [acknowledged, setAcknowledged] = React.useState(false);
|
|
90
|
+
const [picking, setPicking] = React.useState(false);
|
|
91
|
+
const epoch = React.useRef(0), request = React.useRef(0), catalogRequest = React.useRef(0);
|
|
92
|
+
const writing = React.useRef(false), nativeWriting = React.useRef(false);
|
|
93
|
+
const stateRef = React.useRef(null), api = React.useRef(transport);
|
|
94
|
+
api.current = transport;
|
|
95
|
+
const publish = next => { stateRef.current = next; setState(next); };
|
|
96
|
+
const message = cause => cause instanceof Error ? cause.message : String(cause);
|
|
97
|
+
const read = async () => {
|
|
98
|
+
if (writing.current)
|
|
99
|
+
return;
|
|
100
|
+
const generation = epoch.current, ticket = ++request.current;
|
|
101
|
+
setPending(true);
|
|
102
|
+
try {
|
|
103
|
+
const next = await api.current.get();
|
|
104
|
+
if (generation === epoch.current && ticket === request.current) {
|
|
105
|
+
publish(next);
|
|
106
|
+
setError('');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
if (generation === epoch.current && ticket === request.current) {
|
|
111
|
+
publish(null);
|
|
112
|
+
setError(message(cause));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (generation === epoch.current && ticket === request.current)
|
|
117
|
+
setPending(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const readCatalog = async () => {
|
|
121
|
+
const generation = epoch.current, ticket = ++catalogRequest.current;
|
|
122
|
+
try {
|
|
123
|
+
const next = await api.current.catalog();
|
|
124
|
+
if (generation === epoch.current && ticket === catalogRequest.current) {
|
|
125
|
+
setCatalog(next);
|
|
126
|
+
setNativeError('');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
if (generation === epoch.current && ticket === catalogRequest.current)
|
|
131
|
+
setNativeError(message(cause));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
++epoch.current;
|
|
136
|
+
writing.current = false;
|
|
137
|
+
nativeWriting.current = false;
|
|
138
|
+
publish(null);
|
|
139
|
+
setCatalog(null);
|
|
140
|
+
setOpen(false);
|
|
141
|
+
setConfirmation(null);
|
|
142
|
+
setPicking(false);
|
|
143
|
+
readCatalog();
|
|
144
|
+
const stop = api.current.subscribeCatalog?.(() => { setCatalog(null); readCatalog(); });
|
|
145
|
+
return () => { ++epoch.current; stop?.(); };
|
|
146
|
+
}, [sessionId]);
|
|
147
|
+
// No polling: refresh on opening and on authoritative permission projection changes.
|
|
148
|
+
React.useEffect(() => { read(); if (open)
|
|
149
|
+
readCatalog(); }, [open, sessionId, selection]);
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
if (locked || (confirmation !== null && catalog !== null && !catalog.options.some(option => option.value === confirmation))) {
|
|
152
|
+
setOpen(false);
|
|
153
|
+
setConfirmation(null);
|
|
154
|
+
setAcknowledged(false);
|
|
155
|
+
}
|
|
156
|
+
}, [locked, catalog, confirmation]);
|
|
157
|
+
const submit = async (action, mode) => {
|
|
158
|
+
if (locked || writing.current || !stateRef.current)
|
|
159
|
+
return;
|
|
160
|
+
const generation = epoch.current;
|
|
161
|
+
const focusedSegment = document.activeElement?.matches?.('.dsh-bg-level') ? document.activeElement : null;
|
|
162
|
+
++request.current;
|
|
163
|
+
writing.current = true;
|
|
164
|
+
setPending(true);
|
|
165
|
+
setError('');
|
|
166
|
+
try {
|
|
167
|
+
const next = await api.current.write(action, stateRef.current, mode);
|
|
168
|
+
if (generation === epoch.current)
|
|
169
|
+
publish(next);
|
|
170
|
+
}
|
|
171
|
+
catch (cause) {
|
|
172
|
+
if (generation === epoch.current) {
|
|
173
|
+
setError(message(cause));
|
|
174
|
+
try {
|
|
175
|
+
const next = await api.current.get();
|
|
176
|
+
if (generation === epoch.current)
|
|
177
|
+
publish(next);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
if (generation === epoch.current)
|
|
181
|
+
publish(null);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
if (generation === epoch.current) {
|
|
187
|
+
writing.current = false;
|
|
188
|
+
setPending(false);
|
|
189
|
+
if (focusedSegment)
|
|
190
|
+
requestAnimationFrame(() => {
|
|
191
|
+
if (generation === epoch.current && focusedSegment.isConnected && !focusedSegment.disabled && document.activeElement === document.body)
|
|
192
|
+
focusedSegment.focus();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const selectPreset = async (id) => {
|
|
198
|
+
if (locked || nativeWriting.current)
|
|
199
|
+
return;
|
|
200
|
+
const generation = epoch.current;
|
|
201
|
+
nativeWriting.current = true;
|
|
202
|
+
setPicking(true);
|
|
203
|
+
setNativeError('');
|
|
204
|
+
try {
|
|
205
|
+
await api.current.preset(id);
|
|
206
|
+
if (generation === epoch.current) {
|
|
207
|
+
publish(null);
|
|
208
|
+
++request.current;
|
|
209
|
+
read();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (cause) {
|
|
213
|
+
if (generation === epoch.current)
|
|
214
|
+
setNativeError(message(cause));
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
if (generation === epoch.current) {
|
|
218
|
+
nativeWriting.current = false;
|
|
219
|
+
setPicking(false);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const choose = id => {
|
|
224
|
+
if (locked || picking)
|
|
225
|
+
return;
|
|
226
|
+
if (id === 'bash-guard') {
|
|
227
|
+
if (!pending && !state?.active)
|
|
228
|
+
submit('activate');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!catalog?.options.some(option => option.value === id))
|
|
232
|
+
return;
|
|
233
|
+
setOpen(false);
|
|
234
|
+
if (id === selection?.currentValue && !state?.active)
|
|
235
|
+
return;
|
|
236
|
+
if (id === 'danger-full-access' || id === 'auto') {
|
|
237
|
+
setAcknowledged(false);
|
|
238
|
+
setConfirmation(id);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
selectPreset(id);
|
|
242
|
+
};
|
|
243
|
+
const labelFor = (value, name = value) => {
|
|
244
|
+
if (value === 'auto')
|
|
245
|
+
return t('auto.label');
|
|
246
|
+
const builtin = labels[value];
|
|
247
|
+
if (builtin && (name === value || name === builtin[1]))
|
|
248
|
+
return t(builtin[0]);
|
|
249
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) ? name.split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' ') : name;
|
|
250
|
+
};
|
|
251
|
+
const icon = id => icons[id] ? h(icons[id]) : undefined;
|
|
252
|
+
const current = state?.active ? 'bash-guard' : selection?.currentValue;
|
|
253
|
+
const option = catalog?.options.find(item => item.value === current);
|
|
254
|
+
const label = current === 'bash-guard' ? `bash-guard ${state.mode}` : labelFor(current || '权限', option?.name);
|
|
255
|
+
const items = (catalog?.options || []).map(option => ({
|
|
256
|
+
id: option.value, icon: icon(option.value),
|
|
257
|
+
label: option.value === 'auto' ? h('span', { className: 'dsh-bg-option-label', 'aria-label': `${t('auto.label')} ${t('auto.badge')}` }, t('auto.label'), h('sup', { className: 'dsh-bg-badge' }, t('auto.badge'))) : labelFor(option.value, option.name),
|
|
258
|
+
disabled: !!locked || picking,
|
|
259
|
+
}));
|
|
260
|
+
items.push({ id: 'guard-separator', type: 'separator' }, {
|
|
261
|
+
id: 'bash-guard', icon: icon('bash-guard'), disabled: !!locked || !state,
|
|
262
|
+
label: h('span', null, 'bash-guard 自定义', state && h('span', { className: 'dsh-bg-mode' }, ` ${state.mode}`)),
|
|
263
|
+
});
|
|
264
|
+
const closeConfirmation = () => { setConfirmation(null); setAcknowledged(false); };
|
|
265
|
+
const riskPrefix = confirmation === 'auto' ? 'auto.confirm.' : 'confirm.';
|
|
266
|
+
return h(React.Fragment, null, h(Menu, { open, items, selectedId: current, onSelect: choose, onClose: () => setOpen(false), side: 'top', portal: true,
|
|
267
|
+
anchor: h('button', { type: 'button', className: 'dsh-bg-trigger', disabled: !!locked || picking || confirmation !== null,
|
|
268
|
+
'aria-label': `访问模式,当前:${label}`, 'aria-haspopup': 'menu', 'aria-expanded': open,
|
|
269
|
+
title: current === 'auto' ? t('auto.description') : option?.description, onClick: () => setOpen(!open),
|
|
270
|
+
}, icon(current) && h('span', { className: 'dsh-bg-trigger-icon', 'aria-hidden': true }, icon(current)), h('span', { className: 'dsh-bg-trigger-label' }, label), current === 'auto' && h('sup', { className: 'dsh-bg-badge' }, t('auto.badge')), h('span', { className: `dsh-bg-chevron${open ? ' dsh-bg-chevron-open' : ''}`, 'aria-hidden': true }, h(IconChevronDownOutlineRegular))),
|
|
271
|
+
}, state && h(GuardEditor, { mode: state.mode, active: state.active, pending, locked,
|
|
272
|
+
onModeChange: mode => submit('mode', mode),
|
|
273
|
+
onChange: (row, digit) => submit('mode', stateRef.current.mode.slice(0, row) + digit + stateRef.current.mode.slice(row + 1)),
|
|
274
|
+
}), h('span', { className: 'dsh-bg-sr-only', role: 'status' }, pending ? '正在同步权限' : ''), error && h('div', { className: 'dsh-bg-error', role: 'alert' }, error, h('button', { type: 'button', disabled: pending, onClick: read }, '重试')), nativeError && h('div', { className: 'dsh-bg-error', role: 'alert' }, nativeError, h('button', { type: 'button', onClick: readCatalog }, '重试'))), confirmation !== null && h(RiskConfirmation, { open: true, title: t(`${riskPrefix}title`), description: t(`${riskPrefix}description`),
|
|
275
|
+
acknowledgeLabel: t(`${riskPrefix}acknowledge`), cancelLabel: t('confirm.cancel'), closeLabel: t('close'), confirmLabel: t(`${riskPrefix}enable`),
|
|
276
|
+
acknowledged, disabled: !!locked || picking, onAcknowledgedChange: setAcknowledged, onCancel: closeConfirmation,
|
|
277
|
+
onConfirm: () => { if (!acknowledged || locked || picking)
|
|
278
|
+
return; const id = confirmation; closeConfirmation(); selectPreset(id); },
|
|
279
|
+
}));
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
// Client-owned Typert contribution: mount the namespace before rendering the seat.
|
|
283
|
+
// Structural schemas keep this artifact on host modules (no bundled Zod/React).
|
|
284
|
+
exports.guardRemoteContribution = {
|
|
285
|
+
package: 'dsh-bash-guard',
|
|
286
|
+
descriptors: ['get', 'update'].map(method => ({
|
|
287
|
+
id: `dsh-bash-guard#bashGuard/${method}`, service: 'bashGuard', namespace: 'bashGuard', method,
|
|
288
|
+
invocation: { kind: 'direct' },
|
|
289
|
+
parameters: [
|
|
290
|
+
{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-bash-guard#SessionId', create: () => ({ parse(value) {
|
|
291
|
+
if (typeof value !== 'string' || !value.trim())
|
|
292
|
+
throw new Error('A session ID is required');
|
|
293
|
+
return value;
|
|
294
|
+
} }) } },
|
|
295
|
+
...(method === 'update' ? [{ name: 'request', wire: 'request', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-bash-guard#GuardUpdate', create: () => ({ parse(value) {
|
|
296
|
+
if (!value || !['activate', 'mode', 'deactivate'].includes(value.action) || !Number.isSafeInteger(value.revision) || value.revision < 0 || (value.mode !== undefined && !/^[0-7]{4}$(?![\s\S])/.test(value.mode)) || (value.action === 'mode' && value.mode === undefined))
|
|
297
|
+
throw new Error('Invalid Bash Guard update');
|
|
298
|
+
return { action: value.action, revision: value.revision, ...(value.mode === undefined ? {} : { mode: value.mode }) };
|
|
299
|
+
} }) } }] : []),
|
|
300
|
+
],
|
|
301
|
+
result: { mode: 'strict', typeSymbol: 'dsh-bash-guard#GuardRecord', create: () => ({ parse: value => decodeGuardState({ ok: true, value }) }) },
|
|
302
|
+
})),
|
|
303
|
+
};
|
|
304
|
+
async function apply(ctx) {
|
|
305
|
+
// Resolve host modules inside apply: the browser loader owns React and styles.
|
|
306
|
+
const React = require('react');
|
|
307
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
308
|
+
const Control = createPermissionControl(React, primitives);
|
|
309
|
+
const register = (scope, guard) => {
|
|
310
|
+
// Only the injected child may read the guarded Cordis service. The adapter
|
|
311
|
+
// also makes missing-guard reads ordinary errors rather than proxy violations.
|
|
312
|
+
const remote = {
|
|
313
|
+
bashGuard: guard,
|
|
314
|
+
permissionPresets: scope.remote.permissionPresets,
|
|
315
|
+
$on: (event, listener) => scope.remote.$on(event, listener),
|
|
316
|
+
};
|
|
317
|
+
scope.slots.inject('conversation.input.permission', () => scope.slots.register({
|
|
318
|
+
name: 'conversation.input.permission', priority: -100, locale: 'permission.access',
|
|
319
|
+
inject: sessionId => ({ sessionId, transport: createGuardTransport(remote, sessionId, () => scope.sessions.binding(sessionId)?.session) }),
|
|
320
|
+
}, Control));
|
|
321
|
+
};
|
|
322
|
+
// Leave the host seat intact while the injected guard namespace resolves.
|
|
323
|
+
// On mount failure, native choices still work through the guard-free adapter.
|
|
324
|
+
try {
|
|
325
|
+
await ctx.remote.$mount(exports.guardRemoteContribution);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
console.warn('Bash Guard RPC unavailable', error);
|
|
329
|
+
register(ctx, undefined);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
ctx.inject(['remote.bashGuard'], child => register(child, child.remote.bashGuard));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
},
|
|
336
|
+
"./GuardEditor.js": (module,exports,require) => {
|
|
337
|
+
"use strict";
|
|
338
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
339
|
+
exports.createGuardEditor = createGuardEditor;
|
|
340
|
+
// Independent permission bits; the host owns React and the check glyph.
|
|
341
|
+
const scopes = ['系统', '外部', '网络', '工作区'];
|
|
342
|
+
const permissions = [{ label: '读', bit: 4 }, { label: '写', bit: 2 }, { label: '执行', bit: 1 }];
|
|
343
|
+
function createGuardEditor(React, CheckIcon) {
|
|
344
|
+
const h = React.createElement;
|
|
345
|
+
return function GuardEditor({ mode, active, pending = false, locked = false, error, onChange, onModeChange }) {
|
|
346
|
+
if (!active)
|
|
347
|
+
return null;
|
|
348
|
+
const disabled = pending || locked;
|
|
349
|
+
const keydown = (event, row, col) => {
|
|
350
|
+
const delta = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] }[event.key];
|
|
351
|
+
if (!delta)
|
|
352
|
+
return;
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
event.stopPropagation();
|
|
355
|
+
const root = event.currentTarget.closest('[data-guard-editor]');
|
|
356
|
+
root?.querySelector(`[data-cell="${Math.max(0, Math.min(3, row + delta[0]))}-${Math.max(0, Math.min(2, col + delta[1]))}"]`)?.focus();
|
|
357
|
+
};
|
|
358
|
+
return h('div', { className: 'dsh-bg-editor', 'data-guard-editor': '', 'aria-busy': pending }, h('div', { className: 'dsh-bg-scope dsh-bg-head' }, h('button', {
|
|
359
|
+
type: 'button', className: 'dsh-bg-scope-label dsh-bg-all-toggle dsh-bg-scope-toggle',
|
|
360
|
+
disabled: locked, 'aria-disabled': disabled,
|
|
361
|
+
title: mode === '7777' ? '恢复默认权限 0477' : '开启全部 Guard 权限',
|
|
362
|
+
onClick: event => { event.stopPropagation(); if (!disabled)
|
|
363
|
+
onModeChange(mode === '7777' ? '0477' : '7777'); },
|
|
364
|
+
}, mode === '7777' ? '默认' : '全选'), ...permissions.map(p => h('span', { className: 'dsh-bg-column', key: p.bit }, p.label))), ...scopes.map((scope, row) => h('div', { className: 'dsh-bg-scope', key: row, role: 'group', 'aria-label': `${scope}权限` }, h('button', {
|
|
365
|
+
type: 'button', className: 'dsh-bg-scope-label dsh-bg-scope-toggle',
|
|
366
|
+
disabled: locked, 'aria-disabled': disabled,
|
|
367
|
+
'aria-label': `${scope}:${mode[row] === '7' ? '取消全选' : '全选'}`,
|
|
368
|
+
title: mode[row] === '7' ? '取消该范围全部权限' : '开启该范围全部权限',
|
|
369
|
+
onClick: event => { event.stopPropagation(); if (!disabled)
|
|
370
|
+
onChange(row, mode[row] === '7' ? 0 : 7); },
|
|
371
|
+
}, scope), ...permissions.map((permission, col) => {
|
|
372
|
+
const checked = (Number(mode[row]) & permission.bit) !== 0;
|
|
373
|
+
return h('button', {
|
|
374
|
+
type: 'button', key: permission.bit, role: 'checkbox', 'aria-checked': checked,
|
|
375
|
+
'aria-label': `${scope} · ${permission.label}权限`, title: `${scope} · ${permission.label}权限:${checked ? '开启' : '关闭'}`,
|
|
376
|
+
'data-cell': `${row}-${col}`, disabled: locked, 'aria-disabled': disabled, className: 'dsh-bg-level',
|
|
377
|
+
onClick: event => { event.stopPropagation(); if (!disabled)
|
|
378
|
+
onChange(row, Number(mode[row]) ^ permission.bit); },
|
|
379
|
+
onKeyDown: event => keydown(event, row, col),
|
|
380
|
+
}, checked && CheckIcon ? h(CheckIcon, { 'aria-hidden': true }) : null);
|
|
381
|
+
}))), error ? h('div', { className: 'dsh-bg-error', role: 'alert' }, error) : null);
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
}};const cache={};function require(id){if(!factories[id])return hostRequire(id);if(cache[id])return cache[id].exports;const module={exports:{}};cache[id]=module;factories[id](module,module.exports,require);return module.exports;}
|
|
386
|
+
const api=require('./client.js');const original=api.apply;api.apply=(ctx)=>{ctx.effect(()=>{const style=document.createElement('style');style.dataset.plugin='dsh-bash-guard';style.textContent="/* Native permission trigger declarations, namespaced; Menu owns all menu chrome. */\n.dsh-bg-trigger { border-radius: var(--dsw-radius-sm); min-width: 0; max-width: 220px; height: 28px; color: var(--dsw-alias-label-secondary); cursor: pointer; background: transparent; border: none; outline: none; align-items: center; gap: 4px; padding: 0 4px 0 8px; font-size: 13px; font-weight: 500; line-height: 20px; display: inline-flex; }\n.dsh-bg-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }\n.dsh-bg-trigger:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }\n.dsh-bg-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }\n.dsh-bg-trigger-icon { flex: none; display: inline-flex; }\n.dsh-bg-trigger-icon svg { width: 14px; height: 14px; }\n.dsh-bg-trigger-label { text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; }\n.dsh-bg-option-label { align-items: baseline; gap: 4px; min-width: 0; max-width: 100%; display: inline-flex; }\n.dsh-bg-badge { color: var(--dsw-alias-label-tertiary); letter-spacing: .2px; flex: none; align-self: flex-start; margin-top: -1px; font-size: 8px; font-weight: 600; line-height: 10px; }\n.dsh-bg-chevron { color: var(--dsw-alias-label-caption); flex: none; transition: transform .12s; display: inline-flex; }\n.dsh-bg-chevron-open { transform: rotate(180deg); }\n@container (width <= 460px) { .dsh-bg-trigger:has(.dsh-bg-trigger-icon) .dsh-bg-trigger-label { display: none; } }\n.dsh-bg-editor { padding: 0 8px 4px 32px; color: var(--dsw-alias-label-primary); }\n.dsh-bg-scope { display: grid; grid-template-columns: 48px repeat(3, 32px); align-items: center; gap: 8px; min-height: 32px; }\n.dsh-bg-head { min-height: 26px; }\n.dsh-bg-column { text-align: center; font-size: 12px; color: var(--dsw-alias-label-secondary); }\n.dsh-bg-level svg { width: 16px; height: 16px; }\n.dsh-bg-scope-label { width: 42px; flex: 0 0 42px; color: var(--dsw-alias-label-secondary); font-size: 12px; }\n.dsh-bg-scope-toggle { appearance: none; border: 0; background: transparent; border-radius: var(--dsw-radius-sm); box-sizing: border-box; width: max-content; max-width: none; justify-self: start; height: 24px; margin-left: -6px; padding: 0 6px; text-align: left; white-space: nowrap; font-family: inherit; cursor: pointer; }\n.dsh-bg-scope-toggle:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }\n.dsh-bg-level { appearance: none; border: 1px solid var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: var(--dsw-radius-sm); width: 32px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }\n.dsh-bg-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.dsh-bg-level:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }\n.dsh-bg-level[aria-checked=\"true\"] { background: var(--dsw-alias-interactive-bg-hover-solid); color: var(--dsw-alias-label-primary); }\n.dsh-bg-editor button:focus-visible, .dsh-bg-error button:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-label-secondary)); outline-offset: 1px; }\n.dsh-bg-editor button:disabled { cursor: default; color: var(--dsw-alias-label-tertiary); }\n.dsh-bg-error, .dsh-bg-status { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; padding: 6px 8px; overflow-wrap: anywhere; max-width: 300px; }\n.dsh-bg-error button { margin-left: 8px; padding: 2px 6px; border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-sm); background: transparent; color: inherit; font: inherit; cursor: pointer; }\n.dsh-bg-mode { font: 12px var(--ds-font-family-code, monospace); color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }\n@media (max-width: 360px) { .dsh-bg-editor { padding-left: 24px; } .dsh-bg-level { padding-inline: 4px; } }\n@media (prefers-reduced-motion: reduce) { .dsh-bg-chevron { transition: none; } }\n";document.head.append(style);return ()=>style.remove()});return original(ctx)};return api;}});
|
package/cordis.patch.yml
ADDED
package/lib/classify.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative, lexical bash-agent policy scanner, NOT an OS sandbox or a full
|
|
3
|
+
* shell parser. Requirements are four octal digits: system/external/network/
|
|
4
|
+
* workspace, each with read=4, write=2, execute=1. Compound calls accumulate
|
|
5
|
+
* every requirement; callers must grant ALL bits. Real filesystem confinement,
|
|
6
|
+
* symlink resolution and runtime/network isolation belong to the sandbox.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors docs/bash-tool-policy.md and the Go/Rust/bash scanners in bash-agent.
|
|
9
|
+
* Unlike their whitespace scanner, quoted paths and stderr redirections are
|
|
10
|
+
* retained. Unresolved shell expansion fails closed rather than guessing a path.
|
|
11
|
+
*/
|
|
12
|
+
import { posix as path } from 'node:path';
|
|
13
|
+
const SCOPES = ['system', 'external', 'network', 'workspace'];
|
|
14
|
+
const VALID_MODE = /^[0-7]{4}$(?![\s\S])/;
|
|
15
|
+
const SYSTEM = /^\/(?:etc|usr|bin|sbin|var|library|system|dev|boot|proc|sys|root)(?:\/|$)/i;
|
|
16
|
+
const SENSITIVE = /(?:^|\/)(?:\.ssh|\.gnupg|\.aws|\.docker)(?:\/|$)|(?:\.env|\.pem|\.key)(?:$|\/)|token|credential|secret/i;
|
|
17
|
+
const READERS = new Set(['cat', 'ls', 'head', 'tail', 'less', 'more', 'grep', 'egrep', 'fgrep', 'rg', 'find', 'stat', 'wc', 'file', 'du', 'readlink', 'realpath', 'sort', 'uniq', 'cut', 'diff', 'cmp', 'sed', 'awk']);
|
|
18
|
+
const MUTATORS = new Set(['mkdir', 'touch', 'cp', 'mv', 'rm', 'rmdir', 'tee', 'chmod', 'chown', 'chgrp', 'truncate', 'install', 'ln', 'dd']);
|
|
19
|
+
const INTERPRETERS = /^(?:ba|z|da|k)?sh$|^(?:python\d*(?:\.\d+)?|node|ruby|perl|php|lua|eval|source|\.)$/;
|
|
20
|
+
const NETWORK = /^(?:curl|wget|http|https|ssh|scp|sftp|rsync|nc|ncat|netcat|telnet|ftp|ping)$/;
|
|
21
|
+
const GIT_WRITE = /^(?:fetch|pull|clone|commit|add|checkout|switch|restore|merge|rebase|stash|cherry-pick|reset|clean|init)$/;
|
|
22
|
+
const GIT_EXEC = /^(?:commit|add|checkout|switch|restore|merge|rebase|stash|cherry-pick)$/;
|
|
23
|
+
const within = (target, root) => target === root || target.startsWith(root === '/' ? '/' : `${root}/`);
|
|
24
|
+
const format = (mask) => mask.toString(8).padStart(4, '0');
|
|
25
|
+
const bit = (scope, permissions) => permissions << ((3 - SCOPES.indexOf(scope)) * 3);
|
|
26
|
+
function context(input) {
|
|
27
|
+
const options = typeof input === 'string' ? { cwd: input, workspace: input } : input ?? {};
|
|
28
|
+
const cwd = path.resolve(options.cwd ?? options.workspace ?? process.cwd());
|
|
29
|
+
return { cwd, workspace: path.resolve(options.workspace ?? cwd), home: options.home ?? process.env.BASH_AGENT_HOME ?? process.env.HOME ?? '' };
|
|
30
|
+
}
|
|
31
|
+
/** Pure lexical classification: never follows symlinks or touches the filesystem. */
|
|
32
|
+
function pathMask(value, permissions, ctx) {
|
|
33
|
+
if (!value || /^&\d+$/.test(value) || value === '-')
|
|
34
|
+
return 0;
|
|
35
|
+
const original = value;
|
|
36
|
+
value = value.replace(/^~(?=\/|$)|^\$(?:HOME|\{HOME\})(?=\/|$)/, ctx.home || '$UNKNOWN_HOME');
|
|
37
|
+
if (/[\x00\r\n]/.test(value) || /\$|`|~/.test(value))
|
|
38
|
+
return bit('system', permissions) | bit('external', permissions) | bit('network', permissions) | bit('workspace', permissions);
|
|
39
|
+
const trusted = ['/tmp', ...(ctx.home ? [path.join(ctx.home, '.bash-agent/projects')] : [])];
|
|
40
|
+
// Check BEFORE normalization: /tmp/../etc is explicitly never trusted.
|
|
41
|
+
if (trusted.some(root => within(value, root)) && value.split('/').includes('..'))
|
|
42
|
+
return bit('system', permissions);
|
|
43
|
+
const resolved = path.resolve(ctx.cwd, value);
|
|
44
|
+
if (resolved === '/dev/null')
|
|
45
|
+
return 0;
|
|
46
|
+
if (within(resolved, '/dev/tcp') || within(resolved, '/dev/udp'))
|
|
47
|
+
return bit('network', permissions);
|
|
48
|
+
if (trusted.some(root => within(resolved, root)))
|
|
49
|
+
return bit(value.split('/').includes('..') ? 'system' : 'workspace', permissions);
|
|
50
|
+
if (resolved === '/' || /^\/[?*\[]/.test(resolved) || SYSTEM.test(resolved) || SENSITIVE.test(original))
|
|
51
|
+
return bit('system', permissions);
|
|
52
|
+
return bit(within(resolved, ctx.workspace) ? 'workspace' : 'external', permissions);
|
|
53
|
+
}
|
|
54
|
+
/** One scope's Unix-style permission bit. */
|
|
55
|
+
export function opBit(op) { return op === 'read' ? 4 : op === 'write' ? 2 : 1; }
|
|
56
|
+
/** Invalid modes/scopes never accidentally become an allowed digit. */
|
|
57
|
+
export function scopeDigit(mode, scope) {
|
|
58
|
+
return VALID_MODE.test(mode) && SCOPES.includes(scope) ? Number(mode[SCOPES.indexOf(scope)]) : -1;
|
|
59
|
+
}
|
|
60
|
+
export function allows(mode, required) {
|
|
61
|
+
if (!VALID_MODE.test(mode) || !VALID_MODE.test(required))
|
|
62
|
+
return false;
|
|
63
|
+
const allowedMask = Number.parseInt(mode, 8);
|
|
64
|
+
const requiredMask = Number.parseInt(required, 8);
|
|
65
|
+
return (allowedMask & requiredMask) === requiredMask;
|
|
66
|
+
}
|
|
67
|
+
/** Actual DSH names/arguments; legacy casing is accepted, not required. */
|
|
68
|
+
export function classifyTool(tool, args = {}, input) {
|
|
69
|
+
const name = tool.toLowerCase();
|
|
70
|
+
const ctx = context(input);
|
|
71
|
+
if (name === 'bash') {
|
|
72
|
+
if (typeof args.command !== 'string')
|
|
73
|
+
return '7777';
|
|
74
|
+
const cwd = typeof args.workdir === 'string' ? path.resolve(ctx.cwd, args.workdir) : ctx.cwd;
|
|
75
|
+
return classifyCommand(args.command, { ...ctx, cwd });
|
|
76
|
+
}
|
|
77
|
+
const read = ['read', 'read_image', 'grep', 'glob', 'ls'].includes(name);
|
|
78
|
+
const write = ['write', 'edit', 'multiedit', 'notebookedit'].includes(name);
|
|
79
|
+
if (!read && !write)
|
|
80
|
+
return undefined;
|
|
81
|
+
const value = args.file_path ?? args.path ?? args.notebook_path;
|
|
82
|
+
if (value !== undefined && typeof value !== 'string')
|
|
83
|
+
return '7777';
|
|
84
|
+
if (name === 'glob' && value === undefined && typeof args.pattern === 'string' && (path.isAbsolute(args.pattern) || args.pattern.split('/').includes('..')))
|
|
85
|
+
return '4000';
|
|
86
|
+
return format(pathMask(typeof value === 'string' && value ? value : '.', write ? 2 : 4, ctx));
|
|
87
|
+
}
|
|
88
|
+
/** Quotes group literal paths. Operators are recognized even without whitespace. */
|
|
89
|
+
function tokenize(script) {
|
|
90
|
+
const result = [];
|
|
91
|
+
let word = '', active = false, quote = '';
|
|
92
|
+
const flush = () => { if (active)
|
|
93
|
+
result.push({ text: word, operator: false }); word = ''; active = false; };
|
|
94
|
+
for (let i = 0; i < script.length; i += 1) {
|
|
95
|
+
const ch = script[i];
|
|
96
|
+
if (ch === '\\' && quote !== "'") {
|
|
97
|
+
if (i + 1 >= script.length)
|
|
98
|
+
return undefined;
|
|
99
|
+
const next = script[++i];
|
|
100
|
+
if (next !== '\n') {
|
|
101
|
+
word += next;
|
|
102
|
+
active = true;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (quote) {
|
|
107
|
+
if (ch === quote)
|
|
108
|
+
quote = '';
|
|
109
|
+
else
|
|
110
|
+
word += ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === '"' || ch === "'") {
|
|
114
|
+
quote = ch;
|
|
115
|
+
active = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === '#' && !active) {
|
|
119
|
+
while (i < script.length && script[i] !== '\n')
|
|
120
|
+
i += 1;
|
|
121
|
+
flush();
|
|
122
|
+
result.push({ text: ';', operator: true });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === '\n' || /\s/.test(ch)) {
|
|
126
|
+
flush();
|
|
127
|
+
if (ch === '\n')
|
|
128
|
+
result.push({ text: ';', operator: true });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (';&|<>()'.includes(ch)) {
|
|
132
|
+
// A numeric word immediately before >/< is an fd, not a path.
|
|
133
|
+
let fd = '';
|
|
134
|
+
if ((ch === '>' || ch === '<') && /^\d+$/.test(word)) {
|
|
135
|
+
fd = word;
|
|
136
|
+
word = '';
|
|
137
|
+
active = false;
|
|
138
|
+
}
|
|
139
|
+
flush();
|
|
140
|
+
let op = ch;
|
|
141
|
+
if (script[i + 1] === ch || (ch === '<' && script[i + 1] === '>') || ('<>'.includes(ch) && script[i + 1] === '&') || (ch === '>' && script[i + 1] === '|'))
|
|
142
|
+
op += script[++i];
|
|
143
|
+
result.push({ text: fd + op, operator: true });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
word += ch;
|
|
147
|
+
active = true;
|
|
148
|
+
}
|
|
149
|
+
if (quote)
|
|
150
|
+
return undefined;
|
|
151
|
+
flush();
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Accumulate command segments, pipelines, path scopes, redirections and execution.
|
|
156
|
+
* Opaque substitutions/heredocs/dynamic command names fail closed. This cannot
|
|
157
|
+
* prove the effects of arbitrary programs, aliases, symlinks or shell functions.
|
|
158
|
+
*/
|
|
159
|
+
export function classifyCommand(command, input) {
|
|
160
|
+
if (!command.trim())
|
|
161
|
+
return '0000';
|
|
162
|
+
const ctx = context(input);
|
|
163
|
+
// Do not pretend to expand arbitrary shell code, variables, or process substitution.
|
|
164
|
+
if (/\$\(|`|[<>]\(|<<|\$\{|\$(?!HOME(?:\W|$))[A-Za-z_]/.test(command))
|
|
165
|
+
return '7777';
|
|
166
|
+
// Branch-dependent cwd and shell control flow cannot be resolved by this scanner.
|
|
167
|
+
if (/\b(?:if|then|else|elif|fi|for|while|until|case|esac|function)\b|[{}]/.test(command) || /\bcd\b/.test(command) && /\|\||(?:^|[^&])&(?:[^&]|$)|[()]/.test(command))
|
|
168
|
+
return '7777';
|
|
169
|
+
const tokens = tokenize(command);
|
|
170
|
+
if (!tokens)
|
|
171
|
+
return '7777';
|
|
172
|
+
let mask = 0;
|
|
173
|
+
let segment = [];
|
|
174
|
+
let networkSeen = false, networkExecution = false;
|
|
175
|
+
let cwd = ctx.cwd;
|
|
176
|
+
const scan = () => {
|
|
177
|
+
if (!segment.length)
|
|
178
|
+
return;
|
|
179
|
+
const local = { ...ctx, cwd };
|
|
180
|
+
const words = [];
|
|
181
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
182
|
+
const token = segment[i];
|
|
183
|
+
if (token.operator && /[<>]/.test(token.text)) {
|
|
184
|
+
const next = segment[++i];
|
|
185
|
+
if (!next || next.operator) {
|
|
186
|
+
mask |= 0o7777;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (token.text.endsWith('&') && /^(?:\d+|-)$/.test(next.text))
|
|
190
|
+
continue;
|
|
191
|
+
const permissions = token.text.includes('<>') ? 6 : token.text.includes('>') ? 2 : 4;
|
|
192
|
+
mask |= pathMask(next.text, permissions, local);
|
|
193
|
+
}
|
|
194
|
+
else if (!token.operator)
|
|
195
|
+
words.push(token.text);
|
|
196
|
+
}
|
|
197
|
+
segment = [];
|
|
198
|
+
while (words[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
|
|
199
|
+
// Environment overrides can change executable lookup / shell startup semantics.
|
|
200
|
+
if (/^(?:PATH|BASH_ENV|ENV|SHELLOPTS|LD_|DYLD_)/.test(words[0]))
|
|
201
|
+
mask |= 0o7777;
|
|
202
|
+
words.shift();
|
|
203
|
+
}
|
|
204
|
+
while (['command', 'builtin', 'exec', 'env', 'nohup', 'timeout'].includes(words[0] ?? '')) {
|
|
205
|
+
words.shift();
|
|
206
|
+
while (words[0]?.startsWith('-') || /^\d+$/.test(words[0] ?? '') || /^[A-Za-z_]\w*=/.test(words[0] ?? ''))
|
|
207
|
+
words.shift();
|
|
208
|
+
}
|
|
209
|
+
if (!words.length)
|
|
210
|
+
return;
|
|
211
|
+
if (words[0].startsWith('-') || /[?*\[\]$]/.test(words[0])) {
|
|
212
|
+
mask |= 0o7777;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const executable = words[0];
|
|
216
|
+
const cmd = path.basename(executable).toLowerCase();
|
|
217
|
+
const args = words.slice(1);
|
|
218
|
+
const sub = args.find(arg => !arg.startsWith('-'))?.toLowerCase() ?? '';
|
|
219
|
+
const git = cmd === 'git';
|
|
220
|
+
const network = NETWORK.test(cmd) || git && /^(?:clone|fetch|pull|ls-remote|push)$/.test(sub) || args.some(arg => /^https?:\/\//i.test(arg));
|
|
221
|
+
if (network) {
|
|
222
|
+
networkSeen = true;
|
|
223
|
+
if (!(git && sub === 'push'))
|
|
224
|
+
mask |= bit('network', 4);
|
|
225
|
+
if (['ssh', 'scp', 'sftp', 'rsync', 'ftp', 'nc', 'ncat', 'netcat', 'telnet'].includes(cmd))
|
|
226
|
+
mask |= bit('network', 2);
|
|
227
|
+
if (cmd === 'ssh') {
|
|
228
|
+
mask |= bit('network', 1);
|
|
229
|
+
networkExecution = true;
|
|
230
|
+
}
|
|
231
|
+
if (git && sub === 'push' || cmd === 'curl' && args.some(arg => /^(?:-[dFTX]|--(?:data|json|form|upload-file|request))(?:$|[=A-Z])/.test(arg)))
|
|
232
|
+
mask |= bit('network', 2);
|
|
233
|
+
}
|
|
234
|
+
if (/^(?:sudo|su|doas|shutdown|reboot|halt|poweroff)$/.test(cmd))
|
|
235
|
+
mask |= bit('system', 1);
|
|
236
|
+
if (['sudo', 'su', 'doas'].includes(cmd) && args.length && !['echo', 'printf', 'true', 'false', ':', 'pwd'].includes(args[0])) {
|
|
237
|
+
// Options such as sudo -u require command-specific parsing; do not guess.
|
|
238
|
+
mask |= args[0].startsWith('-') ? 0o7777 : Number.parseInt(classifyCommand(args.map(arg => JSON.stringify(arg)).join(' '), local), 8);
|
|
239
|
+
}
|
|
240
|
+
if (/^(?:mkfs.*|fdisk|diskutil|mount|umount)$/.test(cmd))
|
|
241
|
+
mask |= bit('system', 2);
|
|
242
|
+
const interpreter = INTERPRETERS.test(cmd);
|
|
243
|
+
if (interpreter) {
|
|
244
|
+
mask |= pathMask('.', 1, local);
|
|
245
|
+
networkExecution = true;
|
|
246
|
+
const inline = args.findIndex(arg => /^-[^-]*c/.test(arg));
|
|
247
|
+
if (inline >= 0 && args[inline + 1] !== undefined && /^(?:ba|z|da|k)?sh$|^eval$/.test(cmd))
|
|
248
|
+
mask |= Number.parseInt(classifyCommand(args[inline + 1], local), 8);
|
|
249
|
+
if (['eval', 'source', '.'].includes(cmd) && args.length)
|
|
250
|
+
mask |= cmd === 'eval' ? Number.parseInt(classifyCommand(args.join(' '), local), 8) : pathMask(args[0], 1, local);
|
|
251
|
+
}
|
|
252
|
+
const build = /^(?:make|gmake|cmake|ninja)$/.test(cmd) || /^(?:npm|pnpm|yarn|bun)$/.test(cmd) && /^(?:run|test|exec|dlx|install|add|publish)$/.test(sub) || /^(?:cargo|go)$/.test(cmd) && /^(?:test|build|run|install)$/.test(sub);
|
|
253
|
+
const mutation = MUTATORS.has(cmd) || git && GIT_WRITE.test(sub) || build && !/^(?:make|gmake|cmake|ninja)$/.test(cmd) && /^(?:test|build|install|add)$/.test(sub) || cmd === 'sed' && args.some(arg => /^-i|^--in-place/.test(arg)) || cmd === 'find' && args.includes('-delete');
|
|
254
|
+
if (build || git && GIT_EXEC.test(sub))
|
|
255
|
+
mask |= pathMask('.', 1, local);
|
|
256
|
+
if (/^(?:npm|pnpm|yarn|bun|pip\d*|brew|apt(?:-get)?)$/.test(cmd) && /^(?:install|add|publish)$/.test(sub))
|
|
257
|
+
mask |= bit('network', sub === 'publish' ? 2 : 4);
|
|
258
|
+
let pathSeen = false;
|
|
259
|
+
if (executable.includes('/')) {
|
|
260
|
+
mask |= pathMask(executable, 1, local);
|
|
261
|
+
pathSeen = true;
|
|
262
|
+
}
|
|
263
|
+
// Read/write tools conservatively require both operations on explicit operands,
|
|
264
|
+
// as in the reference scanner. Redirection destinations are write-only.
|
|
265
|
+
const permissions = mutation ? 6 : 4;
|
|
266
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
267
|
+
let arg = args[i];
|
|
268
|
+
if (/^https?:\/\//i.test(arg))
|
|
269
|
+
continue;
|
|
270
|
+
if ((cmd === 'curl' && /^(?:-o|--output|-T|--upload-file)$/.test(arg)) || cmd === 'wget' && /^(?:-O|--output-document)$/.test(arg)) {
|
|
271
|
+
const target = args[++i];
|
|
272
|
+
if (target)
|
|
273
|
+
mask |= pathMask(target, /^(?:-T|--upload-file)$/.test(arg) ? 4 : 2, local);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (arg.startsWith('-')) {
|
|
277
|
+
const optionPath = arg.match(/=(\/[^\s]*|\.\.?\/[^\s]*)$/)?.[1];
|
|
278
|
+
if (!optionPath)
|
|
279
|
+
continue;
|
|
280
|
+
arg = optionPath;
|
|
281
|
+
}
|
|
282
|
+
if (/^of=/.test(arg)) {
|
|
283
|
+
mask |= pathMask(arg.slice(3), 2, local);
|
|
284
|
+
pathSeen = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (/^if=/.test(arg)) {
|
|
288
|
+
mask |= pathMask(arg.slice(3), 4, local);
|
|
289
|
+
pathSeen = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const explicit = /^(?:\/|\.\.?\/|~|\$HOME)/.test(arg) || SENSITIVE.test(arg);
|
|
293
|
+
if (explicit || READERS.has(cmd) || MUTATORS.has(cmd)) {
|
|
294
|
+
// Search expressions without path syntax are not filesystem targets.
|
|
295
|
+
if (['grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk'].includes(cmd) && i === 0 && !explicit)
|
|
296
|
+
continue;
|
|
297
|
+
mask |= pathMask(arg, permissions, local);
|
|
298
|
+
pathSeen = true;
|
|
299
|
+
}
|
|
300
|
+
if (interpreter && i === 0 && !arg.startsWith('-') && !explicit)
|
|
301
|
+
mask |= pathMask(arg, 1, local);
|
|
302
|
+
}
|
|
303
|
+
if (mutation && !pathSeen)
|
|
304
|
+
mask |= pathMask('.', 2, local);
|
|
305
|
+
if (cmd === 'cd') {
|
|
306
|
+
const target = args[0] ?? ctx.home;
|
|
307
|
+
mask |= pathMask(target || '$UNKNOWN_HOME', 4, local);
|
|
308
|
+
if (!/[$~*?\[\]]/.test(target))
|
|
309
|
+
cwd = path.resolve(cwd, target);
|
|
310
|
+
else
|
|
311
|
+
mask |= 0o7777;
|
|
312
|
+
}
|
|
313
|
+
// Unknown local programs can execute code; never classify them as mere reads.
|
|
314
|
+
if (!executable.includes('/') && !READERS.has(cmd) && !MUTATORS.has(cmd) && !interpreter && !build && !git && !NETWORK.test(cmd) && !['echo', 'printf', 'pwd', 'true', 'false', ':', 'test', '[', 'cd'].includes(cmd) && !/^(?:sudo|su|doas|shutdown|reboot|halt|poweroff|mkfs.*|fdisk|diskutil|mount|umount)$/.test(cmd))
|
|
315
|
+
mask |= pathMask('.', 1, local);
|
|
316
|
+
};
|
|
317
|
+
for (const token of tokens) {
|
|
318
|
+
if (token.operator && /^[;&|()]$|^&&$|^\|\|$/.test(token.text))
|
|
319
|
+
scan();
|
|
320
|
+
else
|
|
321
|
+
segment.push(token);
|
|
322
|
+
}
|
|
323
|
+
scan();
|
|
324
|
+
if (networkSeen && networkExecution)
|
|
325
|
+
mask |= bit('network', 1);
|
|
326
|
+
return format(mask || bit('workspace', 4));
|
|
327
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
2
|
+
import { allows, classifyTool } from './classify.js';
|
|
3
|
+
export const name = 'bash-guard';
|
|
4
|
+
export const inject = ['storageDomain', 'sessions', 'permissionPresets'];
|
|
5
|
+
export const BASH_GUARD_DENIED = 'BASH_GUARD_DENIED';
|
|
6
|
+
const valid = (value) => typeof value === 'string' && /^[0-7]{4}$(?![\s\S])/.test(value);
|
|
7
|
+
export const recordSchema = {
|
|
8
|
+
parse(value) {
|
|
9
|
+
if (!value || !valid(value.mode) || typeof value.active !== 'boolean' || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Number.isSafeInteger(value.presetSeq) || value.presetSeq < -1)
|
|
10
|
+
throw new Error('Invalid stored Bash Guard configuration');
|
|
11
|
+
return { mode: value.mode, active: value.active, revision: value.revision, presetSeq: value.presetSeq };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
function presetBoundary(session) {
|
|
15
|
+
let boundary = -1;
|
|
16
|
+
const permissionRuns = new Set();
|
|
17
|
+
for (const event of session.snapshotEvents()) {
|
|
18
|
+
// Knob edits also invalidate the guard, even without a preset identity change.
|
|
19
|
+
if (['permission/preset', 'sandbox/mode', 'approval/policy'].includes(event.type))
|
|
20
|
+
boundary = event.seq;
|
|
21
|
+
if (event.type === 'command/run' && event.data.name === 'permission' && event.data.args?.trim())
|
|
22
|
+
permissionRuns.add(event.data.commandId);
|
|
23
|
+
if (event.type === 'command/done' && event.data.kind === 'success' && permissionRuns.has(event.data.commandId))
|
|
24
|
+
boundary = event.seq;
|
|
25
|
+
}
|
|
26
|
+
return boundary;
|
|
27
|
+
}
|
|
28
|
+
/** Standard Cordis service + source-mode Typert descriptors; the gateway supplies
|
|
29
|
+
* the { ok, value } RPC envelope, not these methods. No session command transport. */
|
|
30
|
+
export class BashGuardService extends TypertRemoteService {
|
|
31
|
+
readState;
|
|
32
|
+
mutate;
|
|
33
|
+
constructor(ctx, readState, mutate) {
|
|
34
|
+
super(ctx, 'bashGuard');
|
|
35
|
+
this.readState = readState;
|
|
36
|
+
this.mutate = mutate;
|
|
37
|
+
// Invoke the installed standard-decorator API explicitly so both the host
|
|
38
|
+
// transpiler and source-mode test runners discover the same descriptors.
|
|
39
|
+
for (const method of ['get', 'update']) {
|
|
40
|
+
Remote(method)(this[method], {
|
|
41
|
+
kind: 'method', name: method, static: false, private: false,
|
|
42
|
+
access: { has: (object) => method in object, get: (object) => object[method] },
|
|
43
|
+
addInitializer: (initialize) => initialize.call(this),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
get(sessionId) { return this.readState(sessionId); }
|
|
48
|
+
update(sessionId, request) { return this.mutate(sessionId, request); }
|
|
49
|
+
}
|
|
50
|
+
export async function apply(ctx, config = {}) {
|
|
51
|
+
const fallback = config.mode ?? '0467';
|
|
52
|
+
if (!valid(fallback))
|
|
53
|
+
throw new Error('bash-guard: expected four octal digits');
|
|
54
|
+
const domain = await ctx.storageDomain.open({ name: 'bash_guard', version: 1, layout: 'per-record', tables: { sessions: { valueSchema: recordSchema } } });
|
|
55
|
+
ctx.effect(() => () => domain.close());
|
|
56
|
+
const records = domain.table('sessions');
|
|
57
|
+
const queues = new Map();
|
|
58
|
+
const sessionId = (id) => {
|
|
59
|
+
if (typeof id !== 'string' || !id.trim())
|
|
60
|
+
throw new Error('A session ID is required');
|
|
61
|
+
return id;
|
|
62
|
+
};
|
|
63
|
+
const read = (id, session = ctx.sessions.get(sessionId(id))) => {
|
|
64
|
+
const stored = records.get(id);
|
|
65
|
+
const state = stored === undefined ? { mode: fallback, active: false, revision: 0, presetSeq: -1 } : recordSchema.parse(stored);
|
|
66
|
+
// A read never resumes a session or acquires its persistence writer. An
|
|
67
|
+
// unbound session cannot establish that its saved baseline still applies.
|
|
68
|
+
if (!session)
|
|
69
|
+
return state.active ? { ...state, active: false, revision: state.revision + 1 } : state;
|
|
70
|
+
const boundary = presetBoundary(session);
|
|
71
|
+
return state.active && boundary > state.presetSeq
|
|
72
|
+
? { ...state, active: false, revision: state.revision + 1, presetSeq: boundary }
|
|
73
|
+
: state;
|
|
74
|
+
};
|
|
75
|
+
const serialized = (id, action) => {
|
|
76
|
+
const pending = (queues.get(id) ?? Promise.resolve()).catch(() => { }).then(action);
|
|
77
|
+
queues.set(id, pending);
|
|
78
|
+
void pending.finally(() => { if (queues.get(id) === pending)
|
|
79
|
+
queues.delete(id); }).catch(() => { });
|
|
80
|
+
return pending;
|
|
81
|
+
};
|
|
82
|
+
new BashGuardService(ctx, (id) => read(id), (id, request) => serialized(sessionId(id), async () => {
|
|
83
|
+
// Resolve inside the queue: never retain a stale binding across mutations.
|
|
84
|
+
const session = ctx.sessions.get(id);
|
|
85
|
+
if (!session)
|
|
86
|
+
throw new Error('Session is not live; open the session before changing Bash Guard');
|
|
87
|
+
const state = read(id, session);
|
|
88
|
+
if (!request || !['activate', 'mode', 'deactivate'].includes(request.action))
|
|
89
|
+
throw new Error('Unknown guard operation');
|
|
90
|
+
if (!Number.isSafeInteger(request.revision) || request.revision !== state.revision)
|
|
91
|
+
throw new Error('Guard configuration changed; reopen the permission menu');
|
|
92
|
+
if (state.revision >= Number.MAX_SAFE_INTEGER)
|
|
93
|
+
throw new Error('Guard revision exhausted');
|
|
94
|
+
if (request.action === 'mode' && (!state.active || !valid(request.mode)))
|
|
95
|
+
throw new Error('An active guard and four octal digits are required');
|
|
96
|
+
if (request.action === 'activate') {
|
|
97
|
+
// Installed permissionPresets.set uses canonical sandbox/approval setters.
|
|
98
|
+
// Never recursively execute a command against an already-running handler.
|
|
99
|
+
const baseline = ctx.permissionPresets.resolve('workspace-write');
|
|
100
|
+
if (baseline.sandbox !== 'workspace-write')
|
|
101
|
+
throw new Error('Workspace baseline must confine writes to the workspace');
|
|
102
|
+
ctx.permissionPresets.set(session, 'workspace-write');
|
|
103
|
+
if (ctx.permissionPresets.current(session) !== 'workspace-write')
|
|
104
|
+
throw new Error('Cannot activate workspace-write baseline');
|
|
105
|
+
}
|
|
106
|
+
const next = recordSchema.parse({
|
|
107
|
+
mode: request.action === 'mode' ? request.mode : state.mode,
|
|
108
|
+
active: request.action !== 'deactivate', revision: state.revision + 1,
|
|
109
|
+
presetSeq: presetBoundary(session),
|
|
110
|
+
});
|
|
111
|
+
await records.put(id, next);
|
|
112
|
+
return read(id);
|
|
113
|
+
}));
|
|
114
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
115
|
+
const session = exec.agent?.session;
|
|
116
|
+
if (!session)
|
|
117
|
+
return next();
|
|
118
|
+
// Observe writes already in flight before making a permission decision.
|
|
119
|
+
await queues.get(session.id);
|
|
120
|
+
const state = read(session.id, session);
|
|
121
|
+
if (!state.active)
|
|
122
|
+
return next();
|
|
123
|
+
const workspace = session.header.cwd ?? process.cwd();
|
|
124
|
+
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
|
125
|
+
const required = classifyTool(exec.name, args, { workspace, cwd: workspace });
|
|
126
|
+
if (required === undefined || allows(state.mode, required))
|
|
127
|
+
return next();
|
|
128
|
+
const message = `command blocked by bash safety policy (required=${required} allowed=${state.mode}; mode=system/external/network/workspace bits=4:read,2:write,1:execute). Adjust Bash Guard in the permission menu; do not bypass this policy.`;
|
|
129
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, error: { message, info: { name: 'BashGuardError', code: BASH_GUARD_DENIED } } };
|
|
130
|
+
});
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-bash-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "lloydzhou (https://github.com/lloydzhou)",
|
|
6
|
+
"homepage": "https://github.com/lloydzhou/dsh-bash-guard#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lloydzhou/dsh-bash-guard.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/lloydzhou/dsh-bash-guard/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"dsh",
|
|
16
|
+
"deepseek-harness",
|
|
17
|
+
"cordis",
|
|
18
|
+
"plugin",
|
|
19
|
+
"bash",
|
|
20
|
+
"permissions"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"description": "Workspace-based Bash Guard policy with native inline permission controls",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./lib/index.js",
|
|
26
|
+
"./client": "./client.js",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"client.js",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"dsh": {
|
|
36
|
+
"bundle": {
|
|
37
|
+
"patch": "./cordis.patch.yml"
|
|
38
|
+
},
|
|
39
|
+
"client": {
|
|
40
|
+
"inject": [
|
|
41
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-permission-presets"
|
|
45
|
+
],
|
|
46
|
+
"platform": "web"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "node build.mjs",
|
|
51
|
+
"prepack": "npm run build",
|
|
52
|
+
"test": "vitest run --config vitest.config.mjs"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^5.9.3",
|
|
56
|
+
"vitest": "^4.1.0"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
60
|
+
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2"
|
|
61
|
+
}
|
|
62
|
+
}
|