diff --git a/.agents/notes/implemented/2026-10-27-recovery-must-not-claim-certainty.md b/.agents/notes/implemented/2026-10-27-recovery-must-not-claim-certainty.md
new file mode 100644
index 0000000..e416736
--- /dev/null
+++ b/.agents/notes/implemented/2026-10-27-recovery-must-not-claim-certainty.md
@@ -0,0 +1,40 @@
+---
+title: Recovery must not claim certainty
+status: implemented
+date: 2026-10-27
+scope: stack
+---
+
+## Decision
+
+Record remediation step starts and results durably, and classify interrupted
+steps as completed, unknown, or not started. Redact a step's whole output, then
+keep its last 10,000 characters (omit it past a megabyte). Reserve signed
+inbound requests with renewable SQLite leases, recording completion only after
+handling. Treat incomplete posture measurements as unverifiable, and ASK the
+instance metadata service, as the AWS CLI does, when nothing names an AWS
+credential: a role it offers is measured; no role, another vendor's service or
+nothing there is no credential. A writing node with a declared radius refuses
+an unverified measurement under enforce.
+
+Amended in review before merge: the first version assumed metadata credentials
+whenever nothing named one, which read every laptop and every probe on an
+internal network as `unverifiable` and stopped a declared writing node from
+starting; it omitted any output past 10,000 bytes, tail included; and it cut
+the pipe's explanatory comments to fit its line ceiling. The pipe's two unused
+signature methods went instead.
+
+## Why
+
+A crash between a side effect and its record cannot establish that it did not
+happen. Concurrent requests must not both enter the pipeline while the first is
+still processing, and a failed permission check cannot prove a declared radius.
+
+## Consequences
+
+- An unknown remediation step requires reconciliation before another attempt.
+- A lease can expire after a crash, allowing retries but not exactly-once external effects.
+- Patterns do not catch every secret, and proposals retain executable commands.
+- Declared writing nodes may need corrected tooling or an explicit metadata setting before starting.
+- The pipe gains a `pending_signatures` table. A rollback to an earlier pipe
+ ignores it: nothing fails to open, and a lease left in it simply expires.
diff --git a/docs/containment.md b/docs/containment.md
index 976fa3e..f12f972 100644
--- a/docs/containment.md
+++ b/docs/containment.md
@@ -19,9 +19,17 @@ a real model cannot be steered into emitting a harmful one.
> defaults to `enforce` in the code, so the deploy composes now spell it out rather than leaving the reader to find it;
> a `danger-only` runner records its blast radius; an identity that cannot be asked is reported *unverifiable*, never confirmed.
> A credential whose measuring tool is not in the image is *unmeasurable* (2026-10-12): a readonly runner refuses to start
-> under `enforce`, a writing runner starts and pages, and the hourly selftest fails until the tool is back — ten days of
+> under `enforce`, as does a writing runner with a declared radius; an undeclared writing runner starts and pages, and the hourly selftest fails until the tool is back — ten days of
> `kubectl: not found` had passed as `unverifiable` and `recorded` before this. The measurement is served on `GET /v1/posture`
> and cited in every run's audit record.
+> An unverifiable writing runner with a declared radius also refuses under `enforce`. With no explicit AWS source, the
+> instance metadata service is asked directly whether it offers a role: one it offers is measured like any other source.
+
+> **Recovery is not exactly-once (2026-10-27).** Concurrent signed hook requests use a durable, renewable processing
+> lease; an in-flight duplicate receives 503 and can retry, while completed signatures are deduplicated. A crash after
+> an external side effect but before the completion record can still cause that effect to repeat. Remediation records
+> each step's start and result; a started step without a durable result is unknown and needs operator reconciliation.
+> Output is redacted whole before it is cut to its last 10,000 characters (omitted past a megabyte); redaction is by pattern and can miss secrets, and original proposal commands remain stored.
## What holds, and what only helps
diff --git a/hookprobe/README.md b/hookprobe/README.md
index 744bded..ade8f9f 100644
--- a/hookprobe/README.md
+++ b/hookprobe/README.md
@@ -178,11 +178,18 @@ always reach. Two things about the word *default*, before the layers:
over the dangerous actions) and compares the answer with the declaration:
`readonly-confirmed`, `wider-than-declared` (refuses to start under the
default `HOOKPROBE_POSTURE_CHECK=enforce`), `unverifiable`, `unmeasurable`
- (the measuring tool is not in the image: a readonly node refuses to start
- under `enforce`, a writing node starts and pages), `recorded`,
+ (the measuring tool is not in the image: a readonly node or a writing node
+ with a declared radius refuses to start under `enforce`; an undeclared
+ writing node starts and pages), `recorded`,
`no-credentials`. The verdict is at `GET /v1/posture` and at the top of
every run's audit, so "read-only" is a measurement with a date rather than
a sentence in this file.
+ An unverifiable writing node with a declared radius also refuses under
+ `enforce`. When nothing names an AWS credential, the instance metadata
+ service is asked directly, the way the AWS CLI asks it (no proxy, a second
+ per question): a role it offers is measured like any other source; no role,
+ another vendor's service at the address, or nothing there is no credential;
+ `AWS_EC2_METADATA_DISABLED=true` skips the question.
- **Widening is a decision, and it is signed.** Under `danger-only` the guard
keeps only the rules no credential scope can undo — `rm -rf`, `mkfs`/`dd`
onto a device, fork bombs, container runtimes, `terraform destroy`,
@@ -198,6 +205,13 @@ always reach. Two things about the word *default*, before the layers:
(`HOOKPROBE_REMEDIATION_COOLDOWN_SECONDS`, 15 minutes, 0 disables) is held
rather than changed twice with nobody looking in between. Both are recorded
in the run's audit with the person's action beside them.
+ Execution checkpoints distinguish completed steps, a started step with an
+ unknown outcome, and steps not started after an interruption; reconcile an
+ unknown outcome before retrying. A step's output is redacted whole and then
+ cut to its last 10,000 characters, so a secret the cut would split was seen
+ whole by the redactor; past a megabyte it is omitted instead. Redaction is by
+ pattern, not a security boundary, and the stored proposal retains its
+ original commands.
1. **Credentials** — mount query-only credentials (read-only kubeconfig,
Prometheus/Loki endpoints, viewer tokens). This is the real boundary;
diff --git a/hookprobe/hookprobe/posture.py b/hookprobe/hookprobe/posture.py
index e0ae484..176cb8c 100644
--- a/hookprobe/hookprobe/posture.py
+++ b/hookprobe/hookprobe/posture.py
@@ -48,6 +48,8 @@
import logging
import os
import time
+import urllib.error
+import urllib.request
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -155,7 +157,19 @@ def kubeconfig_path() -> Path | None:
def aws_configured() -> bool:
- if os.environ.get("AWS_ACCESS_KEY_ID"):
+ # A profile NAME alone is not a source: it names a section of a file, and
+ # the files are what the loop below looks for. An instance metadata service
+ # is not configured at all — it is asked (see metadata_offers_credentials).
+ if any(
+ os.environ.get(name)
+ for name in (
+ "AWS_ACCESS_KEY_ID",
+ "AWS_ROLE_ARN",
+ "AWS_WEB_IDENTITY_TOKEN_FILE",
+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
+ )
+ ):
return True
for var, default in (("AWS_SHARED_CREDENTIALS_FILE", "~/.aws/credentials"), ("AWS_CONFIG_FILE", "~/.aws/config")):
if Path(os.environ.get(var) or default).expanduser().is_file():
@@ -190,11 +204,60 @@ async def check_kube(run: Runner) -> dict[str, Any]:
return record
+def metadata_offers_credentials(timeout: float = 1.0) -> bool:
+ """Whether an AWS instance metadata service here hands out a role's credentials.
+
+ When nothing names a credential this is the source left, and the AWS CLI
+ asks it the same two questions: a token (IMDSv2), then the role listing.
+ A role name in the answer is a credential; a 404 is an instance with no role;
+ anything else is no AWS service at all. A cloud VM of another vendor answers
+ at the same address, and an answer of any kind is not a credential — GitHub's
+ runners are Azure VMs, and counting any answer made the image refuse to start
+ there. No proxy, on purpose: the probes' egress proxy answers for addresses
+ it refuses, and the question is what this container reaches by itself.
+ """
+ base = (
+ os.environ.get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
+ or (
+ "http://[fd00:ec2::254]"
+ if os.environ.get("AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", "").lower() == "ipv6"
+ else "http://169.254.169.254"
+ )
+ ).rstrip("/")
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ token: dict[str, str] = {}
+ put = urllib.request.Request(
+ base + "/latest/api/token", method="PUT", headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"}
+ )
+ try:
+ with opener.open(put, timeout=timeout) as answer: # nosec B310 — a fixed link-local address
+ token = {"X-aws-ec2-metadata-token": answer.read(256).decode("ascii", "replace").strip()}
+ except (OSError, ValueError):
+ pass # IMDSv1 only, another vendor's service, or nothing: the listing decides
+ listing = urllib.request.Request(base + "/latest/meta-data/iam/security-credentials/", headers=token)
+ try:
+ with opener.open(listing, timeout=timeout) as answer: # nosec B310
+ return bool(answer.read(1024).strip())
+ except (OSError, ValueError): # HTTPError included: 404 is no role, anything else no AWS
+ return False
+
+
async def check_aws(run: Runner) -> dict[str, Any]:
if not aws_configured():
- return {"present": False}
+ # Asked, not assumed. Assuming made every laptop and every probe on an
+ # internal network `unverifiable`, and a writing node with a declared
+ # radius refused to start over a service that cannot exist there.
+ if os.environ.get("AWS_EC2_METADATA_DISABLED", "").lower() == "true":
+ return {"present": False}
+ if not await asyncio.to_thread(metadata_offers_credentials):
+ return {"present": False}
+ # A role is offered: its credentials are measured like any other source.
record: dict[str, Any] = {"present": True, "identity": None, "allowed": [], "errors": [], "unverifiable": False}
code, out, err = await run(["aws", "sts", "get-caller-identity", "--output", "json"])
+ if code != 0 and "Unable to locate credentials" in (err or out):
+ # The CLI looked everywhere it looks and found nothing: an instance with
+ # no role, say. That is an absence, not a measurement that failed.
+ return {"present": False}
if code != 0:
record["errors"].append(f"get-caller-identity: {(err or out).strip()[:200]}")
record["unverifiable"] = True
@@ -228,7 +291,29 @@ async def check_aws(run: Runner) -> dict[str, Any]:
record["errors"].append(f"simulate-principal-policy: {(err or out).strip()[:200]}")
record["unverifiable"] = True
else:
- record["allowed"] = aws_allowed(out)
+ try:
+ results = json.loads(out)["EvaluationResults"]
+ if not isinstance(results, list):
+ raise ValueError("invalid results")
+ record["allowed"] = sorted(
+ {
+ str(item.get("EvalActionName"))
+ for item in results
+ if isinstance(item, dict) and item.get("EvalDecision") == "allowed" and item.get("EvalActionName")
+ }
+ )
+ if any(
+ not isinstance(item, dict)
+ or item.get("EvalDecision") not in {"allowed", "explicitDeny", "implicitDeny"}
+ for item in results
+ ):
+ raise ValueError("invalid decision")
+ decisions = {str(item["EvalActionName"]) for item in results if item.get("EvalDecision")}
+ if not set(DANGEROUS_AWS_ACTIONS).issubset(decisions):
+ raise ValueError("incomplete simulation")
+ except (ValueError, KeyError, TypeError, AttributeError):
+ record["errors"].append("simulate-principal-policy: invalid or incomplete results")
+ record["unverifiable"] = True
return record
@@ -281,19 +366,21 @@ def verdict(bash_guard: str, kube: dict[str, Any], aws: dict[str, Any], declared
# nothing to answer that with and this stays an observation, which is
# what it was for a year. Declared, `enforce` means the same thing on
# both postures: the credential may not exceed the declaration.
- if declared is None:
- return "unmeasurable" if missing else "recorded"
if beyond(kube, aws, declared):
return "wider-than-declared"
- return "unmeasurable" if missing else "within-declared-radius"
- if not kube.get("present") and not aws.get("present"):
- return "no-credentials"
+ if missing:
+ return "unmeasurable"
+ if kube.get("errors") or aws.get("unverifiable"):
+ return "unverifiable"
+ return "recorded" if declared is None else "within-declared-radius"
if kube.get("mutating") or aws.get("allowed"):
return "wider-than-declared"
if missing:
return "unmeasurable"
if kube.get("errors") or aws.get("unverifiable"):
return "unverifiable"
+ if not kube.get("present") and not aws.get("present"):
+ return "no-credentials"
return "readonly-confirmed"
@@ -395,13 +482,18 @@ async def on_startup(
if alarm is not None:
with contextlib.suppress(Exception): # the page is a courtesy; the verdict below is the decision
await alarm(f"posture UNMEASURABLE — {text}")
- if bash_guard == "readonly" and mode == "enforce":
+ if (bash_guard == "readonly" or declared is not None) and mode == "enforce":
logger.error("REFUSING TO START — %s", text)
raise PostureViolation(
f"posture cannot be measured as declared {bash_guard!r}: {missing} not in this image"
)
logger.error("%s", text)
elif record["verdict"] == "unverifiable":
+ if alarm is not None:
+ with contextlib.suppress(Exception):
+ await alarm(f"posture UNVERIFIABLE — {line}")
+ if bash_guard != "readonly" and declared is not None and mode == "enforce":
+ raise PostureViolation(f"declared writing posture could not be verified: {line}")
logger.warning(
"posture could not be fully verified: %s · %s",
line,
diff --git a/hookprobe/hookprobe/remediation.py b/hookprobe/hookprobe/remediation.py
index e4d4c65..c6ee78f 100644
--- a/hookprobe/hookprobe/remediation.py
+++ b/hookprobe/hookprobe/remediation.py
@@ -74,6 +74,7 @@
from hookprobe import audit, automation
from hookprobe.files import atomic_write
+from hookprobe.redact import redact
logger = logging.getLogger("hookprobe.remediation")
@@ -276,6 +277,7 @@ def pending_duplicate(workdir: Path, steps: list[dict[str, Any]], now: float | N
def save(workdir: Path, row: dict[str, Any]) -> None:
+ (workdir / DIRNAME).mkdir(parents=True, exist_ok=True)
_write(workdir / DIRNAME / f"{row['id']}.json", row)
@@ -284,10 +286,8 @@ def settle_interrupted(workdir: Path) -> list[dict[str, Any]]:
A procedure is written as 1-2-3 and runs stop-on-first-failure, so the fact
an operator needs before touching the target again is which commands landed.
- The results list already holds one entry per command that ran, in order —
- everything past it never started. `failed` is the terminal state for a
- sequence that did not complete, and unlike `running` it is a state the row
- can be read in.
+ A started step without a durable result has an unknown outcome. Older
+ rows without a start marker cannot prove whether any remaining step ran.
"""
settled: list[dict[str, Any]] = []
for row in list_all(workdir, limit=1000):
@@ -297,7 +297,13 @@ def settle_interrupted(workdir: Path) -> list[dict[str, Any]]:
ran = len(row.get("results") or [])
row["status"] = FAILED
row["executed_at"] = round(time.time(), 3)
- row["interrupted"] = {"ran": commands[:ran], "not_run": commands[ran:]}
+ if "in_flight" not in row:
+ unknown, not_run = commands[ran:], []
+ elif row["in_flight"] is None:
+ unknown, not_run = [], commands[ran:]
+ else:
+ unknown, not_run = commands[ran : ran + 1], commands[ran + 1 :]
+ row["interrupted"] = {"ran": commands[:ran], "unknown": unknown, "not_run": not_run}
try:
save(workdir, row)
except OSError:
@@ -784,6 +790,7 @@ def approve(
"target needs now."
)
row["status"] = "running"
+ row["in_flight"] = None
row["approved_at"] = round(time.time(), 3)
row["approved_note"] = note[:300]
row["approved_by"] = actor[:120]
@@ -886,7 +893,7 @@ async def execute(
"""
timeout = max(30.0, (bash_timeout_ms or 120000) / 1000.0)
failed = False
- for step in row.get("steps", []):
+ for index, step in enumerate(row.get("steps", [])):
command = str(step.get("command") or "")
started = time.monotonic()
# Second gate. Deny-by-default holds: a file that has since been emptied
@@ -902,13 +909,20 @@ async def execute(
if argv is None:
row.setdefault("results", []).append(
{
- "command": command,
+ "command": redact(command),
"exit": -1,
"ms": 0,
- "output": f"refused at execution: {refusal}",
+ "output": redact(f"refused at execution: {refusal}"),
}
)
- logger.warning("remediation step refused at execution: %s — %s", command, refusal)
+ logger.warning("remediation step refused at execution: %s — %s", redact(command), redact(str(refusal)))
+ failed = True
+ break
+ row["in_flight"] = index
+ try:
+ save(workdir, row)
+ except OSError:
+ logger.exception("remediation %s cannot record step start; nothing further will run", row["id"])
failed = True
break
try:
@@ -920,23 +934,35 @@ async def execute(
env=execution_env(),
)
try:
- output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ output, truncated = await asyncio.wait_for(_read_output(process), timeout=timeout)
except TimeoutError:
process.kill()
await process.wait()
- output, returncode = b"(timed out)", -1
+ output, returncode, truncated = "(timed out)", -1, False
+ except asyncio.CancelledError:
+ if process.returncode is None:
+ process.kill()
+ await process.wait()
+ raise
else:
returncode = int(process.returncode or 0)
except OSError as exc:
- output, returncode = str(exc).encode(), -1
+ output, returncode, truncated = redact(str(exc)), -1, False
result = {
- "command": command,
+ "command": redact(command),
"exit": returncode,
"ms": int((time.monotonic() - started) * 1000),
- "output": output.decode("utf-8", "replace")[-10000:],
+ "output": output,
+ "output_truncated": truncated,
}
row.setdefault("results", []).append(result)
_audit(workdir, row["id"], command, returncode != 0)
+ row["in_flight"] = None
+ try:
+ save(workdir, row)
+ except OSError:
+ logger.exception("remediation %s cannot record step result; nothing further will run", row["id"])
+ return
if returncode != 0:
failed = True
break
@@ -1038,6 +1064,36 @@ def evidence(
return stamped
+OUTPUT_KEPT = 10_000 # characters of a step's output the record keeps, from the end
+OUTPUT_HELD = 1_000_000 # bytes held so the whole output is redacted before it is cut
+
+
+async def _read_output(process: asyncio.subprocess.Process) -> tuple[str, bool]:
+ """A step's output as the record keeps it, and whether any of it was dropped.
+
+ The whole output is redacted BEFORE it is cut to its last 10,000 characters,
+ so a secret the cut would have split was seen whole by the redactor — and the
+ tail, where a failing command says why, is kept. That needs the whole output
+ in hand: past a megabyte nothing is held, and the output is omitted rather
+ than cut at a point nobody redacted.
+ """
+ if process.stdout is None:
+ raise RuntimeError("remediation process has no output pipe")
+ held = bytearray()
+ total = 0
+ while chunk := await process.stdout.read(65536):
+ total += len(chunk)
+ if total <= OUTPUT_HELD:
+ held.extend(chunk)
+ elif held:
+ held.clear()
+ await process.wait()
+ if total > OUTPUT_HELD:
+ return f"(output omitted: {total} bytes, more than can be redacted whole)", True
+ text = redact(bytes(held).decode("utf-8", "replace"))
+ return text[-OUTPUT_KEPT:], len(text) > OUTPUT_KEPT
+
+
def _audit(workdir: Path, proposal_id: str, command: str, error: bool, *, tool: str = "Exec") -> None:
"""Same flight recorder the agent's tools write to — one account.
@@ -1054,7 +1110,7 @@ def _audit(workdir: Path, proposal_id: str, command: str, error: bool, *, tool:
"ts": round(time.time(), 3),
"session": f"remediation:{proposal_id}",
"tool": tool,
- "detail": command[:300],
+ "detail": redact(command)[:300],
"error": error,
},
)
diff --git a/hookprobe/hookprobe/selftest.py b/hookprobe/hookprobe/selftest.py
index b1a1f05..8555d9e 100644
--- a/hookprobe/hookprobe/selftest.py
+++ b/hookprobe/hookprobe/selftest.py
@@ -221,7 +221,9 @@ async def posture_still_holds(settings: Settings) -> dict[str, Any]:
detail = f"{detail} · cannot measure: {missing} not in this image"[:280]
return _check(
"credentials are no wider than the declared posture",
- verdict not in ("wider-than-declared", "unmeasurable"),
+ (False if settings.bash_guard != "readonly" and settings.blast_radius is not None else None)
+ if verdict == "unverifiable"
+ else verdict not in ("wider-than-declared", "unmeasurable"),
detail,
"a credential whose far side cannot be asked — `unverifiable` is a verdict, not a pass",
)
diff --git a/hookprobe/hookprobe/service.py b/hookprobe/hookprobe/service.py
index c2f0591..45c4886 100644
--- a/hookprobe/hookprobe/service.py
+++ b/hookprobe/hookprobe/service.py
@@ -927,9 +927,10 @@ def sweep_interrupted_remediations(self) -> int:
for row in settled:
interrupted = row.get("interrupted") or {}
logger.warning(
- "remediation interrupted by a restart id=%s ran=%s not_run=%s",
+ "remediation interrupted by a restart id=%s ran=%s unknown=%s not_run=%s",
row.get("id"),
len(interrupted.get("ran") or []),
+ len(interrupted.get("unknown") or []),
len(interrupted.get("not_run") or []),
)
if settled:
diff --git a/hookprobe/hookprobe/ui.html b/hookprobe/hookprobe/ui.html
index 2ffd835..1232322 100644
--- a/hookprobe/hookprobe/ui.html
+++ b/hookprobe/hookprobe/ui.html
@@ -1217,6 +1217,7 @@
Recipes
"sys.kube_v": "present · {m} mutating verbs allowed · {e} errors",
"sys.aws_v": "present · {n} write actions allowed",
"sys.not_present": "not present",
+ "sys.unverifiable": "unverifiable",
"act.title": "Remediation proposals",
"act.hint": "An investigation proposes a step only when it is confident, and nothing runs without an approval.",
"act.none": "No remediation proposals.",
@@ -1228,6 +1229,9 @@ Recipes
"act.status.rejected": "Rejected",
"act.status.expired": "Expired",
"act.undo": "undo: {cmd}",
+ "act.unknown": "Outcome unknown — check the target before retrying",
+ "act.not_run": "Not started",
+ "act.truncated": "Output truncated",
"act.approved_by": "approved by {who}",
"audit.title": "Audit",
"audit.hint": "Every tool call in every run, subagents included · {dir}/audit/*.jsonl",
@@ -1568,6 +1572,7 @@ Recipes
"sys.kube_v": "已挂载 · 允许 {m} 个变更动作 · {e} 个错误",
"sys.aws_v": "已挂载 · 允许 {n} 个写操作",
"sys.not_present": "未挂载",
+ "sys.unverifiable": "无法验证",
"act.title": "处置建议",
"act.hint": "调查只在有把握时才提出处置步骤,而且没有批准什么都不会执行。",
"act.none": "没有处置建议。",
@@ -1579,6 +1584,9 @@ Recipes
"act.status.rejected": "已拒绝",
"act.status.expired": "已过期",
"act.undo": "撤销:{cmd}",
+ "act.unknown": "结果未知——重试前请检查目标状态",
+ "act.not_run": "未开始",
+ "act.truncated": "输出已截断",
"act.approved_by": "批准人 {who}",
"audit.title": "审计",
"audit.hint": "每次运行里的每一次工具调用,包括子智能体的 · {dir}/audit/*.jsonl",
@@ -3133,7 +3141,7 @@ Recipes
(posture ? `${icon("shield")}${esc(t("sys.posture"))}
` + kv([
[t("sys.verdict"), posture.verdict], [t("sys.guard"), posture.bash_guard], [t("sys.checked"), fmtTime(posture.checked_at)],
["kube", (posture.kube || {}).present ? t("sys.kube_v", { m: ((posture.kube || {}).mutating || []).length, e: ((posture.kube || {}).errors || []).length }) : t("sys.not_present")],
- ["aws", (posture.aws || {}).present ? t("sys.aws_v", { n: ((posture.aws || {}).allowed || []).length }) : t("sys.not_present")],
+ ["aws", (posture.aws || {}).unverifiable ? t("sys.unverifiable") : (posture.aws || {}).present ? t("sys.aws_v", { n: ((posture.aws || {}).allowed || []).length }) : t("sys.not_present")],
]) + "" : "");
} catch (e) { box.innerHTML = errorHtml(e); }
}
@@ -3151,9 +3159,11 @@ Recipes
box.innerHTML = head + rows.map((r) => {
const steps = (r.steps || []).map((s, i) => {
const res = (r.results || [])[i];
- const tag = res ? (res.exit === 0 ? `exit 0 · ${fmtMs(res.ms)}` : `exit ${esc(res.exit)}`) : "";
+ const interrupted = r.interrupted;
+ const unknown = interrupted && i >= (interrupted.ran || []).length && (!Array.isArray(interrupted.unknown) || i < (interrupted.ran || []).length + interrupted.unknown.length);
+ const tag = res ? (res.exit === 0 ? `exit 0 · ${fmtMs(res.ms)}` : `exit ${esc(res.exit)}`) : interrupted ? `${esc(t(unknown ? "act.unknown" : "act.not_run"))}` : "";
return `${esc(s.risk)}${esc(s.action)} ${tag}
$ ${esc(s.command)}
` +
- (s.rollback ? `
${esc(t("act.undo", { cmd: s.rollback }))}
` : "") + (res && res.output ? `
${esc(res.output)}` : "") + "
";
+ (s.rollback ? `${esc(t("act.undo", { cmd: s.rollback }))}
` : "") + (res && res.output ? `${esc(res.output)}` : "") + (res && res.output_truncated ? `${esc(t("act.truncated"))}
` : "") + "";
}).join("");
// Past its approval window a proposal is inert: approving it answers 409,
// so it offers nothing but says what it is. A status this page has no
diff --git a/hookprobe/tests/conftest.py b/hookprobe/tests/conftest.py
new file mode 100644
index 0000000..d7aef80
--- /dev/null
+++ b/hookprobe/tests/conftest.py
@@ -0,0 +1,22 @@
+"""Fixtures every investigator test shares."""
+
+from __future__ import annotations
+
+import pytest
+
+from hookprobe import posture
+
+_REAL_METADATA_PROBE = posture.metadata_offers_credentials
+
+
+@pytest.fixture(autouse=True)
+def _no_instance_metadata(monkeypatch):
+ """No test asks the network for an instance metadata service. A CI runner on
+ a cloud VM has a metadata service at the same address, and a verdict must not depend on the
+ machine that ran the tests; a test that wants a service says so."""
+ monkeypatch.setattr(posture, "metadata_offers_credentials", lambda timeout=1.0: False)
+
+
+@pytest.fixture
+def real_metadata_probe():
+ return _REAL_METADATA_PROBE
diff --git a/hookprobe/tests/test_posture.py b/hookprobe/tests/test_posture.py
index 9ae3185..52519bb 100644
--- a/hookprobe/tests/test_posture.py
+++ b/hookprobe/tests/test_posture.py
@@ -10,6 +10,7 @@
import asyncio
import json
+import os
import pytest
@@ -79,6 +80,7 @@ def _with_kube(tmp_path, monkeypatch) -> None:
cfg.write_text("apiVersion: v1\n")
monkeypatch.setenv("KUBECONFIG", str(cfg))
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
+ monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "absent"))
monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "absent2"))
@@ -123,6 +125,7 @@ def test_unverifiable_is_a_verdict_not_a_confirmation(tmp_path, monkeypatch) ->
def test_no_credentials_and_off(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("KUBECONFIG", str(tmp_path / "nope"))
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
+ monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "absent"))
monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "absent2"))
run = _runner({})
@@ -131,6 +134,194 @@ def test_no_credentials_and_off(tmp_path, monkeypatch) -> None:
assert asyncio.run(posture.on_startup(tmp_path, "readonly", "off", run))["verdict"] == "skipped"
+@pytest.mark.parametrize(
+ "variables",
+ [
+ {"AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/example", "AWS_WEB_IDENTITY_TOKEN_FILE": "/tmp/token"},
+ {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/example"},
+ {"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://localhost/credentials"},
+ ],
+)
+def test_aws_credential_sources_are_detected(tmp_path, monkeypatch, variables) -> None:
+ for name in list(os.environ):
+ if name.startswith("AWS_"):
+ monkeypatch.delenv(name)
+ monkeypatch.setenv("HOME", str(tmp_path))
+ for name, value in variables.items():
+ monkeypatch.setenv(name, value)
+ assert posture.aws_configured()
+
+
+def _no_aws_named(tmp_path, monkeypatch) -> None:
+ for name in list(os.environ):
+ if name.startswith("AWS_"):
+ monkeypatch.delenv(name)
+ monkeypatch.setenv("HOME", str(tmp_path))
+
+
+def test_a_profile_name_with_no_file_behind_it_is_not_a_credential(tmp_path, monkeypatch) -> None:
+ _no_aws_named(tmp_path, monkeypatch)
+ monkeypatch.setenv("AWS_PROFILE", "example")
+ assert not posture.aws_configured()
+
+
+def test_no_metadata_service_answering_is_no_credential(tmp_path, monkeypatch) -> None:
+ """The laptop and the internal network: nothing at the address, nothing to measure."""
+ _no_aws_named(tmp_path, monkeypatch)
+ run = _runner({})
+ aws = asyncio.run(posture.check_aws(run))
+ assert aws == {"present": False} and run.calls == []
+ assert posture.verdict("readonly", {"present": False}, aws) == "no-credentials"
+
+
+def test_a_metadata_service_that_answers_is_measured_like_any_source(tmp_path, monkeypatch) -> None:
+ _no_aws_named(tmp_path, monkeypatch)
+ monkeypatch.setattr(posture, "metadata_offers_credentials", lambda timeout=1.0: True)
+ denied = [{"EvalActionName": name, "EvalDecision": "implicitDeny"} for name in posture.DANGEROUS_AWS_ACTIONS]
+ run = _runner(
+ {
+ "aws sts": (0, '{"Arn":"arn:aws:sts::1:assumed-role/instance/i-0"}', ""),
+ "aws iam": (0, json.dumps({"EvaluationResults": denied}), ""),
+ }
+ )
+ aws = asyncio.run(posture.check_aws(run))
+ assert aws["present"] is True and aws["unverifiable"] is False and aws["allowed"] == []
+ assert posture.verdict("readonly", {"present": False}, aws) == "readonly-confirmed"
+
+
+def test_an_instance_with_no_role_has_no_credential(tmp_path, monkeypatch) -> None:
+ _no_aws_named(tmp_path, monkeypatch)
+ monkeypatch.setattr(posture, "metadata_offers_credentials", lambda timeout=1.0: True)
+ run = _runner({"aws sts": (253, "", "Unable to locate credentials. You can configure credentials by running")})
+ assert asyncio.run(posture.check_aws(run)) == {"present": False}
+
+
+def test_metadata_disabled_is_not_asked(tmp_path, monkeypatch) -> None:
+ _no_aws_named(tmp_path, monkeypatch)
+ monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
+
+ def asked(timeout=1.0):
+ raise AssertionError("the disabled service was asked")
+
+ monkeypatch.setattr(posture, "metadata_offers_credentials", asked)
+ assert asyncio.run(posture.check_aws(_runner({}))) == {"present": False}
+
+
+def _metadata_service(put_status: int, listing_status: int, listing: bytes = b""):
+ """A stand-in for the address, answering the CLI's two questions as told."""
+ import threading
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+ class Service(BaseHTTPRequestHandler):
+ def do_PUT(self) -> None: # noqa: N802
+ self.send_response(put_status)
+ self.end_headers()
+ if put_status == 200:
+ self.wfile.write(b"a-token")
+
+ def do_GET(self) -> None: # noqa: N802
+ asked_with_token = self.headers.get("X-aws-ec2-metadata-token") == "a-token" or put_status != 200
+ ok = listing_status == 200 and asked_with_token
+ self.send_response(200 if ok else listing_status if listing_status != 200 else 401)
+ self.end_headers()
+ if ok:
+ self.wfile.write(listing)
+
+ def log_message(self, *args) -> None:
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Service)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server
+
+
+@pytest.mark.parametrize(
+ ("put_status", "listing_status", "listing", "offered"),
+ [
+ (200, 200, b"instance-role", True), # IMDSv2 with a role
+ (403, 200, b"instance-role", True), # IMDSv1 only, a role all the same
+ (200, 404, b"", False), # an instance with no role
+ (400, 400, b"", False), # another vendor's service at the address
+ ],
+)
+def test_the_metadata_question_is_the_clis_and_goes_direct(
+ monkeypatch, real_metadata_probe, put_status, listing_status, listing, offered
+) -> None:
+ """Only a role name is a credential, and the question skips the proxy: one
+ that refuses the address would otherwise read as a service being there."""
+ server = _metadata_service(put_status, listing_status, listing)
+ monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.1:9")
+ monkeypatch.setenv("http_proxy", "http://127.0.0.1:9")
+ monkeypatch.setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", f"http://127.0.0.1:{server.server_port}")
+ try:
+ assert real_metadata_probe(timeout=2.0) is offered
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+def test_nothing_at_the_address_is_no_credential(monkeypatch, real_metadata_probe) -> None:
+ import socket
+
+ with socket.socket() as closed:
+ closed.bind(("127.0.0.1", 0))
+ port = closed.getsockname()[1]
+ monkeypatch.setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", f"http://127.0.0.1:{port}")
+ assert real_metadata_probe(timeout=2.0) is False
+
+
+def test_writing_node_refuses_unverifiable_declared_radius(tmp_path, monkeypatch) -> None:
+ _with_kube(tmp_path, monkeypatch)
+ radius = tmp_path / "radius.txt"
+ radius.write_text("", encoding="utf-8")
+ _with_aws_too(tmp_path, monkeypatch)
+ run = _runner(
+ {
+ "kubectl auth can-i --list": (0, CAN_I_LIST_READONLY, ""),
+ "aws sts": (0, '{"Arn":"arn:aws:iam::1:user/example"}', ""),
+ "aws iam": (1, "", "AccessDenied"),
+ }
+ )
+ with pytest.raises(posture.PostureViolation, match="could not be verified"):
+ asyncio.run(posture.on_startup(tmp_path, "danger-only", "enforce", run, radius=radius))
+ assert posture.read(tmp_path)["verdict"] == "unverifiable"
+
+
+@pytest.mark.parametrize(
+ "response",
+ [
+ "not-json",
+ "{}",
+ '{"EvaluationResults": []}',
+ '{"EvaluationResults": [{"EvalActionName": "x", "EvalDecision": "bad"}]}',
+ ],
+)
+def test_incomplete_or_invalid_aws_simulation_is_unverifiable(tmp_path, monkeypatch, response) -> None:
+ _with_aws_too(tmp_path, monkeypatch)
+ run = _runner(
+ {
+ "aws sts": (0, '{"Arn":"arn:aws:iam::1:user/example"}', ""),
+ "aws iam": (0, response, ""),
+ }
+ )
+ record = asyncio.run(posture.check_aws(run))
+ assert record["unverifiable"] and record["errors"]
+ assert posture.verdict("readonly", {"present": False}, record) == "unverifiable"
+
+
+@pytest.mark.parametrize("malformed", [False, True])
+def test_partial_simulation_still_reports_known_allowed_actions(tmp_path, monkeypatch, malformed) -> None:
+ _with_aws_too(tmp_path, monkeypatch)
+ results = [{"EvalActionName": "s3:PutObject", "EvalDecision": "allowed"}]
+ if malformed:
+ results.append(None)
+ response = json.dumps({"EvaluationResults": results})
+ run = _runner({"aws sts": (0, '{"Arn":"arn:aws:iam::1:user/example"}', ""), "aws iam": (0, response, "")})
+ record = asyncio.run(posture.check_aws(run))
+ assert record["unverifiable"] and record["allowed"] == ["s3:PutObject"]
+ assert posture.verdict("readonly", {"present": False}, record) == "wider-than-declared"
+
+
def test_a_typo_in_the_mode_fails_closed(tmp_path, monkeypatch) -> None:
_with_kube(tmp_path, monkeypatch)
run = _runner({"kubectl auth can-i --list": (0, CAN_I_LIST_ADMIN, "")})
@@ -264,6 +455,16 @@ def test_a_writing_node_without_kubectl_starts_and_pages(tmp_path, monkeypatch)
assert pages and "kubectl" in pages[0]
+def test_writing_node_with_declared_radius_refuses_missing_tool(tmp_path, monkeypatch) -> None:
+ _with_kube(tmp_path, monkeypatch)
+ radius = tmp_path / "radius.txt"
+ radius.write_text("", encoding="utf-8")
+ run = _runner({"kubectl": (127, "", "kubectl: not found")})
+ with pytest.raises(posture.PostureViolation, match="kubectl"):
+ asyncio.run(posture.on_startup(tmp_path, "danger-only", "enforce", run, radius=radius))
+ assert posture.read(tmp_path)["verdict"] == "unmeasurable"
+
+
def test_warn_starts_a_readonly_node_without_its_tool_and_pages(tmp_path, monkeypatch) -> None:
_with_kube(tmp_path, monkeypatch)
run = _runner({"kubectl": (127, "", "kubectl: not found")})
diff --git a/hookprobe/tests/test_remediation.py b/hookprobe/tests/test_remediation.py
index 99ca1e8..b16a52f 100644
--- a/hookprobe/tests/test_remediation.py
+++ b/hookprobe/tests/test_remediation.py
@@ -280,6 +280,7 @@ def test_a_restart_settles_a_procedure_it_died_in_the_middle_of(tmp_path):
"session_key": "probe:inbound:7",
"created_at": 1.0,
"status": "running",
+ "in_flight": None,
"steps": [
{"action": "drain", "command": "kubectl drain node-3", "risk": "high", "rollback": "uncordon"},
{"action": "restart", "command": "kubectl rollout restart deploy/api", "risk": "medium"},
@@ -311,6 +312,76 @@ def test_a_restart_settles_a_procedure_it_died_in_the_middle_of(tmp_path):
]
+def test_interruption_records_completed_and_unknown_steps(tmp_path, monkeypatch):
+ async def scenario():
+ allow = tmp_path / "allow"
+ allow.write_text(".*\n", encoding="utf-8")
+ steps = [
+ {"action": "first", "command": "echo completed", "risk": "low"},
+ {"action": "second", "command": "sleep 2", "risk": "low"},
+ {"action": "third", "command": "echo later", "risk": "low"},
+ ]
+ proposal_id = remediation.propose(tmp_path, "run", steps)
+ row = remediation.load(tmp_path, proposal_id)
+ row.update(status="running", in_flight=None)
+ remediation.save(tmp_path, row)
+ first_done = asyncio.Event()
+ original = remediation._audit
+
+ def record(*args, **kwargs):
+ original(*args, **kwargs)
+ first_done.set()
+
+ monkeypatch.setattr(remediation, "_audit", record)
+ task = asyncio.create_task(remediation.execute(tmp_path, row, bash_timeout_ms=30000, allowlist=allow))
+ await asyncio.wait_for(first_done.wait(), 5)
+ for _ in range(100):
+ if remediation.load(tmp_path, proposal_id).get("in_flight") == 1:
+ break
+ await asyncio.sleep(0.01)
+ else:
+ pytest.fail("second step never started")
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ return remediation.settle_interrupted(tmp_path)[0]["interrupted"]
+
+ interrupted = asyncio.run(scenario())
+ assert interrupted == {"ran": ["echo completed"], "unknown": ["sleep 2"], "not_run": ["echo later"]}
+
+
+def test_remediation_output_is_redacted_and_bounded(tmp_path):
+ import sys
+
+ async def scenario(command):
+ allow = tmp_path / "allow"
+ allow.write_text(".*\n", encoding="utf-8")
+ row = {"id": "0123456789", "status": "running", "steps": [{"command": command, "risk": "low"}]}
+ await remediation.execute(tmp_path, row, bash_timeout_ms=30000, allowlist=allow)
+ return row["results"][0]
+
+ secret = asyncio.run(scenario("echo password=not-a-real-secret"))
+ assert "not-a-real-secret" not in secret["output"]
+ assert "[redacted]" in secret["output"]
+ # A secret straddling the 10,000-character cut: redacted whole before the cut,
+ # so no piece of it survives, and the tail a failing command writes is kept.
+ script = tmp_path / "straddle.py"
+ script.write_text(
+ 'print("A" * 20000 + " sk-ant-" + "Q" * 24 + " " + "B" * 9985)\n',
+ encoding="utf-8",
+ )
+ large = asyncio.run(scenario(f"{sys.executable} {script}"))
+ assert large["output_truncated"] is True
+ assert len(large["output"]) <= 10000
+ assert "QQQQ" not in large["output"]
+ assert large["output"].rstrip().endswith("B" * 9985)
+ flood = tmp_path / "flood.py"
+ flood.write_text('import sys\nsys.stdout.write("x" * 1_100_000)\n', encoding="utf-8")
+ omitted = asyncio.run(scenario(f"{sys.executable} {flood}"))
+ assert omitted["output_truncated"] is True
+ assert omitted["output"].startswith("(output omitted: 1100000 bytes")
+
+
def test_a_row_that_cannot_be_written_after_execution_is_loud(tmp_path, monkeypatch, caplog):
"""The commands have already run by then, so a lost write is not a detail:
without a line in the log, the only account of it would be the audit file
diff --git a/hookprobe/tests/test_selftest.py b/hookprobe/tests/test_selftest.py
index c0982e6..231d46d 100644
--- a/hookprobe/tests/test_selftest.py
+++ b/hookprobe/tests/test_selftest.py
@@ -50,6 +50,17 @@ def test_a_check_that_could_not_run_is_never_a_pass() -> None:
assert len(worse["checks"]) == 3
+def test_unverifiable_posture_is_unproven(tmp_path, monkeypatch) -> None:
+ async def unverifiable(*args, **kwargs):
+ return {"verdict": "unverifiable", "kube": {}, "aws": {}}
+
+ monkeypatch.setattr(selftest.posture, "check", unverifiable)
+ result = asyncio.run(selftest.posture_still_holds(make_settings(tmp_path, workdir=tmp_path)))
+ assert result["held"] is None
+ settings = make_settings(tmp_path, workdir=tmp_path, bash_guard="danger-only", blast_radius=tmp_path / "radius")
+ assert asyncio.run(selftest.posture_still_holds(settings))["held"] is False
+
+
def test_the_gate_check_actually_asks_the_gate(tmp_path) -> None:
"""In-process and through the subprocess path. The second is the one that
was once simply absent — the hook command could not import hookprobe, the
diff --git a/hookrelay/hookrelay/app.py b/hookrelay/hookrelay/app.py
index 41a02b0..e2a7e84 100644
--- a/hookrelay/hookrelay/app.py
+++ b/hookrelay/hookrelay/app.py
@@ -19,6 +19,7 @@
import os
import re
import tempfile
+import uuid
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
@@ -451,70 +452,107 @@ async def hook(source_name: str, request: Request) -> JSONResponse:
raise HTTPException(status_code=401, detail="bad signature")
# A captured request replayed inside the skew is the same signed BYTES,
# and the content fingerprint cannot see that: it folds a restatement of
- # the same alert however long ago the content window closed. So ask here
- # and answer the second copy as a skip — but PEEK, do not claim: the row
- # is written only after the pipeline has answered (below), because a
- # claim made before it would turn a sender's identical retry after a 5xx,
- # a timeout or the fuse's 429 into a replay, losing the event while both
- # sides believed it was handled. Off when the window is 0, and a scheme
+ # the same alert however long ago the content window closed. So the
+ # signature is held while this request runs and marked spent only once
+ # the pipeline has answered (below): spent before it, a sender's identical
+ # retry after a 5xx, a timeout or the fuse's 429 would read as a replay,
+ # losing the event while both sides believed it was handled. The hold is
+ # a renewable lease, so an identical copy arriving meanwhile is told to
+ # retry (503) instead of entering the pipeline beside the first, and a
+ # crashed holder's lease runs out. Off when the window is 0, and a scheme
# this cannot name (a plugin adapter's own header) yields no token, which
# is a refusal to guess rather than a miss.
replay: str | None = None
+ owner = uuid.uuid4().hex
+ lease_seconds = 120
+ heartbeat: asyncio.Task[None] | None = None
if app_settings.signature_replay_seconds > 0:
replay = replay_token(source.name, headers.get("x-hook-signature"))
- if replay is not None and await app.state.store.signature_seen(
- replay, app_settings.signature_replay_seconds, now_ts()
- ):
- return JSONResponse({"outcome": "skipped", "skip_code": "replayed_signature"})
+ if replay is not None:
+ state = await app.state.store.acquire_signature(
+ replay, owner, app_settings.signature_replay_seconds, now_ts(), lease_seconds
+ )
+ if state == "completed":
+ return JSONResponse({"outcome": "skipped", "skip_code": "replayed_signature"})
+ if state == "busy":
+ return JSONResponse({"detail": "identical request is still processing"}, 503, {"Retry-After": "1"})
+
+ processing = asyncio.current_task()
+
+ async def renew() -> None:
+ while True:
+ await asyncio.sleep(lease_seconds / 3)
+ try:
+ renewed = await app.state.store.renew_signature(replay, owner, now_ts(), lease_seconds)
+ except Exception:
+ logger.exception("signature processing lease renewal failed")
+ renewed = False
+ if not renewed:
+ logger.error("signature processing lease was lost")
+ if processing is not None:
+ processing.cancel()
+ return
+
+ heartbeat = asyncio.create_task(renew())
try:
- payload = json.loads(body or b"{}")
- except ValueError:
- raise HTTPException(status_code=400, detail="body is not JSON") from None
- # ── storm fuse (volume, not content) ──────────────────────────────
- # After verification (an unsigned flood is already 401 and free) and
- # before the pipeline. Two stages: suppress keeps the account, reject
- # protects the account itself.
- verdict = app.state.fuse.check(source.name, source.storm_threshold, source.storm_window_seconds, now_ts())
- if verdict == "reject":
- raise HTTPException(status_code=429, detail="storm fuse open (hard)")
- # The adapter reads the payload for BOTH paths from here on. A suppressed
- # event is still recorded, so it must be recorded the way this door
- # actually reads its senders: the storm path used to re-extract with the
- # door's templates alone, and a reshaping adapter (SNS hides the alert
- # inside a JSON-string `Message`) mis-titled every row the storm made.
- # It sits after the hard reject, which stores nothing and so reads nothing.
- extracted = adapter.parse(source, payload)
- if verdict == "suppress":
- event_id = await record_storm_suppressed(
- app.state.store,
- source,
- payload,
- now_ts(),
- app.state.fuse.window_count(source.name),
- source.storm_threshold,
- extracted=extracted,
- )
+ try:
+ payload = json.loads(body or b"{}")
+ except ValueError:
+ raise HTTPException(status_code=400, detail="body is not JSON") from None
+ # ── storm fuse (volume, not content) ──────────────────────────
+ # After verification (an unsigned flood is already 401 and free) and
+ # before the pipeline. Two stages: suppress keeps the account, reject
+ # protects the account itself.
+ verdict = app.state.fuse.check(source.name, source.storm_threshold, source.storm_window_seconds, now_ts())
+ if verdict == "reject":
+ raise HTTPException(status_code=429, detail="storm fuse open (hard)")
+ # The adapter reads the payload for BOTH paths from here on. A suppressed
+ # event is still recorded, so it must be recorded the way this door
+ # actually reads its senders: the storm path used to re-extract with the
+ # door's templates alone, and a reshaping adapter (SNS hides the alert
+ # inside a JSON-string `Message`) mis-titled every row the storm made.
+ # It sits after the hard reject, which stores nothing and so reads nothing.
+ extracted = adapter.parse(source, payload)
+ if verdict == "suppress":
+ event_id = await record_storm_suppressed(
+ app.state.store,
+ source,
+ payload,
+ now_ts(),
+ app.state.fuse.window_count(source.name),
+ source.storm_threshold,
+ extracted=extracted,
+ )
+ result = {"event_id": event_id, "outcome": "skipped", "skip_code": "storm_suppressed"}
+ else:
+ result = await handle_hook(
+ app.state.store,
+ app.state.config,
+ source,
+ payload,
+ now_ts(),
+ settings=app_settings,
+ client=app.state.http_client,
+ extracted=extracted,
+ )
+ # The pipeline answered, so the event was handled once: the signature is
+ # spent now. Anything that raised above (the 400 for a body that is not
+ # JSON, the 429 from the hard fuse, a fault inside handle_hook) reaches
+ # `finally` having spent nothing and is released there, so a retry is processed.
+ if replay is not None and not await app.state.store.complete_signature(
+ replay, owner, app_settings.signature_replay_seconds, now_ts()
+ ):
+ logger.error("signature processing lease was lost before completion")
+ raise HTTPException(status_code=503, detail="signature lease lost; outcome needs reconciliation")
+ return JSONResponse(result)
+ finally:
+ if heartbeat is not None:
+ heartbeat.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await heartbeat
if replay is not None:
- await app.state.store.claim_signature(replay, app_settings.signature_replay_seconds, now_ts())
- return JSONResponse({"event_id": event_id, "outcome": "skipped", "skip_code": "storm_suppressed"})
-
- result = await handle_hook(
- app.state.store,
- app.state.config,
- source,
- payload,
- now_ts(),
- settings=app_settings,
- client=app.state.http_client,
- extracted=extracted,
- )
- # The pipeline answered, so the event was handled once: remember the
- # signature now. Anything that raised above — the 400 for a body that is
- # not JSON, the 429 from the hard fuse, a fault inside handle_hook —
- # reaches here having claimed nothing, so the sender's retry is processed.
- if replay is not None:
- await app.state.store.claim_signature(replay, app_settings.signature_replay_seconds, now_ts())
- return JSONResponse(result)
+ with contextlib.suppress(Exception):
+ await app.state.store.release_signature(replay, owner)
@app.post("/explain/{source_name}")
async def explain(
diff --git a/hookrelay/hookrelay/security.py b/hookrelay/hookrelay/security.py
index ba7cb2f..612e8b5 100644
--- a/hookrelay/hookrelay/security.py
+++ b/hookrelay/hookrelay/security.py
@@ -4,7 +4,7 @@
("sha256=" prefix tolerated). With X-Hook-Timestamp the signature covers
"{timestamp}.{body}" and must be FRESH, which BOUNDS how long a captured
request stays useful rather than preventing replay outright; the door pairs it
-with `replay_token` + `Store.claim_signature` to close that window (see
+with `replay_token` + the store's signature lease to close that window (see
verify_signature). Without a timestamp the legacy body-only form is
accepted unless the door sets require_timestamp. A source configured without
a secret is accepted unsigned — a deliberate, documented decision for trusted
@@ -15,6 +15,7 @@
import hashlib
import hmac
+import math
import time
@@ -63,7 +64,7 @@ def verify_signature(
CLOSED BY THE DOOR, not here (2026-10-12). A pure function has no store to
write to, and that is deliberate: `verify_signature` is the one thing both
the pipe's doors and any second implementation must agree on, so it stays
- pure. The door pairs it with `replay_token` and `Store.claim_signature`,
+ pure. The door pairs it with `replay_token` and the store's signature lease,
which together answer the second copy as a duplicate before the pipeline and
keep it from spending a second verdict. The old reasoning — "the window is
narrower than the dedup window, so a replay lands as a duplicate anyway" —
@@ -89,6 +90,8 @@ def verify_signature(
sent_at = float(str(timestamp_value).strip())
except ValueError:
return False
+ if not math.isfinite(sent_at):
+ return False
current = time.time() if now is None else now
if abs(current - sent_at) > max(1, max_skew_seconds):
return False
diff --git a/hookrelay/hookrelay/store.py b/hookrelay/hookrelay/store.py
index b975dda..8e5dcbc 100644
--- a/hookrelay/hookrelay/store.py
+++ b/hookrelay/hookrelay/store.py
@@ -146,6 +146,11 @@
seen_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_spent_signatures_seen ON spent_signatures (seen_at);
+CREATE TABLE IF NOT EXISTS pending_signatures (
+ token TEXT NOT NULL PRIMARY KEY,
+ owner TEXT NOT NULL,
+ expires_at REAL NOT NULL
+);
"""
@@ -973,42 +978,52 @@ async def record_action_outcome(self, jti: str, outcome: str) -> None:
# ── inbound signatures ────────────────────────────────────────────────
- async def signature_seen(self, token: str, window_seconds: int, now: float) -> bool:
- """Whether this signature was CLAIMED inside the window. A read, no claim.
-
- Paired with `claim_signature` so the door can ask before the pipeline and
- record only once the pipeline has answered. Claiming up front looked
- tidier and lost events: a row written before a 5xx, a client timeout or
- the fuse's 429 makes the sender's identical RETRY answer as a replay, and
- both sides then believe the event was handled. The cost of the split is
- the race between two concurrent identical requests, which degrades to the
- content dedup — the behaviour before this cache existed, and acceptable.
-
- The prune lives in `claim_signature`, which runs on every HANDLED
- request, so this stays a read and the table stays bounded by the window.
+ async def acquire_signature(self, token: str, owner: str, window: int, now: float, lease: int) -> str:
+ """`acquired`, `busy` (another request holds the lease) or `completed`
+ (spent inside the window). One write, so two identical requests cannot
+ both acquire; a crashed owner's lease simply expires. Spent rows are
+ pruned in `complete_signature`, so the table is the size of the window.
"""
- cursor = await self.read.execute(
- "SELECT 1 FROM spent_signatures WHERE token = ? AND seen_at >= ? LIMIT 1",
- (token, now - window_seconds),
- )
- return await cursor.fetchone() is not None
-
- async def claim_signature(self, token: str, window_seconds: int, now: float) -> bool:
- """True the first time this signature is seen inside the window, else False.
+ async with self._write():
+ await self.db.execute("DELETE FROM pending_signatures WHERE expires_at <= ?", (now,))
+ cursor = await self.db.execute(
+ "INSERT OR IGNORE INTO pending_signatures (token, owner, expires_at) "
+ "SELECT ?, ?, ? WHERE NOT EXISTS "
+ "(SELECT 1 FROM spent_signatures WHERE token = ? AND seen_at >= ?)",
+ (token, owner, now + lease, token, now - window),
+ )
+ if cursor.rowcount:
+ return "acquired"
+ cursor = await self.db.execute(
+ "SELECT 1 FROM spent_signatures WHERE token = ? AND seen_at >= ?", (token, now - window)
+ )
+ return "completed" if await cursor.fetchone() else "busy"
- Same shape as spend_action: the PRIMARY KEY is the decision rather than a
- read-then-write with a gap. The prune runs inside the same write lock, so
- the table is the size of the window and not of uptime.
- `window_seconds <= 0` is the caller's business, not this method's — a zero
- window is a refusal to cache, not a claim with nothing to compare against.
- """
+ async def renew_signature(self, token: str, owner: str, now: float, lease: int) -> bool:
async with self._write():
- await self.db.execute("DELETE FROM spent_signatures WHERE seen_at < ?", (now - window_seconds,))
cursor = await self.db.execute(
- "INSERT OR IGNORE INTO spent_signatures (token, seen_at) VALUES (?, ?)", (token, now)
+ "UPDATE pending_signatures SET expires_at = ? WHERE token = ? AND owner = ? AND expires_at > ?",
+ (now + lease, token, owner, now),
)
return cursor.rowcount == 1
+ async def release_signature(self, token: str, owner: str) -> None:
+ async with self._write():
+ await self.db.execute("DELETE FROM pending_signatures WHERE token = ? AND owner = ?", (token, owner))
+
+ async def complete_signature(self, token: str, owner: str, window: int, now: float) -> bool:
+ async with self._write():
+ cursor = await self.db.execute(
+ "SELECT 1 FROM pending_signatures WHERE token = ? AND owner = ? AND expires_at > ?",
+ (token, owner, now),
+ )
+ if not await cursor.fetchone():
+ return False
+ await self.db.execute("DELETE FROM spent_signatures WHERE seen_at < ?", (now - window,))
+ await self.db.execute("INSERT OR REPLACE INTO spent_signatures(token,seen_at) VALUES (?,?)", (token, now))
+ await self.db.execute("DELETE FROM pending_signatures WHERE token = ? AND owner = ?", (token, owner))
+ return True
+
async def recent_actions(self, limit: int = 50) -> list[dict[str, Any]]:
cursor = await self.read.execute(
"SELECT kind, event_id, correlation_id, actor, outcome, pressed_at "
diff --git a/hookrelay/tests/test_signature_replay.py b/hookrelay/tests/test_signature_replay.py
index 913674b..c6ee992 100644
--- a/hookrelay/tests/test_signature_replay.py
+++ b/hookrelay/tests/test_signature_replay.py
@@ -2,8 +2,8 @@
The timestamp on a signed door bounds how long a capture stays useful; on its
own it does not close the window, because the same signature inside it is
-accepted again. `replay_token` + the store's `signature_seen`/`claim_signature`
-pair close it at the door. These tests pin three things: the door's observable
+accepted again. `replay_token` + the store's signature lease
+(`acquire_signature` / `complete_signature`) close it at the door. These tests pin three things: the door's observable
answer, the `None` cases where there is nothing a guard can track without
guessing, and — the one a first cut got wrong — that the signature is claimed
only once the pipeline has ANSWERED, so a retry after a 5xx or a 429 is
@@ -12,6 +12,7 @@
from __future__ import annotations
+import asyncio
import contextlib
import dataclasses
import hashlib
@@ -19,10 +20,12 @@
import json
import httpx
+import pytest
import hookrelay.app as app_module
from hookrelay.app import create_app
-from hookrelay.security import replay_token
+from hookrelay.security import replay_token, verify_signature
+from hookrelay.store import Store
PAYLOAD = {"title": "db down", "message": "x", "state": "alerting"}
@@ -163,6 +166,105 @@ async def test_a_second_request_after_success_is_still_folded(client):
assert again.json() == {"outcome": "skipped", "skip_code": "replayed_signature"}
+async def test_simultaneous_replay_waits_for_the_first_request(settings, cfg, monkeypatch):
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ real = app_module.handle_hook
+ calls = 0
+
+ async def delayed(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ entered.set()
+ await asyncio.wait_for(release.wait(), 2)
+ return await real(*args, **kwargs)
+
+ monkeypatch.setattr(app_module, "handle_hook", delayed)
+ body = json.dumps(PAYLOAD).encode()
+ headers = {"X-Hook-Signature": _sig("s3cret", body)}
+ app = create_app(settings=settings, cfg=cfg)
+ async with _serve(app) as client:
+ first = asyncio.create_task(client.post("/hook/grafana", content=body, headers=headers))
+ await asyncio.wait_for(entered.wait(), 2)
+ concurrent = await client.post("/hook/grafana", content=body, headers=headers)
+ assert concurrent.status_code == 503 and concurrent.headers["retry-after"] == "1"
+ release.set()
+ assert (await first).json()["outcome"] == "routed"
+ retry = await client.post("/hook/grafana", content=body, headers=headers)
+ assert retry.json()["skip_code"] == "replayed_signature"
+ assert calls == 1
+
+
+async def test_expired_processing_lease_can_be_recovered(store):
+ assert await store.acquire_signature("token", "first", 300, 1000, 120) == "acquired"
+ assert await store.acquire_signature("token", "second", 300, 1001, 120) == "busy"
+ assert await store.acquire_signature("token", "second", 300, 1121, 120) == "acquired"
+ assert not await store.complete_signature("token", "first", 300, 1122)
+ assert await store.complete_signature("token", "second", 300, 1122)
+ assert await store.acquire_signature("token", "third", 300, 1123, 120) == "completed"
+
+
+async def test_separate_stores_serialize_signature_acquisition(tmp_path):
+ first = Store(str(tmp_path / "shared.db"))
+ second = Store(str(tmp_path / "shared.db"))
+ await first.open()
+ await second.open()
+ try:
+ outcomes = await asyncio.gather(
+ first.acquire_signature("token", "first", 300, 1000, 120),
+ second.acquire_signature("token", "second", 300, 1000, 120),
+ )
+ assert sorted(outcomes) == ["acquired", "busy"]
+ finally:
+ await first.close()
+ await second.close()
+
+
+async def test_failed_lease_renewal_cancels_processing_and_releases_lease(settings, cfg, monkeypatch):
+ entered = asyncio.Event()
+ real_handle = app_module.handle_hook
+ real_sleep = asyncio.sleep
+ calls = 0
+
+ async def fast_heartbeat(delay):
+ if delay == 40:
+ await entered.wait()
+ else:
+ await real_sleep(delay)
+
+ async def handle(*args, **kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ entered.set()
+ await asyncio.Event().wait()
+ return await real_handle(*args, **kwargs)
+
+ async def broken_renew(*args, **kwargs):
+ raise OSError("database unavailable")
+
+ monkeypatch.setattr(app_module.asyncio, "sleep", fast_heartbeat)
+ monkeypatch.setattr(app_module, "handle_hook", handle)
+ body = json.dumps(PAYLOAD).encode()
+ headers = {"X-Hook-Signature": _sig("s3cret", body)}
+ app = create_app(settings=settings, cfg=cfg)
+ async with _serve(app) as client:
+ monkeypatch.setattr(app.state.store, "renew_signature", broken_renew)
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(client.post("/hook/grafana", content=body, headers=headers), 2)
+ monkeypatch.setattr(app_module.asyncio, "sleep", real_sleep)
+ response = await client.post("/hook/grafana", content=body, headers=headers)
+ assert response.status_code == 200 and response.json()["outcome"] == "routed"
+ assert calls == 2
+
+
+def test_nonfinite_signed_timestamps_are_refused():
+ body = b"{}"
+ for timestamp in ("NaN", "inf", "-inf"):
+ signature = hmac.new(b"s3cret", timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
+ assert not verify_signature("s3cret", body, signature, timestamp_value=timestamp, require_timestamp=True)
+
+
# ── the token ─────────────────────────────────────────────────────────────
@@ -182,23 +284,25 @@ def test_replay_token_is_none_when_there_is_nothing_to_track():
assert replay_token("", "sig") is None
-async def test_claim_signature_is_true_once_then_false(store):
+async def test_a_completed_signature_is_spent_once(store):
now = 1_000.0
- assert await store.claim_signature("grafana:abc", 300, now) is True
- assert await store.claim_signature("grafana:abc", 300, now + 1) is False
+ assert await store.acquire_signature("grafana:abc", "a", 300, now, 120) == "acquired"
+ assert await store.complete_signature("grafana:abc", "a", 300, now) is True
+ assert await store.acquire_signature("grafana:abc", "b", 300, now + 1, 120) == "completed"
-async def test_signature_seen_asks_without_claiming(store):
- """The peek must not have the side effect of the claim, or the door's
- peek-then-claim split would be a claim wearing two names."""
- assert await store.signature_seen("grafana:abc", 300, 1_000.0) is False
- assert await store.signature_seen("grafana:abc", 300, 1_000.0) is False
- assert await store.claim_signature("grafana:abc", 300, 1_000.0) is True
- assert await store.signature_seen("grafana:abc", 300, 1_000.0) is True
+async def test_a_held_signature_is_busy_until_its_holder_lets_go(store):
+ """Released after a failure, the next identical request is processed —
+ the property the spent-after-answer order exists for."""
+ assert await store.acquire_signature("grafana:abc", "a", 300, 1_000.0, 120) == "acquired"
+ assert await store.acquire_signature("grafana:abc", "b", 300, 1_000.0, 120) == "busy"
+ await store.release_signature("grafana:abc", "a")
+ assert await store.acquire_signature("grafana:abc", "b", 300, 1_000.0, 120) == "acquired"
-async def test_claim_signature_forgets_outside_the_window(store):
+async def test_a_spent_signature_is_forgotten_outside_the_window(store):
"""The prune is what keeps the table the size of the window, not of uptime —
and what lets a genuinely new request with recycled bytes through later."""
- assert await store.claim_signature("grafana:abc", 300, 1_000.0) is True
- assert await store.claim_signature("grafana:abc", 300, 1_301.0) is True
+ assert await store.acquire_signature("grafana:abc", "a", 300, 1_000.0, 120) == "acquired"
+ assert await store.complete_signature("grafana:abc", "a", 300, 1_000.0) is True
+ assert await store.acquire_signature("grafana:abc", "b", 300, 1_301.0, 120) == "acquired"
diff --git a/scripts/assert_guards_are_tested.py b/scripts/assert_guards_are_tested.py
index 47357ef..35ef066 100755
--- a/scripts/assert_guards_are_tested.py
+++ b/scripts/assert_guards_are_tested.py
@@ -163,8 +163,8 @@
(
"a credential nobody could measure passes as recorded",
"hookprobe/hookprobe/posture.py",
- ' return "unmeasurable" if missing else "recorded"',
- ' return "recorded"',
+ ' if missing:\n return "unmeasurable"\n if kube.get("errors")',
+ ' if missing:\n return "recorded"\n if kube.get("errors")',
("tests/test_posture.py",),
),
(