#!/usr/bin/env python3
"""
sentiqra-agent-gateway.py — reference implementation of an enforceable
action boundary for LLM agents.

The agent proposes an action (tool + arguments). The gateway — your code,
outside the model — decides whether it executes:

  1. Authorization: is this actor allowed to call this tool, in this
     tenant, with these arguments? Deterministic policy — neither the
     model nor any analysis result can widen it.
  2. Required checks: for tools that declare them, the GATEWAY calls
     Sentiqra /v1/analyze itself on the designated arguments. The agent
     cannot skip the check — it is a step of dispatch, not a tool the
     agent may or may not choose to call.
  3. Bound approval: passing mints a short-lived, single-use approval
     signed by the gateway key. It binds the tenant, actor, tool, a
     canonical digest of the exact arguments, the policy version, and a
     validity interval — changing any argument after approval invalidates
     the approval.
  4. Execution: the executor verifies the approval and re-checks
     authorization before dispatch. Analysis is evidence, never a
     credential: a benign result authorizes nothing, and tenant/actor
     identity is supplied by your application layer — never parsed out
     of the analyzed text.

Completion properties (--selftest proves them):
  * the agent cannot bypass the check — plan() is the only path to an
    approval, and required checks run inside it;
  * approval cannot be replayed, expired, or reused with different
    arguments, actor, tenant, or policy version;
  * an unauthorized action is refused even with a benign analysis.

Policy document (sentiqra/agent-policy/v1):

  {"schema": "sentiqra/agent-policy/v1",
   "policy_version": "1.0.0",
   "tools": {
     "send_email": {
       "actors": {"tenant-acme": ["support-agent-7"]},
       "argument_rules": [
         {"field": "to", "domain_in": ["example.com"]},
         {"field": "ticket_id", "context_equals": "tenant"}
       ],
       "required_checks": [
         {"analyzer": "trust-safety", "content_from": "arguments.body"},
         {"analyzer": "cybersecurity", "content_from": "arguments.body"}
       ],
       "block_signal_score_gte": 0.70,
       "approval_ttl_seconds": 120
     }}}

  argument_rules — deterministic authorization on arguments, evaluated
    before any analysis: "in" (value in list), "domain_in" (email/host
    domain part in list), "context_equals" (argument must equal a
    context field such as "tenant" or "actor").
  required_checks — content_from is "arguments" (whole canonical
    argument set) or "arguments.<dotted.path>" (one field's text).
    block_signal_score_gte is REQUIRED when checks are declared —
    an explicit null means "record signals but never block" so a
    log-only policy is a deliberate choice, not a silent default.

Usage:
    python3 sentiqra-agent-gateway.py keygen
    export SENTIQRA_API_KEY=sq_live_... SENTIQRA_GATEWAY_KEY=<hex from keygen>
    python3 sentiqra-agent-gateway.py plan --policy policy.json \
        --tool send_email --tenant tenant-acme --actor support-agent-7 \
        --arguments '{"to":"u@example.com","body":"Your refund is ready"}'
    python3 sentiqra-agent-gateway.py execute --policy policy.json \
        --approval sga1.... --tool send_email --tenant tenant-acme \
        --actor support-agent-7 --arguments '{"to":"u@example.com",...}'

In production the Gateway instance owns your real executor registry
(Gateway(executors={"send_email": send_fn})) and a durable nonce store;
the CLI prints the dispatch decision and keeps nonces in-process.

Requires: Python 3.8+, no third-party packages.
"""

import argparse
import base64
import hashlib
import hmac
import json
import secrets
import sys
import time
import urllib.error
import urllib.request
import uuid
from os import environ

SCHEMA_POLICY = "sentiqra/agent-policy/v1"
SCHEMA_APPROVAL = "sentiqra/agent-approval/v1"
SCHEMA_AUDIT = "sentiqra/agent-gateway-audit/v1"
TOKEN_PREFIX = "sga1"

CHECK_OK = ("completed", "not_applicable")  # anything else fails closed


def canonical(obj) -> bytes:
    return json.dumps(obj, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")


def digest(obj) -> str:
    return "sha256:" + hashlib.sha256(canonical(obj)).hexdigest()


def b64u(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def b64u_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


def resolve_path(obj, dotted: str):
    """Resolve 'a.b.c' into nested dicts. Returns (found, value)."""
    cur = obj
    for part in dotted.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return False, None
        cur = cur[part]
    return True, cur


# ---------------------------------------------------------------------------
# Policy
# ---------------------------------------------------------------------------

def validate_policy(pol: dict) -> dict:
    if not isinstance(pol, dict) or pol.get("schema") != SCHEMA_POLICY:
        sys.exit(f'policy schema must be "{SCHEMA_POLICY}"')
    if not pol.get("policy_version"):
        sys.exit("policy needs a 'policy_version' — approvals bind to it")
    tools = pol.get("tools")
    if not isinstance(tools, dict) or not tools:
        sys.exit("policy has no tools — a gateway with no policy grants nothing")
    for name, spec in tools.items():
        if not isinstance(spec, dict):
            sys.exit(f"tool '{name}': spec must be an object")
        actors = spec.get("actors")
        if not isinstance(actors, dict) or not any(actors.values()):
            sys.exit(f"tool '{name}': 'actors' must map tenants to actor lists")
        for rule in spec.get("argument_rules", []):
            kinds = [k for k in ("in", "domain_in", "context_equals") if k in rule]
            if not rule.get("field") or len(kinds) != 1:
                sys.exit(f"tool '{name}': each argument_rule needs 'field' and "
                         f"exactly one of in/domain_in/context_equals")
        checks = spec.get("required_checks", [])
        for c in checks:
            if not c.get("analyzer") or not c.get("content_from"):
                sys.exit(f"tool '{name}': each required_check needs "
                         f"'analyzer' and 'content_from'")
        if checks and "block_signal_score_gte" not in spec:
            sys.exit(f"tool '{name}': required_checks declared but "
                     f"block_signal_score_gte is absent — set a threshold, "
                     f"or null explicitly for observe-only")
    return pol


def load_policy(path: str) -> dict:
    try:
        with open(path, encoding="utf-8") as fh:
            return validate_policy(json.load(fh))
    except json.JSONDecodeError as e:
        sys.exit(f"policy is not valid JSON: {e}")


# ---------------------------------------------------------------------------
# Gateway
# ---------------------------------------------------------------------------

class Gateway:
    """The action boundary. plan() decides and (on approval) mints a bound,
    single-use token; execute() verifies the binding and re-checks
    authorization before dispatching to a registered executor."""

    def __init__(self, policy: dict, key: bytes, check, executors=None,
                 audit=None, now=None):
        self.policy = policy
        self.key = key
        self.check = check            # fn(analyzer, text) -> outcome dict
        self.executors = executors or {}
        self.audit = audit or (lambda rec: None)
        self.now = now or time.time
        self._used_nonces = set()     # durable store in production

    # -- internals ---------------------------------------------------------

    def _audit(self, **rec):
        rec.update(schema=SCHEMA_AUDIT, ts=int(self.now()),
                   policy_version=self.policy["policy_version"])
        self.audit(rec)
        return rec

    def _deny(self, reason, tool, context, arguments, checks=None,
              detail=None):
        rec = self._audit(decision="denied", reason=reason, tool=tool,
                          tenant=context.get("tenant"),
                          actor=context.get("actor"), checks=checks or [],
                          args_digest=digest(arguments)
                          if arguments is not None else None,
                          detail=detail)
        out = {"decision": "denied", "reason": reason, "checks": checks or [],
               "audit": rec}
        if detail:
            out["detail"] = detail
        return out

    def _authorize(self, spec: dict, arguments: dict, context: dict):
        """Deterministic authorization — evaluated before (and again at)
        execution, entirely independent of any analysis result. Returns an
        error reason string or None."""
        actors = spec["actors"].get(context.get("tenant"), [])
        if context.get("actor") not in actors:
            return "actor_not_authorized"
        for rule in spec.get("argument_rules", []):
            found, val = resolve_path(arguments, rule["field"])
            if not found:
                return f"argument_missing:{rule['field']}"
            if "in" in rule:
                if val not in rule["in"]:
                    return f"argument_rule_violation:{rule['field']}"
            elif "domain_in" in rule:
                dom = str(val).rsplit("@", 1)[-1].lower()
                if dom not in rule["domain_in"]:
                    return f"argument_rule_violation:{rule['field']}"
            elif "context_equals" in rule:
                if str(val) != str(context.get(rule["context_equals"])):
                    return f"argument_rule_violation:{rule['field']}"
        return None

    def _content_for(self, content_from: str, arguments: dict):
        if content_from == "arguments":
            return canonical(arguments).decode("utf-8")
        if content_from.startswith("arguments."):
            found, val = resolve_path(arguments, content_from[len("arguments."):])
            if found:
                return val if isinstance(val, str) else canonical(val).decode("utf-8")
        return None

    def _sign(self, payload: dict) -> str:
        sig = hmac.new(self.key, canonical(payload), hashlib.sha256).digest()
        return f"{TOKEN_PREFIX}.{b64u(canonical(payload))}.{b64u(sig)}"

    # -- plan --------------------------------------------------------------

    def plan(self, tool: str, arguments: dict, context: dict) -> dict:
        """Authorize + run required checks + (maybe) mint a bound approval.
        This is the only path to an approval: required checks are invoked
        here, inside the gateway — never at the agent's option."""
        spec = self.policy["tools"].get(tool)
        if spec is None:
            return self._deny("unknown_tool", tool, context, arguments,
                              detail="tool is not registered in policy")

        err = self._authorize(spec, arguments, context)
        if err:
            return self._deny(err, tool, context, arguments)

        checks = []
        for req in spec.get("required_checks", []):
            text = self._content_for(req["content_from"], arguments)
            if text is None or text == "":
                checks.append({"analyzer": req["analyzer"],
                               "content_from": req["content_from"],
                               "status": "missing_content", "signals": []})
                return self._deny("required_content_missing", tool, context,
                                  arguments, checks,
                                  detail=req["content_from"])
            outcome = self.check(req["analyzer"], text)  # invoked BY the gateway
            entry = {"analyzer": req["analyzer"],
                     "content_from": req["content_from"],
                     "status": outcome.get("status", "failed"),
                     "signals": [{"analyzer": s.get("analyzer"),
                                  "code": s.get("code"),
                                  "score": s.get("score")}
                                 for s in outcome.get("signals", [])]}
            checks.append(entry)
            if entry["status"] not in CHECK_OK:
                return self._deny("check_not_completed", tool, context,
                                  arguments, checks,
                                  detail=f"{req['analyzer']}: {entry['status']}")
            threshold = spec.get("block_signal_score_gte")
            if threshold is not None:
                hits = [s for s in entry["signals"]
                        if (s["score"] or 0) >= threshold]
                if hits:
                    return self._deny("signal_policy_block", tool, context,
                                      arguments, checks,
                                      detail="; ".join(
                                          f"{h['analyzer']}:{h['code']}"
                                          f"@{h['score']}" for h in hits))

        now = int(self.now())
        ttl = int(spec.get("approval_ttl_seconds", 120))
        payload = {
            "schema": SCHEMA_APPROVAL,
            "tenant": context["tenant"],
            "actor": context["actor"],
            "tool": tool,
            "args_digest": digest(arguments),
            "policy_version": self.policy["policy_version"],
            "iat": now,
            "exp": now + ttl,
            "nonce": secrets.token_hex(8),
        }
        token = self._sign(payload)
        rec = self._audit(decision="approved", tool=tool,
                          tenant=context["tenant"], actor=context["actor"],
                          checks=checks, args_digest=payload["args_digest"],
                          approval_exp=payload["exp"])
        return {"decision": "approved", "approval": token, "checks": checks,
                "expires_at": payload["exp"], "audit": rec}

    # -- execute -----------------------------------------------------------

    def _refuse(self, reason, detail=None):
        rec = self._audit(decision="refused", reason=reason, detail=detail)
        return {"decision": "refused", "reason": reason, "audit": rec}

    def execute(self, approval: str, tool: str, arguments: dict,
                context: dict) -> dict:
        """Verify the bound approval and dispatch. Re-runs authorization so
        a minted approval cannot launder a since-revoked permission, then
        calls the registered executor — the only place a tool ever runs."""
        try:
            prefix, payload_b64, sig_b64 = approval.split(".")
        except ValueError:
            return self._refuse("malformed_approval")
        if prefix != TOKEN_PREFIX:
            return self._refuse("malformed_approval")
        try:
            payload = json.loads(b64u_decode(payload_b64))
            sig = b64u_decode(sig_b64)
        except Exception:
            return self._refuse("malformed_approval")
        if not isinstance(payload, dict) or payload.get("schema") != SCHEMA_APPROVAL:
            return self._refuse("malformed_approval")
        expect = hmac.new(self.key, canonical(payload), hashlib.sha256).digest()
        if not hmac.compare_digest(sig, expect):
            return self._refuse("invalid_signature")

        if int(self.now()) > int(payload.get("exp", 0)):
            return self._refuse("approval_expired")
        if payload.get("nonce") in self._used_nonces:
            return self._refuse("approval_replayed")
        if payload.get("policy_version") != self.policy["policy_version"]:
            return self._refuse("policy_version_changed")
        for field, actual in (("tenant", context.get("tenant")),
                              ("actor", context.get("actor")),
                              ("tool", tool)):
            if payload.get(field) != actual:
                return self._refuse("approval_context_mismatch",
                                    detail=f"{field} differs")
        if payload.get("args_digest") != digest(arguments):
            return self._refuse("arguments_changed")

        spec = self.policy["tools"].get(tool)
        if spec is None:
            return self._refuse("unknown_tool")
        err = self._authorize(spec, arguments, context)
        if err:
            return self._refuse("authorization_revoked", detail=err)

        self._used_nonces.add(payload["nonce"])
        handler = self.executors.get(tool)
        result = handler(arguments) if handler else {"dispatched": tool}
        self._audit(decision="executed", tool=tool,
                    tenant=context["tenant"], actor=context["actor"],
                    args_digest=payload["args_digest"], nonce=payload["nonce"])
        return {"decision": "executed", "tool": tool, "result": result,
                "nonce": payload["nonce"]}


# ---------------------------------------------------------------------------
# Real check: POST /v1/analyze — one analyzer per required check
# ---------------------------------------------------------------------------

def make_check(base: str, key: str):
    def check(analyzer: str, text: str) -> dict:
        payload = {"text": text, "language": "en", "analyzers": [analyzer]}
        req = urllib.request.Request(
            f"{base}/v1/analyze", data=json.dumps(payload).encode(),
            method="POST",
            headers={"Authorization": f"Bearer {key}",
                     "Content-Type": "application/json",
                     "Idempotency-Key": "idem_" + uuid.uuid4().hex[:24]})
        try:
            with urllib.request.urlopen(req, timeout=60) as res:
                body = json.loads(res.read())
        except urllib.error.HTTPError as e:
            code = f"http_{e.code}"
            try:
                code = (json.loads(e.read()).get("error") or {}).get("code") or code
            except Exception:
                pass
            return {"status": "overloaded" if e.code in (429, 503) else "failed",
                    "signals": [], "error": code}
        except (urllib.error.URLError, TimeoutError, OSError) as e:
            return {"status": "failed", "signals": [], "error": f"transport: {e}"}
        entries = (body.get("meta") or {}).get("analyzers") or [{}]
        entry = next((a for a in entries if a.get("name") == analyzer),
                     entries[0])
        signals = [s for s in body.get("signals") or []
                   if s.get("analyzer") in (analyzer, None) or len(entries) == 1]
        return {"status": entry.get("status", "completed"),
                "signals": [{"analyzer": analyzer,
                             "code": s.get("code") or s.get("type"),
                             "score": s.get("score")}
                            for s in signals]}
    return check


# ---------------------------------------------------------------------------
# Selftest — the completion test for the boundary
# ---------------------------------------------------------------------------

def _selftest() -> int:
    failures = []

    def check(name, cond, detail=""):
        if cond:
            print(f"  ok   {name}")
        else:
            failures.append(name)
            print(f"  FAIL {name}: {detail}")

    policy = {
        "schema": SCHEMA_POLICY,
        "policy_version": "1.0.0",
        "tools": {
            "send_email": {
                "actors": {"acme": ["agent.7"], "otherco": ["agent.9"]},
                "argument_rules": [
                    {"field": "to", "domain_in": ["example.com"]}],
                "required_checks": [
                    {"analyzer": "trust-safety",
                     "content_from": "arguments.body"}],
                "block_signal_score_gte": 0.70,
                "approval_ttl_seconds": 60,
            },
            "read_kb": {
                "actors": {"acme": ["agent.7"]},
                "required_checks": [],
                "approval_ttl_seconds": 60,
            },
        },
    }
    ctx = {"tenant": "acme", "actor": "agent.7"}
    args = {"to": "u@example.com", "body": "your refund is ready"}
    key = b"test-gateway-key"

    calls = []

    def clean_check(analyzer, text):
        calls.append(analyzer)
        return {"status": "completed", "signals": []}

    clock = [1_700_000_000]

    def make_gw(**kw):
        audit_rows = []
        gw = Gateway(policy, key, kw.get("check", clean_check),
                     executors=kw.get("executors", {}),
                     audit=audit_rows.append, now=lambda: clock[0])
        return gw, audit_rows

    # 1. unknown tool → denied, no check invoked
    gw, _ = make_gw()
    r = gw.plan("delete_db", args, ctx)
    check("deny_unknown_tool", r["decision"] == "denied"
          and r["reason"] == "unknown_tool" and calls == [], r)

    # 2. unauthorized actor → denied before any check runs (auth ≠ analysis)
    calls.clear()
    r = gw.plan("send_email", args, {"tenant": "acme", "actor": "mallory"})
    check("deny_unauthorized_actor", r["reason"] == "actor_not_authorized"
          and calls == [], r)

    # 3. actor valid in one tenant is not valid in another
    r = gw.plan("send_email", args, {"tenant": "acme", "actor": "agent.9"})
    check("deny_cross_tenant_actor", r["reason"] == "actor_not_authorized", r)

    # 4. argument rule violated → denied with ZERO check calls:
    #    authorization is separate from (and precedes) semantic analysis
    calls.clear()
    r = gw.plan("send_email", {"to": "u@evil.net", "body": "hi"}, ctx)
    check("argument_rule_denied_without_checks",
          r["reason"].startswith("argument_rule_violation") and calls == [], r)

    # 5. required checks run inside the gateway — the agent cannot skip them
    calls.clear()
    r = gw.plan("send_email", args, ctx)
    check("checks_run_inside_gateway",
          calls == ["trust-safety"] and r["decision"] == "approved", calls)

    # 6. clean analysis authorizes nothing: a benign result for an
    #    unauthorized actor still denies (proven by 2) — and for a
    #    rule-violating argument still denies (proven by 4); assert both
    #    landed before the check stage
    check("clean_analysis_grants_nothing", True)

    # 7. signal at/above threshold → denied, no approval minted
    def hot_check(analyzer, text):
        return {"status": "completed",
                "signals": [{"analyzer": analyzer, "code": "threat",
                             "score": 0.91}]}
    gw2, _ = make_gw(check=hot_check)
    r = gw2.plan("send_email", args, ctx)
    check("signal_block_denies", r["decision"] == "denied"
          and r["reason"] == "signal_policy_block"
          and "approval" not in r, r)

    # 8. failed/overloaded check → fail closed
    for st in ("failed", "overloaded", "timeout"):
        gw3, _ = make_gw(check=lambda a, t, s=st: {"status": s, "signals": []})
        r = gw3.plan("send_email", args, ctx)
        check(f"check_{st}_fails_closed",
              r["reason"] == "check_not_completed", r)

    # 9. approved path executes — executor dispatched once
    gw4, audit4 = make_gw(executors={"send_email": lambda a: {"sent": True}})
    r = gw4.plan("send_email", args, ctx)
    token = r["approval"]
    r = gw4.execute(token, "send_email", args, ctx)
    check("approval_executes", r["decision"] == "executed"
          and r["result"] == {"sent": True}, r)

    # 10. replay → refused (single-use nonce)
    r = gw4.execute(token, "send_email", args, ctx)
    check("replay_rejected", r["decision"] == "refused"
          and r["reason"] == "approval_replayed", r)

    # 11. different arguments → refused (args digest binding)
    gw5, _ = make_gw(executors={"send_email": lambda a: {"sent": True}})
    r = gw5.plan("send_email", args, ctx)
    r = gw5.execute(r["approval"], "send_email",
                    {"to": "u@example.com", "body": "CHANGED"}, ctx)
    check("args_mutation_rejected", r["reason"] == "arguments_changed", r)

    # 12. expired → refused (validity interval binding)
    r = gw5.plan("send_email", args, ctx)
    clock[0] += 61
    r = gw5.execute(r["approval"], "send_email", args, ctx)
    check("expired_rejected", r["reason"] == "approval_expired", r)
    clock[0] -= 61

    # 13. policy version bump → refused
    r = gw5.plan("send_email", args, ctx)
    gw5.policy = dict(policy, policy_version="2.0.0")
    r = gw5.execute(r["approval"], "send_email", args, ctx)
    check("policy_version_bump_rejected",
          r["reason"] == "policy_version_changed", r)
    gw5.policy = policy

    # 14. approval minted for acme/agent.7 cannot be replayed under a
    #     different tenant or actor context
    r = gw5.plan("send_email", args, ctx)
    r2 = gw5.execute(r["approval"], "send_email", args,
                     {"tenant": "otherco", "actor": "agent.7"})
    check("context_swap_rejected",
          r2["reason"] == "approval_context_mismatch", r2)

    # 15. tampered token → invalid signature
    r = gw5.plan("send_email", args, ctx)
    head, _, sig = r["approval"].rpartition(".")
    tampered = head[:-4] + ("AAAA" if head[-4:] != "AAAA" else "BBBB") + "." + sig
    r2 = gw5.execute(tampered, "send_email", args, ctx)
    check("tampered_token_rejected", r2["reason"] in
          ("invalid_signature", "malformed_approval"), r2)

    # 16. tool with no required checks still requires authorization +
    #     bound approval — a clean tool is not a backdoor
    calls.clear()
    r = gw5.plan("read_kb", {"doc": "faq"}, ctx)
    check("no_check_tool_still_bound",
          r["decision"] == "approved" and calls == [], r)
    r2 = gw5.plan("read_kb", {"doc": "x"},
                  {"tenant": "acme", "actor": "mallory"})
    check("no_check_tool_still_authorized",
          r2["reason"] == "actor_not_authorized", r2)

    # 17. audit rows carry digests, never content — the analyzed text is
    #     not persisted by the boundary
    blob = json.dumps(audit4)
    check("audit_records_digests_not_content",
          "sha256:" in blob and "refund is ready" not in blob
          and "u@example.com" not in blob, blob[:200])

    # 18. required content missing → denied (a check that cannot run
    #     cannot be skipped silently)
    r = gw5.plan("send_email", {"to": "u@example.com"}, ctx)
    check("missing_check_content_denied",
          r["reason"] == "required_content_missing", r)

    print(f"\n{len(failures)} failure(s)" if failures
          else "\nall selftest checks passed")
    return 1 if failures else 0


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> int:
    ap = argparse.ArgumentParser(
        description="Reference tool-execution gateway: authorize, run "
                    "required checks inside dispatch, mint bound approvals, "
                    "execute or refuse")
    sub = ap.add_subparsers(dest="cmd")
    sub.add_parser("keygen", help="print a random gateway signing key")
    for name in ("plan", "execute"):
        p = sub.add_parser(name)
        p.add_argument("--policy", required=True)
        p.add_argument("--tool", required=True)
        p.add_argument("--arguments", required=True,
                       help="JSON object of the proposed tool arguments")
        p.add_argument("--tenant", required=True,
                       help="tenant from YOUR session context — never parsed "
                            "from the analyzed text")
        p.add_argument("--actor", required=True,
                       help="actor id from YOUR session context")
        if name == "execute":
            p.add_argument("--approval", required=True)
    ap.add_argument("--selftest", action="store_true")
    ap.add_argument("--base", default="https://sentiqra.tech")
    ap.add_argument("--api-key", default=environ.get("SENTIQRA_API_KEY"))
    ap.add_argument("--gateway-key", default=environ.get("SENTIQRA_GATEWAY_KEY"),
                    help="hex key from `keygen` (env SENTIQRA_GATEWAY_KEY)")
    ap.add_argument("--audit-log", help="append JSONL audit rows (digests only)")
    args = ap.parse_args()
    if args.selftest:
        return _selftest()
    if args.cmd == "keygen":
        print(secrets.token_hex(32))
        return 0
    if not args.cmd:
        ap.error("a subcommand is required (keygen, plan, execute)")
    if not args.gateway_key:
        ap.error("--gateway-key or SENTIQRA_GATEWAY_KEY is required "
                 "(generate one with `keygen`)")

    policy = load_policy(args.policy)
    try:
        arguments = json.loads(args.arguments)
    except json.JSONDecodeError as e:
        sys.exit(f"--arguments is not valid JSON: {e}")
    if not isinstance(arguments, dict):
        sys.exit("--arguments must be a JSON object")
    context = {"tenant": args.tenant, "actor": args.actor}

    audit_fh = open(args.audit_log, "a", encoding="utf-8") if args.audit_log else None

    def audit(rec):
        line = json.dumps(rec, ensure_ascii=False)
        if audit_fh:
            audit_fh.write(line + "\n")
            audit_fh.flush()
        else:
            print(f"audit: {line}", file=sys.stderr)

    spec = policy["tools"].get(args.tool, {})
    needs_api = bool(spec.get("required_checks")) and args.cmd == "plan"
    if needs_api and not args.api_key:
        ap.error("this tool declares required_checks — SENTIQRA_API_KEY is "
                 "needed so the GATEWAY can run them (the agent never does)")
    check = make_check(args.base.rstrip("/"), args.api_key or "")
    gw = Gateway(policy, bytes.fromhex(args.gateway_key), check, audit=audit)

    out = (gw.plan(args.tool, arguments, context) if args.cmd == "plan"
           else gw.execute(args.approval, args.tool, arguments, context))
    print(json.dumps({k: v for k, v in out.items() if k != "audit"},
                     indent=2, ensure_ascii=False))
    if args.cmd == "plan" and out["decision"] == "approved":
        print("\napproval is single-use and bound to tenant, actor, tool, "
              "the exact argument digest, policy version, and its validity "
              "window — pass it verbatim to `execute`.", file=sys.stderr)
    return 0 if out["decision"] in ("approved", "executed") else 1


if __name__ == "__main__":
    sys.exit(main())
