diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 75a1675..a2f1855 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -42,14 +42,16 @@ # duplicate, and whose investigator measures its posture with the tools its # image carries; 0.7.1 is the first whose images ship neither a CA bundle that # trusts compromised intermediates nor a trace-header parser one request could -# keep busy for seconds. This file needs all of it, so it cannot point at an -# older tag. +# keep busy for seconds; 0.8.0 is the first whose judge takes the in-flight cap +# and the opt-in dollar ceiling set below, and whose investigator refuses an +# unsigned approve, which is why the pipe and the investigator both carry the +# action secret. This file needs all of it, so it cannot point at an older tag. name: hookstack services: hookrelay: - image: ghcr.io/itswl/hookrelay:0.7.1@sha256:5e3e20c37905d0bf82e4f6086b8eb5d68677461919c78a683028b02ee9861a53 + image: ghcr.io/itswl/hookrelay:0.8.0@sha256:934d9353508d77033e7d9683f03c5da76adbec5ff155bbdfd6f097ced639e2ff container_name: hs-hookrelay restart: unless-stopped environment: @@ -68,6 +70,12 @@ services: # found by actually running this file rather than reading it. Same demo # values as docker-compose.yml, which has always set both. HOOKRELAY_ACTION_SECRET: demo-card-secret + # The baked config signs the press it forwards to the investigator's action + # door with this (`to-probe-action`); the investigator below verifies with + # the same variable. Since 0.8.0 an unsigned approve answers 503 while the + # button still renders, so one side without it is a demo that stops at + # the press. + HOOKPROBE_ACTION_SECRET: ${HOOKPROBE_ACTION_SECRET:-demo-card-secret} HOOKRELAY_PUBLIC_URL: ${HOOKRELAY_PUBLIC_URL:-http://127.0.0.1:8100} HOOKRELAY_WORKER_INTERVAL: "0.5" # A captured request replayed inside the signature's freshness window is @@ -91,7 +99,7 @@ services: - sink hookjudge: - image: ghcr.io/itswl/hookjudge:0.7.1@sha256:d9f617024cbb4b2baee2e318a0ba87ca5d8436163fea4ec450df69f4733927b1 + image: ghcr.io/itswl/hookjudge:0.8.0@sha256:28f45f38a045da139cf65d970e7b7a9c8efe835dcd327c2afd529bc4b4a7b5da container_name: hs-hookjudge restart: unless-stopped environment: @@ -124,7 +132,7 @@ services: # operator would actually have received. It runs from the pipe's own image # because that image now carries it — one fewer thing to pull, and no mount. sink: - image: ghcr.io/itswl/hookrelay:0.7.1@sha256:5e3e20c37905d0bf82e4f6086b8eb5d68677461919c78a683028b02ee9861a53 + image: ghcr.io/itswl/hookrelay:0.8.0@sha256:934d9353508d77033e7d9683f03c5da76adbec5ff155bbdfd6f097ced639e2ff container_name: hs-sink restart: unless-stopped command: ["python", "-u", "/app/examples/sink.py"] @@ -137,7 +145,7 @@ services: # exactly the hop a real deployment makes to a custom bot's incoming URL. # `ops-feishu` in the baked config is `type: bridge` and points here. lark-bridge: - image: ghcr.io/itswl/lark-bridge:0.7.1@sha256:a2cb299043a983a59de1f60fb8fb20b2c1769255c9295091b0a555b4505f281f + image: ghcr.io/itswl/lark-bridge:0.8.0@sha256:b1a06bd785ed4d0de99d8dbf49d01b2f258ba63267a2f452b1182d559d385b1d container_name: hs-lark-bridge restart: unless-stopped environment: @@ -155,7 +163,7 @@ services: # not a model: it reports itself as `stub-4o-mini`. Point the judge's AI vars # at a real provider and this stops being used. stub-ai: - image: ghcr.io/itswl/hookjudge:0.7.1@sha256:d9f617024cbb4b2baee2e318a0ba87ca5d8436163fea4ec450df69f4733927b1 + image: ghcr.io/itswl/hookjudge:0.8.0@sha256:28f45f38a045da139cf65d970e7b7a9c8efe835dcd327c2afd529bc4b4a7b5da container_name: hs-stub-ai restart: unless-stopped command: ["python", "-u", "/app/examples/stub_ai.py"] @@ -167,7 +175,7 @@ services: # pipe to the same channels; the disk alert in demo.sh also proposes a # two-step procedure the approve button runs against the allowlist below. hookprobe: - image: ghcr.io/itswl/hookprobe:0.7.1@sha256:9a4f7d26c0457df35f643cb083f325dd1cb5e78ad6035f29b6045fc5ced23ef0 + image: ghcr.io/itswl/hookprobe:0.8.0@sha256:f49d4fc36cf18c31456403076c203c54e7d12cee93fac130019dda8c10614920 container_name: hs-hookprobe restart: unless-stopped # The agent executes arbitrary bash, so the container is bounded even in a @@ -180,6 +188,9 @@ services: environment: HOOKPROBE_TOKEN: ${HOOKPROBE_TOKEN:-quickstart-token} HOOKPROBE_RETURN_URL: http://hookrelay:8100/hook/probe-notify + # Verifies the press the pipe forwards, with the same variable as the pipe + # above: an unsigned approve is indistinguishable from a run shaping one. + HOOKPROBE_ACTION_SECRET: ${HOOKPROBE_ACTION_SECRET:-demo-card-secret} # The rehearsal, and the name its runs carry. Set all three of # HOOKPROBE_RUNTIME, HOOKPROBE_MODEL and a key to investigate for real. HOOKPROBE_RUNTIME: ${HOOKPROBE_RUNTIME:-replay} diff --git a/hookrelay/tests/test_stack_compose.py b/hookrelay/tests/test_stack_compose.py index 52ec5d4..1f9a291 100644 --- a/hookrelay/tests/test_stack_compose.py +++ b/hookrelay/tests/test_stack_compose.py @@ -5,6 +5,10 @@ `HOOKPROBE_ACTION_SECRET` at all: the pipe drew a button and the investigator answered 503. A service that looks healthy while its buttons cannot land is the shape this test exists to catch, without needing Docker. + +The quickstart compose carries the same pair since 0.8.0: pinned to images whose +investigator refuses an unsigned approve, it would have drawn the button and +stopped the demo at the press, and nothing here read that file. """ from __future__ import annotations @@ -12,7 +16,11 @@ import re from pathlib import Path +import pytest +import yaml + COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.yml" +QUICKSTART = Path(__file__).resolve().parents[2] / "docker-compose.quickstart.yml" STACK_CONFIG = Path(__file__).resolve().parents[2] / "hookrelay" / "examples" / "stack.yaml" @@ -38,3 +46,27 @@ def test_the_pipe_signs_the_forwarded_press_with_the_door_it_verifies_with() -> assert "secret: ${HOOKPROBE_ACTION_SECRET}" in block, ( "to-probe-action is unsigned while the action door requires a signature; every approve press is a 401" ) + + +def _resolved(value: object) -> str: + """A compose value as a run from an empty directory resolves it: ${VAR:-default} is the default.""" + text = str(value or "") + match = re.fullmatch(r"\$\{(\w+):-([^}]*)\}", text) + return match.group(2) if match else text + + +def _environment(compose: Path, service: str) -> dict[str, str]: + env = yaml.safe_load(compose.read_text(encoding="utf-8"))["services"][service].get("environment") or {} + if isinstance(env, list): + env = dict(item.split("=", 1) for item in env) + return {str(key): _resolved(value) for key, value in env.items()} + + +@pytest.mark.parametrize("compose", [COMPOSE, QUICKSTART], ids=lambda path: path.name) +def test_the_pipe_signs_the_forwarded_press_with_the_value_the_investigator_verifies(compose: Path) -> None: + signed = _environment(compose, "hookrelay").get("HOOKPROBE_ACTION_SECRET", "") + verified = _environment(compose, "hookprobe").get("HOOKPROBE_ACTION_SECRET", "") + assert signed and signed == verified, ( + f"{compose.name}: the pipe forwards approve signed with {signed!r} and the investigator verifies with " + f"{verified!r}; an unsigned or mismatched press is refused while the button still renders" + ) diff --git a/scripts/demo.sh b/scripts/demo.sh index 355009a..3aacb5c 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -198,6 +198,16 @@ for res in row.get("results", []): first = (res.get("output") or "").strip().splitlines()[:1] print(f" exit {res['exit']:>2} {res['command']:<22} {first[0][:66] if first else ''}") PY +# An accepted press that never executes is the loop not closing, and the tour +# must say so rather than finish as if it had: with the action door unsigned on +# one side, 0.8.0's investigator refused both presses with a 503 and this script +# still ended 0. Nothing approved (the cooldown on a second run) is not a failure. +press_failed="" +if [ "$approved" = "1" ] && [ "$pstatus" != "executed" ]; then + press_failed="the press was accepted but the procedure is '${pstatus:-unknown}', not executed" + echo "!! $press_failed. The investigator's log says why: docker logs hs-hookprobe 2>&1 | grep hooks/action" + echo " (a 503 there means the pipe and the investigator do not share HOOKPROBE_ACTION_SECRET)." +fi say "9. the condition ends: the recovery verifies the work, and the pipe's audit record holds the chain" post '{"title":"Disk /data at 92% on db-1","message":"back to 15% after the slot was dropped","state":"resolved","env":"prod"}' >/dev/null @@ -225,6 +235,11 @@ print(f"the page: {os.environ['RELAY_URL']}/audit/{rec.get('operation')}") print(f"the alert's story, top to bottom: {os.environ['RELAY_URL']}/#/alert/{rec.get('operation')}") PY +if [ -n "$press_failed" ]; then + printf '\nThe loop did not close: %s.\n' "$press_failed" >&2 + exit 1 +fi + printf '\nThis was a rehearsal: no model was called, and nothing ran except the two observations a person approved.\n' printf 'The boards: %s (pipe) %s (judge) %s/ui (investigator)\n' "$base" "$judge" "$probe" printf 'What the operator would have received: docker compose logs -f sink\n'