git-ai-control 0.4.0 → 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.
@@ -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("http://127.0.0.1:38742/api/status", "配置页面")
476
+ await waitForHttp(CONTROL_PANEL_STATUS_URL, "配置页面")
438
477
 
439
478
  console.log("安装完成:")
440
479
  console.log(" 配置页面:http://127.0.0.1:38742")
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") != 1:
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) > 1:
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
- if not isinstance(allow, dict) or set(allow) != EVENT_KEYS:
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
- "match": {"hosts": hosts},
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": {key: allow[key] for key in sorted(EVENT_KEYS)},
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": 1,
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: