git-ai-control 0.4.12 → 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 +7 -0
- package/README.zh-CN.md +8 -1
- 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-BcO5FeRx.js +0 -50
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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
|
+
|
|
5
12
|
## [0.4.12] - 2026-08-12
|
|
6
13
|
|
|
7
14
|
### 变更
|
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
|
|
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,
|