Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ This project tries to be explicit about the difference. Current state:
condemn; that is not a statement about either project. See
[`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md) and
[`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md).
- **A CI recipe, by running it on GitHub.** The exit-code gate and the workflow wiring are
documented in [`docs/ci-integration.md`](https://github.com/slow-stack/euthyna/blob/main/docs/ci-integration.md);
the workflow snippet has not been exercised as a live Actions run.

---

Expand Down
104 changes: 104 additions & 0 deletions docs/ci-integration-zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 把 euthyna 用作 PR 门禁(GitHub Actions)

中文原文 · [English](ci-integration.md)

> **本文是什么**:一份可直接粘贴的配方,让 pull request 接受 `euthyna audit` 门禁,
> 并说明门禁变红时每个退出码的含义。它只使用 CLI 已经发布的退出码契约——不解释任何
> flag,不解析任何报告。
>
> **局限**:history 生产者读 git,所以需要 `fetch-depth: 0`(或至少 `--base` 可达)。
> dependency 生产者读 lockfile;它回答的是 manifest 锁定了什么,而不是被锁版本有没有
> CVE——那个裁定按设计留给 agent 层(见 [`fact-contract-zh.md`](fact-contract-zh.md) §6.3)。

## 门禁,一步版

把它放进想加门禁的仓库,存为 `.github/workflows/audit.yml`:

```yaml
name: euthyna audit

on:
pull_request:

jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 历史归属要遍历真实的 git 对象
- uses: actions/setup-node@v4
with:
node-version: 24
- name: 变更面审计(删除代码来源 + 依赖锁定版本)
run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD
```

这就是整个门禁。退出码按 CLI 发布的契约解读:

| 退出码 | 在 PR 门禁里的含义 | 合并按钮该做什么 |
|---|---|---|
| `0` | 已测量,没有 security 分类的发现 | 直接合并 |
| `10` | 已测量;至少一条 fact 属于 security 分类(删除代码来自安全修复、`--pickaxe` 下的重新引入) | 合并前由人读一遍报告——这是设计中的停留点,不是 bug |
| `1` | 用法错误(`--base` 写错、flag 错) | 修 workflow,不是修 PR |
| `2` | 完全无法测量(找不到 lockfile **不是**这种情况——缺数据按未评估报告,不是致命错) | **`2` 绝不合并**;「没测成」不等于「干净」 |

GitHub 把任何非零退出当作步骤失败,所以 `0` 显示绿,`10`、`1`、`2` 都显示红。如果希望
`10`(有发现)显示绿但留下评论而不是挡合并,捕获后翻译:

```yaml
- name: 变更面审计(删除代码来源 + 依赖锁定版本)
run: |
npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD
continue-on-error: true
id: audit
- name: 执行契约(10 = 留给人工审阅,其余非零失败)
if: always()
run: |
code=${{ steps.audit.outcome == 'success' && 0 || 1 }}
if [ "$code" -eq 0 ]; then exit 0; fi
echo "euthyna audit 未测成干净(或发现了 security 分类的历史);见上一步。" >&2
exit 1
```

`ponytail:` 两步版用 shell 重述了一遍退出码,把 `10` 和 `2` 混在一起;一步版才是诚实的
门禁。只有当 `10` 不该挡合并时才用两步版。

## 把报告挂到 PR 上

运行会把人类可读的报告打进步骤日志。要作为附件挂到 PR 上:

```yaml
- name: 变更面审计
run: |
npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD \
| tee audit-report.txt
continue-on-error: true
id: audit
- uses: actions/upload-artifact@v4
if: always()
with:
name: euthyna-audit-report
path: audit-report.txt
```

下游消费方(评论机器人、裁定 agent)要读 fact 而不是散文时,加 `--json` 并把 artifact
指向 JSON 文件。

## 这个门禁回答什么、不回答什么

- **回答**:这个 PR 里哪些被删除的行来自被分类为 `security` 或 `fix` 的提交(origins
默认开启——归到最初引入者,而不是最后修改者),manifest 锁定了哪些 lockfile 依赖、
位置在哪。
- **不回答**:任何东西是不是漏洞。`10` 的意思是*存在可供裁定的材料*;六道门的裁定
发生在 agent 层(`euthyna gate`),这个 workflow 刻意不跑它——workflow 产不出门禁
要求的证据纪律,假装能产就是在制造裁定。

## 附注

- `--pickaxe` 增加「被删除又加回」检测,代价是对每条删除行做一次 `git log -S` 探测
(有上限);当威胁模型是「修过的东西被改回来」时开启。
- `github.base_ref` 是 PR 的目标分支,所以常规场景下 `origin/main` 就是正确的 `--base`;
叠 PR(stacked PR)需要真实的分叉点。
- `npx euthyna@latest` 什么都没锁;跟着 npm 的 `latest` 标签漂移的门禁,其结论跨时间
不可比。在意一致性就钉住版本(`euthyna@0.5.0`)。
107 changes: 107 additions & 0 deletions docs/ci-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Running euthyna as a PR gate (GitHub Actions)

English · [中文](ci-integration-zh.md)

> **What this document is**: a copy-paste recipe for gating a pull request on `euthyna audit`,
> plus what each exit code means when the gate goes red. It uses only the exit-code contract the
> CLI already publishes — no flags are interpreted, no report is parsed.
>
> **Limitations**: the history producer reads git, so it needs `fetch-depth: 0` (or at least
> `--base` reachable). The dependency producer reads lockfiles; it answers what the manifest
> pins, not whether a pinned version has a CVE — that adjudication is left to the agent layer,
> by design (see [`fact-contract.md`](fact-contract.md) §6.3).

## The gate, one step

Put this in the repository that wants the gate, as `.github/workflows/audit.yml`:

```yaml
name: euthyna audit

on:
pull_request:

jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # history attribution walks real git objects
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Change-surface audit (deleted-code provenance + dependency pins)
run: npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD
```

That is the whole gate. Read the exit code the way the CLI publishes it:

| Exit | Meaning in a PR gate | What the merge button should do |
|---|---|---|
| `0` | Measured, nothing security-classified found | Merge freely |
| `10` | Measured; at least one fact is security-classified (deleted code from a security fix, a reintroduction under `--pickaxe`) | A human reads the report before merging — this is the intended stop, not a bug |
| `1` | Usage error (bad `--base`, wrong flag) | Fix the workflow, not the PR |
| `2` | Could not measure at all (no lockfile is *not* this — missing data is reported, not fatal) | **Never merge on `2`**; "failed to measure" is not "clean" |

Because GitHub treats any non-zero exit as a failed step, `0` shows green and `10`, `1`, `2` all
show red. If you want `10` (something found) to show green-but-commented instead, capture and
translate:

```yaml
- name: Change-surface audit (deleted-code provenance + dependency pins)
run: |
npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD
continue-on-error: true
id: audit
- name: Enforce the contract (10 = review, anything else non-zero fails)
if: always()
run: |
code=${{ steps.audit.outcome == 'success' && 0 || 1 }}
if [ "$code" -eq 0 ]; then exit 0; fi
echo "euthyna audit did not measure clean (or found security-classified history); see the step above." >&2
exit 1
Comment on lines +53 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The optional two-step workflow cannot distinguish exit code 10 from exit codes 1 and 2: every non-successful audit outcome is converted to 1, so the enforcement step fails for flagged audits as well as measurement or usage errors. This contradicts the stated purpose of allowing 10 not to block merges.

Triggers: When a repository copies the two-step variant intending security-classified findings (10) to remain mergeable.

Suggested fix: Capture the audit process exit code explicitly, for example by writing $? to GITHUB_OUTPUT before enabling continue-on-error, then allow only code 10 and fail for 1 or 2.

```

`ponytail:` the two-step form re-implements the exit code in shell to blur `10` vs `2`; the
one-step form is the honest gate. Use the two-step form only when `10` must not block merges.

## Reading the report in the PR

The run prints the human-readable report to the step log. To attach it to the PR instead:

```yaml
- name: Change-surface audit
run: |
npx --yes euthyna@latest audit --base "origin/${{ github.base_ref }}" --head HEAD \
| tee audit-report.txt
continue-on-error: true
id: audit
- uses: actions/upload-artifact@v4
if: always()
with:
name: euthyna-audit-report
path: audit-report.txt
Comment on lines +75 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): The artifact-upload recipe pipes euthyna audit into tee without enabling pipefail, so the shell reports tee's successful status instead of the audit's exit code. A usage error, unmeasured result, or flagged result therefore makes this copied workflow step succeed and can silently turn the purported PR gate green.

Triggers: When this artifact snippet is used as the audit step in a gating workflow.

Suggested fix: Enable set -o pipefail before the pipeline, or capture the audit status explicitly while still writing the report file.

```

Add `--json` and point the artifact at the JSON file when a downstream consumer (a comment bot,
an adjudicator agent) should read facts rather than prose.

## What the gate does and does not answer

- **Does**: which deleted lines in this PR come from commits classified `security` or `fix`
(origins on by default — the first introducer, not the last modifier), which lockfile
dependencies the manifest pins and where.
- **Does not**: decide whether anything is a vulnerability. `10` means *there is material for
adjudication*; the six-gate verdict happens in the agent layer (`euthyna gate`), which this
workflow deliberately does not run — a workflow cannot produce the evidence discipline the
gates demand, and pretending otherwise would manufacture verdicts.

## Notes

- `--pickaxe` adds reintroduced-line detection at a cost of one `git log -S` probe per deleted
line (capped); enable it when regressions-of-fixed-code are the threat model.
- `github.base_ref` is the PR's target branch, so `origin/main` is the right `--base` for the
usual case; a stack of PRs needs the real fork point instead.
- `npx euthyna@latest` pins nothing; a gate that drifts with npm's `latest` tag is a gate whose
verdicts are not comparable across time. Pin it (`euthyna@0.5.0`) when consistency matters.
Loading