phase-barrier 0.2.0__py3-none-any.whl
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.
- anti_shortcut/__init__.py +34 -0
- anti_shortcut/__main__.py +107 -0
- anti_shortcut/audit.py +109 -0
- anti_shortcut/config.py +106 -0
- anti_shortcut/integration.py +108 -0
- anti_shortcut/interceptors.py +144 -0
- anti_shortcut/skill.py +289 -0
- anti_shortcut/state.py +159 -0
- anti_shortcut/validators.py +243 -0
- phase_barrier-0.2.0.dist-info/METADATA +279 -0
- phase_barrier-0.2.0.dist-info/RECORD +15 -0
- phase_barrier-0.2.0.dist-info/WHEEL +5 -0
- phase_barrier-0.2.0.dist-info/entry_points.txt +2 -0
- phase_barrier-0.2.0.dist-info/licenses/LICENSE +21 -0
- phase_barrier-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""反捷径校验 Skill:强制编码 Agent 遵循标准工程师 SOP(阶段门禁)。
|
|
2
|
+
|
|
3
|
+
用法::
|
|
4
|
+
|
|
5
|
+
from anti_shortcut import AntiShortcutSkill
|
|
6
|
+
|
|
7
|
+
skill = AntiShortcutSkill(workspace=".", user_request="实现一个斐波那契函数")
|
|
8
|
+
tools = skill.install({"write_file": my_write, "execute_command": my_exec})
|
|
9
|
+
result = tools["advance_stage"](2) # 完成 spec 后推进
|
|
10
|
+
"""
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
|
12
|
+
|
|
13
|
+
from .config import STAGES, GateConfig, load_config
|
|
14
|
+
from .integration import bootstrap, install_into, load_plugins, register_integration
|
|
15
|
+
from .skill import AntiShortcutSkill
|
|
16
|
+
from .state import StateManager
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _distribution_version("phase-barrier")
|
|
20
|
+
except PackageNotFoundError: # 直接从源码运行(未安装)时的占位版本
|
|
21
|
+
__version__ = "0.0.0.dev0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AntiShortcutSkill",
|
|
25
|
+
"StateManager",
|
|
26
|
+
"GateConfig",
|
|
27
|
+
"STAGES",
|
|
28
|
+
"load_config",
|
|
29
|
+
"bootstrap",
|
|
30
|
+
"install_into",
|
|
31
|
+
"load_plugins",
|
|
32
|
+
"register_integration",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""命令行入口:外部门禁检查与阶段推进(供编排器 / 人工监督使用)。
|
|
2
|
+
|
|
3
|
+
用法::
|
|
4
|
+
|
|
5
|
+
python -m anti_shortcut inspect [--workspace .] [--json]
|
|
6
|
+
python -m anti_shortcut advance --to 2 [--workspace .] [--json]
|
|
7
|
+
python -m anti_shortcut --version
|
|
8
|
+
|
|
9
|
+
``advance`` 与 Agent 内部的 ``advance_stage`` 走同一套证据校验:
|
|
10
|
+
通过返回退出码 0,被拒绝返回退出码 1 并打印原因。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import STAGES
|
|
21
|
+
from .skill import AntiShortcutSkill
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_skill(args: argparse.Namespace) -> AntiShortcutSkill:
|
|
25
|
+
return AntiShortcutSkill(
|
|
26
|
+
args.workspace,
|
|
27
|
+
config=args.config,
|
|
28
|
+
user_request=getattr(args, "user_request", "") or "",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
33
|
+
skill = _build_skill(args)
|
|
34
|
+
tr = skill.state.get_evidence("last_test_run") or {}
|
|
35
|
+
payload = {
|
|
36
|
+
"workspace": str(skill.workspace),
|
|
37
|
+
"current_stage": skill.current_stage,
|
|
38
|
+
"stage_name": skill.stage_name,
|
|
39
|
+
"completed_stages": skill.state.completed_stages,
|
|
40
|
+
"complete": skill.is_complete,
|
|
41
|
+
"last_test_run": (
|
|
42
|
+
{k: tr.get(k) for k in ("exit_code", "passed", "summary")} if tr else None
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
if args.json:
|
|
46
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
47
|
+
else:
|
|
48
|
+
print(f"workspace : {payload['workspace']}")
|
|
49
|
+
print(f"current_stage : {payload['current_stage']}({payload['stage_name']})")
|
|
50
|
+
print(f"completed_stages: {payload['completed_stages']}")
|
|
51
|
+
print(f"complete : {payload['complete']}")
|
|
52
|
+
if payload["last_test_run"]:
|
|
53
|
+
ltr = payload["last_test_run"]
|
|
54
|
+
print(
|
|
55
|
+
f"last_test_run : exit_code={ltr['exit_code']} "
|
|
56
|
+
f"passed={ltr['passed']} summary={ltr['summary']!r}"
|
|
57
|
+
)
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_advance(args: argparse.Namespace) -> int:
|
|
62
|
+
skill = _build_skill(args)
|
|
63
|
+
result = skill.advance_stage(args.to)
|
|
64
|
+
if args.json:
|
|
65
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
66
|
+
elif result["success"]:
|
|
67
|
+
print(f"OK: {result['message']}")
|
|
68
|
+
else:
|
|
69
|
+
print(f"REJECTED: {result['error']}")
|
|
70
|
+
return 0 if result["success"] else 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="python -m anti_shortcut",
|
|
76
|
+
description="反捷径校验 Skill 命令行:状态检查与阶段推进",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
79
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
80
|
+
|
|
81
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
82
|
+
common.add_argument("--workspace", type=str, default=".", help="工作区根目录(默认当前目录)")
|
|
83
|
+
common.add_argument("--config", type=str, default=None, help="YAML 配置文件路径")
|
|
84
|
+
common.add_argument("--json", action="store_true", help="以 JSON 输出")
|
|
85
|
+
|
|
86
|
+
p_inspect = sub.add_parser("inspect", parents=[common], help="查看当前门禁状态")
|
|
87
|
+
p_inspect.set_defaults(func=_cmd_inspect)
|
|
88
|
+
|
|
89
|
+
p_advance = sub.add_parser("advance", parents=[common], help="推进阶段(校验当前阶段证据)")
|
|
90
|
+
p_advance.add_argument("--to", type=int, required=True, help="目标阶段(必须等于当前阶段 + 1)")
|
|
91
|
+
p_advance.add_argument("--user-request", type=str, default="", help="用户需求原文(首次初始化时记录)")
|
|
92
|
+
p_advance.set_defaults(func=_cmd_advance)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
try:
|
|
98
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
99
|
+
except (AttributeError, ValueError): # pragma: no cover
|
|
100
|
+
pass
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
return args.func(args)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
anti_shortcut/audit.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""审计日志:结构化 JSON 日志(优先 structlog,缺省回退 stdlib)。
|
|
2
|
+
|
|
3
|
+
记录内容:阶段状态变更、工具拦截事件、证据校验结果。
|
|
4
|
+
日志文件位于 ``<workspace>/.agent_gate/audit.log``,每个 Skill 实例使用独立
|
|
5
|
+
logger(按文件句柄),同一进程内的多个工作区互不串扰。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
_HAS_STRUCTLOG = True
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
_HAS_STRUCTLOG = False
|
|
19
|
+
|
|
20
|
+
LOGGER_NAME = "anti_shortcut.audit"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _JsonFallbackFormatter(logging.Formatter):
|
|
24
|
+
"""无 structlog 时的简易 JSON 格式化器。"""
|
|
25
|
+
|
|
26
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
27
|
+
import json
|
|
28
|
+
|
|
29
|
+
payload = {
|
|
30
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
|
31
|
+
"level": record.levelname,
|
|
32
|
+
"event": record.getMessage(),
|
|
33
|
+
}
|
|
34
|
+
for key, value in getattr(record, "payload", {}).items():
|
|
35
|
+
payload[key] = value
|
|
36
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _TeeLogger:
|
|
40
|
+
"""同时写入文件 logger 与控制台 logger 的简单组合。"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, *loggers) -> None:
|
|
43
|
+
self._loggers = loggers
|
|
44
|
+
|
|
45
|
+
def _emit(self, level: str, event, **kwargs):
|
|
46
|
+
for lg in self._loggers:
|
|
47
|
+
getattr(lg, level)(event, **kwargs)
|
|
48
|
+
|
|
49
|
+
def info(self, event, **kwargs):
|
|
50
|
+
self._emit("info", event, **kwargs)
|
|
51
|
+
|
|
52
|
+
def warning(self, event, **kwargs):
|
|
53
|
+
self._emit("warning", event, **kwargs)
|
|
54
|
+
|
|
55
|
+
def error(self, event, **kwargs):
|
|
56
|
+
self._emit("error", event, **kwargs)
|
|
57
|
+
|
|
58
|
+
def debug(self, event, **kwargs):
|
|
59
|
+
self._emit("debug", event, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_audit_logger(log_file: Path, *, console: bool = False, level: int = logging.INFO):
|
|
63
|
+
"""创建审计 logger(独立文件句柄,进程内可安全创建多个)。
|
|
64
|
+
|
|
65
|
+
:param log_file: 审计日志文件路径
|
|
66
|
+
:param console: 是否同时输出到控制台
|
|
67
|
+
"""
|
|
68
|
+
log_file = Path(log_file)
|
|
69
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
if _HAS_STRUCTLOG:
|
|
72
|
+
json_processors = [
|
|
73
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
74
|
+
structlog.processors.add_log_level,
|
|
75
|
+
structlog.processors.StackInfoRenderer(),
|
|
76
|
+
structlog.processors.format_exc_info,
|
|
77
|
+
structlog.processors.JSONRenderer(ensure_ascii=False),
|
|
78
|
+
]
|
|
79
|
+
file_logger = structlog.wrap_logger(
|
|
80
|
+
structlog.PrintLogger(file=open(log_file, "a", encoding="utf-8")),
|
|
81
|
+
processors=json_processors,
|
|
82
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
83
|
+
cache_logger_on_first_use=True,
|
|
84
|
+
)
|
|
85
|
+
if not console:
|
|
86
|
+
return file_logger
|
|
87
|
+
console_logger = structlog.wrap_logger(
|
|
88
|
+
structlog.PrintLogger(file=sys.stdout),
|
|
89
|
+
processors=[
|
|
90
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
91
|
+
structlog.processors.add_log_level,
|
|
92
|
+
structlog.processors.ConsoleRenderer(colors=False),
|
|
93
|
+
],
|
|
94
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
95
|
+
cache_logger_on_first_use=True,
|
|
96
|
+
)
|
|
97
|
+
return _TeeLogger(file_logger, console_logger)
|
|
98
|
+
|
|
99
|
+
# 回退:标准库 logging + JSON Formatter
|
|
100
|
+
std_logger = logging.getLogger(f"{LOGGER_NAME}:{log_file}")
|
|
101
|
+
std_logger.setLevel(level)
|
|
102
|
+
if not std_logger.handlers:
|
|
103
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
104
|
+
handler.setFormatter(_JsonFallbackFormatter())
|
|
105
|
+
std_logger.addHandler(handler)
|
|
106
|
+
if console:
|
|
107
|
+
std_logger.addHandler(logging.StreamHandler(sys.stdout))
|
|
108
|
+
std_logger.propagate = False
|
|
109
|
+
return std_logger
|
anti_shortcut/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""阶段门禁配置:Pydantic 模型 + YAML 加载。
|
|
2
|
+
|
|
3
|
+
配置允许项目自定义阶段划分、证据要求、文件模式、测试命令等,
|
|
4
|
+
从而在“严格门禁”与“灵活性”之间取得平衡(见方案第 5 章/第 10 章)。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
|
|
14
|
+
# 阶段名称(与方案第 5 章保持一致)
|
|
15
|
+
STAGES: dict[int, str] = {
|
|
16
|
+
0: "需求接收",
|
|
17
|
+
1: "Spec 设计",
|
|
18
|
+
2: "测试用例编写",
|
|
19
|
+
3: "实现代码",
|
|
20
|
+
4: "运行测试",
|
|
21
|
+
5: "修复与回归",
|
|
22
|
+
6: "交付",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DEFAULT_SPEC_SECTIONS = ["## 需求分析", "## 设计方案", "## 接口定义"]
|
|
26
|
+
|
|
27
|
+
# 默认测试命令正则(匹配命令前缀),可按语言扩展
|
|
28
|
+
DEFAULT_TEST_COMMANDS: list[str] = [
|
|
29
|
+
r"^\s*python3?\s+(-m\s+)?pytest\b",
|
|
30
|
+
r"^\s*pytest\b",
|
|
31
|
+
r"^\s*python3?\s+-m\s+unittest\b",
|
|
32
|
+
r"^\s*npm\s+test\b",
|
|
33
|
+
r"^\s*npx\s+(jest|vitest|mocha|playwright)\b",
|
|
34
|
+
r"^\s*(go|rust)\s+test\b",
|
|
35
|
+
r"^\s*cargo\s+test\b",
|
|
36
|
+
r"^\s*gradle\s+test\b",
|
|
37
|
+
r"^\s*mvn\s+test\b",
|
|
38
|
+
r"^\s*tox\b",
|
|
39
|
+
r"^\s*unittest\b",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# 默认测试文件匹配模式(fnmatch / Path.match 语义)
|
|
43
|
+
DEFAULT_TEST_FILE_PATTERNS: list[str] = [
|
|
44
|
+
"test_*.py",
|
|
45
|
+
"*_test.py",
|
|
46
|
+
"tests/**/test_*.py",
|
|
47
|
+
"tests/**/*_test.py",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
DEFAULT_SOURCE_FILE_PATTERNS: list[str] = ["*.py"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GateConfig(BaseModel):
|
|
54
|
+
"""反捷径校验 Skill 的可配置项。所有字段都有合理默认值,YAML 可部分覆盖。"""
|
|
55
|
+
|
|
56
|
+
workspace: Path = Path(".")
|
|
57
|
+
gate_dir_name: str = ".agent_gate"
|
|
58
|
+
state_file_name: str = "state.json"
|
|
59
|
+
audit_log_name: str = "audit.log"
|
|
60
|
+
# ---- 阶段 1:Spec ----
|
|
61
|
+
spec_file: str = "spec.md"
|
|
62
|
+
spec_sections: list[str] = Field(default_factory=lambda: list(DEFAULT_SPEC_SECTIONS))
|
|
63
|
+
spec_min_chars: int = 120
|
|
64
|
+
# ---- 阶段 2:测试用例 ----
|
|
65
|
+
test_file_patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_TEST_FILE_PATTERNS))
|
|
66
|
+
min_test_functions: int = 2
|
|
67
|
+
require_assert_per_test: bool = True
|
|
68
|
+
# ---- 阶段 3:实现 ----
|
|
69
|
+
source_file_patterns: list[str] = Field(default_factory=lambda: list(DEFAULT_SOURCE_FILE_PATTERNS))
|
|
70
|
+
require_implementation: bool = True
|
|
71
|
+
# ---- 阶段 4/5:测试运行 ----
|
|
72
|
+
test_commands: list[str] = Field(default_factory=lambda: list(DEFAULT_TEST_COMMANDS))
|
|
73
|
+
max_test_output_tail: int = 4000
|
|
74
|
+
# ---- 安全 ----
|
|
75
|
+
protect_gate_dir: bool = True
|
|
76
|
+
# 允许 Agent 直接写入“其他”类型文件(如 README.md、docs),默认不限
|
|
77
|
+
allow_other_files_any_stage: bool = True
|
|
78
|
+
|
|
79
|
+
@model_validator(mode="after")
|
|
80
|
+
def _expand_workspace(self) -> "GateConfig":
|
|
81
|
+
self.workspace = Path(self.workspace).expanduser().resolve()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_config(path: str | Path | dict[str, Any] | None = None) -> GateConfig:
|
|
86
|
+
"""从 YAML 文件或字典加载配置,缺失字段使用默认值。
|
|
87
|
+
|
|
88
|
+
- ``None``:纯默认配置
|
|
89
|
+
- ``dict``:字段覆盖
|
|
90
|
+
- ``Path``/``str``:读取 YAML 文件后覆盖
|
|
91
|
+
"""
|
|
92
|
+
if path is None:
|
|
93
|
+
data: dict[str, Any] = {}
|
|
94
|
+
elif isinstance(path, (str, Path)):
|
|
95
|
+
p = Path(path)
|
|
96
|
+
if not p.exists():
|
|
97
|
+
raise FileNotFoundError(f"配置文件不存在: {p}")
|
|
98
|
+
with p.open("r", encoding="utf-8") as fh:
|
|
99
|
+
data = yaml.safe_load(fh) or {}
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
raise ValueError(f"配置文件 {p} 顶层必须是映射(dict)")
|
|
102
|
+
elif isinstance(path, dict):
|
|
103
|
+
data = path
|
|
104
|
+
else:
|
|
105
|
+
raise TypeError(f"不支持的配置类型: {type(path)!r}")
|
|
106
|
+
return GateConfig(**data)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""集成层:插件加载与通用 Agent 工具注册表接入。
|
|
2
|
+
|
|
3
|
+
对接方式(对应方案“技术栈选型—集成与部署”):
|
|
4
|
+
|
|
5
|
+
1. **一键接入**:``bootstrap(agent_tools, workspace, ...)`` 创建 Skill、包装工具、
|
|
6
|
+
注入 ``advance_stage`` 并加载集成插件,适合 Alpha-SWE 等以“工具表 dict”暴露工具的 Agent。
|
|
7
|
+
2. **进程内插件**:``register_integration(name, installer)`` 注册,``installer(agent, skill)``
|
|
8
|
+
负责把包装后的工具装回 Agent;宿主进程启动时调用 ``load_plugins(agent, skill)``。
|
|
9
|
+
3. **入口点插件**:发布为独立包的插件可声明 ``anti_shortcut.integrations`` 入口点,
|
|
10
|
+
``load_plugins`` 自动发现并执行其 ``install``。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from .skill import AntiShortcutSkill
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ENTRY_POINT_GROUP",
|
|
22
|
+
"bootstrap",
|
|
23
|
+
"install_into",
|
|
24
|
+
"load_plugins",
|
|
25
|
+
"register_integration",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
ENTRY_POINT_GROUP = "anti_shortcut.integrations"
|
|
29
|
+
|
|
30
|
+
# 进程内集成注册表:name -> installer(agent, skill)
|
|
31
|
+
_registry: dict[str, Callable[[Any, AntiShortcutSkill], None]] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_integration(
|
|
35
|
+
name: str,
|
|
36
|
+
installer: Callable[[Any, AntiShortcutSkill], None],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""进程内注册一个集成插件。
|
|
39
|
+
|
|
40
|
+
:param name: 插件名(重复注册会覆盖)
|
|
41
|
+
:param installer: ``installer(agent, skill)``,负责把 ``skill`` 包装后的工具装回 ``agent``
|
|
42
|
+
"""
|
|
43
|
+
if not callable(installer):
|
|
44
|
+
raise TypeError("installer 必须可调用")
|
|
45
|
+
_registry[name] = installer
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_plugins(
|
|
49
|
+
agent: Any = None,
|
|
50
|
+
skill: AntiShortcutSkill | None = None,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""加载并执行所有集成插件(进程内注册表 + 已安装包的入口点)。
|
|
53
|
+
|
|
54
|
+
:param agent: 宿主 Agent 对象(原样传给 installer)
|
|
55
|
+
:param skill: 已创建的 Skill 实例
|
|
56
|
+
:return: 已加载的插件名列表
|
|
57
|
+
"""
|
|
58
|
+
loaded: list[str] = []
|
|
59
|
+
for name, installer in _registry.items():
|
|
60
|
+
installer(agent, skill)
|
|
61
|
+
loaded.append(name)
|
|
62
|
+
try:
|
|
63
|
+
entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
|
|
64
|
+
except (TypeError, importlib.metadata.PackageNotFoundError): # pragma: no cover
|
|
65
|
+
entry_points = ()
|
|
66
|
+
for ep in entry_points:
|
|
67
|
+
plugin = ep.load()
|
|
68
|
+
installer = plugin if callable(plugin) else getattr(plugin, "install", None)
|
|
69
|
+
if installer is None:
|
|
70
|
+
raise TypeError(
|
|
71
|
+
f"入口点插件 {ep.name!r} 必须整体可调用,或导出 install(agent, skill)"
|
|
72
|
+
)
|
|
73
|
+
installer(agent, skill)
|
|
74
|
+
loaded.append(ep.name)
|
|
75
|
+
return loaded
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def install_into(
|
|
79
|
+
agent_tools: dict[str, Callable],
|
|
80
|
+
skill: AntiShortcutSkill,
|
|
81
|
+
) -> dict[str, Callable]:
|
|
82
|
+
"""把 Skill 包装后的工具写回 Agent 工具表(等价于 ``skill.install``)。"""
|
|
83
|
+
return skill.install(agent_tools)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def bootstrap(
|
|
87
|
+
agent_tools: dict[str, Callable],
|
|
88
|
+
workspace: str | Path,
|
|
89
|
+
config: Any = None,
|
|
90
|
+
user_request: str = "",
|
|
91
|
+
*,
|
|
92
|
+
agent: Any = None,
|
|
93
|
+
load_integrations: bool = True,
|
|
94
|
+
) -> AntiShortcutSkill:
|
|
95
|
+
"""一步完成:创建 Skill -> 包装 Agent 工具 -> 加载集成插件。
|
|
96
|
+
|
|
97
|
+
:param agent_tools: Agent 的工具表,如 ``{"write_file": ..., "execute_command": ...}``
|
|
98
|
+
:param workspace: 工作区根目录
|
|
99
|
+
:param config: Skill 配置(YAML 路径 / dict / GateConfig / None)
|
|
100
|
+
:param user_request: 用户需求原文(阶段 0 证据)
|
|
101
|
+
:param agent: 宿主 Agent 对象(透传给集成插件)
|
|
102
|
+
:param load_integrations: 是否加载已注册的集成插件
|
|
103
|
+
"""
|
|
104
|
+
skill = AntiShortcutSkill(workspace, config=config, user_request=user_request)
|
|
105
|
+
install_into(agent_tools, skill)
|
|
106
|
+
if load_integrations:
|
|
107
|
+
load_plugins(agent=agent, skill=skill)
|
|
108
|
+
return skill
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""工具拦截辅助:命令分类、门禁目录保护、shell 写路径提取、测试输出摘要。
|
|
2
|
+
|
|
3
|
+
- 识别测试运行命令(可配置正则)
|
|
4
|
+
- 识别试图访问/篡改门禁目录的命令
|
|
5
|
+
- 从 shell 命令中提取“被写入的路径”(重定向、mv/cp 目标、sed -i、rm/touch 等),
|
|
6
|
+
从而让 ``execute_command`` 的写操作受到与 ``write_file`` 相同的阶段限制
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .config import GateConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------- 测试命令识别 ----------
|
|
19
|
+
|
|
20
|
+
def is_test_command(command: str, config: GateConfig) -> bool:
|
|
21
|
+
"""判断命令是否为测试运行命令(按 config.test_commands 正则匹配前缀)。"""
|
|
22
|
+
cmd = (command or "").strip()
|
|
23
|
+
if not cmd:
|
|
24
|
+
return False
|
|
25
|
+
for pattern in config.test_commands:
|
|
26
|
+
try:
|
|
27
|
+
if re.search(pattern, cmd, flags=re.IGNORECASE):
|
|
28
|
+
return True
|
|
29
|
+
except re.error:
|
|
30
|
+
continue
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------- 门禁目录保护 ----------
|
|
35
|
+
|
|
36
|
+
def touches_gate_dir(command: str, gate_dir: Path) -> bool:
|
|
37
|
+
"""判断命令是否可能访问或篡改门禁目录(.agent_gate)。"""
|
|
38
|
+
gate_dir = Path(gate_dir)
|
|
39
|
+
name = gate_dir.name
|
|
40
|
+
try:
|
|
41
|
+
tokens = shlex.split(command, posix=True)
|
|
42
|
+
except ValueError:
|
|
43
|
+
tokens = command.split()
|
|
44
|
+
for raw in tokens:
|
|
45
|
+
tok = raw.strip('"\'')
|
|
46
|
+
if not tok:
|
|
47
|
+
continue
|
|
48
|
+
if tok == name or tok == str(gate_dir):
|
|
49
|
+
return True
|
|
50
|
+
if tok.startswith(name + "/") or tok.startswith(name + "\\"):
|
|
51
|
+
return True
|
|
52
|
+
if tok.startswith(str(gate_dir) + "/") or tok.startswith(str(gate_dir) + "\\"):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------- shell 写路径提取 ----------
|
|
58
|
+
|
|
59
|
+
def _looks_like_sed_expr(token: str) -> bool:
|
|
60
|
+
"""粗略判断 token 是否为 sed 表达式(如 s/a/b/、/pattern/、1,5d)。"""
|
|
61
|
+
if "/" in token:
|
|
62
|
+
return True
|
|
63
|
+
return bool(re.match(r"^[0-9$!~,]*[sSdDcCpPaAiI]", token))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_written_paths(command: str) -> list[str]:
|
|
67
|
+
"""从 shell 命令中提取可能被写入的路径(启发式,用于阶段门禁检查)。"""
|
|
68
|
+
try:
|
|
69
|
+
tokens = shlex.split(command, posix=True)
|
|
70
|
+
except ValueError:
|
|
71
|
+
tokens = command.split()
|
|
72
|
+
paths: list[str] = []
|
|
73
|
+
n = len(tokens)
|
|
74
|
+
i = 0
|
|
75
|
+
while i < n:
|
|
76
|
+
tok = tokens[i]
|
|
77
|
+
if tok in (">", ">>", "2>", "2>>", "1>", "1>>"):
|
|
78
|
+
if i + 1 < n:
|
|
79
|
+
nxt = tokens[i + 1]
|
|
80
|
+
if not nxt.startswith("&"):
|
|
81
|
+
paths.append(nxt)
|
|
82
|
+
elif tok in ("mv", "cp", "install", "rename"):
|
|
83
|
+
dest = [t for t in tokens[i + 1:] if not t.startswith("-")]
|
|
84
|
+
if dest:
|
|
85
|
+
paths.append(dest[-1])
|
|
86
|
+
elif tok == "sed":
|
|
87
|
+
j = i + 1
|
|
88
|
+
if j < n and tokens[j].startswith("-i"):
|
|
89
|
+
j += 1 # 跳过 -i 或 -i.bak
|
|
90
|
+
while j < n and tokens[j].startswith("-"):
|
|
91
|
+
opt = tokens[j]
|
|
92
|
+
j += 1
|
|
93
|
+
if opt in ("-e", "--expression", "-f", "--file") and j < n:
|
|
94
|
+
j += 1
|
|
95
|
+
if j < n and _looks_like_sed_expr(tokens[j]):
|
|
96
|
+
j += 1 # 跳过 sed 表达式
|
|
97
|
+
for t in tokens[j:]:
|
|
98
|
+
if not t.startswith("-"):
|
|
99
|
+
paths.append(t)
|
|
100
|
+
elif tok in ("rm", "touch", "tee", "truncate", "unlink", "shred"):
|
|
101
|
+
for t in tokens[i + 1:]:
|
|
102
|
+
if t == "--" or t.startswith("-"):
|
|
103
|
+
continue
|
|
104
|
+
paths.append(t)
|
|
105
|
+
i += 1
|
|
106
|
+
return paths
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------- 测试输出摘要 ----------
|
|
110
|
+
|
|
111
|
+
def summarize_test_output(
|
|
112
|
+
output: str,
|
|
113
|
+
exit_code: int | None = None,
|
|
114
|
+
*,
|
|
115
|
+
max_tail: int = 4000,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""把测试命令输出规整为结构化记录:退出码、是否通过、摘要、输出尾部。"""
|
|
118
|
+
text = output or ""
|
|
119
|
+
if exit_code is None:
|
|
120
|
+
passed = bool(re.search(r"\b\d+ passed\b|tests? (passed|ok)\b|All tests? passed", text, re.IGNORECASE))
|
|
121
|
+
if re.search(r"\b\d+ failed\b|FAILED|ERRORS?\b", text, re.IGNORECASE):
|
|
122
|
+
passed = False
|
|
123
|
+
else:
|
|
124
|
+
passed = exit_code == 0
|
|
125
|
+
|
|
126
|
+
lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
|
|
127
|
+
tail = lines[-20:]
|
|
128
|
+
tail_text = "\n".join(tail)[-max_tail:]
|
|
129
|
+
|
|
130
|
+
# 尝试提取 pytest / unittest 风格摘要行
|
|
131
|
+
summary = ""
|
|
132
|
+
for ln in reversed(tail):
|
|
133
|
+
if re.search(r"\b\d+ (passed|failed|error|skipped|warning)\b|tests? (passed|ok|failed)\b|OK|FAILED", ln, re.IGNORECASE):
|
|
134
|
+
summary = ln.strip()
|
|
135
|
+
break
|
|
136
|
+
if not summary and tail:
|
|
137
|
+
summary = tail[-1][:300]
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"exit_code": exit_code,
|
|
141
|
+
"passed": passed,
|
|
142
|
+
"summary": summary,
|
|
143
|
+
"output_tail": tail_text,
|
|
144
|
+
}
|