git-ai-control 0.4.6 → 0.4.8

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  本项目的所有重要变更都会记录在此文件中。
4
4
 
5
+ ## [0.4.8] - 2026-08-12
6
+
7
+ ### 新增
8
+
9
+ - 新增最近 24 小时过滤审计列表,按仓库和 Skill 汇总本机拦截次数、原因与最近发生时间;审计记录不保存上报正文。
10
+ - 新增过滤服务在线状态卡片,便于确认本机细粒度过滤是否可用。
11
+
12
+ ### 变更
13
+
14
+ - 顶部版本号调整至产品标题旁;右侧状态徽章仅展示发行版与过滤运行状态。
15
+
16
+ ## [0.4.7] - 2026-08-11
17
+
18
+ ### 修复
19
+
20
+ - 修复全部“每行一条”规则输入框在最后一行按 Enter 后空行被受控状态立即清除的问题,覆盖 Skill、仓库匹配、敏感正则及 Agent / 模型治理配置。
21
+
5
22
  ## [0.4.6] - 2026-08-07
6
23
 
7
24
  ### 变更
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ai-control",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Git AI 本地配置与上传隐私控制面板",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,8 +8,10 @@ import os
8
8
  import re
9
9
  import subprocess
10
10
  import sys
11
+ import threading
11
12
  import urllib.error
12
13
  import urllib.request
14
+ from datetime import datetime, timedelta, timezone
13
15
  from fnmatch import fnmatchcase
14
16
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
17
  from pathlib import Path
@@ -36,6 +38,10 @@ PORT = 38741
36
38
  GIT_AI_ROOT = Path(os.environ.get("GIT_AI_ROOT", Path.home() / ".git-ai")).resolve()
37
39
  POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
38
40
  UPSTREAM_CONFIG_PATH = GIT_AI_ROOT / "upstream_metrics.json"
41
+ FILTER_AUDIT_PATH = GIT_AI_ROOT / "filter_audit.jsonl"
42
+ FILTER_AUDIT_RETENTION = timedelta(hours=24)
43
+ FILTER_AUDIT_MAX_EVENTS = 2_000
44
+ FILTER_AUDIT_LOCK = threading.Lock()
39
45
  OWNER_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?$")
40
46
  SKILL_KEYS = {"skillName", "skill", "name"}
41
47
  PATH_KEYS = {
@@ -269,6 +275,138 @@ def iter_skill_text(value):
269
275
  yield from iter_skill_text(child)
270
276
 
271
277
 
278
+ def audit_timestamp() -> str:
279
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
280
+
281
+
282
+ def parse_audit_timestamp(value) -> datetime | None:
283
+ if not isinstance(value, str):
284
+ return None
285
+ try:
286
+ timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
287
+ except ValueError:
288
+ return None
289
+ if timestamp.tzinfo is None:
290
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
291
+ return timestamp.astimezone(timezone.utc)
292
+
293
+
294
+ def audit_text(value, maximum: int = 240) -> str:
295
+ return str(value).strip()[:maximum]
296
+
297
+
298
+ def audit_repository_label(value) -> str:
299
+ label = audit_text(value)
300
+ label = re.sub(r"([a-z][a-z0-9+.-]*://)[^/\s]*@", r"\1", label, flags=re.IGNORECASE)
301
+ label = re.sub(r"^[^@/\s]+@", "", label)
302
+ return label.split("?", 1)[0].split("#", 1)[0]
303
+
304
+
305
+ def read_filter_audit_events(
306
+ *, now: datetime | None = None, path: Path | None = None
307
+ ) -> list[dict]:
308
+ now = now or datetime.now(timezone.utc)
309
+ if now.tzinfo is None:
310
+ now = now.replace(tzinfo=timezone.utc)
311
+ cutoff = now.astimezone(timezone.utc) - FILTER_AUDIT_RETENTION
312
+ path = path or FILTER_AUDIT_PATH
313
+ try:
314
+ lines = path.read_text(encoding="utf-8").splitlines()
315
+ except OSError:
316
+ return []
317
+
318
+ events = []
319
+ for line in lines:
320
+ try:
321
+ event = json.loads(line)
322
+ except json.JSONDecodeError:
323
+ continue
324
+ if not isinstance(event, dict):
325
+ continue
326
+ timestamp = parse_audit_timestamp(event.get("timestamp"))
327
+ if timestamp is None or timestamp < cutoff:
328
+ continue
329
+ repositories = sorted(
330
+ {
331
+ label
332
+ for value in event.get("repositories", [])
333
+ if isinstance(value, str)
334
+ if (label := audit_repository_label(value))
335
+ }
336
+ )[:10]
337
+ skills = sorted(
338
+ {
339
+ label
340
+ for value in event.get("skills", [])
341
+ if isinstance(value, str)
342
+ if (label := audit_text(value, 160))
343
+ }
344
+ )[:10]
345
+ events.append(
346
+ {
347
+ "timestamp": timestamp.isoformat(timespec="seconds").replace("+00:00", "Z"),
348
+ "eventType": audit_text(event.get("eventType", ""), 80),
349
+ "reason": audit_text(event.get("reason", ""), 160),
350
+ "plugin": audit_text(event.get("plugin", ""), 80) or None,
351
+ "repositories": repositories,
352
+ "skills": skills,
353
+ }
354
+ )
355
+ return sorted(events, key=lambda event: event["timestamp"], reverse=True)[:FILTER_AUDIT_MAX_EVENTS]
356
+
357
+
358
+ def write_filter_audit_events(events: list[dict], *, path: Path | None = None) -> None:
359
+ path = path or FILTER_AUDIT_PATH
360
+ temporary = None
361
+ try:
362
+ path.parent.mkdir(parents=True, exist_ok=True)
363
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
364
+ temporary.write_text(
365
+ "".join(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" for event in events),
366
+ encoding="utf-8",
367
+ )
368
+ if os.name != "nt":
369
+ os.chmod(temporary, 0o600)
370
+ os.replace(temporary, path)
371
+ except OSError:
372
+ if temporary:
373
+ try:
374
+ temporary.unlink(missing_ok=True)
375
+ except OSError:
376
+ pass
377
+
378
+
379
+ def record_filter_event(
380
+ endpoint_path: str, payload, reason: str, plugin: str | None = None
381
+ ) -> None:
382
+ event = {
383
+ "timestamp": audit_timestamp(),
384
+ "eventType": EVENT_TYPES.get(endpoint_path, ""),
385
+ "reason": reason,
386
+ "plugin": plugin,
387
+ "repositories": sorted(
388
+ {
389
+ label
390
+ for value in iter_repository_text(payload)
391
+ if (label := audit_repository_label(value))
392
+ }
393
+ )[:10],
394
+ "skills": sorted(
395
+ {
396
+ label
397
+ for value in iter_skill_text(payload)
398
+ if (label := audit_text(value, 160))
399
+ }
400
+ )[:10],
401
+ }
402
+ with FILTER_AUDIT_LOCK:
403
+ events = read_filter_audit_events()
404
+ events.append(event)
405
+ write_filter_audit_events(
406
+ sorted(events, key=lambda item: item["timestamp"], reverse=True)[:FILTER_AUDIT_MAX_EVENTS]
407
+ )
408
+
409
+
272
410
  def iter_path_text(value):
273
411
  if isinstance(value, dict):
274
412
  for key, child in value.items():
@@ -714,12 +852,17 @@ class Handler(BaseHTTPRequestHandler):
714
852
  try:
715
853
  payload = json.loads(body.decode("utf-8") or "{}")
716
854
  except json.JSONDecodeError:
717
- self.send_filtered(endpoint_path, "invalid_json")
855
+ self.send_filtered(endpoint_path, "invalid_json", payload={})
718
856
  return
719
857
 
720
858
  decision = evaluate_request(endpoint_path, payload, body)
721
859
  if decision["blocked"]:
722
- self.send_filtered(endpoint_path, decision["reason"], decision.get("plugin"))
860
+ self.send_filtered(
861
+ endpoint_path,
862
+ decision["reason"],
863
+ decision.get("plugin"),
864
+ payload,
865
+ )
723
866
  return
724
867
 
725
868
  upstreams, upstream_error = load_upstreams()
@@ -769,7 +912,14 @@ class Handler(BaseHTTPRequestHandler):
769
912
  except Exception as error:
770
913
  self.send_json(502, {"code": -1, "data": None, "message": str(error)})
771
914
 
772
- def send_filtered(self, endpoint_path: str, reason: str, plugin: str | None = None):
915
+ def send_filtered(
916
+ self,
917
+ endpoint_path: str,
918
+ reason: str,
919
+ plugin: str | None = None,
920
+ payload=None,
921
+ ):
922
+ record_filter_event(endpoint_path, payload or {}, reason, plugin)
773
923
  self.send_json(
774
924
  200,
775
925
  {
package/server.py CHANGED
@@ -13,6 +13,7 @@ import subprocess
13
13
  import tempfile
14
14
  import urllib.error
15
15
  import urllib.request
16
+ from datetime import datetime, timedelta, timezone
16
17
  from functools import lru_cache
17
18
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
19
  from pathlib import Path
@@ -28,6 +29,8 @@ NATIVE_CONFIG_PATH = GIT_AI_ROOT / "config.json"
28
29
  POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
29
30
  CUSTOM_METRICS_PATH = GIT_AI_ROOT / "custom_metrics.json"
30
31
  FILTER_SCRIPT_PATH = GIT_AI_ROOT / "filters" / "plugin_filter_runtime.py"
32
+ FILTER_AUDIT_PATH = GIT_AI_ROOT / "filter_audit.jsonl"
33
+ FILTER_AUDIT_RETENTION = timedelta(hours=24)
31
34
  FILTER_HEALTH_URL = "http://127.0.0.1:38741/health"
32
35
  FILTER_MAC_LABEL = "com.git-ai.skill-usage-filter"
33
36
  FILTER_LINUX_UNIT = "git-ai-filter.service"
@@ -590,6 +593,59 @@ def runtime_status() -> dict:
590
593
  }
591
594
 
592
595
 
596
+ def read_filter_audit_events(now: datetime | None = None) -> tuple[str, list[dict]]:
597
+ now = now or datetime.now(timezone.utc)
598
+ if now.tzinfo is None:
599
+ now = now.replace(tzinfo=timezone.utc)
600
+ now = now.astimezone(timezone.utc)
601
+ cutoff = now - FILTER_AUDIT_RETENTION
602
+ events = []
603
+ try:
604
+ lines = FILTER_AUDIT_PATH.read_text(encoding="utf-8").splitlines()
605
+ except OSError:
606
+ lines = []
607
+
608
+ for line in lines:
609
+ try:
610
+ event = json.loads(line)
611
+ timestamp = datetime.fromisoformat(str(event.get("timestamp", "")).replace("Z", "+00:00"))
612
+ except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
613
+ continue
614
+ if timestamp.tzinfo is None:
615
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
616
+ timestamp = timestamp.astimezone(timezone.utc)
617
+ if timestamp < cutoff or not isinstance(event, dict):
618
+ continue
619
+ repositories = event.get("repositories", [])
620
+ skills = event.get("skills", [])
621
+ events.append(
622
+ {
623
+ "timestamp": timestamp.isoformat(timespec="seconds").replace("+00:00", "Z"),
624
+ "eventType": str(event.get("eventType", ""))[:80],
625
+ "reason": str(event.get("reason", ""))[:160],
626
+ "plugin": str(event["plugin"])[:80] if event.get("plugin") else None,
627
+ "repositories": [
628
+ str(value)[:240]
629
+ for value in repositories
630
+ if isinstance(value, str) and value.strip()
631
+ ][:10]
632
+ if isinstance(repositories, list)
633
+ else [],
634
+ "skills": [
635
+ str(value)[:160]
636
+ for value in skills
637
+ if isinstance(value, str) and value.strip()
638
+ ][:10]
639
+ if isinstance(skills, list)
640
+ else [],
641
+ }
642
+ )
643
+ return (
644
+ cutoff.isoformat(timespec="seconds").replace("+00:00", "Z"),
645
+ sorted(events, key=lambda event: event["timestamp"], reverse=True)[:2_000],
646
+ )
647
+
648
+
593
649
  def run_self_test() -> dict:
594
650
  checks = []
595
651
  try:
@@ -694,6 +750,10 @@ class Handler(BaseHTTPRequestHandler):
694
750
  if path == "/api/status":
695
751
  self.send_json(200, runtime_status())
696
752
  return
753
+ if path == "/api/filter-events":
754
+ since, events = read_filter_audit_events()
755
+ self.send_json(200, {"since": since, "events": events})
756
+ return
697
757
  self.serve_static(path)
698
758
 
699
759
  def do_PUT(self):