pywrit 0.1.4__tar.gz → 0.1.6__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.1.4
3
+ Version: 0.1.6
4
4
  Summary: Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log
5
5
  Project-URL: Homepage, https://withwrit.com
6
6
  Project-URL: Documentation, https://docs.withwrit.com
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # Distribution name: pywrit (approved by Chad 2026-09-27).
7
7
  # The bare name "writ" is taken on PyPI.
8
8
  name = "pywrit"
9
- version = "0.1.4"
9
+ version = "0.1.6"
10
10
  description = "Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Writ — the gate before the write. Allow/deny gate with a tamper-evident audit log
5
5
  Project-URL: Homepage, https://withwrit.com
6
6
  Project-URL: Documentation, https://docs.withwrit.com
@@ -10,4 +10,5 @@ pywrit.egg-info/top_level.txt
10
10
  tests/test_scan_tsjs.py
11
11
  writ_cli/__init__.py
12
12
  writ_cli/cli.py
13
+ writ_cli/config.py
13
14
  writ_cli/risk.py
@@ -15,30 +15,17 @@ import urllib.parse
15
15
  import urllib.request
16
16
  from datetime import datetime, timezone
17
17
 
18
+ try:
19
+ from writ_cli import config
20
+ except ImportError: # when cli.py is run directly as a script
21
+ import config
22
+
18
23
  BASE = os.environ.get(
19
24
  "WRIT_BASE",
20
- "https://api.withwrit.com",
25
+ config.base_url() or "https://api.withwrit.com",
21
26
  )
22
27
 
23
28
 
24
- def request(method, path, payload=None, key=None, token=None):
25
- headers = {"content-type": "application/json"}
26
- if key:
27
- headers["authorization"] = "Bearer " + key
28
- elif token:
29
- headers["authorization"] = token
30
- data = None if payload is None else json.dumps(payload).encode()
31
- req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
32
- try:
33
- with urllib.request.urlopen(req, timeout=20) as resp:
34
- body = resp.read().decode()
35
- print(body)
36
- return 0
37
- except urllib.error.HTTPError as exc:
38
- print(exc.read().decode(), file=sys.stderr)
39
- return exc.code
40
-
41
-
42
29
  def stream_cmd(args):
43
30
  """Tail the tenant audit log via the SSE stream, reconnecting on each
44
31
  long-poll expiry. Prints one line per receipt."""
@@ -11227,6 +11214,34 @@ def request(method, path, payload=None, key=None, token=None):
11227
11214
  return exc.code, exc.read().decode()
11228
11215
 
11229
11216
 
11217
+ def _request_and_print(method, path, payload=None, key=None, token=None):
11218
+ """Call request(), print the response, and return a clean exit code.
11219
+
11220
+ Success bodies go to stdout; error bodies go to stderr. This is the
11221
+ helper all simple dispatchers should use so the CLI exits 0 on success.
11222
+ """
11223
+ code, text = request(method, path, payload, key=key, token=token)
11224
+ if code == 0:
11225
+ print(text)
11226
+ return 0
11227
+ print(text, file=sys.stderr)
11228
+ return code
11229
+
11230
+
11231
+ def _require_key(key):
11232
+ if not key:
11233
+ print("error: no API key. Run `writ login --email you@example.com` or set WRIT_API_KEY.", file=sys.stderr)
11234
+ return False
11235
+ return True
11236
+
11237
+
11238
+ def _require_sponsor_token(token):
11239
+ if not token:
11240
+ print("error: no sponsor token. Run `writ login` or set WRIT_SPONSOR_TOKEN.", file=sys.stderr)
11241
+ return False
11242
+ return True
11243
+
11244
+
11230
11245
  # ---------------------------------------------------------------------------
11231
11246
  # Agent Action Report
11232
11247
  # ---------------------------------------------------------------------------
@@ -11523,17 +11538,28 @@ def main():
11523
11538
  key.add_argument("--tenant-name", default="", help="Optional tenant name (defaults to email domain)")
11524
11539
  key.add_argument("--invite-code", default="", help="Join an existing tenant instead of creating one")
11525
11540
 
11541
+ login = sub.add_parser("login", help="Create or join a tenant and save credentials to ~/.writ/config.json")
11542
+ login.add_argument("--email", required=True)
11543
+ login.add_argument("--tenant-name", default="", help="Optional tenant name (defaults to email domain)")
11544
+ login.add_argument("--invite-code", default="", help="Join an existing tenant instead of creating one")
11545
+
11546
+ cfg = sub.add_parser("config", help="Read or write ~/.writ/config.json")
11547
+ cfg.add_argument("action", choices=("show", "get", "set", "remove"), default="show", nargs="?",
11548
+ help="Action (default: show)")
11549
+ cfg.add_argument("name", default="", help="Config key")
11550
+ cfg.add_argument("value", default="", help="Config value (for set)")
11551
+
11526
11552
  me = sub.add_parser("me", help="Show tenant, key meta, and policy")
11527
- me.add_argument("--key", required=True)
11553
+ me.add_argument("--key", default=config.api_key())
11528
11554
 
11529
11555
  MODES = ("allow", "deny", "require_grant", "step_up")
11530
11556
  policy = sub.add_parser("policy", help="Get or set tenant verb policy")
11531
- policy.add_argument("--key", required=True)
11557
+ policy.add_argument("--key", default=config.api_key())
11532
11558
  policy.add_argument("--set", nargs=2, metavar=("VERB", "MODE"), action="append", default=[])
11533
11559
  policy.add_argument("--env", default="", help="Environment override to get/set: dev, staging, or prod (default: tenant policy)")
11534
11560
 
11535
11561
  check = sub.add_parser("check", help="ALLOW or DENY a write")
11536
- check.add_argument("--key", required=True)
11562
+ check.add_argument("--key", default=config.api_key())
11537
11563
  check.add_argument("--sponsor", required=True)
11538
11564
  check.add_argument("--agent", required=True)
11539
11565
  check.add_argument("--verb", required=True)
@@ -11551,30 +11577,30 @@ def main():
11551
11577
  revoke.add_argument("--sponsor", required=True)
11552
11578
  revoke.add_argument("--agent", required=True)
11553
11579
  revoke.add_argument("--reason", default="")
11554
- revoke.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
11580
+ revoke.add_argument("--sponsor-token", default=config.sponsor_token())
11555
11581
 
11556
11582
  reinstate = sub.add_parser("reinstate", help="Undo a revocation (sponsor token)")
11557
11583
  reinstate.add_argument("--sponsor", required=True)
11558
11584
  reinstate.add_argument("--agent", required=True)
11559
- reinstate.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
11585
+ reinstate.add_argument("--sponsor-token", default=config.sponsor_token())
11560
11586
 
11561
11587
  revoked = sub.add_parser("revoked", help="List revoked principals (sponsor token)")
11562
- revoked.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
11588
+ revoked.add_argument("--sponsor-token", default=config.sponsor_token())
11563
11589
 
11564
11590
  receipts = sub.add_parser("receipts", help="Latest receipts for your tenant")
11565
- receipts.add_argument("--key", required=True)
11591
+ receipts.add_argument("--key", default=config.api_key())
11566
11592
 
11567
11593
  stream = sub.add_parser("stream", help="Tail the live audit log (SSE stream)")
11568
- stream.add_argument("--key", required=True)
11594
+ stream.add_argument("--key", default=config.api_key())
11569
11595
  stream.add_argument("--since", default="", help="receiptId to resume after")
11570
11596
  stream.add_argument("--timeout", type=int, default=600, help="Seconds to stream before exiting (0 = forever)")
11571
11597
 
11572
11598
  verify = sub.add_parser("verify-chain", help="Verify the tenant audit-log hash chain")
11573
- verify.add_argument("--key", required=True)
11599
+ verify.add_argument("--key", default=config.api_key())
11574
11600
  verify.add_argument("--limit", type=int, default=200, help="Max receipts to verify (1-1000)")
11575
11601
 
11576
11602
  invite = sub.add_parser("invite", help="Create a team invite code for your tenant")
11577
- invite.add_argument("--key", required=True)
11603
+ invite.add_argument("--key", default=config.api_key())
11578
11604
 
11579
11605
  grant = sub.add_parser("grant", help="Sponsor approval: grant one write (sponsor token)")
11580
11606
  grant.add_argument("--sponsor", required=True)
@@ -11583,10 +11609,10 @@ def main():
11583
11609
  grant.add_argument("--target", required=True)
11584
11610
  grant.add_argument("--purpose", required=True)
11585
11611
  grant.add_argument("--ttl", type=int, default=90, help="Seconds the grant stays valid (1-3600)")
11586
- grant.add_argument("--sponsor-token", default=os.environ.get("WRIT_SPONSOR_TOKEN", ""))
11612
+ grant.add_argument("--sponsor-token", default=config.sponsor_token())
11587
11613
 
11588
11614
  tv = sub.add_parser("token-verify", help="Validate an ALLOW auth token")
11589
- tv.add_argument("--key", required=True)
11615
+ tv.add_argument("--key", default=config.api_key())
11590
11616
  tv.add_argument("--token", required=True)
11591
11617
  tv.add_argument("--verb", default=None)
11592
11618
  tv.add_argument("--target", default=None)
@@ -11610,7 +11636,7 @@ def main():
11610
11636
  help="Fetch the tenant's verb manifest (GET /v1/verbs) and "
11611
11637
  "map inferred verbs onto custom verbs when the action "
11612
11638
  "matches exactly one (needs --key)")
11613
- scan.add_argument("--key", default="",
11639
+ scan.add_argument("--key", default=config.api_key(),
11614
11640
  help="API key (for --push-policy / --manifest-out)")
11615
11641
  scan.add_argument("--manifest-out", default="",
11616
11642
  help="Write a signed CI attestation manifest to PATH")
@@ -11623,7 +11649,7 @@ def main():
11623
11649
 
11624
11650
  report = sub.add_parser("report", help="Agent Action Report from receipts")
11625
11651
  rsrc = report.add_mutually_exclusive_group(required=True)
11626
- rsrc.add_argument("--key", help="API key — fetch receipts from the Writ API")
11652
+ rsrc.add_argument("--key", default=config.api_key(), help="API key — fetch receipts from the Writ API")
11627
11653
  rsrc.add_argument("--file", help="Read receipts from a local JSON file (array or JSON-lines)")
11628
11654
  report.add_argument("--agent", help="Filter to one agent ID")
11629
11655
  report.add_argument("--since", help="Only receipts after this date (YYYY-MM-DD) or unix timestamp")
@@ -11634,7 +11660,7 @@ def main():
11634
11660
  help="Output format (default: text)")
11635
11661
 
11636
11662
  manifest_key = sub.add_parser("manifest-key", help="Fetch the tenant's CI manifest HMAC key")
11637
- manifest_key.add_argument("--key", required=True)
11663
+ manifest_key.add_argument("--key", default=config.api_key())
11638
11664
 
11639
11665
  args = parser.parse_args()
11640
11666
  if args.cmd == "key":
@@ -11643,14 +11669,54 @@ def main():
11643
11669
  payload["tenantName"] = args.tenant_name.strip()
11644
11670
  if args.invite_code.strip():
11645
11671
  payload["inviteCode"] = args.invite_code.strip()
11646
- return request("POST", "/v1/keys", payload)
11672
+ return _request_and_print("POST", "/v1/keys", payload)
11673
+ if args.cmd == "login":
11674
+ payload = {"email": args.email}
11675
+ if args.tenant_name.strip():
11676
+ payload["tenantName"] = args.tenant_name.strip()
11677
+ if args.invite_code.strip():
11678
+ payload["inviteCode"] = args.invite_code.strip()
11679
+ code, text = request("POST", "/v1/keys", payload)
11680
+ if code != 0:
11681
+ print(text, file=sys.stderr)
11682
+ return code
11683
+ data = json.loads(text)
11684
+ config.set_item("apiKey", data.get("apiKey", ""))
11685
+ config.set_item("sponsorToken", data.get("sponsorToken", ""))
11686
+ if data.get("tenantId"):
11687
+ print("Tenant: %s" % data["tenantId"])
11688
+ print("API key and sponsor token saved to ~/.writ/config.json")
11689
+ return 0
11690
+ if args.cmd == "config":
11691
+ if args.action == "show":
11692
+ print(json.dumps(config.read_config(), indent=2))
11693
+ elif args.action == "get":
11694
+ print(config.get(args.name, ""))
11695
+ elif args.action == "set":
11696
+ if not args.name:
11697
+ print("error: config set needs a name.", file=sys.stderr)
11698
+ return 2
11699
+ config.set_item(args.name, args.value)
11700
+ print("set %s" % args.name)
11701
+ elif args.action == "remove":
11702
+ if not args.name:
11703
+ print("error: config remove needs a name.", file=sys.stderr)
11704
+ return 2
11705
+ config.set_item(args.name, None)
11706
+ print("removed %s" % args.name)
11707
+ return 0
11708
+
11709
+ _KEY_COMMANDS = {"me", "policy", "check", "receipts", "stream",
11710
+ "verify-chain", "invite", "token-verify", "manifest-key"}
11711
+ if args.cmd in _KEY_COMMANDS and not _require_key(args.key):
11712
+ return 2
11713
+
11647
11714
  if args.cmd == "invite":
11648
- return request("POST", "/v1/tenant/invites", key=args.key)
11715
+ return _request_and_print("POST", "/v1/tenant/invites", key=args.key)
11649
11716
  if args.cmd == "grant":
11650
- if not args.sponsor_token:
11651
- print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
11717
+ if not _require_sponsor_token(args.sponsor_token):
11652
11718
  return 2
11653
- return request(
11719
+ return _request_and_print(
11654
11720
  "POST",
11655
11721
  "/v1/grants",
11656
11722
  {
@@ -11664,7 +11730,7 @@ def main():
11664
11730
  token="Bearer " + args.sponsor_token,
11665
11731
  )
11666
11732
  if args.cmd == "me":
11667
- return request("GET", "/v1/me", key=args.key)
11733
+ return _request_and_print("GET", "/v1/me", key=args.key)
11668
11734
  if args.cmd == "policy":
11669
11735
  url = "/v1/policy" if not args.env else f"/v1/policies/environments/{args.env}"
11670
11736
  if args.set:
@@ -11672,45 +11738,39 @@ def main():
11672
11738
  if mode not in MODES:
11673
11739
  print(f"error: mode must be one of {', '.join(MODES)}", file=sys.stderr)
11674
11740
  return 2
11675
- return request("PUT", url, {verb: mode for verb, mode in args.set}, key=args.key)
11676
- return request("GET", url, key=args.key)
11741
+ return _request_and_print("PUT", url, {verb: mode for verb, mode in args.set}, key=args.key)
11742
+ return _request_and_print("GET", url, key=args.key)
11677
11743
  if args.cmd == "receipts":
11678
- return request("GET", "/v1/receipts", key=args.key)
11744
+ return _request_and_print("GET", "/v1/receipts", key=args.key)
11679
11745
  if args.cmd == "stream":
11680
11746
  return stream_cmd(args)
11681
11747
  if args.cmd == "verify-chain":
11682
- return request("GET", f"/v1/receipts/verify?limit={args.limit}", key=args.key)
11748
+ return _request_and_print("GET", f"/v1/receipts/verify?limit={args.limit}", key=args.key)
11683
11749
  if args.cmd == "token-verify":
11684
11750
  payload = {"token": args.token}
11685
11751
  for field in ("verb", "target", "purpose"):
11686
11752
  value = getattr(args, field)
11687
11753
  if value is not None:
11688
11754
  payload[field] = value
11689
- return request("POST", "/v1/tokens/verify", payload, key=args.key)
11755
+ return _request_and_print("POST", "/v1/tokens/verify", payload, key=args.key)
11690
11756
  if args.cmd == "scan":
11691
11757
  return scan_cmd(args)
11692
11758
  if args.cmd == "report":
11693
11759
  return report_cmd(args)
11694
11760
  if args.cmd == "manifest-key":
11695
- code, text = request("GET", "/v1/manifest-key", key=args.key)
11696
- if code == 0:
11697
- print(text)
11698
- return 0
11699
- print(text, file=sys.stderr)
11700
- return code
11761
+ return _request_and_print("GET", "/v1/manifest-key", key=args.key)
11701
11762
  if args.cmd in ("revoke", "reinstate", "revoked"):
11702
- if not args.sponsor_token:
11703
- print("error: sponsor token required (--sponsor-token or WRIT_SPONSOR_TOKEN)", file=sys.stderr)
11763
+ if not _require_sponsor_token(args.sponsor_token):
11704
11764
  return 2
11705
11765
  token = "Bearer " + args.sponsor_token
11706
11766
  if args.cmd == "revoked":
11707
- return request("GET", "/v1/revoked", token=token)
11767
+ return _request_and_print("GET", "/v1/revoked", token=token)
11708
11768
  payload = {"sponsorId": args.sponsor, "agentId": args.agent}
11709
11769
  if args.cmd == "revoke":
11710
11770
  if args.reason:
11711
11771
  payload["reason"] = args.reason
11712
- return request("POST", "/v1/revoke", payload, token=token)
11713
- return request("POST", "/v1/reinstate", payload, token=token)
11772
+ return _request_and_print("POST", "/v1/revoke", payload, token=token)
11773
+ return _request_and_print("POST", "/v1/reinstate", payload, token=token)
11714
11774
  body = {
11715
11775
  "sponsorId": args.sponsor,
11716
11776
  "agentId": args.agent,
@@ -11721,8 +11781,8 @@ def main():
11721
11781
  if args.cmd == "check" and args.environment:
11722
11782
  body["environment"] = args.environment
11723
11783
  if args.cmd == "sandbox":
11724
- return request("POST", "/v1/sandbox", body)
11725
- return request("POST", "/v1/check", body, key=args.key)
11784
+ return _request_and_print("POST", "/v1/sandbox", body)
11785
+ return _request_and_print("POST", "/v1/check", body, key=args.key)
11726
11786
 
11727
11787
 
11728
11788
  if __name__ == "__main__":
@@ -0,0 +1,65 @@
1
+ """Local CLI configuration: ~/.writ/config.json.
2
+
3
+ The CLI looks up credentials in this order:
4
+ 1. Explicit flags (--key, --sponsor-token)
5
+ 2. Environment variables (WRIT_API_KEY, WRIT_SPONSOR_TOKEN, WRIT_BASE)
6
+ 3. ~/.writ/config.json (apiKey, sponsorToken, baseUrl)
7
+ """
8
+
9
+ import json
10
+ import os
11
+
12
+
13
+ def _config_dir():
14
+ home = os.path.expanduser("~")
15
+ d = os.path.join(home, ".writ")
16
+ os.makedirs(d, exist_ok=True, mode=0o700)
17
+ return d
18
+
19
+
20
+ def _config_path():
21
+ return os.path.join(_config_dir(), "config.json")
22
+
23
+
24
+ def read_config():
25
+ path = _config_path()
26
+ if not os.path.exists(path):
27
+ return {}
28
+ try:
29
+ with open(path, "r", encoding="utf-8") as f:
30
+ return json.load(f)
31
+ except (ValueError, OSError):
32
+ return {}
33
+
34
+
35
+ def write_config(cfg):
36
+ path = _config_path()
37
+ with open(path, "w", encoding="utf-8") as f:
38
+ json.dump(cfg, f, indent=2)
39
+ f.write("\n")
40
+ os.chmod(path, 0o600)
41
+
42
+
43
+ def get(key, default=None):
44
+ return read_config().get(key, default)
45
+
46
+
47
+ def set_item(key, value):
48
+ cfg = read_config()
49
+ if value is None:
50
+ cfg.pop(key, None)
51
+ else:
52
+ cfg[key] = value
53
+ write_config(cfg)
54
+
55
+
56
+ def api_key():
57
+ return os.environ.get("WRIT_API_KEY") or get("apiKey", "")
58
+
59
+
60
+ def sponsor_token():
61
+ return os.environ.get("WRIT_SPONSOR_TOKEN") or get("sponsorToken", "")
62
+
63
+
64
+ def base_url():
65
+ return os.environ.get("WRIT_BASE") or get("baseUrl", "")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes