@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,114 @@
1
+ {
2
+ "description": "PUA v3 hooks: optional context notes, failure-pattern reminders, local state continuity, and cleanup helpers. All hooks are local-only — no telemetry, no network requests.",
3
+ "hooks": {
4
+ "UserPromptSubmit": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/frustration-trigger.sh\"",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "PostToolUse": [
16
+ {
17
+ "matcher": "Bash",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/failure-detector.sh\"",
22
+ "timeout": 5
23
+ }
24
+ ]
25
+ }
26
+ ],
27
+ "PostToolUseFailure": [
28
+ {
29
+ "matcher": "Bash",
30
+ "hooks": [
31
+ {
32
+ "type": "command",
33
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/failure-detector.sh\"",
34
+ "timeout": 5
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "PreCompact": [
40
+ {
41
+ "matcher": "*",
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/checkpoint-save.sh\"",
46
+ "timeout": 5
47
+ }
48
+ ]
49
+ }
50
+ ],
51
+ "SessionStart": [
52
+ {
53
+ "matcher": "compact",
54
+ "hooks": [
55
+ {
56
+ "type": "command",
57
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
58
+ "timeout": 5
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "matcher": "startup|resume|clear",
64
+ "hooks": [
65
+ {
66
+ "type": "command",
67
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
68
+ "timeout": 5
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "Stop": [
74
+ {
75
+ "matcher": "*",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pua-loop-hook.sh\""
80
+ },
81
+ {
82
+ "type": "command",
83
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-feedback.sh\"",
84
+ "timeout": 10
85
+ }
86
+ ]
87
+ }
88
+ ],
89
+ "SubagentStop": [
90
+ {
91
+ "matcher": "*",
92
+ "hooks": [
93
+ {
94
+ "type": "command",
95
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-teardown.sh\"",
96
+ "timeout": 5
97
+ }
98
+ ]
99
+ }
100
+ ],
101
+ "PreToolUse": [
102
+ {
103
+ "matcher": "Bash|Read|Grep|Glob|Edit|Write|MultiEdit|WebSearch|WebFetch",
104
+ "hooks": [
105
+ {
106
+ "type": "command",
107
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/integrity-guard.sh\"",
108
+ "timeout": 5
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ }
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env bash
2
+ # PUA Integrity Guard — PreToolUse anti-cheating gate
3
+ # Separates action rights from scoring / verifier / environment-modification rights.
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
+ source "${SCRIPT_DIR}/flavor-helper.sh"
8
+ PUA_PY="$(pua_python_cmd 2>/dev/null || true)"
9
+ [ -n "$PUA_PY" ] || exit 0
10
+ PUA_CONFIG_PY="$(pua_to_python_path "$(pua_config_file)")"
11
+ export PUA_CONFIG_PY
12
+
13
+ TMP_INPUT=$(mktemp)
14
+ trap 'rm -f "$TMP_INPUT"' EXIT
15
+ cat > "$TMP_INPUT"
16
+
17
+ "$PUA_PY" - "$TMP_INPUT" <<'PY'
18
+ import io
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ input_path = Path(sys.argv[1])
27
+ try:
28
+ data = json.loads(input_path.read_text(encoding='utf-8') or '{}')
29
+ except Exception:
30
+ sys.exit(0)
31
+
32
+ PUA_MARKERS = [
33
+ 'PUA ACTIVATED',
34
+ 'PUA Always-On',
35
+ 'PUA生效',
36
+ '[PUA',
37
+ 'pua:pua',
38
+ 'pua-loop',
39
+ 'Confidence Gate',
40
+ ]
41
+
42
+
43
+ def read_text_tail(path: str, max_bytes: int = 200_000) -> str:
44
+ try:
45
+ p = Path(path).expanduser()
46
+ if not p.is_file():
47
+ return ''
48
+ size = p.stat().st_size
49
+ with p.open('rb') as f:
50
+ if size > max_bytes:
51
+ f.seek(size - max_bytes)
52
+ return f.read().decode('utf-8', errors='ignore')
53
+ except Exception:
54
+ return ''
55
+
56
+
57
+ def config_always_on() -> bool:
58
+ cfg = os.environ.get('PUA_CONFIG_PY') or os.environ.get('PUA_CONFIG') or str(Path.home() / '.pua' / 'config.json')
59
+ try:
60
+ return bool(json.loads(Path(cfg).expanduser().read_text(encoding='utf-8')).get('always_on', False))
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ def is_active() -> bool:
66
+ if os.environ.get('PUA_INTEGRITY_FORCE') == '1' or os.environ.get('PUA_FORCE_ON') == '1':
67
+ return True
68
+ if config_always_on():
69
+ return True
70
+ transcript = data.get('transcript_path') or ''
71
+ text = read_text_tail(transcript)
72
+ return any(marker in text for marker in PUA_MARKERS)
73
+
74
+ if not is_active():
75
+ sys.exit(0)
76
+
77
+ tool = data.get('tool_name') or ''
78
+ tool_input = data.get('tool_input') or {}
79
+
80
+ # Keep these patterns intentionally conservative: they target governance assets,
81
+ # not ordinary application files.
82
+ PROTECTED_WRITE_PATTERNS = [
83
+ (re.compile(r'(^|/)(tests?|__tests__|test|spec|evals?|e2e|cypress|playwright)(/|$)|\.(test|spec)\.[A-Za-z0-9]+$|(^|/)(playwright|cypress)\.config\.', re.I), 'Grader gaming risk: tests/evals/E2E assets are scoring-adjacent.'),
84
+ (re.compile(r'(^|/)(score|scoring|grader|verifier)(\.[A-Za-z0-9]+)?$|(^|/)(scoring|grader|verifier)(/|$)', re.I), 'Grader gaming risk: scoring/verifier assets must not be changed by the executor.'),
85
+ (re.compile(r'(^|/)\.github/workflows(/|$)|(^|/)ci(/|$)|(^|/)(buildkite|circleci|jenkins)(/|$)', re.I), 'Environment-modification risk: CI gates are part of the verifier boundary.'),
86
+ (re.compile(r'(^|/)(feature_contracts\.json|claude-progress\.md|progress\.json|status\.json)$', re.I), 'Self-report cheating risk: status/progress files need verifier ownership.'),
87
+ (re.compile(r'(^|/)(memory|memories)(/|$)|(^|/)(decisions|failures)\.log\.jsonl$|(^|/)CLAUDE\.md$|(^|/)\.claude/(settings|settings\.local)\.json$', re.I), 'Persistent-memory risk: long-term memory/status must be append-only or approved.'),
88
+ (re.compile(r'(^|/)\.env(\.|$)|(^|/)(secrets?|credentials?)(\.|/|$)', re.I), 'Capability-abuse risk: secrets and environment files require human gate.'),
89
+ ]
90
+
91
+ CONTAMINATION_PATTERNS = [
92
+ (re.compile(r'(^|/)(hidden[-_]?tests?|verifier[-_]?private|private[-_]?verifier|hidden[-_]?cases?)(/|$)', re.I), 'Solution contamination risk: hidden tests/verifier-private assets must stay outside the agent workspace.'),
93
+ (re.compile(r'(^|/)(hidden_solution|gold_patch|golden_patch|benchmark_answers?|answer_key|official_solution)(\.|/|$)', re.I), 'Solution contamination risk: hidden solution / benchmark answer artifact detected.'),
94
+ ]
95
+
96
+ SENSITIVE_READ_PATTERNS = [
97
+ (re.compile(r'(^|/)\.env(\.|$)|(^|/)(secrets?|credentials?)(\.|/|$)|(^|/)(id_rsa|id_ed25519|private[-_]?key)(\.|$)', re.I), 'Capability-abuse risk: secrets and credentials require human gate.'),
98
+ ]
99
+
100
+ MUTATING_BASH = re.compile(
101
+ r'(^|[;&|()\s])(rm|mv|cp|chmod|chown|truncate|tee|touch|mkdir|rmdir|git\s+(reset|clean|checkout|restore)|sed\s+(-i|--in-place)|perl\s+-p?i|python3?\s+.*open\(|node\s+.*writeFile|npm\s+version)\b|>>|>[^&]',
102
+ re.I | re.S,
103
+ )
104
+ READING_BASH = re.compile(r'(^|[;&|()\s])(cat|less|more|head|tail|sed|awk|grep|rg|find|python3?|node)\b', re.I)
105
+ WEB_CONTAMINATION = re.compile(r'(hidden[-_\s]+solution|official[-_\s]+solution|gold[-_\s]+patch|benchmark[-_\s]+answer|swe[-_\s]?bench[-_\s]+solution|leaderboard[-_\s]+answer)', re.I)
106
+ GIT_MUTATING_SUBCOMMANDS = {
107
+ 'reset', 'clean', 'checkout', 'restore', 'apply', 'am', 'rm', 'mv',
108
+ }
109
+ GIT_DRY_RUN_SUBCOMMANDS = {'clean', 'rm', 'mv'}
110
+ GIT_APPLY_PREVIEW_OPTIONS = ('check', 'stat', 'numstat', 'summary')
111
+ GIT_GLOBAL_OPTIONS_WITH_VALUE = {
112
+ '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',
113
+ '--super-prefix', '--config-env',
114
+ }
115
+ GIT_GLOBAL_OPTIONS_WITH_ATTACHED_VALUE = (
116
+ '-C', '-c', '--git-dir=', '--work-tree=', '--namespace=', '--exec-path=',
117
+ '--super-prefix=', '--config-env=',
118
+ )
119
+ GIT_PATHSPEC_MAGIC = re.compile(r'(^:|[\*\?\[\]\{\}\$])')
120
+
121
+
122
+ def command_tokens(command: str):
123
+ try:
124
+ return shlex.split(command)
125
+ except Exception:
126
+ return re.split(r'\s+', command)
127
+
128
+
129
+ def is_direct_git_command(tokens) -> bool:
130
+ if not tokens:
131
+ return False
132
+ executable = tokens[0].replace('\\', '/').rsplit('/', 1)[-1].lower()
133
+ return executable in {'git', 'git.exe'}
134
+
135
+
136
+ def git_subcommand_and_args(tokens):
137
+ """Return a direct Git subcommand and its arguments, if one is present."""
138
+ if not is_direct_git_command(tokens):
139
+ return None
140
+
141
+ arg_index = 1
142
+ while arg_index < len(tokens):
143
+ arg = tokens[arg_index]
144
+ if arg == '--':
145
+ return None
146
+ if arg in {'-h', '--help', '--version'}:
147
+ return None
148
+ if arg in GIT_GLOBAL_OPTIONS_WITH_VALUE:
149
+ arg_index += 2
150
+ continue
151
+ if arg.startswith(GIT_GLOBAL_OPTIONS_WITH_ATTACHED_VALUE):
152
+ arg_index += 1
153
+ continue
154
+ if arg.startswith('-'):
155
+ # Other Git global flags (for example --no-pager) take no argument
156
+ # for this narrow recognizer.
157
+ arg_index += 1
158
+ continue
159
+ return arg.lower(), tokens[arg_index + 1:]
160
+ return None
161
+
162
+
163
+ def git_dry_run_requested(args) -> bool:
164
+ dry_run = False
165
+ for arg in args:
166
+ if arg in {'-n', '--dry-run'}:
167
+ dry_run = True
168
+ elif arg == '--no-dry-run':
169
+ dry_run = False
170
+ return dry_run
171
+
172
+
173
+ def git_apply_mutates(args) -> bool:
174
+ """Handle apply's documented preview flags without parsing patch contents."""
175
+ preview = {name: False for name in GIT_APPLY_PREVIEW_OPTIONS}
176
+ apply_override = None
177
+ for arg in args:
178
+ if arg == '--apply':
179
+ apply_override = True
180
+ elif arg == '--no-apply':
181
+ apply_override = False
182
+ for name in GIT_APPLY_PREVIEW_OPTIONS:
183
+ if arg == f'--{name}' or arg.startswith(f'--{name}='):
184
+ preview[name] = True
185
+ elif arg == f'--no-{name}':
186
+ preview[name] = False
187
+ if apply_override is not None:
188
+ return apply_override
189
+ return not any(preview.values())
190
+
191
+
192
+ def is_mutating_git_command(tokens):
193
+ """Classify selected direct Git worktree changes and documented previews.
194
+
195
+ Git permits forms such as ``git -C /repo restore -- evals/case.sh``.
196
+ This intentionally handles only explicit worktree-changing subcommands and
197
+ their common preview forms; it is not a shell parser or Git policy engine.
198
+ """
199
+ parts = git_subcommand_and_args(tokens)
200
+ if parts is None:
201
+ return None
202
+ subcommand, args = parts
203
+ if subcommand == 'apply':
204
+ return git_apply_mutates(args)
205
+ if subcommand in GIT_DRY_RUN_SUBCOMMANDS:
206
+ return not git_dry_run_requested(args)
207
+ if subcommand == 'am' and any(
208
+ arg == '--show-current-patch' or arg.startswith('--show-current-patch=')
209
+ for arg in args
210
+ ):
211
+ return False
212
+ return subcommand in GIT_MUTATING_SUBCOMMANDS
213
+
214
+
215
+ def mask_direct_git_subcommand(command: str, subcommand: str) -> str:
216
+ """Mask only Git's leading subcommand before applying the shell heuristic.
217
+
218
+ A preview such as ``git rm --dry-run`` must not match the legacy ``rm``
219
+ shell rule. Conversely, ``git diff ... > evals/out`` and ``git show |
220
+ tee tests/out`` still have shell-side effects. This deliberately finds
221
+ only the direct Git subcommand (including global options), rather than
222
+ attempting to parse arbitrary shell syntax.
223
+ """
224
+ try:
225
+ lexer = shlex.shlex(
226
+ io.StringIO(command), posix=True, punctuation_chars='|&;()<>'
227
+ )
228
+ lexer.whitespace_split = True
229
+ saw_executable = False
230
+ global_option_needs_value = False
231
+ while True:
232
+ token_start = lexer.instream.tell()
233
+ token = lexer.get_token()
234
+ token_end = lexer.instream.tell()
235
+ if token is None:
236
+ return command
237
+ if not saw_executable:
238
+ saw_executable = True
239
+ continue
240
+ if global_option_needs_value:
241
+ global_option_needs_value = False
242
+ continue
243
+ if token == '--' or token in {'|', '||', '&', '&&', ';', '(', ')', '<', '>', '>>'}:
244
+ return command
245
+ if token in GIT_GLOBAL_OPTIONS_WITH_VALUE:
246
+ global_option_needs_value = True
247
+ continue
248
+ if token.startswith(GIT_GLOBAL_OPTIONS_WITH_ATTACHED_VALUE):
249
+ continue
250
+ if token.startswith('-'):
251
+ continue
252
+ if token.lower() != subcommand:
253
+ return command
254
+ raw_token = command[token_start:token_end]
255
+ match = re.search(re.escape(subcommand), raw_token, re.I)
256
+ if not match:
257
+ return command
258
+ start = token_start + match.start()
259
+ end = token_start + match.end()
260
+ return command[:start] + '__pua_git_subcommand__' + command[end:]
261
+ except Exception:
262
+ return command
263
+
264
+
265
+ def is_mutating_command(command: str) -> bool:
266
+ tokens = command_tokens(command)
267
+ git_mutates = is_mutating_git_command(tokens)
268
+ if git_mutates is True:
269
+ return True
270
+ if git_mutates is False:
271
+ # Do not short-circuit the generic heuristic: a read-only Git command
272
+ # can still redirect or pipe into a separate shell write. Mask only
273
+ # the recognized Git subcommand so preview flags do not inherit the
274
+ # generic ``rm``/``mv`` false positive.
275
+ parts = git_subcommand_and_args(tokens)
276
+ if parts is not None:
277
+ command = mask_direct_git_subcommand(command, parts[0])
278
+ if MUTATING_BASH.search(command):
279
+ return True
280
+ # Python one-liners often hide writes inside quoted code, so detect common
281
+ # write APIs separately instead of relying on shell-token boundaries.
282
+ return bool(re.search(r'python3?\s+.*(open\(|write_text\(|write_bytes\(|Path\([^)]*\)\.write)', command, re.I | re.S))
283
+
284
+
285
+ def norm_path(p: str) -> str:
286
+ if not p:
287
+ return ''
288
+ return p.replace('\\', '/')
289
+
290
+
291
+ def collect_paths(value):
292
+ paths = []
293
+ if isinstance(value, dict):
294
+ for k, v in value.items():
295
+ if k in {'file_path', 'path', 'notebook_path', 'pattern', 'glob'} and isinstance(v, str):
296
+ paths.append(v)
297
+ else:
298
+ paths.extend(collect_paths(v))
299
+ elif isinstance(value, list):
300
+ for item in value:
301
+ paths.extend(collect_paths(item))
302
+ return paths
303
+
304
+
305
+ def find_reason_for_path(path: str, include_write: bool):
306
+ n = norm_path(path)
307
+ for rx, reason in CONTAMINATION_PATTERNS:
308
+ if rx.search(n):
309
+ return 'deny', reason, n
310
+ for rx, reason in SENSITIVE_READ_PATTERNS:
311
+ if rx.search(n):
312
+ return 'advisory', reason, n
313
+ if include_write:
314
+ for rx, reason in PROTECTED_WRITE_PATTERNS:
315
+ if rx.search(n):
316
+ return 'advisory', reason, n
317
+ return None
318
+
319
+
320
+ def looks_like_path(s: str) -> bool:
321
+ # A real path has a directory separator or a file-extension suffix; bare
322
+ # identifiers like the shell `eval` builtin do not, and must not be matched
323
+ # against (^|/)(evals?|tests?|spec|...)(/|$) as if they were paths.
324
+ if '/' in s or '\\' in s:
325
+ return True
326
+ return bool(re.search(r'\.[A-Za-z0-9]+$', s))
327
+
328
+
329
+ def path_candidates(tokens):
330
+ for token in tokens:
331
+ if not token:
332
+ continue
333
+ stripped = token.strip("\"'`")
334
+ if stripped and looks_like_path(stripped):
335
+ yield stripped
336
+ # Pull paths embedded inside code strings, e.g. open("tests/fixtures.json", "w").
337
+ for match in re.findall(r'[A-Za-z0-9_.@+~:-]+(?:/[A-Za-z0-9_.@+~:-]+)+', token.replace('\\', '/')):
338
+ yield match
339
+
340
+
341
+ def git_include_path_candidates(tokens):
342
+ """Extract Git apply/am include values as paths, not option spellings."""
343
+ parts = git_subcommand_and_args(tokens)
344
+ if parts is None or parts[0] not in {'apply', 'am'}:
345
+ return
346
+ for index, token in enumerate(tokens):
347
+ if token.startswith('--include='):
348
+ value = token.split('=', 1)[1]
349
+ if value:
350
+ yield value
351
+ elif token == '--include' and index + 1 < len(tokens):
352
+ value = tokens[index + 1]
353
+ if value and value != '--':
354
+ yield value
355
+
356
+
357
+ def is_explicit_ordinary_git_path(path: str) -> bool:
358
+ """Whether a literal path proves a Git mutation stays off protected assets."""
359
+ normalized = norm_path(path)
360
+ return bool(
361
+ normalized
362
+ and looks_like_path(normalized)
363
+ and not normalized.endswith('/')
364
+ and not GIT_PATHSPEC_MAGIC.search(normalized)
365
+ and find_reason_for_path(normalized, include_write=True) is None
366
+ )
367
+
368
+
369
+ def opaque_git_mutation_target(tokens):
370
+ """Return a target label when a mutating Git command lacks safe scope.
371
+
372
+ Patch application can touch an unknown set unless literal --include paths
373
+ bound it. reset/checkout/clean need literal pathspecs after ``--``; a
374
+ branch, ref, or no pathspec can affect protected assets. This is a narrow
375
+ command-boundary check, not a Git or shell sandbox.
376
+ """
377
+ parts = git_subcommand_and_args(tokens)
378
+ if parts is None or is_mutating_git_command(tokens) is not True:
379
+ return None
380
+ subcommand, args = parts
381
+ if subcommand in {'apply', 'am'}:
382
+ include_paths = list(git_include_path_candidates(tokens))
383
+ if include_paths and all(is_explicit_ordinary_git_path(path) for path in include_paths):
384
+ return None
385
+ return f'git {subcommand} (unbounded target set)'
386
+ if subcommand in {'reset', 'checkout', 'clean'}:
387
+ if '--' in args:
388
+ pathspecs = args[args.index('--') + 1:]
389
+ if pathspecs and all(is_explicit_ordinary_git_path(path) for path in pathspecs):
390
+ return None
391
+ return f'git {subcommand} (unbounded target set)'
392
+ return None
393
+
394
+
395
+ SSH_IDENTITY_RE = re.compile(r'\bssh\b.*-i\s', re.I)
396
+ SSH_KEY_PATH_RE = re.compile(r'(^|/)\.ssh/(id_|.*[-_]key)', re.I)
397
+
398
+
399
+ def is_ssh_identity_usage(command: str, candidate: str) -> bool:
400
+ if not SSH_IDENTITY_RE.search(command):
401
+ return False
402
+ return bool(SSH_KEY_PATH_RE.search(norm_path(candidate)))
403
+
404
+
405
+ def command_hits(command: str):
406
+ tokens = [t for t in command_tokens(command) if t]
407
+ # Include values are protected paths even though their command-line token
408
+ # begins with an option prefix. Put them first so advisory output names the
409
+ # actual asset rather than ``--include=<path>``.
410
+ candidates = list(git_include_path_candidates(tokens))
411
+ candidates.extend(path_candidates(tokens))
412
+ normalized = command.replace('\\', '/')
413
+
414
+ # Hidden/private solution artifacts are blocked even for read-like commands.
415
+ for candidate in candidates:
416
+ for rx, reason in CONTAMINATION_PATTERNS:
417
+ if rx.search(norm_path(candidate)):
418
+ return 'deny', reason, candidate
419
+ for rx, reason in CONTAMINATION_PATTERNS:
420
+ m = rx.search(normalized)
421
+ if m:
422
+ return 'deny', reason, m.group(0)
423
+ if WEB_CONTAMINATION.search(command):
424
+ return 'deny', 'Solution contamination risk: command appears to search/fetch benchmark or hidden answers.', command[:160]
425
+ if READING_BASH.search(command):
426
+ for candidate in candidates:
427
+ if is_ssh_identity_usage(command, candidate):
428
+ continue
429
+ for rx, reason in SENSITIVE_READ_PATTERNS:
430
+ if rx.search(norm_path(candidate)):
431
+ return 'advisory', reason, candidate
432
+
433
+ # Protected scoring assets need a human gate only when the command mutates them.
434
+ if is_mutating_command(command):
435
+ for candidate in candidates:
436
+ for rx, reason in PROTECTED_WRITE_PATTERNS:
437
+ if rx.search(norm_path(candidate)):
438
+ return 'advisory', reason, candidate
439
+ for rx, reason in PROTECTED_WRITE_PATTERNS:
440
+ m = rx.search(normalized)
441
+ if m:
442
+ return 'advisory', reason, m.group(0)
443
+ opaque_target = opaque_git_mutation_target(tokens)
444
+ if opaque_target:
445
+ return (
446
+ 'advisory',
447
+ 'Grader gaming risk: Git mutation target set cannot be proven limited to ordinary source paths.',
448
+ opaque_target,
449
+ )
450
+ return None
451
+
452
+ hit = None
453
+
454
+ if tool in {'Write', 'Edit', 'MultiEdit'}:
455
+ for path in collect_paths(tool_input):
456
+ hit = find_reason_for_path(path, include_write=True)
457
+ if hit:
458
+ break
459
+ elif tool in {'Read', 'Grep', 'Glob'}:
460
+ for path in collect_paths(tool_input):
461
+ hit = find_reason_for_path(path, include_write=False)
462
+ if hit:
463
+ break
464
+ elif tool == 'Bash':
465
+ command = str(tool_input.get('command') or '')
466
+ hit = command_hits(command)
467
+ elif tool in {'WebSearch', 'WebFetch'}:
468
+ query = '\n'.join(str(tool_input.get(k) or '') for k in ('query', 'url', 'prompt'))
469
+ if WEB_CONTAMINATION.search(query):
470
+ hit = ('deny', 'Solution contamination risk: searching for benchmark/hidden answers can poison the task.', query[:160])
471
+
472
+ if not hit:
473
+ sys.exit(0)
474
+
475
+ decision, reason, target = hit
476
+ message = (
477
+ 'PUA Integrity Guard: ' + reason +
478
+ ' Four-power separation is active: action right, self-evaluation right, scoring right, and environment-modification right must remain separate. '
479
+ f'Target: {target}'
480
+ )
481
+
482
+ output = {'hookSpecificOutput': {'hookEventName': 'PreToolUse'}}
483
+ if decision == 'deny':
484
+ output['hookSpecificOutput']['permissionDecision'] = 'deny'
485
+ output['hookSpecificOutput']['permissionDecisionReason'] = message
486
+ output['hookSpecificOutput']['additionalContext'] = (
487
+ 'PUA Integrity Guard: DENY — ' + reason + f' Target: {target}'
488
+ )
489
+ else:
490
+ output['hookSpecificOutput']['additionalContext'] = (
491
+ 'PUA Integrity Guard (advisory): ' + reason + f' Target: {target}'
492
+ )
493
+ print(json.dumps(output, ensure_ascii=False, separators=(',', ':')))
494
+ PY