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
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
title: The open bridge and the replay window close, both fail-closed
status: implemented
date: 2026-10-12
scope: stack
---

## Decision

Two boundaries that were shaped so an operator had to remember something are now
shaped so forgetting fails closed.

1. **The bridge refuses to start with an empty `BRIDGE_INBOUND_SECRET`.** It used
to accept every card, and `docs/containment.md` recorded that as the *shipped
default* — the reason both deployments set the secret. A deployment that
genuinely has nobody untrusted on the wire now says `BRIDGE_ALLOW_UNSIGNED=1`
and accepts everything on purpose, out loud.
2. **The pipe claims each inbound signature once.** `security.replay_token` +
`Store.claim_signature` answer the second copy of a signed request as a
duplicate *before* the fuse and the pipeline, so a capture replayed inside the
300-second skew cannot buy a second verdict. `HOOKRELAY_SIGNATURE_REPLAY_SECONDS`
is the window (default 300; 0 turns the cache off and leaves the old behaviour).

The `HOOKPROBE_POSTURE_CHECK=enforce` default, which the containment page used to
read as an opt-in, is now spelled out in the deploy composes. The code already
defaulted to `enforce`; only the documents disagreed.

## Why

Both rows in `docs/containment.md` named the gap themselves, and a gap that is
safe only while an operator remembers is one the next deployment will not have.
That page's own thesis is that a boundary with limits written down is worth more
than one trusted for more than it holds — but writing the limit down is not the
same as closing it, and two of these were closable in the schema and a startup
check.

The replay cache closes the last hole in the old argument. `verify_signature`
said a replay "mostly arrives as a duplicate and is skipped as one" — true only
inside the 120-second content-dedup window. An alert replayed at t=200s is inside
the 300-second skew and *outside* the content window, and was a fresh judge call.
The second identity (`replayed_signature`, keyed on the signature) folds exactly
that case without touching the first (`duplicate`, keyed on content).

The bridge change is a reversal of a shipped default, which is why it has this
note rather than a comment: the old behaviour was documented, deliberate, and
wrong for anyone but the one operator who knew to set the secret.

## Consequences

- Two knobs to know: `HOOKRELAY_SIGNATURE_REPLAY_SECONDS` and
`BRIDGE_ALLOW_UNSIGNED`. Both are reachable from a compose and documented in
the reference tables the gate generates.
- A legitimate sender that retries an identical signed request inside the skew
now gets `{"outcome": "skipped", "skip_code": "replayed_signature"}` instead of
a recorded `duplicate` row. Same HTTP shape, one fewer ledger row; the sender's
view is unchanged.
- Plugin adapters that speak their own header (`X-Hub-Signature-256`, Stripe) are
**not** tracked — `replay_token` says so rather than guessing, so those doors
keep the bounded window. Closing them means teaching the adapter its own replay
key, not widening this one.
- The bridge's in-body legacy sign still covers only the timestamp, so its
five-minute window stays bounded, not closed. The containment page says so.
- The pipe grew past 6,300 source lines, so `CEILINGS['hookrelay']` moved to
6,400 with the doctrine answer recorded beside it: the door is the pipe's job,
it reads the pipe's own header and never a payload.
3 changes: 2 additions & 1 deletion .github/workflows/ci-hookjudge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ jobs:
JSON.parse(fs.readFileSync("hookjudge/static/manifest.webmanifest", "utf8"));
console.log("status.html inline JS, sw.js and the manifest: OK");
'
- run: pytest -q
# Coverage floor, matching gate.sh: a regression catch, not a target.
- run: pytest -q --cov=hookjudge --cov-fail-under=90
- run: python -m pip_audit --progress-spinner off

docker:
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ci-hookprobe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ jobs:
'
- name: shell scripts parse
run: find examples scripts -name '*.sh' -print0 | xargs -0 -r -n1 bash -n
- run: pytest -q
# Coverage floor, matching gate.sh: a regression catch, not a target.
- run: pytest -q --cov=hookprobe --cov-fail-under=85
- run: python -m pip_audit --progress-spinner off

docker:
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci-stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ jobs:
# in is a comment.
- name: the pipe and the brain are inside their stated ceilings
run: python3 scripts/assert_weight.py
# Every headline number, in the two classes it can be in: reproducible here
# (run now, fail if it has stopped holding) or measured on the retired
# deployments and named as such. The two pytest-backed claims are UNRUN in
# this job (no service venvs) and reported as UNRUN, never as OK; the
# whole-repository gate, where the component gates just built the venvs,
# runs them for real.
- name: every headline number is reproduced here or named as reported
run: python3 scripts/reproduce_claims.py
# Three services build one image tag, and the tag is whichever build ran
# last: a build arg one of them does not pass is an image the others did
# not mean. The work image shipped without kubectl this way (2026-09-29).
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ jobs:
cfg = Config.from_file("config.example.yaml")
print(f"{len(cfg.sources)} sources, {len(cfg.channels)} channels, {len(cfg.routes)} routes")
PYEOF
- run: pytest -q
# Coverage floor, matching gate.sh: a regression catch, not a target.
- run: pytest -q --cov=hookrelay --cov-fail-under=90
- run: python -m pip_audit --progress-spinner off

docker:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
__pycache__/
.pytest_cache/
.ruff_cache/
# Coverage data from `pytest --cov`, which every service's gate now runs.
.coverage
.coverage.*
htmlcov/
*.db
.env
# Rotation backups hold the same credentials as .env.
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ Four workflows, and between them nothing at the root is uncovered:
`ci` (pipe) · `ci-hookjudge` (brain) · `ci-hookprobe` (investigator) ·
`ci-stack` (the demo pair together, plus the docs and the compose files).

## Evidence and releases

- [docs/evidence.md](docs/evidence.md) and `scripts/reproduce_claims.py`: every
headline number, split into one this repository can reproduce (the script runs
it) and one measured on the retired deployments and named as such.
- [docs/releasing.md](docs/releasing.md): a release is one tag and four
digest-pinned images, and the notes are the decision records between two tags.

## The one rule that is not about layout

Run `bash scripts/gate.sh` as its own step and READ the verdict before any
Expand Down
47 changes: 19 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,27 @@ A signal comes in: an alert, a chat message, a ticket, a timer. A pipe signs, ro

The investigator is the product. The pipe and the judge are the smallest alerting front end for a team that has none. One deployment is one team: this is not a work-management platform, a multi-tenant SaaS or an agent framework.

![hookprobe's console: a real investigation, conclusion first, the root cause it could not establish said so, and the bill under it](docs/img/hookprobe-sessions.png)

## Try it: ten minutes, no keys, no bill

```bash
curl -fsSLO https://raw.githubusercontent.com/itswl/hookstack/main/docker-compose.quickstart.yml
docker compose -f docker-compose.quickstart.yml up -d # pipe + judge + stub model + investigator on a rehearsal + readable sink
bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts/demo.sh)
```
## Start here

The demo runs the whole loop. An alert is judged, a recorded investigation replays through the real read-only gate, the report arrives as a card, an approve press runs two allowlisted commands, and the alert resolves. To use a real model, put `HOOKPROBE_RUNTIME=claude`, `HOOKPROBE_MODEL` and a key in `.env`.
- **30 seconds:** [docs/quickstart.md](docs/quickstart.md) opens with the shape of it — three services, one diagram.
- **10 minutes, no keys, no bill:** pipe + judge + a stub model + the investigator on a rehearsal + a readable sink. Docker is the only prerequisite.

## What you get
```bash
curl -fsSLO https://raw.githubusercontent.com/itswl/hookstack/main/docker-compose.quickstart.yml
docker compose -f docker-compose.quickstart.yml up -d
bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts/demo.sh)
```

- **Only the work worth doing.** A storm, a restatement or a recovery never buys a second verdict. On 795 production alerts, 28 of 29 rules answered the same every time, so `rule-reuse` answers them for free.
- **A card a person can rule on.** Every button is signed before the card leaves, and every press is recorded.
- **Remediation with a person in the loop.** An approved procedure runs step by step against an allowlist, as argv, never through a shell. A press approves the version that was read: the card and the console both name the procedure's digest, and one that changed since is refused. A plan a person hands off is carried out by a separate work node, with a write credential of its own and a guard that refuses destructive commands; it made its first real change on 2026-09-30 ([how it went](docs/deployments.md#the-first-real-write)).
- **Investigations that leave something behind.** A finished run distills a runbook, and the next occurrence starts from it.
- **Agents you can contain.** Read-only by default and measured at startup, budget ceilings that refuse out loud, and the structural boundaries, each written up with what it does **not** stop ([containment](docs/containment.md)).
- **Boards that install on your phone.** All three boards are web apps you add to the home screen, the investigator's under its node's own name; each keeps its tokens in that browser and nothing else, and offline it shows the page and says its service is out of reach. On a phone the header runs under the status bar in its own colour and follows the theme, the tabs fit one row, and wide tables become one block per row.
- **One audit page per event.** Every hop, digest, decision and human action, a flight recorder of every tool call, and a timing waterfall per run.
- **Your model, your chat.** Any Anthropic-dialect endpoint for the investigator, any OpenAI-compatible one for the judge, local models included. Feishu/Lark through a bridge ([protocol](docs/bridge-protocol.md)), DingTalk and WeCom as a plugin, or signed JSON to any webhook.
The demo runs the whole loop: an alert judged, a recorded investigation replayed
through the real read-only gate, the report as a card, an approve press running
two allowlisted commands, the alert resolved. [docs/quickstart.md](docs/quickstart.md)
has the rest of it — a real model, the boards, the half-day path.
- **Half a day, for real:** [STACK.md](STACK.md) runs all three from source; [docs/containment.md](docs/containment.md) is the boundary map you read *before* pointing it at a cluster.
- **What it does, in full:** [docs/what-you-get.md](docs/what-you-get.md).

![hookrelay's board: one sentence on what waits on you, four numbers, and one row per alert with its seven stages](docs/img/hookrelay-timeline.png)

![hookjudge's board: verdicts with their routes and what each cost](docs/img/hookjudge-status.png)

![hookrelay's ledger: every event with its decision chain, its deliveries, and what came back](docs/img/hookrelay-ledger.png)

![The three boards on a phone: the pipe's attention entry, the investigator's procedure executed after a named person approved it, and the judge's board](docs/img/hookstack-phones.png)
![hookprobe's console: a real investigation, conclusion first, the root cause it could not establish said so, and the bill under it](docs/img/hookprobe-sessions.png)

The three boards read in Chinese or English and follow the system's light or dark. The screenshots come from local runs, not mockups.
The three boards read in Chinese or English and follow the system's light or dark; [the full tour](OVERVIEW.md) has every screenshot, from local runs and not mockups.

## How it fits together

Expand All @@ -64,6 +53,8 @@ The same pipe also carries an operator's own work signals to a planner and, on a

## Read more

- [docs/quickstart.md](docs/quickstart.md): the one page to start from
- [docs/what-you-get.md](docs/what-you-get.md): the feature list, in full
- [OVERVIEW.md](OVERVIEW.md): the tour, with every screenshot
- [STACK.md](STACK.md): run the whole stack locally, step by step
- [hookrelay](hookrelay/README.md), [hookjudge](hookjudge/README.md), [hookprobe](hookprobe/README.md): each service in depth
Expand All @@ -73,6 +64,6 @@ The same pipe also carries an operator's own work signals to a planner and, on a

## Developing

`bash scripts/gate.sh` runs every service's gate plus the stack checks. Read its verdict before you commit. The pipe caps itself at 6,300 source lines and the judge at 3,400; the investigator is uncapped.
`bash scripts/gate.sh` runs every service's gate plus the stack checks. Read its verdict before you commit. The pipe caps itself at 6,450 source lines and the judge at 3,400; the investigator is uncapped.

MIT licensed.
45 changes: 17 additions & 28 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,25 @@

调查员是产品本身,管道和判官是给没有告警平台的团队准备的最小前端。一套部署就是一个团队:它不是工作管理平台,不是多租户 SaaS,也不是 agent 框架。

![hookprobe 的控制台:一次真实的调查,结论在最前面,查不到的根因明说查不到,下面是这次的花费](docs/img/zh/hookprobe-sessions.png)

## 试一下:十分钟,不要 key,不花钱

```bash
curl -fsSLO https://raw.githubusercontent.com/itswl/hookstack/main/docker-compose.quickstart.yml
docker compose -f docker-compose.quickstart.yml up -d # 管道 + 判官 + 桩模型 + 跑排练的调查员 + 可读的接收端
bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts/demo.sh)
```
## 从这里开始

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The three runnable demo lines and the Chinese feature list left this page, and the targets are English only. Short is right; losing the only runnable commands from both front pages is not. Keep the three lines inline under “10 分钟” and link the rest. — Claude (claude-fable-5-1)


演示会跑完整条闭环:告警被判定,一次录好的调查经过真实的只读门回放,报告变成卡片,按下批准后跑两条允许清单里的命令,告警恢复。要换成真实模型,在 `.env` 里设置 `HOOKPROBE_RUNTIME=claude`、`HOOKPROBE_MODEL` 和模型 key。
- **30 秒:**[docs/quickstart.md](docs/quickstart.md) 开头就讲清了它的形状 —— 三个服务,一张图。
- **10 分钟,不要 key,不花钱:** 管道 + 判官 + 桩模型 + 跑排练的调查员 + 可读的接收端,只要有 Docker。

## 你能得到什么
```bash
curl -fsSLO https://raw.githubusercontent.com/itswl/hookstack/main/docker-compose.quickstart.yml
docker compose -f docker-compose.quickstart.yml up -d
bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts/demo.sh)
```

- **只做值得做的事。** 告警风暴、重复的同一条件、恢复事件都不会再花第二次判定的钱。在 795 条生产告警上实测,29 条规则里有 28 条每次答案都一样,所以 `rule-reuse` 不调模型就能回答。
- **一张人可以裁定的卡片。** 每个按钮在卡片发出前就签了名,每次按下都有记录。
- **修复有人把关。** 批准后的流程逐步对照允许清单执行,以 argv 运行,从不经过 shell。批准的是读到的那个版本:卡片和控制台都会带上流程的摘要,之后变过的流程会被拒绝。人交接的计划由单独的执行节点用它自己的写凭证去做,危险命令会被拒绝;2026-09-30 它第一次真实改动了系统([经过](docs/deployments.md#the-first-real-write))。
- **调查会留下东西。** 跑完的调查会提炼成手册,同一条件下次出现时从手册开始。
- **关得住的 agent。** 默认只读并在启动时实测,预算用完会明确拒绝,另有一组结构性边界,每一条都写明它**挡不住**什么([containment](docs/containment.md))。
- **装到手机上的看板。** 三个看板都是可以添加到主屏幕的 web 应用,调查员的以节点自己的名字出现;口令只留在那个浏览器里,离线时显示页面并告诉你连不上服务。在手机上,页头延伸到状态栏下方、颜色随主题切换,页签一行放下,宽表格按行变成小块。
- **每个事件一页审计。** 每一跳、摘要、决策和人的操作,每次工具调用的飞行记录,以及每次运行的耗时瀑布图。
- **模型和聊天工具由你选。** 调查员接任意 Anthropic 方言的端点,判官接任意 OpenAI 兼容的端点,本地模型也可以。飞书经桥接送达([协议](docs/bridge-protocol.md)),钉钉和企微有插件,也可以把签名 JSON 发到任意 webhook。
演示会跑完整条闭环:告警被判定,一次录好的调查经过真实的只读门回放,报告变成卡片,按下批准后跑两条允许清单里的命令,告警恢复。[docs/quickstart.md](docs/quickstart.md)
里有其余内容 —— 换成真实模型、三块看板、半天的路径。
- **半天,真上:**[STACK.md](STACK.md) 从源码一步步跑起三个服务;[docs/containment.md](docs/containment.md) 是指向真实集群**之前**要读的边界地图。
- **完整功能清单:**[docs/what-you-get.md](docs/what-you-get.md)(英文)。

![hookrelay 的看板:一句话说有几件事等你处理,四个数字,每条告警一行,各带七个阶段](docs/img/zh/hookrelay-timeline.png)

![hookjudge 的看板:每条判定走了哪条路径、花了多少](docs/img/zh/hookjudge-status.png)

![hookrelay 的流水:每个事件的决策、投递去向和回流](docs/img/zh/hookrelay-ledger.png)

![三块看板在手机上:管道的待处理入口,调查员那条经有名字的人批准后执行的处置流程,以及判官的看板](docs/img/zh/hookstack-phones.png)
![hookprobe 的控制台:一次真实的调查,结论在最前面,查不到的根因明说查不到,下面是这次的花费](docs/img/zh/hookprobe-sessions.png)

三块看板都能切换中英文,并跟随系统的浅色或深色。截图取自本地真实运行,不是效果图。
三块看板都能切换中英文,并跟随系统的浅色或深色;[完整介绍](OVERVIEW.md)(英文)里有全部截图,取自本地真实运行,不是效果图。

## 怎么串起来

Expand All @@ -62,6 +49,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts

以下文档都是英文。

- [docs/quickstart.md](docs/quickstart.md):应该从这里读的第一页
- [docs/what-you-get.md](docs/what-you-get.md):完整功能清单
- [OVERVIEW.md](OVERVIEW.md):完整介绍,含全部截图
- [STACK.md](STACK.md):在本地一步步跑起整个栈
- [hookrelay](hookrelay/README.md)、[hookjudge](hookjudge/README.md)、[hookprobe](hookprobe/README.md):每个服务的详细说明
Expand All @@ -71,6 +60,6 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itswl/hookstack/main/scripts

## 开发

`bash scripts/gate.sh` 会跑每个服务的 gate 和整个栈的检查。提交前读它的结论。管道的源码上限是 6,300 行,判官 3,400 行,调查员不设上限。
`bash scripts/gate.sh` 会跑每个服务的 gate 和整个栈的检查。提交前读它的结论。管道的源码上限是 6,450 行,判官 3,400 行,调查员不设上限。

MIT 协议。
Loading
Loading