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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ai-control",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Git AI 本地配置与上传隐私控制面板",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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))
@@ -22,7 +22,12 @@
22
22
  "git-hub",
23
23
  "cloudflare",
24
24
  "cloud-flare"
25
- ]
25
+ ],
26
+ "recent_directory_guard": {
27
+ "enabled": true,
28
+ "allowlist_patterns": [],
29
+ "blocklist_patterns": []
30
+ }
26
31
  },
27
32
  "plugins": [
28
33
  {
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,