pywrit 0.2.1__tar.gz → 0.2.3__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.1
3
+ Version: 0.2.3
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.1"
7
+ version = "0.2.3"
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.1"
30
+ __version__ = "0.2.3"
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,305 @@ 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
+ # ---------------------------------------------------------------------------
736
+ # Agent Action Report
737
+ # ---------------------------------------------------------------------------
738
+
739
+ def _is_money_verb(verb):
740
+ v = (verb or "").lower()
741
+ return any(p in v for p in MONEY_VERB_PATTERNS)
742
+
743
+
744
+ def _parse_amount(receipt):
745
+ """Extract a dollar amount from a receipt.
746
+
747
+ Checks explicit amount fields first, then parses common patterns from
748
+ the target string. Returns a float (dollars) or None.
749
+ """
750
+ # Explicit fields (dollars or cents)
751
+ for field in ("amount", "amountDollars", "amount_dollars"):
752
+ val = receipt.get(field)
753
+ if val is not None:
754
+ try:
755
+ return float(val)
756
+ except (TypeError, ValueError):
757
+ pass
758
+ for field in ("amountCents", "amount_cents"):
759
+ val = receipt.get(field)
760
+ if val is not None:
761
+ try:
762
+ return float(val) / 100.0
763
+ except (TypeError, ValueError):
764
+ pass
765
+ # Parse from target string: amount=5000, $50.00, 5000c, etc.
766
+ target = str(receipt.get("target", "") or "")
767
+ patterns = [
768
+ r"amount[=:]\s*\$?([\d,]+\.?\d*)", # amount=5000, amount:$50.00
769
+ r"\$\s*([\d,]+\.?\d*)", # $50.00
770
+ r"([\d,]+\.?\d*)\s*(?:usd|dollars?)", # 50 USD
771
+ r"([\d,]+)\s*cents?", # 5000 cents
772
+ ]
773
+ for pattern in patterns:
774
+ m = re.search(pattern, target, re.IGNORECASE)
775
+ if m:
776
+ try:
777
+ val = float(m.group(1).replace(",", ""))
778
+ if "cent" in pattern:
779
+ val = val / 100.0
780
+ return val
781
+ except ValueError:
782
+ continue
783
+ return None
784
+
785
+
786
+ def _load_receipts_from_file(path):
787
+ """Load receipts from a JSON file (array or JSON-lines)."""
788
+ receipts = []
789
+ with open(path, "r", encoding="utf-8") as f:
790
+ content = f.read().strip()
791
+ if not content:
792
+ return receipts
793
+ # Try JSON array first
794
+ try:
795
+ data = json.loads(content)
796
+ if isinstance(data, list):
797
+ return [r for r in data if isinstance(r, dict)]
798
+ if isinstance(data, dict):
799
+ # Maybe {"receipts": [...]}
800
+ inner = data.get("receipts", data.get("items", []))
801
+ if isinstance(inner, list):
802
+ return [r for r in inner if isinstance(r, dict)]
803
+ return [data]
804
+ except json.JSONDecodeError:
805
+ pass
806
+ # Fall back to JSON lines
807
+ for line in content.splitlines():
808
+ line = line.strip()
809
+ if not line:
810
+ continue
811
+ try:
812
+ r = json.loads(line)
813
+ if isinstance(r, dict):
814
+ receipts.append(r)
815
+ except json.JSONDecodeError:
816
+ continue
817
+ return receipts
818
+
819
+
820
+ def _fetch_receipts_from_api(key, agent=None, since=None, limit=1000):
821
+ """Fetch receipts from the Writ API."""
822
+ receipts = []
823
+ cursor = None
824
+ while len(receipts) < limit:
825
+ params = {"limit": min(100, limit - len(receipts))}
826
+ if cursor:
827
+ params["since"] = cursor
828
+ url = BASE + "/v1/receipts?" + urllib.parse.urlencode(params)
829
+ req = urllib.request.Request(
830
+ url, headers={"authorization": "Bearer " + key}, method="GET"
831
+ )
832
+ try:
833
+ with urllib.request.urlopen(req, timeout=20) as resp:
834
+ data = json.loads(resp.read().decode())
835
+ except urllib.error.HTTPError as exc:
836
+ print(f"API error {exc.code}: {exc.read().decode()}", file=sys.stderr)
837
+ sys.exit(1)
838
+ batch = data.get("receipts", data.get("items", []))
839
+ if not batch:
840
+ break
841
+ receipts.extend(batch)
842
+ cursor = data.get("cursor")
843
+ if not cursor or len(batch) < params["limit"]:
844
+ break
845
+ return receipts
846
+
847
+
848
+ def _filter_receipts(receipts, agent=None, since_ts=None):
849
+ out = []
850
+ for r in receipts:
851
+ if agent and r.get("agentId") != agent:
852
+ continue
853
+ if since_ts is not None:
854
+ try:
855
+ if int(r.get("createdAt", 0)) < since_ts:
856
+ continue
857
+ except (TypeError, ValueError):
858
+ pass
859
+ out.append(r)
860
+ return out
861
+
862
+
863
+ def _build_report(receipts, agent=None, period=None):
864
+ """Aggregate receipts into an executive summary dict."""
865
+ total = len(receipts)
866
+ allowed = sum(1 for r in receipts if r.get("decision") == "ALLOW")
867
+ denied = sum(1 for r in receipts if r.get("decision") == "DENY")
868
+ step_up = sum(1 for r in receipts if r.get("decision") == "STEP_UP")
869
+ revoked = sum(1 for r in receipts if r.get("decision") == "REVOKE")
870
+
871
+ # Per-verb breakdown
872
+ verbs = {}
873
+ for r in receipts:
874
+ verb = r.get("verb", "unknown")
875
+ entry = verbs.setdefault(verb, {"attempted": 0, "allowed": 0, "denied": 0})
876
+ entry["attempted"] += 1
877
+ if r.get("decision") == "ALLOW":
878
+ entry["allowed"] += 1
879
+ elif r.get("decision") == "DENY":
880
+ entry["denied"] += 1
881
+
882
+ # Money aggregation
883
+ money_attempted = 0.0
884
+ money_denied = 0.0
885
+ money_verbs = set()
886
+ for r in receipts:
887
+ if not _is_money_verb(r.get("verb", "")):
888
+ continue
889
+ amount = _parse_amount(r)
890
+ if amount is None:
891
+ continue
892
+ money_verbs.add(r.get("verb"))
893
+ money_attempted += amount
894
+ if r.get("decision") == "DENY":
895
+ money_denied += amount
896
+
897
+ # Unauthorized writes: DENY decisions are writes Writ stopped
898
+ unauthorized_blocked = denied
899
+
900
+ # Period label
901
+ if not period:
902
+ if receipts:
903
+ timestamps = []
904
+ for r in receipts:
905
+ try:
906
+ timestamps.append(int(r.get("createdAt", 0)))
907
+ except (TypeError, ValueError):
908
+ pass
909
+ if timestamps:
910
+ earliest = datetime.fromtimestamp(min(timestamps), tz=timezone.utc)
911
+ latest = datetime.fromtimestamp(max(timestamps), tz=timezone.utc)
912
+ if earliest.strftime("%Y-%m") == latest.strftime("%Y-%m"):
913
+ period = earliest.strftime("%B %Y")
914
+ else:
915
+ period = f"{earliest.strftime('%b %Y')} – {latest.strftime('%b %Y')}"
916
+ else:
917
+ period = "all time"
918
+ else:
919
+ period = "all time"
920
+
921
+ # Agent label
922
+ agents = sorted({r.get("agentId", "unknown") for r in receipts})
923
+ if agent:
924
+ agent_label = agent
925
+ elif len(agents) == 1:
926
+ agent_label = agents[0]
927
+ elif len(agents) > 1:
928
+ agent_label = f"{len(agents)} agents"
929
+ else:
930
+ agent_label = "—"
931
+
932
+ return {
933
+ "agent": agent_label,
934
+ "period": period,
935
+ "total": total,
936
+ "allowed": allowed,
937
+ "denied": denied,
938
+ "stepUp": step_up,
939
+ "revocations": revoked,
940
+ "unauthorizedBlocked": unauthorized_blocked,
941
+ "money": {
942
+ "verbs": sorted(money_verbs),
943
+ "attempted": round(money_attempted, 2),
944
+ "denied": round(money_denied, 2),
945
+ },
946
+ "verbs": verbs,
947
+ }
948
+
949
+
950
+ def _fmt_dollars(amount):
951
+ return f"${amount:,.0f}" if amount == int(amount) else f"${amount:,.2f}"
952
+
953
+
954
+ def _render_text(report):
955
+ lines = []
956
+ lines.append("Agent Action Report")
957
+ lines.append(f"Agent: {report['agent']}")
958
+ lines.append(f"Period: {report['period']}")
959
+ lines.append("")
960
+ lines.append(f"{report['total']:,} actions attempted")
961
+ lines.append(f"{report['allowed']:,} allowed")
962
+ lines.append(f"{report['denied']:,} denied")
963
+ if report["stepUp"]:
964
+ lines.append(f"{report['stepUp']:,} escalated for approval")
965
+ lines.append("")
966
+ money = report["money"]
967
+ if money["attempted"] > 0 or money["verbs"]:
968
+ verb_word = "refunds" if any("refund" in v for v in money["verbs"]) else "payments"
969
+ lines.append(f"{_fmt_dollars(money['attempted'])} {verb_word} attempted")
970
+ lines.append(f"{_fmt_dollars(money['denied'])} denied by policy")
971
+ lines.append("")
972
+ lines.append(f"{report['revocations']:,} permission revocations")
973
+ lines.append(f"{report['unauthorizedBlocked']:,} unauthorized writes blocked")
974
+ # Per-verb detail
975
+ if report["verbs"]:
976
+ lines.append("")
977
+ lines.append("By verb:")
978
+ for verb in sorted(report["verbs"]):
979
+ v = report["verbs"][verb]
980
+ lines.append(
981
+ f" {verb}: {v['attempted']:,} attempted, "
982
+ f"{v['allowed']:,} allowed, {v['denied']:,} denied"
983
+ )
984
+ return "\n".join(lines)
985
+
986
+
987
+ def report_cmd(args):
988
+ """Generate an Agent Action Report from receipts."""
989
+ if args.file:
990
+ receipts = _load_receipts_from_file(args.file)
991
+ elif args.key:
992
+ receipts = _fetch_receipts_from_api(args.key, agent=args.agent, limit=args.limit)
993
+ else:
994
+ print("error: provide --file or --key", file=sys.stderr)
995
+ sys.exit(2)
996
+
997
+ since_ts = None
998
+ if args.since:
999
+ try:
1000
+ # Accept YYYY-MM-DD or a unix timestamp
1001
+ if re.match(r"^\d+$", args.since):
1002
+ since_ts = int(args.since)
1003
+ else:
1004
+ dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1005
+ since_ts = int(dt.timestamp())
1006
+ except ValueError:
1007
+ print(f"error: bad --since value {args.since!r} (use YYYY-MM-DD)", file=sys.stderr)
1008
+ sys.exit(2)
1009
+
1010
+ receipts = _filter_receipts(receipts, agent=args.agent, since_ts=since_ts)
1011
+ report = _build_report(receipts, agent=args.agent, period=args.period)
1012
+
1013
+ if args.format == "json":
1014
+ print(json.dumps(report, indent=2))
1015
+ else:
1016
+ print(_render_text(report))
1017
+ return 0
1018
+
1019
+
1020
+
1021
+
590
1022
  def main():
591
1023
  parser = argparse.ArgumentParser(description="Ask Writ before an agent writes.")
592
1024
  sub = parser.add_subparsers(dest="cmd", required=True)
@@ -673,11 +1105,25 @@ def main():
673
1105
  help="Write instrumentation into the repo")
674
1106
  scan.add_argument("--yes", action="store_true",
675
1107
  help="Apply without prompting")
1108
+ scan.add_argument("--score", action="store_true",
1109
+ help="Print only the Writ Score risk report (no diff, no policy file)")
676
1110
  scan.add_argument("--push-policy", action="store_true",
677
1111
  help="PUT the discovered policy to the gate (needs --key)")
678
1112
  scan.add_argument("--key", default="",
679
1113
  help="API key (for --push-policy)")
680
1114
 
1115
+ report = sub.add_parser("report", help="Agent Action Report from receipts")
1116
+ rsrc = report.add_mutually_exclusive_group(required=True)
1117
+ rsrc.add_argument("--key", help="API key — fetch receipts from the Writ API")
1118
+ rsrc.add_argument("--file", help="Read receipts from a local JSON file (array or JSON-lines)")
1119
+ report.add_argument("--agent", help="Filter to one agent ID")
1120
+ report.add_argument("--since", help="Only receipts after this date (YYYY-MM-DD) or unix timestamp")
1121
+ report.add_argument("--period", help="Period label (e.g. 'September 2026')")
1122
+ report.add_argument("--limit", type=int, default=1000,
1123
+ help="Max receipts to fetch from API (default 1000)")
1124
+ report.add_argument("--format", choices=("text", "json"), default="text",
1125
+ help="Output format (default: text)")
1126
+
681
1127
  args = parser.parse_args()
682
1128
  if args.cmd == "key":
683
1129
  payload = {"email": args.email}
@@ -730,6 +1176,8 @@ def main():
730
1176
  return request("POST", "/v1/tokens/verify", payload, key=args.key)
731
1177
  if args.cmd == "scan":
732
1178
  return scan_cmd(args)
1179
+ if args.cmd == "report":
1180
+ return report_cmd(args)
733
1181
  if args.cmd in ("revoke", "reinstate", "revoked"):
734
1182
  if not args.sponsor_token:
735
1183
  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.1
3
+ Version: 0.2.3
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