@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.
- package/CHANGELOG.md +152 -0
- package/LICENSE +201 -0
- package/NOTICE +38 -0
- package/README.md +150 -0
- package/assets/pua/command-again.md +23 -0
- package/assets/pua/command-done-check.md +21 -0
- package/assets/pua/command-evidence.md +18 -0
- package/assets/pua/flavors.md +388 -0
- package/assets/pua/methodology-alibaba.md +33 -0
- package/assets/pua/methodology-amazon.md +42 -0
- package/assets/pua/methodology-apple.md +42 -0
- package/assets/pua/methodology-baidu.md +33 -0
- package/assets/pua/methodology-bytedance.md +41 -0
- package/assets/pua/methodology-ding.md +75 -0
- package/assets/pua/methodology-huawei.md +95 -0
- package/assets/pua/methodology-jd.md +42 -0
- package/assets/pua/methodology-meituan.md +41 -0
- package/assets/pua/methodology-microsoft.md +138 -0
- package/assets/pua/methodology-netflix.md +41 -0
- package/assets/pua/methodology-pinduoduo.md +33 -0
- package/assets/pua/methodology-tencent.md +41 -0
- package/assets/pua/methodology-tesla.md +42 -0
- package/assets/pua/methodology-xiaomi.md +42 -0
- package/assets/pua/upstream/agents/cto-p10.md +87 -0
- package/assets/pua/upstream/agents/pua-action-executor.md +60 -0
- package/assets/pua/upstream/agents/pua-policy-guardian.md +54 -0
- package/assets/pua/upstream/agents/pua-self-reviewer.md +62 -0
- package/assets/pua/upstream/agents/pua-verifier.md +61 -0
- package/assets/pua/upstream/agents/senior-engineer-p7.md +116 -0
- package/assets/pua/upstream/agents/tech-lead-p9.md +97 -0
- package/assets/pua/upstream/commands/again.md +23 -0
- package/assets/pua/upstream/commands/cancel-pua-loop.md +62 -0
- package/assets/pua/upstream/commands/ding.md +25 -0
- package/assets/pua/upstream/commands/done-check.md +21 -0
- package/assets/pua/upstream/commands/evidence.md +18 -0
- package/assets/pua/upstream/commands/flavor.md +6 -0
- package/assets/pua/upstream/commands/kpi.md +5 -0
- package/assets/pua/upstream/commands/mama.md +5 -0
- package/assets/pua/upstream/commands/off.md +41 -0
- package/assets/pua/upstream/commands/offline.md +38 -0
- package/assets/pua/upstream/commands/on.md +15 -0
- package/assets/pua/upstream/commands/p10.md +5 -0
- package/assets/pua/upstream/commands/p7.md +5 -0
- package/assets/pua/upstream/commands/p9.md +5 -0
- package/assets/pua/upstream/commands/pro.md +5 -0
- package/assets/pua/upstream/commands/pua-loop.md +5 -0
- package/assets/pua/upstream/commands/pua.md +44 -0
- package/assets/pua/upstream/commands/reap-orphans.md +68 -0
- package/assets/pua/upstream/commands/survey.md +9 -0
- package/assets/pua/upstream/commands/team-status.md +56 -0
- package/assets/pua/upstream/commands/teardown-all.md +80 -0
- package/assets/pua/upstream/commands/yes.md +5 -0
- package/assets/pua/upstream/hooks/checkpoint-save.sh +56 -0
- package/assets/pua/upstream/hooks/failure-detector.sh +266 -0
- package/assets/pua/upstream/hooks/flavor-helper.sh +300 -0
- package/assets/pua/upstream/hooks/frustration-trigger.sh +61 -0
- package/assets/pua/upstream/hooks/hooks.json +114 -0
- package/assets/pua/upstream/hooks/integrity-guard.sh +494 -0
- package/assets/pua/upstream/hooks/pua-loop-hook.sh +360 -0
- package/assets/pua/upstream/hooks/runtime-state.py +460 -0
- package/assets/pua/upstream/hooks/sanitize-session.sh +165 -0
- package/assets/pua/upstream/hooks/session-restore.sh +189 -0
- package/assets/pua/upstream/hooks/stop-feedback.sh +51 -0
- package/assets/pua/upstream/hooks/subagent-teardown.sh +56 -0
- package/assets/pua/upstream/skills/ding/SKILL.md +83 -0
- package/assets/pua/upstream/skills/ding/references/ding-reminders.md +77 -0
- package/assets/pua/upstream/skills/ding/references/methodology-ding.md +75 -0
- package/assets/pua/upstream/skills/mama/SKILL.md +117 -0
- package/assets/pua/upstream/skills/p10/SKILL.md +13 -0
- package/assets/pua/upstream/skills/p7/SKILL.md +13 -0
- package/assets/pua/upstream/skills/p9/SKILL.md +15 -0
- package/assets/pua/upstream/skills/pro/SKILL.md +69 -0
- package/assets/pua/upstream/skills/pua/SKILL.md +438 -0
- package/assets/pua/upstream/skills/pua/references/agent-team.md +110 -0
- package/assets/pua/upstream/skills/pua/references/de-escalation-protocol.md +134 -0
- package/assets/pua/upstream/skills/pua/references/ding-reminders.md +77 -0
- package/assets/pua/upstream/skills/pua/references/display-protocol.md +63 -0
- package/assets/pua/upstream/skills/pua/references/evolution-protocol.md +187 -0
- package/assets/pua/upstream/skills/pua/references/flavors.md +388 -0
- package/assets/pua/upstream/skills/pua/references/harness-governance.md +159 -0
- package/assets/pua/upstream/skills/pua/references/methodology-alibaba.md +33 -0
- package/assets/pua/upstream/skills/pua/references/methodology-amazon.md +42 -0
- package/assets/pua/upstream/skills/pua/references/methodology-apple.md +42 -0
- package/assets/pua/upstream/skills/pua/references/methodology-baidu.md +33 -0
- package/assets/pua/upstream/skills/pua/references/methodology-bytedance.md +41 -0
- package/assets/pua/upstream/skills/pua/references/methodology-ding.md +75 -0
- package/assets/pua/upstream/skills/pua/references/methodology-huawei.md +95 -0
- package/assets/pua/upstream/skills/pua/references/methodology-jd.md +42 -0
- package/assets/pua/upstream/skills/pua/references/methodology-meituan.md +41 -0
- package/assets/pua/upstream/skills/pua/references/methodology-microsoft.md +138 -0
- package/assets/pua/upstream/skills/pua/references/methodology-netflix.md +41 -0
- package/assets/pua/upstream/skills/pua/references/methodology-pinduoduo.md +33 -0
- package/assets/pua/upstream/skills/pua/references/methodology-router.md +81 -0
- package/assets/pua/upstream/skills/pua/references/methodology-tencent.md +41 -0
- package/assets/pua/upstream/skills/pua/references/methodology-tesla.md +42 -0
- package/assets/pua/upstream/skills/pua/references/methodology-xiaomi.md +42 -0
- package/assets/pua/upstream/skills/pua/references/p10-protocol.md +127 -0
- package/assets/pua/upstream/skills/pua/references/p7-protocol.md +250 -0
- package/assets/pua/upstream/skills/pua/references/p9-protocol.md +266 -0
- package/assets/pua/upstream/skills/pua/references/platform.md +126 -0
- package/assets/pua/upstream/skills/pua/references/runtime-contract.md +65 -0
- package/assets/pua/upstream/skills/pua/references/survey.md +292 -0
- package/assets/pua/upstream/skills/pua/references/teardown-protocol.md +195 -0
- package/assets/pua/upstream/skills/pua-en/SKILL.md +344 -0
- package/assets/pua/upstream/skills/pua-ja/SKILL.md +378 -0
- package/assets/pua/upstream/skills/pua-loop/SKILL.md +162 -0
- package/assets/pua/upstream/skills/shot/SKILL.md +449 -0
- package/assets/pua/upstream/skills/yes/SKILL.md +76 -0
- package/assets/pua/upstream.json +637 -0
- package/assets/screenshots/pua-global-settings.png +0 -0
- package/assets/screenshots/pua-session-settings.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/args.d.ts +34 -0
- package/lib/args.js +149 -0
- package/lib/args.js.map +1 -0
- package/lib/client-refresh.d.ts +6 -0
- package/lib/client-refresh.js +41 -0
- package/lib/client-refresh.js.map +1 -0
- package/lib/client.d.ts +30 -0
- package/lib/client.js +68 -0
- package/lib/client.js.map +7 -0
- package/lib/command.d.ts +18 -0
- package/lib/command.js +118 -0
- package/lib/command.js.map +1 -0
- package/lib/configuration.d.ts +89 -0
- package/lib/configuration.js +31 -0
- package/lib/configuration.js.map +1 -0
- package/lib/content.d.ts +14 -0
- package/lib/content.js +51 -0
- package/lib/content.js.map +1 -0
- package/lib/flavors.d.ts +82 -0
- package/lib/flavors.js +30 -0
- package/lib/flavors.js.map +1 -0
- package/lib/hook-content.d.ts +14 -0
- package/lib/hook-content.js +63 -0
- package/lib/hook-content.js.map +1 -0
- package/lib/index.d.ts +21 -0
- package/lib/index.js +75 -0
- package/lib/index.js.map +1 -0
- package/lib/remote-contract.d.ts +148 -0
- package/lib/remote-contract.js +20 -0
- package/lib/remote-contract.js.map +1 -0
- package/lib/remote.d.ts +20 -0
- package/lib/remote.js +133 -0
- package/lib/remote.js.map +1 -0
- package/lib/review.d.ts +4 -0
- package/lib/review.js +72 -0
- package/lib/review.js.map +1 -0
- package/lib/runtime.d.ts +61 -0
- package/lib/runtime.js +519 -0
- package/lib/runtime.js.map +1 -0
- package/lib/session-compat.d.ts +3 -0
- package/lib/session-compat.js +9 -0
- package/lib/session-compat.js.map +1 -0
- package/lib/settings.d.ts +46 -0
- package/lib/settings.js +44 -0
- package/lib/settings.js.map +1 -0
- package/lib/source.d.ts +10 -0
- package/lib/source.js +32 -0
- package/lib/source.js.map +1 -0
- package/lib/state.d.ts +36 -0
- package/lib/state.js +187 -0
- package/lib/state.js.map +1 -0
- package/lib/terminal-observation.d.ts +4 -0
- package/lib/terminal-observation.js +12 -0
- package/lib/terminal-observation.js.map +1 -0
- package/lib/tool-order.d.ts +9 -0
- package/lib/tool-order.js +82 -0
- package/lib/tool-order.js.map +1 -0
- 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
|