@michengai/dsh-pua 0.3.8

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.
Files changed (170) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +38 -0
  4. package/README.md +150 -0
  5. package/assets/pua/command-again.md +23 -0
  6. package/assets/pua/command-done-check.md +21 -0
  7. package/assets/pua/command-evidence.md +18 -0
  8. package/assets/pua/flavors.md +388 -0
  9. package/assets/pua/methodology-alibaba.md +33 -0
  10. package/assets/pua/methodology-amazon.md +42 -0
  11. package/assets/pua/methodology-apple.md +42 -0
  12. package/assets/pua/methodology-baidu.md +33 -0
  13. package/assets/pua/methodology-bytedance.md +41 -0
  14. package/assets/pua/methodology-ding.md +75 -0
  15. package/assets/pua/methodology-huawei.md +95 -0
  16. package/assets/pua/methodology-jd.md +42 -0
  17. package/assets/pua/methodology-meituan.md +41 -0
  18. package/assets/pua/methodology-microsoft.md +138 -0
  19. package/assets/pua/methodology-netflix.md +41 -0
  20. package/assets/pua/methodology-pinduoduo.md +33 -0
  21. package/assets/pua/methodology-tencent.md +41 -0
  22. package/assets/pua/methodology-tesla.md +42 -0
  23. package/assets/pua/methodology-xiaomi.md +42 -0
  24. package/assets/pua/upstream/agents/cto-p10.md +87 -0
  25. package/assets/pua/upstream/agents/pua-action-executor.md +60 -0
  26. package/assets/pua/upstream/agents/pua-policy-guardian.md +54 -0
  27. package/assets/pua/upstream/agents/pua-self-reviewer.md +62 -0
  28. package/assets/pua/upstream/agents/pua-verifier.md +61 -0
  29. package/assets/pua/upstream/agents/senior-engineer-p7.md +116 -0
  30. package/assets/pua/upstream/agents/tech-lead-p9.md +97 -0
  31. package/assets/pua/upstream/commands/again.md +23 -0
  32. package/assets/pua/upstream/commands/cancel-pua-loop.md +62 -0
  33. package/assets/pua/upstream/commands/ding.md +25 -0
  34. package/assets/pua/upstream/commands/done-check.md +21 -0
  35. package/assets/pua/upstream/commands/evidence.md +18 -0
  36. package/assets/pua/upstream/commands/flavor.md +6 -0
  37. package/assets/pua/upstream/commands/kpi.md +5 -0
  38. package/assets/pua/upstream/commands/mama.md +5 -0
  39. package/assets/pua/upstream/commands/off.md +41 -0
  40. package/assets/pua/upstream/commands/offline.md +38 -0
  41. package/assets/pua/upstream/commands/on.md +15 -0
  42. package/assets/pua/upstream/commands/p10.md +5 -0
  43. package/assets/pua/upstream/commands/p7.md +5 -0
  44. package/assets/pua/upstream/commands/p9.md +5 -0
  45. package/assets/pua/upstream/commands/pro.md +5 -0
  46. package/assets/pua/upstream/commands/pua-loop.md +5 -0
  47. package/assets/pua/upstream/commands/pua.md +44 -0
  48. package/assets/pua/upstream/commands/reap-orphans.md +68 -0
  49. package/assets/pua/upstream/commands/survey.md +9 -0
  50. package/assets/pua/upstream/commands/team-status.md +56 -0
  51. package/assets/pua/upstream/commands/teardown-all.md +80 -0
  52. package/assets/pua/upstream/commands/yes.md +5 -0
  53. package/assets/pua/upstream/hooks/checkpoint-save.sh +56 -0
  54. package/assets/pua/upstream/hooks/failure-detector.sh +266 -0
  55. package/assets/pua/upstream/hooks/flavor-helper.sh +300 -0
  56. package/assets/pua/upstream/hooks/frustration-trigger.sh +61 -0
  57. package/assets/pua/upstream/hooks/hooks.json +114 -0
  58. package/assets/pua/upstream/hooks/integrity-guard.sh +494 -0
  59. package/assets/pua/upstream/hooks/pua-loop-hook.sh +360 -0
  60. package/assets/pua/upstream/hooks/runtime-state.py +460 -0
  61. package/assets/pua/upstream/hooks/sanitize-session.sh +165 -0
  62. package/assets/pua/upstream/hooks/session-restore.sh +189 -0
  63. package/assets/pua/upstream/hooks/stop-feedback.sh +51 -0
  64. package/assets/pua/upstream/hooks/subagent-teardown.sh +56 -0
  65. package/assets/pua/upstream/skills/ding/SKILL.md +83 -0
  66. package/assets/pua/upstream/skills/ding/references/ding-reminders.md +77 -0
  67. package/assets/pua/upstream/skills/ding/references/methodology-ding.md +75 -0
  68. package/assets/pua/upstream/skills/mama/SKILL.md +117 -0
  69. package/assets/pua/upstream/skills/p10/SKILL.md +13 -0
  70. package/assets/pua/upstream/skills/p7/SKILL.md +13 -0
  71. package/assets/pua/upstream/skills/p9/SKILL.md +15 -0
  72. package/assets/pua/upstream/skills/pro/SKILL.md +69 -0
  73. package/assets/pua/upstream/skills/pua/SKILL.md +438 -0
  74. package/assets/pua/upstream/skills/pua/references/agent-team.md +110 -0
  75. package/assets/pua/upstream/skills/pua/references/de-escalation-protocol.md +134 -0
  76. package/assets/pua/upstream/skills/pua/references/ding-reminders.md +77 -0
  77. package/assets/pua/upstream/skills/pua/references/display-protocol.md +63 -0
  78. package/assets/pua/upstream/skills/pua/references/evolution-protocol.md +187 -0
  79. package/assets/pua/upstream/skills/pua/references/flavors.md +388 -0
  80. package/assets/pua/upstream/skills/pua/references/harness-governance.md +159 -0
  81. package/assets/pua/upstream/skills/pua/references/methodology-alibaba.md +33 -0
  82. package/assets/pua/upstream/skills/pua/references/methodology-amazon.md +42 -0
  83. package/assets/pua/upstream/skills/pua/references/methodology-apple.md +42 -0
  84. package/assets/pua/upstream/skills/pua/references/methodology-baidu.md +33 -0
  85. package/assets/pua/upstream/skills/pua/references/methodology-bytedance.md +41 -0
  86. package/assets/pua/upstream/skills/pua/references/methodology-ding.md +75 -0
  87. package/assets/pua/upstream/skills/pua/references/methodology-huawei.md +95 -0
  88. package/assets/pua/upstream/skills/pua/references/methodology-jd.md +42 -0
  89. package/assets/pua/upstream/skills/pua/references/methodology-meituan.md +41 -0
  90. package/assets/pua/upstream/skills/pua/references/methodology-microsoft.md +138 -0
  91. package/assets/pua/upstream/skills/pua/references/methodology-netflix.md +41 -0
  92. package/assets/pua/upstream/skills/pua/references/methodology-pinduoduo.md +33 -0
  93. package/assets/pua/upstream/skills/pua/references/methodology-router.md +81 -0
  94. package/assets/pua/upstream/skills/pua/references/methodology-tencent.md +41 -0
  95. package/assets/pua/upstream/skills/pua/references/methodology-tesla.md +42 -0
  96. package/assets/pua/upstream/skills/pua/references/methodology-xiaomi.md +42 -0
  97. package/assets/pua/upstream/skills/pua/references/p10-protocol.md +127 -0
  98. package/assets/pua/upstream/skills/pua/references/p7-protocol.md +250 -0
  99. package/assets/pua/upstream/skills/pua/references/p9-protocol.md +266 -0
  100. package/assets/pua/upstream/skills/pua/references/platform.md +126 -0
  101. package/assets/pua/upstream/skills/pua/references/runtime-contract.md +65 -0
  102. package/assets/pua/upstream/skills/pua/references/survey.md +292 -0
  103. package/assets/pua/upstream/skills/pua/references/teardown-protocol.md +195 -0
  104. package/assets/pua/upstream/skills/pua-en/SKILL.md +344 -0
  105. package/assets/pua/upstream/skills/pua-ja/SKILL.md +378 -0
  106. package/assets/pua/upstream/skills/pua-loop/SKILL.md +162 -0
  107. package/assets/pua/upstream/skills/shot/SKILL.md +449 -0
  108. package/assets/pua/upstream/skills/yes/SKILL.md +76 -0
  109. package/assets/pua/upstream.json +637 -0
  110. package/assets/screenshots/pua-global-settings.png +0 -0
  111. package/assets/screenshots/pua-session-settings.png +0 -0
  112. package/cordis.patch.yml +5 -0
  113. package/lib/args.d.ts +34 -0
  114. package/lib/args.js +149 -0
  115. package/lib/args.js.map +1 -0
  116. package/lib/client-refresh.d.ts +6 -0
  117. package/lib/client-refresh.js +41 -0
  118. package/lib/client-refresh.js.map +1 -0
  119. package/lib/client.d.ts +30 -0
  120. package/lib/client.js +68 -0
  121. package/lib/client.js.map +7 -0
  122. package/lib/command.d.ts +18 -0
  123. package/lib/command.js +118 -0
  124. package/lib/command.js.map +1 -0
  125. package/lib/configuration.d.ts +89 -0
  126. package/lib/configuration.js +31 -0
  127. package/lib/configuration.js.map +1 -0
  128. package/lib/content.d.ts +14 -0
  129. package/lib/content.js +51 -0
  130. package/lib/content.js.map +1 -0
  131. package/lib/flavors.d.ts +82 -0
  132. package/lib/flavors.js +30 -0
  133. package/lib/flavors.js.map +1 -0
  134. package/lib/hook-content.d.ts +14 -0
  135. package/lib/hook-content.js +63 -0
  136. package/lib/hook-content.js.map +1 -0
  137. package/lib/index.d.ts +21 -0
  138. package/lib/index.js +75 -0
  139. package/lib/index.js.map +1 -0
  140. package/lib/remote-contract.d.ts +148 -0
  141. package/lib/remote-contract.js +20 -0
  142. package/lib/remote-contract.js.map +1 -0
  143. package/lib/remote.d.ts +20 -0
  144. package/lib/remote.js +133 -0
  145. package/lib/remote.js.map +1 -0
  146. package/lib/review.d.ts +4 -0
  147. package/lib/review.js +72 -0
  148. package/lib/review.js.map +1 -0
  149. package/lib/runtime.d.ts +61 -0
  150. package/lib/runtime.js +519 -0
  151. package/lib/runtime.js.map +1 -0
  152. package/lib/session-compat.d.ts +3 -0
  153. package/lib/session-compat.js +9 -0
  154. package/lib/session-compat.js.map +1 -0
  155. package/lib/settings.d.ts +46 -0
  156. package/lib/settings.js +44 -0
  157. package/lib/settings.js.map +1 -0
  158. package/lib/source.d.ts +10 -0
  159. package/lib/source.js +32 -0
  160. package/lib/source.js.map +1 -0
  161. package/lib/state.d.ts +36 -0
  162. package/lib/state.js +187 -0
  163. package/lib/state.js.map +1 -0
  164. package/lib/terminal-observation.d.ts +4 -0
  165. package/lib/terminal-observation.js +12 -0
  166. package/lib/terminal-observation.js.map +1 -0
  167. package/lib/tool-order.d.ts +9 -0
  168. package/lib/tool-order.js +82 -0
  169. package/lib/tool-order.js.map +1 -0
  170. package/package.json +172 -0
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal, local runtime state for the PUA Claude Code hooks.
3
+
4
+ The hook host already supplies the event payload. This helper deliberately
5
+ persists only facts that the host can prove:
6
+
7
+ * a scoped hash of ``session_id + cwd`` (never either raw value);
8
+ * confirmed tool-failure counts and the derived pressure level;
9
+ * hashes of processed ``tool_use_id`` values for idempotency; and
10
+ * a PreCompact checkpoint containing those numeric facts.
11
+
12
+ On an official SessionStart ``source == "clear"`` event, the helper removes
13
+ only that exact hashed scope's local numeric state. A host that reuses a
14
+ session id after ``/clear`` therefore cannot restore or escalate the prior
15
+ task's observations.
16
+
17
+ It does not read transcripts, prompts, model reasoning, tool input, tool
18
+ output, or error text, and it never writes a long-term memory/journal.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import contextlib
25
+ import hashlib
26
+ import json
27
+ import os
28
+ from pathlib import Path
29
+ import sys
30
+ import tempfile
31
+ import time
32
+ from typing import Any, Dict, Iterator, Optional, Tuple
33
+
34
+
35
+ SCHEMA_VERSION = 1
36
+ MAX_PROCESSED_IDS = 128
37
+ MAX_FAILURE_COUNT = 1_000_000
38
+ LOCK_TIMEOUT_SECONDS = 1.0
39
+ STALE_LOCK_SECONDS = 30.0
40
+
41
+
42
+ def utc_timestamp() -> str:
43
+ """Return an auditable UTC timestamp without inspecting user content."""
44
+
45
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
46
+
47
+
48
+ def emit(action: str, count: int = 0, level: int = 0, scope: str = "-") -> None:
49
+ """Emit a machine-readable, non-sensitive response for the shell wrappers."""
50
+
51
+ print(f"{action}\t{count}\t{level}\t{scope}")
52
+
53
+
54
+ def read_payload() -> Dict[str, Any]:
55
+ try:
56
+ payload = json.load(sys.stdin)
57
+ except (json.JSONDecodeError, OSError, ValueError):
58
+ return {}
59
+ return payload if isinstance(payload, dict) else {}
60
+
61
+
62
+ def scoped_identity(payload: Dict[str, Any], cwd_override: str) -> Optional[Tuple[str, str]]:
63
+ """Bind all state to a real Claude session and workspace.
64
+
65
+ A missing official identity is intentionally ignored rather than guessed.
66
+ Guessing from a global file or shell fallback would recreate the old
67
+ cross-session contamination bug.
68
+ """
69
+
70
+ session_id = payload.get("session_id")
71
+ cwd = cwd_override or payload.get("cwd")
72
+ if not isinstance(session_id, str) or not session_id.strip():
73
+ return None
74
+ if not isinstance(cwd, str) or not cwd.strip():
75
+ return None
76
+
77
+ # Resolve the path on the host Python runtime. The shell wrappers convert
78
+ # Git-Bash paths with cygpath before passing --cwd to native Windows Python.
79
+ canonical_cwd = os.path.realpath(os.path.abspath(cwd))
80
+ material = f"pua-runtime-v{SCHEMA_VERSION}\0{session_id}\0{canonical_cwd}".encode(
81
+ "utf-8", "surrogatepass"
82
+ )
83
+ scope = hashlib.sha256(material).hexdigest()
84
+ return scope, scope[:12]
85
+
86
+
87
+ def state_root(home: str, configured_state_dir: str) -> Path:
88
+ """Return the trusted process-local state directory.
89
+
90
+ ``configured_state_dir`` comes only from the wrapper's PUA_STATE_DIR
91
+ environment variable, never from the untrusted hook payload. It is useful
92
+ for an isolated Claude/cc0 process or tests; the normal persistent default
93
+ remains HOME/.pua/runtime-state.
94
+ """
95
+
96
+ if configured_state_dir:
97
+ return Path(configured_state_dir)
98
+ return Path(home) / ".pua" / "runtime-state"
99
+
100
+
101
+ def state_path(home: str, configured_state_dir: str, scope: str) -> Path:
102
+ return state_root(home, configured_state_dir) / f"{scope}.json"
103
+
104
+
105
+ def clamp_int(value: Any, default: int = 0) -> int:
106
+ if isinstance(value, bool):
107
+ return default
108
+ try:
109
+ number = int(value)
110
+ except (TypeError, ValueError):
111
+ return default
112
+ return max(0, min(number, MAX_FAILURE_COUNT))
113
+
114
+
115
+ def pressure_level(count: int) -> int:
116
+ if count >= 5:
117
+ return 4
118
+ if count == 4:
119
+ return 3
120
+ if count == 3:
121
+ return 2
122
+ if count == 2:
123
+ return 1
124
+ return 0
125
+
126
+
127
+ def default_state(scope: str) -> Dict[str, Any]:
128
+ now = utc_timestamp()
129
+ return {
130
+ "schema_version": SCHEMA_VERSION,
131
+ "scope_fingerprint": scope,
132
+ "failure_count": 0,
133
+ "peak_pressure_level": 0,
134
+ "processed_tool_use_ids": [],
135
+ "created_at": now,
136
+ "updated_at": now,
137
+ }
138
+
139
+
140
+ def normalize_state(raw: Any, scope: str) -> Dict[str, Any]:
141
+ """Discard malformed/unneeded fields instead of preserving user content."""
142
+
143
+ state = default_state(scope)
144
+ if not isinstance(raw, dict):
145
+ return state
146
+
147
+ state["failure_count"] = clamp_int(raw.get("failure_count"))
148
+ state["peak_pressure_level"] = max(
149
+ pressure_level(state["failure_count"]),
150
+ min(4, clamp_int(raw.get("peak_pressure_level"))),
151
+ )
152
+ if isinstance(raw.get("created_at"), str):
153
+ state["created_at"] = raw["created_at"]
154
+
155
+ processed = raw.get("processed_tool_use_ids")
156
+ if isinstance(processed, list):
157
+ state["processed_tool_use_ids"] = [
158
+ value
159
+ for value in processed[-MAX_PROCESSED_IDS:]
160
+ if isinstance(value, str)
161
+ and len(value) == 64
162
+ and all(char in "0123456789abcdef" for char in value)
163
+ ]
164
+
165
+ checkpoint = raw.get("checkpoint")
166
+ if isinstance(checkpoint, dict) and isinstance(checkpoint.get("saved_at"), str):
167
+ # Store only numeric runtime observations and a timestamp; no task text,
168
+ # paths, commands, outputs, secrets, or hidden reasoning are retained.
169
+ state["checkpoint"] = {
170
+ "saved_at": checkpoint["saved_at"],
171
+ "kind": "tool_observation_only",
172
+ "failure_count": clamp_int(checkpoint.get("failure_count")),
173
+ "peak_pressure_level": min(4, clamp_int(checkpoint.get("peak_pressure_level"))),
174
+ }
175
+ return state
176
+
177
+
178
+ def load_state(path: Path, scope: str) -> Dict[str, Any]:
179
+ try:
180
+ with path.open("r", encoding="utf-8") as handle:
181
+ raw = json.load(handle)
182
+ except (OSError, json.JSONDecodeError, ValueError):
183
+ raw = None
184
+ return normalize_state(raw, scope)
185
+
186
+
187
+ def write_state(path: Path, state: Dict[str, Any]) -> None:
188
+ root = path.parent
189
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
190
+ try:
191
+ os.chmod(root, 0o700)
192
+ except OSError:
193
+ pass
194
+
195
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{path.stem}.", suffix=".tmp", dir=str(root))
196
+ try:
197
+ try:
198
+ os.chmod(temporary_name, 0o600)
199
+ except OSError:
200
+ pass
201
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
202
+ json.dump(state, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
203
+ handle.write("\n")
204
+ handle.flush()
205
+ os.fsync(handle.fileno())
206
+ os.replace(temporary_name, path)
207
+ finally:
208
+ with contextlib.suppress(FileNotFoundError):
209
+ os.unlink(temporary_name)
210
+
211
+
212
+ @contextlib.contextmanager
213
+ def lock_scope(root: Path, scope: str) -> Iterator[bool]:
214
+ """Use a portable short lock so concurrent hooks cannot double-increment."""
215
+
216
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
217
+ lock = root / f"{scope}.lock"
218
+ deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
219
+ acquired = False
220
+
221
+ while time.monotonic() < deadline:
222
+ try:
223
+ descriptor = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
224
+ except FileExistsError:
225
+ try:
226
+ if time.time() - lock.stat().st_mtime > STALE_LOCK_SECONDS:
227
+ lock.unlink()
228
+ continue
229
+ except OSError:
230
+ pass
231
+ time.sleep(0.025)
232
+ continue
233
+ except OSError:
234
+ break
235
+ else:
236
+ os.close(descriptor)
237
+ acquired = True
238
+ break
239
+
240
+ try:
241
+ yield acquired
242
+ finally:
243
+ if acquired:
244
+ with contextlib.suppress(FileNotFoundError, OSError):
245
+ lock.unlink()
246
+
247
+
248
+ def parsed_exit_code(value: Any) -> Optional[int]:
249
+ if isinstance(value, bool):
250
+ return None
251
+ if isinstance(value, int):
252
+ return value
253
+ if isinstance(value, str):
254
+ try:
255
+ return int(value.strip())
256
+ except ValueError:
257
+ return None
258
+ return None
259
+
260
+
261
+ def explicit_tool_response_failure(response: Any) -> bool:
262
+ """Read only official structured failure fields, never error-text heuristics."""
263
+
264
+ # Restrict inspection to direct host fields. Recursing through arbitrary
265
+ # nested content could mistake a successful command's printed JSON such as
266
+ # {"status":"error"} for a host-level tool failure.
267
+ if not isinstance(response, dict):
268
+ return False
269
+ for key in ("exit_code", "exitCode"):
270
+ exit_code = parsed_exit_code(response.get(key))
271
+ if exit_code is not None and exit_code != 0:
272
+ return True
273
+ if response.get("is_error") is True:
274
+ return True
275
+ return False
276
+
277
+
278
+ def is_interruption(payload: Dict[str, Any]) -> bool:
279
+ value = payload.get("is_interrupt")
280
+ return value is True or (isinstance(value, str) and value.lower() == "true")
281
+
282
+
283
+ def confirmed_failure(payload: Dict[str, Any]) -> bool:
284
+ event_name = payload.get("hook_event_name")
285
+ if event_name == "PostToolUseFailure":
286
+ # User cancellation is not evidence that the task itself failed.
287
+ return not is_interruption(payload)
288
+ if event_name == "PostToolUse":
289
+ # ``tool_result`` was a legacy/non-host field. Only the current official
290
+ # ``tool_response`` structured value participates in accounting.
291
+ return explicit_tool_response_failure(payload.get("tool_response"))
292
+ return False
293
+
294
+
295
+ def tool_use_hash(payload: Dict[str, Any]) -> Optional[str]:
296
+ tool_use_id = payload.get("tool_use_id")
297
+ if not isinstance(tool_use_id, str) or not tool_use_id.strip():
298
+ # The host provides tool_use_id. Without it an event cannot be
299
+ # deduplicated safely, so fail closed rather than inventing a count.
300
+ return None
301
+ return hashlib.sha256(tool_use_id.encode("utf-8", "surrogatepass")).hexdigest()
302
+
303
+
304
+ def command_record(payload: Dict[str, Any], home: str, configured_state_dir: str, cwd_override: str) -> None:
305
+ if payload.get("tool_name") != "Bash" or not confirmed_failure(payload):
306
+ emit("ignored")
307
+ return
308
+
309
+ identity = scoped_identity(payload, cwd_override)
310
+ event_hash = tool_use_hash(payload)
311
+ if identity is None or event_hash is None:
312
+ emit("ignored")
313
+ return
314
+ scope, short_scope = identity
315
+ path = state_path(home, configured_state_dir, scope)
316
+
317
+ with lock_scope(path.parent, scope) as acquired:
318
+ if not acquired:
319
+ emit("ignored")
320
+ return
321
+ state = load_state(path, scope)
322
+ if event_hash in state["processed_tool_use_ids"]:
323
+ emit("duplicate", state["failure_count"], state["peak_pressure_level"], short_scope)
324
+ return
325
+
326
+ count = min(state["failure_count"] + 1, MAX_FAILURE_COUNT)
327
+ level = pressure_level(count)
328
+ state["failure_count"] = count
329
+ state["peak_pressure_level"] = max(state["peak_pressure_level"], level)
330
+ state["processed_tool_use_ids"] = (state["processed_tool_use_ids"] + [event_hash])[-MAX_PROCESSED_IDS:]
331
+ state["updated_at"] = utc_timestamp()
332
+ write_state(path, state)
333
+ emit("updated", count, state["peak_pressure_level"], short_scope)
334
+
335
+
336
+ def command_checkpoint(payload: Dict[str, Any], home: str, configured_state_dir: str, cwd_override: str) -> None:
337
+ if payload.get("hook_event_name") != "PreCompact":
338
+ emit("ignored")
339
+ return
340
+ identity = scoped_identity(payload, cwd_override)
341
+ if identity is None:
342
+ emit("ignored")
343
+ return
344
+ scope, short_scope = identity
345
+ path = state_path(home, configured_state_dir, scope)
346
+
347
+ with lock_scope(path.parent, scope) as acquired:
348
+ if not acquired:
349
+ emit("ignored")
350
+ return
351
+ state = load_state(path, scope)
352
+ state["checkpoint"] = {
353
+ "saved_at": utc_timestamp(),
354
+ "kind": "tool_observation_only",
355
+ "failure_count": state["failure_count"],
356
+ "peak_pressure_level": state["peak_pressure_level"],
357
+ }
358
+ state["updated_at"] = utc_timestamp()
359
+ write_state(path, state)
360
+ emit("saved", state["failure_count"], state["peak_pressure_level"], short_scope)
361
+
362
+
363
+ def command_restore(payload: Dict[str, Any], home: str, configured_state_dir: str, cwd_override: str) -> None:
364
+ if payload.get("hook_event_name") != "SessionStart":
365
+ emit("ignored")
366
+ return
367
+ # Defense in depth for callers other than session-restore.sh: an official
368
+ # /clear event is a fresh-context boundary, never a restore request.
369
+ if payload.get("source") == "clear":
370
+ emit("ignored")
371
+ return
372
+ identity = scoped_identity(payload, cwd_override)
373
+ if identity is None:
374
+ emit("ignored")
375
+ return
376
+ scope, short_scope = identity
377
+ path = state_path(home, configured_state_dir, scope)
378
+ if not path.is_file():
379
+ emit("ignored")
380
+ return
381
+ state = load_state(path, scope)
382
+ checkpoint = state.get("checkpoint")
383
+ if not isinstance(checkpoint, dict) or not isinstance(checkpoint.get("saved_at"), str):
384
+ emit("ignored")
385
+ return
386
+ emit(
387
+ "restored",
388
+ clamp_int(checkpoint.get("failure_count")),
389
+ min(4, clamp_int(checkpoint.get("peak_pressure_level"))),
390
+ short_scope,
391
+ )
392
+
393
+
394
+ def command_clear(payload: Dict[str, Any], home: str, configured_state_dir: str, cwd_override: str) -> None:
395
+ """Forget only this plugin's exact scope on an official ``/clear`` event.
396
+
397
+ This intentionally neither traverses the configured state root nor reads
398
+ task content. It can unlink only the SHA-256-derived state filename for
399
+ the supplied official session/workspace identity, so other sessions and
400
+ user files remain untouched.
401
+ """
402
+
403
+ if payload.get("hook_event_name") != "SessionStart" or payload.get("source") != "clear":
404
+ emit("ignored")
405
+ return
406
+ identity = scoped_identity(payload, cwd_override)
407
+ if identity is None:
408
+ emit("ignored")
409
+ return
410
+ scope, short_scope = identity
411
+ path = state_path(home, configured_state_dir, scope)
412
+
413
+ # A no-state /clear is a no-op. In particular, do not create the default
414
+ # runtime directory merely because a disabled/missing configuration sees a
415
+ # clear lifecycle event.
416
+ if not path.is_file():
417
+ emit("cleared", 0, 0, short_scope)
418
+ return
419
+
420
+ with lock_scope(path.parent, scope) as acquired:
421
+ if not acquired:
422
+ emit("ignored")
423
+ return
424
+ with contextlib.suppress(FileNotFoundError, OSError):
425
+ path.unlink()
426
+ emit("cleared", 0, 0, short_scope)
427
+
428
+
429
+ def parse_args() -> argparse.Namespace:
430
+ parser = argparse.ArgumentParser(add_help=False)
431
+ parser.add_argument("operation", choices=("record", "checkpoint", "restore", "clear"))
432
+ parser.add_argument("--home", required=True)
433
+ parser.add_argument("--state-dir", default="")
434
+ parser.add_argument("--cwd", default="")
435
+ return parser.parse_args()
436
+
437
+
438
+ def main() -> int:
439
+ args = parse_args()
440
+ payload = read_payload()
441
+ if not args.home:
442
+ emit("ignored")
443
+ return 0
444
+ try:
445
+ if args.operation == "record":
446
+ command_record(payload, args.home, args.state_dir, args.cwd)
447
+ elif args.operation == "checkpoint":
448
+ command_checkpoint(payload, args.home, args.state_dir, args.cwd)
449
+ elif args.operation == "restore":
450
+ command_restore(payload, args.home, args.state_dir, args.cwd)
451
+ else:
452
+ command_clear(payload, args.home, args.state_dir, args.cwd)
453
+ except Exception:
454
+ # A hook must never leak event contents or block the host on state I/O.
455
+ emit("ignored")
456
+ return 0
457
+
458
+
459
+ if __name__ == "__main__":
460
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ #!/bin/bash
2
+ # PUA session sanitizer — strips sensitive data before upload
3
+ # Layers: (1) known-format blacklist (2) K=V context-aware (3) Shannon entropy
4
+ # Usage: bash sanitize-session.sh [input.jsonl] [output.jsonl]
5
+
6
+ INPUT="${1:-$(ls -t ~/.claude/projects/*/*.jsonl 2>/dev/null | head -1)}"
7
+ OUTPUT="${2:-/tmp/pua-sanitized-session.jsonl}"
8
+
9
+ if [ -z "$INPUT" ] || [ ! -f "$INPUT" ]; then
10
+ echo "No session file found" >&2
11
+ exit 1
12
+ fi
13
+
14
+ export PUA_INPUT="$INPUT"
15
+ export PUA_OUTPUT="$OUTPUT"
16
+
17
+ python3 - << 'PYEOF'
18
+ import os, json, re, math
19
+ from collections import Counter
20
+
21
+ input_file = os.environ["PUA_INPUT"]
22
+ output_file = os.environ["PUA_OUTPUT"]
23
+
24
+ # ── Layer 1: Known-format blacklist ──────────────────────────────────────────
25
+ PATTERNS = [
26
+ # --- File paths ---
27
+ # Quoted forms first (handles spaces in path); unquoted forms follow
28
+ (r'"/Users/[^"\n]+"', '"[PATH]"'), # macOS quoted
29
+ (r"/Users/[^\s\n\"'`]+", "[PATH]"), # macOS unquoted
30
+ (r'"/home/[^"\n]+"', '"[PATH]"'), # Linux quoted
31
+ (r"/home/[^\s\n\"'`]+", "[PATH]"), # Linux unquoted
32
+ (r"/root/[^\s\n\"'`]+", "[PATH]"), # Linux root
33
+ (r"/mnt/[a-z]/[^\s\n\"'`]+", "[PATH]"), # WSL
34
+ (r"/[a-z]/(?:Users|home)/[^\s\n\"'`]+", "[PATH]"), # Git Bash (/c/Users/...)
35
+ (r"[A-Za-z]:\\[^\s\n\"]+", "[PATH]"), # Windows backslash
36
+ (r"[A-Za-z]:/[A-Za-z][^\s\n\"]+", "[PATH]"), # Windows forward-slash
37
+
38
+ # --- Anthropic / OpenAI (specific prefixes first, generic last) ---
39
+ (r"sk-ant-[a-zA-Z0-9_-]{20,}", "[API_KEY]"),
40
+ (r"sk-proj-[a-zA-Z0-9_-]{20,}", "[API_KEY]"),
41
+ (r"sk-[a-zA-Z0-9]{20,}", "[API_KEY]"),
42
+
43
+ # --- Stripe ---
44
+ (r"sk_(?:live|test)_[a-zA-Z0-9]{24,}", "[STRIPE_KEY]"),
45
+ (r"pk_(?:live|test)_[a-zA-Z0-9]{24,}", "[STRIPE_KEY]"),
46
+ (r"rk_(?:live|test)_[a-zA-Z0-9]{24,}", "[STRIPE_KEY]"),
47
+
48
+ # --- GitHub ---
49
+ (r"github_pat_[a-zA-Z0-9_]{40,}", "[GITHUB_TOKEN]"), # fine-grained PAT
50
+ (r"gh[opsr]_[a-zA-Z0-9]{36}", "[GITHUB_TOKEN]"), # ghp_/gho_/ghs_/ghr_
51
+
52
+ # --- AWS ---
53
+ (r"AKIA[A-Z0-9]{16}", "[AWS_KEY]"),
54
+
55
+ # --- International services ---
56
+ (r"glpat-[a-zA-Z0-9_-]{20}", "[GITLAB_TOKEN]"),
57
+ (r"xox[bpas]-[0-9A-Za-z-]+", "[SLACK_TOKEN]"),
58
+ (r"hf_[a-zA-Z0-9]{30,}", "[HF_TOKEN]"),
59
+ (r"npm_[a-zA-Z0-9]{36}", "[NPM_TOKEN]"),
60
+ (r"AIza[0-9A-Za-z_-]{35}", "[FIREBASE_KEY]"),
61
+ (r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "[SENDGRID_KEY]"),
62
+ (r"AC[a-f0-9]{32}", "[TWILIO_SID]"),
63
+
64
+ # --- Chinese cloud services ---
65
+ (r"LTAI[a-zA-Z0-9]{16,20}", "[ALIYUN_KEY]"), # Alibaba Cloud AK
66
+ (r"AKID[a-zA-Z0-9]{32,}", "[TENCENT_KEY]"), # Tencent Cloud SecretId
67
+
68
+ # --- Auth tokens ---
69
+ (r"ya29\.[a-zA-Z0-9_-]{60,}", "[GOOGLE_OAUTH]"),
70
+ (r"eyJ[a-zA-Z0-9_-]{20,}", "[JWT]"),
71
+ (r"Bearer\s+[a-zA-Z0-9_./-]{10,}", "[BEARER_TOKEN]"),
72
+
73
+ # --- PEM private keys (multiline) ---
74
+ (r"(?s)-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----.*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
75
+ "[PRIVATE_KEY]"),
76
+
77
+ # --- Database connection strings ---
78
+ (r"(?:postgresql|mysql|mongodb(?:\+srv)?|redis|mssql)://[^\s\"']+",
79
+ "[DB_URL]"),
80
+
81
+ # --- PII ---
82
+ (r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "[EMAIL]"),
83
+ (r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]"),
84
+ (r"\b1[3-9]\d{9}\b", "[CN_PHONE]"), # Chinese mobile
85
+ (r"ssh-(?:rsa|ed25519|ecdsa)\s+\S+", "[SSH_KEY]"),
86
+
87
+ # --- Credentials in URLs ---
88
+ (r"://[^:\"'\s]+:[^@\"'\s]+@", "://[CRED]@"),
89
+ (r"[?&](?:password|passwd|pwd|secret|token|api_?key)=[^&\s\"']+",
90
+ "[QUERYPARAM_CRED]"),
91
+ ]
92
+
93
+ # ── Layer 2: K=V context-aware redaction ────────────────────────────────────
94
+ _KV = (
95
+ r"(?:password|passwd|pwd|secret|api[_-]?key|apikey|app[_-]?key|"
96
+ r"access[_-]?key|secret[_-]?key|private[_-]?key|"
97
+ r"auth[_-]?token|access[_-]?token|refresh[_-]?token|"
98
+ r"client[_-]?secret|app[_-]?secret|"
99
+ r"db[_-]?pass(?:word)?|database[_-]?(?:url|password)|"
100
+ r"encryption[_-]?key|signing[_-]?key|"
101
+ r"密钥|密码|口令)"
102
+ )
103
+
104
+ KV_PATTERNS = [
105
+ # KEY="VALUE" (env file with double-quoted values)
106
+ (r'(?i)(' + _KV + r')\s*=\s*"([^"]{8,})"', r'\1="[REDACTED]"'),
107
+ # KEY='VALUE' (env file with single-quoted values)
108
+ (r"(?i)(" + _KV + r")\s*=\s*'([^']{8,})'", r"\1='[REDACTED]'"),
109
+ # KEY=VALUE or KEY = VALUE (env file, shell, unquoted)
110
+ (r"(?i)(" + _KV + r")\s*=\s*([^\s\n\"']{8,})", r"\1=[REDACTED]"),
111
+ # export KEY="VALUE" (shell)
112
+ (r"(?i)export\s+(" + _KV + r")\s*=\s*([^\s\n\"']{8,})", r"export \1=[REDACTED]"),
113
+ # "key": "value" (JSON)
114
+ (r'(?i)"(' + _KV + r')"\s*:\s*"([^"]{8,})"', r'"\1": "[REDACTED]"'),
115
+ # key: value (YAML unquoted)
116
+ (r"(?i)(" + _KV + r")\s*:\s*([a-zA-Z0-9_\-+/]{12,})", r"\1: [REDACTED]"),
117
+ ]
118
+
119
+ # ── Layer 3: Shannon entropy detection ──────────────────────────────────────
120
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9+/=_\-]{32,}")
121
+ _PURE_HEX_RE = re.compile(r"^[0-9a-f]+$") # git hashes, UUID hex — max entropy = log2(16) = 4.0
122
+
123
+ def _entropy(s):
124
+ n = len(s)
125
+ freq = Counter(s)
126
+ return -sum((c / n) * math.log2(c / n) for c in freq.values())
127
+
128
+ def _redact_entropy(text):
129
+ def _sub(m):
130
+ s = m.group()
131
+ # Pure lowercase hex strings (git hashes, UUIDs) have theoretical max entropy = 4.0;
132
+ # use threshold 4.1 so they are never flagged as secrets.
133
+ threshold = 4.1 if _PURE_HEX_RE.match(s) else 3.5
134
+ return "[HIGH_ENTROPY_SECRET]" if _entropy(s) > threshold else s
135
+ return _TOKEN_RE.sub(_sub, text)
136
+
137
+ # ── Sanitize ─────────────────────────────────────────────────────────────────
138
+ def sanitize(text):
139
+ if not isinstance(text, str):
140
+ return text
141
+ for pat, rep in PATTERNS:
142
+ text = re.sub(pat, rep, text)
143
+ for pat, rep in KV_PATTERNS:
144
+ text = re.sub(pat, rep, text)
145
+ text = _redact_entropy(text)
146
+ return text
147
+
148
+ def sanitize_obj(obj):
149
+ if isinstance(obj, str): return sanitize(obj)
150
+ if isinstance(obj, dict): return {k: sanitize_obj(v) for k, v in obj.items()}
151
+ if isinstance(obj, list): return [sanitize_obj(i) for i in obj]
152
+ return obj
153
+
154
+ count = 0
155
+ skipped = 0
156
+ with open(input_file, encoding="utf-8") as f, open(output_file, "w", encoding="utf-8") as out:
157
+ for line in f:
158
+ try:
159
+ out.write(json.dumps(sanitize_obj(json.loads(line)), ensure_ascii=False) + "\n")
160
+ count += 1
161
+ except Exception:
162
+ skipped += 1
163
+
164
+ print(f"Sanitized {count} lines -> {output_file}" + (f" (skipped {skipped})" if skipped else ""))
165
+ PYEOF