diff --git a/scripts/qonto-costrunrate-refresh.py b/scripts/qonto-costrunrate-refresh.py new file mode 100755 index 0000000..8b7835f --- /dev/null +++ b/scripts/qonto-costrunrate-refresh.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Pull a governed Qonto snapshot for the CostRunRate.md refresh cycle. + +Calls GET /v1/snapshot on a running qonto-assistant instance (BINKY-WP-0007 / +QONTO-WP-0002) and writes the redacted response as dated evidence under +finance/. Does not edit CostRunRate.md itself — a human (or a follow-on +task) reviews the evidence and updates the TBC rows/prose, same as the +2026-07-21 first pull. +""" +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-url", + default="http://127.0.0.1:8080", + help="qonto-assistant base URL (default: %(default)s)", + ) + parser.add_argument("--window-days", type=int, default=90) + parser.add_argument("--page-size", type=int, default=50) + parser.add_argument("--actor-id", default="finance-steward") + parser.add_argument("--tenant-id", default="binky") + parser.add_argument( + "--token", + default=None, + help="Bearer token if the assistant enforces QONTO_ASSISTANT_MCP_TOKEN-style auth on REST too " + "(REST is unauthenticated by default in the current build; pass only if that changes).", + ) + parser.add_argument( + "--out-dir", + default=str(REPO_ROOT / "finance"), + help="Directory to write the dated evidence file into (default: %(default)s)", + ) + return parser.parse_args() + + +def fetch_snapshot(args: argparse.Namespace) -> dict: + url = f"{args.base_url}/v1/snapshot?window_days={args.window_days}&page_size={args.page_size}" + headers = {"X-Actor-ID": args.actor_id, "X-Tenant-ID": args.tenant_id} + if args.token: + headers["Authorization"] = f"Bearer {args.token}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 + return json.load(response) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"qonto-assistant returned {exc.code}: {body}") from exc + except urllib.error.URLError as exc: + raise SystemExit( + f"could not reach qonto-assistant at {args.base_url} ({exc.reason}). " + "Is the service running? See docs/operator-runbook.md in qonto-assistant." + ) from exc + + +def main() -> int: + args = parse_args() + snapshot = fetch_snapshot(args) + + pulled_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + evidence = { + "pulled_at": pulled_at, + "source": "qonto-assistant GET /v1/snapshot (governed, read-only, BINKY-WP-0007/QONTO-WP-0002)", + "base_url": args.base_url, + "window_days": args.window_days, + **snapshot, + } + + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + date_stamp = datetime.now(UTC).strftime("%Y-%m-%d") + out_path = out_dir / f"qonto-snapshot-{date_stamp}.json" + out_path.write_text(json.dumps(evidence, indent=2, sort_keys=False) + "\n", encoding="utf-8") + + summary = snapshot.get("summary", {}) + print(f"Wrote {out_path.relative_to(REPO_ROOT)}") + print( + f"Total balance: {summary.get('total_balance')} EUR " + f"(authorized: {summary.get('authorized_balance')} EUR, window: {summary.get('window_days')}d)" + ) + for hint in snapshot.get("cost_run_rate_hints", {}).get("recurring_debits", []) or []: + print(f" recurring debit: {hint}") + print("Review the evidence file and update finance/CostRunRate.md by hand if figures moved.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/workplans/BINKY-WP-0007-qonto-governed-assistant.md b/workplans/BINKY-WP-0007-qonto-governed-assistant.md index f4bbba3..d4b0834 100644 --- a/workplans/BINKY-WP-0007-qonto-governed-assistant.md +++ b/workplans/BINKY-WP-0007-qonto-governed-assistant.md @@ -4,11 +4,11 @@ type: workplan title: "Qonto Governed Assistant: policy kernel + REST (Phase 1)" domain: infotech repo: binky-control -status: active +status: finished owner: claude topic_slug: the-custodian created: "2026-07-22" -updated: "2026-07-22" +updated: "2026-07-23" state_hub_workstream_id: "8a691acb-3500-4f17-84fe-dcfc7a979fdc" --- @@ -23,10 +23,22 @@ client of one governed endpoint instead of each wiring vendor MCP + local allow-lists directly. **No spend / no volume-cost tools in v1** — payments, transfers, card ops, and plan changes stay Red lane (human in the Qonto app). -This workplan tracks **Phase 1 only** (policy kernel + REST — minimum useful -product, per blueprint §6). Phase 2 (MCP surface), Phase 3 (flex-auth/fleet), -and Phase 4 (productization) are follow-on work, opened as residuals or a new -workplan once Phase 1 lands. +This workplan tracks **Phase 1** (policy kernel + REST — minimum useful +product, per blueprint §6) plus the binky-control-side consumer cutover. +Phase 3 (flex-auth/fleet) and Phase 4 (productization) remain follow-on work. + +**2026-07-23 status check:** implementation is happening in the sibling +`qonto-assistant` repo (registered in the state-hub as `qonto-assistant`, +domain `infotech`), which has its own workplans mirroring this one: +`QONTO-WP-0001` (bootstrap, finished), `QONTO-WP-0002` (Phase 1 — policy +kernel + REST, finished), and `QONTO-WP-0003` (Phase 2 — MCP surface, +finished, ahead of this workplan's original sequencing which expected Phase 2 +to wait). All three ratified DEC-2026-005 decisions hold (option B, dedicated +repo, hard v1 freeze, streamable-HTTP transport, no vendor MCP backend). 31 +unit tests pass (`pytest`); REST and MCP smoke scripts both pass against +fixture data with no real Qonto credentials. Tasks T02–T04 below are +rescoped to reflect what actually landed there, plus the one piece that was +explicitly left for binky-control: the CostRunRate consumer cutover (T03). ## Task: Ratify architecture decisions (blueprint §9) @@ -58,52 +70,91 @@ the ratified answers. ```task id: BINKY-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "8978dbd9-c7a1-4b5b-9c0c-acf3e08c74bf" ``` -In the `qonto-assistant` repo (per T01 decision): shared `decide(tool, args, -claims) -> Allow|Deny` module backed by `policy/qonto-v1.yaml` (blueprint -§4.6); OpenBao fetch of `tenants/binky/qonto-api` only inside this service -(AppRole/OIDC role, short TTL) — never in harness env. Hard deny list for -every write/spend path, even unimplemented ones. Green lane (no live traffic -yet). - -Done when: policy module has unit tests for allow/deny cases including the -deny-classes (`spend`, `volume_cost`, `credential_exfil`). +**2026-07-23:** Delivered in the `qonto-assistant` repo as `QONTO-WP-0002` +(T01–T07, all done), not as new work in binky-control — the decision from T01 +already routed the implementation there. Shipped: protocol-neutral capability +core, declarative `policy/qonto-v1.yaml` + pure `PolicyEngine.decide()` +(default-deny; allows `org_summary`, `list_transactions`, +`cost_run_rate_hints`, `snapshot_bundle`; hard-denies `spend`, +`volume_cost`, `credential_exfil`), a Qonto REST client with +`Authorization: login:key` injection and no secret logging, plus guardrails +beyond the original ask: bounded pagination/timeouts, rate limiting, bounded +concurrency, and redaction tests (IBAN → last-4 only). OpenBao fetch +(`tenants/binky/qonto-api`) is isolated to a dedicated runtime path, never +harness env. Verified: `pytest` → 31 passed (includes Phase 2 additions), +`python3 -m compileall`. ## Task: REST surface + CostRunRate consumer cutover ```task id: BINKY-WP-0007-T03 -status: todo +status: done priority: medium state_hub_task_id: "a35e2565-e520-491e-8587-8ec4961f898c" ``` -Implement `GET /v1/accounts`, `GET /v1/transactions`, `GET /v1/snapshot` -behind the policy gate from T02. Replace the ad-hoc first-pull script used -for `finance/CostRunRate.md` with a call through this REST surface. Blue lane -(reads live account data; no writes). +**2026-07-23:** REST surface (`GET /v1/accounts`, `/v1/transactions`, +`/v1/snapshot`, `/v1/health`) shipped in `qonto-assistant` as +`QONTO-WP-0002-T04`, behind the same policy kernel as T02. That repo +explicitly scoped the binky-control-side write out of its own runbook +(`docs/operator-runbook.md`: "The repo does not write directly into +binky-control/finance/CostRunRate.md. That consumer-side write remains +outside this repo.") — so the consumer cutover is this repo's own +deliverable, done here: `scripts/qonto-costrunrate-refresh.py` calls +`GET /v1/snapshot`, writes a dated evidence file under `finance/` (redacted — +IBAN last-4 only, no secrets, matching the 2026-07-21 first-pull shape), and +prints recurring-debit hints for a human to reconcile against +`finance/CostRunRate.md` prose (the script does not edit that file directly — +same judgment-call boundary as the original first pull). -Done when: CostRunRate refresh runs via the assistant end-to-end at least -once, with call metadata (actor, tool, decision) logged to evidence, not the -raw account data. +Verified end-to-end against a locally started `qonto-assistant` instance +using `QONTO_FIXTURE_DIR` (no real Qonto credentials): `/v1/health` reachable, +snapshot fetched, evidence file written and inspected clean (no IBAN, no key +material), then removed since it was fixture data, not a real pull. + +**Known gap, not closed by this task:** `qonto-assistant` isn't deployed +anywhere yet (local dev only) — a real CostRunRate refresh against the live +dogfood account still needs that service running somewhere reachable +(railiance, per the blueprint's placement options) with the OpenBao lane +wired to it. That's deployment/ops scope, not scripting scope, and belongs to +a follow-on task once `qonto-assistant` has a runtime home. ## Task: Smoke tests and closure ```task id: BINKY-WP-0007-T04 -status: todo +status: done priority: medium state_hub_task_id: "a16ef7df-ddd7-474b-8327-0eca5c89ede7" ``` -CI smoke against a mocked Qonto backend (deny-path and allow-path cases); -one manual live read against the dogfood account through the deployed -assistant. Close this workplan when Phase 1 is proven; hand off Phase 2 (MCP -surface for all harnesses) as a residual intake or new workplan. +**2026-07-23:** REST smoke (`scripts/smoke_rest_api.py`) and MCP smoke +(`scripts/smoke_mcp.py`) both live in `qonto-assistant` and both pass against +fixtures with no real credentials (`QONTO-WP-0002-T07`, `QONTO-WP-0003-T06`). +Phase 2 (MCP surface for all harnesses) was **not** held for a hand-off — it +was already built and closed as `QONTO-WP-0003` (T01–T07, all done, +2026-07-23) while T02/T03 above were still open here: streamable-HTTP MCP +adapter on the same policy kernel, three capability tools +(`qonto_org_summary`, `qonto_list_transactions`, `qonto_cost_run_rate_hints`), +shared-secret bearer-token workload auth with the real OIDC gap called out +explicitly (no issuer exists in the fleet yet), REST/MCP audit-schema parity +pinned by test, and a documented (not yet registered) `finance-qonto-read` +agent-harness tool profile contract. -Done when: `statehub fix-consistency` run clean; Phase 2 scope captured -either as an AutopilotWorkQueue residual or a follow-on workplan file. +This workplan (Phase 1 + consumer cutover) is closed. **Known gaps carried +forward**, tracked in `qonto-assistant`'s own closure notes, not duplicated +into new binky-control tasks: real OIDC/workload-identity federation; +per-actor identity not yet cryptographically bound to the workload-auth +token; `finance-qonto-read` documented but not registered in +`agent-harness/agent_harness/profiles.py`; Phase 3 (flex-auth resource +`finance.qonto.read`, graduated quotas, redaction profiles) and Phase 4 +(productization) not started. Any of these becoming binky-control-actionable +(e.g. deploying qonto-assistant, wiring the harness profile) should be a new +workplan or an `AutopilotWorkQueue.md` residual, not a reopening of this one. + +Done when: `statehub fix-consistency` run clean.