pywrit 0.2.4__tar.gz → 0.2.5__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,15 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.4
3
+ Version: 0.2.5
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
7
7
  Project-URL: Documentation, https://withwrit.com/docs
8
- Keywords: ai-agents,policy,audit-log,guardrails
9
8
  Requires-Python: >=3.9
10
9
  Description-Content-Type: text/markdown
11
10
  License-File: LICENSE
12
11
  Requires-Dist: httpx>=0.27
12
+ Provides-Extra: polyglot
13
+ Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
14
+ Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
15
+ Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
16
+ Provides-Extra: keywords
17
+ Requires-Dist: ai-agents; extra == "keywords"
18
+ Requires-Dist: policy; extra == "keywords"
19
+ Requires-Dist: audit-log; extra == "keywords"
20
+ Requires-Dist: guardrails; extra == "keywords"
13
21
  Dynamic: license-file
14
22
 
15
23
  # pywrit
@@ -114,13 +122,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
114
122
  calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
115
123
  several layers down. The risk report lists these gaps every time.
116
124
 
117
- ### TypeScript / JavaScript: coming soon (not yet released)
125
+ ### TypeScript / JavaScript (scan-only)
118
126
 
119
- > **Not available in `pywrit` 0.2.2 or on PyPI yet.** The current release scans
120
- > Python only. The preview below shows scan-only TS/JS support that is still in
121
- > development; the command it uses is not available yet.
127
+ `writ scan` also finds write sites in TypeScript and JavaScript. It needs the
128
+ optional extra (tree-sitter based):
122
129
 
123
- ![Preview, not yet released: writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
130
+ pip install 'pywrit[polyglot]'
131
+ writ scan .
132
+
133
+ Without the extra, the scanner prints a one-line hint and keeps going —
134
+ Python scanning never needs it.
135
+
136
+ TS/JS is **scan-only**: findings are listed with verbs for your policy file,
137
+ but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
138
+ Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
139
+ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
140
+ (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
141
+ — see "Not covered" above.)
142
+
143
+ ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
124
144
 
125
145
  ## Python client
126
146
 
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: pywrit
3
- Version: 0.2.4
4
- Summary: Python client + CLI for Writ — the gate before the write
5
- License: MIT
6
- Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
- Project-URL: Documentation, https://withwrit.com/docs
8
- Keywords: ai-agents,policy,audit-log,guardrails
9
- Requires-Python: >=3.9
10
- Description-Content-Type: text/markdown
11
- License-File: LICENSE
12
- Requires-Dist: httpx>=0.27
13
- Dynamic: license-file
14
-
15
1
  # pywrit
16
2
 
17
3
  **Find the dangerous writes in your Python agent's code, then gate it.** `pywrit` is the Python
@@ -114,13 +100,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
114
100
  calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
115
101
  several layers down. The risk report lists these gaps every time.
116
102
 
117
- ### TypeScript / JavaScript: coming soon (not yet released)
103
+ ### TypeScript / JavaScript (scan-only)
104
+
105
+ `writ scan` also finds write sites in TypeScript and JavaScript. It needs the
106
+ optional extra (tree-sitter based):
107
+
108
+ pip install 'pywrit[polyglot]'
109
+ writ scan .
110
+
111
+ Without the extra, the scanner prints a one-line hint and keeps going —
112
+ Python scanning never needs it.
118
113
 
119
- > **Not available in `pywrit` 0.2.2 or on PyPI yet.** The current release scans
120
- > Python only. The preview below shows scan-only TS/JS support that is still in
121
- > development; the command it uses is not available yet.
114
+ TS/JS is **scan-only**: findings are listed with verbs for your policy file,
115
+ but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
116
+ Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
117
+ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
118
+ (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
119
+ — see "Not covered" above.)
122
120
 
123
- ![Preview, not yet released: writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
121
+ ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
124
122
 
125
123
  ## Python client
126
124
 
@@ -4,12 +4,21 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywrit"
7
- version = "0.2.4"
7
+ version = "0.2.5"
8
8
  description = "Python client + CLI for Writ — the gate before the write"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.9"
12
12
  dependencies = ["httpx>=0.27"]
13
+
14
+ [project.optional-dependencies]
15
+ # `pip install 'pywrit[polyglot]'`: lets `writ scan` parse TypeScript and
16
+ # JavaScript (scan only). Python scanning never needs it.
17
+ polyglot = [
18
+ "tree-sitter>=0.23",
19
+ "tree-sitter-typescript>=0.23",
20
+ "tree-sitter-javascript>=0.23",
21
+ ]
13
22
  keywords = ["ai-agents", "policy", "audit-log", "guardrails"]
14
23
 
15
24
  [project.urls]
@@ -27,7 +27,7 @@ from .errors import (
27
27
  WritError,
28
28
  )
29
29
 
30
- __version__ = "0.2.4"
30
+ __version__ = "0.2.5"
31
31
 
32
32
  __all__ = [
33
33
  "WritClient",
@@ -610,6 +610,468 @@ def _apply_edits(lines, edits):
610
610
  return out
611
611
 
612
612
 
613
+ # ---------------------------------------------------------------------------
614
+ # writ scan, TypeScript/JavaScript: deterministic and scan-only (TS/JS files
615
+ # are never rewritten). Parsing needs the optional extra
616
+ # `pip install 'pywrit[polyglot]'` (tree-sitter); without it the scanner
617
+ # prints a one-line hint and keeps going. Rules are data (_TSJS_RULES) so
618
+ # other languages can reuse the same (id, kind, callee patterns, condition)
619
+ # shape.
620
+ # ---------------------------------------------------------------------------
621
+ import re as _re
622
+ import subprocess as _subprocess
623
+
624
+ _TSJS_EXTS = {".ts": "typescript", ".tsx": "tsx", ".js": "javascript",
625
+ ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript"}
626
+ _TSJS_SKIP_DIR_NAMES = _SKIP_DIR_NAMES | {".next", "vendor", "venv", ".venv"}
627
+ _TSJS_TEST_DIRS = {"tests", "testing", "__tests__"}
628
+ _TSJS_MAX_BYTES = 1000000 # larger files are almost always bundles
629
+ _TSJS_HINT = ("hint: %d TS/JS file(s) not scanned; install the extra: "
630
+ "pip install 'pywrit[polyglot]'")
631
+ _TSJS_WRITE_METHODS = {"post", "put", "patch", "delete"}
632
+ _TSJS_SQL_WRITE = _re.compile(
633
+ r"^\s*\(*\s*(insert|update|delete|drop|alter|truncate|create|replace|"
634
+ r"merge)\b", _re.I)
635
+ _TSJS_GATE_NAMES = {"writ_check", "_writ_check", "writcheck"}
636
+
637
+ # import/require source -> canonical callee prefix
638
+ _TSJS_MODULES = {
639
+ "fs": ("fs",), "fs/promises": ("fs", "promises"), "fs-extra": ("fs",),
640
+ "graceful-fs": ("fs",), "child_process": ("child_process",),
641
+ "http": ("http",), "https": ("https",), "axios": ("axios",),
642
+ "got": ("got",), "ky": ("ky",), "superagent": ("superagent",),
643
+ "node-fetch": ("fetch",), "cross-fetch": ("fetch",),
644
+ "isomorphic-fetch": ("fetch",), "knex": ("knex",), "stripe": ("stripe",),
645
+ }
646
+
647
+ _FS_FNS = ("writeFile", "appendFile", "unlink", "rm", "rmdir", "rename",
648
+ "mkdir", "copyFile", "cp", "truncate", "symlink")
649
+ _TSJS_FS = "|".join(_FS_FNS + tuple(f + "Sync" for f in _FS_FNS)
650
+ + ("createWriteStream",))
651
+ _TSJS_DB_HINTS = "|".join(sorted(_DB_HINTS | {"knex", "trx", "pool"}))
652
+ _TSJS_DB_METHODS = ("insert|update|del|delete|upsert|truncate|save|remove|"
653
+ "insertOne|insertMany|updateOne|updateMany|deleteOne|"
654
+ "deleteMany|replaceOne|bulkWrite")
655
+ _TSJS_HTTP_HINTS = "|".join(sorted(_HTTP_HINTS | {"httpclient", "$http",
656
+ "request", "agent"}))
657
+ _TSJS_HTTP_VERBS = "post|put|patch|delete|del"
658
+
659
+ # (rule id, kind, callee patterns, condition). First match wins.
660
+ # Patterns are dotted callee chains (after import aliasing and dropping a
661
+ # leading `this`): `a|b` alternatives, `*` one segment, `**` any segments.
662
+ # Conditions: None, "method" (a literal write method in an options object
663
+ # or as a leading string arg), "sql" (first arg is a literal SQL write).
664
+ _TSJS_RULES = (
665
+ ("child-process", "exec",
666
+ ("child_process.exec|execSync|execFile|execFileSync|spawn|spawnSync|"
667
+ "fork",), None),
668
+ ("fs-write", "file",
669
+ ("fs." + _TSJS_FS, "fs.promises." + _TSJS_FS), None),
670
+ ("sql-write", "db",
671
+ ("**.query|execute|raw|exec|run|unsafe|$executeRaw|$executeRawUnsafe|"
672
+ "$queryRaw|$queryRawUnsafe|sql",), "sql"),
673
+ ("stripe-write", "sdk",
674
+ ("stripe.**.create|update|del|cancel|capture|confirm|pay",), None),
675
+ ("prisma-write", "db",
676
+ ("prisma.*.create|createMany|update|updateMany|upsert|delete|deleteMany",),
677
+ None),
678
+ ("db-write", "db",
679
+ ("%s.**.%s" % (_TSJS_DB_HINTS, _TSJS_DB_METHODS),
680
+ "**.%s.%s" % (_TSJS_DB_HINTS, _TSJS_DB_METHODS)), None),
681
+ ("fetch-write", "http",
682
+ ("fetch", "window|globalThis|self.fetch"), "method"),
683
+ ("http-client-write", "http",
684
+ ("axios|got|ky|superagent.**." + _TSJS_HTTP_VERBS,
685
+ "**.%s.%s" % (_TSJS_HTTP_HINTS, _TSJS_HTTP_VERBS)), None),
686
+ ("http-config-write", "http",
687
+ ("axios|got|ky|superagent", "axios|got|ky.request",
688
+ "http|https.request"), "method"),
689
+ )
690
+
691
+
692
+ def _tsjs_compile(pattern):
693
+ return tuple(s if s in ("*", "**") else
694
+ frozenset(a.lower() for a in s.split("|"))
695
+ for s in pattern.split("."))
696
+
697
+
698
+ _TSJS_COMPILED = tuple((rid, kind, tuple(_tsjs_compile(p) for p in pats), cond)
699
+ for rid, kind, pats, cond in _TSJS_RULES)
700
+
701
+
702
+ def _tsjs_glob(segs, chain):
703
+ if not segs:
704
+ return not chain
705
+ s = segs[0]
706
+ if s == "**":
707
+ return any(_tsjs_glob(segs[1:], chain[i:])
708
+ for i in range(len(chain) + 1))
709
+ if not chain or (s != "*" and chain[0] not in s):
710
+ return False
711
+ return _tsjs_glob(segs[1:], chain[1:])
712
+
713
+
714
+ def _tsjs_parsers():
715
+ """Return {grammar: Parser}, or {} when the polyglot extra is missing."""
716
+ try:
717
+ import tree_sitter as ts
718
+ except ImportError:
719
+ return {}
720
+ try:
721
+ import tree_sitter_typescript as tst
722
+ import tree_sitter_javascript as tsj
723
+ fns = {"typescript": tst.language_typescript,
724
+ "tsx": tst.language_tsx, "javascript": tsj.language}
725
+ except ImportError:
726
+ try:
727
+ from tree_sitter_language_pack import get_language
728
+ except ImportError:
729
+ return {}
730
+ fns = {k: (lambda k=k: get_language(k))
731
+ for k in ("typescript", "tsx", "javascript")}
732
+ parsers = {}
733
+ try:
734
+ for name, fn in fns.items():
735
+ lang = fn()
736
+ if not isinstance(lang, ts.Language):
737
+ lang = ts.Language(lang)
738
+ try:
739
+ parser = ts.Parser(lang)
740
+ except TypeError:
741
+ parser = ts.Parser()
742
+ parser.language = lang
743
+ parsers[name] = parser
744
+ except Exception:
745
+ return {}
746
+ return parsers
747
+
748
+
749
+ def _tsjs_should_skip(path, root, excludes):
750
+ rel = path.relative_to(root)
751
+ if any(p in _TSJS_SKIP_DIR_NAMES or p.startswith(".")
752
+ for p in rel.parts[:-1]):
753
+ return True
754
+ if any(p in _TSJS_TEST_DIRS for p in rel.parts[:-1]):
755
+ return True
756
+ name = path.name
757
+ if ".test." in name or ".spec." in name or name.endswith(
758
+ (".d.ts", ".min.js")):
759
+ return True
760
+ s = str(path)
761
+ return any(e in s for e in excludes)
762
+
763
+
764
+ def _tsjs_git_files(root):
765
+ """Tracked + untracked-not-ignored files under root, or None."""
766
+ try:
767
+ out = _subprocess.run(
768
+ ["git", "-C", str(root), "ls-files", "-co", "--exclude-standard",
769
+ "-z"], stdout=_subprocess.PIPE, stderr=_subprocess.DEVNULL,
770
+ timeout=60, check=False)
771
+ except (OSError, _subprocess.SubprocessError):
772
+ return None
773
+ if out.returncode != 0:
774
+ return None
775
+ return [r for r in out.stdout.decode("utf-8", "replace").split("\0") if r]
776
+
777
+
778
+ def _tsjs_collect_files(root, excludes):
779
+ rels = _tsjs_git_files(root)
780
+ if rels is None:
781
+ rels = []
782
+ for dp, dns, fns in os.walk(str(root)):
783
+ dns[:] = [d for d in dns if d not in _TSJS_SKIP_DIR_NAMES
784
+ and not d.startswith(".")]
785
+ rels.extend(os.path.relpath(os.path.join(dp, f), str(root))
786
+ for f in fns)
787
+ out = set()
788
+ for r in rels:
789
+ p = root / r
790
+ if p.suffix in _TSJS_EXTS and not _tsjs_should_skip(p, root, excludes) \
791
+ and p.is_file():
792
+ out.add(p)
793
+ return sorted(out)
794
+
795
+
796
+ _TSJS_FUNC_TYPES = {"function_declaration", "generator_function_declaration",
797
+ "function_expression", "function", "generator_function",
798
+ "arrow_function", "method_definition"}
799
+ _TSJS_CLASS_TYPES = {"class_declaration", "class",
800
+ "abstract_class_declaration"}
801
+ _TSJS_UNWRAP = {"parenthesized_expression", "await_expression",
802
+ "non_null_expression", "as_expression",
803
+ "satisfies_expression", "type_assertion"}
804
+
805
+
806
+ class _TsjsFile:
807
+ def __init__(self, src):
808
+ self.src = src
809
+
810
+ def text(self, node):
811
+ return self.src[node.start_byte:node.end_byte].decode("utf-8",
812
+ "replace")
813
+
814
+ def literal(self, node):
815
+ """String value of a string/template literal node, else None."""
816
+ if node is None:
817
+ return None
818
+ if node.type == "string":
819
+ return self.text(node)[1:-1]
820
+ if node.type == "template_string":
821
+ return self.text(node)[1:-1] # substitutions stay as ${...}
822
+ return None
823
+
824
+ def chain(self, node):
825
+ """Dotted callee chain, e.g. knex("t").where().del -> [knex(), where(), del]."""
826
+ while node is not None and node.type in _TSJS_UNWRAP:
827
+ kids = node.named_children
828
+ node = kids[-1] if node.type == "await_expression" and kids \
829
+ else (kids[0] if kids else None)
830
+ if node is None:
831
+ return ["?"]
832
+ t = node.type
833
+ if t in ("identifier", "property_identifier",
834
+ "private_property_identifier", "shorthand_property_identifier"):
835
+ return [self.text(node)]
836
+ if t == "this":
837
+ return ["this"]
838
+ if t == "member_expression":
839
+ prop = node.child_by_field_name("property")
840
+ return self.chain(node.child_by_field_name("object")) + \
841
+ [self.text(prop) if prop is not None else "?"]
842
+ if t == "call_expression":
843
+ c = self.chain(node.child_by_field_name("function"))
844
+ c[-1] = c[-1] + "()"
845
+ return c
846
+ return ["?"]
847
+
848
+
849
+ def _tsjs_func_name(node, f):
850
+ name = node.child_by_field_name("name")
851
+ if name is not None and node.type != "arrow_function":
852
+ return f.text(name)
853
+ parent = node.parent
854
+ if parent is None:
855
+ return None
856
+ pt = parent.type
857
+ if pt == "variable_declarator":
858
+ n = parent.child_by_field_name("name")
859
+ return f.text(n) if n is not None and n.type == "identifier" else None
860
+ if pt == "pair":
861
+ k = parent.child_by_field_name("key")
862
+ return (f.literal(k) or f.text(k)) if k is not None else None
863
+ if pt == "assignment_expression":
864
+ left = parent.child_by_field_name("left")
865
+ if left is not None and left.type == "member_expression":
866
+ left = left.child_by_field_name("property")
867
+ return f.text(left) if left is not None else None
868
+ if pt in ("public_field_definition", "field_definition"):
869
+ n = parent.child_by_field_name("name") or \
870
+ parent.child_by_field_name("property")
871
+ return f.text(n) if n is not None else None
872
+ return None
873
+
874
+
875
+ def _tsjs_module_prefix(source):
876
+ if source is None:
877
+ return None
878
+ if source.startswith("node:"):
879
+ source = source[5:]
880
+ return _TSJS_MODULES.get(source)
881
+
882
+
883
+ def _tsjs_record_import(node, f, aliases):
884
+ src = node.child_by_field_name("source")
885
+ prefix = _tsjs_module_prefix(f.literal(src))
886
+ if prefix is None:
887
+ return
888
+ for clause in node.named_children:
889
+ if clause.type != "import_clause":
890
+ continue
891
+ for part in clause.named_children:
892
+ if part.type == "identifier": # default import
893
+ aliases[f.text(part)] = prefix
894
+ elif part.type == "namespace_import":
895
+ ids = [c for c in part.named_children if c.type == "identifier"]
896
+ if ids:
897
+ aliases[f.text(ids[-1])] = prefix
898
+ elif part.type == "named_imports":
899
+ for spec in part.named_children:
900
+ if spec.type != "import_specifier":
901
+ continue
902
+ ids = [c for c in spec.named_children
903
+ if c.type in ("identifier", "string")]
904
+ if not ids:
905
+ continue
906
+ imported = f.literal(ids[0]) or f.text(ids[0])
907
+ local = f.text(ids[-1])
908
+ if imported == "default":
909
+ aliases[local] = prefix
910
+ else:
911
+ aliases[local] = prefix + (imported,)
912
+
913
+
914
+ def _tsjs_record_require(node, f, aliases):
915
+ """const x = require("m") / const {a, b: c} = require("m") / .prop"""
916
+ value = node.child_by_field_name("value")
917
+ extra = ()
918
+ if value is not None and value.type == "member_expression":
919
+ prop = value.child_by_field_name("property")
920
+ extra = (f.text(prop),) if prop is not None else ()
921
+ value = value.child_by_field_name("object")
922
+ if value is None or value.type != "call_expression":
923
+ return
924
+ fn = value.child_by_field_name("function")
925
+ if fn is None or f.text(fn) != "require":
926
+ return
927
+ args = value.child_by_field_name("arguments")
928
+ first = args.named_children[0] if args is not None and \
929
+ args.named_children else None
930
+ prefix = _tsjs_module_prefix(f.literal(first))
931
+ if prefix is None:
932
+ return
933
+ prefix = prefix + extra
934
+ name = node.child_by_field_name("name")
935
+ if name is None:
936
+ return
937
+ if name.type == "identifier":
938
+ aliases[f.text(name)] = prefix
939
+ elif name.type == "object_pattern":
940
+ for p in name.named_children:
941
+ if p.type == "shorthand_property_identifier_pattern":
942
+ aliases[f.text(p)] = prefix + (f.text(p),)
943
+ elif p.type == "pair_pattern":
944
+ k = p.child_by_field_name("key")
945
+ v = p.child_by_field_name("value")
946
+ if k is not None and v is not None and v.type == "identifier":
947
+ aliases[f.text(v)] = prefix + (f.text(k),)
948
+
949
+
950
+ def _tsjs_args(call):
951
+ args = call.child_by_field_name("arguments")
952
+ if args is None:
953
+ return []
954
+ if args.type == "template_string": # tagged template: sql`...`
955
+ return [args]
956
+ return [a for a in args.named_children if a.type != "comment"]
957
+
958
+
959
+ def _tsjs_has_write_method(call, f):
960
+ for a in _tsjs_args(call)[:2]:
961
+ lit = f.literal(a)
962
+ if lit is not None and lit.lower() in _TSJS_WRITE_METHODS:
963
+ return True
964
+ if a.type != "object":
965
+ continue
966
+ for pair in a.named_children:
967
+ if pair.type != "pair":
968
+ continue
969
+ k = pair.child_by_field_name("key")
970
+ key = (f.literal(k) or f.text(k)) if k is not None else ""
971
+ if key != "method":
972
+ continue
973
+ v = f.literal(pair.child_by_field_name("value"))
974
+ if v is not None and v.lower() in _TSJS_WRITE_METHODS:
975
+ return True
976
+ return False
977
+
978
+
979
+ def _tsjs_classify(call, chain, f):
980
+ for rid, kind, pats, cond in _TSJS_COMPILED:
981
+ if not any(_tsjs_glob(p, chain) for p in pats):
982
+ continue
983
+ if cond == "method" and not _tsjs_has_write_method(call, f):
984
+ continue
985
+ if cond == "sql":
986
+ args = _tsjs_args(call)
987
+ lit = f.literal(args[0]) if args else None
988
+ if lit is None or not _TSJS_SQL_WRITE.match(lit):
989
+ continue
990
+ return rid, kind
991
+ return None
992
+
993
+
994
+ def _tsjs_scan_source(src, filename, parser):
995
+ """Return (sites, gated) where sites = [(_WriteSite, rule_id)] and
996
+ gated = {(func_name, func_line)}. Module-level writes use func ""."""
997
+ f = _TsjsFile(src)
998
+ tree = parser.parse(src)
999
+ aliases, calls, gated = {}, [], set()
1000
+ stack = [(tree.root_node, ("", 0), "")]
1001
+ while stack:
1002
+ node, fkey, cls = stack.pop()
1003
+ t = node.type
1004
+ if t in _TSJS_FUNC_TYPES:
1005
+ name = _tsjs_func_name(node, f)
1006
+ if name:
1007
+ fkey = (name, node.start_point[0] + 1)
1008
+ elif t in _TSJS_CLASS_TYPES:
1009
+ n = node.child_by_field_name("name")
1010
+ cls = f.text(n) if n is not None else cls
1011
+ elif t == "import_statement":
1012
+ _tsjs_record_import(node, f, aliases)
1013
+ elif t == "variable_declarator":
1014
+ _tsjs_record_require(node, f, aliases)
1015
+ elif t == "call_expression":
1016
+ calls.append((node, fkey, cls))
1017
+ stack.extend((c, fkey, cls) for c in reversed(node.children))
1018
+ sites = []
1019
+ for node, fkey, cls in calls:
1020
+ raw = f.chain(node.child_by_field_name("function"))
1021
+ chain = [c[:-2] if c.endswith("()") else c for c in raw]
1022
+ if chain and chain[0] == "this":
1023
+ chain = chain[1:]
1024
+ if chain and chain[0] in aliases:
1025
+ chain = list(aliases[chain[0]]) + chain[1:]
1026
+ chain = [c.lower() for c in chain]
1027
+ if not chain:
1028
+ continue
1029
+ if chain[-1] in _TSJS_GATE_NAMES:
1030
+ gated.add(fkey)
1031
+ continue
1032
+ hit = _tsjs_classify(node, chain, f)
1033
+ if hit:
1034
+ snippet = " ".join(f.text(node).split())[:100]
1035
+ sites.append((_WriteSite(filename, fkey[0], cls,
1036
+ node.start_point[0] + 1, hit[1], snippet),
1037
+ hit[0], fkey))
1038
+ return sites, gated
1039
+
1040
+
1041
+ def _tsjs_verb(rel, func_name, cls_name, kind):
1042
+ """infer_verb for TS/JS: camelCase names, `orders.service.ts` -> orders."""
1043
+ snake = _re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", func_name)
1044
+ base = rel.with_name(rel.name.split(".")[0] + rel.suffix)
1045
+ return infer_verb(base, snake, cls_name, kind)
1046
+
1047
+
1048
+ def _tsjs_scan(root, excludes):
1049
+ """Return dict with files, skipped, sites, gated, or hint when the
1050
+ extra is missing. Never raises on a bad file."""
1051
+ files = _tsjs_collect_files(root, excludes)
1052
+ res = {"files": files, "skipped": [], "sites": [], "gated": set(),
1053
+ "missing_extra": False}
1054
+ if not files:
1055
+ return res
1056
+ parsers = _tsjs_parsers()
1057
+ if not parsers:
1058
+ res["missing_extra"] = True
1059
+ return res
1060
+ for path in files:
1061
+ try:
1062
+ if path.stat().st_size > _TSJS_MAX_BYTES:
1063
+ res["skipped"].append(str(path))
1064
+ continue
1065
+ src = path.read_bytes()
1066
+ sites, gated = _tsjs_scan_source(
1067
+ src, str(path), parsers[_TSJS_EXTS[path.suffix]])
1068
+ except Exception:
1069
+ res["skipped"].append(str(path))
1070
+ continue
1071
+ res["sites"].extend(sites)
1072
+ res["gated"].update((str(path),) + g for g in gated)
1073
+ return res
1074
+
613
1075
  def scan_cmd(args):
614
1076
  import json as _json
615
1077
  root = _pathlib.Path(args.path).resolve()
@@ -635,6 +1097,14 @@ def scan_cmd(args):
635
1097
  sites.extend(v.sites)
636
1098
  gated.update((str(path), n, ln) for (n, ln) in v.gated)
637
1099
 
1100
+ tsjs = _tsjs_scan(root, args.exclude or [])
1101
+ ts_sites = tsjs["sites"]
1102
+ ts_by_func = {}
1103
+ for s, _rid, fkey in ts_sites:
1104
+ ts_by_func.setdefault((s.file,) + fkey, []).append(s)
1105
+ ts_gated = sum(1 for s, _rid, fkey in ts_sites
1106
+ if (s.file,) + fkey in tsjs["gated"])
1107
+
638
1108
  by_func = {}
639
1109
  for s in sites:
640
1110
  by_func.setdefault((s.file, s.func, s.cls), []).append(s)
@@ -649,6 +1119,10 @@ def scan_cmd(args):
649
1119
  rel = _pathlib.Path(f).relative_to(root)
650
1120
  verbs.setdefault(infer_verb(rel, func_name, cls_name, ss[0].kind),
651
1121
  "require_grant")
1122
+ for (f, func_name, _ln), ss in sorted(ts_by_func.items()):
1123
+ rel = _pathlib.Path(f).relative_to(root)
1124
+ verbs.setdefault(_tsjs_verb(rel, func_name, ss[0].cls, ss[0].kind),
1125
+ "require_grant")
652
1126
 
653
1127
  if args.score:
654
1128
  print_score_report(root, sites, gated_funcs)
@@ -658,6 +1132,22 @@ def scan_cmd(args):
658
1132
  print(" files scanned: %d skipped: %d" % (len(files), len(skipped)))
659
1133
  print(" write sites: %d in %d function(s)" % (total, len(by_func)))
660
1134
  print(" gated: %d/%d (%d%%)" % (gated_site_count, total, pct))
1135
+ if tsjs["missing_extra"]:
1136
+ print(" " + _TSJS_HINT % len(tsjs["files"]))
1137
+ elif tsjs["files"]:
1138
+ ts_total = len(ts_sites)
1139
+ print(" ts/js files scanned: %d skipped: %d (scan only, no --apply)"
1140
+ % (len(tsjs["files"]), len(tsjs["skipped"])))
1141
+ print(" ts/js write sites: %d in %d function(s)"
1142
+ % (ts_total, len(ts_by_func)))
1143
+ print(" ts/js gated: %d/%d (%d%%)" % (
1144
+ ts_gated, ts_total,
1145
+ (100 * ts_gated // ts_total) if ts_total else 100))
1146
+ for s, rid, _fkey in sorted(ts_sites, key=lambda x: (x[0].file,
1147
+ x[0].lineno)):
1148
+ print(" %s:%d [%s] %s %s(): %s" % (
1149
+ _pathlib.Path(s.file).relative_to(root), s.lineno, s.kind,
1150
+ rid, s.func or "<module>", s.snippet))
661
1151
  if verbs:
662
1152
  print(" verbs discovered: %d" % len(verbs))
663
1153
  for vname in sorted(verbs):
@@ -692,7 +1182,11 @@ def scan_cmd(args):
692
1182
  print(" manual: %s %s() [%s]: %s" % (path, func_name, verb, why))
693
1183
 
694
1184
  if not results:
695
- print("nothing to instrument.")
1185
+ if ts_sites:
1186
+ print("nothing to instrument (ts/js findings are scan-only; "
1187
+ "gate them by hand).")
1188
+ else:
1189
+ print("nothing to instrument.")
696
1190
  return 0
697
1191
 
698
1192
  total_gates = sum(r[3] for r in results)
@@ -1,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywrit
3
+ Version: 0.2.5
4
+ Summary: Python client + CLI for Writ — the gate before the write
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
7
+ Project-URL: Documentation, https://withwrit.com/docs
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: polyglot
13
+ Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
14
+ Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
15
+ Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
16
+ Provides-Extra: keywords
17
+ Requires-Dist: ai-agents; extra == "keywords"
18
+ Requires-Dist: policy; extra == "keywords"
19
+ Requires-Dist: audit-log; extra == "keywords"
20
+ Requires-Dist: guardrails; extra == "keywords"
21
+ Dynamic: license-file
22
+
1
23
  # pywrit
2
24
 
3
25
  **Find the dangerous writes in your Python agent's code, then gate it.** `pywrit` is the Python
@@ -100,13 +122,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
100
122
  calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
101
123
  several layers down. The risk report lists these gaps every time.
102
124
 
103
- ### TypeScript / JavaScript: coming soon (not yet released)
125
+ ### TypeScript / JavaScript (scan-only)
126
+
127
+ `writ scan` also finds write sites in TypeScript and JavaScript. It needs the
128
+ optional extra (tree-sitter based):
129
+
130
+ pip install 'pywrit[polyglot]'
131
+ writ scan .
132
+
133
+ Without the extra, the scanner prints a one-line hint and keeps going —
134
+ Python scanning never needs it.
104
135
 
105
- > **Not available in `pywrit` 0.2.2 or on PyPI yet.** The current release scans
106
- > Python only. The preview below shows scan-only TS/JS support that is still in
107
- > development; the command it uses is not available yet.
136
+ TS/JS is **scan-only**: findings are listed with verbs for your policy file,
137
+ but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
138
+ Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
139
+ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
140
+ (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
141
+ — see "Not covered" above.)
108
142
 
109
- ![Preview, not yet released: writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
143
+ ![writ scan on a TypeScript agent finds 5 write sites, 1 of 5 gated](https://raw.githubusercontent.com/AvenueDAdmin/pywrit/main/docs/assets/scan-tsjs.gif)
110
144
 
111
145
  ## Python client
112
146
 
@@ -11,4 +11,5 @@ src/pywrit.egg-info/dependency_links.txt
11
11
  src/pywrit.egg-info/entry_points.txt
12
12
  src/pywrit.egg-info/requires.txt
13
13
  src/pywrit.egg-info/top_level.txt
14
- tests/test_client.py
14
+ tests/test_client.py
15
+ tests/test_scan_tsjs.py
@@ -0,0 +1,12 @@
1
+ httpx>=0.27
2
+
3
+ [keywords]
4
+ ai-agents
5
+ policy
6
+ audit-log
7
+ guardrails
8
+
9
+ [polyglot]
10
+ tree-sitter>=0.23
11
+ tree-sitter-typescript>=0.23
12
+ tree-sitter-javascript>=0.23
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic tests for `writ scan` TypeScript/JavaScript support.
3
+
4
+ Fixtures live in tests/fixtures/tsjs. Every line tagged `// expect: <rule>`
5
+ must be reported with that rule, and nothing else may be reported.
6
+ Tests that parse TS/JS skip when the optional `polyglot` extra
7
+ (tree-sitter) is not installed; the rest run with zero extra deps.
8
+ """
9
+
10
+ import argparse
11
+ import contextlib
12
+ import io
13
+ import os
14
+ import pathlib
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import unittest
21
+ from unittest import mock
22
+
23
+ HERE = pathlib.Path(__file__).resolve().parent
24
+ sys.path.insert(0, str(HERE.parent / "src"))
25
+
26
+ from pywrit import cli # noqa: E402
27
+
28
+ FIXTURES = HERE / "fixtures" / "tsjs"
29
+ HAVE_EXTRA = bool(cli._tsjs_parsers())
30
+ _BLOCK_EXTRA = {m: None for m in ("tree_sitter", "tree_sitter_typescript",
31
+ "tree_sitter_javascript",
32
+ "tree_sitter_language_pack")}
33
+ _EXPECT = re.compile(r"//\s*expect:\s*([a-z0-9-]+)")
34
+ _FINDING = re.compile(
35
+ r"^ (?P<file>\S+):(?P<line>\d+) \[(?P<kind>[a-z]+)\] "
36
+ r"(?P<rule>[a-z0-9-]+) (?P<func>\S+)\(\): (?P<snippet>.+)$")
37
+
38
+
39
+ def _args(path, policy_out, **kw):
40
+ ns = argparse.Namespace(path=str(path), exclude=[], policy_out=str(policy_out),
41
+ apply=False, yes=False, score=False, push_policy=False,
42
+ key="")
43
+ for k, v in kw.items():
44
+ setattr(ns, k, v)
45
+ return ns
46
+
47
+
48
+ def _run_scan(path, **kw):
49
+ """Run scan_cmd in-process; return (exit code, stdout)."""
50
+ with tempfile.TemporaryDirectory() as td:
51
+ buf = io.StringIO()
52
+ with contextlib.redirect_stdout(buf):
53
+ code = cli.scan_cmd(_args(path, pathlib.Path(td) / "policy.json", **kw))
54
+ return code, buf.getvalue()
55
+
56
+
57
+ def _expected():
58
+ exp = set()
59
+ for p in sorted(FIXTURES.rglob("*")):
60
+ if p.suffix not in cli._TSJS_EXTS:
61
+ continue
62
+ for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
63
+ m = _EXPECT.search(line)
64
+ if m:
65
+ exp.add((str(p.relative_to(FIXTURES)), i, m.group(1)))
66
+ return exp
67
+
68
+
69
+ def _findings(out):
70
+ return {(m.group("file"), int(m.group("line")), m.group("rule"))
71
+ for m in map(_FINDING.match, out.splitlines()) if m}
72
+
73
+
74
+ def _copy_fixtures(dst):
75
+ shutil.copytree(str(FIXTURES), str(dst))
76
+ return dst
77
+
78
+
79
+ class FixtureRulesTest(unittest.TestCase):
80
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
81
+ def test_every_expected_finding_and_nothing_else(self):
82
+ with tempfile.TemporaryDirectory() as td:
83
+ root = _copy_fixtures(pathlib.Path(td) / "repo") # outside git
84
+ code, out = _run_scan(root)
85
+ self.assertEqual(code, 0)
86
+ self.assertEqual(_findings(out), _expected(), out)
87
+
88
+ def test_every_rule_has_a_fixture(self):
89
+ covered = {r for (_f, _l, r) in _expected()}
90
+ self.assertEqual(covered, {r[0] for r in cli._TSJS_RULES})
91
+
92
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
93
+ def test_negative_reads_not_flagged(self):
94
+ with tempfile.TemporaryDirectory() as td:
95
+ root = pathlib.Path(td)
96
+ shutil.copy(str(FIXTURES / "reads.tsx"), str(root / "reads.tsx"))
97
+ code, out = _run_scan(root)
98
+ self.assertEqual(code, 0)
99
+ self.assertIn("ts/js files scanned: 1 skipped: 0", out)
100
+ self.assertIn("ts/js write sites: 0 in 0 function(s)", out)
101
+ self.assertEqual(_findings(out), set())
102
+ self.assertIn("nothing to instrument.", out)
103
+
104
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
105
+ def test_output_format_counts_gating_and_verbs(self):
106
+ with tempfile.TemporaryDirectory() as td:
107
+ root = _copy_fixtures(pathlib.Path(td) / "repo")
108
+ policy = pathlib.Path(td) / "p.json"
109
+ buf = io.StringIO()
110
+ with contextlib.redirect_stdout(buf):
111
+ code = cli.scan_cmd(_args(root, policy))
112
+ out = buf.getvalue()
113
+ verbs = __import__("json").loads(policy.read_text())
114
+ self.assertEqual(code, 0)
115
+ n = len(_expected())
116
+ self.assertIn("ts/js files scanned: 7 skipped: 0 (scan only, no --apply)", out)
117
+ self.assertIn("ts/js write sites: %d in 11 function(s)" % n, out)
118
+ self.assertIn("ts/js gated: 1/%d" % n, out) # gated.ts calls writCheck
119
+ self.assertIn(" payments/refunds.service.ts:10 [sdk] stripe-write "
120
+ "issueRefund(): stripe.refunds.create({ charge: chargeId })", out)
121
+ self.assertIn(" http.ts:10 [http] fetch-write createOrder(): fetch(url, "
122
+ "{ method: \"POST\", body: JSON.stringify(body) })", out)
123
+ for v in ("payments.refund", "payments.record", "payments.export",
124
+ "storage.save", "storage.create", "http.create", "legacy.write"):
125
+ self.assertEqual(verbs.get(v), "require_grant", v)
126
+ self.assertIn("nothing to instrument (ts/js findings are scan-only", out)
127
+
128
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
129
+ def test_apply_never_rewrites_tsjs_but_still_gates_python(self):
130
+ with tempfile.TemporaryDirectory() as td:
131
+ root = pathlib.Path(td) / "repo"
132
+ (root / "orders").mkdir(parents=True)
133
+ ts = root / "orders" / "api.ts"
134
+ ts.write_text('export async function createOrder(u: string) {\n'
135
+ ' await fetch(u, { method: "POST" });\n}\n')
136
+ py = root / "orders" / "store.py"
137
+ py.write_text('def create_order():\n'
138
+ ' with open("/tmp/o", "w") as f:\n'
139
+ ' f.write("o")\n')
140
+ before = ts.read_bytes()
141
+ code, out = _run_scan(root, apply=True, yes=True)
142
+ self.assertEqual(code, 0)
143
+ self.assertEqual(ts.read_bytes(), before)
144
+ self.assertIn("_writ_check(\"orders.create\")", py.read_text())
145
+ self.assertIn("orders/api.ts:2 [http] fetch-write createOrder()", out)
146
+ self.assertIn("applied 1 gate(s) to 1 file(s).", out)
147
+
148
+
149
+ class FileWalkTest(unittest.TestCase):
150
+ """File collection is stdlib-only, so these run with or without the extra."""
151
+
152
+ WRITE = 'export function f(){ return fetch("/x", { method: "POST" }); }\n'
153
+
154
+ def _tree(self, root, rels):
155
+ for rel in rels:
156
+ p = root / rel
157
+ p.parent.mkdir(parents=True, exist_ok=True)
158
+ p.write_text(self.WRITE)
159
+
160
+ def test_skip_dirs_and_test_files(self):
161
+ with tempfile.TemporaryDirectory() as td:
162
+ root = pathlib.Path(td)
163
+ skipped = ["node_modules/pkg/index.js", "dist/app.js", "build/app.js",
164
+ ".next/server/page.js", ".git/hooks/x.js",
165
+ "vendor/lib.js", "venv/lib/x.js", ".venv/x.js",
166
+ "src/__tests__/a.ts", "tests/a.ts", "src/a.test.ts",
167
+ "src/a.spec.tsx", "types/a.d.ts", "public/app.min.js",
168
+ "src/readme.md", "src/a.py"]
169
+ kept = ["src/a.ts", "src/b.tsx", "src/c.js", "src/d.jsx",
170
+ "src/e.mjs", "src/f.cjs", "lib/distance.ts"]
171
+ self._tree(root, skipped + kept)
172
+ got = [str(p.relative_to(root))
173
+ for p in cli._tsjs_collect_files(root, [])]
174
+ self.assertEqual(sorted(got), sorted(kept))
175
+
176
+ def test_exclude_substring(self):
177
+ with tempfile.TemporaryDirectory() as td:
178
+ root = pathlib.Path(td)
179
+ self._tree(root, ["src/a.ts", "generated/b.ts"])
180
+ got = [str(p.relative_to(root))
181
+ for p in cli._tsjs_collect_files(root, ["generated"])]
182
+ self.assertEqual(got, ["src/a.ts"])
183
+
184
+ @unittest.skipUnless(shutil.which("git"), "git not installed")
185
+ def test_respects_gitignore_inside_git_repo(self):
186
+ with tempfile.TemporaryDirectory() as td:
187
+ root = pathlib.Path(td)
188
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
189
+ (root / ".gitignore").write_text("generated/\n*.gen.ts\n")
190
+ self._tree(root, ["src/a.ts", "generated/b.ts", "src/c.gen.ts",
191
+ "node_modules/x/y.js"])
192
+ got = [str(p.relative_to(root))
193
+ for p in cli._tsjs_collect_files(root, [])]
194
+ self.assertEqual(got, ["src/a.ts"])
195
+
196
+
197
+ class MissingExtraTest(unittest.TestCase):
198
+ def test_hint_once_and_python_still_scanned(self):
199
+ with tempfile.TemporaryDirectory() as td:
200
+ root = pathlib.Path(td)
201
+ (root / "web").mkdir()
202
+ (root / "web" / "a.ts").write_text(FileWalkTest.WRITE)
203
+ (root / "web" / "b.js").write_text(FileWalkTest.WRITE)
204
+ (root / "orders.py").write_text(
205
+ 'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
206
+ with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
207
+ self.assertEqual(cli._tsjs_parsers(), {})
208
+ code, out = _run_scan(root)
209
+ self.assertEqual(code, 0)
210
+ hints = [l for l in out.splitlines() if "hint:" in l]
211
+ self.assertEqual(hints, [" hint: 2 TS/JS file(s) not scanned; install "
212
+ "the extra: pip install 'pywrit[polyglot]'"])
213
+ self.assertIn("write sites: 1 in 1 function(s)", out)
214
+ self.assertIn("orders.create", out)
215
+ self.assertNotIn("ts/js files scanned", out)
216
+
217
+ def test_python_only_repo_output_unchanged(self):
218
+ with tempfile.TemporaryDirectory() as td:
219
+ root = pathlib.Path(td)
220
+ (root / "orders.py").write_text(
221
+ 'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
222
+ with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
223
+ code, out = _run_scan(root)
224
+ self.assertEqual(code, 0)
225
+ self.assertNotIn("hint:", out)
226
+ self.assertNotIn("ts/js", out)
227
+ self.assertTrue(out.startswith("writ scan: %s\n files scanned: 1 skipped: 0\n"
228
+ " write sites: 1 in 1 function(s)\n"
229
+ " gated: 0/1 (0%%)\n verbs discovered: 1\n"
230
+ " orders.create\n" % root.resolve()), out)
231
+
232
+
233
+ class CliSyncTest(unittest.TestCase):
234
+ def test_download_copy_is_byte_identical(self):
235
+ web = HERE.parent.parent / "gate" / "web" / "writ"
236
+ if not web.exists():
237
+ self.skipTest("gate/web/writ not present")
238
+ self.assertEqual(web.read_bytes(),
239
+ (HERE.parent / "src" / "pywrit" / "cli.py").read_bytes())
240
+
241
+
242
+ if __name__ == "__main__":
243
+ unittest.main(verbosity=2)
@@ -1 +0,0 @@
1
- httpx>=0.27
File without changes
File without changes
File without changes
File without changes
File without changes