Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
312 lines (303 loc) · 17 KB
/
Copy pathdocker-compose.yml
File metadata and controls
312 lines (303 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# The whole family, one command:
#
# docker compose up -d --build
#
# Four containers — the pipe, the brain, a downstream you can read, and a stub
# model so the paid routes are visible without a key or a bill.
#
# you ──► hookrelay :8100 ──► hookjudge :8200 ──► hookrelay ──► sink
# /hook/inbound (judges) /hook/judge-notify
# /hook/alertmanager
#
# Production does NOT use this file — see deploy/docker-compose.prod.yml, which
# runs the pipe alone, bound to loopback, on the network of the platform it feeds.
name: hookstack
services:
hookrelay:
build: ./hookrelay
# Named, so an image built ahead of `up` (CI bakes the four with a layer
# cache) is the one compose looks for. The default name would be the same;
# bake needs it written down.
image: hookstack-hookrelay
container_name: hs-hookrelay
environment:
HOOKRELAY_HOST: 0.0.0.0
HOOKRELAY_PORT: 8100
HOOKRELAY_DB: /data/hookrelay.db
HOOKRELAY_CONFIG: /etc/hookrelay/config.yaml
HOOKRELAY_WORKER_INTERVAL: "0.5"
HOOKRELAY_SIGNATURE_REPLAY_SECONDS: ${HOOKRELAY_SIGNATURE_REPLAY_SECONDS:-300} # closes the captured-request replay window (0 = bounded only)
HOOKRELAY_SIGNATURE_LEASE_SECONDS: ${HOOKRELAY_SIGNATURE_LEASE_SECONDS:-120} # how long an in-flight signed request holds its signature
# The shipped example plugins, mounted read-only below: the DingTalk
# dialect the stack config uses is one of them (chat_markdown_channels.py).
HOOKRELAY_PLUGINS: /etc/hookrelay/plugins
# Where the escalation route delivers: the investigator's door, which is
# up by default on the rehearsal (see the hookprobe service). Point it at
# http://sink:9000/probe-standin to see the escalation shape alone.
HOOKPROBE_EVENT_URL: ${HOOKPROBE_EVENT_URL:-http://hookprobe:8088/hooks/event}
# Where a press on an investigator's card goes back to — the approve,
# follow-up and remember buttons the stack config offers forward here.
HOOKPROBE_ACTION_URL: ${HOOKPROBE_ACTION_URL:-http://hookprobe:8088/hooks/action}
# The same value the action door verifies with below. The example config
# signs to-probe-action with this; one side without it is a 401 on the
# approve press, which the stack smoke catches and a unit test cannot.
HOOKPROBE_ACTION_SECRET: ${HOOKPROBE_ACTION_SECRET:-demo-card-secret}
# Card actions. A demo value on purpose: without a secret no card carries a
# button at all, so the whole return leg would be invisible here — and the
# escalation sweep would disarm itself, which is correct behaviour but not
# something you can see. Use a real secret anywhere the port is reachable.
HOOKRELAY_ACTION_SECRET: demo-card-secret
# Where an action LINK points, for the dialects that cannot call back
# (DingTalk, WeCom). Loopback is right for the demo; in a real deployment
# this is the address an operator's browser can actually reach.
HOOKRELAY_PUBLIC_URL: http://127.0.0.1:8100
# Where the investigator's console is, as YOUR browser reaches it, so a
# hop in the timeline links to the investigation it names.
HOOKRELAY_UI_LINKS: ${HOOKRELAY_UI_LINKS:-probe-notify=http://127.0.0.1:8088/ui}
volumes:
# Checked in and mounted read-only, so `up` needs no setup step. Point
# HOOKRELAY_CONFIG at /data/config.yaml to use your own instead.
- ${HOOKRELAY_CONFIG_FILE:-./hookrelay/examples/stack.yaml}:/etc/hookrelay/config.yaml:ro
- ./hookrelay/examples/plugins:/etc/hookrelay/plugins:ro
- relay-data:/data
# The whole .env is injected, so a new ${NAME} in the config only needs a
# line in .env — no compose edit, no forgotten passthrough.
env_file:
- path: .env
required: false
ports:
- "127.0.0.1:8100:8100"
restart: unless-stopped
depends_on:
- sink
- lark-bridge
hookjudge:
build: ./hookjudge
image: hookstack-hookjudge
container_name: hs-hookjudge
environment:
HOOKJUDGE_HOST: 0.0.0.0
HOOKJUDGE_PORT: 8200
HOOKJUDGE_DB: /data/hookjudge.db
# One outbound address, and it is the pipe. Fan-out is not its business.
HOOKJUDGE_RETURN_URL: http://hookrelay:8100/hook/judge-notify
HOOKJUDGE_WORKER_INTERVAL: "0.5"
HOOKJUDGE_MAX_INFLIGHT: ${HOOKJUDGE_MAX_INFLIGHT:-8}
HOOKJUDGE_BUDGET_USD: ${HOOKJUDGE_BUDGET_USD:-0}
HOOKJUDGE_BUDGET_WINDOW_HOURS: ${HOOKJUDGE_BUDGET_WINDOW_HOURS:-24}
HOOKJUDGE_REUSE_WINDOW_SECONDS: "3600"
# The third free route: an alert RULE's last AI verdict answers its next
# instances for an hour (needs a rule identity — the alertmanager door's
# `alertname`). Off (0) unless set; on here so the demo shows the route.
HOOKJUDGE_RULE_REUSE_WINDOW_SECONDS: ${HOOKJUDGE_RULE_REUSE_WINDOW_SECONDS:-3600}
# Defaults to the stub. Leaving these empty instead would send every
# event to the rule floor, and `reuse` — which only ever follows an ai
# verdict — would never happen, hiding half the cost policy.
HOOKJUDGE_AI_BASE_URL: ${HOOKJUDGE_AI_BASE_URL:-http://stub-ai:8300/v1}
HOOKJUDGE_AI_API_KEY: ${HOOKJUDGE_AI_API_KEY:-stub-key}
HOOKJUDGE_AI_MODEL: ${HOOKJUDGE_AI_MODEL:-stub-4o-mini}
HOOKJUDGE_AI_PRICE_IN_PER_1K: ${HOOKJUDGE_AI_PRICE_IN_PER_1K:-0.00015}
HOOKJUDGE_AI_PRICE_OUT_PER_1K: ${HOOKJUDGE_AI_PRICE_OUT_PER_1K:-0.0006}
# Direct self-alarm when a return goes dead — the pipe is the broken
# link at that moment, so this posts straight to a bot/collector URL.
HOOKJUDGE_ALARM_URL: ${HOOKJUDGE_ALARM_URL:-}
HOOKJUDGE_ALARM_MIN_INTERVAL_SECONDS: ${HOOKJUDGE_ALARM_MIN_INTERVAL_SECONDS:-}
volumes:
- judge-data:/data
ports:
- "127.0.0.1:8200:8200"
restart: unless-stopped
depends_on:
- stub-ai
# The chat's adapter (docs/bridge-protocol.md). The pipe sends it a card
# MODEL; it renders the Feishu card. Here it runs in WEBHOOK mode with no
# Lark account: the rendered card is posted to the sink exactly as it would
# be to a custom bot's incoming URL. Point LARK_WEBHOOK_URL at a real bot and
# the same container notifies a real chat; give it an app instead
# (deploy/lark-bridge/README.md) and it gains buttons that call back and
# replies in threads.
lark-bridge:
build: ./deploy/lark-bridge
image: hookstack-lark-bridge
container_name: hs-lark-bridge
environment:
LARK_WEBHOOK_URL: ${LARK_WEBHOOK_URL:-http://sink:9000/feishu}
LARK_WEBHOOK_SECRET: ${LARK_WEBHOOK_SECRET:-}
# Same value as the channel's `secret` in hookrelay/examples/stack.yaml.
BRIDGE_INBOUND_SECRET: demo-bridge-secret
restart: unless-stopped
depends_on:
- sink
# The last link, otherwise invisible: `docker compose logs -f sink` is what
# an operator would actually have received. Lays out Feishu cards; prints
# anything else as JSON. Answers {"code": 0} so the ledger records `sent`.
sink:
image: python:3.14-slim
container_name: hs-sink
command: ["python", "-u", "/examples/sink.py"]
volumes:
- ./hookrelay/examples:/examples:ro
restart: unless-stopped
# Canned verdicts over a real OpenAI-compatible endpoint, so the client, the
# JSON-in-content convention and the token accounting are all exercised. It
# is not a model: it reports itself as `stub-4o-mini`. Put real credentials
# in .env and it stops being used.
stub-ai:
image: python:3.14-slim
container_name: hs-stub-ai
command: ["python", "-u", "/examples/stub_ai.py"]
volumes:
# The brain's stand-in, so it lives with the brain — sink stands in for
# the pipe's downstreams and lives with the pipe.
- ./hookjudge/examples:/examples:ro
restart: unless-stopped
# High/critical alerts get a deep read-only investigation whose report
# returns through the pipe's probe-notify door to the same channels.
# Up by default, on the `replay` rehearsal: a recorded investigation played
# back through the real read-only gate and audit, priced at nothing, so the
# plain `up` shows the whole loop — report, approve press, execution record,
# audit page — with no key. Set HOOKPROBE_RUNTIME, HOOKPROBE_MODEL and a key
# in .env to investigate for real; see hookprobe/docs/runtimes.md.
hookprobe:
build: ./hookprobe
image: hookstack-hookprobe
container_name: hs-hookprobe
environment:
HOOKPROBE_TOKEN: ${HOOKPROBE_TOKEN:-}
# The pipe signs every card button with HOOKRELAY_ACTION_SECRET; the
# investigator must verify with the same value. Leaving this unset made
# approve answer 503 while the pipe still drew the button — the stack
# smoke's press then failed with a service that looked healthy.
HOOKPROBE_ACTION_SECRET: ${HOOKPROBE_ACTION_SECRET:-demo-card-secret}
HOOKPROBE_RETURN_URL: http://hookrelay:8100/hook/probe-notify
# The pipe's board as your browser reaches it; a run links to its chain there.
HOOKPROBE_RELAY_UI_URL: ${HOOKPROBE_RELAY_UI_URL:-http://127.0.0.1:8100}
# And this console as your browser reaches it, so a card links to its run.
HOOKPROBE_PUBLIC_URL: ${HOOKPROBE_PUBLIC_URL:-http://127.0.0.1:8088}
HOOKPROBE_RUNTIME: ${HOOKPROBE_RUNTIME:-replay}
HOOKPROBE_MODEL: ${HOOKPROBE_MODEL:-rehearsal}
# The allowlist the approve press is checked against: the one that ships
# with the rehearsal admits exactly the two observations its disk script
# proposes. A real deployment writes its own file and points here.
HOOKPROBE_REMEDIATION_ALLOWLIST: ${HOOKPROBE_REMEDIATION_ALLOWLIST:-/app/hookprobe/replays/rehearsal-allowlist}
# How long an executed procedure waits for the condition to answer
# before the board calls it held (exit 0 alone verifies nothing); the
# demo's recovery arrives seconds later, so the default is never reached.
HOOKPROBE_REMEDIATION_VERIFY_SECONDS: ${HOOKPROBE_REMEDIATION_VERIFY_SECONDS:-}
# Budget breaker for the autonomous door: window spend >= budget means
# new escalations are refused (with a report card saying why). 0 = off.
HOOKPROBE_BUDGET_USD: ${HOOKPROBE_BUDGET_USD:-}
HOOKPROBE_BUDGET_WINDOW_HOURS: ${HOOKPROBE_BUDGET_WINDOW_HOURS:-}
# Case files and transcripts older than this are pruned daily. 0 keeps
# everything — the case files are the agent's episodic memory.
HOOKPROBE_RETENTION_DAYS: ${HOOKPROBE_RETENTION_DAYS:-}
HOOKPROBE_SCRATCH_DAYS: ${HOOKPROBE_SCRATCH_DAYS:-}
# Skill layers: "project" (distilled, on the volume) is always on;
# add "user" to also load a host library mounted at
# /data/home/.claude/skills. HOOKPROBE_SKILLS pins the session's
# skill list (comma names or "all") so a big library cannot flood it.
HOOKPROBE_SETTING_SOURCES: ${HOOKPROBE_SETTING_SOURCES:-}
HOOKPROBE_SKILLS: ${HOOKPROBE_SKILLS:-}
# Operator methodology appended to the system prompt (a file path;
# default: {workdir}/system-prompt.md when present). Named subagent
# roles come from a JSON file (name -> {description, prompt, ...}).
HOOKPROBE_SYSTEM_PROMPT_APPEND: ${HOOKPROBE_SYSTEM_PROMPT_APPEND:-}
HOOKPROBE_AGENTS_CONFIG: ${HOOKPROBE_AGENTS_CONFIG:-}
# MCP servers config (three dialects accepted; env values are
# secrets — keep the file mounted ro or on the volume):
HOOKPROBE_MCP_CONFIG: ${HOOKPROBE_MCP_CONFIG:-}
# Loop hygiene, all advisory (the boundary stays the bash guard):
# nudge the agent after N identical calls, and cap how long any single
# command may hang. 0 = off.
HOOKPROBE_REPEAT_REMINDER_AT: ${HOOKPROBE_REPEAT_REMINDER_AT:-}
HOOKPROBE_BASH_TIMEOUT_MS: ${HOOKPROBE_BASH_TIMEOUT_MS:-}
HOOKPROBE_BASH_MAX_TIMEOUT_MS: ${HOOKPROBE_BASH_MAX_TIMEOUT_MS:-}
# Model-call telemetry. On by default and harmless when nothing is
# listening — measured: with no endpoint a run finishes in the same time,
# emits no retries and logs nothing. So the switch stays on and the only
# decision left is where to send it: set OTEL_EXPORTER_OTLP_ENDPOINT when
# you have a collector, and the data starts arriving with no restart of
# anything else. What arrives per LLM call: claude_code.api_request
# {model, input_tokens, output_tokens, cache_read_tokens,
# cache_creation_tokens, cost_usd, duration_ms, session.id, prompt.id,
# event.sequence}, plus assistant_response, tool_result, tool_decision,
# hook_execution_*, mcp_server_connection, user_prompt, and the counters
# token.usage / cost.usage / session.count / active_time.total.
# NOTE the cost_usd it reports prices Claude models; on another provider
# re-price from the raw token counts and the real model name.
CLAUDE_CODE_ENABLE_TELEMETRY: ${CLAUDE_CODE_ENABLE_TELEMETRY:-1}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-otlp}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-otlp}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/json}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-}
OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-}
# Content redaction is the CLI's own (<REDACTED>), not this repository's,
# and it is five independent switches. Measured on 2.1.229: responses=1
# exposes the answers and leaves prompts redacted, while prompts=1 exposes
# both. So the answers are on by default — they are what a report is judged
# on, and their inputs are already kept upstream in the pipe's ledger —
# while the inbound side, which carries the alert payload and whatever the
# environment memory says, stays off until someone decides otherwise.
OTEL_LOG_ASSISTANT_RESPONSES: ${OTEL_LOG_ASSISTANT_RESPONSES:-1}
OTEL_LOG_USER_PROMPTS: ${OTEL_LOG_USER_PROMPTS:-1}
OTEL_LOG_TOOL_DETAILS: ${OTEL_LOG_TOOL_DETAILS:-1}
OTEL_LOG_TOOL_CONTENT: ${OTEL_LOG_TOOL_CONTENT:-1}
# Off: it costs ~82% of the telemetry volume and, capped, cannot do the
# one thing it is for. Every real call exceeds 60 KiB, so what arrives
# is half a payload plus a truncation marker — the most expensive
# signal here delivering the least reliable artifact. Turn it on
# together with CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH when byte-exact
# replay is actually the job, then turn it back off. ("0" disables it;
# verified, not assumed — a truthy-string reading would have enabled it.)
OTEL_LOG_RAW_API_BODIES: ${OTEL_LOG_RAW_API_BODIES:-0}
# Raw bodies are capped at 60 KiB by the CLI, which says so in the value
# it sends ("[TRUNCATED - Content exceeds 60KB limit]") rather than losing
# it silently — body_length still reports the true size, so the loss is
# measurable. Every real investigation exceeds it: a single call here
# carries ~113 KB once the conversation and the tool schemas are in it.
# This lifts the cap (verified: 113426 declared, 113425 received, valid
# JSON to the closing brace). Left unset on purpose — with all five
# content switches already on, uncapping as well would push the entire
# conversation on every call unconditionally. Set it when you actually
# want byte-exact replay of what the model saw.
CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH: ${CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH:-}
# Direct self-alarm when report returns exhaust their retries:
HOOKPROBE_ALARM_URL: ${HOOKPROBE_ALARM_URL:-}
HOOKPROBE_ALARM_MIN_INTERVAL_SECONDS: ${HOOKPROBE_ALARM_MIN_INTERVAL_SECONDS:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
ANTHROPIC_AUTH_TOKEN: ${ANTHROPIC_AUTH_TOKEN:-}
# Any Anthropic-dialect provider works — point ANTHROPIC_BASE_URL at its
# endpoint and map the engine's model aliases to that provider's models:
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-}
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${ANTHROPIC_DEFAULT_HAIKU_MODEL:-}
ANTHROPIC_DEFAULT_SONNET_MODEL: ${ANTHROPIC_DEFAULT_SONNET_MODEL:-}
ANTHROPIC_DEFAULT_OPUS_MODEL: ${ANTHROPIC_DEFAULT_OPUS_MODEL:-}
CLAUDE_CODE_SUBAGENT_MODEL: ${CLAUDE_CODE_SUBAGENT_MODEL:-}
API_TIMEOUT_MS: ${API_TIMEOUT_MS:-}
volumes:
- probe-data:/data
# A host skills library as the read-only user layer — mount ONLY the
# skills subdir, never the whole ~/.claude (credentials live there):
# - ~/.claude/skills:/data/home/.claude/skills:ro
ports:
- "127.0.0.1:8088:8088"
restart: unless-stopped
depends_on:
- hookrelay
# A self-scraping Prometheus, so a REAL investigator's prometheus MCP has
# something to query. Opt-in (`--profile prometheus`): the rehearsal reads
# nothing, so the default stack has no use for it.
prometheus:
image: prom/prometheus:v3.13.2
container_name: hs-prometheus
profiles: ["prometheus"]
ports:
- "127.0.0.1:9090:9090"
restart: unless-stopped
volumes:
relay-data:
judge-data:
probe-data: