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.
- {pywrit-0.2.0/src/pywrit.egg-info → pywrit-0.2.2}/PKG-INFO +1 -1
- {pywrit-0.2.0 → pywrit-0.2.2}/pyproject.toml +1 -1
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit/__init__.py +1 -1
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit/cli.py +463 -0
- {pywrit-0.2.0 → pywrit-0.2.2/src/pywrit.egg-info}/PKG-INFO +1 -1
- {pywrit-0.2.0 → pywrit-0.2.2}/LICENSE +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/README.md +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/setup.cfg +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit/client.py +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit/errors.py +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit.egg-info/SOURCES.txt +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit.egg-info/dependency_links.txt +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit.egg-info/entry_points.txt +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit.egg-info/requires.txt +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/src/pywrit.egg-info/top_level.txt +0 -0
- {pywrit-0.2.0 → pywrit-0.2.2}/tests/test_client.py +0 -0
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|