git-ai-control 0.3.1 → 0.4.3
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 +41 -0
- package/COMPATIBILITY.md +2 -1
- package/README.md +36 -7
- package/bin/git-ai-control.js +12 -6
- package/package.json +5 -1
- package/plugins/upload-filter/plugin_filter_runtime.py +266 -23
- package/policy.example.json +43 -5
- package/scripts/check.mjs +16 -0
- package/scripts/install.mjs +40 -1
- package/scripts/verify-release.mjs +19 -0
- package/server.py +182 -11
- package/static/assets/index-DGRb1OwU.js +50 -0
- package/static/assets/index-DSQfR-ve.css +2 -0
- package/static/index.html +2 -2
- package/static/assets/index-DWeFCLPQ.js +0 -50
- package/static/assets/index-WWn7U1pB.css +0 -2
package/scripts/check.mjs
CHANGED
|
@@ -36,12 +36,28 @@ if (
|
|
|
36
36
|
process.exit(1)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
if (
|
|
40
|
+
!run(python, [
|
|
41
|
+
...prefix,
|
|
42
|
+
"-m",
|
|
43
|
+
"unittest",
|
|
44
|
+
"discover",
|
|
45
|
+
"-s",
|
|
46
|
+
"tests",
|
|
47
|
+
"-p",
|
|
48
|
+
"test_*.py",
|
|
49
|
+
])
|
|
50
|
+
) {
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
for (const file of [
|
|
40
55
|
"bin/git-ai-control.js",
|
|
41
56
|
"scripts/check.mjs",
|
|
42
57
|
"scripts/install.mjs",
|
|
43
58
|
"scripts/platform-services.mjs",
|
|
44
59
|
"scripts/uninstall.mjs",
|
|
60
|
+
"scripts/verify-release.mjs",
|
|
45
61
|
]) {
|
|
46
62
|
if (!run(process.execPath, ["--check", file])) {
|
|
47
63
|
process.exit(1)
|
package/scripts/install.mjs
CHANGED
|
@@ -40,6 +40,7 @@ const ROUTE_KEYS = {
|
|
|
40
40
|
"/agent-usage": "agent_usage_endpoint_v2",
|
|
41
41
|
"/prompt-report": "prompt_report_endpoint_v2",
|
|
42
42
|
}
|
|
43
|
+
const CONTROL_PANEL_STATUS_URL = "http://127.0.0.1:38742/api/status"
|
|
43
44
|
|
|
44
45
|
function run(command, args, options = {}) {
|
|
45
46
|
const result = spawnSync(command, args, {
|
|
@@ -229,6 +230,21 @@ function copyRuntime(gitAiRoot) {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
function removeLegacyUsageDashboard(gitAiRoot, policyPath) {
|
|
234
|
+
const policy = readJson(policyPath, {})
|
|
235
|
+
if (policy && typeof policy === "object" && !Array.isArray(policy) && Object.hasOwn(policy, "usage_dashboard")) {
|
|
236
|
+
const {usage_dashboard: _legacyUsageDashboard, ...nextPolicy} = policy
|
|
237
|
+
writeJsonAtomic(policyPath, nextPolicy)
|
|
238
|
+
console.log("已移除旧版本的本地用量看板配置")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const usageDataPath = path.join(gitAiRoot, "usage_dashboard.json")
|
|
242
|
+
if (fs.existsSync(usageDataPath)) {
|
|
243
|
+
fs.rmSync(usageDataPath)
|
|
244
|
+
console.log("已删除旧版本的本地用量聚合数据")
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
232
248
|
function writeServiceFiles(platform, gitAiRoot, pythonCommand) {
|
|
233
249
|
if (platform === "darwin") {
|
|
234
250
|
const directory = path.join(os.homedir(), "Library", "LaunchAgents")
|
|
@@ -371,6 +387,28 @@ async function waitForHttp(url, label) {
|
|
|
371
387
|
throw new Error(`${label}启动失败:${url}`)
|
|
372
388
|
}
|
|
373
389
|
|
|
390
|
+
export async function controlPanelIsRunning(url = CONTROL_PANEL_STATUS_URL) {
|
|
391
|
+
const controller = new AbortController()
|
|
392
|
+
const timeout = setTimeout(() => controller.abort(), 1_500)
|
|
393
|
+
try {
|
|
394
|
+
const response = await fetch(url, {signal: controller.signal})
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
return false
|
|
397
|
+
}
|
|
398
|
+
const status = await response.json()
|
|
399
|
+
return (
|
|
400
|
+
status &&
|
|
401
|
+
typeof status === "object" &&
|
|
402
|
+
typeof status.distribution === "string" &&
|
|
403
|
+
typeof status.gitAiStatus === "string"
|
|
404
|
+
)
|
|
405
|
+
} catch {
|
|
406
|
+
return false
|
|
407
|
+
} finally {
|
|
408
|
+
clearTimeout(timeout)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
374
412
|
export async function install(options = {}) {
|
|
375
413
|
const platform = options.platform ?? process.platform
|
|
376
414
|
if (!["darwin", "linux", "win32"].includes(platform)) {
|
|
@@ -405,6 +443,7 @@ export async function install(options = {}) {
|
|
|
405
443
|
fs.chmodSync(policyPath, 0o600)
|
|
406
444
|
}
|
|
407
445
|
}
|
|
446
|
+
removeLegacyUsageDashboard(gitAiRoot, policyPath)
|
|
408
447
|
|
|
409
448
|
const customMetricsSupported = fs
|
|
410
449
|
.readFileSync(binary)
|
|
@@ -434,7 +473,7 @@ export async function install(options = {}) {
|
|
|
434
473
|
writeServiceFiles(platform, gitAiRoot, pythonCommand)
|
|
435
474
|
startServices(platform, gitAiRoot)
|
|
436
475
|
await waitForHttp("http://127.0.0.1:38741/health", "过滤服务")
|
|
437
|
-
await waitForHttp(
|
|
476
|
+
await waitForHttp(CONTROL_PANEL_STATUS_URL, "配置页面")
|
|
438
477
|
|
|
439
478
|
console.log("安装完成:")
|
|
440
479
|
console.log(" 配置页面:http://127.0.0.1:38742")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
|
|
3
|
+
const tag = process.argv[2]
|
|
4
|
+
const packageJson = JSON.parse(
|
|
5
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
6
|
+
)
|
|
7
|
+
const expectedTag = `v${packageJson.version}`
|
|
8
|
+
|
|
9
|
+
if (!tag) {
|
|
10
|
+
console.error("缺少版本标签")
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (tag !== expectedTag) {
|
|
15
|
+
console.error(`版本标签 ${tag} 与 package.json ${packageJson.version} 不一致`)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log(`发布版本校验通过:${packageJson.name}@${packageJson.version}`)
|
package/server.py
CHANGED
|
@@ -51,6 +51,8 @@ EVENT_KEYS = {
|
|
|
51
51
|
"prompt_report",
|
|
52
52
|
}
|
|
53
53
|
FIELD_KEYS = {"repository", "path", "branch"}
|
|
54
|
+
SENSITIVE_RULE_KEYS = {"api_key", "private_key", "credential", "email", "phone"}
|
|
55
|
+
POLICY_PLUGIN_ORDER_KEYS = {"skill_filter", "sensitive_data", "agent_model"}
|
|
54
56
|
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
|
|
55
57
|
|
|
56
58
|
|
|
@@ -140,11 +142,70 @@ def validate_native_config(value) -> dict:
|
|
|
140
142
|
return result
|
|
141
143
|
|
|
142
144
|
|
|
145
|
+
def default_event_switches() -> dict:
|
|
146
|
+
return {key: True for key in sorted(EVENT_KEYS)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_event_switches(value, label: str, *, default: bool = True) -> dict:
|
|
150
|
+
if value is None:
|
|
151
|
+
return {key: default for key in sorted(EVENT_KEYS)}
|
|
152
|
+
if not isinstance(value, dict) or set(value) != EVENT_KEYS:
|
|
153
|
+
raise ConfigError(f"{label} 必须包含全部数据类型")
|
|
154
|
+
if not all(isinstance(value[key], bool) for key in EVENT_KEYS):
|
|
155
|
+
raise ConfigError(f"{label} 的数据类型选项必须是布尔值")
|
|
156
|
+
return {key: value[key] for key in sorted(EVENT_KEYS)}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_plugin_control(value, label: str, *, extra: dict) -> dict:
|
|
160
|
+
if not isinstance(value, dict):
|
|
161
|
+
raise ConfigError(f"缺少 {label}")
|
|
162
|
+
installed = value.get("installed", False)
|
|
163
|
+
enabled = value.get("enabled", False)
|
|
164
|
+
if not isinstance(installed, bool) or not isinstance(enabled, bool):
|
|
165
|
+
raise ConfigError(f"{label} 的安装和启用状态必须是布尔值")
|
|
166
|
+
return {"installed": installed, "enabled": enabled, **extra}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def normalize_plugin_order(
|
|
170
|
+
value,
|
|
171
|
+
plugins: list[dict],
|
|
172
|
+
*,
|
|
173
|
+
skill_installed: bool,
|
|
174
|
+
sensitive_installed: bool,
|
|
175
|
+
agent_model_installed: bool,
|
|
176
|
+
) -> list[str]:
|
|
177
|
+
expected = [f"repository:{plugin['id']}" for plugin in plugins]
|
|
178
|
+
if skill_installed:
|
|
179
|
+
expected.append("skill_filter")
|
|
180
|
+
if sensitive_installed:
|
|
181
|
+
expected.append("sensitive_data")
|
|
182
|
+
if agent_model_installed:
|
|
183
|
+
expected.append("agent_model")
|
|
184
|
+
|
|
185
|
+
if value is None:
|
|
186
|
+
return expected
|
|
187
|
+
if not isinstance(value, list) or len(value) > len(expected) + len(POLICY_PLUGIN_ORDER_KEYS):
|
|
188
|
+
raise ConfigError("plugin_order 必须是有效的插件顺序数组")
|
|
189
|
+
|
|
190
|
+
expected_keys = set(expected)
|
|
191
|
+
result = []
|
|
192
|
+
for item in value:
|
|
193
|
+
if not isinstance(item, str) or not item or len(item) > 80:
|
|
194
|
+
raise ConfigError("plugin_order 包含无效条目")
|
|
195
|
+
if item in expected_keys and item not in result:
|
|
196
|
+
result.append(item)
|
|
197
|
+
return [*result, *(item for item in expected if item not in result)]
|
|
198
|
+
|
|
199
|
+
|
|
143
200
|
def validate_policy_config(value) -> dict:
|
|
144
201
|
if not isinstance(value, dict):
|
|
145
202
|
raise ConfigError("插件策略必须是对象")
|
|
146
|
-
if value.get("version")
|
|
147
|
-
raise ConfigError("仅支持插件策略版本 1")
|
|
203
|
+
if value.get("version") not in {1, 2}:
|
|
204
|
+
raise ConfigError("仅支持插件策略版本 1 或 2")
|
|
205
|
+
|
|
206
|
+
git_ai_enabled = value.get("git_ai_enabled", True)
|
|
207
|
+
if not isinstance(git_ai_enabled, bool):
|
|
208
|
+
raise ConfigError("git_ai_enabled 必须是布尔值")
|
|
148
209
|
|
|
149
210
|
skill_policy = value.get("skill_policy")
|
|
150
211
|
if not isinstance(skill_policy, dict):
|
|
@@ -167,8 +228,8 @@ def validate_policy_config(value) -> dict:
|
|
|
167
228
|
raise ConfigError(f"无效的 Skill 正则:{pattern}({error})") from error
|
|
168
229
|
|
|
169
230
|
plugins = value.get("plugins")
|
|
170
|
-
if not isinstance(plugins, list) or len(plugins) >
|
|
171
|
-
raise ConfigError("
|
|
231
|
+
if not isinstance(plugins, list) or len(plugins) > 32:
|
|
232
|
+
raise ConfigError("仓库插件最多配置 32 个")
|
|
172
233
|
|
|
173
234
|
seen_ids = set()
|
|
174
235
|
normalized_plugins = []
|
|
@@ -193,6 +254,23 @@ def validate_policy_config(value) -> dict:
|
|
|
193
254
|
if not isinstance(match, dict):
|
|
194
255
|
raise ConfigError(f"插件 {plugin_id} match 无效")
|
|
195
256
|
hosts = validate_string_list(match.get("hosts", []), f"{plugin_id}.match.hosts", maximum=32)
|
|
257
|
+
repositories = validate_string_list(
|
|
258
|
+
match.get("repositories", []), f"{plugin_id}.match.repositories", maximum=64
|
|
259
|
+
)
|
|
260
|
+
directories = validate_string_list(
|
|
261
|
+
match.get("directories", []), f"{plugin_id}.match.directories", maximum=64
|
|
262
|
+
)
|
|
263
|
+
branches = validate_string_list(
|
|
264
|
+
match.get("branches", []), f"{plugin_id}.match.branches", maximum=64
|
|
265
|
+
)
|
|
266
|
+
all_repositories = match.get("all_repositories", False)
|
|
267
|
+
if not isinstance(all_repositories, bool):
|
|
268
|
+
raise ConfigError(f"{plugin_id}.match.all_repositories 必须是布尔值")
|
|
269
|
+
if not all_repositories and not any((hosts, repositories, directories, branches)):
|
|
270
|
+
raise ConfigError(f"插件 {plugin_id} 至少需要一个匹配条件,或开启全部仓库")
|
|
271
|
+
priority = plugin.get("priority", 100)
|
|
272
|
+
if not isinstance(priority, int) or isinstance(priority, bool) or not 0 <= priority <= 1000:
|
|
273
|
+
raise ConfigError(f"插件 {plugin_id} 的优先级必须是 0 到 1000 的整数")
|
|
196
274
|
fixed_directory = plugin.get("fixed_project_directory", "")
|
|
197
275
|
if not isinstance(fixed_directory, str) or len(fixed_directory) > 500:
|
|
198
276
|
raise ConfigError(f"插件 {plugin_id} 的固定项目目录无效")
|
|
@@ -200,10 +278,7 @@ def validate_policy_config(value) -> dict:
|
|
|
200
278
|
raise ConfigError(f"插件 {plugin_id} 的固定项目目录必须是绝对路径")
|
|
201
279
|
|
|
202
280
|
allow = plugin.get("allow")
|
|
203
|
-
|
|
204
|
-
raise ConfigError(f"插件 {plugin_id} 的数据类型选项不完整")
|
|
205
|
-
if not all(isinstance(allow[key], bool) for key in EVENT_KEYS):
|
|
206
|
-
raise ConfigError(f"插件 {plugin_id} 的数据类型选项必须是布尔值")
|
|
281
|
+
allow = validate_event_switches(allow, f"插件 {plugin_id} 的数据类型选项")
|
|
207
282
|
|
|
208
283
|
fields = plugin.get("fields")
|
|
209
284
|
if not isinstance(fields, dict) or set(fields) != FIELD_KEYS:
|
|
@@ -216,15 +291,99 @@ def validate_policy_config(value) -> dict:
|
|
|
216
291
|
"id": plugin_id,
|
|
217
292
|
"name": name.strip(),
|
|
218
293
|
"enabled": enabled,
|
|
219
|
-
"
|
|
294
|
+
"priority": priority,
|
|
295
|
+
"match": {
|
|
296
|
+
"all_repositories": all_repositories,
|
|
297
|
+
"hosts": hosts,
|
|
298
|
+
"repositories": repositories,
|
|
299
|
+
"directories": directories,
|
|
300
|
+
"branches": branches,
|
|
301
|
+
},
|
|
220
302
|
"fixed_project_directory": fixed_directory,
|
|
221
|
-
"allow":
|
|
303
|
+
"allow": allow,
|
|
222
304
|
"fields": {key: fields[key] for key in sorted(FIELD_KEYS)},
|
|
223
305
|
}
|
|
224
306
|
)
|
|
225
307
|
|
|
308
|
+
sensitive = value.get("sensitive_data_policy", {})
|
|
309
|
+
sensitive_rules = sensitive.get("built_in_rules", {}) if isinstance(sensitive, dict) else {}
|
|
310
|
+
if sensitive_rules and set(sensitive_rules) != SENSITIVE_RULE_KEYS:
|
|
311
|
+
raise ConfigError("敏感内容插件的内置规则不完整")
|
|
312
|
+
if sensitive_rules and not all(isinstance(sensitive_rules[key], bool) for key in SENSITIVE_RULE_KEYS):
|
|
313
|
+
raise ConfigError("敏感内容插件的内置规则必须是布尔值")
|
|
314
|
+
sensitive_patterns = validate_string_list(
|
|
315
|
+
sensitive.get("custom_patterns", []) if isinstance(sensitive, dict) else [],
|
|
316
|
+
"敏感内容插件自定义正则",
|
|
317
|
+
maximum=64,
|
|
318
|
+
)
|
|
319
|
+
for pattern in sensitive_patterns:
|
|
320
|
+
try:
|
|
321
|
+
re.compile(pattern)
|
|
322
|
+
except re.error as error:
|
|
323
|
+
raise ConfigError(f"无效的敏感内容正则:{pattern}({error})") from error
|
|
324
|
+
sensitive_action = sensitive.get("action", "redact") if isinstance(sensitive, dict) else "redact"
|
|
325
|
+
if sensitive_action not in {"redact", "block"}:
|
|
326
|
+
raise ConfigError("敏感内容插件动作只能是 redact 或 block")
|
|
327
|
+
normalized_sensitive = validate_plugin_control(
|
|
328
|
+
sensitive,
|
|
329
|
+
"敏感内容插件",
|
|
330
|
+
extra={
|
|
331
|
+
"action": sensitive_action,
|
|
332
|
+
"event_types": validate_event_switches(
|
|
333
|
+
sensitive.get("event_types") if isinstance(sensitive, dict) else None,
|
|
334
|
+
"敏感内容插件作用事件",
|
|
335
|
+
),
|
|
336
|
+
"built_in_rules": {
|
|
337
|
+
key: sensitive_rules.get(key, key in {"api_key", "private_key", "credential"})
|
|
338
|
+
for key in sorted(SENSITIVE_RULE_KEYS)
|
|
339
|
+
},
|
|
340
|
+
"custom_patterns": sensitive_patterns,
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
agent_model = value.get("agent_model_policy", {})
|
|
345
|
+
agent_mode = agent_model.get("mode", "audit") if isinstance(agent_model, dict) else "audit"
|
|
346
|
+
if agent_mode not in {"audit", "block"}:
|
|
347
|
+
raise ConfigError("Agent / 模型治理模式只能是 audit 或 block")
|
|
348
|
+
agent_blocked_patterns = validate_string_list(
|
|
349
|
+
agent_model.get("blocked_patterns", []) if isinstance(agent_model, dict) else [],
|
|
350
|
+
"Agent / 模型治理拦截正则",
|
|
351
|
+
maximum=128,
|
|
352
|
+
)
|
|
353
|
+
for pattern in agent_blocked_patterns:
|
|
354
|
+
try:
|
|
355
|
+
re.compile(pattern)
|
|
356
|
+
except re.error as error:
|
|
357
|
+
raise ConfigError(f"无效的 Agent / 模型正则:{pattern}({error})") from error
|
|
358
|
+
normalized_agent_model = validate_plugin_control(
|
|
359
|
+
agent_model,
|
|
360
|
+
"Agent / 模型治理插件",
|
|
361
|
+
extra={
|
|
362
|
+
"mode": agent_mode,
|
|
363
|
+
"allowed_agents": validate_string_list(
|
|
364
|
+
agent_model.get("allowed_agents", []) if isinstance(agent_model, dict) else [],
|
|
365
|
+
"允许的 Agent",
|
|
366
|
+
maximum=128,
|
|
367
|
+
),
|
|
368
|
+
"allowed_models": validate_string_list(
|
|
369
|
+
agent_model.get("allowed_models", []) if isinstance(agent_model, dict) else [],
|
|
370
|
+
"允许的模型",
|
|
371
|
+
maximum=128,
|
|
372
|
+
),
|
|
373
|
+
"blocked_patterns": agent_blocked_patterns,
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
plugin_order = normalize_plugin_order(
|
|
377
|
+
value.get("plugin_order"),
|
|
378
|
+
normalized_plugins,
|
|
379
|
+
skill_installed=skill_installed,
|
|
380
|
+
sensitive_installed=normalized_sensitive["installed"],
|
|
381
|
+
agent_model_installed=normalized_agent_model["installed"],
|
|
382
|
+
)
|
|
383
|
+
|
|
226
384
|
return {
|
|
227
|
-
"version":
|
|
385
|
+
"version": 2,
|
|
386
|
+
"git_ai_enabled": git_ai_enabled,
|
|
228
387
|
"default_allow_unmatched": bool(value.get("default_allow_unmatched", True)),
|
|
229
388
|
"skill_policy": {
|
|
230
389
|
"installed": skill_installed,
|
|
@@ -232,6 +391,9 @@ def validate_policy_config(value) -> dict:
|
|
|
232
391
|
"blocked_patterns": blocked_patterns,
|
|
233
392
|
},
|
|
234
393
|
"plugins": normalized_plugins,
|
|
394
|
+
"plugin_order": plugin_order,
|
|
395
|
+
"sensitive_data_policy": normalized_sensitive,
|
|
396
|
+
"agent_model_policy": normalized_agent_model,
|
|
235
397
|
}
|
|
236
398
|
|
|
237
399
|
|
|
@@ -398,8 +560,16 @@ def runtime_status() -> dict:
|
|
|
398
560
|
and bool(health.get("ok"))
|
|
399
561
|
and bool(service.get("ok"))
|
|
400
562
|
)
|
|
563
|
+
policy = read_json(POLICY_CONFIG_PATH, {})
|
|
564
|
+
git_ai_enabled = (
|
|
565
|
+
policy.get("git_ai_enabled", True)
|
|
566
|
+
if isinstance(policy, dict) and isinstance(policy.get("git_ai_enabled", True), bool)
|
|
567
|
+
else True
|
|
568
|
+
)
|
|
401
569
|
return {
|
|
402
570
|
"ok": granular_filter_active if supports_custom_metrics else True,
|
|
571
|
+
"gitAiEnabled": git_ai_enabled,
|
|
572
|
+
"gitAiStatus": "enabled" if git_ai_enabled else "paused",
|
|
403
573
|
"distribution": "custom-metrics" if supports_custom_metrics else "upstream-oss",
|
|
404
574
|
"granularFilterActive": granular_filter_active,
|
|
405
575
|
"capabilities": {
|
|
@@ -543,6 +713,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
543
713
|
"message": "配置已保存",
|
|
544
714
|
"gitAi": public_native_config(),
|
|
545
715
|
"policy": read_json(POLICY_CONFIG_PATH, {}),
|
|
716
|
+
"runtime": runtime_status(),
|
|
546
717
|
},
|
|
547
718
|
)
|
|
548
719
|
except ConfigError as error:
|