From 5b9a7ff8d4d6f14cac68b94e60a24e588302d266 Mon Sep 17 00:00:00 2001 From: chengshuyi Date: Mon, 11 May 2026 19:26:44 +0800 Subject: [PATCH 01/23] fix(sight): mask payload_len for BPF verifier on older kernels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BPF verifier rejects trace_write_enter because it cannot prove payload_len (R2) is non-negative after 64→32 bit truncation. Add a bitmask `& (MAX_STDOUT_PAYLOAD - 1)` before bpf_probe_read_user to satisfy the verifier's range check. Signed-off-by: chengshuyi --- src/agentsight/src/bpf/proctrace.bpf.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/agentsight/src/bpf/proctrace.bpf.c b/src/agentsight/src/bpf/proctrace.bpf.c index 78325c706..d4cd9aed1 100644 --- a/src/agentsight/src/bpf/proctrace.bpf.c +++ b/src/agentsight/src/bpf/proctrace.bpf.c @@ -315,6 +315,8 @@ int trace_write_enter(struct syscall_trace_enter *ctx) // Copy variable-length payload u8 *payload_dst = (void *)(stdout_data + 1); + // Mask payload_len so BPF verifier can prove it's bounded and non-negative + payload_len &= (MAX_STDOUT_PAYLOAD - 1); int ret = bpf_probe_read_user(payload_dst, payload_len, buf); if (ret != 0) { // Read failed, submit with zero payload From 3329cbef5a104542cb759c36e0ff4969d7c6bd12 Mon Sep 17 00:00:00 2001 From: yizheng Date: Mon, 11 May 2026 16:38:33 +0800 Subject: [PATCH 02/23] ci(sec-core): add python code style check Signed-off-by: yizheng --- .github/workflows/ci.yaml | 27 ++++++++++++ src/agent-sec-core/DEVELOPMENT.md | 21 +++++++++- src/agent-sec-core/Makefile | 12 ++++++ .../agent-sec-cli/pyproject.toml | 42 ++++++++++++++++++- src/agent-sec-core/agent-sec-cli/uv.lock | 4 +- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 742be1953..7e2657590 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -280,6 +280,33 @@ jobs: fi echo "Code style check passed." + - name: Lint check (incremental) + if: github.event_name == 'pull_request' + run: | + cd src/agent-sec-core + uv run --project agent-sec-cli ruff check --config agent-sec-cli/pyproject.toml --output-format=concise . > ruff_report.txt || true + # Prefix paths to match git-diff repo-root-relative paths + sed -i 's|^\([^: ]*\.py\)|src/agent-sec-core/\1|' ruff_report.txt + cd "$GITHUB_WORKSPACE" + LINT_OUTPUT=$(diff-quality --violations=flake8 --fail-under=100 src/agent-sec-core/ruff_report.txt 2>&1) || true + rm -f src/agent-sec-core/ruff_report.txt + echo "$LINT_OUTPUT" + # Extract violation count from diff-quality output + if echo "$LINT_OUTPUT" | grep -q "Failure"; then + { + echo "### ⚠️ agent-sec-core Lint Warnings (incremental)" + echo "" + echo '以下为 PR 变更行中的 ruff lint 违规(不卡点,仅提示):' + echo "" + echo '```' + echo "$LINT_OUTPUT" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::Lint violations found in changed lines. See step summary for details." + else + echo "### ✅ agent-sec-core Lint Check Passed" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Run Python tests with coverage run: | cd src/agent-sec-core diff --git a/src/agent-sec-core/DEVELOPMENT.md b/src/agent-sec-core/DEVELOPMENT.md index 59872a1c4..5b3df92c6 100644 --- a/src/agent-sec-core/DEVELOPMENT.md +++ b/src/agent-sec-core/DEVELOPMENT.md @@ -61,9 +61,26 @@ - 空函数/抽象方法使用 `pass` 占位,不使用 `...`(Ellipsis) -## 9. 代码格式化 +## 9. 代码格式化与 Lint + +- **格式化**: 使用 [black](https://black.readthedocs.io/) + [isort](https://pycqa.github.io/isort/)(保持现有风格不变) +- **静态检查**: 使用 [ruff](https://docs.astral.sh/ruff/) 进行 lint(仅对增量代码卡点) ```bash # 从 agent-sec-core 目录 -make python-code-pretty +make python-code-pretty # 格式化(black + isort) +make python-lint # 全量 ruff lint 检查(不修改文件) ``` + +## 10. CI 检查项 + +CI 对 Python 代码执行以下检查: + +| 检查项 | 范围 | 失败行为 | +|--------|------|----------| +| black + isort 格式化 | 全量代码 | 存在未格式化代码则失败 | +| ruff lint(增量) | 仅 PR 变更行 | 不卡点,违规以 warning 形式显示在 CI Summary | +| `pytest --cov` | 全量测试 | 测试失败则失败 | +| `uv lock --check` | 依赖锁文件 | uv.lock 与 pyproject.toml 不同步则失败 | + +> **重要**: Lint 检查仅在 PR 触发时对增量代码卡点,不检查历史代码。 diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 3dae0442d..4f6259bcd 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -8,6 +8,18 @@ python-code-pretty: ## Format Python code using black and isort @uv run --project agent-sec-cli isort --profile black --skip-glob "*/backend-skill/templates/*" . @uv run --project agent-sec-cli black --force-exclude "backend-skill/templates/" . +.PHONY: python-lint +python-lint: ## Run ruff lint check on all Python code (no fix) + @echo "🔍 Running ruff lint check..." + @uv run --project agent-sec-cli ruff check --config agent-sec-cli/pyproject.toml . + +.PHONY: python-lint-ci +python-lint-ci: ## Run incremental ruff lint check (only changed lines vs main, non-blocking) + @echo "🔍 Running incremental ruff lint check (diff-quality)..." + @uv run --project agent-sec-cli ruff check --config agent-sec-cli/pyproject.toml --output-format=concise . > ruff_report.txt || true + @diff-quality --violations=flake8 ruff_report.txt || true + @rm -f ruff_report.txt + # ============================================================================= # BENCHMARK # ============================================================================= diff --git a/src/agent-sec-core/agent-sec-cli/pyproject.toml b/src/agent-sec-core/agent-sec-cli/pyproject.toml index fcdd72062..59d3266be 100644 --- a/src/agent-sec-core/agent-sec-cli/pyproject.toml +++ b/src/agent-sec-core/agent-sec-cli/pyproject.toml @@ -50,7 +50,7 @@ dev = [ "maturin>=1.0,<2.0", "pytest>=7.0", "pytest-cov", - "ruff", + "ruff>=0.11", ] [project.scripts] @@ -102,6 +102,46 @@ exclude_lines = [ [tool.coverage.xml] output = "coverage.xml" +[tool.ruff] +target-version = "py311" +line-length = 100 +src = ["src", "tests"] +extend-exclude = ["**/backend-skill/templates"] + +[tool.ruff.lint] +select = [ + "F", # pyflakes + "E", "W", # pycodestyle + "I", # isort + "TID252", # 禁止相对导入 + "PLC0415", # 禁止函数体内导入 + "ANN001", # 函数参数必须标注类型 + "ANN201", # 公有函数必须标注返回类型 + "ANN202", # 私有函数必须标注返回类型 + "S602", # 禁止 subprocess shell=True + "S605", # 禁止 os.system() + "S606", # 禁止 os.popen() + "S108", # 禁止硬编码 /tmp 路径 + "PLW1510", # subprocess.run() 必须指定 check + "SIM115", # open() 必须使用 with + "B006", # 禁止可变默认参数 + "B008", # 禁止默认参数中调用函数 +] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["agent_sec_cli"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"importlib.import_module".msg = "禁止动态导入(若为 lazy import 请加 # noqa: TID251)" +"builtins.__import__".msg = "禁止动态导入(若为 lazy import 请加 # noqa: TID251)" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["ANN", "S"] + [tool.black] line-length = 100 target-version = ["py311"] diff --git a/src/agent-sec-core/agent-sec-cli/uv.lock b/src/agent-sec-core/agent-sec-cli/uv.lock index d6cd81829..8795c447c 100644 --- a/src/agent-sec-core/agent-sec-cli/uv.lock +++ b/src/agent-sec-core/agent-sec-cli/uv.lock @@ -58,7 +58,7 @@ dev = [ { name = "maturin", specifier = ">=1.0,<2.0" }, { name = "pytest", specifier = ">=7.0" }, { name = "pytest-cov" }, - { name = "ruff" }, + { name = "ruff", specifier = ">=0.11" }, ] [[package]] @@ -289,7 +289,9 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082" }, { url = "https://mirrors.aliyun.com/pypi/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3" }, { url = "https://mirrors.aliyun.com/pypi/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564" }, { url = "https://mirrors.aliyun.com/pypi/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc" }, { url = "https://mirrors.aliyun.com/pypi/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b" }, { url = "https://mirrors.aliyun.com/pypi/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4" }, { url = "https://mirrors.aliyun.com/pypi/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8" }, From 820a0b69c77883d425ec109298b6388b7e69e56f Mon Sep 17 00:00:00 2001 From: yizheng Date: Mon, 11 May 2026 18:32:11 +0800 Subject: [PATCH 03/23] chore(sec-core): add AGENTS.md for sec-core Signed-off-by: yizheng --- src/agent-sec-core/AGENTS.md | 282 +++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/agent-sec-core/AGENTS.md diff --git a/src/agent-sec-core/AGENTS.md b/src/agent-sec-core/AGENTS.md new file mode 100644 index 000000000..0ab123e4d --- /dev/null +++ b/src/agent-sec-core/AGENTS.md @@ -0,0 +1,282 @@ +# agent-sec-core Development Standards + +本仓库包含多个组件,请根据你要修改的模块查阅对应章节: + +| 组件 | 语言 | 路径 | 章节 | +|------|------|------|------| +| agent-sec-cli | Python + Rust | agent-sec-cli/ | [agent-sec-cli](#agent-sec-cli) | +| cosh-extension | Python (hooks) | cosh-extension/ | [cosh-extension](#cosh-extension) | +| openclaw-plugin | TypeScript | openclaw-plugin/ | [openclaw-plugin](#openclaw-plugin) | +| linux-sandbox | Rust | linux-sandbox/ | [linux-sandbox](#linux-sandbox) | +| skills | Shell/Python | skills/ | [skills](#skills) | + +--- + +## agent-sec-cli + +### 1. 项目概述 + +agent-sec-cli 是面向 AI Agent 的安全 CLI 工具,提供系统加固、沙箱策略生成、资产完整性验证、代码安全扫描、提示词安全检测和安全事件追踪等功能。 + +**关键目录结构:** + +``` +agent-sec-cli/ +├── src/agent_sec_cli/ # 主 Python 包 +│ ├── cli.py # 统一 CLI 入口 +│ ├── asset_verify/ # 资产完整性验证(GPG 签名) +│ ├── code_scanner/ # 代码安全扫描 +│ ├── prompt_scanner/ # 提示词安全检测(ML 分类器) +│ ├── sandbox/ # 沙箱策略生成 +│ ├── security_events/ # 安全事件日志 +│ ├── security_middleware/ # 统一中间件层(路由+后端) +│ └── skill_ledger/ # 技能账本管理 +├── src/lib.rs # Rust 原生模块入口(PyO3) +├── pyproject.toml # 构建配置 + lint/格式化配置 +├── Cargo.toml # Rust 依赖 +└── uv.lock # 依赖锁定文件 +tests/ # 测试目录(位于 agent-sec-core/ 下) +├── unit-test/ # 单元测试 +├── integration-test/ # 集成测试 +└── e2e/ # 端到端测试 +``` + +### 2. 环境准备 + +- **Python 版本**: 严格固定 `3.11.6`(`pyproject.toml` 中 `requires-python = "==3.11.6"`) +- **包管理器**: [uv](https://docs.astral.sh/uv/),管理依赖和虚拟环境 +- **Rust 构建**: [maturin](https://www.maturin.rs/),编译 PyO3 原生扩展为 `.so` +- **初始化环境**: + +```bash +cd agent-sec-cli && uv sync +``` + +> uv 会自动创建 `.venv` 并安装所有依赖(含 dev group)。 + +### 3. 依赖管理 + +| 场景 | 命令 | 说明 | +|------|------|------| +| 安装所有依赖(含 dev) | `uv sync` | 自动创建 .venv 并安装 | +| 仅安装运行时依赖 | `uv sync --no-group dev` | 生产环境用 | +| 添加运行时依赖 | `uv add ` | 自动更新 pyproject.toml 和 uv.lock | +| 添加 dev 依赖 | `uv add --group dev ` | 写入 [dependency-groups].dev | +| 添加可选依赖 | `uv add --optional ` | 写入 [project.optional-dependencies],如 `uv add --optional pgpy pgpy` | +| 删除依赖 | `uv remove ` | 同时清理 pyproject.toml 和 uv.lock | +| 更新单个依赖 | `uv lock --upgrade-package ` | 仅升级指定包 | +| 更新所有依赖 | `uv lock --upgrade` | 重新解析所有版本 | +| 运行命令 | `uv run ` | 在 .venv 环境中执行 | +| 运行测试 | `make test-python` | 从 agent-sec-core 目录执行 | +| 构建 wheel | `make build-cli` | maturin + Python 3.11 | + +> **重要**: 修改依赖后务必提交更新后的 `pyproject.toml` 和 `uv.lock`。 + +### 4. 代码格式化 + +使用 **black + isort** 进行代码格式化(配置在 `agent-sec-cli/pyproject.toml`): + +- `line-length = 100` +- `target-version = py311` +- `isort` profile = "black" + +```bash +# 从 agent-sec-core 目录执行 +make python-code-pretty +``` + +> 格式化排除 `dev-tools/backend-skill/templates/` 目录(含 Jinja 模板)。 + +### 5. 静态检查 (ruff lint) + +使用 [ruff](https://docs.astral.sh/ruff/) 进行静态检查(仅 lint,不做格式化)。 + +**启用规则:** + +| 规则 | 说明 | +|------|------| +| F | pyflakes — 未使用 import、未定义变量等逻辑错误 | +| E, W | pycodestyle — PEP 8 编码风格(E501 行超长已 ignore) | +| I | isort — import 排序 | +| TID252 | 禁止相对导入 | +| PLC0415 | 禁止函数体内导入 | +| ANN001 | 函数参数必须标注类型 | +| ANN201 | 公有函数必须标注返回类型 | +| ANN202 | 私有函数必须标注返回类型 | +| S602 | 禁止 subprocess shell=True | +| S605 | 禁止 os.system() | +| S606 | 禁止 os.popen() | +| S108 | 禁止硬编码 /tmp 路径 | +| PLW1510 | subprocess.run() 必须指定 check | +| SIM115 | open() 必须使用 with | +| B006 | 禁止可变默认参数 | +| B008 | 禁止默认参数中调用函数 | + +**已禁用规则:** + +| 规则 | 原因 | +|------|------| +| PTH (pathlib 强制) | 存量代码中 os.path 使用过多,暂不启用,待后续逐步治理 | +| E501 (行超长) | 由格式化工具自动处理 | + +**豁免规则:** + +| 作用范围 | 豁免规则 | 原因 | +|----------|----------|------| +| `tests/**` | ANN(类型注解) | 测试代码标注类型收益低 | +| `tests/**` | S(安全规则) | 测试需构造危险输入验证防护逻辑 | +| ML lazy import 行 | PLC0415 | torch/transformers 等重型依赖延迟加载,用 `# noqa: PLC0415` 豁免 | + +**命令:** + +```bash +# 全量检查(从 agent-sec-core 目录) +make python-lint + +# 注意: ruff 需显式指定配置文件 +# ruff check --config agent-sec-cli/pyproject.toml . +``` + +### 6. 导入规范 + +- **绝对导入**: 所有 import 使用绝对路径 `from agent_sec_cli.xxx import yyy` +- **禁止相对导入**: `from .xxx import` 或 `from ..xxx import` 一律禁止 +- **禁止动态导入**: `importlib.import_module()` 和 `__import__()` 禁止使用 +- **禁止函数体内导入**: 所有 import 必须在文件头部 + +**例外 — ML 延迟加载:** 对于重型 ML 依赖(torch、transformers、modelscope),允许在实际推理时才导入,需添加行内注释: + +```python +def predict(self, text: str) -> float: + import torch # noqa: PLC0415 - lazy import: only needed when running ML inference + from transformers import AutoModel # noqa: PLC0415 + ... +``` + +### 7. 类型注解 + +- 所有函数/方法必须标注**参数类型**和**返回类型** +- 使用 Python 3.11 原生语法:`dict[str, Any]`、`str | None`、`list[int]` +- 无需 `from __future__ import annotations` +- `tests/` 目录下所有文件豁免类型注解要求 + +```python +# 正确 +def process(name: str, count: int, items: list[str]) -> dict[str, Any]: + ... + +# 错误 — 缺少类型标注 +def process(name, count, items): + ... +``` + +### 8. 编码风格 + +**通用规范:** + +- 空函数/抽象方法使用 `pass` 占位,不使用 `...`(Ellipsis) +- 数据类优先使用 `pydantic` +- 路径操作优先使用 `pathlib.Path`,而非 `os.path` +- 禁止使用可变对象(`[]`、`{}`、`set()`)作为函数默认参数(B006) +- 禁止在默认参数中调用函数(B008),如 `def f(x=time.time())` 是错误写法 + +**Import 规范:** + +- import 排序由 isort 自动管理(I) +- 禁止相对导入(TID252):使用 `from agent_sec_cli.xxx import yyy` +- 禁止函数体内导入(PLC0415):所有 import 放在文件顶部 + +**类型标注:** + +- 函数参数必须标注类型(ANN001) +- 公有函数必须标注返回类型(ANN201) +- 私有函数必须标注返回类型(ANN202) + +**安全规范:** + +- 禁止 `subprocess` 使用 `shell=True`(S602) +- 禁止使用 `os.system()`(S605) +- 禁止使用 `os.popen()`(S606) +- 禁止硬编码 `/tmp` 路径(S108),应使用 `tempfile` 模块 +- `subprocess.run()` 必须显式指定 `check` 参数(PLW1510) +- `open()` 必须使用 `with` 上下文管理器(SIM115) + +### 9. 测试 + +- **框架**: pytest +- **测试目录结构**: + - `tests/unit-test/` — 单元测试 + - `tests/integration-test/` — 集成测试 + - `tests/e2e/` — 端到端测试 +- **测试文件放置**: 统一放在 `tests/` 目录下,不放入 `agent-sec-cli/` 内部 +- **e2e 测试要求**: 必须同时支持两种调用方式: + 1. **二进制 CLI 调用**(subprocess):`subprocess.run(["agent-sec-cli", "scan-code", "--code", code, "--language", "bash"], ...)` + 2. **Python 模块回退**:`subprocess.run(["python", "-m", "agent_sec_cli.cli", "scan-code", ...], ...)` + + 两种方式均以字符串数组传参(不经 shell 解析),保障参数完整性。 + +**常用命令(从 agent-sec-core 目录执行):** + +```bash +make test-python # 运行单元 + 集成 + CLI e2e 测试 +make test-python-coverage # 运行测试并生成覆盖率报告 +``` + +### 10. 构建 + +```bash +make build-cli # 构建 wheel(maturin + Python 3.11) +make export-requirements # 从 uv.lock 导出 requirements.txt +``` + +- Rust 原生扩展通过 PyO3 编译为 `_native.cpython-311-*.so`,随 wheel 分发 +- 构建产物位于 `agent-sec-cli/target/wheels/` +- **非 .py 文件打包**: 新增的非 Python 文件(如 `.yaml`、`.conf`、`.asc`、`.json` 等)如果需要随 wheel 分发,必须在 `pyproject.toml` 的 `[tool.maturin].include` 中添加对应路径: + +```toml +[tool.maturin] +include = [ + "src/agent_sec_cli/asset_verify/config.conf", + "src/agent_sec_cli/asset_verify/trusted-keys/*.asc", + "src/agent_sec_cli/code_scanner/rules/**/*.yaml", + "src/agent_sec_cli/prompt_scanner/rules/*.yaml", + # 新增资源文件在此添加 +] +``` + +### 11. CI 检查项 + +| 检查项 | 范围 | 失败行为 | +|--------|------|----------| +| black + isort 格式化 | 全量代码 | 存在未格式化代码则 CI 失败 | +| ruff lint(增量) | 仅 PR 变更行 | **不卡点**,违规以 warning 显示在 CI Summary | +| pytest --cov | 全量测试 | 测试失败则 CI 失败 | +| 增量代码覆盖率 | 仅 PR 变更行 | 新增/修改代码覆盖率 < 80% 则 CI 失败 | +| uv lock --check | 依赖锁文件 | uv.lock 与 pyproject.toml 不同步则 CI 失败 | + +> Lint 检查仅在 PR 触发时对增量代码检查,不检查历史代码。违规信息显示在 PR 的 Job Summary 区域。 +> 增量覆盖率门禁仅在 PR 触发,要求本次 PR 新增/修改的代码行中被测试覆盖的比例 ≥ 80%。 + +--- + +## cosh-extension + +> TODO: 待补充 + +--- + +## openclaw-plugin + +> TODO: 待补充 + +--- + +## linux-sandbox + +> TODO: 待补充 + +--- + +## skills + +> TODO: 待补充 From 45dcd09da568648d02005968e6fb18f9d8ac318f Mon Sep 17 00:00:00 2001 From: yizheng Date: Tue, 12 May 2026 10:10:46 +0800 Subject: [PATCH 04/23] fix(sec-core): fix reviewer comments Signed-off-by: yizheng --- src/agent-sec-core/AGENTS.md | 9 +++++++-- src/agent-sec-core/DEVELOPMENT.md | 4 ++-- src/agent-sec-core/Makefile | 5 ++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/agent-sec-core/AGENTS.md b/src/agent-sec-core/AGENTS.md index 0ab123e4d..7c0a4bc5e 100644 --- a/src/agent-sec-core/AGENTS.md +++ b/src/agent-sec-core/AGENTS.md @@ -133,10 +133,15 @@ make python-code-pretty # 全量检查(从 agent-sec-core 目录) make python-lint -# 注意: ruff 需显式指定配置文件 -# ruff check --config agent-sec-cli/pyproject.toml . +# 增量检查(仅报告相对 upstream/main 变更行的违规,含未提交修改) +make python-lint-ci + +# 自定义对比分支 +make python-lint-ci COMPARE_BRANCH=origin/main ``` +> `python-lint-ci` 对比范围包含 committed + staged + unstaged 变更,无需先 commit。 + ### 6. 导入规范 - **绝对导入**: 所有 import 使用绝对路径 `from agent_sec_cli.xxx import yyy` diff --git a/src/agent-sec-core/DEVELOPMENT.md b/src/agent-sec-core/DEVELOPMENT.md index 5b3df92c6..48bfb143c 100644 --- a/src/agent-sec-core/DEVELOPMENT.md +++ b/src/agent-sec-core/DEVELOPMENT.md @@ -64,7 +64,7 @@ ## 9. 代码格式化与 Lint - **格式化**: 使用 [black](https://black.readthedocs.io/) + [isort](https://pycqa.github.io/isort/)(保持现有风格不变) -- **静态检查**: 使用 [ruff](https://docs.astral.sh/ruff/) 进行 lint(仅对增量代码卡点) +- **静态检查**: 使用 [ruff](https://docs.astral.sh/ruff/) 进行 lint(仅对增量代码 warning,不卡点) ```bash # 从 agent-sec-core 目录 @@ -83,4 +83,4 @@ CI 对 Python 代码执行以下检查: | `pytest --cov` | 全量测试 | 测试失败则失败 | | `uv lock --check` | 依赖锁文件 | uv.lock 与 pyproject.toml 不同步则失败 | -> **重要**: Lint 检查仅在 PR 触发时对增量代码卡点,不检查历史代码。 +> **重要**: Lint 检查仅在 PR 触发时对增量代码报 warning,不检查历史代码,不卡点。 diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 4f6259bcd..942b1bddc 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -13,11 +13,14 @@ python-lint: ## Run ruff lint check on all Python code (no fix) @echo "🔍 Running ruff lint check..." @uv run --project agent-sec-cli ruff check --config agent-sec-cli/pyproject.toml . +COMPARE_BRANCH ?= upstream/main + .PHONY: python-lint-ci python-lint-ci: ## Run incremental ruff lint check (only changed lines vs main, non-blocking) @echo "🔍 Running incremental ruff lint check (diff-quality)..." @uv run --project agent-sec-cli ruff check --config agent-sec-cli/pyproject.toml --output-format=concise . > ruff_report.txt || true - @diff-quality --violations=flake8 ruff_report.txt || true + @sed -i '' 's|^\([^: ]*\.py\)|src/agent-sec-core/\1|' ruff_report.txt + @cd "$(shell git rev-parse --show-toplevel)" && diff-quality --violations=flake8 --compare-branch=$(COMPARE_BRANCH) src/agent-sec-core/ruff_report.txt || true @rm -f ruff_report.txt # ============================================================================= From 21d1b7a748af2932c6ad561e1646cd8806fc45a6 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Mon, 11 May 2026 11:02:25 +0800 Subject: [PATCH 05/23] fix(sec-core): detect unsigned skill files --- .../agent_sec_cli/asset_verify/__init__.py | 2 + .../src/agent_sec_cli/asset_verify/errors.py | 9 ++++ .../agent_sec_cli/asset_verify/verifier.py | 51 +++++++++++++++++-- .../tests/e2e/skill-signing/e2e_test.py | 35 ++++++++++++- .../asset-verify/test_verifier.py | 34 +++++++++++++ src/agent-sec-core/tools/SIGNING_GUIDE.md | 1 + src/agent-sec-core/tools/SIGNING_GUIDE_CN.md | 1 + 7 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/__init__.py b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/__init__.py index 780a98e37..aad1366e3 100644 --- a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/__init__.py +++ b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/__init__.py @@ -7,6 +7,7 @@ ErrNoTrustedKeys, ErrSigInvalid, ErrSigMissing, + ErrUnexpectedFile, ) from agent_sec_cli.asset_verify.verifier import ( compute_file_hash, @@ -25,6 +26,7 @@ "ErrNoTrustedKeys", "ErrSigInvalid", "ErrSigMissing", + "ErrUnexpectedFile", "compute_file_hash", "load_config", "load_trusted_keys", diff --git a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/errors.py b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/errors.py index 3da745b45..6cd4cfa6d 100644 --- a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/errors.py +++ b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/errors.py @@ -47,6 +47,15 @@ def __init__( ) +class ErrUnexpectedFile(SkillVerifyError): + code = 14 + + def __init__(self, skill_name: str, file_path: str) -> None: + super().__init__( + f"ERR_UNEXPECTED_FILE: Unsigned file '{file_path}' present in '{skill_name}'" + ) + + class ErrConfigMissing(SkillVerifyError): code = 20 diff --git a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/verifier.py b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/verifier.py index eb6fa4a1c..918d24bbf 100644 --- a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/verifier.py +++ b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/verifier.py @@ -6,6 +6,7 @@ import json import os import shutil +import stat import subprocess import sys from pathlib import Path @@ -22,6 +23,7 @@ ErrNoTrustedKeys, ErrSigInvalid, ErrSigMissing, + ErrUnexpectedFile, ) SCRIPT_DIR = Path(__file__).parent.resolve() @@ -101,6 +103,43 @@ def compute_file_hash(file_path: str) -> str: return sha256.hexdigest() +def _is_hidden_manifest_path(rel_path: str) -> bool: + """Return True if a manifest-relative path contains a hidden component.""" + return any(part.startswith(".") for part in Path(rel_path).parts) + + +def collect_signed_file_paths(skill_dir: str) -> set[str]: + """Collect files that should be covered by a skill manifest. + + This mirrors ``sign-skill.sh``: regular files are included recursively, while + hidden files and files in hidden directories such as ``.skill-meta`` are + ignored. + """ + signed_paths: set[str] = set() + root = Path(skill_dir) + + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [name for name in dirnames if not name.startswith(".")] + + for filename in filenames: + if filename.startswith("."): + continue + + file_path = Path(dirpath) / filename + try: + if not stat.S_ISREG(os.lstat(file_path).st_mode): + continue + except OSError: + continue + + rel_path = file_path.relative_to(root).as_posix() + if _is_hidden_manifest_path(rel_path): + continue + signed_paths.add(rel_path) + + return signed_paths + + def verify_signature_gpg( manifest_path: str, sig_path: str, key_files: list, skill_name: str ) -> bool: @@ -185,10 +224,13 @@ def verify_signature( def verify_manifest_hashes(skill_dir: str, manifest: dict, skill_name: str) -> None: - """Verify all file hashes in manifest""" + """Verify manifest hashes and reject unsigned files.""" + manifest_paths: set[str] = set() + for file_entry in manifest.get("files", []): rel_path = file_entry["path"] expected_hash = file_entry["hash"] + manifest_paths.add(rel_path) full_path = os.path.join(skill_dir, rel_path) if not os.path.exists(full_path): @@ -198,6 +240,9 @@ def verify_manifest_hashes(skill_dir: str, manifest: dict, skill_name: str) -> N if actual_hash != expected_hash: raise ErrHashMismatch(skill_name, rel_path, expected_hash, actual_hash) + for rel_path in sorted(collect_signed_file_paths(skill_dir) - manifest_paths): + raise ErrUnexpectedFile(skill_name, rel_path) + def verify_skill(skill_dir: str, trusted_keys: list) -> tuple[bool, str]: """Verify a single skill directory""" @@ -298,10 +343,10 @@ def main() -> int: print(f"[ERROR] {item['name']}") print(f" {item['error']}") - print(f"\n{'='*50}") + print(f"\n{'=' * 50}") print(f"PASSED: {len(results['passed'])}") print(f"FAILED: {len(results['failed'])}") - print(f"{'='*50}") + print(f"{'=' * 50}") if results["failed"]: print("VERIFICATION FAILED") diff --git a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py index 5a0d6908a..3cd60c0c5 100644 --- a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py +++ b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py @@ -35,7 +35,12 @@ # Make verifier importable sys.path.insert(0, str(VERIFIER_DIR)) -from errors import ErrHashMismatch, ErrSigInvalid, ErrSigMissing # noqa: E402 +from errors import ( # noqa: E402 + ErrHashMismatch, + ErrSigInvalid, + ErrSigMissing, + ErrUnexpectedFile, +) from verifier import load_trusted_keys, verify_skill # noqa: E402 # ── Colours ──────────────────────────────────────────────────────────────── @@ -320,6 +325,30 @@ def test_tampered_file_detected(ws: Workspace): pass # expected +def test_unsigned_reference_file_detected(ws: Workspace): + """Verifier detects new files added under references after signing.""" + skill = make_skill( + ws.skills_dir, + "skill-extra-file", + { + "SKILL.md": "# Skill\n", + "references/original.md": "signed\n", + }, + ) + r = run_sign_skill([str(skill), "--force"]) + assert r.returncode == 0 + + # Empty files are still unsigned payloads when they are absent from Manifest.json. + (skill / "references" / "a.md").write_text("") + + keys = load_trusted_keys(ws.trusted_keys) + try: + verify_skill(str(skill), keys) + assert False, "Expected ErrUnexpectedFile" + except ErrUnexpectedFile as exc: + assert "references/a.md" in str(exc) + + def test_missing_sig_detected(ws: Workspace): """Verifier raises ErrSigMissing when .skill.sig is deleted.""" skill = make_skill(ws.skills_dir, "skill-nosig", {"f.txt": "f"}) @@ -522,6 +551,10 @@ def main(): # Negative / security tests test("Tampered file detected", lambda: test_tampered_file_detected(ws)) + test( + "Unsigned reference file detected", + lambda: test_unsigned_reference_file_detected(ws), + ) test("Missing .skill.sig detected", lambda: test_missing_sig_detected(ws)) test("Wrong key rejected", lambda: test_wrong_key_rejected(ws)) diff --git a/src/agent-sec-core/tests/integration-test/asset-verify/test_verifier.py b/src/agent-sec-core/tests/integration-test/asset-verify/test_verifier.py index bc2fd81a4..c00ebbb51 100644 --- a/src/agent-sec-core/tests/integration-test/asset-verify/test_verifier.py +++ b/src/agent-sec-core/tests/integration-test/asset-verify/test_verifier.py @@ -25,6 +25,7 @@ ErrManifestMissing, ErrNoTrustedKeys, ErrSigMissing, + ErrUnexpectedFile, ) from agent_sec_cli.asset_verify.verifier import ( compute_file_hash, @@ -82,6 +83,39 @@ def test_missing_file(self): verify_manifest_hashes(self.tmpdir, manifest, "test_skill") self.assertIn("FILE_MISSING", str(ctx.exception)) + def test_unexpected_file(self): + extra_file = os.path.join(self.tmpdir, "references", "a.md") + os.makedirs(os.path.dirname(extra_file), exist_ok=True) + with open(extra_file, "w") as f: + f.write("") + + manifest = {"files": [{"path": "main.py", "hash": self.file_hash}]} + with self.assertRaises(ErrUnexpectedFile) as ctx: + verify_manifest_hashes(self.tmpdir, manifest, "test_skill") + self.assertIn("references/a.md", str(ctx.exception)) + + def test_unexpected_root_file(self): + extra_file = os.path.join(self.tmpdir, "a.md") + with open(extra_file, "w") as f: + f.write("") + + manifest = {"files": [{"path": "main.py", "hash": self.file_hash}]} + with self.assertRaises(ErrUnexpectedFile) as ctx: + verify_manifest_hashes(self.tmpdir, manifest, "test_skill") + self.assertIn("a.md", str(ctx.exception)) + + def test_hidden_files_are_ignored(self): + hidden_file = os.path.join(self.tmpdir, ".hidden.md") + hidden_dir_file = os.path.join(self.tmpdir, ".skill-meta", "Manifest.json") + os.makedirs(os.path.dirname(hidden_dir_file), exist_ok=True) + with open(hidden_file, "w") as f: + f.write("ignored") + with open(hidden_dir_file, "w") as f: + f.write("ignored") + + manifest = {"files": [{"path": "main.py", "hash": self.file_hash}]} + verify_manifest_hashes(self.tmpdir, manifest, "test_skill") + class TestVerifySkill(unittest.TestCase): def setUp(self): diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE.md b/src/agent-sec-core/tools/SIGNING_GUIDE.md index 755977fd3..a83f98663 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE.md @@ -197,6 +197,7 @@ agent-sec-cli verify | 11 | Missing `.skill-meta/Manifest.json` | Skill was never signed | | 12 | Invalid signature | Signed with a key not in `trusted-keys/` | | 13 | Hash mismatch | Skill files changed after signing | +| 14 | Unexpected file | Unsigned file added after signing | ## sign-skill.sh Command Reference diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md index af1605d84..99a585a5f 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md @@ -197,6 +197,7 @@ agent-sec-cli verify | 11 | 缺失 `.skill-meta/Manifest.json` | skill 从未签名 | | 12 | 签名无效 | 签名密钥不在 `trusted-keys/` 中 | | 13 | 哈希不匹配 | 签名后 skill 文件被修改 | +| 14 | 存在未签名文件 | 签名后新增了未写入 manifest 的文件 | ## sign-skill.sh 命令参考 From ba9d0cdf0cd6b5683202d117fd57a3419d16b7b2 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Tue, 12 May 2026 16:13:42 +0800 Subject: [PATCH 06/23] fix(sec-core): align skill signing paths --- src/agent-sec-core/README.md | 2 +- src/agent-sec-core/README_CN.md | 2 +- .../agent_sec_cli/asset_verify/config.conf | 3 +- src/agent-sec-core/tools/SIGNING_GUIDE.md | 33 ++++--- src/agent-sec-core/tools/SIGNING_GUIDE_CN.md | 31 +++--- src/agent-sec-core/tools/sign-skill.sh | 95 +++---------------- 6 files changed, 51 insertions(+), 115 deletions(-) diff --git a/src/agent-sec-core/README.md b/src/agent-sec-core/README.md index f7d2b0e89..134622d4d 100644 --- a/src/agent-sec-core/README.md +++ b/src/agent-sec-core/README.md @@ -208,7 +208,7 @@ Output example: ### Verification Flow -1. Load trusted public keys from `agent-sec-cli/asset-verify/trusted-keys/*.asc` +1. Load trusted public keys from `agent_sec_cli/asset_verify/trusted-keys/*.asc` 2. Verify the GPG signature (`.skill-meta/.skill.sig`) of `.skill-meta/Manifest.json` in each skill directory 3. Validate SHA-256 hashes of all files listed in the Manifest diff --git a/src/agent-sec-core/README_CN.md b/src/agent-sec-core/README_CN.md index a9ceb1a2a..18c0d4cc7 100644 --- a/src/agent-sec-core/README_CN.md +++ b/src/agent-sec-core/README_CN.md @@ -208,7 +208,7 @@ python3 agent-sec-cli/src/agent_sec_cli/sandbox/sandbox_policy.py --cwd "$PWD" " ### 校验流程 -1. 加载受信公钥(`agent-sec-cli/asset-verify/trusted-keys/*.asc`) +1. 加载受信公钥(`agent_sec_cli/asset_verify/trusted-keys/*.asc`) 2. 验证 Skill 目录中 `.skill-meta/Manifest.json` 的 GPG 签名(`.skill-meta/.skill.sig`) 3. 校验 Manifest 中所有文件的 SHA-256 哈希 diff --git a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf index 5367304da..585422eee 100644 --- a/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf +++ b/src/agent-sec-core/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf @@ -1,5 +1,6 @@ # Skill Verification Config -# trusted_keys_dir = /etc/agent-sec/trusted-keys +# Trusted keys are loaded from the packaged trusted-keys/ directory next to +# this file. # Skills directories to verify (one per line) skills_dir = [ diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE.md b/src/agent-sec-core/tools/SIGNING_GUIDE.md index a83f98663..959251830 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE.md @@ -25,18 +25,18 @@ tools/sign-skill.sh --check Three commands cover the entire workflow. Step 1 is a one-time setup; step 2 should be re-run whenever skill files change. ```bash -# 1. One-time setup — generate GPG key + export public key to trusted-keys +# 1. One-time setup — generate GPG key + export public key to verifier package data tools/sign-skill.sh --init -# 2. Batch-sign all deployed skills (default: ~/.copilot-shell/skills/) -tools/sign-skill.sh --batch --force +# 2. Batch-sign all skills in this source checkout +tools/sign-skill.sh --batch skills --force # 3. Verify agent-sec-cli verify ``` `--init` automatically generates a dedicated signing key (`ANOLISA Local Deploy Key`) and -exports the public key to `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/`. +exports the public key to `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`. You can override the export path with `--trusted-keys-dir `. ## Step-by-Step (Manual Key Management) @@ -65,8 +65,10 @@ gpg --list-secret-keys me@example.com ### 2. Export the Public Key -The verifier loads trusted public keys from `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/`. -`--init` exports there automatically. To re-export manually: +The verifier loads trusted public keys from the packaged `agent_sec_cli/asset_verify/trusted-keys/` +directory. When running from this source checkout, `--init` exports to +`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/` automatically. +To re-export manually: ```bash tools/sign-skill.sh --export-key @@ -82,7 +84,7 @@ Or fully manually: ```bash gpg --armor --export me@example.com \ - > ~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/me-example-com.asc + > agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/me-example-com.asc ``` ### 3. Sign Skills @@ -96,10 +98,10 @@ tools/sign-skill.sh /usr/share/anolisa/skills/my-skill --force Batch-sign all skills under a directory: ```bash -# Uses the default directory (~/.copilot-shell/skills/) -tools/sign-skill.sh --batch --force +# Source checkout example +tools/sign-skill.sh --batch skills --force -# Or specify a custom directory +# Custom or installed directory tools/sign-skill.sh --batch /usr/share/anolisa/skills --force ``` @@ -112,7 +114,10 @@ Each signed skill directory will contain: ### 4. Configure the Verifier -When using `--batch`, the script automatically registers the skills directory in `config.conf`. For manual setups, make sure the skills directory is listed in the deployed `config.conf` (e.g. `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/config.conf`): +`--batch` signs skill directories but does not edit verifier configuration. For +batch verification, make sure the skills root is listed in the verifier config +packaged with the CLI (`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf` +in this source tree): ```ini skills_dir = [ @@ -179,7 +184,7 @@ tools/sign-skill.sh --batch /path/to/skills --force Whenever skill files are modified, the existing `.skill-meta/Manifest.json` hashes become stale. Re-sign with `--force`: ```bash -tools/sign-skill.sh --batch --force +tools/sign-skill.sh --batch skills --force ``` Then verify: @@ -206,8 +211,8 @@ agent-sec-cli verify | **Init** | `--init [--trusted-keys-dir DIR]` | Generate GPG key + export public key | | **Check** | `--check` | Verify prerequisites (gpg, jq, sha256sum) | | **Single** | ` [--force]` | Sign one skill directory | -| **Batch** | `--batch [parent_dir] [--force]` | Sign all subdirectories under parent (default: `~/.copilot-shell/skills/`). Auto-registers the directory in `config.conf`. | -| **Export** | `--export-key [DIR]` | Export public key (default: `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/`) | +| **Batch** | `--batch [--force]` | Sign all subdirectories under parent. | +| **Export** | `--export-key [DIR]` | Export public key (default: `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`) | Common options: diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md index 99a585a5f..88c78f816 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md @@ -25,18 +25,18 @@ tools/sign-skill.sh --check 三条命令即可完成全部流程。步骤 1 每台机器只需执行一次;步骤 2 在 skill 文件变更后需重新执行。 ```bash -# 1. 一次性初始化 — 生成 GPG 密钥并导出公钥到 trusted-keys 目录 +# 1. 一次性初始化 — 生成 GPG 密钥并导出公钥到校验器包内数据目录 tools/sign-skill.sh --init -# 2. 批量签名所有已部署的 skill(默认:~/.copilot-shell/skills/) -tools/sign-skill.sh --batch --force +# 2. 批量签名当前源码树中的所有 skill +tools/sign-skill.sh --batch skills --force # 3. 验证 agent-sec-cli verify ``` `--init` 会自动生成专用签名密钥(`ANOLISA Local Deploy Key`),并将公钥导出到 -`~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/`。 +`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。 可通过 `--trusted-keys-dir ` 覆盖导出路径。 ## 手动逐步操作 @@ -65,8 +65,9 @@ gpg --list-secret-keys me@example.com ### 2. 导出公钥 -校验器从 `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/` 加载受信公钥, -`--init` 会自动导出到此目录。手动重新导出: +校验器从打包后的 `agent_sec_cli/asset_verify/trusted-keys/` 目录加载受信公钥。 +在当前源码树中运行时,`--init` 会自动导出到 +`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。手动重新导出: ```bash tools/sign-skill.sh --export-key @@ -82,7 +83,7 @@ tools/sign-skill.sh --export-key /custom/path/to/trusted-keys/ ```bash gpg --armor --export me@example.com \ - > ~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/me-example-com.asc + > agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/me-example-com.asc ``` ### 3. 签名 Skill @@ -96,10 +97,10 @@ tools/sign-skill.sh /usr/share/anolisa/skills/my-skill --force 批量签名目录下所有 skill: ```bash -# 使用默认目录(~/.copilot-shell/skills/) -tools/sign-skill.sh --batch --force +# 当前源码树示例 +tools/sign-skill.sh --batch skills --force -# 或指定自定义目录 +# 自定义目录 / 已安装目录 tools/sign-skill.sh --batch /usr/share/anolisa/skills --force ``` @@ -112,7 +113,9 @@ tools/sign-skill.sh --batch /usr/share/anolisa/skills --force ### 4. 配置校验器 -使用 `--batch` 时,脚本会自动将 skill 目录注册到 `config.conf` 中。如果手动配置,请确保 skill 目录已配置在已部署的 `config.conf` 中(如 `~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/config.conf`): +`--batch` 只负责签名 skill 目录,不会修改校验器配置。若要进行批量校验,请确保 +skill 根目录已配置在随 CLI 打包的校验器配置中(当前源码树中的路径为 +`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf`): ```ini skills_dir = [ @@ -179,7 +182,7 @@ tools/sign-skill.sh --batch /path/to/skills --force 每当 skill 文件被修改,已有的 `.skill-meta/Manifest.json` 哈希值将失效。使用 `--force` 重新签名: ```bash -tools/sign-skill.sh --batch --force +tools/sign-skill.sh --batch skills --force ``` 然后验证: @@ -206,8 +209,8 @@ agent-sec-cli verify | **初始化** | `--init [--trusted-keys-dir DIR]` | 生成 GPG 密钥 + 导出公钥 | | **检查** | `--check` | 检查前置依赖(gpg、jq、sha256sum) | | **单个签名** | ` [--force]` | 签名单个 skill 目录 | -| **批量签名** | `--batch [parent_dir] [--force]` | 签名目录下所有子目录(默认:`~/.copilot-shell/skills/`)。自动将目录注册到 `config.conf`。 | -| **导出公钥** | `--export-key [DIR]` | 导出公钥(默认:`~/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys/`) | +| **批量签名** | `--batch [--force]` | 签名目录下所有子目录。 | +| **导出公钥** | `--export-key [DIR]` | 导出公钥(默认:`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`) | 常用选项: diff --git a/src/agent-sec-core/tools/sign-skill.sh b/src/agent-sec-core/tools/sign-skill.sh index cc88c51b0..51ea3e548 100755 --- a/src/agent-sec-core/tools/sign-skill.sh +++ b/src/agent-sec-core/tools/sign-skill.sh @@ -9,7 +9,7 @@ # # Usage: # Single mode: ./sign-skill.sh [--skill-name NAME] [--force] -# Batch mode: ./sign-skill.sh --batch [parent_dir] [--force] +# Batch mode: ./sign-skill.sh --batch [--force] # Init mode: ./sign-skill.sh --init [--trusted-keys-dir DIR] # Export key: ./sign-skill.sh --export-key [DIR] # Check deps: ./sign-skill.sh --check @@ -51,17 +51,10 @@ SIGN_KEY_EMAIL="anolisa-deploy@$(hostname -s 2>/dev/null || echo localhost)" SIGN_KEY_NAME="ANOLISA Local Deploy Key" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT_SEC_CORE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# Default path for deployed skills -DEFAULT_SKILLS_DIR="$HOME/.copilot-shell/skills" - -# Default path for trusted public keys (verifier reads from here) -DEFAULT_TRUSTED_KEYS_DIR="$HOME/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/trusted-keys" - -# Path to the deployed verifier config (inside agent-sec-core skill). -# Batch mode auto-registers the signed directory here so that the verifier -# picks it up without manual config.conf editing. -DEPLOY_CONFIG_CONF="$HOME/.copilot-shell/skills/agent-sec-core/scripts/asset-verify/config.conf" +# Default path for trusted public keys in the verifier package data. +DEFAULT_TRUSTED_KEYS_DIR="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys" # Resolve gpg binary: prefer 'gpg', fall back to 'gpg2' (RHEL/Alinux minimal) if command -v gpg &>/dev/null; then @@ -175,76 +168,20 @@ sign_manifest() { return 0 } -# Ensure a skills directory is registered in the deployed config.conf. -# This must be called BEFORE signing agent-sec-core, because config.conf -# is part of that skill and will be included in its manifest hash. -ensure_config_dir_entry() { - local dir_to_add="$1" - local config_file="$DEPLOY_CONFIG_CONF" - - if [[ ! -f "$config_file" ]]; then - echo -e "${YELLOW}NOTE: config.conf not found at $config_file — skipping auto-register${NC}" - return 0 - fi - - # Already registered? Use awk to check only inside the skills_dir - # array for an exact (whitespace-trimmed) match, avoiding substring - # false positives from grep -F. - if awk -v target="$dir_to_add" ' - /skills_dir[[:space:]]*=/ { in_list=1; next } - in_list && /^[[:space:]]*\]/ { exit 1 } - in_list { - line=$0; gsub(/^[[:space:]]+|[[:space:],]+$/, "", line) - if (line == target) { found=1; exit 0 } - } - END { exit (found ? 0 : 1) } - ' "$config_file" 2>/dev/null; then - echo "Skills directory already in config.conf: $dir_to_add" - return 0 - fi - - # Preserve original file permissions across the temp-file swap. - local orig_mode - orig_mode=$(stat -c '%a' "$config_file" 2>/dev/null) \ - || orig_mode=$(stat -f '%Lp' "$config_file" 2>/dev/null) \ - || orig_mode="" - - # Insert entry before the first ']' (end of skills_dir array). - # The pattern tolerates an optionally-indented closing bracket. - local tmp_file - tmp_file=$(mktemp) - awk -v entry=" $dir_to_add" ' - /^[[:space:]]*\]/ && !done { print entry; done=1 } - { print } - ' "$config_file" > "$tmp_file" && mv "$tmp_file" "$config_file" - - # Restore original permissions if we captured them. - if [[ -n "$orig_mode" ]]; then - chmod "$orig_mode" "$config_file" 2>/dev/null - fi - - if grep -qF "$dir_to_add" "$config_file" 2>/dev/null; then - echo -e "${GREEN}Added skills directory to config.conf: $dir_to_add${NC}" - else - echo -e "${YELLOW}WARNING: Could not update config.conf — please add '$dir_to_add' manually${NC}" - fi -} - # Function to show usage show_usage() { echo -e "${BOLD}Skill Manifest and Signature Generator${NC}" echo "" echo "Usage:" echo " $0 [--skill-name NAME] [--force]" - echo " $0 --batch [parent_dir] [--force]" + echo " $0 --batch [--force]" echo " $0 --init [--trusted-keys-dir DIR]" echo " $0 --export-key [DIR]" echo " $0 --check" echo "" echo "Modes:" echo " (default) Sign a single skill directory" - echo " --batch [DIR] Sign every subdirectory under DIR" - echo " (default: $DEFAULT_SKILLS_DIR)" + echo " --batch DIR Sign every subdirectory under DIR" echo " --init One-time setup: generate GPG key + export public key" echo " --export-key [DIR] Export signing public key to DIR" echo " (default: $DEFAULT_TRUSTED_KEYS_DIR)" @@ -259,8 +196,8 @@ show_usage() { echo "" echo "Quick Start (self-deployment):" echo " $0 --init" - echo " $0 --batch --force" - echo " python3 /path/to/verifier.py" + echo " $0 --batch /path/to/skills --force" + echo " agent-sec-cli verify" echo "" echo "Environment Variables:" echo " GPG_PRIVATE_KEY ASCII-armored GPG private key (for CI/CD auto-import)" @@ -555,13 +492,13 @@ main() { ;; --batch) batch=true - # Directory is optional; default to DEFAULT_SKILLS_DIR if [[ -n "${2:-}" && "${2:0:1}" != "-" ]]; then batch_dir="$2" shift 2 else - batch_dir="" - shift + echo -e "${RED}ERROR: --batch requires a parent directory${NC}" >&2 + show_usage + exit 1 fi ;; --skill-name) @@ -614,22 +551,12 @@ main() { # ── Batch mode ── if [[ "$batch" == true ]]; then - # Default to the standard deployed skills directory - if [[ -z "$batch_dir" ]]; then - batch_dir="$DEFAULT_SKILLS_DIR" - fi - batch_dir=$(cd "$batch_dir" 2>/dev/null && pwd) || true if [[ ! -d "$batch_dir" ]]; then echo -e "${RED}ERROR: Batch directory does not exist: $batch_dir${NC}" >&2 exit 1 fi - # Register the skills directory in config.conf BEFORE signing. - # config.conf is part of the agent-sec-core skill, so it must be - # updated first to ensure its manifest hash is correct. - ensure_config_dir_entry "$batch_dir" - echo "Batch signing skills under: $batch_dir" echo "" From 5f07e728df650cf3ae697af312e74609c78d139d Mon Sep 17 00:00:00 2001 From: chengshuyi Date: Tue, 12 May 2026 15:18:28 +0800 Subject: [PATCH 07/23] feat(sight): add uid field to SLS logs with OnceLock cache and startup validation --- src/agentsight/src/genai/instance_id.rs | 68 ++++++++++++++++++++++--- src/agentsight/src/genai/logtail.rs | 10 +++- src/agentsight/src/unified.rs | 11 +++- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/agentsight/src/genai/instance_id.rs b/src/agentsight/src/genai/instance_id.rs index 92dd4ada8..6f87a1837 100644 --- a/src/agentsight/src/genai/instance_id.rs +++ b/src/agentsight/src/genai/instance_id.rs @@ -2,14 +2,70 @@ //! //! Provides a shared function to resolve the current machine's instance ID, //! used by both SLS PutLogs uploader and Logtail file exporter. +//! +//! Both `get_instance_id()` and `get_owner_account_id()` are cached via +//! `OnceLock` — the metadata HTTP call is only made once per process lifetime. + +use std::sync::OnceLock; +use std::time::Duration; + +/// ECS metadata 请求超时(连接 + 读取均为 1 秒) +const METADATA_TIMEOUT: Duration = Duration::from_secs(1); + +/// 全局缓存:owner-account-id +static OWNER_ACCOUNT_ID: OnceLock = OnceLock::new(); +/// 全局缓存:instance-id +static INSTANCE_ID: OnceLock = OnceLock::new(); + +/// 构建带有显式 connect timeout 的 ureq agent,避免非 ECS 环境 TCP SYN 重试卡死 +fn metadata_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(METADATA_TIMEOUT) + .timeout(METADATA_TIMEOUT) + .build() +} + +/// 获取 owner account ID(带缓存):首次调用请求阿里云 ECS metadata(超时1秒), +/// 失败返回空字符串。后续调用直接返回缓存值。 +pub fn get_owner_account_id() -> &'static str { + OWNER_ACCOUNT_ID.get_or_init(|| { + fetch_owner_account_id() + }) +} + +/// 实际请求 owner-account-id +fn fetch_owner_account_id() -> String { + let agent = metadata_agent(); + match agent.get("http://100.100.100.200/latest/meta-data/owner-account-id").call() { + Ok(resp) => { + if let Ok(body) = resp.into_string() { + let uid = body.trim().to_string(); + if !uid.is_empty() { + log::info!("Got ECS owner-account-id: {}", uid); + return uid; + } + } + } + Err(e) => { + log::warn!("ECS owner-account-id not available: {}", e); + } + } + String::new() +} + +/// 获取实例ID(带缓存):首次调用请求阿里云 ECS metadata(超时1秒), +/// 失败则回退到 hostname。后续调用直接返回缓存值。 +pub fn get_instance_id() -> &'static str { + INSTANCE_ID.get_or_init(|| { + fetch_instance_id() + }) +} -/// 获取实例ID:优先请求阿里云 ECS metadata(超时1秒),失败则回退到 hostname -pub fn get_instance_id() -> String { +/// 实际请求 instance-id +fn fetch_instance_id() -> String { // 尝试从 ECS metadata service 获取 instance-id - match ureq::get("http://100.100.100.200/latest/meta-data/instance-id") - .timeout(std::time::Duration::from_secs(1)) - .call() - { + let agent = metadata_agent(); + match agent.get("http://100.100.100.200/latest/meta-data/instance-id").call() { Ok(resp) => { if let Ok(body) = resp.into_string() { let id = body.trim().to_string(); diff --git a/src/agentsight/src/genai/logtail.rs b/src/agentsight/src/genai/logtail.rs index ed4c3952b..75fd64f80 100644 --- a/src/agentsight/src/genai/logtail.rs +++ b/src/agentsight/src/genai/logtail.rs @@ -114,6 +114,7 @@ impl GenAIExporter for LogtailExporter { /// 此函数被 Logtail 文件导出器使用,由 iLogtail 采集后上传到 SLS。 pub fn events_to_flat_records(events: &[GenAISemanticEvent]) -> Vec> { let hostname = instance_id::get_instance_id(); + let uid = instance_id::get_owner_account_id(); let mut records = Vec::with_capacity(events.len()); for event in events { @@ -122,11 +123,16 @@ pub fn events_to_flat_records(events: &[GenAISemanticEvent]) -> Vec { diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index f1cd3e95e..19ce951b4 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -158,7 +158,16 @@ impl AgentSight { // When SLS_LOGTAIL_FILE is set, use Logtail file exporter only (skip local storage) // — the Logtail file will be collected by iLogtail and uploaded to SLS. if let Some(exporter) = LogtailExporter::new() { - log::info!("Logtail file exporter enabled ({})", exporter.path().display()); + // SLS 模式必须能获取到 uid (owner-account-id),否则拒绝启动 + let uid = crate::genai::instance_id::get_owner_account_id(); + if uid.is_empty() { + anyhow::bail!( + "SLS Logtail exporter is enabled (SLS_LOGTAIL_FILE set) but failed to \ + fetch owner-account-id from ECS metadata service. \ + Cannot upload logs without uid. Aborting." + ); + } + log::info!("Logtail file exporter enabled ({}), uid={}", exporter.path().display(), uid); genai_exporters.push(Box::new(exporter)); } else { // No Logtail: use local JSONL + SQLite From 01a375a70aa25ff2807aae5a28dd665737f7a6af Mon Sep 17 00:00:00 2001 From: chengshuyi Date: Tue, 12 May 2026 17:05:55 +0800 Subject: [PATCH 08/23] fix(sight): handle Node.js process.title change in OpenClaw matcher --- src/agentsight/src/discovery/agents/openclaw.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/agentsight/src/discovery/agents/openclaw.rs b/src/agentsight/src/discovery/agents/openclaw.rs index 09a53578a..df28a3c99 100644 --- a/src/agentsight/src/discovery/agents/openclaw.rs +++ b/src/agentsight/src/discovery/agents/openclaw.rs @@ -47,7 +47,13 @@ impl AgentMatcher for OpenClawMatcher { } // Case 2: Node runtime with "openclaw" and "gateway" in cmdline args - let is_node = match_name_with_version_suffix(&comm_lower, "node"); + // Note: Node.js apps can change process.title (e.g., to "MainThread"), + // so we also check if cmdline_args[0] (the actual executable) contains "node". + let is_node = match_name_with_version_suffix(&comm_lower, "node") + || ctx.cmdline_args.first().map_or(false, |arg| { + let basename = arg.rsplit('/').next().unwrap_or(arg); + match_name_with_version_suffix(&basename.to_lowercase(), "node") + }); if is_node { let has_openclaw = ctx.cmdline_args.iter().any(|arg| { arg.to_lowercase().contains("openclaw") From cb4820fa76b17156cb82fc1a2abf560a9a4ee937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A9=BA=E6=BE=88?= Date: Mon, 11 May 2026 15:08:14 +0800 Subject: [PATCH 09/23] feat(cosh): expose run_id in HookInput for per-run event correlation - add run_id field to HookInput interface (optional, backward-compat) - add setCurrentRunId/getCurrentRunId accessors to Config - set currentRunId from prompt_id in GeminiClient.sendMessageStream() - reset currentRunId on session init in Config - populate run_id in HookEventHandler.createBaseInput() - add runId field to ChatRecord in chatRecordingService - update hook reference and writing-hooks docs - add unit tests for config, client, and hookEventHandler --- src/copilot-shell/hooks/docs/reference.md | 10 ++++ src/copilot-shell/hooks/docs/writing-hooks.md | 32 ++++++++++++ .../packages/core/src/config/config.test.ts | 36 +++++++++++++ .../packages/core/src/config/config.ts | 11 ++++ .../packages/core/src/core/client.test.ts | 44 ++++++++++++++++ .../packages/core/src/core/client.ts | 1 + .../core/src/hooks/hookEventHandler.test.ts | 50 +++++++++++++++++++ .../core/src/hooks/hookEventHandler.ts | 1 + .../packages/core/src/hooks/types.ts | 1 + .../src/services/chatRecordingService.test.ts | 1 + .../core/src/services/chatRecordingService.ts | 3 ++ 11 files changed, 190 insertions(+) diff --git a/src/copilot-shell/hooks/docs/reference.md b/src/copilot-shell/hooks/docs/reference.md index a74c314a6..310981e5e 100644 --- a/src/copilot-shell/hooks/docs/reference.md +++ b/src/copilot-shell/hooks/docs/reference.md @@ -25,6 +25,7 @@ All hooks receive these common fields via `stdin`: ```json { "session_id": "string", + "run_id": "string | undefined", "transcript_path": "string", "cwd": "string", "hook_event_name": "string", @@ -32,6 +33,15 @@ All hooks receive these common fields via `stdin`: } ``` +| Field | Type | Description | +| :---------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session_id` | `string` | Unique identifier for the CLI session (1 session = N runs). | +| `run_id` | `string \| undefined` | Unique identifier for the current agent run (1 run = 1 user prompt → complete response). Format: `{sessionId}########{counter}`. Undefined for session-level events (`SessionStart`, `SessionEnd`) and for `UserPromptSubmit` (which fires before the run begins). | +| `transcript_path` | `string` | Path to the session's JSONL transcript file. | +| `cwd` | `string` | Current working directory. | +| `hook_event_name` | `string` | The event that triggered this hook. | +| `timestamp` | `string` | ISO 8601 timestamp of when the event fired. | + --- ## Common output fields diff --git a/src/copilot-shell/hooks/docs/writing-hooks.md b/src/copilot-shell/hooks/docs/writing-hooks.md index fad5b1748..576d98104 100644 --- a/src/copilot-shell/hooks/docs/writing-hooks.md +++ b/src/copilot-shell/hooks/docs/writing-hooks.md @@ -267,6 +267,38 @@ else: print("{}") ``` +### Audit Trail with run_id (PostToolUse) + +Use `run_id` to correlate all tool calls within a single agent run for auditing. + +**`.copilot-shell/hooks/audit-trail.py`:** + +```python +#!/usr/bin/env python3 +import sys, json, datetime + +input_data = json.load(sys.stdin) + +entry = { + "timestamp": datetime.datetime.now().isoformat(), + "session_id": input_data["session_id"], + "run_id": input_data.get("run_id"), + "event": input_data["hook_event_name"], + "tool": input_data.get("tool_name", ""), +} + +with open(".copilot-shell/audit.jsonl", "a") as f: + f.write(json.dumps(entry) + "\n") + +print("{}") +``` + +Query all actions from a specific run: + +```bash +jq 'select(.run_id == "sess########3")' .copilot-shell/audit.jsonl +``` + ### Response Logger (AfterModel) Log all LLM responses for auditing. diff --git a/src/copilot-shell/packages/core/src/config/config.test.ts b/src/copilot-shell/packages/core/src/config/config.test.ts index 4633b8759..ae7785732 100644 --- a/src/copilot-shell/packages/core/src/config/config.test.ts +++ b/src/copilot-shell/packages/core/src/config/config.test.ts @@ -1527,3 +1527,39 @@ describe('Custom Skill Paths Configuration', () => { expect(resolved).toEqual(['/absolute/path']); }); }); + +describe('currentRunId', () => { + const baseParams: ConfigParameters = { + cwd: '/tmp', + embeddingModel: 'test-embedding-model', + targetDir: '/path/to/target', + debugMode: false, + usageStatisticsEnabled: false, + overrideExtensions: [], + }; + + it('should return undefined before any run_id is set', () => { + const config = new Config({ ...baseParams }); + expect(config.getCurrentRunId()).toBeUndefined(); + }); + + it('should store and retrieve a run_id', () => { + const config = new Config({ ...baseParams }); + config.setCurrentRunId('session-123########1'); + expect(config.getCurrentRunId()).toBe('session-123########1'); + }); + + it('should overwrite the previous run_id', () => { + const config = new Config({ ...baseParams }); + config.setCurrentRunId('run-1'); + config.setCurrentRunId('run-2'); + expect(config.getCurrentRunId()).toBe('run-2'); + }); + + it('should be cleared when startNewSession is called', () => { + const config = new Config({ ...baseParams }); + config.setCurrentRunId('run-from-old-session'); + config.startNewSession(); + expect(config.getCurrentRunId()).toBeUndefined(); + }); +}); diff --git a/src/copilot-shell/packages/core/src/config/config.ts b/src/copilot-shell/packages/core/src/config/config.ts index 139126f52..d62fa172c 100644 --- a/src/copilot-shell/packages/core/src/config/config.ts +++ b/src/copilot-shell/packages/core/src/config/config.ts @@ -954,6 +954,16 @@ export class Config { return this.sessionId; } + private currentRunId: string | undefined = undefined; + + setCurrentRunId(runId: string): void { + this.currentRunId = runId; + } + + getCurrentRunId(): string | undefined { + return this.currentRunId; + } + /** * Releases resources owned by the config instance. */ @@ -971,6 +981,7 @@ export class Config { sessionData?: ResumedSessionData, ): string { this.sessionId = sessionId ?? randomUUID(); + this.currentRunId = undefined; this.sessionData = sessionData; this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) diff --git a/src/copilot-shell/packages/core/src/core/client.test.ts b/src/copilot-shell/packages/core/src/core/client.test.ts index de2c3b453..8af31e340 100644 --- a/src/copilot-shell/packages/core/src/core/client.test.ts +++ b/src/copilot-shell/packages/core/src/core/client.test.ts @@ -316,6 +316,7 @@ describe('Gemini Client (client.ts)', () => { getUserMemory: vi.fn().mockReturnValue(''), getFullContext: vi.fn().mockReturnValue(false), getSessionId: vi.fn().mockReturnValue('test-session-id'), + setCurrentRunId: vi.fn(), getProxy: vi.fn().mockReturnValue(undefined), getWorkingDir: vi.fn().mockReturnValue('/test/dir'), getFileService: vi.fn().mockReturnValue(fileService), @@ -1146,6 +1147,49 @@ describe('Gemini Client (client.ts)', () => { }, ); + it('should set currentRunId on config when not a continuation', async () => { + // Arrange + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + // Act + const stream = client.sendMessageStream( + [{ text: 'Hi' }], + new AbortController().signal, + 'test-prompt-id-42', + ); + await fromAsync(stream); + + // Assert + expect(mockConfig.setCurrentRunId).toHaveBeenCalledWith( + 'test-prompt-id-42', + ); + }); + + it('should not set currentRunId on config when isContinuation is true', async () => { + // Arrange + mockTurnRunFn.mockReturnValue( + (async function* () { + yield { type: 'content', value: 'Hello' }; + })(), + ); + + // Act + const stream = client.sendMessageStream( + [{ text: 'Hi' }], + new AbortController().signal, + 'test-prompt-id-42', + { isContinuation: true }, + ); + await fromAsync(stream); + + // Assert + expect(mockConfig.setCurrentRunId).not.toHaveBeenCalled(); + }); + it('should include editor context when ideMode is enabled', async () => { // Arrange vi.mocked(ideContextStore.get).mockReturnValue({ diff --git a/src/copilot-shell/packages/core/src/core/client.ts b/src/copilot-shell/packages/core/src/core/client.ts index 223de2b47..18e63c637 100644 --- a/src/copilot-shell/packages/core/src/core/client.ts +++ b/src/copilot-shell/packages/core/src/core/client.ts @@ -617,6 +617,7 @@ export class GeminiClient { if (!options?.isContinuation) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; + this.config.setCurrentRunId(prompt_id); // record user message for session management this.config.getChatRecordingService()?.recordUserMessage(request); diff --git a/src/copilot-shell/packages/core/src/hooks/hookEventHandler.test.ts b/src/copilot-shell/packages/core/src/hooks/hookEventHandler.test.ts index 4fafd3832..ef5bcf9b2 100644 --- a/src/copilot-shell/packages/core/src/hooks/hookEventHandler.test.ts +++ b/src/copilot-shell/packages/core/src/hooks/hookEventHandler.test.ts @@ -26,6 +26,7 @@ describe('HookEventHandler', () => { beforeEach(() => { mockConfig = { getSessionId: vi.fn().mockReturnValue('test-session-id'), + getCurrentRunId: vi.fn().mockReturnValue('test-run-id'), getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'), getWorkingDir: vi.fn().mockReturnValue('/test/cwd'), } as unknown as Config; @@ -71,6 +72,55 @@ describe('HookEventHandler', () => { finalOutput, }); + describe('createBaseInput run_id', () => { + it('should include run_id from config in hook input', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { session_id: string; run_id: string }; + expect(input.session_id).toBe('test-session-id'); + expect(input.run_id).toBe('test-run-id'); + }); + + it('should include undefined run_id when not set', async () => { + vi.mocked(mockConfig.getCurrentRunId).mockReturnValue(undefined); + + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireUserPromptSubmitEvent('test'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { run_id?: string }; + expect(input.run_id).toBeUndefined(); + }); + }); + describe('fireUserPromptSubmitEvent', () => { it('should execute hooks for UserPromptSubmit event', async () => { const mockPlan = createMockExecutionPlan([]); diff --git a/src/copilot-shell/packages/core/src/hooks/hookEventHandler.ts b/src/copilot-shell/packages/core/src/hooks/hookEventHandler.ts index 5b5ae6ef5..2a8400b1f 100644 --- a/src/copilot-shell/packages/core/src/hooks/hookEventHandler.ts +++ b/src/copilot-shell/packages/core/src/hooks/hookEventHandler.ts @@ -435,6 +435,7 @@ export class HookEventHandler { return { session_id: this.config.getSessionId(), + run_id: this.config.getCurrentRunId(), transcript_path: transcriptPath, cwd: this.config.getWorkingDir(), hook_event_name: eventName, diff --git a/src/copilot-shell/packages/core/src/hooks/types.ts b/src/copilot-shell/packages/core/src/hooks/types.ts index 13998f4a4..12c08e02e 100644 --- a/src/copilot-shell/packages/core/src/hooks/types.ts +++ b/src/copilot-shell/packages/core/src/hooks/types.ts @@ -101,6 +101,7 @@ export type HookDecision = 'ask' | 'block' | 'deny' | 'approve' | 'allow'; */ export interface HookInput { session_id: string; + run_id?: string; transcript_path: string; cwd: string; hook_event_name: string; diff --git a/src/copilot-shell/packages/core/src/services/chatRecordingService.test.ts b/src/copilot-shell/packages/core/src/services/chatRecordingService.test.ts index 050b9381f..d84ec966d 100644 --- a/src/copilot-shell/packages/core/src/services/chatRecordingService.test.ts +++ b/src/copilot-shell/packages/core/src/services/chatRecordingService.test.ts @@ -40,6 +40,7 @@ describe('ChatRecordingService', () => { mockConfig = { getSessionId: vi.fn().mockReturnValue('test-session-id'), + getCurrentRunId: vi.fn().mockReturnValue('test-run-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), getCliVersion: vi.fn().mockReturnValue('1.0.0'), storage: { diff --git a/src/copilot-shell/packages/core/src/services/chatRecordingService.ts b/src/copilot-shell/packages/core/src/services/chatRecordingService.ts index 7a90293af..8b01db395 100644 --- a/src/copilot-shell/packages/core/src/services/chatRecordingService.ts +++ b/src/copilot-shell/packages/core/src/services/chatRecordingService.ts @@ -41,6 +41,8 @@ export interface ChatRecord { parentUuid: string | null; /** Session identifier - groups records into a logical conversation */ sessionId: string; + /** Run identifier - correlates records within a single agent run */ + runId?: string; /** ISO 8601 timestamp of when the record was created */ timestamp: string; /** @@ -255,6 +257,7 @@ export class ChatRecordingService { uuid: randomUUID(), parentUuid: this.lastRecordUuid, sessionId: this.getSessionId(), + runId: this.config.getCurrentRunId(), timestamp: new Date().toISOString(), type, cwd: this.config.getProjectRoot(), From 16f17e58d73914416ea9bf5f236519b959e12cde Mon Sep 17 00:00:00 2001 From: liyuqing Date: Wed, 13 May 2026 10:25:20 +0800 Subject: [PATCH 10/23] fix(sight): adapt skill extraction for Hermes agent architecture - Add 'skill_view' to SKILL_FUNCTION_NAMES for Hermes skill load detection - Support Hermes argument format {"name": "skill-name"} in extract_skill_name_from_args - Add plain-text fallback regex for Hermes format (- skill-name: desc) - XML format takes priority; plain-text fallback only when XML yields no results Closes alibaba/anolisa#504 --- src/agentsight/src/skill_metrics/extractor.rs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/agentsight/src/skill_metrics/extractor.rs b/src/agentsight/src/skill_metrics/extractor.rs index 67a98bbb1..2972ad49d 100644 --- a/src/agentsight/src/skill_metrics/extractor.rs +++ b/src/agentsight/src/skill_metrics/extractor.rs @@ -28,12 +28,16 @@ static RE_AVAILABLE_SKILLS: LazyLock = static RE_SKILL_NAME: LazyLock = LazyLock::new(|| Regex::new(r"(?s).*?(.*?).*?").unwrap()); +/// Regex for Hermes-style plain-text skill entries: ` - skill-name: description` +static RE_HERMES_SKILL_ENTRY: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^\s*-\s+([\w-]+):.*$").unwrap()); + /// Function names that indicate a file read operation (case-insensitive match). const READ_FUNCTION_NAMES: &[&str] = &["read", "readfile", "read_file"]; /// Function names that indicate a skill invocation (case-insensitive match). -/// Used by cosh/copilot-shell which calls skills via a "Skill" tool_call. -const SKILL_FUNCTION_NAMES: &[&str] = &["skill"]; +/// Used by cosh/copilot-shell ("Skill") and Hermes ("skill_view") tool_calls. +const SKILL_FUNCTION_NAMES: &[&str] = &["skill", "skill_view"]; // ─── Public API ────────────────────────────────────────────────────────────── @@ -287,18 +291,34 @@ fn scan_skills_dir_recursive(dir: &str, depth: u32) -> Vec { names } -/// Parse `` XML block and extract skill names. +/// Parse `` block and extract skill names. +/// +/// Supports two formats: +/// 1. XML (cosh): `foo...` +/// 2. Plain-text indented (Hermes): ` - skill-name: description` fn parse_available_skills(text: &str) -> Vec { let mut skill_names = Vec::new(); for block_match in RE_AVAILABLE_SKILLS.captures_iter(text) { let block = &block_match[1]; + + // Try XML format first (cosh/generic agents) for name_match in RE_SKILL_NAME.captures_iter(block) { let name = name_match[1].trim().to_string(); if !name.is_empty() && !skill_names.contains(&name) { skill_names.push(name); } } + + // If no XML skills found, try Hermes plain-text format + if skill_names.is_empty() { + for name_match in RE_HERMES_SKILL_ENTRY.captures_iter(block) { + let name = name_match[1].to_string(); + if !name.is_empty() && !skill_names.contains(&name) { + skill_names.push(name); + } + } + } } skill_names @@ -328,15 +348,25 @@ fn extract_file_path(args: &serde_json::Value) -> Option { } /// Extract skill name from Skill tool_call arguments. -/// Supports: {"skill": "pdf"} or {"skill": "ms-office-suite:pdf"} +/// Supports: +/// - Cosh: {"skill": "pdf"} or {"skill": "ms-office-suite:pdf"} +/// - Hermes: {"name": "test-driven-development"} fn extract_skill_name_from_args(args: &serde_json::Value) -> Option { if let Some(obj) = args.as_object() { + // Cosh format: {"skill": "skill-name"} if let Some(name) = obj.get("skill").and_then(|v| v.as_str()) { let trimmed = name.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } + // Hermes format: {"name": "skill-name"} + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } } // Try as string (double-encoded JSON) From f80fe2b786d0483953ac8fd7c8271c3c1c242d75 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Tue, 12 May 2026 17:49:34 +0800 Subject: [PATCH 11/23] fix(sec-core): limit skill-ledger hook scope --- .../cosh-extension/hooks/skill_ledger_hook.py | 120 ++++++++++++++++-- .../docs/design/SKILL_LEDGER_CN.md | 9 +- .../cosh_hooks/test_skill_ledger_hook.py | 107 +++++++++++++++- 3 files changed, 216 insertions(+), 20 deletions(-) diff --git a/src/agent-sec-core/cosh-extension/hooks/skill_ledger_hook.py b/src/agent-sec-core/cosh-extension/hooks/skill_ledger_hook.py index 71698ca93..d6782a75b 100644 --- a/src/agent-sec-core/cosh-extension/hooks/skill_ledger_hook.py +++ b/src/agent-sec-core/cosh-extension/hooks/skill_ledger_hook.py @@ -2,7 +2,7 @@ """Cosh hook script for skill-ledger. Reads a cosh PreToolUse JSON from stdin, resolves the skill directory -from the skill name, invokes ``agent-sec-cli skill-ledger check`` via +from the skill context or skill name, invokes ``agent-sec-cli skill-ledger check`` via subprocess, and writes a cosh HookOutput JSON to stdout. Hook point: **PreToolUse** — matcher: ``skill`` @@ -82,10 +82,99 @@ def _allow_with_reason(reason: str) -> str: return json.dumps({"decision": "allow", "reason": reason}, ensure_ascii=False) +def _debug(message: str) -> None: + """Write debug-only hook details to stderr.""" + print(f"[skill-ledger debug] {message}", file=sys.stderr) + + +def _supported_skill_bases(cwd: str) -> list[Path]: + """Return the skill roots currently covered by this hook. + + Current scope is intentionally limited to: + project (.copilot-shell/skills/) → user (~/.copilot-shell/skills/) + → system (/usr/share/anolisa/skills/). + """ + return [ + Path(cwd) / ".copilot-shell" / "skills", + Path.home() / ".copilot-shell" / "skills", + Path("/usr/share/anolisa/skills"), + ] + + +def _resolve_supported_skill_bases(cwd: str, skill_name: str) -> list[Path]: + """Resolve supported skill roots, skipping only roots that fail.""" + supported_bases: list[Path] = [] + for base in _supported_skill_bases(cwd): + try: + supported_bases.append(base.resolve()) + except (OSError, ValueError) as exc: + _debug( + "Skill '{}' check skipped for base '{}': failed to resolve: {}".format( + skill_name, base, exc + ) + ) + return supported_bases + + +def _resolve_skill_dir_from_context( + input_data: dict, cwd: str, skill_name: str +) -> tuple[str | None, bool]: + """Resolve the skill dir from ``skill_context.file_path`` when available. + + Returns ``(skill_dir, handled)``. ``handled`` is True whenever a + well-formed ``skill_context.file_path`` was present, even if the path is + outside the supported project/user/system scope. In that case the caller + should fail open without falling back to name-based lookup, because the + context identifies the actual skill that copilot-shell resolved. + """ + skill_context = input_data.get("skill_context") + if not isinstance(skill_context, dict): + return None, False + + file_path = skill_context.get("file_path") + if not isinstance(file_path, str) or not file_path.strip(): + return None, False + + try: + skill_file = Path(file_path).expanduser().resolve() + except (OSError, ValueError) as exc: + _debug( + "Skill '{}' check skipped: invalid skill_context.file_path '{}': {}".format( + skill_name, file_path, exc + ) + ) + return None, True + + supported_bases = _resolve_supported_skill_bases(cwd, skill_name) + if not supported_bases: + _debug( + "Skill '{}' check skipped: no supported skill bases could be resolved".format( + skill_name + ) + ) + return None, True + + if not any(skill_file.is_relative_to(base) for base in supported_bases): + _debug( + "Skill '{}' at '{}' is outside current skill-ledger hook scope " + "(project/user/system); check skipped".format(skill_name, skill_file) + ) + return None, True + + if skill_file.name != "SKILL.md" or not skill_file.is_file(): + _debug( + "Skill '{}' check skipped: skill_context.file_path '{}' does not " + "point to an existing SKILL.md".format(skill_name, skill_file) + ) + return None, True + + return str(skill_file.parent), True + + def _resolve_skill_dir(skill_name: str, cwd: str) -> tuple[str | None, bool]: """Resolve a skill name to its on-disk directory. - Search order mirrors copilot-shell's SkillManager priority: + Current hook scope is intentionally limited to: project (.copilot-shell/skills/) → user (~/.copilot-shell/skills/) → system (/usr/share/anolisa/skills/). @@ -95,18 +184,15 @@ def _resolve_skill_dir(skill_name: str, cwd: str) -> tuple[str | None, bool]: - ``(None, False)`` — not found (remote or unknown skill). """ traversal_detected = False - bases = [ - Path(cwd) / ".copilot-shell" / "skills", - Path.home() / ".copilot-shell" / "skills", - Path("/usr/share/anolisa/skills"), - ] + bases = _supported_skill_bases(cwd) for base in bases: candidate = base / skill_name try: + resolved_base = base.resolve() resolved = candidate.resolve() except (OSError, ValueError): continue - if not resolved.is_relative_to(base.resolve()): + if not resolved.is_relative_to(resolved_base): traversal_detected = True continue # path-traversal attempt — skip this base if resolved.is_dir() and (resolved / "SKILL.md").is_file(): @@ -198,9 +284,21 @@ def main() -> None: ) return - # 3. Resolve skill directory + # 3. Resolve skill directory. Prefer copilot-shell's resolved file path + # when present so SKILL.md names may differ from directory names, but only + # within the current project/user/system scope. cwd = input_data.get("cwd", os.environ.get("COPILOT_SHELL_PROJECT_DIR", ".")) - skill_dir, traversal = _resolve_skill_dir(skill_name, cwd) + skill_dir, context_handled = _resolve_skill_dir_from_context( + input_data, cwd, skill_name + ) + if context_handled: + if skill_dir is None: + print(_allow()) + return + traversal = False + else: + skill_dir, traversal = _resolve_skill_dir(skill_name, cwd) + if traversal: reason = "\U0001f6a8 Skill '{}' rejected: path traversal detected".format( skill_name @@ -208,7 +306,7 @@ def main() -> None: print(_allow_with_reason(reason)) return if skill_dir is None: - # Not found in any location (project/user/system) — remote or unknown → fail-open + # Not found in any supported location (project/user/system) → fail-open reason = ( "\u26a0\ufe0f Skill '{}' not found on disk \u2014 check skipped".format( skill_name diff --git a/src/agent-sec-core/docs/design/SKILL_LEDGER_CN.md b/src/agent-sec-core/docs/design/SKILL_LEDGER_CN.md index d6ce69b56..f5213d453 100644 --- a/src/agent-sec-core/docs/design/SKILL_LEDGER_CN.md +++ b/src/agent-sec-core/docs/design/SKILL_LEDGER_CN.md @@ -618,8 +618,11 @@ skill-ledger 需适配两个宿主系统,两者 Skill 模型和 Hook 机制存 } ``` -**Skill 目录定位**:`tool_input` 仅含 skill 名称,hook 脚本按 project → custom → user → extension → system 优先级自行查找。project 级路径通过 event 的 `cwd` 字段推断。 +**Skill 目录定位(当前版本范围)**:copilot-shell hook 仅覆盖 project → user → system 三类 skill: +- project:`/.copilot-shell/skills//` +- user:`~/.copilot-shell/skills//` +- system:`/usr/share/anolisa/skills//` -**extension Skills**:读取 `~/.copilot-shell/extensions//` 下的 `cosh-extension.json` 配置,按 `skills` 字段确定 skill 基目录,支持 `link` 类型安装(跟随 `.qwen-extension-install.json` 中的 `source` 路径)。extension skill 与其他级别 skill 享有相同的安全检查。 +当 PreToolUse 事件包含 `skill_context.file_path` 时,hook 优先使用该路径解决 `SKILL.md` 中 `name` 与目录名不一致的问题;但该路径仍必须落在上述 project/user/system 根目录内。若路径落在 custom、extension、remote 或其他目录,当前版本不执行 skill-ledger 检查,hook fail-open,并仅写入 debug 日志说明该 skill 不在当前 hook 支持范围内。 -**remote Skills**:首次下载的 remote skill 无 `.skill-meta/`,hook 返回 `unscanned`,输出告警但不阻断。 +**custom / extension / remote Skills**:当前版本的 copilot-shell hook 不覆盖这些来源。未来若扩展覆盖范围,需要单独补充目录解析、信任边界和测试用例。 diff --git a/src/agent-sec-core/tests/unit-test/cosh_hooks/test_skill_ledger_hook.py b/src/agent-sec-core/tests/unit-test/cosh_hooks/test_skill_ledger_hook.py index 67429c833..fbc3c5dfa 100644 --- a/src/agent-sec-core/tests/unit-test/cosh_hooks/test_skill_ledger_hook.py +++ b/src/agent-sec-core/tests/unit-test/cosh_hooks/test_skill_ledger_hook.py @@ -39,7 +39,7 @@ # --------------------------------------------------------------------------- -def _run_hook(input_data, *, env_override=None): +def _run_hook(input_data, *, env_override=None, return_stderr=False): """Run the hook as a subprocess with *input_data* as stdin JSON. Returns the parsed JSON output dict. @@ -56,28 +56,38 @@ def _run_hook(input_data, *, env_override=None): env=env, ) assert proc.returncode == 0, f"Hook stderr: {proc.stderr}" - return json.loads(proc.stdout) + output = json.loads(proc.stdout) + if return_stderr: + return output, proc.stderr + return output -def _make_skill_event(skill_name, cwd="."): +def _make_skill_event(skill_name, cwd=".", skill_file_path=None): """Build a minimal PreToolUse event for the skill tool.""" - return { + event = { "hook_event_name": "PreToolUse", "tool_name": "skill", "tool_input": {"skill": skill_name}, "cwd": cwd, } + if skill_file_path is not None: + event["skill_context"] = { + "skill_name": skill_name, + "file_path": str(skill_file_path), + } + return event -def _create_skill_dir(parent, name="test-skill"): +def _create_skill_dir(parent, name="test-skill", manifest_name=None): """Create a minimal skill directory with a SKILL.md file. Returns the absolute path to ``/.copilot-shell/skills//``. """ + manifest_name = manifest_name or name skill_dir = Path(parent) / ".copilot-shell" / "skills" / name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text( - "---\nname: test-skill\ndescription: A test skill\n---\nHello\n" + f"---\nname: {manifest_name}\ndescription: A test skill\n---\nHello\n" ) return str(skill_dir) @@ -186,6 +196,91 @@ def test_project_level_skill_found(self, mock_cli_env): assert output["decision"] == "allow" assert "reason" in output, "Skill dir not found — CLI was never called" + def test_skill_context_resolves_name_directory_mismatch(self, mock_cli_env): + """skill_context.file_path should locate project skills by real path. + + This covers the case where the Skill tool receives the frontmatter + name, but the on-disk directory uses a different name. + """ + skill_dir = _create_skill_dir( + mock_cli_env["cwd"], + name="directory-name", + manifest_name="frontmatter-name", + ) + env = mock_cli_env["make_env"](json.dumps({"status": "warn"})) + output = _run_hook( + _make_skill_event( + "frontmatter-name", + mock_cli_env["cwd"], + Path(skill_dir) / "SKILL.md", + ), + env_override=env, + ) + assert output["decision"] == "allow" + assert "low-risk" in output["reason"] + + def test_skill_context_skips_only_unresolvable_supported_base(self, mock_cli_env): + """A bad project base should not discard user/system base checks.""" + home = Path(mock_cli_env["cwd"]).parent / "home" + skill_dir = home / ".copilot-shell" / "skills" / "user-dir" + skill_dir.mkdir(parents=True) + skill_file = skill_dir / "SKILL.md" + skill_file.write_text( + "---\nname: user-skill\ndescription: A user skill\n---\nHello\n" + ) + + env = mock_cli_env["make_env"](json.dumps({"status": "warn"})) + env["HOME"] = str(home) + output = _run_hook( + _make_skill_event("user-skill", "\0bad-project", skill_file), + env_override=env, + ) + + assert output["decision"] == "allow" + assert "low-risk" in output["reason"] + + @pytest.mark.parametrize("scope_name", ["custom", "extension", "remote"]) + def test_skill_context_outside_supported_scope_debug_skips( + self, tmp_path, scope_name + ): + """custom/extension/remote paths are out of scope for this hook.""" + home = tmp_path / "home" + project = tmp_path / "project" + home.mkdir() + project.mkdir() + + if scope_name == "custom": + skill_dir = tmp_path / "custom-skills" / "custom-skill" + elif scope_name == "extension": + skill_dir = ( + home + / ".copilot-shell" + / "extensions" + / "test-ext" + / "skills" + / "extension-skill" + ) + else: + skill_dir = ( + home / ".copilot-shell" / "remote-skills" / "system" / "remote-skill" + ) + + skill_dir.mkdir(parents=True) + skill_file = skill_dir / "SKILL.md" + skill_file.write_text( + f"---\nname: {scope_name}-skill\ndescription: A test skill\n---\n" + ) + + output, stderr = _run_hook( + _make_skill_event(f"{scope_name}-skill", str(project), skill_file), + env_override={"HOME": str(home)}, + return_stderr=True, + ) + + assert output == {"decision": "allow"} + assert "outside current skill-ledger hook scope" in stderr + assert "project/user/system" in stderr + def test_missing_skill_md_not_found(self): """Directory exists but no SKILL.md → not recognized as a skill.""" with tempfile.TemporaryDirectory() as tmpdir: From 06fe804947404c6466bbb0f208d5e66a60634ac7 Mon Sep 17 00:00:00 2001 From: Shile Zhang Date: Sun, 10 May 2026 22:06:19 +0800 Subject: [PATCH 12/23] fix(tokenless): add activation onCapabilities hook for openclaw plugin compatibility Signed-off-by: Shile Zhang --- src/tokenless/openclaw/openclaw.plugin.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tokenless/openclaw/openclaw.plugin.json b/src/tokenless/openclaw/openclaw.plugin.json index 49ce8ca24..9e6335299 100644 --- a/src/tokenless/openclaw/openclaw.plugin.json +++ b/src/tokenless/openclaw/openclaw.plugin.json @@ -3,6 +3,9 @@ "name": "Token-Less", "version": "5.0.0", "description": "Unified RTK command rewriting + schema/response/TOON compression + Tool Ready environment pre-check", + "activation": { + "onCapabilities": ["hook"] + }, "configSchema": { "type": "object", "properties": { From 987ee320818a87d03fdea235048291d6b836de14 Mon Sep 17 00:00:00 2001 From: Shile Zhang Date: Sun, 10 May 2026 22:38:49 +0800 Subject: [PATCH 13/23] chore(tokenless): bump to v0.3.1 Signed-off-by: Shile Zhang --- src/tokenless/Cargo.lock | 6 +++--- src/tokenless/Cargo.toml | 2 +- src/tokenless/crates/tokenless-stats/Cargo.toml | 2 +- src/tokenless/openclaw/openclaw.plugin.json | 2 +- src/tokenless/tokenless.spec.in | 3 +++ 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/tokenless/Cargo.lock b/src/tokenless/Cargo.lock index 3ae61ece9..622ac0819 100644 --- a/src/tokenless/Cargo.lock +++ b/src/tokenless/Cargo.lock @@ -545,7 +545,7 @@ dependencies = [ [[package]] name = "tokenless-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "clap", @@ -557,7 +557,7 @@ dependencies = [ [[package]] name = "tokenless-schema" -version = "0.3.0" +version = "0.3.1" dependencies = [ "regex", "serde_json", @@ -565,7 +565,7 @@ dependencies = [ [[package]] name = "tokenless-stats" -version = "0.3.0" +version = "0.3.1" dependencies = [ "chrono", "dirs", diff --git a/src/tokenless/Cargo.toml b/src/tokenless/Cargo.toml index 34a5f6ad2..e21be0bc5 100644 --- a/src/tokenless/Cargo.toml +++ b/src/tokenless/Cargo.toml @@ -11,7 +11,7 @@ exclude = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" diff --git a/src/tokenless/crates/tokenless-stats/Cargo.toml b/src/tokenless/crates/tokenless-stats/Cargo.toml index 5fe7468f2..fd56fc802 100644 --- a/src/tokenless/crates/tokenless-stats/Cargo.toml +++ b/src/tokenless/crates/tokenless-stats/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokenless-stats" -version = "0.3.0" +version = "0.3.1" edition = "2024" description = "Statistics tracking for tokenless - SQLite-based metrics storage" license = "MIT OR Apache-2.0" diff --git a/src/tokenless/openclaw/openclaw.plugin.json b/src/tokenless/openclaw/openclaw.plugin.json index 9e6335299..bcf48fd1f 100644 --- a/src/tokenless/openclaw/openclaw.plugin.json +++ b/src/tokenless/openclaw/openclaw.plugin.json @@ -1,7 +1,7 @@ { "id": "tokenless-openclaw", "name": "Token-Less", - "version": "5.0.0", + "version": "5.0.1", "description": "Unified RTK command rewriting + schema/response/TOON compression + Tool Ready environment pre-check", "activation": { "onCapabilities": ["hook"] diff --git a/src/tokenless/tokenless.spec.in b/src/tokenless/tokenless.spec.in index c2e01af7a..a39b09c5b 100644 --- a/src/tokenless/tokenless.spec.in +++ b/src/tokenless/tokenless.spec.in @@ -165,6 +165,9 @@ if [ -x %{_datadir}/tokenless/scripts/install.sh ]; then fi %changelog +* Sat May 10 2026 Shile Zhang - 0.3.1-1 +- fix(openclaw): add activation onCapabilities hook for high-version plugin compatibility + * Sat May 10 2026 Shile Zhang - 0.3.0-1 - Bump to v0.3.0 with tool-ready env pre-check and multiple fixes From e912e312cfa27b471a03a3332869d731d61e78bf Mon Sep 17 00:00:00 2001 From: yizheng Date: Wed, 13 May 2026 10:24:57 +0800 Subject: [PATCH 14/23] feat(sec-core): support build all for sec-core Signed-off-by: yizheng --- scripts/build-all.sh | 78 +++++++++++++++++++++++++++---------- src/agent-sec-core/Makefile | 19 +++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 0c9caf816..5898d19d2 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -13,7 +13,7 @@ # Components (build order): # cosh copilot-shell (Node.js / TypeScript) # skills os-skills (Markdown skill definitions, no compilation) -# sec-core agent-sec-core (Rust sandbox, Linux only) +# sec-core agent-sec-core (Security CLI + sandbox + hooks) # sight agentsight (eBPF / Rust, Linux only, NOT built by default) # tokenless tokenless (Rust compression library, cross-platform) # ────────────────────────────────────────────────────────────────── @@ -642,6 +642,8 @@ do_install_deps() { fi if want_component sec-core; then + install_node # openclaw-plugin needs Node.js + install_build_tools # gcc + make install_uv fi @@ -702,26 +704,25 @@ build_skills() { } build_sec_core() { - step "Building agent-sec-core (linux-sandbox)" + step "Building agent-sec-core" local dir="$PROJECT_ROOT/src/agent-sec-core" [[ -d "$dir" ]] || die "Directory not found: $dir" cd "$dir" - info "cargo build --release (linux-sandbox) ..." - if [[ -f Makefile ]] && grep -q 'build-sandbox' Makefile; then - make build-sandbox - else - cd linux-sandbox && cargo build --release && cd .. - fi + # build-all = build-sandbox + build-cli + build-openclaw-plugin + info "make build-all ..." + make build-all - local bin="linux-sandbox/target/release/linux-sandbox" - if [[ -f "$bin" ]]; then - ARTIFACT_NAMES+=("agent-sec-core") - ARTIFACT_PATHS+=("src/agent-sec-core/$bin") - ok "agent-sec-core built successfully" - else - warn "Expected artifact $bin not found" - fi + # Track artifacts + local sandbox_bin="linux-sandbox/target/release/linux-sandbox" + local wheel + wheel=$(ls agent-sec-cli/target/wheels/agent_sec_cli-*.whl 2>/dev/null | head -1) + local plugin_entry="openclaw-plugin/dist/index.js" + + [[ -f "$sandbox_bin" ]] && ARTIFACT_NAMES+=("linux-sandbox") && ARTIFACT_PATHS+=("src/agent-sec-core/$sandbox_bin") + [[ -n "$wheel" ]] && ARTIFACT_NAMES+=("agent-sec-cli") && ARTIFACT_PATHS+=("src/agent-sec-core/$wheel") + [[ -f "$plugin_entry" ]] && ARTIFACT_NAMES+=("openclaw-plugin") && ARTIFACT_PATHS+=("src/agent-sec-core/openclaw-plugin/dist/") + ok "agent-sec-core built successfully" } build_sight() { @@ -814,9 +815,46 @@ install_sec_core() { [[ -d "$dir" ]] || die "Directory not found: $dir" cd "$dir" - info "sudo make install-sandbox ..." - sudo make install-sandbox - ok "agent-sec-core (linux-sandbox) installed to /usr/local/bin/" + local venv_dir="/opt/agent-sec/venv" + + # 1. Create isolated venv (uv auto-downloads Python 3.11.6 if needed) + info "Creating Python venv at $venv_dir ..." + sudo mkdir -p "$venv_dir" + sudo chown "$(id -u):$(id -g)" "$venv_dir" + uv venv --python "3.11.6" "$venv_dir" + + # 2. Install deps from uv.lock into venv (uv sync understands [tool.uv.sources]) + # UV_PROJECT_ENVIRONMENT tells uv to use our venv instead of .venv + # --no-install-project: only install deps, not the project itself + info "Installing agent-sec-cli dependencies (from uv.lock) ..." + (cd agent-sec-cli && UV_PROJECT_ENVIRONMENT="$venv_dir" uv sync --frozen --no-dev --no-install-project) + + # 3. Install pre-built wheel into venv (no-deps since deps are already installed) + uv pip install --python "$venv_dir/bin/python" --no-deps \ + agent-sec-cli/target/wheels/agent_sec_cli-*.whl + + # 4. Symlink CLI command to /usr/local/bin/ + sudo ln -sf "$venv_dir/bin/agent-sec-cli" /usr/local/bin/agent-sec-cli + ok "agent-sec-cli installed ($venv_dir + symlink to /usr/local/bin/)" + + # 5. Install non-Python components (sandbox, hooks, plugin, skills) + info "Installing sandbox + hooks + plugin + skills ..." + sudo make install-cosh-hook install-openclaw-plugin install-skills install-tool + ok "cosh-hook + openclaw-plugin + skills installed" + + # 6. Runtime dependencies + if ! cmd_exists bwrap; then + info "Installing runtime dependency: bubblewrap ..." + sudo $PKG_INSTALL bubblewrap || warn "bubblewrap not installed (linux-sandbox runtime dep)" + fi + if ! cmd_exists gpg && ! cmd_exists gpg2; then + info "Installing runtime dependency: gnupg2 ..." + sudo $PKG_INSTALL gnupg2 || warn "gnupg2 not installed (skill signature verification)" + fi + if ! cmd_exists jq; then + info "Installing runtime dependency: jq ..." + sudo $PKG_INSTALL jq || warn "jq not installed (openclaw-plugin deploy)" + fi } install_sight() { @@ -906,7 +944,7 @@ $(echo -e "${BOLD}Examples:${NC}") $(echo -e "${BOLD}Components:${NC}") cosh copilot-shell Node.js / TypeScript AI terminal assistant [default] skills os-skills Markdown skill definitions (deploy only) [default] - sec-core agent-sec-core Rust secure sandbox (Linux only) [default] + sec-core agent-sec-core Security CLI + sandbox + hooks [default] sight agentsight eBPF observability/audit agent (Linux only) [optional] tokenless tokenless Rust token compression library (cross-platform) [default] diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 942b1bddc..64a25e503 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -77,6 +77,25 @@ test-e2e-rpm: ## Run E2E tests against RPM-installed agent-sec-cli binary @# skill-signing e2e skipped: imports Python source code @# linux-sandbox e2e skipped: requires privileged container +VENV_PYTHON ?= /opt/agent-sec/venv/bin/python + +.PHONY: test-e2e-source-build +test-e2e-source-build: ## Run E2E tests against source-build-installed agent-sec-cli + @echo "🧪 Running E2E tests on source build..." + @command -v agent-sec-cli >/dev/null 2>&1 || { echo "ERROR: agent-sec-cli not found on PATH"; exit 1; } + @$(VENV_PYTHON) -m pytest --version >/dev/null 2>&1 || uv pip install --python $(VENV_PYTHON) --quiet pytest + $(VENV_PYTHON) -m pytest tests/e2e/ \ + --import-mode=importlib \ + --ignore=tests/e2e/skill-ledger \ + --ignore=tests/e2e/skill-signing \ + --ignore=tests/e2e/linux-sandbox \ + --ignore=tests/e2e/prompt-scanner \ + -k 'not test_error_event_writes_to_sqlite' \ + -v --tb=short + @# skill-ledger e2e skipped: installed system skills affect G6/G8 expected results + @# skill-signing e2e skipped: imports Python source code + @# linux-sandbox e2e skipped: requires privileged container + .PHONY: test-python-coverage test-python-coverage: ## Run Python tests with coverage report @echo "🧪 Running Python tests with coverage..." From 3f222868ba035894fe1f28d8e8ba46aca2272288 Mon Sep 17 00:00:00 2001 From: yizheng Date: Wed, 13 May 2026 10:25:30 +0800 Subject: [PATCH 15/23] ci(sec-core): add source build ci Signed-off-by: yizheng --- .../workflows/sec-core-source-code-build.yaml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/sec-core-source-code-build.yaml diff --git a/.github/workflows/sec-core-source-code-build.yaml b/.github/workflows/sec-core-source-code-build.yaml new file mode 100644 index 000000000..ee4343709 --- /dev/null +++ b/.github/workflows/sec-core-source-code-build.yaml @@ -0,0 +1,60 @@ +name: sec-core-source-code-build + +on: + pull_request: + branches: + - main + - 'release/agent-sec-core/**' + paths: + - 'src/agent-sec-core/**' + - 'scripts/build-all.sh' + - '.github/workflows/sec-core-source-code-build.yaml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu 22.04 + runner: ubuntu-22.04 + container: '' + - name: Alinux4 + runner: ubuntu-22.04 + container: alibaba-cloud-linux-4-registry.cn-hangzhou.cr.aliyuncs.com/alinux4/alinux4:latest + continue-on-error: true + name: Source Build (${{ matrix.name }}) + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container || '' }} + steps: + - name: Configure mirrors and install base tools (Alinux4) + if: matrix.container != '' + run: | + sed -i -e "s/cloud.aliyuncs/aliyun/g" /etc/yum.repos.d/*.repo + dnf install -y tar git sudo + # Fix sudo PAM in container: replace with permissive config + cat > /etc/pam.d/sudo <<'EOF' + #%PAM-1.0 + auth sufficient pam_rootok.so + account sufficient pam_permit.so + session sufficient pam_permit.so + EOF + - uses: actions/checkout@v4 + - name: Build and install + run: ./scripts/build-all.sh --component sec-core + - name: Verify CLI + run: | + agent-sec-cli --version + agent-sec-cli --help + - name: Verify sandbox + run: linux-sandbox --help + - name: Verify deployment + run: | + ls /usr/share/anolisa/skills/ + ls /usr/share/anolisa/extensions/agent-sec-core/ + - name: Run E2E tests + run: make -C src/agent-sec-core test-e2e-source-build From 5aec5cff04dd8b591e98d78ee5547509666256ad Mon Sep 17 00:00:00 2001 From: yizheng Date: Wed, 13 May 2026 10:26:01 +0800 Subject: [PATCH 16/23] ci(sec-core): check cosh python deps in ci Signed-off-by: yizheng --- .github/workflows/sec-core-rpmbuild.yaml | 41 ++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sec-core-rpmbuild.yaml b/.github/workflows/sec-core-rpmbuild.yaml index e6ab1fc71..c36162041 100644 --- a/.github/workflows/sec-core-rpmbuild.yaml +++ b/.github/workflows/sec-core-rpmbuild.yaml @@ -106,7 +106,7 @@ jobs: print(f'All {len(reqs) - skipped} dependencies verified ({skipped} skipped due to platform markers).') PYEOF - - name: Verify all Python imports + - name: Verify cli Python imports run: | export PYTHONPATH=/opt/agent-sec/lib/python3.11/site-packages${PYTHONPATH:+:$PYTHONPATH} python3 << 'PYEOF' @@ -145,8 +145,43 @@ jobs: print('All modules imported successfully') PYEOF - - name: Install E2E test dependencies - run: dnf install -y jq + - name: Verify cosh hook Python imports + run: | + python3 << 'PYEOF' + import importlib.util, pathlib, sys + + hooks_dir = pathlib.Path('/usr/share/anolisa/extensions/agent-sec-core/hooks') + print(f'Hooks directory: {hooks_dir.resolve()}') + + py_files = sorted(hooks_dir.glob('*.py')) + if not py_files: + print('ERROR: no hook .py files found', file=sys.stderr) + sys.exit(1) + + failed = [] + for pyfile in py_files: + mod_name = pyfile.stem.replace('-', '_') + spec = importlib.util.spec_from_file_location(mod_name, pyfile) + if spec is None: + print(f' {pyfile.name} ... FAILED: cannot create module spec') + failed.append((pyfile.name, 'cannot create module spec')) + continue + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + print(f' {pyfile.name} ... OK') + except Exception as e: + print(f' {pyfile.name} ... FAILED: {e}') + failed.append((pyfile.name, str(e))) + + print(f'\nTotal: {len(py_files)} hooks, {len(failed)} failed') + if failed: + print('\nFailed hooks:', file=sys.stderr) + for name, err in failed: + print(f' - {name}: {err}', file=sys.stderr) + sys.exit(1) + print('All hook scripts imported successfully') + PYEOF - name: Uninstall skills package before E2E tests run: rpm -e agent-sec-skills --nodeps From 5bdc220f6ef49c28bcc9991dea376db1c7a056c8 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Wed, 13 May 2026 16:01:12 +0800 Subject: [PATCH 17/23] fix(sec-core): support installed skill signing paths --- src/agent-sec-core/tools/SIGNING_GUIDE.md | 43 ++++- src/agent-sec-core/tools/SIGNING_GUIDE_CN.md | 40 ++++- src/agent-sec-core/tools/sign-skill.sh | 159 ++++++++++++++++++- 3 files changed, 224 insertions(+), 18 deletions(-) diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE.md b/src/agent-sec-core/tools/SIGNING_GUIDE.md index 959251830..27bc50a33 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE.md @@ -39,6 +39,28 @@ agent-sec-cli verify exports the public key to `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`. You can override the export path with `--trusted-keys-dir `. +## After Source Build Installation + +After running the unified source build, use the installed script and verifier: + +```bash +./scripts/build-all.sh --component sec-core + +# 1. One-time setup. The installed script auto-detects the trusted-keys +# directory used by agent-sec-cli verify. +/usr/local/bin/sign-skill.sh --init + +# 2. Sign the installed agent-sec-core skills. +/usr/local/bin/sign-skill.sh --batch /usr/share/anolisa/skills --force + +# 3. Verify all configured skill directories. +agent-sec-cli verify +``` + +For the default source-build install, `agent-sec-cli verify` already reads +`/usr/share/anolisa/skills` from its packaged `config.conf`, so no verification +directory argument is required. + ## Step-by-Step (Manual Key Management) If you prefer full control over GPG key management instead of using `--init`: @@ -66,8 +88,10 @@ gpg --list-secret-keys me@example.com ### 2. Export the Public Key The verifier loads trusted public keys from the packaged `agent_sec_cli/asset_verify/trusted-keys/` -directory. When running from this source checkout, `--init` exports to -`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/` automatically. +directory. When `agent-sec-cli` is installed, `sign-skill.sh` auto-detects this +directory from `agent_sec_cli.asset_verify.verifier`. When running only from this +source checkout, it falls back to +`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`. To re-export manually: ```bash @@ -114,10 +138,16 @@ Each signed skill directory will contain: ### 4. Configure the Verifier -`--batch` signs skill directories but does not edit verifier configuration. For -batch verification, make sure the skills root is listed in the verifier config +For installed `agent-sec-cli`, `--batch` uses the detected verifier +`config.conf` and registers the skills root before signing. For source-tree-only +or custom layouts, make sure the skills root is listed in the verifier config packaged with the CLI (`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf` -in this source tree): +in this source tree). You can also choose the config file explicitly: + +```bash +tools/sign-skill.sh --batch /custom/skills --force \ + --config-file /path/to/agent_sec_cli/asset_verify/config.conf +``` ```ini skills_dir = [ @@ -212,7 +242,7 @@ agent-sec-cli verify | **Check** | `--check` | Verify prerequisites (gpg, jq, sha256sum) | | **Single** | ` [--force]` | Sign one skill directory | | **Batch** | `--batch [--force]` | Sign all subdirectories under parent. | -| **Export** | `--export-key [DIR]` | Export public key (default: `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`) | +| **Export** | `--export-key [DIR]` | Export public key (default: auto-detected verifier `trusted-keys/`, then source-tree fallback) | Common options: @@ -221,3 +251,4 @@ Common options: | `--force` | Overwrite existing `.skill-meta/Manifest.json` and `.skill-meta/.skill.sig` | | `--skill-name NAME` | Override the skill name in the manifest (default: directory name) | | `--trusted-keys-dir DIR` | Override the public key export directory (used with `--init`) | +| `--config-file FILE` | Override the verifier config updated by `--batch` | diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md index 88c78f816..55c9e1a2e 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md @@ -39,6 +39,27 @@ agent-sec-cli verify `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。 可通过 `--trusted-keys-dir ` 覆盖导出路径。 +## 源码构建安装后的用法 + +执行统一源码构建后,使用已安装的脚本和校验器: + +```bash +./scripts/build-all.sh --component sec-core + +# 1. 一次性初始化。已安装脚本会自动识别 agent-sec-cli verify 使用的 +# trusted-keys 目录。 +/usr/local/bin/sign-skill.sh --init + +# 2. 签名已安装的 agent-sec-core skills。 +/usr/local/bin/sign-skill.sh --batch /usr/share/anolisa/skills --force + +# 3. 验证所有已配置的 skill 目录。 +agent-sec-cli verify +``` + +默认源码构建安装场景下,`agent-sec-cli verify` 已经从随包安装的 +`config.conf` 读取 `/usr/share/anolisa/skills`,因此不需要再指定验签目录。 + ## 手动逐步操作 如果你希望完全控制 GPG 密钥管理,而不使用 `--init`: @@ -66,8 +87,10 @@ gpg --list-secret-keys me@example.com ### 2. 导出公钥 校验器从打包后的 `agent_sec_cli/asset_verify/trusted-keys/` 目录加载受信公钥。 -在当前源码树中运行时,`--init` 会自动导出到 -`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。手动重新导出: +当 `agent-sec-cli` 已安装时,`sign-skill.sh` 会从 +`agent_sec_cli.asset_verify.verifier` 自动识别该目录;仅在源码树中运行时, +会回退到 `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。 +手动重新导出: ```bash tools/sign-skill.sh --export-key @@ -113,9 +136,15 @@ tools/sign-skill.sh --batch /usr/share/anolisa/skills --force ### 4. 配置校验器 -`--batch` 只负责签名 skill 目录,不会修改校验器配置。若要进行批量校验,请确保 +当使用已安装的 `agent-sec-cli` 时,`--batch` 会使用自动识别到的 verifier +`config.conf`,并在签名前注册 skill 根目录。对于仅源码树运行或自定义布局,请确保 skill 根目录已配置在随 CLI 打包的校验器配置中(当前源码树中的路径为 -`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf`): +`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf`)。也可以显式指定配置文件: + +```bash +tools/sign-skill.sh --batch /custom/skills --force \ + --config-file /path/to/agent_sec_cli/asset_verify/config.conf +``` ```ini skills_dir = [ @@ -210,7 +239,7 @@ agent-sec-cli verify | **检查** | `--check` | 检查前置依赖(gpg、jq、sha256sum) | | **单个签名** | ` [--force]` | 签名单个 skill 目录 | | **批量签名** | `--batch [--force]` | 签名目录下所有子目录。 | -| **导出公钥** | `--export-key [DIR]` | 导出公钥(默认:`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`) | +| **导出公钥** | `--export-key [DIR]` | 导出公钥(默认:自动识别 verifier 的 `trusted-keys/`,失败后回退源码树路径) | 常用选项: @@ -219,3 +248,4 @@ agent-sec-cli verify | `--force` | 覆盖已有的 `.skill-meta/Manifest.json` 和 `.skill-meta/.skill.sig` | | `--skill-name NAME` | 覆盖 Manifest 中的 skill 名称(默认:目录名) | | `--trusted-keys-dir DIR` | 覆盖公钥导出目录(配合 `--init` 使用) | +| `--config-file FILE` | 覆盖 `--batch` 更新的 verifier 配置文件 | diff --git a/src/agent-sec-core/tools/sign-skill.sh b/src/agent-sec-core/tools/sign-skill.sh index 51ea3e548..a77cc7aa0 100755 --- a/src/agent-sec-core/tools/sign-skill.sh +++ b/src/agent-sec-core/tools/sign-skill.sh @@ -55,6 +55,9 @@ AGENT_SEC_CORE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # Default path for trusted public keys in the verifier package data. DEFAULT_TRUSTED_KEYS_DIR="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys" +DEFAULT_CONFIG_FILE="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf" +VERIFIER_PATH_SOURCE="source" +VERIFIER_PATHS_RESOLVED=false # Resolve gpg binary: prefer 'gpg', fall back to 'gpg2' (RHEL/Alinux minimal) if command -v gpg &>/dev/null; then @@ -69,6 +72,45 @@ fi # or GPG_PRIVATE_KEY import; empty means "let gpg pick its default". GPG_SIGN_KEY="" +resolve_verifier_paths() { + if [[ "$VERIFIER_PATHS_RESOLVED" == true ]]; then + return 0 + fi + VERIFIER_PATHS_RESOLVED=true + + local py + local out + local trusted_keys_dir + local config_file + local candidates=("/opt/agent-sec/venv/bin/python" "python3") + + for py in "${candidates[@]}"; do + if [[ "$py" == */* ]]; then + [[ -x "$py" ]] || continue + else + command -v "$py" &>/dev/null || continue + fi + + out=$("$py" - <<'PY' 2>/dev/null || true +from agent_sec_cli.asset_verify import verifier +print(verifier.DEFAULT_TRUSTED_KEYS_DIR) +print(verifier.DEFAULT_CONFIG) +PY +) + trusted_keys_dir=$(printf '%s\n' "$out" | sed -n '1p') + config_file=$(printf '%s\n' "$out" | sed -n '2p') + + if [[ -n "$trusted_keys_dir" && -n "$config_file" ]]; then + DEFAULT_TRUSTED_KEYS_DIR="$trusted_keys_dir" + DEFAULT_CONFIG_FILE="$config_file" + VERIFIER_PATH_SOURCE="$py" + return 0 + fi + done + + return 0 +} + # Function to compute SHA256 hash of a file compute_file_hash() { local file_path="$1" @@ -139,6 +181,21 @@ sign_manifest() { local manifest_path="$1" local signature_path="$2" + local secret_key_query="${GPG_SIGN_KEY:-}" + if [[ -n "$secret_key_query" ]]; then + if ! "$GPG" --list-secret-keys --with-colons "$secret_key_query" 2>/dev/null | grep -q '^sec'; then + echo -e "${RED}ERROR: No GPG secret key found for '$secret_key_query'.${NC}" >&2 + echo "Run '$0 --init' first, or set GPG_PRIVATE_KEY before signing." >&2 + return 1 + fi + else + if ! "$GPG" --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec'; then + echo -e "${RED}ERROR: No GPG secret key is available for signing.${NC}" >&2 + echo "Run '$0 --init' first, or set GPG_PRIVATE_KEY before signing." >&2 + return 1 + fi + fi + local cmd=("$GPG" --batch --yes --armor --detach-sign --output "$signature_path") # Pin signing key so the correct key is used when multiple exist @@ -153,23 +210,85 @@ sign_manifest() { cmd+=("$manifest_path") + local gpg_err + gpg_err=$(mktemp) if [[ -n "${GPG_PASSPHRASE:-}" ]]; then - if ! "${cmd[@]}" <<<"$GPG_PASSPHRASE" 2>/dev/null; then + if ! "${cmd[@]}" <<<"$GPG_PASSPHRASE" 2>"$gpg_err"; then echo -e "${RED}ERROR: Failed to sign manifest${NC}" >&2 + sed 's/^/ gpg: /' "$gpg_err" >&2 + rm -f "$gpg_err" return 1 fi else - if ! "${cmd[@]}" 2>/dev/null; then + if ! "${cmd[@]}" 2>"$gpg_err"; then echo -e "${RED}ERROR: Failed to sign manifest${NC}" >&2 + sed 's/^/ gpg: /' "$gpg_err" >&2 + rm -f "$gpg_err" return 1 fi fi + rm -f "$gpg_err" return 0 } +ensure_config_dir_entry() { + local dir_to_add="$1" + local config_file="$2" + + if [[ -z "$config_file" ]]; then + return 0 + fi + if [[ ! -f "$config_file" ]]; then + echo -e "${YELLOW}NOTE: verifier config not found at $config_file; skipping skills_dir registration${NC}" + return 0 + fi + if [[ ! -w "$config_file" ]]; then + echo -e "${YELLOW}NOTE: verifier config is not writable at $config_file; skipping skills_dir registration${NC}" + return 0 + fi + + if awk -v target="$dir_to_add" ' + /skills_dir[[:space:]]*=/ { in_list=1; next } + in_list && /^[[:space:]]*\]/ { exit 1 } + in_list { + line=$0; gsub(/^[[:space:]]+|[[:space:],]+$/, "", line) + if (line == target) { found=1; exit 0 } + } + END { exit (found ? 0 : 1) } + ' "$config_file" 2>/dev/null; then + echo "Skills directory already registered in config.conf: $dir_to_add" + return 0 + fi + + local orig_mode + orig_mode=$(stat -c '%a' "$config_file" 2>/dev/null) \ + || orig_mode=$(stat -f '%Lp' "$config_file" 2>/dev/null) \ + || orig_mode="" + + local tmp_file + tmp_file=$(mktemp) + if ! awk -v entry=" $dir_to_add" ' + /skills_dir[[:space:]]*=/ { in_list=1 } + in_list && /^[[:space:]]*\]/ && !done { print entry; done=1 } + { print } + END { exit (done ? 0 : 1) } + ' "$config_file" > "$tmp_file"; then + rm -f "$tmp_file" + echo -e "${YELLOW}WARNING: Could not update config.conf; please add '$dir_to_add' manually${NC}" + return 0 + fi + + mv "$tmp_file" "$config_file" + if [[ -n "$orig_mode" ]]; then + chmod "$orig_mode" "$config_file" 2>/dev/null || true + fi + echo -e "${GREEN}Added skills directory to config.conf: $dir_to_add${NC}" +} + # Function to show usage show_usage() { + resolve_verifier_paths echo -e "${BOLD}Skill Manifest and Signature Generator${NC}" echo "" echo "Usage:" @@ -192,6 +311,8 @@ show_usage() { echo " --force Overwrite existing manifest and signature files" echo " --trusted-keys-dir DIR Where to export the public key (used with --init)" echo " (default: $DEFAULT_TRUSTED_KEYS_DIR)" + echo " --config-file FILE Verifier config.conf updated by --batch" + echo " (default: $DEFAULT_CONFIG_FILE)" echo " -h, --help Show this help message" echo "" echo "Quick Start (self-deployment):" @@ -379,9 +500,10 @@ GPGEOF do_export_key() { local output_dir="${1:-$DEFAULT_TRUSTED_KEYS_DIR}" + local key_to_export="${GPG_SIGN_KEY:-$SIGN_KEY_EMAIL}" - if ! "$GPG" --list-secret-keys "$SIGN_KEY_EMAIL" &>/dev/null 2>&1; then - echo -e "${RED}ERROR: No GPG secret key found for '$SIGN_KEY_EMAIL'.${NC}" >&2 + if ! "$GPG" --list-secret-keys --with-colons "$key_to_export" 2>/dev/null | grep -q '^sec'; then + echo -e "${RED}ERROR: No GPG secret key found for '$key_to_export'.${NC}" >&2 echo "Run '$0 --init' first to generate a signing key." >&2 return 1 fi @@ -389,15 +511,15 @@ do_export_key() { mkdir -p "$output_dir" local safe_name - safe_name=$(echo "$SIGN_KEY_EMAIL" | tr '@.' '-') + safe_name=$(echo "$key_to_export" | tr '@.:' '---') local output_file="$output_dir/${safe_name}.asc" - "$GPG" --armor --export "$SIGN_KEY_EMAIL" > "$output_file" + "$GPG" --armor --export "$key_to_export" > "$output_file" if [[ -s "$output_file" ]]; then echo -e "${GREEN}Public key exported: $output_file${NC}" else - echo -e "${RED}ERROR: Failed to export public key for $SIGN_KEY_EMAIL${NC}" >&2 + echo -e "${RED}ERROR: Failed to export public key for $key_to_export${NC}" >&2 rm -f "$output_file" return 1 fi @@ -414,6 +536,8 @@ main() { local mode="" # "", "init", "export-key", "check" local trusted_keys_dir="" local export_key_dir="" + local config_file="" + local config_file_explicit=false # Import GPG private key from environment variable if provided if [[ -n "${GPG_PRIVATE_KEY:-}" ]]; then @@ -490,6 +614,12 @@ main() { trusted_keys_dir="$2" shift 2 ;; + --config-file) + [[ -n "${2:-}" ]] || { echo -e "${RED}ERROR: --config-file requires a file path${NC}" >&2; exit 1; } + config_file="$2" + config_file_explicit=true + shift 2 + ;; --batch) batch=true if [[ -n "${2:-}" && "${2:0:1}" != "-" ]]; then @@ -531,6 +661,14 @@ main() { esac done + resolve_verifier_paths + if [[ -z "$trusted_keys_dir" ]]; then + trusted_keys_dir="$DEFAULT_TRUSTED_KEYS_DIR" + fi + if [[ -z "$config_file" ]]; then + config_file="$DEFAULT_CONFIG_FILE" + fi + # ── Mode dispatch ── if [[ "$mode" == "check" ]]; then @@ -544,6 +682,9 @@ main() { fi if [[ "$mode" == "export-key" ]]; then + if [[ -z "$export_key_dir" ]]; then + export_key_dir="$DEFAULT_TRUSTED_KEYS_DIR" + fi do_export_key "$export_key_dir" exit $? fi @@ -557,6 +698,10 @@ main() { exit 1 fi + if $config_file_explicit || [[ "$config_file" != "$AGENT_SEC_CORE_DIR/"* ]]; then + ensure_config_dir_entry "$batch_dir" "$config_file" + fi + echo "Batch signing skills under: $batch_dir" echo "" From 716db9255b6578782c282a51cc0a2e4206f56661 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Wed, 13 May 2026 17:26:01 +0800 Subject: [PATCH 18/23] test(sec-core): run skill signing e2e in ci --- .github/workflows/sec-core-rpmbuild.yaml | 2 +- src/agent-sec-core/Makefile | 4 - .../tests/e2e/skill-signing/e2e_test.py | 812 ++++++++++-------- 3 files changed, 456 insertions(+), 362 deletions(-) diff --git a/.github/workflows/sec-core-rpmbuild.yaml b/.github/workflows/sec-core-rpmbuild.yaml index c36162041..aa0d5232d 100644 --- a/.github/workflows/sec-core-rpmbuild.yaml +++ b/.github/workflows/sec-core-rpmbuild.yaml @@ -25,7 +25,7 @@ jobs: sed -i -e "s/cloud.aliyuncs/aliyun/g" /etc/yum.repos.d/*.repo dnf install -y tar git rpm-build gcc make clang llvm \ openssl-devel libseccomp-devel bubblewrap \ - python3-pip + python3-pip gnupg2 jq - uses: actions/checkout@v4 diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 64a25e503..86ec7549b 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -67,14 +67,12 @@ test-e2e-rpm: ## Run E2E tests against RPM-installed agent-sec-cli binary python3 -m pytest tests/e2e/ \ --import-mode=importlib \ --ignore=tests/e2e/skill-ledger \ - --ignore=tests/e2e/skill-signing \ --ignore=tests/e2e/linux-sandbox \ --ignore=tests/e2e/prompt-scanner \ -k 'not test_error_event_writes_to_sqlite' \ -v --tb=short @# standalone-script e2e suites (not pytest-compatible) python3 tests/e2e/skill-ledger/e2e_test.py - @# skill-signing e2e skipped: imports Python source code @# linux-sandbox e2e skipped: requires privileged container VENV_PYTHON ?= /opt/agent-sec/venv/bin/python @@ -87,13 +85,11 @@ test-e2e-source-build: ## Run E2E tests against source-build-installed agent-sec $(VENV_PYTHON) -m pytest tests/e2e/ \ --import-mode=importlib \ --ignore=tests/e2e/skill-ledger \ - --ignore=tests/e2e/skill-signing \ --ignore=tests/e2e/linux-sandbox \ --ignore=tests/e2e/prompt-scanner \ -k 'not test_error_event_writes_to_sqlite' \ -v --tb=short @# skill-ledger e2e skipped: installed system skills affect G6/G8 expected results - @# skill-signing e2e skipped: imports Python source code @# linux-sandbox e2e skipped: requires privileged container .PHONY: test-python-coverage diff --git a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py index 3cd60c0c5..9fce8751e 100644 --- a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py +++ b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py @@ -1,16 +1,13 @@ #!/usr/bin/env python3 -"""End-to-end tests for skill signing (sign-skill.sh) and verification (verifier.py). +"""Pytest E2E tests for skill signing and verification. -Exercises the full pipeline: - 1. sign-skill.sh --init → GPG key generation + public key export - 2. sign-skill.sh → single skill signing - 3. sign-skill.sh --batch → batch skill signing - 4. verifier.py → signature + hash verification +The default tests exercise the source-tree ``sign-skill.sh`` against temporary +skills, trusted keys, and verifier config. When a source-build installation is +detected, the installed-path test also runs the user workflow: -All GPG operations use an isolated GNUPGHOME so the host keyring is never -touched. - -Prerequisites: gpg, jq, python3 + /usr/local/bin/sign-skill.sh --init + /usr/local/bin/sign-skill.sh --batch /usr/share/anolisa/skills --force + agent-sec-cli verify """ import json @@ -19,158 +16,214 @@ import subprocess import sys import tempfile -from dataclasses import dataclass, field +import uuid +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Iterable, Optional + +import pytest -# ── Paths ────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +# Path and import resolution +# --------------------------------------------------------------------------- REPO_ROOT = Path(__file__).resolve().parents[3] # agent-sec-core/ SIGN_SKILL_SH = REPO_ROOT / "tools" / "sign-skill.sh" -VERIFIER_DIR = REPO_ROOT / "agent-sec-cli" / "src" / "agent_sec_cli" / "asset_verify" -VERIFIER_PY = VERIFIER_DIR / "verifier.py" +SOURCE_PYTHONPATH = REPO_ROOT / "agent-sec-cli" / "src" SIGNING_DIR = ".skill-meta" -# Make verifier importable -sys.path.insert(0, str(VERIFIER_DIR)) +# Prefer the source-tree package, even when pytest is launched from the +# installed source-build venv or an RPM test environment. +sys.path.insert(0, str(SOURCE_PYTHONPATH)) -from errors import ( # noqa: E402 +from agent_sec_cli.asset_verify.errors import ( # noqa: E402 ErrHashMismatch, ErrSigInvalid, ErrSigMissing, ErrUnexpectedFile, ) -from verifier import load_trusted_keys, verify_skill # noqa: E402 - -# ── Colours ──────────────────────────────────────────────────────────────── - -RED = "\033[0;31m" -GREEN = "\033[0;32m" -YELLOW = "\033[1;33m" -BLUE = "\033[0;34m" -BOLD = "\033[1m" -NC = "\033[0m" - - -# ── Result tracker ───────────────────────────────────────────────────────── +from agent_sec_cli.asset_verify.verifier import ( # noqa: E402 + load_trusted_keys, + verify_skill, +) @dataclass -class Results: - passed: int = 0 - failed: int = 0 - errors: list = field(default_factory=list) - - -results = Results() - - -# ── Helpers ──────────────────────────────────────────────────────────────── +class Workspace: + """Shared source-tree signing workspace.""" + + root: Path + gnupg_home: Path + trusted_keys: Path + skills_dir: Path + config_file: Path + + def env(self, extra: Optional[dict[str, str]] = None) -> dict[str, str]: + env = os.environ.copy() + env["GNUPGHOME"] = str(self.gnupg_home) + env["LC_ALL"] = "C" + env["LANG"] = "C" + if extra: + env.update(extra) + return env + + +def require_tools(*tools: str) -> None: + missing = [tool for tool in tools if shutil.which(tool) is None] + if missing: + pytest.skip(f"missing required tool(s): {', '.join(missing)}") + + +def require_passwordless_sudo() -> None: + sudo_bin = shutil.which("sudo") + if not sudo_bin: + pytest.skip("installed paths require sudo, but sudo is not available") + + probe = run_command([sudo_bin, "-n", "true"], timeout=10) + if probe.returncode != 0: + pytest.skip("installed paths require sudo, but sudo -n is not available") + + +def run_command( + args: Iterable[str | Path], + *, + env: Optional[dict[str, str]] = None, + input_text: Optional[str] = None, + timeout: int = 120, +) -> subprocess.CompletedProcess: + return subprocess.run( + [str(arg) for arg in args], + capture_output=True, + text=True, + env=env, + input=input_text, + timeout=timeout, + ) def run_sign_skill( args: list[str], - env_extra: Optional[dict] = None, + *, + ws: Optional[Workspace] = None, + env_extra: Optional[dict[str, str]] = None, + script: Path = SIGN_SKILL_SH, + timeout: int = 120, ) -> subprocess.CompletedProcess: - """Run sign-skill.sh with the given arguments in the isolated env.""" - env = os.environ.copy() - if env_extra: - env.update(env_extra) - cmd = ["bash", str(SIGN_SKILL_SH)] + args - return subprocess.run(cmd, capture_output=True, text=True, env=env) - + """Run sign-skill.sh with an isolated environment when a workspace is given.""" + env = ws.env(env_extra) if ws else os.environ.copy() + return run_command(["bash", script, *args], env=env, timeout=timeout) -def make_skill(parent: Path, name: str, files: dict[str, str]) -> Path: - """Create a fake skill directory with the given files. - ``files`` maps relative path → content. - Returns the skill directory path. - """ - skill_dir = parent / name - for rel, content in files.items(): - p = skill_dir / rel - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(content) - return skill_dir - - -def test(name: str, fn): - """Run a single named test, catch exceptions, record results.""" - print(f"\n{BLUE}--- {name} ---{NC}") - try: - fn() - print(f"{GREEN}✓ PASS{NC}") - results.passed += 1 - except AssertionError as exc: - print(f"{RED}✗ FAIL {exc}{NC}") - results.failed += 1 - results.errors.append((name, exc)) - except Exception as exc: - print(f"{RED}✗ ERROR {exc}{NC}") - results.failed += 1 - results.errors.append((name, exc)) +def run_maybe_sudo( + args: Iterable[str | Path], + *, + env: Optional[dict[str, str]] = None, + sudo: bool = False, + timeout: int = 120, +) -> subprocess.CompletedProcess: + cmd = [str(arg) for arg in args] + run_env = os.environ.copy() + if env: + run_env.update(env) + + if sudo and os.geteuid() != 0: + sudo_bin = shutil.which("sudo") + if not sudo_bin: + pytest.fail("sudo is required for installed skill signing e2e") + preserved = [ + f"{key}={run_env[key]}" + for key in ("GNUPGHOME", "PATH", "LC_ALL", "LANG") + if key in run_env + ] + cmd = [sudo_bin, "-n", "env", *preserved, *cmd] + run_env = os.environ.copy() + return subprocess.run( + cmd, + capture_output=True, + text=True, + env=run_env, + timeout=timeout, + ) -# We reuse a single temp workspace across all tests so the GPG key only -# needs to be generated once. +def make_skill(parent: Path, name: str, files: dict[str, str]) -> Path: + """Create a fake skill directory with the given files.""" + skill_dir = parent / name + for rel_path, content in files.items(): + path = skill_dir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return skill_dir -class Workspace: - """Shared test workspace: isolated GNUPGHOME, trusted-keys dir, etc.""" - def __init__(self): - self.root = Path(tempfile.mkdtemp(prefix="e2e_sign_")) - self.gnupg_home = self.root / "gnupg" - self.gnupg_home.mkdir(mode=0o700) - self.trusted_keys = self.root / "trusted-keys" - self.trusted_keys.mkdir() - self.skills_dir = self.root / "skills" - self.skills_dir.mkdir() +def assert_success(result: subprocess.CompletedProcess, context: str) -> None: + assert result.returncode == 0, ( + f"{context} failed with exit {result.returncode}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) - # Propagate isolated GNUPGHOME to all child processes - os.environ["GNUPGHOME"] = str(self.gnupg_home) - def cleanup(self): - if "GNUPGHOME" in os.environ: - del os.environ["GNUPGHOME"] - shutil.rmtree(self.root, ignore_errors=True) +@pytest.fixture(scope="module") +def signing_ws(tmp_path_factory: pytest.TempPathFactory) -> Workspace: + """Initialize one isolated source-tree signing workspace for the module.""" + require_tools("gpg", "jq") + assert SIGN_SKILL_SH.exists(), f"{SIGN_SKILL_SH} not found" + + tmp_root = Path(os.environ.get("ANOLISA_E2E_TMPDIR", "/tmp")) + if not tmp_root.is_dir(): + tmp_root = tmp_path_factory.mktemp("e2e_sign_root") + root = Path(tempfile.mkdtemp(prefix="agent-sec-e2e-sign-", dir=tmp_root)) + gnupg_home = root / "gnupg" + gnupg_home.mkdir(mode=0o700) + trusted_keys = root / "trusted-keys" + trusted_keys.mkdir() + skills_dir = root / "skills" + skills_dir.mkdir() + config_file = root / "config.conf" + config_file.write_text("skills_dir = [\n]\n") + + ws = Workspace( + root=root, + gnupg_home=gnupg_home, + trusted_keys=trusted_keys, + skills_dir=skills_dir, + config_file=config_file, + ) + result = run_sign_skill( + ["--init", "--trusted-keys-dir", str(ws.trusted_keys)], + ws=ws, + ) + assert_success(result, "--init") -# ── Test cases ───────────────────────────────────────────────────────────── + try: + yield ws + finally: + shutil.rmtree(root, ignore_errors=True) -def test_check(ws: Workspace): +def test_check_reports_prerequisites() -> None: """--check should report all prerequisites OK.""" - r = run_sign_skill(["--check"]) - assert r.returncode == 0, f"exit {r.returncode}: {r.stderr}" - combined = r.stdout + r.stderr - assert "All prerequisites satisfied" in combined, combined + require_tools("gpg", "jq") + result = run_sign_skill(["--check"]) + assert_success(result, "--check") + assert "All prerequisites satisfied" in result.stdout + result.stderr -def test_init(ws: Workspace): - """--init generates a GPG key and exports the public key.""" - r = run_sign_skill( - [ - "--init", - "--trusted-keys-dir", - str(ws.trusted_keys), - ] - ) - assert r.returncode == 0, f"exit {r.returncode}: {r.stdout}\n{r.stderr}" - - # Public key file must exist - asc_files = list(ws.trusted_keys.glob("*.asc")) - assert ( - len(asc_files) >= 1 - ), f"No .asc in {ws.trusted_keys}: {list(ws.trusted_keys.iterdir())}" +def test_init_exports_public_key(signing_ws: Workspace) -> None: + """The module fixture should generate and export a signing public key.""" + asc_files = list(signing_ws.trusted_keys.glob("*.asc")) + assert asc_files, f"No .asc in {signing_ws.trusted_keys}" assert asc_files[0].stat().st_size > 0, "Exported .asc is empty" -def test_single_sign_and_verify(ws: Workspace): +def test_single_sign_and_verify(signing_ws: Workspace) -> None: """Sign a single skill, then verify with the verifier module.""" skill = make_skill( - ws.skills_dir, + signing_ws.skills_dir, "skill-a", { "main.py": "print('hello')\n", @@ -178,114 +231,135 @@ def test_single_sign_and_verify(ws: Workspace): }, ) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0, f"exit {r.returncode}: {r.stdout}\n{r.stderr}" + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "single sign") - # Manifest and signature must exist inside .skill-meta/ signing = skill / SIGNING_DIR assert (signing / "Manifest.json").exists(), ".skill-meta/Manifest.json missing" assert (signing / ".skill.sig").exists(), ".skill-meta/.skill.sig missing" - # Manifest must contain our files manifest = json.loads((signing / "Manifest.json").read_text()) - paths_in_manifest = {f["path"] for f in manifest["files"]} - assert ( - "main.py" in paths_in_manifest - ), f"main.py not in manifest: {paths_in_manifest}" - assert ( - "README.md" in paths_in_manifest - ), f"README.md not in manifest: {paths_in_manifest}" - # .skill-meta/ contents should NOT be in manifest + paths_in_manifest = {file_entry["path"] for file_entry in manifest["files"]} + assert "main.py" in paths_in_manifest + assert "README.md" in paths_in_manifest assert "Manifest.json" not in paths_in_manifest assert ".skill.sig" not in paths_in_manifest - signing_paths = [p for p in paths_in_manifest if p.startswith(".skill-meta")] - assert not signing_paths, f".skill-meta paths should be excluded: {signing_paths}" + assert not [path for path in paths_in_manifest if path.startswith(".skill-meta")] - # Verify with verifier - keys = load_trusted_keys(ws.trusted_keys) + keys = load_trusted_keys(signing_ws.trusted_keys) ok, name = verify_skill(str(skill), keys) - assert ok, "verify_skill returned False" + assert ok assert name == "skill-a" -def test_batch_sign_and_verify(ws: Workspace): - """Batch-sign multiple skills, then verify each.""" - batch_root = ws.root / "batch_skills" +def test_batch_sign_registers_explicit_config_and_verifies( + signing_ws: Workspace, +) -> None: + """Batch-sign multiple skills and register only the temporary config.""" + batch_root = signing_ws.root / "batch_skills" batch_root.mkdir() - for sname, content in [("alpha", "A"), ("beta", "B"), ("gamma", "C")]: - make_skill(batch_root, sname, {"data.txt": content}) + for skill_name, content in [("alpha", "A"), ("beta", "B"), ("gamma", "C")]: + make_skill(batch_root, skill_name, {"data.txt": content}) - r = run_sign_skill(["--batch", str(batch_root), "--force"]) - assert r.returncode == 0, f"exit {r.returncode}: {r.stdout}\n{r.stderr}" - assert "3/3" in r.stdout, f"Expected 3/3 in output: {r.stdout}" + result = run_sign_skill( + [ + "--batch", + str(batch_root), + "--force", + "--config-file", + str(signing_ws.config_file), + ], + ws=signing_ws, + ) + assert_success(result, "batch sign") + assert "3/3" in result.stdout + assert str(batch_root.resolve()) in signing_ws.config_file.read_text() - keys = load_trusted_keys(ws.trusted_keys) - for sname in ("alpha", "beta", "gamma"): - ok, name = verify_skill(str(batch_root / sname), keys) - assert ok, f"verify_skill failed for {sname}" - assert name == sname + keys = load_trusted_keys(signing_ws.trusted_keys) + for skill_name in ("alpha", "beta", "gamma"): + ok, name = verify_skill(str(batch_root / skill_name), keys) + assert ok, f"verify_skill failed for {skill_name}" + assert name == skill_name -def test_force_overwrite(ws: Workspace): +def test_force_overwrite(signing_ws: Workspace) -> None: """--force overwrites existing manifest and signature.""" - skill = make_skill(ws.skills_dir, "skill-force", {"f.txt": "v1"}) + skill = make_skill(signing_ws.skills_dir, "skill-force", {"f.txt": "v1"}) - r1 = run_sign_skill([str(skill), "--force"]) - assert r1.returncode == 0 + first = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(first, "initial sign") sig1 = (skill / SIGNING_DIR / ".skill.sig").read_text() - # Change content and re-sign (skill / "f.txt").write_text("v2") - r2 = run_sign_skill([str(skill), "--force"]) - assert r2.returncode == 0 + second = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(second, "re-sign") sig2 = (skill / SIGNING_DIR / ".skill.sig").read_text() assert sig1 != sig2, "Signature should differ after content change" - # Verify new signature - keys = load_trusted_keys(ws.trusted_keys) + keys = load_trusted_keys(signing_ws.trusted_keys) ok, _ = verify_skill(str(skill), keys) assert ok -def test_no_force_rejects(ws: Workspace): +def test_no_force_rejects_existing(signing_ws: Workspace) -> None: """Without --force, existing manifest/sig blocks signing.""" - skill = make_skill(ws.skills_dir, "skill-noforce", {"x.txt": "x"}) + skill = make_skill(signing_ws.skills_dir, "skill-noforce", {"x.txt": "x"}) + + first = run_sign_skill([str(skill)], ws=signing_ws) + assert_success(first, "initial sign") + + second = run_sign_skill([str(skill)], ws=signing_ws) + assert second.returncode != 0, "Expected non-zero exit without --force" + assert "already exists" in second.stdout + second.stderr + + +def test_no_secret_key_error_is_actionable(signing_ws: Workspace) -> None: + """Signing without a secret key should fail before creating .skill.sig.""" + blank_home = signing_ws.root / "no_key_gpg" + blank_home.mkdir(mode=0o700) + skill = make_skill(signing_ws.skills_dir, "skill-no-key", {"x.txt": "x"}) - r1 = run_sign_skill([str(skill)]) - assert r1.returncode == 0 + result = run_sign_skill( + [str(skill), "--force"], + ws=signing_ws, + env_extra={"GNUPGHOME": str(blank_home)}, + ) - # Second run without --force should fail - r2 = run_sign_skill([str(skill)]) - assert r2.returncode != 0, "Expected non-zero exit without --force" - assert "already exists" in r2.stdout + r2.stderr + assert result.returncode != 0, "Expected signing to fail without a secret key" + combined = result.stdout + result.stderr + assert "No GPG secret key" in combined + assert "--init" in combined + assert "GPG_PRIVATE_KEY" in combined + assert not (skill / SIGNING_DIR / ".skill.sig").exists() -def test_export_key_default_and_custom(ws: Workspace): +def test_export_key_to_custom_dir(signing_ws: Workspace) -> None: """--export-key exports to a specified directory.""" - custom_dir = ws.root / "custom_keys" - r = run_sign_skill(["--export-key", str(custom_dir)]) - assert r.returncode == 0, f"exit {r.returncode}: {r.stdout}\n{r.stderr}" + custom_dir = signing_ws.root / "custom_keys" + result = run_sign_skill(["--export-key", str(custom_dir)], ws=signing_ws) + assert_success(result, "--export-key custom") asc_files = list(custom_dir.glob("*.asc")) - assert len(asc_files) >= 1, f"No .asc in {custom_dir}" + assert asc_files, f"No .asc in {custom_dir}" -def test_skill_name_override(ws: Workspace): +def test_skill_name_override(signing_ws: Workspace) -> None: """--skill-name overrides the skill name in the manifest.""" - skill = make_skill(ws.skills_dir, "skill-rename", {"a.txt": "a"}) - r = run_sign_skill([str(skill), "--skill-name", "custom-name", "--force"]) - assert r.returncode == 0 + skill = make_skill(signing_ws.skills_dir, "skill-rename", {"a.txt": "a"}) + result = run_sign_skill( + [str(skill), "--skill-name", "custom-name", "--force"], + ws=signing_ws, + ) + assert_success(result, "skill name override") manifest = json.loads((skill / SIGNING_DIR / "Manifest.json").read_text()) - assert ( - manifest["skill_name"] == "custom-name" - ), f"Expected 'custom-name', got '{manifest['skill_name']}'" + assert manifest["skill_name"] == "custom-name" -def test_hidden_files_excluded(ws: Workspace): +def test_hidden_files_excluded(signing_ws: Workspace) -> None: """Hidden files and directories are excluded from the manifest.""" skill = make_skill( - ws.skills_dir, + signing_ws.skills_dir, "skill-hidden", { "visible.txt": "ok", @@ -293,190 +367,182 @@ def test_hidden_files_excluded(ws: Workspace): ".hidden_dir/inner.txt": "secret2", }, ) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "hidden file sign") manifest = json.loads((skill / SIGNING_DIR / "Manifest.json").read_text()) - paths = {f["path"] for f in manifest["files"]} + paths = {file_entry["path"] for file_entry in manifest["files"]} assert "visible.txt" in paths - assert ".hidden_file" not in paths, f".hidden_file should be excluded: {paths}" - assert ( - ".hidden_dir/inner.txt" not in paths - ), f".hidden_dir should be excluded: {paths}" - # .skill-meta dir itself should not appear - meta_paths = [p for p in paths if p.startswith(".skill-meta")] - assert not meta_paths, f".skill-meta paths should be excluded: {meta_paths}" + assert ".hidden_file" not in paths + assert ".hidden_dir/inner.txt" not in paths + assert not [path for path in paths if path.startswith(".skill-meta")] -def test_tampered_file_detected(ws: Workspace): +def test_tampered_file_detected(signing_ws: Workspace) -> None: """Verifier detects file content tampering after signing.""" - skill = make_skill(ws.skills_dir, "skill-tamper", {"payload.txt": "original"}) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + skill = make_skill( + signing_ws.skills_dir, "skill-tamper", {"payload.txt": "original"} + ) + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "tamper setup sign") - # Tamper with the file (skill / "payload.txt").write_text("TAMPERED") - keys = load_trusted_keys(ws.trusted_keys) - try: + keys = load_trusted_keys(signing_ws.trusted_keys) + with pytest.raises(ErrHashMismatch): verify_skill(str(skill), keys) - assert False, "Expected ErrHashMismatch" - except ErrHashMismatch: - pass # expected -def test_unsigned_reference_file_detected(ws: Workspace): +def test_unsigned_reference_file_detected(signing_ws: Workspace) -> None: """Verifier detects new files added under references after signing.""" skill = make_skill( - ws.skills_dir, + signing_ws.skills_dir, "skill-extra-file", { "SKILL.md": "# Skill\n", "references/original.md": "signed\n", }, ) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "extra file setup sign") - # Empty files are still unsigned payloads when they are absent from Manifest.json. (skill / "references" / "a.md").write_text("") - keys = load_trusted_keys(ws.trusted_keys) - try: + keys = load_trusted_keys(signing_ws.trusted_keys) + with pytest.raises(ErrUnexpectedFile) as exc_info: verify_skill(str(skill), keys) - assert False, "Expected ErrUnexpectedFile" - except ErrUnexpectedFile as exc: - assert "references/a.md" in str(exc) + assert "references/a.md" in str(exc_info.value) -def test_missing_sig_detected(ws: Workspace): +def test_missing_sig_detected(signing_ws: Workspace) -> None: """Verifier raises ErrSigMissing when .skill.sig is deleted.""" - skill = make_skill(ws.skills_dir, "skill-nosig", {"f.txt": "f"}) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + skill = make_skill(signing_ws.skills_dir, "skill-nosig", {"f.txt": "f"}) + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "missing sig setup sign") (skill / SIGNING_DIR / ".skill.sig").unlink() - keys = load_trusted_keys(ws.trusted_keys) - try: + keys = load_trusted_keys(signing_ws.trusted_keys) + with pytest.raises(ErrSigMissing): verify_skill(str(skill), keys) - assert False, "Expected ErrSigMissing" - except ErrSigMissing: - pass -def test_wrong_key_rejected(ws: Workspace): +def test_wrong_key_rejected(signing_ws: Workspace) -> None: """Signature made with key A is rejected when verified with key B only.""" - # Generate a completely separate key pair in a different GNUPGHOME - alt_dir = ws.root / "alt_gpg" + alt_dir = signing_ws.root / "alt_gpg" alt_dir.mkdir(mode=0o700) - alt_keys = ws.root / "alt_keys" + alt_keys = signing_ws.root / "alt_keys" alt_keys.mkdir() + env = signing_ws.env() + + generate = run_command( + ["gpg", "--homedir", alt_dir, "--batch", "--gen-key"], + env=env, + input_text=( + "Key-Type: RSA\n" + "Key-Length: 2048\n" + "Name-Real: Alt Key\n" + "Name-Email: alt@test.local\n" + "Expire-Date: 0\n" + "%no-protection\n" + "%commit\n" + ), + ) + assert_success(generate, "generate alt key") - # Generate alt key - subprocess.run( - ["gpg", "--homedir", str(alt_dir), "--batch", "--gen-key"], - input=( - "Key-Type: RSA\nKey-Length: 2048\nName-Real: Alt Key\n" - "Name-Email: alt@test.local\nExpire-Date: 0\n%no-protection\n%commit\n" - ).encode(), - capture_output=True, + export_alt = run_command( + ["gpg", "--homedir", alt_dir, "--armor", "--export", "alt@test.local"], + env=env, ) + assert_success(export_alt, "export alt public key") alt_pub = alt_keys / "alt.asc" - with open(alt_pub, "w") as f: - subprocess.run( - ["gpg", "--homedir", str(alt_dir), "--armor", "--export", "alt@test.local"], - stdout=f, - ) + alt_pub.write_text(export_alt.stdout) assert alt_pub.stat().st_size > 0, "Failed to export alt public key" - # Skill was signed with the INIT key (ws GNUPGHOME), but verify with ALT - # key only → should fail - skill = make_skill(ws.skills_dir, "skill-wrongkey", {"z.txt": "z"}) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + skill = make_skill(signing_ws.skills_dir, "skill-wrongkey", {"z.txt": "z"}) + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "wrong key setup sign") alt_trusted = load_trusted_keys(alt_keys) - try: + with pytest.raises(ErrSigInvalid): verify_skill(str(skill), alt_trusted) - assert False, "Expected ErrSigInvalid" - except ErrSigInvalid: - pass -def test_gpg_private_key_env(ws: Workspace): +def test_gpg_private_key_env(signing_ws: Workspace) -> None: """GPG_PRIVATE_KEY env var import + signing works end-to-end.""" - # Create a fresh GNUPGHOME with a new key - env_dir = ws.root / "env_gpg" + env_dir = signing_ws.root / "env_gpg" env_dir.mkdir(mode=0o700) - subprocess.run( - ["gpg", "--homedir", str(env_dir), "--batch", "--gen-key"], - input=( - "Key-Type: RSA\nKey-Length: 2048\nName-Real: Env Key\n" - "Name-Email: env@test.local\nExpire-Date: 0\n%no-protection\n%commit\n" - ).encode(), - capture_output=True, + env = signing_ws.env() + + generate = run_command( + ["gpg", "--homedir", env_dir, "--batch", "--gen-key"], + env=env, + input_text=( + "Key-Type: RSA\n" + "Key-Length: 2048\n" + "Name-Real: Env Key\n" + "Name-Email: env@test.local\n" + "Expire-Date: 0\n" + "%no-protection\n" + "%commit\n" + ), ) + assert_success(generate, "generate env key") - # Export private key - priv = subprocess.run( + private_key = run_command( [ "gpg", "--homedir", - str(env_dir), + env_dir, "--armor", "--export-secret-keys", "env@test.local", ], - capture_output=True, - text=True, + env=env, ) - assert priv.returncode == 0 and len(priv.stdout) > 100, "Private key export failed" + assert_success(private_key, "export env private key") + assert len(private_key.stdout) > 100, "Private key export was unexpectedly short" - # Export public key for verification - env_keys = ws.root / "env_keys" + env_keys = signing_ws.root / "env_keys" env_keys.mkdir() - pub_path = env_keys / "env.asc" - with open(pub_path, "w") as f: - subprocess.run( - ["gpg", "--homedir", str(env_dir), "--armor", "--export", "env@test.local"], - stdout=f, - ) + public_key = run_command( + ["gpg", "--homedir", env_dir, "--armor", "--export", "env@test.local"], + env=env, + ) + assert_success(public_key, "export env public key") + (env_keys / "env.asc").write_text(public_key.stdout) - # Use a blank GNUPGHOME so the only way sign-skill.sh can sign is via import - blank_home = ws.root / "blank_gpg" + blank_home = signing_ws.root / "blank_gpg" blank_home.mkdir(mode=0o700) - skill = make_skill(ws.skills_dir, "skill-envkey", {"e.txt": "env"}) - r = run_sign_skill( + skill = make_skill(signing_ws.skills_dir, "skill-envkey", {"e.txt": "env"}) + result = run_sign_skill( [str(skill), "--force"], + ws=signing_ws, env_extra={ "GNUPGHOME": str(blank_home), - "GPG_PRIVATE_KEY": priv.stdout, + "GPG_PRIVATE_KEY": private_key.stdout, }, ) - assert r.returncode == 0, f"exit {r.returncode}: {r.stdout}\n{r.stderr}" - assert ( - "imported and trusted" in r.stdout + r.stderr - ), f"Expected import message: {r.stdout}\n{r.stderr}" + assert_success(result, "GPG_PRIVATE_KEY sign") + assert "imported and trusted" in result.stdout + result.stderr - # Verify env_trusted = load_trusted_keys(env_keys) ok, _ = verify_skill(str(skill), env_trusted) assert ok -def test_manifest_structure(ws: Workspace): +def test_manifest_structure(signing_ws: Workspace) -> None: """Manifest JSON has the expected schema fields.""" skill = make_skill( - ws.skills_dir, + signing_ws.skills_dir, "skill-schema", { "script.sh": "#!/bin/bash\necho hi\n", }, ) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "manifest schema sign") manifest = json.loads((skill / SIGNING_DIR / "Manifest.json").read_text()) for key in ("version", "skill_name", "algorithm", "created_at", "files"): @@ -486,13 +552,13 @@ def test_manifest_structure(ws: Workspace): assert manifest["skill_name"] == "skill-schema" assert len(manifest["files"]) == 1 assert manifest["files"][0]["path"] == "script.sh" - assert len(manifest["files"][0]["hash"]) == 64 # SHA256 hex + assert len(manifest["files"][0]["hash"]) == 64 -def test_subdirectory_files(ws: Workspace): +def test_subdirectory_files(signing_ws: Workspace) -> None: """Files in nested subdirectories are included in the manifest.""" skill = make_skill( - ws.skills_dir, + signing_ws.skills_dir, "skill-nested", { "top.txt": "top", @@ -500,91 +566,123 @@ def test_subdirectory_files(ws: Workspace): "sub/deeper/leaf.txt": "leaf", }, ) - r = run_sign_skill([str(skill), "--force"]) - assert r.returncode == 0 + result = run_sign_skill([str(skill), "--force"], ws=signing_ws) + assert_success(result, "nested files sign") manifest = json.loads((skill / SIGNING_DIR / "Manifest.json").read_text()) - paths = {f["path"] for f in manifest["files"]} - assert paths == {"top.txt", "sub/deep.txt", "sub/deeper/leaf.txt"}, paths + paths = {file_entry["path"] for file_entry in manifest["files"]} + assert paths == {"top.txt", "sub/deep.txt", "sub/deeper/leaf.txt"} - keys = load_trusted_keys(ws.trusted_keys) + keys = load_trusted_keys(signing_ws.trusted_keys) ok, _ = verify_skill(str(skill), keys) assert ok -# ── Main ─────────────────────────────────────────────────────────────────── +def test_source_build_installed_signing_and_verify() -> None: + """Sign installed source-build skills and verify through agent-sec-cli.""" + require_tools("gpg", "jq") + installed_script = Path( + os.environ.get("ANOLISA_INSTALLED_SIGN_SKILL", "/usr/local/bin/sign-skill.sh") + ) + skills_root = Path( + os.environ.get("ANOLISA_INSTALLED_SKILLS_DIR", "/usr/share/anolisa/skills") + ) + venv_python = Path(os.environ.get("VENV_PYTHON", "/opt/agent-sec/venv/bin/python")) + agent_sec_cli = shutil.which("agent-sec-cli") or "/usr/local/bin/agent-sec-cli" -def main(): - # Pre-flight - if not shutil.which("gpg"): - print(f"{RED}ERROR: gpg not found – cannot run e2e tests{NC}") - sys.exit(1) - if not shutil.which("jq"): - print(f"{RED}ERROR: jq not found – cannot run e2e tests{NC}") - sys.exit(1) - if not SIGN_SKILL_SH.exists(): - print(f"{RED}ERROR: {SIGN_SKILL_SH} not found{NC}") - sys.exit(1) + if not installed_script.exists() and not venv_python.exists(): + pytest.skip("source-build installed sign-skill.sh and venv are not present") - ws = Workspace() - try: - print("=" * 60) - print(f"{BOLD}Skill Signing E2E Tests{NC}") - print(f" sign-skill.sh : {SIGN_SKILL_SH}") - print(f" verifier.py : {VERIFIER_PY}") - print(f" workspace : {ws.root}") - print("=" * 60) - - # Run --init first; most subsequent tests depend on the generated key - test("Prerequisites check (--check)", lambda: test_check(ws)) - test("Init: generate key + export (--init)", lambda: test_init(ws)) - - # Signing & verification - test("Single sign + verify", lambda: test_single_sign_and_verify(ws)) - test("Batch sign + verify", lambda: test_batch_sign_and_verify(ws)) - test("Force overwrite re-sign", lambda: test_force_overwrite(ws)) - test("No --force rejects existing", lambda: test_no_force_rejects(ws)) - test("Export key to custom dir", lambda: test_export_key_default_and_custom(ws)) - test("Skill name override", lambda: test_skill_name_override(ws)) - test("Hidden files excluded", lambda: test_hidden_files_excluded(ws)) - - # Negative / security tests - test("Tampered file detected", lambda: test_tampered_file_detected(ws)) - test( - "Unsigned reference file detected", - lambda: test_unsigned_reference_file_detected(ws), - ) - test("Missing .skill.sig detected", lambda: test_missing_sig_detected(ws)) - test("Wrong key rejected", lambda: test_wrong_key_rejected(ws)) + assert installed_script.exists(), f"missing installed script: {installed_script}" + assert skills_root.is_dir(), f"missing installed skills root: {skills_root}" + assert venv_python.exists(), f"missing installed verifier python: {venv_python}" + assert Path( + agent_sec_cli + ).exists(), f"missing agent-sec-cli binary: {agent_sec_cli}" - # Environment variable key import - test("GPG_PRIVATE_KEY env import", lambda: test_gpg_private_key_env(ws)) + verifier_paths = run_command( + [ + venv_python, + "-c", + ( + "from agent_sec_cli.asset_verify import verifier\n" + "print(verifier.DEFAULT_TRUSTED_KEYS_DIR)\n" + "print(verifier.DEFAULT_CONFIG)\n" + ), + ], + ) + assert_success(verifier_paths, "resolve installed verifier paths") + trusted_keys_dir, config_file = [ + Path(line.strip()) for line in verifier_paths.stdout.splitlines()[:2] + ] + assert trusted_keys_dir.is_dir(), f"missing trusted-keys dir: {trusted_keys_dir}" + assert config_file.is_file(), f"missing verifier config: {config_file}" + + expected_skills = {"code-scanner", "prompt-scanner", "skill-ledger"} + for skill_name in expected_skills: + assert ( + skills_root / skill_name + ).is_dir(), f"missing installed skill: {skill_name}" + + needs_sudo = os.geteuid() != 0 and ( + not os.access(skills_root, os.W_OK) + or not os.access(trusted_keys_dir, os.W_OK) + or not os.access(config_file, os.W_OK) + ) + if needs_sudo and os.geteuid() != 0: + require_passwordless_sudo() - # Schema / structure - test("Manifest JSON structure", lambda: test_manifest_structure(ws)) - test("Subdirectory files in manifest", lambda: test_subdirectory_files(ws)) + gnupg_home = Path("/tmp") / f"agent-sec-pytest-gnupg-{uuid.uuid4().hex}" + env = { + "GNUPGHOME": str(gnupg_home), + "LC_ALL": "C", + "LANG": "C", + "PATH": os.environ.get("PATH", ""), + } + try: + if needs_sudo and os.geteuid() != 0: + setup_home = run_maybe_sudo( + ["sh", "-c", f"rm -rf '{gnupg_home}' && mkdir -m 700 '{gnupg_home}'"], + sudo=True, + ) + assert_success(setup_home, "create root GNUPGHOME") + else: + shutil.rmtree(gnupg_home, ignore_errors=True) + gnupg_home.mkdir(mode=0o700) + + init = run_maybe_sudo( + ["bash", installed_script, "--init"], + env=env, + sudo=needs_sudo, + timeout=180, + ) + assert_success(init, "installed --init") + assert str(trusted_keys_dir) in init.stdout + init.stderr + + batch = run_maybe_sudo( + ["bash", installed_script, "--batch", skills_root, "--force"], + env=env, + sudo=needs_sudo, + timeout=180, + ) + assert_success(batch, "installed --batch") + assert "3/3 skills signed successfully" in batch.stdout + batch.stderr + + verify = run_command([agent_sec_cli, "verify"], timeout=180) + assert_success(verify, "agent-sec-cli verify") + assert "VERIFICATION PASSED" in verify.stdout + for skill_name in expected_skills: + assert f"[OK] {skill_name}" in verify.stdout + assert (skills_root / skill_name / SIGNING_DIR / "Manifest.json").is_file() + assert (skills_root / skill_name / SIGNING_DIR / ".skill.sig").is_file() finally: - ws.cleanup() - - # Summary - print() - print("=" * 60) - total = results.passed + results.failed - print(f"{BOLD}Results: {results.passed}/{total} passed{NC}") - if results.errors: - for name, exc in results.errors: - print(f" {RED}FAIL{NC} {name}: {exc}") - print("=" * 60) - - if results.failed: - print(f"{RED}{results.failed} test(s) failed{NC}") - sys.exit(1) - else: - print(f"{GREEN}All tests passed!{NC}") - sys.exit(0) + if needs_sudo and os.geteuid() != 0: + run_maybe_sudo(["rm", "-rf", gnupg_home], sudo=True) + else: + shutil.rmtree(gnupg_home, ignore_errors=True) if __name__ == "__main__": - main() + raise SystemExit(pytest.main([__file__])) From b2e7943b09a47b3547e3bcb95daefde04ca721e0 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Wed, 13 May 2026 17:38:17 +0800 Subject: [PATCH 19/23] test(sec-core): cover legacy skill signing ci call --- .../tests/e2e/skill-signing/e2e_test.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py index 9fce8751e..7bc7be805 100644 --- a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py +++ b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py @@ -28,6 +28,7 @@ # --------------------------------------------------------------------------- REPO_ROOT = Path(__file__).resolve().parents[3] # agent-sec-core/ +PROJECT_ROOT = Path(__file__).resolve().parents[5] # repository root SIGN_SKILL_SH = REPO_ROOT / "tools" / "sign-skill.sh" SOURCE_PYTHONPATH = REPO_ROOT / "agent-sec-cli" / "src" @@ -88,6 +89,7 @@ def require_passwordless_sudo() -> None: def run_command( args: Iterable[str | Path], *, + cwd: Optional[Path] = None, env: Optional[dict[str, str]] = None, input_text: Optional[str] = None, timeout: int = 120, @@ -96,6 +98,7 @@ def run_command( [str(arg) for arg in args], capture_output=True, text=True, + cwd=str(cwd) if cwd else None, env=env, input=input_text, timeout=timeout, @@ -282,6 +285,101 @@ def test_batch_sign_registers_explicit_config_and_verifies( assert name == skill_name +def test_legacy_ci_batch_invocation_with_private_key(signing_ws: Workspace) -> None: + """Package-source CI's historical --batch call remains compatible.""" + archive_skills = signing_ws.root / "tmp_build" / "anolisa-ci" / "skills" + archive_skills.mkdir(parents=True) + for skill_name, content in [("ci-alpha", "A"), ("ci-beta", "B")]: + make_skill(archive_skills, skill_name, {"SKILL.md": f"# {content}\n"}) + + ci_key_home = signing_ws.root / "ci_key_gpg" + ci_key_home.mkdir(mode=0o700) + env = signing_ws.env() + generate = run_command( + ["gpg", "--homedir", ci_key_home, "--batch", "--gen-key"], + env=env, + input_text=( + "Key-Type: RSA\n" + "Key-Length: 2048\n" + "Name-Real: CI Signing Key\n" + "Name-Email: ci-sign@test.local\n" + "Expire-Date: 0\n" + "%no-protection\n" + "%commit\n" + ), + ) + assert_success(generate, "generate CI signing key") + + private_key = run_command( + [ + "gpg", + "--homedir", + ci_key_home, + "--armor", + "--export-secret-keys", + "ci-sign@test.local", + ], + env=env, + ) + assert_success(private_key, "export CI private key") + + public_key = run_command( + ["gpg", "--homedir", ci_key_home, "--armor", "--export", "ci-sign@test.local"], + env=env, + ) + assert_success(public_key, "export CI public key") + ci_trusted_keys = signing_ws.root / "ci_trusted_keys" + ci_trusted_keys.mkdir() + (ci_trusted_keys / "ci-sign.asc").write_text(public_key.stdout) + + blank_home = signing_ws.root / "legacy_ci_gpg" + blank_home.mkdir(mode=0o700) + ci_env = signing_ws.env( + { + "GNUPGHOME": str(blank_home), + "GPG_PRIVATE_KEY": private_key.stdout, + } + ) + + installed_config = Path( + "/opt/agent-sec/venv/lib/python3.11/site-packages/" + "agent_sec_cli/asset_verify/config.conf" + ) + original_installed_config = ( + installed_config.read_text() + if installed_config.is_file() and os.access(installed_config, os.W_OK) + else None + ) + + try: + result = run_command( + [ + "bash", + "src/agent-sec-core/tools/sign-skill.sh", + "--batch", + archive_skills, + ], + cwd=PROJECT_ROOT, + env=ci_env, + timeout=180, + ) + assert_success(result, "legacy CI batch invocation") + assert "GPG private key imported and trusted" in result.stdout + result.stderr + assert "2/2 skills signed successfully" in result.stdout + result.stderr + + keys = load_trusted_keys(ci_trusted_keys) + for skill_name in ("ci-alpha", "ci-beta"): + skill_dir = archive_skills / skill_name + assert (skill_dir / SIGNING_DIR / "Manifest.json").is_file() + assert (skill_dir / SIGNING_DIR / ".skill.sig").is_file() + ok, name = verify_skill(str(skill_dir), keys) + assert ok + assert name == skill_name + finally: + if original_installed_config is not None: + installed_config.write_text(original_installed_config) + + def test_force_overwrite(signing_ws: Workspace) -> None: """--force overwrites existing manifest and signature.""" skill = make_skill(signing_ws.skills_dir, "skill-force", {"f.txt": "v1"}) From ec1073a1a359ca0b8a0f183b04d3974117f57126 Mon Sep 17 00:00:00 2001 From: 1570005763 Date: Wed, 13 May 2026 18:07:06 +0800 Subject: [PATCH 20/23] fix(sec-core): decouple skill signing path detection --- .github/workflows/sec-core-rpmbuild.yaml | 2 +- .../tests/e2e/skill-signing/e2e_test.py | 76 +++++++++++-------- src/agent-sec-core/tools/SIGNING_GUIDE.md | 27 ++++--- src/agent-sec-core/tools/SIGNING_GUIDE_CN.md | 24 +++--- src/agent-sec-core/tools/sign-skill.sh | 52 +++++++------ 5 files changed, 104 insertions(+), 77 deletions(-) diff --git a/.github/workflows/sec-core-rpmbuild.yaml b/.github/workflows/sec-core-rpmbuild.yaml index aa0d5232d..c36162041 100644 --- a/.github/workflows/sec-core-rpmbuild.yaml +++ b/.github/workflows/sec-core-rpmbuild.yaml @@ -25,7 +25,7 @@ jobs: sed -i -e "s/cloud.aliyuncs/aliyun/g" /etc/yum.repos.d/*.repo dnf install -y tar git rpm-build gcc make clang llvm \ openssl-devel libseccomp-devel bubblewrap \ - python3-pip gnupg2 jq + python3-pip - uses: actions/checkout@v4 diff --git a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py index 7bc7be805..dec97f1ed 100644 --- a/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py +++ b/src/agent-sec-core/tests/e2e/skill-signing/e2e_test.py @@ -16,7 +16,6 @@ import subprocess import sys import tempfile -import uuid from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional @@ -151,6 +150,25 @@ def run_maybe_sudo( ) +def resolve_installed_asset_verify_dir() -> Optional[Path]: + """Resolve installed verifier package data without importing agent_sec_cli.""" + search_roots = [ + Path("/opt/agent-sec/venv/lib"), + Path("/opt/agent-sec/lib"), + ] + + for root in search_roots: + if not root.is_dir(): + continue + for asset_dir in sorted( + root.glob("python*/site-packages/agent_sec_cli/asset_verify") + ): + if asset_dir.is_dir() and (asset_dir / "config.conf").is_file(): + return asset_dir + + return None + + def make_skill(parent: Path, name: str, files: dict[str, str]) -> Path: """Create a fake skill directory with the given files.""" skill_dir = parent / name @@ -341,13 +359,17 @@ def test_legacy_ci_batch_invocation_with_private_key(signing_ws: Workspace) -> N } ) - installed_config = Path( - "/opt/agent-sec/venv/lib/python3.11/site-packages/" - "agent_sec_cli/asset_verify/config.conf" + installed_asset_verify_dir = resolve_installed_asset_verify_dir() + installed_config = ( + installed_asset_verify_dir / "config.conf" + if installed_asset_verify_dir is not None + else None ) original_installed_config = ( installed_config.read_text() - if installed_config.is_file() and os.access(installed_config, os.W_OK) + if installed_config is not None + and installed_config.is_file() + and os.access(installed_config, os.W_OK) else None ) @@ -376,7 +398,7 @@ def test_legacy_ci_batch_invocation_with_private_key(signing_ws: Workspace) -> N assert ok assert name == skill_name finally: - if original_installed_config is not None: + if original_installed_config is not None and installed_config is not None: installed_config.write_text(original_installed_config) @@ -686,35 +708,22 @@ def test_source_build_installed_signing_and_verify() -> None: skills_root = Path( os.environ.get("ANOLISA_INSTALLED_SKILLS_DIR", "/usr/share/anolisa/skills") ) - venv_python = Path(os.environ.get("VENV_PYTHON", "/opt/agent-sec/venv/bin/python")) + asset_verify_dir = resolve_installed_asset_verify_dir() agent_sec_cli = shutil.which("agent-sec-cli") or "/usr/local/bin/agent-sec-cli" - if not installed_script.exists() and not venv_python.exists(): - pytest.skip("source-build installed sign-skill.sh and venv are not present") + if not installed_script.exists() or asset_verify_dir is None: + pytest.skip( + "source-build installed sign-skill.sh and verifier asset paths are not present" + ) assert installed_script.exists(), f"missing installed script: {installed_script}" assert skills_root.is_dir(), f"missing installed skills root: {skills_root}" - assert venv_python.exists(), f"missing installed verifier python: {venv_python}" assert Path( agent_sec_cli ).exists(), f"missing agent-sec-cli binary: {agent_sec_cli}" - verifier_paths = run_command( - [ - venv_python, - "-c", - ( - "from agent_sec_cli.asset_verify import verifier\n" - "print(verifier.DEFAULT_TRUSTED_KEYS_DIR)\n" - "print(verifier.DEFAULT_CONFIG)\n" - ), - ], - ) - assert_success(verifier_paths, "resolve installed verifier paths") - trusted_keys_dir, config_file = [ - Path(line.strip()) for line in verifier_paths.stdout.splitlines()[:2] - ] - assert trusted_keys_dir.is_dir(), f"missing trusted-keys dir: {trusted_keys_dir}" + trusted_keys_dir = asset_verify_dir / "trusted-keys" + config_file = asset_verify_dir / "config.conf" assert config_file.is_file(), f"missing verifier config: {config_file}" expected_skills = {"code-scanner", "prompt-scanner", "skill-ledger"} @@ -723,15 +732,20 @@ def test_source_build_installed_signing_and_verify() -> None: skills_root / skill_name ).is_dir(), f"missing installed skill: {skill_name}" + trusted_keys_write_target = ( + trusted_keys_dir if trusted_keys_dir.exists() else trusted_keys_dir.parent + ) needs_sudo = os.geteuid() != 0 and ( not os.access(skills_root, os.W_OK) - or not os.access(trusted_keys_dir, os.W_OK) + or not os.access(trusted_keys_write_target, os.W_OK) or not os.access(config_file, os.W_OK) ) if needs_sudo and os.geteuid() != 0: require_passwordless_sudo() - gnupg_home = Path("/tmp") / f"agent-sec-pytest-gnupg-{uuid.uuid4().hex}" + gnupg_home = Path( + tempfile.mkdtemp(prefix="agent-sec-pytest-gnupg-", dir=tempfile.gettempdir()) + ) env = { "GNUPGHOME": str(gnupg_home), "LC_ALL": "C", @@ -747,8 +761,7 @@ def test_source_build_installed_signing_and_verify() -> None: ) assert_success(setup_home, "create root GNUPGHOME") else: - shutil.rmtree(gnupg_home, ignore_errors=True) - gnupg_home.mkdir(mode=0o700) + gnupg_home.chmod(0o700) init = run_maybe_sudo( ["bash", installed_script, "--init"], @@ -758,6 +771,9 @@ def test_source_build_installed_signing_and_verify() -> None: ) assert_success(init, "installed --init") assert str(trusted_keys_dir) in init.stdout + init.stderr + assert ( + trusted_keys_dir.is_dir() + ), f"trusted-keys dir was not created: {trusted_keys_dir}" batch = run_maybe_sudo( ["bash", installed_script, "--batch", skills_root, "--force"], diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE.md b/src/agent-sec-core/tools/SIGNING_GUIDE.md index 27bc50a33..4f0ea34df 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE.md @@ -50,16 +50,20 @@ After running the unified source build, use the installed script and verifier: # directory used by agent-sec-cli verify. /usr/local/bin/sign-skill.sh --init -# 2. Sign the installed agent-sec-core skills. +# 2. Sign the installed agent-sec-core skills. Replace this path if your +# SKILL_DIR or package layout installs skills elsewhere. /usr/local/bin/sign-skill.sh --batch /usr/share/anolisa/skills --force # 3. Verify all configured skill directories. agent-sec-cli verify ``` -For the default source-build install, `agent-sec-cli verify` already reads -`/usr/share/anolisa/skills` from its packaged `config.conf`, so no verification -directory argument is required. +For the default source-build install, `/usr/share/anolisa/skills` is the +installed skills root and `agent-sec-cli verify` already reads it from the +packaged `config.conf`, so no verification directory argument is required. If a +custom `SKILL_DIR` or package layout is used, pass the actual skills directory +to `--batch`; for non-default verifier layouts, pass the matching verifier +`config.conf` with `--config-file`. ## Step-by-Step (Manual Key Management) @@ -89,8 +93,8 @@ gpg --list-secret-keys me@example.com The verifier loads trusted public keys from the packaged `agent_sec_cli/asset_verify/trusted-keys/` directory. When `agent-sec-cli` is installed, `sign-skill.sh` auto-detects this -directory from `agent_sec_cli.asset_verify.verifier`. When running only from this -source checkout, it falls back to +directory by probing the installed package data under `/opt/agent-sec`. When +running only from this source checkout, it falls back to `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`. To re-export manually: @@ -138,11 +142,12 @@ Each signed skill directory will contain: ### 4. Configure the Verifier -For installed `agent-sec-cli`, `--batch` uses the detected verifier -`config.conf` and registers the skills root before signing. For source-tree-only -or custom layouts, make sure the skills root is listed in the verifier config -packaged with the CLI (`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf` -in this source tree). You can also choose the config file explicitly: +For installed `agent-sec-cli`, `--batch` uses the detected installed verifier +`config.conf` and registers the skills root before signing. Source-tree fallback +does not modify the source checkout's `config.conf` automatically. For +source-tree-only or custom layouts, make sure the actual skills root is listed +in the verifier config packaged with the CLI, or choose the config file +explicitly: ```bash tools/sign-skill.sh --batch /custom/skills --force \ diff --git a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md index 55c9e1a2e..570e90687 100644 --- a/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md +++ b/src/agent-sec-core/tools/SIGNING_GUIDE_CN.md @@ -50,15 +50,19 @@ agent-sec-cli verify # trusted-keys 目录。 /usr/local/bin/sign-skill.sh --init -# 2. 签名已安装的 agent-sec-core skills。 +# 2. 签名已安装的 agent-sec-core skills。若自定义了 SKILL_DIR 或安装布局, +# 请替换为实际 skill 目录。 /usr/local/bin/sign-skill.sh --batch /usr/share/anolisa/skills --force # 3. 验证所有已配置的 skill 目录。 agent-sec-cli verify ``` -默认源码构建安装场景下,`agent-sec-cli verify` 已经从随包安装的 -`config.conf` 读取 `/usr/share/anolisa/skills`,因此不需要再指定验签目录。 +默认源码构建安装场景下,`/usr/share/anolisa/skills` 是已安装的 skill 根目录, +`agent-sec-cli verify` 已经从随包安装的 `config.conf` 读取该目录,因此不需要 +再指定验签目录。若使用自定义 `SKILL_DIR` 或不同的包布局,请将实际 skill 目录 +传给 `--batch`;非默认 verifier 布局可通过 `--config-file` 指定对应的 +`config.conf`。 ## 手动逐步操作 @@ -87,9 +91,9 @@ gpg --list-secret-keys me@example.com ### 2. 导出公钥 校验器从打包后的 `agent_sec_cli/asset_verify/trusted-keys/` 目录加载受信公钥。 -当 `agent-sec-cli` 已安装时,`sign-skill.sh` 会从 -`agent_sec_cli.asset_verify.verifier` 自动识别该目录;仅在源码树中运行时, -会回退到 `agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。 +当 `agent-sec-cli` 已安装时,`sign-skill.sh` 会通过文件系统探测 `/opt/agent-sec` +下的包内数据目录;仅在源码树中运行时,会回退到 +`agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys/`。 手动重新导出: ```bash @@ -136,10 +140,10 @@ tools/sign-skill.sh --batch /usr/share/anolisa/skills --force ### 4. 配置校验器 -当使用已安装的 `agent-sec-cli` 时,`--batch` 会使用自动识别到的 verifier -`config.conf`,并在签名前注册 skill 根目录。对于仅源码树运行或自定义布局,请确保 -skill 根目录已配置在随 CLI 打包的校验器配置中(当前源码树中的路径为 -`agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf`)。也可以显式指定配置文件: +当使用已安装的 `agent-sec-cli` 时,`--batch` 会使用自动识别到的已安装 verifier +`config.conf`,并在签名前注册 skill 根目录。源码树 fallback 不会自动修改源码树中的 +`config.conf`。对于仅源码树运行或自定义布局,请确保实际 skill 根目录已配置在随 CLI +打包的校验器配置中;也可以显式指定配置文件: ```bash tools/sign-skill.sh --batch /custom/skills --force \ diff --git a/src/agent-sec-core/tools/sign-skill.sh b/src/agent-sec-core/tools/sign-skill.sh index a77cc7aa0..81045c299 100755 --- a/src/agent-sec-core/tools/sign-skill.sh +++ b/src/agent-sec-core/tools/sign-skill.sh @@ -54,8 +54,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AGENT_SEC_CORE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # Default path for trusted public keys in the verifier package data. -DEFAULT_TRUSTED_KEYS_DIR="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify/trusted-keys" -DEFAULT_CONFIG_FILE="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify/config.conf" +SOURCE_ASSET_VERIFY_DIR="$AGENT_SEC_CORE_DIR/agent-sec-cli/src/agent_sec_cli/asset_verify" +DEFAULT_TRUSTED_KEYS_DIR="$SOURCE_ASSET_VERIFY_DIR/trusted-keys" +DEFAULT_CONFIG_FILE="$SOURCE_ASSET_VERIFY_DIR/config.conf" VERIFIER_PATH_SOURCE="source" VERIFIER_PATHS_RESOLVED=false @@ -72,42 +73,43 @@ fi # or GPG_PRIVATE_KEY import; empty means "let gpg pick its default". GPG_SIGN_KEY="" +try_verifier_asset_dir() { + local asset_dir="$1" + + if [[ ! -d "$asset_dir" || ! -f "$asset_dir/config.conf" ]]; then + return 1 + fi + + DEFAULT_TRUSTED_KEYS_DIR="$asset_dir/trusted-keys" + DEFAULT_CONFIG_FILE="$asset_dir/config.conf" + VERIFIER_PATH_SOURCE="$asset_dir" + return 0 +} + resolve_verifier_paths() { if [[ "$VERIFIER_PATHS_RESOLVED" == true ]]; then return 0 fi VERIFIER_PATHS_RESOLVED=true - local py - local out - local trusted_keys_dir - local config_file - local candidates=("/opt/agent-sec/venv/bin/python" "python3") + local asset_dir - for py in "${candidates[@]}"; do - if [[ "$py" == */* ]]; then - [[ -x "$py" ]] || continue - else - command -v "$py" &>/dev/null || continue + for asset_dir in /opt/agent-sec/venv/lib/python*/site-packages/agent_sec_cli/asset_verify; do + if try_verifier_asset_dir "$asset_dir"; then + return 0 fi + done - out=$("$py" - <<'PY' 2>/dev/null || true -from agent_sec_cli.asset_verify import verifier -print(verifier.DEFAULT_TRUSTED_KEYS_DIR) -print(verifier.DEFAULT_CONFIG) -PY -) - trusted_keys_dir=$(printf '%s\n' "$out" | sed -n '1p') - config_file=$(printf '%s\n' "$out" | sed -n '2p') - - if [[ -n "$trusted_keys_dir" && -n "$config_file" ]]; then - DEFAULT_TRUSTED_KEYS_DIR="$trusted_keys_dir" - DEFAULT_CONFIG_FILE="$config_file" - VERIFIER_PATH_SOURCE="$py" + for asset_dir in /opt/agent-sec/lib/python*/site-packages/agent_sec_cli/asset_verify; do + if try_verifier_asset_dir "$asset_dir"; then return 0 fi done + if try_verifier_asset_dir "$SOURCE_ASSET_VERIFY_DIR"; then + VERIFIER_PATH_SOURCE="source" + fi + return 0 } From d93e54499f97aa6398949297bb1cae1185645db1 Mon Sep 17 00:00:00 2001 From: yizheng Date: Thu, 14 May 2026 17:25:35 +0800 Subject: [PATCH 21/23] feat(sec-core): install in local space for build-all Signed-off-by: yizheng --- .github/workflows/sec-core-rpmbuild.yaml | 14 ++ .../workflows/sec-core-source-code-build.yaml | 19 ++- scripts/build-all.sh | 52 +++---- scripts/rpm-build.sh | 3 +- src/agent-sec-core/Makefile | 128 +++++++++++++----- .../cosh-extension/hooks/sandbox-guard.py | 2 +- .../linux-sandbox/tests/integration_test.py | 5 +- .../openclaw-plugin/scripts/deploy.sh | 4 +- .../tests/e2e/linux-sandbox/e2e_test.py | 5 +- 9 files changed, 157 insertions(+), 75 deletions(-) diff --git a/.github/workflows/sec-core-rpmbuild.yaml b/.github/workflows/sec-core-rpmbuild.yaml index c36162041..810ab754c 100644 --- a/.github/workflows/sec-core-rpmbuild.yaml +++ b/.github/workflows/sec-core-rpmbuild.yaml @@ -71,7 +71,21 @@ jobs: run: | dnf makecache dnf install -y scripts/rpmbuild/RPMS/**/*.rpm + echo "=== Verify CLI ===" agent-sec-cli --help + echo "=== Verify sandbox ===" + linux-sandbox --help + echo "=== Verify site-packages ===" + ls /opt/agent-sec/lib/python3.11/site-packages/agent_sec_cli/ + echo "=== Verify cosh extension ===" + ls /usr/share/anolisa/extensions/agent-sec-core/ + ls /usr/share/anolisa/extensions/agent-sec-core/hooks/ + echo "=== Verify openclaw plugin ===" + ls /opt/agent-sec/openclaw-plugin/ + ls /opt/agent-sec/openclaw-plugin/dist/ + ls /opt/agent-sec/openclaw-plugin/scripts/deploy.sh + echo "=== Verify skills ===" + ls /usr/share/anolisa/skills/ - name: Verify Python dependencies match requirements.txt run: | diff --git a/.github/workflows/sec-core-source-code-build.yaml b/.github/workflows/sec-core-source-code-build.yaml index ee4343709..50887b75a 100644 --- a/.github/workflows/sec-core-source-code-build.yaml +++ b/.github/workflows/sec-core-source-code-build.yaml @@ -26,7 +26,6 @@ jobs: - name: Alinux4 runner: ubuntu-22.04 container: alibaba-cloud-linux-4-registry.cn-hangzhou.cr.aliyuncs.com/alinux4/alinux4:latest - continue-on-error: true name: Source Build (${{ matrix.name }}) runs-on: ${{ matrix.runner }} container: ${{ matrix.container || '' }} @@ -44,6 +43,9 @@ jobs: session sufficient pam_permit.so EOF - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.93.0 + with: + components: clippy, rustfmt, rust-src - name: Build and install run: ./scripts/build-all.sh --component sec-core - name: Verify CLI @@ -54,7 +56,18 @@ jobs: run: linux-sandbox --help - name: Verify deployment run: | - ls /usr/share/anolisa/skills/ - ls /usr/share/anolisa/extensions/agent-sec-core/ + echo "=== Skills ===" + ls ~/.copilot-shell/skills/ + echo "=== Cosh Extension ===" + ls ~/.copilot-shell/extensions/agent-sec-core/ + ls ~/.copilot-shell/extensions/agent-sec-core/hooks/ + echo "=== OpenClaw Plugin ===" + ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/ + ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/dist/ + ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/scripts/ + echo "=== sign-skill.sh ===" + ls ~/.local/libexec/anolisa/sec-core/sign-skill.sh + echo "=== CLI venv ===" + ls ~/.local/lib/anolisa/sec-core/venv/bin/agent-sec-cli - name: Run E2E tests run: make -C src/agent-sec-core test-e2e-source-build diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 5898d19d2..cdd010cc5 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -713,15 +713,25 @@ build_sec_core() { info "make build-all ..." make build-all - # Track artifacts - local sandbox_bin="linux-sandbox/target/release/linux-sandbox" + # Track artifacts from BUILD_DIR (default: target) + local build_dir="target" + local sandbox_bin="$build_dir/linux-sandbox" local wheel - wheel=$(ls agent-sec-cli/target/wheels/agent_sec_cli-*.whl 2>/dev/null | head -1) - local plugin_entry="openclaw-plugin/dist/index.js" + wheel=$(ls "$build_dir"/wheels/agent_sec_cli-*.whl 2>/dev/null | head -1) + local plugin_entry="$build_dir/openclaw-plugin/dist/index.js" [[ -f "$sandbox_bin" ]] && ARTIFACT_NAMES+=("linux-sandbox") && ARTIFACT_PATHS+=("src/agent-sec-core/$sandbox_bin") [[ -n "$wheel" ]] && ARTIFACT_NAMES+=("agent-sec-cli") && ARTIFACT_PATHS+=("src/agent-sec-core/$wheel") - [[ -f "$plugin_entry" ]] && ARTIFACT_NAMES+=("openclaw-plugin") && ARTIFACT_PATHS+=("src/agent-sec-core/openclaw-plugin/dist/") + [[ -f "$plugin_entry" ]] && ARTIFACT_NAMES+=("openclaw-plugin") && ARTIFACT_PATHS+=("src/agent-sec-core/$build_dir/openclaw-plugin/") + + # Verify all expected artifacts exist + local missing=() + [[ -f "$sandbox_bin" ]] || missing+=("linux-sandbox") + [[ -n "$wheel" ]] || missing+=("agent-sec-cli wheel") + [[ -f "$plugin_entry" ]] || missing+=("openclaw-plugin") + if (( ${#missing[@]} > 0 )); then + die "Build artifacts missing: ${missing[*]}" + fi ok "agent-sec-core built successfully" } @@ -815,34 +825,12 @@ install_sec_core() { [[ -d "$dir" ]] || die "Directory not found: $dir" cd "$dir" - local venv_dir="/opt/agent-sec/venv" - - # 1. Create isolated venv (uv auto-downloads Python 3.11.6 if needed) - info "Creating Python venv at $venv_dir ..." - sudo mkdir -p "$venv_dir" - sudo chown "$(id -u):$(id -g)" "$venv_dir" - uv venv --python "3.11.6" "$venv_dir" - - # 2. Install deps from uv.lock into venv (uv sync understands [tool.uv.sources]) - # UV_PROJECT_ENVIRONMENT tells uv to use our venv instead of .venv - # --no-install-project: only install deps, not the project itself - info "Installing agent-sec-cli dependencies (from uv.lock) ..." - (cd agent-sec-cli && UV_PROJECT_ENVIRONMENT="$venv_dir" uv sync --frozen --no-dev --no-install-project) - - # 3. Install pre-built wheel into venv (no-deps since deps are already installed) - uv pip install --python "$venv_dir/bin/python" --no-deps \ - agent-sec-cli/target/wheels/agent_sec_cli-*.whl - - # 4. Symlink CLI command to /usr/local/bin/ - sudo ln -sf "$venv_dir/bin/agent-sec-cli" /usr/local/bin/agent-sec-cli - ok "agent-sec-cli installed ($venv_dir + symlink to /usr/local/bin/)" - - # 5. Install non-Python components (sandbox, hooks, plugin, skills) - info "Installing sandbox + hooks + plugin + skills ..." - sudo make install-cosh-hook install-openclaw-plugin install-skills install-tool - ok "cosh-hook + openclaw-plugin + skills installed" + # Install all components using user profile (no sudo, paths under ~/.local/) + info "make install-all INSTALL_PROFILE=user ..." + make install-all INSTALL_PROFILE=user + ok "agent-sec-core installed (user profile: ~/.local/ + ~/.copilot-shell/)" - # 6. Runtime dependencies + # Runtime dependencies (system packages, require sudo) if ! cmd_exists bwrap; then info "Installing runtime dependency: bubblewrap ..." sudo $PKG_INSTALL bubblewrap || warn "bubblewrap not installed (linux-sandbox runtime dep)" diff --git a/scripts/rpm-build.sh b/scripts/rpm-build.sh index 28ef7be6c..4fb803464 100755 --- a/scripts/rpm-build.sh +++ b/scripts/rpm-build.sh @@ -209,7 +209,7 @@ build_agent_sec_core() { local tmp_dir tmp_dir=$(mktemp -d) local pkg_dir="${tmp_dir}/${pkg_name}-${version}" - mkdir -p "$pkg_dir"/{skills,linux-sandbox,agent-sec-cli,cosh-extension,openclaw-plugin,scripts} + mkdir -p "$pkg_dir"/{skills,linux-sandbox,agent-sec-cli,cosh-extension,openclaw-plugin,scripts,tools} # skills: use cp -rp dir/. to include hidden files/directories cp -rp "${SEC_DIR}/skills/." "$pkg_dir/skills/" @@ -217,6 +217,7 @@ build_agent_sec_core() { rm -f "$pkg_dir/linux-sandbox/rust-toolchain.toml" cp -rp "${SEC_DIR}/cosh-extension/"* "$pkg_dir/cosh-extension/" cp -p "${SEC_DIR}/scripts/agent-sec-cli-wrapper.sh" "$pkg_dir/scripts/" + cp -p "${SEC_DIR}/tools/sign-skill.sh" "$pkg_dir/tools/" cp "${SEC_DIR}/Makefile" "$pkg_dir/" [ -f "${SEC_DIR}/LICENSE" ] && cp "${SEC_DIR}/LICENSE" "$pkg_dir/" [ -f "${SEC_DIR}/README.md" ] && cp "${SEC_DIR}/README.md" "$pkg_dir/" diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 86ec7549b..72d5bf0c7 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -75,7 +75,7 @@ test-e2e-rpm: ## Run E2E tests against RPM-installed agent-sec-cli binary python3 tests/e2e/skill-ledger/e2e_test.py @# linux-sandbox e2e skipped: requires privileged container -VENV_PYTHON ?= /opt/agent-sec/venv/bin/python +VENV_PYTHON ?= $(HOME)/.local/lib/anolisa/sec-core/venv/bin/python .PHONY: test-e2e-source-build test-e2e-source-build: ## Run E2E tests against source-build-installed agent-sec-cli @@ -130,14 +130,22 @@ test: test-python test-rust test-openclaw-plugin ## Run all tests # BUILD # ============================================================================= +# Build output directory — all artifacts are collected here after build. +# Override from outside: make build-all BUILD_DIR=/path/to/output +BUILD_DIR ?= target + .PHONY: build-sandbox build-sandbox: ## Build linux-sandbox binary cd linux-sandbox && cargo build --release + install -d -m 0755 $(BUILD_DIR) + cp -p linux-sandbox/target/release/linux-sandbox $(BUILD_DIR)/ .PHONY: build-cli build-cli: ## Build agent-sec-cli wheel with maturin (Rust + Python) cd agent-sec-cli && uv sync --only-group dev --no-install-project && \ uv run --no-sync maturin build --release -i python3.11 --manylinux off + install -d -m 0755 $(BUILD_DIR)/wheels + cp -p agent-sec-cli/target/wheels/*.whl $(BUILD_DIR)/wheels/ .PHONY: setup setup: ## Install all dependencies (including dev), create .venv @@ -146,9 +154,31 @@ setup: ## Install all dependencies (including dev), create .venv .PHONY: build-openclaw-plugin build-openclaw-plugin: ## Build openclaw-plugin TypeScript sources cd openclaw-plugin && npm install && npm run build + install -d -m 0755 $(BUILD_DIR)/openclaw-plugin/dist + install -d -m 0755 $(BUILD_DIR)/openclaw-plugin/scripts + cp openclaw-plugin/openclaw.plugin.json $(BUILD_DIR)/openclaw-plugin/ + cp openclaw-plugin/package.json $(BUILD_DIR)/openclaw-plugin/ + cp -r openclaw-plugin/dist/* $(BUILD_DIR)/openclaw-plugin/dist/ + cp -r openclaw-plugin/scripts/* $(BUILD_DIR)/openclaw-plugin/scripts/ + +.PHONY: stage-cosh-extension +stage-cosh-extension: ## Stage cosh-extension hooks to BUILD_DIR + install -d -m 0755 $(BUILD_DIR)/cosh-extension + cp -rp cosh-extension/. $(BUILD_DIR)/cosh-extension/ + +.PHONY: stage-skills +stage-skills: ## Stage skill files to BUILD_DIR + install -d -m 0755 $(BUILD_DIR)/skills + cp -rp skills/. $(BUILD_DIR)/skills/ + +.PHONY: stage-tools +stage-tools: ## Stage tools (sign-skill.sh) to BUILD_DIR + install -d -m 0755 $(BUILD_DIR)/tools + cp -p tools/sign-skill.sh $(BUILD_DIR)/tools/ .PHONY: build-all -build-all: build-sandbox build-cli build-openclaw-plugin ## Build all components (used by rpmbuild) +build-all: build-sandbox build-cli build-openclaw-plugin stage-cosh-extension stage-skills stage-tools ## Build all components + @echo "📦 All artifacts collected to $(BUILD_DIR)/" .PHONY: export-requirements export-requirements: ## Re-export agent-sec-cli/requirements.txt from uv.lock @@ -156,7 +186,8 @@ export-requirements: ## Re-export agent-sec-cli/requirements.txt from uv.lock .PHONY: download-deps download-deps: ## Download ALL Python deps for agent-sec-cli (requires network) - pip3 download --dest agent-sec-cli/target/wheels/ --no-cache-dir \ + install -d -m 0755 $(BUILD_DIR)/wheels + pip3 download --dest $(BUILD_DIR)/wheels/ --no-cache-dir \ --python-version 3.11.6 --only-binary=:all: \ --timeout 60 \ --index-url https://pypi.org/simple/ \ @@ -167,39 +198,56 @@ download-deps: ## Download ALL Python deps for agent-sec-cli (requires network) stage-cli: ## Install all wheels to local staging dir (requires uv) install -d -m 0755 $(CLI_STAGED_SITE) uv pip install --target $(CLI_STAGED_SITE) --no-deps --no-cache --link-mode copy \ - agent-sec-cli/target/wheels/*.whl + $(BUILD_DIR)/wheels/*.whl rm -f $(CLI_STAGED_SITE)/.lock # ============================================================================= # INSTALL # ============================================================================= -PREFIX ?= /usr/local -SKILL_DIR ?= /usr/share/anolisa/skills -OPENCLAW_PLUGIN_DIR ?= /opt/agent-sec/openclaw-plugin -WHEEL_DIR ?= /opt/agent-sec/wheels -CLI_STAGED_SITE ?= _staged/site-packages -CLI_PRIVATE_SITE ?= /opt/agent-sec/lib/python3.11/site-packages +# --- Install profile: 'system' (RPM) or 'user' (source build) ---------------- +INSTALL_PROFILE ?= system + +ifeq ($(INSTALL_PROFILE),user) + PREFIX ?= $(HOME)/.local + EXTENSIONDIR ?= $(HOME)/.copilot-shell/extensions/agent-sec-core + SKILLDIR ?= $(HOME)/.copilot-shell/skills + OPENCLAW_PLUGIN_DIR ?= $(LIBDIR)/openclaw-plugin +else + PREFIX ?= /usr/local + ANOLISA_DATADIR ?= /usr/share/anolisa + EXTENSIONDIR ?= $(ANOLISA_DATADIR)/extensions/agent-sec-core + SKILLDIR ?= $(ANOLISA_DATADIR)/skills + OPENCLAW_PLUGIN_DIR ?= /opt/agent-sec/openclaw-plugin +endif + +BINDIR ?= $(PREFIX)/bin +LIBDIR ?= $(PREFIX)/lib/anolisa/sec-core +LIBEXECDIR ?= $(PREFIX)/libexec/anolisa/sec-core +VENV_DIR ?= $(LIBDIR)/venv +WHEEL_DIR ?= $(LIBDIR)/wheels +CLI_STAGED_SITE ?= $(BUILD_DIR)/site-packages +CLI_PRIVATE_SITE ?= /opt/agent-sec/lib/python3.11/site-packages .PHONY: install-sandbox install-sandbox: ## Install linux-sandbox binary only - install -d -m 0755 $(DESTDIR)$(PREFIX)/bin - install -p -m 0755 linux-sandbox/target/release/linux-sandbox $(DESTDIR)$(PREFIX)/bin/ + install -d -m 0755 $(DESTDIR)$(BINDIR) + install -p -m 0755 $(BUILD_DIR)/linux-sandbox $(DESTDIR)$(BINDIR)/ .PHONY: install-tool -install-tool: ## Install sign-skill.sh to PREFIX/bin - install -d -m 0755 $(DESTDIR)$(PREFIX)/bin - install -p -m 0755 tools/sign-skill.sh $(DESTDIR)$(PREFIX)/bin/ +install-tool: ## Install sign-skill.sh to LIBEXECDIR + install -d -m 0755 $(DESTDIR)$(LIBEXECDIR) + install -p -m 0755 $(BUILD_DIR)/tools/sign-skill.sh $(DESTDIR)$(LIBEXECDIR)/ .PHONY: install install: install-all ## Install all components (alias for install-all) .PHONY: install-cli install-cli: ## Install agent-sec-cli wheel (for dev/debug) - pip3 install agent-sec-cli/target/wheels/agent_sec_cli-*.whl + pip3 install $(BUILD_DIR)/wheels/agent_sec_cli-*.whl .PHONY: install-cli-site -install-cli-site: ## Copy staged agent-sec-cli + deps to private site-packages + wrapper +install-cli-site: ## RPM: Copy staged site-packages to private dir + wrapper # 1. Copy all Python packages to private directory install -d -m 0755 $(DESTDIR)$(CLI_PRIVATE_SITE) cp -rp $(CLI_STAGED_SITE)/. $(DESTDIR)$(CLI_PRIVATE_SITE)/ @@ -210,36 +258,50 @@ install-cli-site: ## Copy staged agent-sec-cli + deps to private site-packages + install -d -m 0755 $(DESTDIR)/usr/bin install -p -m 0755 scripts/agent-sec-cli-wrapper.sh $(DESTDIR)/usr/bin/agent-sec-cli +.PHONY: install-cli-venv +install-cli-venv: ## User: Create venv, install deps from uv.lock, install wheel, symlink + @echo "Creating Python venv at $(VENV_DIR) ..." + install -d -m 0755 $(VENV_DIR) + uv venv --python 3.11.6 $(VENV_DIR) + @echo "Installing dependencies from uv.lock ..." + cd agent-sec-cli && UV_PROJECT_ENVIRONMENT=$(VENV_DIR) uv sync --frozen --no-dev --no-install-project + @echo "Installing agent-sec-cli wheel ..." + uv pip install --python $(VENV_DIR)/bin/python --no-deps \ + $(BUILD_DIR)/wheels/agent_sec_cli-*.whl + @echo "Creating symlink ..." + install -d -m 0755 $(BINDIR) + ln -sf $(VENV_DIR)/bin/agent-sec-cli $(BINDIR)/agent-sec-cli + .PHONY: install-skills -install-skills: ## Install skill files to SKILL_DIR - install -d -m 0755 $(DESTDIR)$(SKILL_DIR) - cp -rp skills/. $(DESTDIR)$(SKILL_DIR)/ - find $(DESTDIR)$(SKILL_DIR) -type f -name '*.sh' -exec chmod 0755 {} + - find $(DESTDIR)$(SKILL_DIR) -type f -name '*.py' -exec chmod 0755 {} + +install-skills: ## Install skill files to SKILLDIR + install -d -m 0755 $(DESTDIR)$(SKILLDIR) + cp -rp $(BUILD_DIR)/skills/. $(DESTDIR)$(SKILLDIR)/ + find $(DESTDIR)$(SKILLDIR) -type f -name '*.sh' -exec chmod 0755 {} + + find $(DESTDIR)$(SKILLDIR) -type f -name '*.py' -exec chmod 0755 {} + .PHONY: install-openclaw-plugin install-openclaw-plugin: ## Install openclaw-plugin to target directory install -d -m 0755 $(DESTDIR)$(OPENCLAW_PLUGIN_DIR) install -d -m 0755 $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/dist install -d -m 0755 $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/scripts - cp openclaw-plugin/openclaw.plugin.json $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/ - cp openclaw-plugin/package.json $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/ - cp -r openclaw-plugin/dist/* $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/dist/ - cp -r openclaw-plugin/scripts/* $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/scripts/ + cp $(BUILD_DIR)/openclaw-plugin/openclaw.plugin.json $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/ + cp $(BUILD_DIR)/openclaw-plugin/package.json $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/ + cp -r $(BUILD_DIR)/openclaw-plugin/dist/* $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/dist/ + cp -r $(BUILD_DIR)/openclaw-plugin/scripts/* $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/scripts/ chmod 0755 $(DESTDIR)$(OPENCLAW_PLUGIN_DIR)/scripts/*.sh .PHONY: install-cosh-hook -install-cosh-hook: ## Install cosh hooks (linux-sandbox + code_scanner_hook) - install -d -m 0755 $(DESTDIR)$(PREFIX)/bin - install -p -m 0755 linux-sandbox/target/release/linux-sandbox $(DESTDIR)$(PREFIX)/bin/ - install -d -m 0755 $(DESTDIR)/usr/share/anolisa/extensions - cp -rp cosh-extension $(DESTDIR)/usr/share/anolisa/extensions/agent-sec-core +install-cosh-hook: ## Install cosh hooks (linux-sandbox + extension) + install -d -m 0755 $(DESTDIR)$(BINDIR) + install -p -m 0755 $(BUILD_DIR)/linux-sandbox $(DESTDIR)$(BINDIR)/ + install -d -m 0755 $(DESTDIR)$(EXTENSIONDIR) + cp -rp $(BUILD_DIR)/cosh-extension/. $(DESTDIR)$(EXTENSIONDIR)/ .PHONY: install-all -install-all: install-cli install-cosh-hook install-openclaw-plugin install-skills ## Install all components (local dev) +install-all: install-cli-venv install-sandbox install-cosh-hook install-openclaw-plugin install-skills install-tool ## Install all (user source build) .PHONY: install-all-for-rpmbuild -install-all-for-rpmbuild: install-cli-site install-cosh-hook install-openclaw-plugin install-skills ## Install all components (used by rpmbuild) +install-all-for-rpmbuild: install-cli-site install-cosh-hook install-openclaw-plugin install-skills ## Install all (RPM build) .PHONY: help help: ## Show this help message diff --git a/src/agent-sec-core/cosh-extension/hooks/sandbox-guard.py b/src/agent-sec-core/cosh-extension/hooks/sandbox-guard.py index 1c8a1bdd9..f966382e4 100755 --- a/src/agent-sec-core/cosh-extension/hooks/sandbox-guard.py +++ b/src/agent-sec-core/cosh-extension/hooks/sandbox-guard.py @@ -58,7 +58,7 @@ def _log_sandbox_event(action: str = "log-sandbox", **kwargs) -> None: pass -LINUX_SANDBOX = "/usr/local/bin/linux-sandbox" +LINUX_SANDBOX = shutil.which("linux-sandbox") or "/usr/local/bin/linux-sandbox" # 危险命令检测规则:(regex_pattern, reason_label) # 分为两类: diff --git a/src/agent-sec-core/linux-sandbox/tests/integration_test.py b/src/agent-sec-core/linux-sandbox/tests/integration_test.py index 880897086..592531e83 100755 --- a/src/agent-sec-core/linux-sandbox/tests/integration_test.py +++ b/src/agent-sec-core/linux-sandbox/tests/integration_test.py @@ -30,7 +30,7 @@ BLUE = "\033[0;34m" NC = "\033[0m" -SANDBOX = "/usr/local/bin/linux-sandbox" +SANDBOX = shutil.which("linux-sandbox") or "/usr/local/bin/linux-sandbox" # 是否显示详细输出 (通过 -v 或 --verbose 参数启用) VERBOSE = False @@ -275,7 +275,8 @@ def main(): if not os.path.isfile(SANDBOX): print(f"{RED}错误: 找不到 {SANDBOX}{NC}") print( - "请先编译并安装: cargo build --release && sudo cp target/release/linux-sandbox /usr/local/bin/" + "请先编译并安装: cargo build --release && sudo cp target/release/linux-sandbox" + " 到 PATH 中的目录 (如 /usr/local/bin/ 或 ~/.local/bin/)" ) sys.exit(1) diff --git a/src/agent-sec-core/openclaw-plugin/scripts/deploy.sh b/src/agent-sec-core/openclaw-plugin/scripts/deploy.sh index c1c867865..ed59e1407 100755 --- a/src/agent-sec-core/openclaw-plugin/scripts/deploy.sh +++ b/src/agent-sec-core/openclaw-plugin/scripts/deploy.sh @@ -17,7 +17,9 @@ set -euo pipefail -PLUGIN_DIR="${1:-/opt/agent-sec/openclaw-plugin}" +# Default PLUGIN_DIR: resolve relative to this script's location (scripts/ -> parent) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="${1:-$(dirname "$SCRIPT_DIR")}" # Convert to absolute path if relative PLUGIN_DIR="$(cd "$PLUGIN_DIR" && pwd)" diff --git a/src/agent-sec-core/tests/e2e/linux-sandbox/e2e_test.py b/src/agent-sec-core/tests/e2e/linux-sandbox/e2e_test.py index 880897086..592531e83 100755 --- a/src/agent-sec-core/tests/e2e/linux-sandbox/e2e_test.py +++ b/src/agent-sec-core/tests/e2e/linux-sandbox/e2e_test.py @@ -30,7 +30,7 @@ BLUE = "\033[0;34m" NC = "\033[0m" -SANDBOX = "/usr/local/bin/linux-sandbox" +SANDBOX = shutil.which("linux-sandbox") or "/usr/local/bin/linux-sandbox" # 是否显示详细输出 (通过 -v 或 --verbose 参数启用) VERBOSE = False @@ -275,7 +275,8 @@ def main(): if not os.path.isfile(SANDBOX): print(f"{RED}错误: 找不到 {SANDBOX}{NC}") print( - "请先编译并安装: cargo build --release && sudo cp target/release/linux-sandbox /usr/local/bin/" + "请先编译并安装: cargo build --release && sudo cp target/release/linux-sandbox" + " 到 PATH 中的目录 (如 /usr/local/bin/ 或 ~/.local/bin/)" ) sys.exit(1) From 79cf23c198b98d5b45d2a28d09bcad982e798ad8 Mon Sep 17 00:00:00 2001 From: yizheng Date: Thu, 14 May 2026 17:39:59 +0800 Subject: [PATCH 22/23] chore(sec-core): remove sign-skill tool Signed-off-by: yizheng --- .github/workflows/sec-core-source-code-build.yaml | 2 -- src/agent-sec-core/Makefile | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sec-core-source-code-build.yaml b/.github/workflows/sec-core-source-code-build.yaml index 50887b75a..e5a66900c 100644 --- a/.github/workflows/sec-core-source-code-build.yaml +++ b/.github/workflows/sec-core-source-code-build.yaml @@ -65,8 +65,6 @@ jobs: ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/ ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/dist/ ls ~/.local/lib/anolisa/sec-core/openclaw-plugin/scripts/ - echo "=== sign-skill.sh ===" - ls ~/.local/libexec/anolisa/sec-core/sign-skill.sh echo "=== CLI venv ===" ls ~/.local/lib/anolisa/sec-core/venv/bin/agent-sec-cli - name: Run E2E tests diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 72d5bf0c7..48fed1f9f 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -177,7 +177,7 @@ stage-tools: ## Stage tools (sign-skill.sh) to BUILD_DIR cp -p tools/sign-skill.sh $(BUILD_DIR)/tools/ .PHONY: build-all -build-all: build-sandbox build-cli build-openclaw-plugin stage-cosh-extension stage-skills stage-tools ## Build all components +build-all: build-sandbox build-cli build-openclaw-plugin stage-cosh-extension stage-skills ## Build all components @echo "📦 All artifacts collected to $(BUILD_DIR)/" .PHONY: export-requirements @@ -298,7 +298,7 @@ install-cosh-hook: ## Install cosh hooks (linux-sandbox + extension) cp -rp $(BUILD_DIR)/cosh-extension/. $(DESTDIR)$(EXTENSIONDIR)/ .PHONY: install-all -install-all: install-cli-venv install-sandbox install-cosh-hook install-openclaw-plugin install-skills install-tool ## Install all (user source build) +install-all: install-cli-venv install-sandbox install-cosh-hook install-openclaw-plugin install-skills ## Install all (user source build) .PHONY: install-all-for-rpmbuild install-all-for-rpmbuild: install-cli-site install-cosh-hook install-openclaw-plugin install-skills ## Install all (RPM build) From 2b2eb12c0a768639cd43d6e121699c2a8961e351 Mon Sep 17 00:00:00 2001 From: yizheng Date: Thu, 14 May 2026 19:21:39 +0800 Subject: [PATCH 23/23] chore(sec-core): fix comments Signed-off-by: yizheng --- src/agent-sec-core/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent-sec-core/Makefile b/src/agent-sec-core/Makefile index 48fed1f9f..9ed2cc448 100644 --- a/src/agent-sec-core/Makefile +++ b/src/agent-sec-core/Makefile @@ -262,7 +262,7 @@ install-cli-site: ## RPM: Copy staged site-packages to private dir + wrapper install-cli-venv: ## User: Create venv, install deps from uv.lock, install wheel, symlink @echo "Creating Python venv at $(VENV_DIR) ..." install -d -m 0755 $(VENV_DIR) - uv venv --python 3.11.6 $(VENV_DIR) + uv venv --python 3.11.6 --allow-existing $(VENV_DIR) @echo "Installing dependencies from uv.lock ..." cd agent-sec-cli && UV_PROJECT_ENVIRONMENT=$(VENV_DIR) uv sync --frozen --no-dev --no-install-project @echo "Installing agent-sec-cli wheel ..." @@ -298,7 +298,7 @@ install-cosh-hook: ## Install cosh hooks (linux-sandbox + extension) cp -rp $(BUILD_DIR)/cosh-extension/. $(DESTDIR)$(EXTENSIONDIR)/ .PHONY: install-all -install-all: install-cli-venv install-sandbox install-cosh-hook install-openclaw-plugin install-skills ## Install all (user source build) +install-all: install-cli-venv install-cosh-hook install-openclaw-plugin install-skills ## Install all (user source build) .PHONY: install-all-for-rpmbuild install-all-for-rpmbuild: install-cli-site install-cosh-hook install-openclaw-plugin install-skills ## Install all (RPM build)