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 +17 -0
- package/package.json +1 -1
- package/plugins/upload-filter/plugin_filter_runtime.py +153 -3
- package/server.py +60 -0
- package/static/assets/index-7Qs4-mts.js +50 -0
- package/static/assets/index-CpOHU4DS.css +2 -0
- package/static/index.html +2 -2
- package/static/assets/index-C52pcV51.css +0 -2
- package/static/assets/index-DaTUOWvb.js +0 -50
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
|
@@ -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(
|
|
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(
|
|
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):
|