git-ai-control 0.4.11 → 0.4.13
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.md +13 -0
- package/README.md +3 -217
- package/README.zh-CN.md +8 -1
- package/docs/images/git-ai-settings-en.png +0 -0
- package/docs/images/plugin-management-en.png +0 -0
- package/package.json +1 -1
- package/plugins/upload-filter/plugin_filter_runtime.py +172 -0
- package/policy.example.json +6 -1
- package/server.py +30 -0
- package/static/assets/index-4-vuUlOm.js +50 -0
- package/static/index.html +1 -1
- package/static/assets/index-CllegGOU.js +0 -50
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重要变更都会记录在此文件中。
|
|
4
4
|
|
|
5
|
+
## [0.4.13] - 2026-09-09
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- Skill 过滤新增“新目录观察期”:目录创建未满 7 天时,首次 Skill 上报默认拦截 24 小时,超时未处理自动放行;观察期状态在本机持久化,服务重启不会重新计时。
|
|
10
|
+
- 管理后台新增观察期白名单和 Skill 黑名单:白名单可立即放行指定 Skill,黑名单持续阻断指定 Skill 上报。
|
|
11
|
+
|
|
12
|
+
## [0.4.12] - 2026-08-12
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- 英文 README 改用完整英文界面截图,并移除末尾误附的中文重复内容;中文说明继续由 `README.zh-CN.md` 提供。
|
|
17
|
+
|
|
5
18
|
## [0.4.11] - 2026-08-12
|
|
6
19
|
|
|
7
20
|
### 修复
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Git AI Control Panel
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
[Chinese documentation](README.zh-CN.md)
|
|
4
4
|
|
|
5
5
|
Git AI Control Panel is a local settings and upload-policy manager for Git AI on macOS, Linux, and Windows. It lets you control report event types and fields by repository host without manually editing JSON or writing a script for every privacy rule. The React/Vite control page listens only on `127.0.0.1:38742`; it is never exposed to your LAN or the public internet.
|
|
6
6
|
|
|
@@ -25,11 +25,11 @@ Git AI Control Panel is a local settings and upload-policy manager for Git AI on
|
|
|
25
25
|
|
|
26
26
|
### Native Git AI settings
|
|
27
27
|
|
|
28
|
-

|
|
29
29
|
|
|
30
30
|
### Plugin management
|
|
31
31
|
|
|
32
|
-

|
|
33
33
|
|
|
34
34
|
## Quick start
|
|
35
35
|
|
|
@@ -166,217 +166,3 @@ The project publishes one cross-platform npm package. Pushing a Git tag matching
|
|
|
166
166
|
- The page rejects modification requests from non-page origins.
|
|
167
167
|
- If plugin configuration is missing or invalid, the default GitHub policy remains fully blocking.
|
|
168
168
|
- Without the granular filtering extension point, the page reports native-settings compatibility instead of claiming filtering is active.
|
|
169
|
-
|
|
170
|
-
> [!IMPORTANT]
|
|
171
|
-
> 项目可以识别 `custom_metrics` 定制版和官方 [`git-ai-project/git-ai`](https://github.com/git-ai-project/git-ai),但两者支持层级不同。仓库级事件开关、字段脱敏和 Skill 过滤目前需要 `custom_metrics` 扩展点;官方版只能管理 Git AI 已公开的原生配置。安装前请阅读[兼容性说明](COMPATIBILITY.md)。
|
|
172
|
-
|
|
173
|
-
## 功能
|
|
174
|
-
|
|
175
|
-
- 可视化管理 Git AI 自动更新、版本检查、仓库排除和提示词排除;
|
|
176
|
-
- 通过“匹配仓库主机”统一处理 GitHub、GitLab、Gitee 或其他 Git 服务;
|
|
177
|
-
- 分别控制 Token、Skills、Commit、Checkpoint、Agent、提示耗时和提示报告;
|
|
178
|
-
- 可删除仓库信息、项目路径和分支名称等敏感字段;
|
|
179
|
-
- 可将允许上传的 Skill 项目目录替换为固定目录;
|
|
180
|
-
- 可按关键词或正则拦截指定 Skill;
|
|
181
|
-
- 仓库插件可按不同主机添加多个,并支持全局兜底、仓库、目录、分支与优先级;
|
|
182
|
-
- 敏感内容脱敏插件可识别 API Key、私钥、凭据,并按规则脱敏或拦截;
|
|
183
|
-
- Agent / 模型治理插件支持允许名单、阻止正则及审计/拦截模式;
|
|
184
|
-
- Skill、敏感内容和 Agent / 模型治理均使用独立配置表单;
|
|
185
|
-
- 支持配置验证、运行状态检查和本机过滤服务重启。
|
|
186
|
-
|
|
187
|
-
## 界面截图
|
|
188
|
-
|
|
189
|
-
### Git AI 原生设置
|
|
190
|
-
|
|
191
|
-

|
|
192
|
-
|
|
193
|
-
### 插件管理
|
|
194
|
-
|
|
195
|
-

|
|
196
|
-
|
|
197
|
-
仓库插件可以同时配置匹配主机、允许上传的数据类型和允许保留的字段,也可以按不同主机添加多个。主机规则重叠时按列表顺序使用第一个命中插件;Skill 过滤插件使用独立表单管理名称规则,且只能添加一次。
|
|
198
|
-
|
|
199
|
-
插件管理页面按添加顺序展示全部插件。无论添加仓库插件还是独立插件,新卡片都会追加到现有列表最后;这只影响页面展示顺序,不改变仓库规则的匹配优先级。
|
|
200
|
-
|
|
201
|
-
## 快速开始
|
|
202
|
-
|
|
203
|
-
### 1. 一条命令启动
|
|
204
|
-
|
|
205
|
-
支持 macOS、Linux 和 Windows,需要 Node.js 18+、Python 3,并要求 Git AI 已安装在默认目录:
|
|
206
|
-
|
|
207
|
-
```text
|
|
208
|
-
macOS / Linux: ~/.git-ai/bin/git-ai
|
|
209
|
-
Windows: %USERPROFILE%\.git-ai\bin\git-ai.exe
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
确认文件存在后运行:
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
npx git-ai-control
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
首次运行时,该命令会自动下载最新版本、安装本机服务,并打开 Git AI 配置中心;之后检测到已运行的配置中心时,会直接复用现有进程。
|
|
219
|
-
|
|
220
|
-
首次安装过程会:
|
|
221
|
-
|
|
222
|
-
1. 识别当前 Git AI 是否支持 `custom_metrics` 扩展;
|
|
223
|
-
2. 将原始上报端点备份到仅当前用户可读的本机配置;
|
|
224
|
-
3. 安装控制页面和上传过滤运行时;
|
|
225
|
-
4. 保留已有的 `config.json` 与 `filter_plugins.json`;
|
|
226
|
-
5. 通过当前系统的用户级服务管理器创建并启动两个本机服务;
|
|
227
|
-
6. 等待控制页面和过滤服务通过健康检查。
|
|
228
|
-
|
|
229
|
-
如果浏览器没有自动打开,可以手动访问:
|
|
230
|
-
|
|
231
|
-
```text
|
|
232
|
-
http://127.0.0.1:38742
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
页面顶部会显示当前 Git AI 发行版、过滤接管状态和服务状态。
|
|
236
|
-
|
|
237
|
-
各系统使用的服务管理方式:
|
|
238
|
-
|
|
239
|
-
| 系统 | 服务管理器 | 使用条件 |
|
|
240
|
-
| --- | --- | --- |
|
|
241
|
-
| macOS | LaunchAgent | 当前图形用户会话 |
|
|
242
|
-
| Linux | systemd user | 可用的 systemd 用户会话 |
|
|
243
|
-
| Windows | 任务计划程序 | Windows 10/11,当前用户可创建登录任务 |
|
|
244
|
-
|
|
245
|
-
### 2. 配置仓库插件
|
|
246
|
-
|
|
247
|
-
以“GitHub 私人仓库只允许上传 Token”为例:
|
|
248
|
-
|
|
249
|
-
1. 在“仓库插件”中添加或打开现有仓库插件;
|
|
250
|
-
2. 在“匹配仓库主机”中填写 `github.com`;
|
|
251
|
-
3. 只打开 `Token`,关闭 `Skills`、`Commit`、`Checkpoint`、`Agent`、提示耗时和提示报告;
|
|
252
|
-
4. 关闭“仓库信息”“项目路径”和“分支名称”;
|
|
253
|
-
5. 保存配置。
|
|
254
|
-
|
|
255
|
-
同一个仓库插件也可以填写多个主机,每行一个;也可以为不同主机分别添加仓库插件。GitLab、Gitee 和自建 Git 服务不需要单独开发插件。
|
|
256
|
-
|
|
257
|
-
仓库策略会先匹配特定规则,再匹配“全部仓库”全局规则;特定规则之间优先级数字越大越靠前,同优先级按插件列表顺序处理。仓库、目录、分支同时填写时需要全部命中。
|
|
258
|
-
|
|
259
|
-
### 3. 配置敏感内容与 Agent
|
|
260
|
-
|
|
261
|
-
在“插件管理”中可分别添加以下独立插件:
|
|
262
|
-
|
|
263
|
-
1. “敏感内容脱敏”:选择作用事件、内置规则和自定义正则;命中后可脱敏或阻止上报;
|
|
264
|
-
2. “Agent / 模型治理”:配置允许的 Agent、模型与阻止正则;先使用“仅审计”确认命中情况,再按需改为阻止。
|
|
265
|
-
|
|
266
|
-
### 4. 配置 Skill 过滤插件
|
|
267
|
-
|
|
268
|
-
需要按名称拦截 Skill 时:
|
|
269
|
-
|
|
270
|
-
1. 添加“Skill 过滤插件”;
|
|
271
|
-
2. 打开插件;
|
|
272
|
-
3. 每行填写一个关键词或正则;
|
|
273
|
-
4. 保存配置。
|
|
274
|
-
|
|
275
|
-
Skill 过滤插件只负责名称规则。仓库匹配、上传类型和目录替换仍由仓库插件负责。
|
|
276
|
-
|
|
277
|
-
### 5. 验证配置
|
|
278
|
-
|
|
279
|
-
点击页面中的“验证配置”。结果会通过右上角 Toast 显示,不会占用配置表单空间。
|
|
280
|
-
|
|
281
|
-
也可以在终端检查两个本机服务:
|
|
282
|
-
|
|
283
|
-
```bash
|
|
284
|
-
curl -fsS http://127.0.0.1:38742/api/status
|
|
285
|
-
curl -fsS http://127.0.0.1:38741/health
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
## 工作方式
|
|
289
|
-
|
|
290
|
-
本项目不会修改 `~/.git-ai/bin/git-ai` 二进制。检测到 `custom_metrics` 扩展点后,安装脚本会将 Git AI 的自定义上报端点指向本机过滤服务:
|
|
291
|
-
|
|
292
|
-
```text
|
|
293
|
-
Git AI
|
|
294
|
-
-> ~/.git-ai/custom_metrics.json
|
|
295
|
-
-> http://127.0.0.1:38741
|
|
296
|
-
-> 读取 ~/.git-ai/filter_plugins.json
|
|
297
|
-
-> 读取 ~/.git-ai/upstream_metrics.json
|
|
298
|
-
-> 拦截,或脱敏后转发至原上报服务
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
两个页签中的“查看配置”会重新读取当前磁盘内容,不会显示未保存的表单修改。Git AI 原生配置只展示本页面安全管理的字段,插件策略展示完整已写入策略。
|
|
302
|
-
|
|
303
|
-
控制页面保存时只更新:
|
|
304
|
-
|
|
305
|
-
- `~/.git-ai/config.json`:Git AI 原生设置;
|
|
306
|
-
- `~/.git-ai/filter_plugins.json`:仓库插件和 Skill 过滤插件策略。
|
|
307
|
-
|
|
308
|
-
过滤逻辑来自仓库中的 `plugins/upload-filter/plugin_filter_runtime.py`。控制页面不会动态生成或改写 Python 脚本,`~/.git-ai` 只保存安装副本和用户配置。
|
|
309
|
-
|
|
310
|
-
## 文件位置
|
|
311
|
-
|
|
312
|
-
| 内容 | 位置 |
|
|
313
|
-
| --- | --- |
|
|
314
|
-
| 过滤运行时源码 | `plugins/upload-filter/plugin_filter_runtime.py` |
|
|
315
|
-
| 跨平台安装器 | `scripts/install.mjs`、`scripts/uninstall.mjs` |
|
|
316
|
-
| 服务适配器 | `scripts/platform-services.mjs` |
|
|
317
|
-
| Git AI 原生配置 | `~/.git-ai/config.json` |
|
|
318
|
-
| 插件策略 | `~/.git-ai/filter_plugins.json` |
|
|
319
|
-
| 原上报端点本机备份 | `~/.git-ai/upstream_metrics.json` |
|
|
320
|
-
| 过滤运行时安装副本 | `~/.git-ai/filters/plugin_filter_runtime.py` |
|
|
321
|
-
| Web 服务安装副本 | `~/.git-ai/control-panel` |
|
|
322
|
-
| macOS 服务定义 | `~/Library/LaunchAgents/com.git-ai.*.plist` |
|
|
323
|
-
| Linux 服务定义 | `~/.config/systemd/user/git-ai-*.service` |
|
|
324
|
-
| Windows 服务定义 | `%USERPROFILE%\.git-ai\services\*.ps1` 与任务计划程序 |
|
|
325
|
-
|
|
326
|
-
## 卸载
|
|
327
|
-
|
|
328
|
-
```bash
|
|
329
|
-
npx git-ai-control uninstall
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
卸载命令会停止两个服务、删除本项目安装的程序副本,并将上报端点恢复为 Git AI 原地址。`config.json`、`filter_plugins.json` 和日志会保留。
|
|
333
|
-
|
|
334
|
-
## 参与贡献
|
|
335
|
-
|
|
336
|
-
欢迎提交 Issue 和 Pull Request,尤其欢迎:
|
|
337
|
-
|
|
338
|
-
- 补充不同 Git AI 版本的兼容性验证;
|
|
339
|
-
- 新增与仓库过滤不同类型的独立插件;
|
|
340
|
-
- 改进过滤规则的测试覆盖;
|
|
341
|
-
- 修正文档、交互和无障碍问题。
|
|
342
|
-
|
|
343
|
-
开发前请先 Fork 仓库并创建主题分支。前端源码位于 `frontend/git-ai-control-panel`,shadcn/ui 组件位于 `src/components/ui`。
|
|
344
|
-
|
|
345
|
-
```bash
|
|
346
|
-
cd frontend/git-ai-control-panel
|
|
347
|
-
npm ci
|
|
348
|
-
npm run lint
|
|
349
|
-
npm run build
|
|
350
|
-
cd ../..
|
|
351
|
-
npm run check
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
提交 Pull Request 时请说明:
|
|
355
|
-
|
|
356
|
-
1. 解决的问题和使用场景;
|
|
357
|
-
2. 对配置结构或过滤行为的影响;
|
|
358
|
-
3. 已执行的验证命令;
|
|
359
|
-
4. 涉及界面变化时附上前后截图。
|
|
360
|
-
|
|
361
|
-
新增插件类型时,请为它设计独立的数据结构和配置表单;不要复用仓库插件表单来表达无关能力。涉及上报兼容性时,应基于实际 Git AI 版本和请求路径验证,避免把“页面可配置”误写成“运行时已接管”。
|
|
362
|
-
|
|
363
|
-
## 发布
|
|
364
|
-
|
|
365
|
-
项目只发布一个跨平台 npm 包,不分别生成 macOS、Linux 或 Windows 安装包。推送与 `package.json` 版本一致的 Git 标签后,GitHub Actions 会:
|
|
366
|
-
|
|
367
|
-
1. 在 macOS、Ubuntu 和 Windows 上检查同一份安装器与运行时源码;
|
|
368
|
-
2. 在 Ubuntu 上只构建一次前端静态资源;
|
|
369
|
-
3. 确认构建产物与仓库内容一致;
|
|
370
|
-
4. 通过 npm Trusted Publishing(OIDC)发布到 npmjs.com。
|
|
371
|
-
|
|
372
|
-
发布工作流不使用长期 npm Access Token。npm 包需要将 `.github/workflows/publish.yml` 配置为可信发布者。
|
|
373
|
-
|
|
374
|
-
## 安全边界
|
|
375
|
-
|
|
376
|
-
- Web 服务只绑定本机回环地址;
|
|
377
|
-
- 不读取或展示 Git AI API key 等敏感字段;
|
|
378
|
-
- 原始上报端点不会写入项目源码,只保存在权限为 `0600` 的本机配置中;
|
|
379
|
-
- 配置保存采用临时文件替换,避免写入中断产生不完整文件;
|
|
380
|
-
- 页面没有跨域权限,拒绝非本页面来源的修改请求;
|
|
381
|
-
- 插件配置缺失或损坏时,GitHub 默认策略保持全部阻断;
|
|
382
|
-
- 官方版缺少细粒度过滤扩展点时,页面会显示“仅原生配置兼容”,不会把空闲代理误报为已接管。
|
package/README.zh-CN.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
- 可删除仓库信息、项目路径和分支名称等敏感字段;
|
|
18
18
|
- 可将允许上传的 Skill 项目目录替换为固定目录;
|
|
19
19
|
- 可按关键词或正则拦截指定 Skill;
|
|
20
|
+
- 新建目录 7 天内的 Skill 上报默认进入 24 小时观察期,可在后台通过白名单放行或黑名单持续拦截;
|
|
20
21
|
- 仓库插件可按不同主机添加多个,并支持全局兜底、仓库、目录、分支与优先级;
|
|
21
22
|
- 敏感内容脱敏插件可识别 API Key、私钥、凭据,并按规则脱敏或拦截;
|
|
22
23
|
- Agent / 模型治理插件支持允许名单、阻止正则及审计/拦截模式;
|
|
@@ -90,7 +91,13 @@ http://127.0.0.1:38742
|
|
|
90
91
|
3. 每行填写一个关键词或正则;
|
|
91
92
|
4. 保存配置。
|
|
92
93
|
|
|
93
|
-
### 5.
|
|
94
|
+
### 5. 新目录 Skill 观察期
|
|
95
|
+
|
|
96
|
+
“Skill 过滤插件”默认启用新目录观察期。上报事件中的项目目录创建未满 7 天时,首次 Skill 上报会被拦截 24 小时;期间可将 Skill 加入观察期白名单立即放行,或加入 Skill 黑名单持续阻断。24 小时没有处理则自动恢复上报,且过滤服务重启不会重新计时。
|
|
97
|
+
|
|
98
|
+
文件系统未提供目录创建时间时,观察期不会基于不可靠的修改时间推断目录年龄。
|
|
99
|
+
|
|
100
|
+
### 6. 验证配置
|
|
94
101
|
|
|
95
102
|
点击页面中的“验证配置”,或在终端检查两个本机服务:
|
|
96
103
|
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import hashlib
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
@@ -39,9 +40,14 @@ GIT_AI_ROOT = Path(os.environ.get("GIT_AI_ROOT", Path.home() / ".git-ai")).resol
|
|
|
39
40
|
POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
|
|
40
41
|
UPSTREAM_CONFIG_PATH = GIT_AI_ROOT / "upstream_metrics.json"
|
|
41
42
|
FILTER_AUDIT_PATH = GIT_AI_ROOT / "filter_audit.jsonl"
|
|
43
|
+
RECENT_SKILL_HOLD_PATH = GIT_AI_ROOT / "recent_skill_holds.json"
|
|
42
44
|
FILTER_AUDIT_RETENTION = timedelta(hours=24)
|
|
43
45
|
FILTER_AUDIT_MAX_EVENTS = 2_000
|
|
44
46
|
FILTER_AUDIT_LOCK = threading.Lock()
|
|
47
|
+
RECENT_SKILL_HOLD_LOCK = threading.Lock()
|
|
48
|
+
RECENT_DIRECTORY_MAX_AGE = timedelta(days=7)
|
|
49
|
+
RECENT_SKILL_HOLD_DURATION = timedelta(hours=24)
|
|
50
|
+
RECENT_SKILL_HOLD_RETENTION = timedelta(days=7)
|
|
45
51
|
OWNER_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?$")
|
|
46
52
|
SKILL_KEYS = {"skillName", "skill", "name"}
|
|
47
53
|
PATH_KEYS = {
|
|
@@ -137,6 +143,11 @@ DEFAULT_POLICY_CONFIG = {
|
|
|
137
143
|
"cloudflare",
|
|
138
144
|
"cloud-flare",
|
|
139
145
|
],
|
|
146
|
+
"recent_directory_guard": {
|
|
147
|
+
"enabled": True,
|
|
148
|
+
"allowlist_patterns": [],
|
|
149
|
+
"blocklist_patterns": [],
|
|
150
|
+
},
|
|
140
151
|
},
|
|
141
152
|
"plugins": [
|
|
142
153
|
{
|
|
@@ -275,6 +286,15 @@ def iter_skill_text(value):
|
|
|
275
286
|
yield from iter_skill_text(child)
|
|
276
287
|
|
|
277
288
|
|
|
289
|
+
def skill_names(payload) -> list[str]:
|
|
290
|
+
names = set()
|
|
291
|
+
for value in iter_skill_text(payload):
|
|
292
|
+
name = audit_text(value, 160)
|
|
293
|
+
if name:
|
|
294
|
+
names.add(name)
|
|
295
|
+
return sorted(names)
|
|
296
|
+
|
|
297
|
+
|
|
278
298
|
def audit_timestamp() -> str:
|
|
279
299
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
280
300
|
|
|
@@ -660,6 +680,148 @@ def matches_allowlist(value: str, patterns) -> bool:
|
|
|
660
680
|
return any(selector_matches(pattern, [value]) for pattern in patterns)
|
|
661
681
|
|
|
662
682
|
|
|
683
|
+
def compile_skill_patterns(patterns) -> list[re.Pattern]:
|
|
684
|
+
compiled = []
|
|
685
|
+
for pattern in patterns:
|
|
686
|
+
try:
|
|
687
|
+
compiled.append(re.compile(str(pattern), re.IGNORECASE))
|
|
688
|
+
except re.error:
|
|
689
|
+
continue
|
|
690
|
+
return compiled
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def directory_created_at(path: Path) -> datetime | None:
|
|
694
|
+
try:
|
|
695
|
+
metadata = path.stat()
|
|
696
|
+
except OSError:
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
timestamp = getattr(metadata, "st_birthtime", None)
|
|
700
|
+
if timestamp is None and os.name == "nt":
|
|
701
|
+
timestamp = metadata.st_ctime
|
|
702
|
+
if timestamp is None:
|
|
703
|
+
return None
|
|
704
|
+
return datetime.fromtimestamp(timestamp, timezone.utc)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def recent_payload_directories(payload, now: datetime) -> list[str]:
|
|
708
|
+
directories = set()
|
|
709
|
+
for value in iter_path_text(payload):
|
|
710
|
+
if not isinstance(value, str) or not value.strip():
|
|
711
|
+
continue
|
|
712
|
+
try:
|
|
713
|
+
path = Path(value).expanduser().resolve(strict=True)
|
|
714
|
+
except OSError:
|
|
715
|
+
continue
|
|
716
|
+
if not path.is_dir():
|
|
717
|
+
path = path.parent
|
|
718
|
+
created_at = directory_created_at(path)
|
|
719
|
+
if created_at is not None and now - created_at < RECENT_DIRECTORY_MAX_AGE:
|
|
720
|
+
directories.add(str(path))
|
|
721
|
+
return sorted(directories)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def read_recent_skill_holds(path: Path = RECENT_SKILL_HOLD_PATH) -> dict[str, str]:
|
|
725
|
+
try:
|
|
726
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
727
|
+
except (OSError, json.JSONDecodeError):
|
|
728
|
+
return {}
|
|
729
|
+
holds = value.get("holds", {}) if isinstance(value, dict) else {}
|
|
730
|
+
if not isinstance(holds, dict):
|
|
731
|
+
return {}
|
|
732
|
+
return {
|
|
733
|
+
key: timestamp
|
|
734
|
+
for key, timestamp in holds.items()
|
|
735
|
+
if isinstance(key, str) and parse_audit_timestamp(timestamp) is not None
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def write_recent_skill_holds(
|
|
740
|
+
holds: dict[str, str], path: Path = RECENT_SKILL_HOLD_PATH
|
|
741
|
+
) -> None:
|
|
742
|
+
temporary = None
|
|
743
|
+
try:
|
|
744
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
745
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
746
|
+
temporary.write_text(
|
|
747
|
+
json.dumps({"version": 1, "holds": holds}, ensure_ascii=False, indent=2)
|
|
748
|
+
+ "\n",
|
|
749
|
+
encoding="utf-8",
|
|
750
|
+
)
|
|
751
|
+
if os.name != "nt":
|
|
752
|
+
os.chmod(temporary, 0o600)
|
|
753
|
+
os.replace(temporary, path)
|
|
754
|
+
except OSError:
|
|
755
|
+
if temporary:
|
|
756
|
+
try:
|
|
757
|
+
temporary.unlink(missing_ok=True)
|
|
758
|
+
except OSError:
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def recent_skill_hold_key(directory: str, skill: str) -> str:
|
|
763
|
+
return hashlib.sha256(f"{directory}\0{skill}".encode("utf-8")).hexdigest()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def recent_directory_skill_guard(
|
|
767
|
+
payload,
|
|
768
|
+
skill_policy: dict,
|
|
769
|
+
*,
|
|
770
|
+
now: datetime | None = None,
|
|
771
|
+
state_path: Path = RECENT_SKILL_HOLD_PATH,
|
|
772
|
+
) -> str | None:
|
|
773
|
+
if not skill_policy.get("installed", True) or not skill_policy.get("enabled", True):
|
|
774
|
+
return None
|
|
775
|
+
guard = skill_policy.get("recent_directory_guard", {})
|
|
776
|
+
if not isinstance(guard, dict):
|
|
777
|
+
return None
|
|
778
|
+
skills = skill_names(payload) or ["[unknown]"]
|
|
779
|
+
blocklist = compile_skill_patterns(guard.get("blocklist_patterns", []))
|
|
780
|
+
if any(pattern.search(skill) for skill in skills for pattern in blocklist):
|
|
781
|
+
return "recent_directory_skill_blocklist"
|
|
782
|
+
if not guard.get("enabled", True):
|
|
783
|
+
return None
|
|
784
|
+
allowlist = compile_skill_patterns(guard.get("allowlist_patterns", []))
|
|
785
|
+
if skills and all(any(pattern.search(skill) for pattern in allowlist) for skill in skills):
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
now = now or datetime.now(timezone.utc)
|
|
789
|
+
if now.tzinfo is None:
|
|
790
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
791
|
+
now = now.astimezone(timezone.utc)
|
|
792
|
+
directories = recent_payload_directories(payload, now)
|
|
793
|
+
if not directories:
|
|
794
|
+
return None
|
|
795
|
+
|
|
796
|
+
with RECENT_SKILL_HOLD_LOCK:
|
|
797
|
+
holds = read_recent_skill_holds(state_path)
|
|
798
|
+
holds = {
|
|
799
|
+
key: timestamp
|
|
800
|
+
for key, timestamp in holds.items()
|
|
801
|
+
if now - parse_audit_timestamp(timestamp) < RECENT_SKILL_HOLD_RETENTION
|
|
802
|
+
}
|
|
803
|
+
should_hold = False
|
|
804
|
+
for directory in directories:
|
|
805
|
+
for skill in skills:
|
|
806
|
+
key = recent_skill_hold_key(directory, skill)
|
|
807
|
+
first_seen = parse_audit_timestamp(holds.get(key))
|
|
808
|
+
if first_seen is None:
|
|
809
|
+
first_seen = now
|
|
810
|
+
holds[key] = audit_timestamp_from(now)
|
|
811
|
+
if now - first_seen < RECENT_SKILL_HOLD_DURATION:
|
|
812
|
+
should_hold = True
|
|
813
|
+
write_recent_skill_holds(holds, state_path)
|
|
814
|
+
return "recent_directory_skill_hold" if should_hold else None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def audit_timestamp_from(value: datetime) -> str:
|
|
818
|
+
return (
|
|
819
|
+
value.astimezone(timezone.utc)
|
|
820
|
+
.isoformat(timespec="seconds")
|
|
821
|
+
.replace("+00:00", "Z")
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
663
825
|
def governance_findings(payload, policy: dict) -> list[str]:
|
|
664
826
|
if not policy.get("installed", False) or not policy.get("enabled", False):
|
|
665
827
|
return []
|
|
@@ -717,6 +879,16 @@ def evaluate_request(
|
|
|
717
879
|
"plugin": plugin_id,
|
|
718
880
|
"config_error": config_error,
|
|
719
881
|
}
|
|
882
|
+
if event_type == "skill" and (
|
|
883
|
+
reason := recent_directory_skill_guard(payload, skill_policy)
|
|
884
|
+
):
|
|
885
|
+
return {
|
|
886
|
+
"blocked": True,
|
|
887
|
+
"reason": reason,
|
|
888
|
+
"payload": payload,
|
|
889
|
+
"plugin": plugin_id,
|
|
890
|
+
"config_error": config_error,
|
|
891
|
+
}
|
|
720
892
|
|
|
721
893
|
if plugin:
|
|
722
894
|
allowed = bool(plugin.get("allow", {}).get(event_type, False))
|
package/policy.example.json
CHANGED
package/server.py
CHANGED
|
@@ -229,6 +229,31 @@ def validate_policy_config(value) -> dict:
|
|
|
229
229
|
re.compile(pattern)
|
|
230
230
|
except re.error as error:
|
|
231
231
|
raise ConfigError(f"无效的 Skill 正则:{pattern}({error})") from error
|
|
232
|
+
recent_directory_guard = skill_policy.get("recent_directory_guard", {})
|
|
233
|
+
if not isinstance(recent_directory_guard, dict):
|
|
234
|
+
raise ConfigError("新目录观察期配置必须是对象")
|
|
235
|
+
recent_directory_guard_enabled = recent_directory_guard.get("enabled", True)
|
|
236
|
+
if not isinstance(recent_directory_guard_enabled, bool):
|
|
237
|
+
raise ConfigError("新目录观察期开关必须是布尔值")
|
|
238
|
+
recent_directory_allowlist = validate_string_list(
|
|
239
|
+
recent_directory_guard.get("allowlist_patterns", []),
|
|
240
|
+
"新目录观察期白名单",
|
|
241
|
+
maximum=256,
|
|
242
|
+
)
|
|
243
|
+
recent_directory_blocklist = validate_string_list(
|
|
244
|
+
recent_directory_guard.get("blocklist_patterns", []),
|
|
245
|
+
"新目录观察期黑名单",
|
|
246
|
+
maximum=256,
|
|
247
|
+
)
|
|
248
|
+
for label, patterns in (
|
|
249
|
+
("新目录观察期白名单", recent_directory_allowlist),
|
|
250
|
+
("新目录观察期黑名单", recent_directory_blocklist),
|
|
251
|
+
):
|
|
252
|
+
for pattern in patterns:
|
|
253
|
+
try:
|
|
254
|
+
re.compile(pattern)
|
|
255
|
+
except re.error as error:
|
|
256
|
+
raise ConfigError(f"无效的{label}正则:{pattern}({error})") from error
|
|
232
257
|
|
|
233
258
|
plugins = value.get("plugins")
|
|
234
259
|
if not isinstance(plugins, list) or len(plugins) > 32:
|
|
@@ -392,6 +417,11 @@ def validate_policy_config(value) -> dict:
|
|
|
392
417
|
"installed": skill_installed,
|
|
393
418
|
"enabled": skill_enabled,
|
|
394
419
|
"blocked_patterns": blocked_patterns,
|
|
420
|
+
"recent_directory_guard": {
|
|
421
|
+
"enabled": recent_directory_guard_enabled,
|
|
422
|
+
"allowlist_patterns": recent_directory_allowlist,
|
|
423
|
+
"blocklist_patterns": recent_directory_blocklist,
|
|
424
|
+
},
|
|
395
425
|
},
|
|
396
426
|
"plugins": normalized_plugins,
|
|
397
427
|
"plugin_order": plugin_order,
|