@andresmassello/uscha 1.55.0 → 1.56.1

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.
package/README.md CHANGED
@@ -40,8 +40,8 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
40
40
  runtime dependencies). The npm package is a thin router; the canonical installer is
41
41
  `uscha-kit/install-uscha.py`.
42
42
 
43
- **Kit v1.55.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
- [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.55.0.md)
43
+ **Kit v1.56.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
44
+ [changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG-1.56.1.md)
45
45
  (the per-release changelogs live in the repo, not in the npm tarball)
46
46
 
47
47
  ---
@@ -80,6 +80,32 @@ and see which file, which test, and when.
80
80
  evidence from **11 language stacks** — maven, gradle, ant, python, node, go, rust, dotnet,
81
81
  cpp, swift, flutter — and computes a readiness score with hard caps and visible provenance.
82
82
 
83
+ ## Compatibility matrix
84
+
85
+ Generated from `TARGETS`/`SKILL_ROOTS` in the installer, so it cannot drift from the code.
86
+
87
+ | target | agent | installs to | INV-GOLDEN-01 | exercised against a real agent |
88
+ |---|---|---|---|---|
89
+ | `codex` | Codex | `~/plugins/uscha` | advisory | **yes** |
90
+ | `claude` | Claude Code | `~/.claude/skills` | **enforced** (PreToolUse hook, best-effort) | **yes** |
91
+ | `pi` | pi (Earendil) | `~/.agents/skills` | advisory | no — placement + read-back only |
92
+ | `cursor` | Cursor | `~/.cursor/skills` | advisory | no — placement + read-back only |
93
+ | `copilot` | VS Code / Copilot | `~/.copilot/skills` | advisory | no — placement + read-back only |
94
+ | `gemini` | Gemini CLI | `~/.gemini/skills` | advisory | no — placement + read-back only |
95
+ | `cline` | Cline | `~/.cline/skills` | advisory | no — placement + read-back only |
96
+
97
+ **"Exercised" is the column that matters.** For every target but Claude Code and Codex, what
98
+ is measured is that the nine skills land where that agent documents reading them and that
99
+ `doctor` reads them back — *that they load is a documented expectation, not a measurement.*
100
+ INV-GOLDEN-01 is mechanically attempted only where a blocking pre-tool hook exists; everywhere
101
+ else `doctor` reports `advisory` rather than implying a guard it cannot see.
102
+
103
+ | OS | how it is verified | status |
104
+ |---|---|---|
105
+ | Linux | CI matrix (py3.8 + py3.13) + local WSL | **measured** |
106
+ | Windows | CI matrix (py3.8 + py3.13) + native local | **measured** |
107
+ | macOS | CI matrix (py3.8 + py3.13), real runners | **measured** |
108
+
83
109
  ## The loop, in short
84
110
 
85
111
  1. **Model first.** `/uscha-discovery` (new) or `/uscha-adr-refine` (known feature) writes
@@ -135,7 +161,11 @@ audits/ # adversarial audit outputs
135
161
 
136
162
  The rules are in [`CLAUDE.md`](CLAUDE.md). The short version: no doc may claim what the
137
163
  engine does not do; the ES and EN twins travel together; every engine change carries a
138
- smoke test; and the agent never writes a `.approved` file — that one is enforced by a hook.
164
+ smoke test; and a PreToolUse hook stops the agent from writing a `.approved` golden.
165
+ **Scoped honestly**: that hook is a *best-effort* guard and it is registered on the Claude
166
+ target only — it inspects a tool call as TEXT, so an indirect write (a script that assembles
167
+ the filename, a symlink) gets through, and every other target reports `golden_guard:
168
+ advisory`. The MEASURED control is `golden-diff`, which compares bytes.
139
169
 
140
170
  ## History
141
171
 
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.55.0",
3
+ "version": "1.56.1",
4
4
  "description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
5
+ "author": {
6
+ "name": "Andres Massello",
7
+ "url": "https://github.com/andresmassello"
8
+ },
5
9
  "bin": {
6
10
  "uscha": "bin/uscha.js",
7
11
  "uscha-kit": "bin/uscha.js"
@@ -96,6 +96,36 @@ SOURCE_EXT = {
96
96
  # --------------------------------------------------------------------------- #
97
97
  # ledger io
98
98
  # --------------------------------------------------------------------------- #
99
+ # Reports come from the user's build, not from us, and the engine is stdlib-only by contract --
100
+ # `defusedxml` is not available. A byte ceiling is the honest mitigation for the realistic
101
+ # failure (a runaway or hostile report exhausting memory on the operator's own machine). It is
102
+ # NOT protection against a determined attacker: entity expansion inside the ceiling still
103
+ # expands. SECURITY.md says so rather than implying the parser is hardened.
104
+ MAX_REPORT_BYTES = 64 * 1024 * 1024 # 64 MB: orders of magnitude above any real JUnit run
105
+
106
+
107
+ class ReportTooLarge(Exception):
108
+ pass
109
+
110
+
111
+ def _parse_xml(source):
112
+ """ET.parse with a size ceiling. Accepts a path or an open binary/text file object."""
113
+ if hasattr(source, "read"):
114
+ head = source.read(MAX_REPORT_BYTES + 1)
115
+ if len(head) > MAX_REPORT_BYTES:
116
+ raise ReportTooLarge("report exceeds %d bytes" % MAX_REPORT_BYTES)
117
+ if isinstance(head, bytes):
118
+ return ET.ElementTree(ET.fromstring(head))
119
+ return ET.ElementTree(ET.fromstring(head))
120
+ try:
121
+ size = os.path.getsize(str(source))
122
+ except OSError:
123
+ size = 0
124
+ if size > MAX_REPORT_BYTES:
125
+ raise ReportTooLarge("%s exceeds %d bytes" % (source, MAX_REPORT_BYTES))
126
+ return ET.parse(str(source))
127
+
128
+
99
129
  def _now():
100
130
  return datetime.now(timezone.utc).isoformat(timespec="seconds")
101
131
 
@@ -168,7 +198,7 @@ def _repo_cfg(ledger, name):
168
198
  def _jacoco_line_counter(xml_path):
169
199
  """Return (missed, covered) for the report-level LINE counter."""
170
200
  try:
171
- root = ET.parse(xml_path).getroot()
201
+ root = _parse_xml(xml_path).getroot()
172
202
  except ET.ParseError:
173
203
  return 0, 0
174
204
  for c in root.findall("counter"):
@@ -237,7 +267,7 @@ def cobertura_coverage(repo_path):
237
267
  if not path:
238
268
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
239
269
  try:
240
- root = ET.parse(path).getroot()
270
+ root = _parse_xml(path).getroot()
241
271
  except (ET.ParseError, OSError):
242
272
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
243
273
  lc, lv = root.get("lines-covered"), root.get("lines-valid")
@@ -358,7 +388,7 @@ def _invalid_junit(path, detail):
358
388
 
359
389
  def _parse_junit_xml(path):
360
390
  try:
361
- root = ET.parse(path).getroot()
391
+ root = _parse_xml(path).getroot()
362
392
  except (ET.ParseError, OSError) as exc:
363
393
  _invalid_junit(path, exc)
364
394
  root_kind = _local(root.tag)
@@ -464,7 +494,7 @@ def _perclass_xml_count(patterns, skip_root=None, tolerant=False):
464
494
  # simply not be ours. Skip it instead of aborting the whole run -- but
465
495
  # NEVER silently: every drop is returned so the ledger can surface it.
466
496
  try:
467
- root = ET.parse(f).getroot()
497
+ root = _parse_xml(f).getroot()
468
498
  except (ET.ParseError, OSError) as exc:
469
499
  dropped.append({"path": f, "reason": f"unreadable XML: {exc}"})
470
500
  continue
@@ -751,7 +781,7 @@ def _ac_tags(repo_path, repo_type):
751
781
  except OSError:
752
782
  pass
753
783
  try:
754
- root = ET.parse(f).getroot()
784
+ root = _parse_xml(f).getroot()
755
785
  except (ET.ParseError, OSError):
756
786
  continue
757
787
  for tc in root.iter():
@@ -1046,7 +1076,7 @@ def _invalid_static_report(path, label, detail):
1046
1076
 
1047
1077
  def _parse_static_xml(path, label, root_name):
1048
1078
  try:
1049
- root = ET.parse(path).getroot()
1079
+ root = _parse_xml(path).getroot()
1050
1080
  except (ET.ParseError, OSError) as exc:
1051
1081
  _invalid_static_report(path, label, exc)
1052
1082
  if _local(root.tag) != root_name:
@@ -4699,7 +4729,7 @@ def _find_pit_report(path_arg):
4699
4729
 
4700
4730
 
4701
4731
  def _pit_metrics(xml_path):
4702
- root = ET.parse(xml_path).getroot()
4732
+ root = _parse_xml(xml_path).getroot()
4703
4733
  total = killed = survived = no_cov = excluded = 0
4704
4734
  by_file = {}
4705
4735
  for mut in root.iter("mutation"):
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.55.0",
4
+ "version": "1.56.1",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 29 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.55.0",
3
+ "version": "1.56.1",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.55.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.56.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
@@ -18,7 +18,7 @@ uscha-kit/
18
18
  ?? install-uscha.py # canonical installer used by npm/npx
19
19
  ├─ uscha.config.json # config: repos, thresholds, commands
20
20
  ├─ hooks/
21
- │ └─ block-approved-writes.py # PreToolUse: the agent CANNOT write .approved (INV-GOLDEN-01)
21
+ │ └─ block-approved-writes.py # PreToolUse: best-effort block on writing a golden (Claude only)
22
22
  ├─ templates/
23
23
  │ ├─ CLAUDE.md # permanent repo protocol
24
24
  │ ├─ CONSTITUTION.md # inviolable invariants (fill in the domain)
@@ -60,7 +60,7 @@ BEFORE touching anything.
60
60
  /uscha-reverse-discovery # FACTS map of the old system (endpoints, contracts, dependencies)
61
61
 
62
62
  /uscha-characterize # runs the ORIGINAL code with a real corpus → .received → STOPS:
63
- ↓ # a HUMAN approves the .approved (the agent never writes them hook)
63
+ ↓ # a HUMAN approves the golden (a best-effort hook discourages the agent; golden-diff measures)
64
64
  /uscha-devloop # migrates; golden-diff byte-compares against the .approved on each pass
65
65
  ```
66
66
 
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.55.0
1
+ uscha-kit 1.56.1
@@ -1,24 +1,130 @@
1
1
  #!/usr/bin/env python3
2
- """Portable Claude PreToolUse hook for INV-GOLDEN-01."""
2
+ """PreToolUse hook for INV-GOLDEN-01 -- a BEST-EFFORT guard, not a sandbox.
3
+
4
+ What it does
5
+ ------------
6
+ Stops the obvious ways an agent writes, renames or deletes an approved golden
7
+ fixture, so the human stays the only author of field truth. Reads are allowed on
8
+ purpose: golden-diff has to read the file it compares against.
9
+
10
+ What it CANNOT do -- read this before trusting it
11
+ -------------------------------------------------
12
+ It inspects the tool call as TEXT. It cannot know a command's real filesystem
13
+ effects, so any indirect write gets through: a Python or Node one-liner that
14
+ assembles the filename from pieces, a script whose contents it never sees, a
15
+ symlink pointing at the golden, a process spawned by another process. Treat this
16
+ as a guardrail against accident and casual shortcut -- NOT as a security boundary
17
+ against an adversarial agent. The measured control is `golden-diff`, which
18
+ compares bytes; this hook only lowers the odds of ever needing it.
19
+
20
+ It also only exists where a blocking pre-tool hook exists. `install-uscha.py`
21
+ registers it for the Claude target only; every Agent-Skills target
22
+ (codex/pi/cursor/copilot/gemini/cline) reports `golden_guard: advisory`, which is
23
+ the doctor telling the truth rather than implying a guard it cannot see.
24
+
25
+ Posture
26
+ -------
27
+ FAIL-CLOSED. An unparseable payload blocks instead of allowing: a guard that
28
+ opens when it is confused is not a guard. Matching is case-insensitive, because
29
+ Windows and macOS filesystems are.
30
+ """
3
31
  import json
32
+ import re
4
33
  import sys
5
34
 
35
+ GOLDEN = ".approved"
36
+
37
+ BLOCK_MSG = (
38
+ "BLOCKED by INV-GOLDEN-01: the agent may not create, edit, rename or delete an "
39
+ "approved golden. The approved file is field truth -- a HUMAN signs it. Emit a "
40
+ ".received instead and stop for human approval."
41
+ )
42
+
43
+ # Tools that cannot write. Reading a golden is legitimate (golden-diff does it).
44
+ READ_ONLY_TOOLS = {"read", "grep", "glob", "notebookread", "websearch", "webfetch",
45
+ "ls", "listdirectory"}
46
+
47
+ # Shell words that read without modifying. Anything NOT here is treated as a write.
48
+ READ_ONLY_SHELL = {
49
+ "cat", "less", "more", "head", "tail", "grep", "egrep", "fgrep", "rg", "ag",
50
+ "diff", "cmp", "wc", "md5sum", "sha1sum", "sha256sum", "shasum", "file", "stat",
51
+ "od", "xxd", "hexdump", "strings", "awk", "cut", "sort", "uniq", "tr", "column",
52
+ "echo", "printf", "test", "true", "false", "ls", "find", "basename", "dirname",
53
+ "git", "python", "python3", "py", "node", "bash", "sh", "env", "which", "type",
54
+ }
55
+ # ... except these, which are how a "reader" still writes.
56
+ WRITE_FLAGS = {"-i", "--in-place", "-o", "--output", "-w", "--write"}
57
+
58
+
59
+ def _touches_golden(text):
60
+ return GOLDEN in str(text).lower()
61
+
62
+
63
+ def _bash_writes(command):
64
+ """True when a shell command plausibly WRITES a golden. Default-deny: anything
65
+ this cannot prove is read-only counts as a write."""
66
+ low = command.lower()
67
+ # any redirection at all in a command naming a golden -- `cat a.approved > b.approved`
68
+ # starts with a reader and still writes one.
69
+ if ">" in low:
70
+ return True
71
+ if any(w in WRITE_FLAGS for w in low.split()):
72
+ return True
73
+ # EVERY segment of the pipeline must start with a known reader. Splitting on the
74
+ # separators FIRST matters: an earlier version blanked them before looking, so it only
75
+ # ever saw the first verb and `echo x | tee y.approved` read as a plain `echo`.
76
+ segments = re.split(r"\|\||&&|[|;&]", low)
77
+ verbs = []
78
+ for seg in segments:
79
+ words = seg.split()
80
+ if words:
81
+ verbs.append(words[0].split("/")[-1].split("\\")[-1])
82
+ if not verbs:
83
+ return True
84
+ return not all(v in READ_ONLY_SHELL for v in verbs)
85
+
86
+
87
+ def decide(payload):
88
+ """Return True to BLOCK."""
89
+ tool = str(payload.get("tool_name", "")).strip().lower()
90
+ tool_input = payload.get("tool_input")
91
+ if not isinstance(tool_input, dict):
92
+ tool_input = {}
93
+ if tool == "bash" or tool == "shell":
94
+ command = tool_input.get("command", "")
95
+ return _touches_golden(command) and _bash_writes(str(command))
96
+ if tool in READ_ONLY_TOOLS:
97
+ return False
98
+ # Any other tool: if a golden appears ANYWHERE in its arguments, block. Unknown
99
+ # write-capable tools must not slip through just because they are unknown.
100
+ def walk(v):
101
+ if isinstance(v, str):
102
+ return _touches_golden(v)
103
+ if isinstance(v, dict):
104
+ return any(walk(x) for x in v.values())
105
+ if isinstance(v, (list, tuple)):
106
+ return any(walk(x) for x in v)
107
+ return False
108
+ return walk(tool_input)
109
+
6
110
 
7
111
  def main():
8
112
  try:
9
113
  payload = json.load(sys.stdin)
10
- except (json.JSONDecodeError, OSError):
11
- return 0
12
- tool = payload.get("tool_name")
13
- target = ""
14
- if tool in {"Write", "Edit", "NotebookEdit", "MultiEdit"}:
15
- target = str(payload.get("tool_input", {}).get("file_path", ""))
16
- elif tool == "Bash":
17
- target = str(payload.get("tool_input", {}).get("command", ""))
18
- if ".approved" not in target:
19
- return 0
20
- print("BLOCKED by INV-GOLDEN-01: the agent may not write or rename an .approved golden. Emit a .received instead and stop for human approval.", file=sys.stderr)
21
- return 2
114
+ except Exception:
115
+ # FAIL-CLOSED: previously this returned 0 (allow), so a malformed payload
116
+ # silently disabled the guard.
117
+ print("BLOCKED by INV-GOLDEN-01: the hook could not read its input, so it "
118
+ "cannot prove this call is safe (fail-closed).", file=sys.stderr)
119
+ return 2
120
+ if not isinstance(payload, dict):
121
+ print("BLOCKED by INV-GOLDEN-01: unexpected hook payload shape (fail-closed).",
122
+ file=sys.stderr)
123
+ return 2
124
+ if decide(payload):
125
+ print(BLOCK_MSG, file=sys.stderr)
126
+ return 2
127
+ return 0
22
128
 
23
129
 
24
130
  if __name__ == "__main__":
@@ -807,6 +807,141 @@ def cmd_mirador(args):
807
807
  print("\n[uscha mirador] stopped")
808
808
 
809
809
 
810
+ def settings_without_hook(path):
811
+ """Return (new_settings, removed_count): the user's settings with OUR PreToolUse entries
812
+ dropped and nothing else touched. A foreign hook -- including one in the same group -- is
813
+ preserved; an emptied group disappears rather than being left as an empty shell."""
814
+ if not path.exists():
815
+ return None, 0
816
+ data = load_json(path, "Claude settings.json")
817
+ if not isinstance(data, dict):
818
+ raise InstallError("[install-uscha] Claude settings.json must be an object: %s" % path)
819
+ hooks_data = data.get("hooks")
820
+ if not isinstance(hooks_data, dict):
821
+ return None, 0
822
+ groups = hooks_data.get("PreToolUse")
823
+ if not isinstance(groups, list):
824
+ return None, 0
825
+ removed = 0
826
+ new_groups = []
827
+ for group in groups:
828
+ if not isinstance(group, dict):
829
+ new_groups.append(group); continue
830
+ items = group.get("hooks")
831
+ if not isinstance(items, list):
832
+ new_groups.append(group); continue
833
+ keep = []
834
+ for item in items:
835
+ c = item.get("command") if isinstance(item, dict) else None
836
+ if isinstance(c, str) and HOOK_NAME in c:
837
+ removed += 1
838
+ else:
839
+ keep.append(item)
840
+ if keep:
841
+ g = dict(group); g["hooks"] = keep; new_groups.append(g)
842
+ elif not items:
843
+ new_groups.append(group) # was already empty; not ours to prune
844
+ if not removed:
845
+ return None, 0
846
+ result = dict(data)
847
+ hooks = dict(hooks_data)
848
+ if new_groups:
849
+ hooks["PreToolUse"] = new_groups
850
+ else:
851
+ hooks.pop("PreToolUse", None)
852
+ if hooks:
853
+ result["hooks"] = hooks
854
+ else:
855
+ result.pop("hooks", None)
856
+ return result, removed
857
+
858
+
859
+ def uninstall_target(target, home, dry_run, operations, force):
860
+ """Remove one target. Refuses on ambiguity instead of guessing: without OUR marker there is
861
+ no proof the files at that root are ours, and deleting a stranger's skills would be a far
862
+ worse bug than leaving ours behind. --force overrides, and says what it assumed."""
863
+ removed, kept = [], []
864
+ if target == "codex":
865
+ root = home / "plugins" / PLUGIN_NAME
866
+ marker_path = root / "uscha-install.json"
867
+ market_path = home / ".agents" / "plugins" / "marketplace.json"
868
+ elif target in SKILL_ROOTS:
869
+ root = home.joinpath(*SKILL_ROOTS[target])
870
+ marker_path = root / "uscha-install.json"
871
+ market_path = None
872
+ else:
873
+ root = home / ".claude"
874
+ marker_path = root / "uscha-install.json"
875
+ market_path = None
876
+
877
+ valid, _ = marker_ok(marker_path, target)
878
+ if not valid and not force:
879
+ raise InstallError(
880
+ "[install-uscha] %s: no uscha install marker at %s -- refusing to delete files this "
881
+ "kit cannot prove it wrote. Re-run with --force if you are sure." % (target, marker_path))
882
+
883
+ def drop(p, why):
884
+ operations.append({"action": "remove", "path": str(p), "reason": why})
885
+ if p.exists() or p.is_symlink():
886
+ if not dry_run:
887
+ remove_path(p)
888
+ removed.append(str(p))
889
+
890
+ if target == "codex":
891
+ drop(root, "codex plugin tree (ours: marker verified)")
892
+ if market_path and market_path.is_file():
893
+ try:
894
+ data = load_json(market_path, "marketplace.json")
895
+ plugins = [p for p in data.get("plugins", []) if p != marketplace_entry()]
896
+ if len(plugins) != len(data.get("plugins", [])):
897
+ data = dict(data); data["plugins"] = plugins
898
+ operations.append({"action": "edit", "path": str(market_path),
899
+ "reason": "drop the uscha marketplace entry, keep the rest"})
900
+ if not dry_run:
901
+ atomic_json(market_path, data)
902
+ removed.append(str(market_path) + " (entry)")
903
+ else:
904
+ kept.append(str(market_path) + " (no uscha entry)")
905
+ except InstallError:
906
+ kept.append(str(market_path) + " (unreadable, left untouched)")
907
+ elif target in SKILL_ROOTS:
908
+ for skill in SKILLS:
909
+ drop(root / skill, "uscha skill")
910
+ drop(marker_path, "install marker")
911
+ else:
912
+ skills_root = root / "skills"
913
+ for skill in SKILLS:
914
+ drop(skills_root / skill, "uscha skill")
915
+ drop(root / "hooks" / HOOK_NAME, "INV-GOLDEN-01 hook")
916
+ drop(marker_path, "install marker")
917
+ settings_path = root / "settings.json"
918
+ try:
919
+ new_settings, n = settings_without_hook(settings_path)
920
+ except InstallError:
921
+ new_settings, n = None, 0
922
+ kept.append(str(settings_path) + " (unreadable, left untouched)")
923
+ if n:
924
+ operations.append({"action": "edit", "path": str(settings_path),
925
+ "reason": "drop %d uscha PreToolUse entry(ies), keep foreign hooks" % n})
926
+ if not dry_run:
927
+ atomic_json(settings_path, new_settings)
928
+ removed.append(str(settings_path) + " (%d hook entry)" % n)
929
+ else:
930
+ kept.append(str(settings_path) + " (no uscha hook entry)")
931
+ return {"removed": removed, "kept": kept}
932
+
933
+
934
+ def cmd_uninstall(args):
935
+ home, operations, result = home_path(args), [], {}
936
+ for target in selected_targets(args.target):
937
+ result[target] = uninstall_target(target, home, args.dry_run, operations, args.force)
938
+ emit({"status": "planned" if args.dry_run else "uninstalled", "dry_run": args.dry_run,
939
+ "home": str(home), "targets": result, "operations": operations,
940
+ "next": ["Run: python install-uscha.py doctor --target %s" % args.target,
941
+ "Your own files were left alone: only paths this kit wrote are removed."]},
942
+ args.json)
943
+
944
+
810
945
  def next_steps(target):
811
946
  picked = selected_targets(target) # resolves both/all so each installed target speaks
812
947
  steps = []
@@ -826,7 +961,16 @@ def next_steps(target):
826
961
  def emit(data, as_json):
827
962
  if as_json:
828
963
  print(json.dumps(data, indent=2, ensure_ascii=False)); return
829
- if "targets" in data and isinstance(data["targets"], dict):
964
+ # uninstall also reports per-target, but with removed/kept instead of health -- match on
965
+ # the SHAPE, not just the key name, or a payload that merely has "targets" crashes here.
966
+ if data.get("status") in ("uninstalled", "planned") and isinstance(data.get("targets"), dict):
967
+ print("Uscha uninstall%s" % (" (dry-run: nothing was touched)" if data.get("dry_run") else ""))
968
+ for name, res in data["targets"].items():
969
+ print(" %-8s removed %d, left alone %d" % (name, len(res.get("removed", [])), len(res.get("kept", []))))
970
+ for k in res.get("kept", []) if data["targets"] else []:
971
+ print(" kept: %s" % k)
972
+ elif ("targets" in data and isinstance(data["targets"], dict)
973
+ and all(isinstance(v, dict) and "healthy" in v for v in data["targets"].values())):
830
974
  print("Uscha %s" % data.get("source_version"))
831
975
  for name, status in data["targets"].items(): print(" %s %s" % ("OK" if status["healthy"] else "WARN", name))
832
976
  elif "operations" in data:
@@ -846,6 +990,13 @@ def build_parser():
846
990
  doctor.add_argument("--target", choices=list(TARGETS) + ["both", "all"], default="both"); doctor.add_argument("--home"); doctor.add_argument("--json", action="store_true"); doctor.set_defaults(func=cmd_doctor)
847
991
  init = sub.add_parser("init", help="prepare a repo with Uscha config/templates")
848
992
  init.add_argument("--repo", default="."); init.add_argument("--force", action="store_true", help="replace differing init files deliberately"); init.add_argument("--dry-run", action="store_true"); init.add_argument("--json", action="store_true"); init.set_defaults(func=cmd_init)
993
+ uninstall = sub.add_parser("uninstall", help="remove what this kit installed, and nothing else")
994
+ uninstall.add_argument("--target", choices=list(TARGETS) + ["both", "all"], default="both")
995
+ uninstall.add_argument("--home"); uninstall.add_argument("--dry-run", action="store_true")
996
+ uninstall.add_argument("--json", action="store_true")
997
+ uninstall.add_argument("--force", action="store_true",
998
+ help="remove even without an install marker (you assert the files are ours)")
999
+ uninstall.set_defaults(func=cmd_uninstall)
849
1000
  mirador = sub.add_parser("mirador", help="render + open the project's mirador dashboard from QA-LEDGER.json")
850
1001
  mirador.add_argument("--ledger", default="QA-LEDGER.json", help="ledger to read (default: the QA-LEDGER.json convention)")
851
1002
  mirador.add_argument("--out", default="mirador.html")
@@ -96,6 +96,36 @@ SOURCE_EXT = {
96
96
  # --------------------------------------------------------------------------- #
97
97
  # ledger io
98
98
  # --------------------------------------------------------------------------- #
99
+ # Reports come from the user's build, not from us, and the engine is stdlib-only by contract --
100
+ # `defusedxml` is not available. A byte ceiling is the honest mitigation for the realistic
101
+ # failure (a runaway or hostile report exhausting memory on the operator's own machine). It is
102
+ # NOT protection against a determined attacker: entity expansion inside the ceiling still
103
+ # expands. SECURITY.md says so rather than implying the parser is hardened.
104
+ MAX_REPORT_BYTES = 64 * 1024 * 1024 # 64 MB: orders of magnitude above any real JUnit run
105
+
106
+
107
+ class ReportTooLarge(Exception):
108
+ pass
109
+
110
+
111
+ def _parse_xml(source):
112
+ """ET.parse with a size ceiling. Accepts a path or an open binary/text file object."""
113
+ if hasattr(source, "read"):
114
+ head = source.read(MAX_REPORT_BYTES + 1)
115
+ if len(head) > MAX_REPORT_BYTES:
116
+ raise ReportTooLarge("report exceeds %d bytes" % MAX_REPORT_BYTES)
117
+ if isinstance(head, bytes):
118
+ return ET.ElementTree(ET.fromstring(head))
119
+ return ET.ElementTree(ET.fromstring(head))
120
+ try:
121
+ size = os.path.getsize(str(source))
122
+ except OSError:
123
+ size = 0
124
+ if size > MAX_REPORT_BYTES:
125
+ raise ReportTooLarge("%s exceeds %d bytes" % (source, MAX_REPORT_BYTES))
126
+ return ET.parse(str(source))
127
+
128
+
99
129
  def _now():
100
130
  return datetime.now(timezone.utc).isoformat(timespec="seconds")
101
131
 
@@ -168,7 +198,7 @@ def _repo_cfg(ledger, name):
168
198
  def _jacoco_line_counter(xml_path):
169
199
  """Return (missed, covered) for the report-level LINE counter."""
170
200
  try:
171
- root = ET.parse(xml_path).getroot()
201
+ root = _parse_xml(xml_path).getroot()
172
202
  except ET.ParseError:
173
203
  return 0, 0
174
204
  for c in root.findall("counter"):
@@ -237,7 +267,7 @@ def cobertura_coverage(repo_path):
237
267
  if not path:
238
268
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
239
269
  try:
240
- root = ET.parse(path).getroot()
270
+ root = _parse_xml(path).getroot()
241
271
  except (ET.ParseError, OSError):
242
272
  return {"covered": 0, "missed": 0, "pct": 0.0, "report_found": False}
243
273
  lc, lv = root.get("lines-covered"), root.get("lines-valid")
@@ -358,7 +388,7 @@ def _invalid_junit(path, detail):
358
388
 
359
389
  def _parse_junit_xml(path):
360
390
  try:
361
- root = ET.parse(path).getroot()
391
+ root = _parse_xml(path).getroot()
362
392
  except (ET.ParseError, OSError) as exc:
363
393
  _invalid_junit(path, exc)
364
394
  root_kind = _local(root.tag)
@@ -464,7 +494,7 @@ def _perclass_xml_count(patterns, skip_root=None, tolerant=False):
464
494
  # simply not be ours. Skip it instead of aborting the whole run -- but
465
495
  # NEVER silently: every drop is returned so the ledger can surface it.
466
496
  try:
467
- root = ET.parse(f).getroot()
497
+ root = _parse_xml(f).getroot()
468
498
  except (ET.ParseError, OSError) as exc:
469
499
  dropped.append({"path": f, "reason": f"unreadable XML: {exc}"})
470
500
  continue
@@ -751,7 +781,7 @@ def _ac_tags(repo_path, repo_type):
751
781
  except OSError:
752
782
  pass
753
783
  try:
754
- root = ET.parse(f).getroot()
784
+ root = _parse_xml(f).getroot()
755
785
  except (ET.ParseError, OSError):
756
786
  continue
757
787
  for tc in root.iter():
@@ -1046,7 +1076,7 @@ def _invalid_static_report(path, label, detail):
1046
1076
 
1047
1077
  def _parse_static_xml(path, label, root_name):
1048
1078
  try:
1049
- root = ET.parse(path).getroot()
1079
+ root = _parse_xml(path).getroot()
1050
1080
  except (ET.ParseError, OSError) as exc:
1051
1081
  _invalid_static_report(path, label, exc)
1052
1082
  if _local(root.tag) != root_name:
@@ -4699,7 +4729,7 @@ def _find_pit_report(path_arg):
4699
4729
 
4700
4730
 
4701
4731
  def _pit_metrics(xml_path):
4702
- root = ET.parse(xml_path).getroot()
4732
+ root = _parse_xml(xml_path).getroot()
4703
4733
  total = killed = survived = no_cov = excluded = 0
4704
4734
  by_file = {}
4705
4735
  for mut in root.iter("mutation"):
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.55.0",
2
+ "version": "1.56.1",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,