pywrit 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywrit"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Python client + CLI for Writ — the gate before the write"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -27,7 +27,7 @@ from .errors import (
27
27
  WritError,
28
28
  )
29
29
 
30
- __version__ = "0.2.0"
30
+ __version__ = "0.2.2"
31
31
 
32
32
  __all__ = [
33
33
  "WritClient",
@@ -4,9 +4,12 @@
4
4
  import argparse
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import sys
8
9
  import urllib.error
10
+ import urllib.parse
9
11
  import urllib.request
12
+ from datetime import datetime, timezone
10
13
 
11
14
  BASE = os.environ.get(
12
15
  "WRIT_BASE",
@@ -364,6 +367,130 @@ def infer_verb(rel_path, func_name, cls_name, kind):
364
367
  _infer_action(func_name, kind))
365
368
 
366
369
 
370
+ # ---------------------------------------------------------------------------
371
+ # writ scan score: risk-graded "Writ Score" report. Lighthouse-style 0-100,
372
+ # based only on what the AST scanner actually finds. The scanner is free
373
+ # forever; scoring needs no API key.
374
+ # ---------------------------------------------------------------------------
375
+ _RISK_HIGH_ACTIONS = {"refund", "charge", "capture", "void", "payout",
376
+ "transfer", "grant", "revoke", "issue", "promote",
377
+ "elevate", "terminate"}
378
+ _RISK_HIGH_DOMAINS = {"payment", "payments", "billing", "stripe", "invoice",
379
+ "invoices", "subscription", "subscriptions",
380
+ "iam", "auth", "role", "roles", "permission",
381
+ "permissions", "admin", "token", "tokens", "identity",
382
+ "aws", "infra", "infrastructure", "deploy", "deploys",
383
+ "dns", "k8s", "kubernetes", "terraform", "ec2", "s3"}
384
+ _RISK_DATA_DOMAINS = {"user", "users", "customer", "customers", "order",
385
+ "orders", "record", "records", "account", "accounts",
386
+ "crm"}
387
+ _RISK_DELETE_ACTIONS = {"delete", "destroy", "drop", "purge", "archive"}
388
+ _RISK_MED_SEND_ACTIONS = {"send", "notify", "dispatch", "emit", "broadcast",
389
+ "alert", "publish"}
390
+ _RISK_MED_DOMAINS = {"email", "sms", "notification", "notifications",
391
+ "webhook", "webhooks", "slack", "ses", "sns"}
392
+ _RISK_MED_UPDATE_ACTIONS = {"update", "modify", "patch", "upsert", "edit",
393
+ "set"}
394
+ _SCORE_MAX_ROWS = 30
395
+ _SCORE_GAPS = [
396
+ "ORM .save() / .delete() on receivers the scanner cannot name as db-hinted",
397
+ "raw SQL strings built dynamically or passed through helpers",
398
+ "boto3 wrappers and shared AWS clients that rename API calls",
399
+ "Celery tasks, Dramatiq, RQ -- writes inside deferred callables",
400
+ "indirect writes through shared HTTP clients several layers down",
401
+ ]
402
+
403
+
404
+ def _risk_tier(verb):
405
+ """Classify an inferred verb (domain.action) into high/medium/low risk.
406
+
407
+ Sees only the verb string; errs toward higher risk on money, identity,
408
+ deletion, and infra patterns. Honest about being a heuristic: the
409
+ report lists exactly what the scanner found, nothing more.
410
+ """
411
+ domain, _, action = verb.partition(".")
412
+ domain, action = domain.lower(), action.lower()
413
+ if action in _RISK_HIGH_ACTIONS:
414
+ return "high"
415
+ if domain in _RISK_HIGH_DOMAINS:
416
+ return "high"
417
+ if action in _RISK_DELETE_ACTIONS and domain in _RISK_DATA_DOMAINS:
418
+ return "high"
419
+ if action in _RISK_MED_SEND_ACTIONS:
420
+ return "medium"
421
+ if domain in _RISK_MED_DOMAINS:
422
+ return "medium"
423
+ if action in _RISK_MED_UPDATE_ACTIONS and domain in _RISK_DATA_DOMAINS:
424
+ return "medium"
425
+ return "low"
426
+
427
+
428
+ def _score_report_rows(root, sites, gated_funcs):
429
+ """Return (rows, n_gated, total) for the score report.
430
+
431
+ rows: list of (tier, verb, rel_path, lineno, protected), sorted by
432
+ tier severity then path. A site is protected when its function already
433
+ has a gate call.
434
+ """
435
+ rows = []
436
+ for s in sites:
437
+ rel = _pathlib.Path(s.file).relative_to(root)
438
+ verb = infer_verb(rel, s.func, s.cls, s.kind)
439
+ rows.append((_risk_tier(verb), verb, str(rel), s.lineno,
440
+ (s.file, s.func) in gated_funcs))
441
+ order = {"high": 0, "medium": 1, "low": 2}
442
+ rows.sort(key=lambda r: (order[r[0]], r[2], r[3]))
443
+ return rows
444
+
445
+
446
+ def _writ_score(rows):
447
+ """0-100 from unprotected sites: -10 high, -5 medium, -2 low."""
448
+ penalty = sum({"high": 10, "medium": 5, "low": 2}[tier]
449
+ for tier, _, _, _, protected in rows if not protected)
450
+ return max(0, 100 - penalty)
451
+
452
+
453
+ def print_score_report(root, sites, gated_funcs):
454
+ """Print the Writ Score block. Pure read-only: no files written."""
455
+ rows = _score_report_rows(root, sites, gated_funcs)
456
+ total = len(rows)
457
+ score = _writ_score(rows)
458
+ n_gated = sum(1 for r in rows if r[4])
459
+ pct = (100 * n_gated // total) if total else 100
460
+
461
+ print()
462
+ print("Writ Score: %d/100" % score)
463
+ print()
464
+ if not total:
465
+ print("No write operations discovered.")
466
+ print()
467
+ else:
468
+ print("%d write operations discovered" % total)
469
+ print()
470
+ for tier, label in (("high", "HIGH RISK"), ("medium", "MEDIUM")):
471
+ tier_rows = [r for r in rows if r[0] == tier and not r[4]]
472
+ print("%s (%d)" % (label, len(tier_rows)))
473
+ for _, verb, rel, lineno, _ in tier_rows[:_SCORE_MAX_ROWS]:
474
+ print(" %-22s %s:%d" % (verb, rel, lineno))
475
+ if len(tier_rows) > _SCORE_MAX_ROWS:
476
+ print(" ... and %d more" % (len(tier_rows) - _SCORE_MAX_ROWS))
477
+ print()
478
+ n_low_unprot = sum(1 for r in rows if r[0] == "low" and not r[4])
479
+ print("UNPROTECTED (%d)" % n_low_unprot)
480
+ print(" %d write paths with no policy gate" % n_low_unprot)
481
+ print()
482
+ print("Coverage: %d/%d writes instrumented (%d%%)"
483
+ % (n_gated, total, pct))
484
+ print()
485
+ print("Not covered by this scanner (review by hand):")
486
+ for gap in _SCORE_GAPS:
487
+ print(" - %s" % gap)
488
+ print()
489
+ print("Scanner is free forever -- no API key needed to scan.")
490
+ print("Fix this with Writ: pip install pywrit && writ scan --apply")
491
+ return score
492
+
493
+
367
494
  def _should_skip(path, root, excludes):
368
495
  rel = path.relative_to(root)
369
496
  if any(p in _SKIP_DIR_NAMES or p.startswith(".") for p in rel.parts[:-1]):
@@ -523,6 +650,10 @@ def scan_cmd(args):
523
650
  verbs.setdefault(infer_verb(rel, func_name, cls_name, ss[0].kind),
524
651
  "require_grant")
525
652
 
653
+ if args.score:
654
+ print_score_report(root, sites, gated_funcs)
655
+ return 0
656
+
526
657
  print("writ scan: %s" % root)
527
658
  print(" files scanned: %d skipped: %d" % (len(files), len(skipped)))
528
659
  print(" write sites: %d in %d function(s)" % (total, len(by_func)))
@@ -532,6 +663,8 @@ def scan_cmd(args):
532
663
  for vname in sorted(verbs):
533
664
  print(" %s" % vname)
534
665
 
666
+ print_score_report(root, sites, gated_funcs)
667
+
535
668
  if verbs:
536
669
  ppath = _pathlib.Path(args.policy_out)
537
670
  ppath.write_text(_json.dumps(verbs, indent=2) + "\n")
@@ -587,6 +720,320 @@ def scan_cmd(args):
587
720
 
588
721
 
589
722
 
723
+ # Verbs that move money. Amounts are aggregated for these in reports.
724
+ MONEY_VERB_PATTERNS = (
725
+ "payments.",
726
+ "billing.",
727
+ "refund",
728
+ "payout",
729
+ "charge",
730
+ "transfer",
731
+ "invoice",
732
+ )
733
+
734
+
735
+ def request(method, path, payload=None, key=None, token=None):
736
+ headers = {"content-type": "application/json"}
737
+ if key:
738
+ headers["authorization"] = "Bearer " + key
739
+ elif token:
740
+ headers["authorization"] = token
741
+ data = None if payload is None else json.dumps(payload).encode()
742
+ req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
743
+ try:
744
+ with urllib.request.urlopen(req, timeout=20) as resp:
745
+ return 0, resp.read().decode()
746
+ except urllib.error.HTTPError as exc:
747
+ return exc.code, exc.read().decode()
748
+
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # Agent Action Report
752
+ # ---------------------------------------------------------------------------
753
+
754
+ def _is_money_verb(verb):
755
+ v = (verb or "").lower()
756
+ return any(p in v for p in MONEY_VERB_PATTERNS)
757
+
758
+
759
+ def _parse_amount(receipt):
760
+ """Extract a dollar amount from a receipt.
761
+
762
+ Checks explicit amount fields first, then parses common patterns from
763
+ the target string. Returns a float (dollars) or None.
764
+ """
765
+ # Explicit fields (dollars or cents)
766
+ for field in ("amount", "amountDollars", "amount_dollars"):
767
+ val = receipt.get(field)
768
+ if val is not None:
769
+ try:
770
+ return float(val)
771
+ except (TypeError, ValueError):
772
+ pass
773
+ for field in ("amountCents", "amount_cents"):
774
+ val = receipt.get(field)
775
+ if val is not None:
776
+ try:
777
+ return float(val) / 100.0
778
+ except (TypeError, ValueError):
779
+ pass
780
+ # Parse from target string: amount=5000, $50.00, 5000c, etc.
781
+ target = str(receipt.get("target", "") or "")
782
+ patterns = [
783
+ r"amount[=:]\s*\$?([\d,]+\.?\d*)", # amount=5000, amount:$50.00
784
+ r"\$\s*([\d,]+\.?\d*)", # $50.00
785
+ r"([\d,]+\.?\d*)\s*(?:usd|dollars?)", # 50 USD
786
+ r"([\d,]+)\s*cents?", # 5000 cents
787
+ ]
788
+ for pattern in patterns:
789
+ m = re.search(pattern, target, re.IGNORECASE)
790
+ if m:
791
+ try:
792
+ val = float(m.group(1).replace(",", ""))
793
+ if "cent" in pattern:
794
+ val = val / 100.0
795
+ return val
796
+ except ValueError:
797
+ continue
798
+ return None
799
+
800
+
801
+ def _load_receipts_from_file(path):
802
+ """Load receipts from a JSON file (array or JSON-lines)."""
803
+ receipts = []
804
+ with open(path, "r", encoding="utf-8") as f:
805
+ content = f.read().strip()
806
+ if not content:
807
+ return receipts
808
+ # Try JSON array first
809
+ try:
810
+ data = json.loads(content)
811
+ if isinstance(data, list):
812
+ return [r for r in data if isinstance(r, dict)]
813
+ if isinstance(data, dict):
814
+ # Maybe {"receipts": [...]}
815
+ inner = data.get("receipts", data.get("items", []))
816
+ if isinstance(inner, list):
817
+ return [r for r in inner if isinstance(r, dict)]
818
+ return [data]
819
+ except json.JSONDecodeError:
820
+ pass
821
+ # Fall back to JSON lines
822
+ for line in content.splitlines():
823
+ line = line.strip()
824
+ if not line:
825
+ continue
826
+ try:
827
+ r = json.loads(line)
828
+ if isinstance(r, dict):
829
+ receipts.append(r)
830
+ except json.JSONDecodeError:
831
+ continue
832
+ return receipts
833
+
834
+
835
+ def _fetch_receipts_from_api(key, agent=None, since=None, limit=1000):
836
+ """Fetch receipts from the Writ API."""
837
+ receipts = []
838
+ cursor = None
839
+ while len(receipts) < limit:
840
+ params = {"limit": min(100, limit - len(receipts))}
841
+ if cursor:
842
+ params["since"] = cursor
843
+ url = BASE + "/v1/receipts?" + urllib.parse.urlencode(params)
844
+ req = urllib.request.Request(
845
+ url, headers={"authorization": "Bearer " + key}, method="GET"
846
+ )
847
+ try:
848
+ with urllib.request.urlopen(req, timeout=20) as resp:
849
+ data = json.loads(resp.read().decode())
850
+ except urllib.error.HTTPError as exc:
851
+ print(f"API error {exc.code}: {exc.read().decode()}", file=sys.stderr)
852
+ sys.exit(1)
853
+ batch = data.get("receipts", data.get("items", []))
854
+ if not batch:
855
+ break
856
+ receipts.extend(batch)
857
+ cursor = data.get("cursor")
858
+ if not cursor or len(batch) < params["limit"]:
859
+ break
860
+ return receipts
861
+
862
+
863
+ def _filter_receipts(receipts, agent=None, since_ts=None):
864
+ out = []
865
+ for r in receipts:
866
+ if agent and r.get("agentId") != agent:
867
+ continue
868
+ if since_ts is not None:
869
+ try:
870
+ if int(r.get("createdAt", 0)) < since_ts:
871
+ continue
872
+ except (TypeError, ValueError):
873
+ pass
874
+ out.append(r)
875
+ return out
876
+
877
+
878
+ def _build_report(receipts, agent=None, period=None):
879
+ """Aggregate receipts into an executive summary dict."""
880
+ total = len(receipts)
881
+ allowed = sum(1 for r in receipts if r.get("decision") == "ALLOW")
882
+ denied = sum(1 for r in receipts if r.get("decision") == "DENY")
883
+ step_up = sum(1 for r in receipts if r.get("decision") == "STEP_UP")
884
+ revoked = sum(1 for r in receipts if r.get("decision") == "REVOKE")
885
+
886
+ # Per-verb breakdown
887
+ verbs = {}
888
+ for r in receipts:
889
+ verb = r.get("verb", "unknown")
890
+ entry = verbs.setdefault(verb, {"attempted": 0, "allowed": 0, "denied": 0})
891
+ entry["attempted"] += 1
892
+ if r.get("decision") == "ALLOW":
893
+ entry["allowed"] += 1
894
+ elif r.get("decision") == "DENY":
895
+ entry["denied"] += 1
896
+
897
+ # Money aggregation
898
+ money_attempted = 0.0
899
+ money_denied = 0.0
900
+ money_verbs = set()
901
+ for r in receipts:
902
+ if not _is_money_verb(r.get("verb", "")):
903
+ continue
904
+ amount = _parse_amount(r)
905
+ if amount is None:
906
+ continue
907
+ money_verbs.add(r.get("verb"))
908
+ money_attempted += amount
909
+ if r.get("decision") == "DENY":
910
+ money_denied += amount
911
+
912
+ # Unauthorized writes: DENY decisions are writes Writ stopped
913
+ unauthorized_blocked = denied
914
+
915
+ # Period label
916
+ if not period:
917
+ if receipts:
918
+ timestamps = []
919
+ for r in receipts:
920
+ try:
921
+ timestamps.append(int(r.get("createdAt", 0)))
922
+ except (TypeError, ValueError):
923
+ pass
924
+ if timestamps:
925
+ earliest = datetime.fromtimestamp(min(timestamps), tz=timezone.utc)
926
+ latest = datetime.fromtimestamp(max(timestamps), tz=timezone.utc)
927
+ if earliest.strftime("%Y-%m") == latest.strftime("%Y-%m"):
928
+ period = earliest.strftime("%B %Y")
929
+ else:
930
+ period = f"{earliest.strftime('%b %Y')} – {latest.strftime('%b %Y')}"
931
+ else:
932
+ period = "all time"
933
+ else:
934
+ period = "all time"
935
+
936
+ # Agent label
937
+ agents = sorted({r.get("agentId", "unknown") for r in receipts})
938
+ if agent:
939
+ agent_label = agent
940
+ elif len(agents) == 1:
941
+ agent_label = agents[0]
942
+ elif len(agents) > 1:
943
+ agent_label = f"{len(agents)} agents"
944
+ else:
945
+ agent_label = "—"
946
+
947
+ return {
948
+ "agent": agent_label,
949
+ "period": period,
950
+ "total": total,
951
+ "allowed": allowed,
952
+ "denied": denied,
953
+ "stepUp": step_up,
954
+ "revocations": revoked,
955
+ "unauthorizedBlocked": unauthorized_blocked,
956
+ "money": {
957
+ "verbs": sorted(money_verbs),
958
+ "attempted": round(money_attempted, 2),
959
+ "denied": round(money_denied, 2),
960
+ },
961
+ "verbs": verbs,
962
+ }
963
+
964
+
965
+ def _fmt_dollars(amount):
966
+ return f"${amount:,.0f}" if amount == int(amount) else f"${amount:,.2f}"
967
+
968
+
969
+ def _render_text(report):
970
+ lines = []
971
+ lines.append("Agent Action Report")
972
+ lines.append(f"Agent: {report['agent']}")
973
+ lines.append(f"Period: {report['period']}")
974
+ lines.append("")
975
+ lines.append(f"{report['total']:,} actions attempted")
976
+ lines.append(f"{report['allowed']:,} allowed")
977
+ lines.append(f"{report['denied']:,} denied")
978
+ if report["stepUp"]:
979
+ lines.append(f"{report['stepUp']:,} escalated for approval")
980
+ lines.append("")
981
+ money = report["money"]
982
+ if money["attempted"] > 0 or money["verbs"]:
983
+ verb_word = "refunds" if any("refund" in v for v in money["verbs"]) else "payments"
984
+ lines.append(f"{_fmt_dollars(money['attempted'])} {verb_word} attempted")
985
+ lines.append(f"{_fmt_dollars(money['denied'])} denied by policy")
986
+ lines.append("")
987
+ lines.append(f"{report['revocations']:,} permission revocations")
988
+ lines.append(f"{report['unauthorizedBlocked']:,} unauthorized writes blocked")
989
+ # Per-verb detail
990
+ if report["verbs"]:
991
+ lines.append("")
992
+ lines.append("By verb:")
993
+ for verb in sorted(report["verbs"]):
994
+ v = report["verbs"][verb]
995
+ lines.append(
996
+ f" {verb}: {v['attempted']:,} attempted, "
997
+ f"{v['allowed']:,} allowed, {v['denied']:,} denied"
998
+ )
999
+ return "\n".join(lines)
1000
+
1001
+
1002
+ def report_cmd(args):
1003
+ """Generate an Agent Action Report from receipts."""
1004
+ if args.file:
1005
+ receipts = _load_receipts_from_file(args.file)
1006
+ elif args.key:
1007
+ receipts = _fetch_receipts_from_api(args.key, agent=args.agent, limit=args.limit)
1008
+ else:
1009
+ print("error: provide --file or --key", file=sys.stderr)
1010
+ sys.exit(2)
1011
+
1012
+ since_ts = None
1013
+ if args.since:
1014
+ try:
1015
+ # Accept YYYY-MM-DD or a unix timestamp
1016
+ if re.match(r"^\d+$", args.since):
1017
+ since_ts = int(args.since)
1018
+ else:
1019
+ dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1020
+ since_ts = int(dt.timestamp())
1021
+ except ValueError:
1022
+ print(f"error: bad --since value {args.since!r} (use YYYY-MM-DD)", file=sys.stderr)
1023
+ sys.exit(2)
1024
+
1025
+ receipts = _filter_receipts(receipts, agent=args.agent, since_ts=since_ts)
1026
+ report = _build_report(receipts, agent=args.agent, period=args.period)
1027
+
1028
+ if args.format == "json":
1029
+ print(json.dumps(report, indent=2))
1030
+ else:
1031
+ print(_render_text(report))
1032
+ return 0
1033
+
1034
+
1035
+
1036
+
590
1037
  def main():
591
1038
  parser = argparse.ArgumentParser(description="Ask Writ before an agent writes.")
592
1039
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -673,11 +1120,25 @@ def main():
673
1120
  help="Write instrumentation into the repo")
674
1121
  scan.add_argument("--yes", action="store_true",
675
1122
  help="Apply without prompting")
1123
+ scan.add_argument("--score", action="store_true",
1124
+ help="Print only the Writ Score risk report (no diff, no policy file)")
676
1125
  scan.add_argument("--push-policy", action="store_true",
677
1126
  help="PUT the discovered policy to the gate (needs --key)")
678
1127
  scan.add_argument("--key", default="",
679
1128
  help="API key (for --push-policy)")
680
1129
 
1130
+ report = sub.add_parser("report", help="Agent Action Report from receipts")
1131
+ rsrc = report.add_mutually_exclusive_group(required=True)
1132
+ rsrc.add_argument("--key", help="API key — fetch receipts from the Writ API")
1133
+ rsrc.add_argument("--file", help="Read receipts from a local JSON file (array or JSON-lines)")
1134
+ report.add_argument("--agent", help="Filter to one agent ID")
1135
+ report.add_argument("--since", help="Only receipts after this date (YYYY-MM-DD) or unix timestamp")
1136
+ report.add_argument("--period", help="Period label (e.g. 'September 2026')")
1137
+ report.add_argument("--limit", type=int, default=1000,
1138
+ help="Max receipts to fetch from API (default 1000)")
1139
+ report.add_argument("--format", choices=("text", "json"), default="text",
1140
+ help="Output format (default: text)")
1141
+
681
1142
  args = parser.parse_args()
682
1143
  if args.cmd == "key":
683
1144
  payload = {"email": args.email}
@@ -730,6 +1191,8 @@ def main():
730
1191
  return request("POST", "/v1/tokens/verify", payload, key=args.key)
731
1192
  if args.cmd == "scan":
732
1193
  return scan_cmd(args)
1194
+ if args.cmd == "report":
1195
+ return report_cmd(args)
733
1196
  if args.cmd in ("revoke", "reinstate", "revoked"):
734
1197
  if not args.sponsor_token:
735
1198
  print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes