vibe-coding-master 0.2.6 → 0.2.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/README.md +164 -9
- package/dist/backend/api/claude-hook-routes.js +7 -1
- package/dist/backend/api/gateway-routes.js +17 -0
- package/dist/backend/api/round-routes.js +1 -1
- package/dist/backend/gateway/channels/weixin-ilink-channel.js +304 -0
- package/dist/backend/gateway/gateway-audit-log.js +39 -0
- package/dist/backend/gateway/gateway-command-parser.js +77 -0
- package/dist/backend/gateway/gateway-service.js +848 -0
- package/dist/backend/gateway/gateway-settings-service.js +214 -0
- package/dist/backend/server.js +42 -2
- package/dist/backend/services/claude-hook-service.js +46 -7
- package/dist/backend/services/harness-service.js +37 -32
- package/dist/backend/services/job-guard-service.js +126 -0
- package/dist/backend/services/round-service.js +110 -64
- package/dist/backend/services/session-service.js +10 -4
- package/dist/backend/services/task-service.js +32 -3
- package/dist/backend/services/translation-service.js +15 -0
- package/dist/backend/templates/harness/architect-agent.js +19 -8
- package/dist/backend/templates/harness/claude-root.js +7 -11
- package/dist/backend/templates/harness/coder-agent.js +45 -17
- package/dist/backend/templates/harness/gitignore.js +3 -2
- package/dist/backend/templates/harness/project-manager-agent.js +16 -11
- package/dist/backend/templates/harness/reviewer-agent.js +25 -28
- package/dist/backend/templates/harness/vcm-final-acceptance-skill.js +42 -31
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +37 -18
- package/dist/shared/types/gateway.js +1 -0
- package/dist-frontend/assets/{index-CPXFnxAY.css → index-7lq6YPCq.css} +1 -1
- package/dist-frontend/assets/index-DHuS-DYr.js +92 -0
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +7 -5
- package/docs/gateway-design.md +200 -27
- package/docs/product-design.md +35 -14
- package/docs/v0.2-implementation-plan.md +22 -7
- package/docs/vcm-cc-best-practices.md +11 -4
- package/package.json +2 -1
- package/scripts/harness-tools/run-long-check +401 -0
- package/scripts/harness-tools/vcm-bash-guard +107 -0
- package/scripts/harness-tools/watch-job +204 -0
- package/scripts/install-vcm-harness.mjs +93 -387
- package/scripts/uninstall-vcm-harness.mjs +18 -1
- package/scripts/verify-package.mjs +3 -0
- package/dist/backend/templates/harness/known-issues-doc.js +0 -22
- package/dist-frontend/assets/index-D0_02lmQ.js +0 -90
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibe-coding-master",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Local GUI session cockpit for Claude Code role sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"execa": "^9.6.0",
|
|
44
44
|
"fastify": "^5.3.3",
|
|
45
45
|
"node-pty": "^1.0.0",
|
|
46
|
+
"qrcode-generator": "^2.0.4",
|
|
46
47
|
"react": "^19.1.0",
|
|
47
48
|
"react-dom": "^19.1.0",
|
|
48
49
|
"react-markdown": "^10.1.0",
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Start one VCM long-running validation job with a worker-enforced ceiling.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
.ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]
|
|
6
|
+
|
|
7
|
+
The command runs in a detached worker process group. The worker itself
|
|
8
|
+
enforces the job ceiling (--timeout, max 60m) and a supervision lease: when
|
|
9
|
+
no foreground watcher renews .ai/vcm/jobs/<job-id>/lease, the worker kills
|
|
10
|
+
the command process group and records the job as orphaned. Watch the job
|
|
11
|
+
with .ai/tools/watch-job in the same turn.
|
|
12
|
+
|
|
13
|
+
Only one validation job may be active at a time.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
MAX_TIMEOUT_SECONDS = 60 * 60
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = MAX_TIMEOUT_SECONDS
|
|
27
|
+
QUEUED_STALE_SECONDS = 60
|
|
28
|
+
ACTIVE_STATUSES = {"queued", "starting", "running"}
|
|
29
|
+
DEFAULT_LEASE_START_GRACE_SECONDS = 300
|
|
30
|
+
DEFAULT_LEASE_RENEW_GRACE_SECONDS = 120
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def root_dir() -> Path:
|
|
34
|
+
return Path(__file__).resolve().parents[2]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def jobs_root() -> Path:
|
|
38
|
+
return root_dir() / ".ai/vcm/jobs"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now_iso() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_json(path: Path, data: dict) -> None:
|
|
46
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
48
|
+
tmp.replace(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_optional_json(path: Path) -> dict | None:
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(path.read_text())
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_duration(value: str) -> float:
|
|
59
|
+
value = value.strip().lower()
|
|
60
|
+
if value.endswith("ms"):
|
|
61
|
+
return float(value[:-2]) / 1000
|
|
62
|
+
if value.endswith("s"):
|
|
63
|
+
return float(value[:-1])
|
|
64
|
+
if value.endswith("m"):
|
|
65
|
+
return float(value[:-1]) * 60
|
|
66
|
+
if value.endswith("h"):
|
|
67
|
+
return float(value[:-1]) * 3600
|
|
68
|
+
return float(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def env_seconds(name: str, default: float) -> float:
|
|
72
|
+
raw = os.environ.get(name)
|
|
73
|
+
if not raw:
|
|
74
|
+
return default
|
|
75
|
+
try:
|
|
76
|
+
return max(1.0, float(raw))
|
|
77
|
+
except ValueError:
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def job_id() -> str:
|
|
82
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
83
|
+
return f"{timestamp}-{uuid.uuid4().hex[:8]}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def process_exists(pid: int) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
os.kill(pid, 0)
|
|
89
|
+
return True
|
|
90
|
+
except ProcessLookupError:
|
|
91
|
+
return False
|
|
92
|
+
except PermissionError:
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def stop_command(process: subprocess.Popen) -> str:
|
|
97
|
+
try:
|
|
98
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
99
|
+
mode = "process-group"
|
|
100
|
+
except ProcessLookupError:
|
|
101
|
+
return "not-running"
|
|
102
|
+
except PermissionError:
|
|
103
|
+
process.terminate()
|
|
104
|
+
mode = "process"
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
process.wait(timeout=2)
|
|
108
|
+
return f"terminated-{mode}"
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
114
|
+
except (ProcessLookupError, PermissionError):
|
|
115
|
+
process.kill()
|
|
116
|
+
return f"killed-{mode}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lease_age_seconds(lease_path: Path) -> float | None:
|
|
120
|
+
try:
|
|
121
|
+
return max(0.0, time.time() - lease_path.stat().st_mtime)
|
|
122
|
+
except OSError:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def lease_iso(lease_path: Path) -> str | None:
|
|
127
|
+
try:
|
|
128
|
+
mtime = lease_path.stat().st_mtime
|
|
129
|
+
except OSError:
|
|
130
|
+
return None
|
|
131
|
+
return datetime.fromtimestamp(mtime, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def mark_stale(status_path: Path, status: dict, reason: str) -> None:
|
|
135
|
+
stale = dict(status)
|
|
136
|
+
stale.update({"status": "stale", "finishedAt": now_iso(), "staleReason": reason})
|
|
137
|
+
write_json(status_path, stale)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find_active_job() -> dict | None:
|
|
141
|
+
root = jobs_root()
|
|
142
|
+
if not root.is_dir():
|
|
143
|
+
return None
|
|
144
|
+
for directory in sorted(root.iterdir()):
|
|
145
|
+
if not directory.is_dir():
|
|
146
|
+
continue
|
|
147
|
+
status_path = directory / "status.json"
|
|
148
|
+
status = read_optional_json(status_path)
|
|
149
|
+
if not status or status.get("status") not in ACTIVE_STATUSES:
|
|
150
|
+
continue
|
|
151
|
+
pid = status.get("processId") or status.get("workerPid")
|
|
152
|
+
if isinstance(pid, int):
|
|
153
|
+
if process_exists(pid):
|
|
154
|
+
return status
|
|
155
|
+
mark_stale(status_path, status, "job process not running")
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
age = time.time() - status_path.stat().st_mtime
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
if age < QUEUED_STALE_SECONDS:
|
|
162
|
+
return status
|
|
163
|
+
mark_stale(status_path, status, "queued job never started")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def start_job(command: list[str], timeout_seconds: float) -> int:
|
|
168
|
+
active = find_active_job()
|
|
169
|
+
if active:
|
|
170
|
+
print(
|
|
171
|
+
f"error: validation job {active.get('jobId')} is already {active.get('status')}",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
print(f"watch it: .ai/tools/watch-job {active.get('jobId')}", file=sys.stderr)
|
|
175
|
+
print("VCM allows one validation job at a time.", file=sys.stderr)
|
|
176
|
+
return 3
|
|
177
|
+
|
|
178
|
+
job = job_id()
|
|
179
|
+
directory = jobs_root() / job
|
|
180
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
181
|
+
|
|
182
|
+
(directory / "command.json").write_text(
|
|
183
|
+
json.dumps(
|
|
184
|
+
{
|
|
185
|
+
"command": command,
|
|
186
|
+
"cwd": ".",
|
|
187
|
+
"timeoutSeconds": timeout_seconds,
|
|
188
|
+
"createdAt": now_iso(),
|
|
189
|
+
},
|
|
190
|
+
indent=2,
|
|
191
|
+
sort_keys=True,
|
|
192
|
+
)
|
|
193
|
+
+ "\n"
|
|
194
|
+
)
|
|
195
|
+
write_json(
|
|
196
|
+
directory / "status.json",
|
|
197
|
+
{
|
|
198
|
+
"jobId": job,
|
|
199
|
+
"status": "queued",
|
|
200
|
+
"command": command,
|
|
201
|
+
"cwd": ".",
|
|
202
|
+
"timeoutSeconds": timeout_seconds,
|
|
203
|
+
"startedAt": None,
|
|
204
|
+
"finishedAt": None,
|
|
205
|
+
"exitCode": None,
|
|
206
|
+
"durationSeconds": None,
|
|
207
|
+
"workerPid": None,
|
|
208
|
+
"processId": None,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
subprocess.Popen(
|
|
213
|
+
[sys.executable, str(Path(__file__).resolve()), "--worker", job],
|
|
214
|
+
cwd=root_dir(),
|
|
215
|
+
stdin=subprocess.DEVNULL,
|
|
216
|
+
stdout=subprocess.DEVNULL,
|
|
217
|
+
stderr=subprocess.DEVNULL,
|
|
218
|
+
start_new_session=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
print(f"job: {job}")
|
|
222
|
+
print(f"ceiling: {int(timeout_seconds)}s (worker-enforced)")
|
|
223
|
+
print(f"watch: .ai/tools/watch-job {job}")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_worker(job: str) -> int:
|
|
228
|
+
directory = jobs_root() / job
|
|
229
|
+
command_path = directory / "command.json"
|
|
230
|
+
status_path = directory / "status.json"
|
|
231
|
+
lease_path = directory / "lease"
|
|
232
|
+
|
|
233
|
+
payload = json.loads(command_path.read_text())
|
|
234
|
+
command = payload["command"]
|
|
235
|
+
timeout_seconds = min(
|
|
236
|
+
float(payload.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS),
|
|
237
|
+
MAX_TIMEOUT_SECONDS,
|
|
238
|
+
)
|
|
239
|
+
start_grace = env_seconds("VCM_JOB_LEASE_START_GRACE_SECONDS", DEFAULT_LEASE_START_GRACE_SECONDS)
|
|
240
|
+
renew_grace = env_seconds("VCM_JOB_LEASE_RENEW_GRACE_SECONDS", DEFAULT_LEASE_RENEW_GRACE_SECONDS)
|
|
241
|
+
|
|
242
|
+
worker_started = time.time()
|
|
243
|
+
base = {
|
|
244
|
+
"jobId": job,
|
|
245
|
+
"command": command,
|
|
246
|
+
"cwd": ".",
|
|
247
|
+
"timeoutSeconds": timeout_seconds,
|
|
248
|
+
"workerPid": os.getpid(),
|
|
249
|
+
}
|
|
250
|
+
write_json(
|
|
251
|
+
status_path,
|
|
252
|
+
{
|
|
253
|
+
**base,
|
|
254
|
+
"status": "starting",
|
|
255
|
+
"startedAt": None,
|
|
256
|
+
"finishedAt": None,
|
|
257
|
+
"exitCode": None,
|
|
258
|
+
"durationSeconds": None,
|
|
259
|
+
"processId": None,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
started = time.time()
|
|
264
|
+
started_at = now_iso()
|
|
265
|
+
verdict = None
|
|
266
|
+
with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
|
|
267
|
+
process = subprocess.Popen(
|
|
268
|
+
command,
|
|
269
|
+
cwd=root_dir(),
|
|
270
|
+
stdout=stdout,
|
|
271
|
+
stderr=stderr,
|
|
272
|
+
start_new_session=True,
|
|
273
|
+
)
|
|
274
|
+
running = {
|
|
275
|
+
**base,
|
|
276
|
+
"status": "running",
|
|
277
|
+
"startedAt": started_at,
|
|
278
|
+
"finishedAt": None,
|
|
279
|
+
"exitCode": None,
|
|
280
|
+
"durationSeconds": None,
|
|
281
|
+
"processId": process.pid,
|
|
282
|
+
}
|
|
283
|
+
write_json(status_path, running)
|
|
284
|
+
|
|
285
|
+
while True:
|
|
286
|
+
exit_code = process.poll()
|
|
287
|
+
if exit_code is not None:
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if time.time() - started >= timeout_seconds:
|
|
291
|
+
stop_result = stop_command(process)
|
|
292
|
+
verdict = (
|
|
293
|
+
"timeout",
|
|
294
|
+
{"processStopResult": stop_result},
|
|
295
|
+
)
|
|
296
|
+
exit_code = process.wait()
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
lease_age = lease_age_seconds(lease_path)
|
|
300
|
+
if lease_age is None:
|
|
301
|
+
if time.time() - worker_started >= start_grace:
|
|
302
|
+
stop_result = stop_command(process)
|
|
303
|
+
verdict = (
|
|
304
|
+
"orphaned",
|
|
305
|
+
{
|
|
306
|
+
"orphanReason": f"no watcher within {int(start_grace)}s",
|
|
307
|
+
"lastWatchedAt": None,
|
|
308
|
+
"processStopResult": stop_result,
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
exit_code = process.wait()
|
|
312
|
+
break
|
|
313
|
+
elif lease_age >= renew_grace:
|
|
314
|
+
stop_result = stop_command(process)
|
|
315
|
+
verdict = (
|
|
316
|
+
"orphaned",
|
|
317
|
+
{
|
|
318
|
+
"orphanReason": f"lease not renewed for {int(lease_age)}s",
|
|
319
|
+
"lastWatchedAt": lease_iso(lease_path),
|
|
320
|
+
"processStopResult": stop_result,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
exit_code = process.wait()
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
time.sleep(1)
|
|
327
|
+
|
|
328
|
+
duration = round(time.time() - started, 3)
|
|
329
|
+
current = read_optional_json(status_path) or {}
|
|
330
|
+
if current.get("status") in {"timeout", "orphaned", "stale"}:
|
|
331
|
+
current["processExitCode"] = exit_code
|
|
332
|
+
current["processFinishedAt"] = now_iso()
|
|
333
|
+
current["processDurationSeconds"] = duration
|
|
334
|
+
write_json(status_path, current)
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
final = {
|
|
338
|
+
**base,
|
|
339
|
+
"startedAt": started_at,
|
|
340
|
+
"finishedAt": now_iso(),
|
|
341
|
+
"durationSeconds": duration,
|
|
342
|
+
"processId": process.pid,
|
|
343
|
+
}
|
|
344
|
+
if verdict is None:
|
|
345
|
+
final.update({"status": "success" if exit_code == 0 else "failed", "exitCode": exit_code})
|
|
346
|
+
else:
|
|
347
|
+
kind, extra = verdict
|
|
348
|
+
final.update({"status": kind, "exitCode": None, "processExitCode": exit_code, **extra})
|
|
349
|
+
write_json(status_path, final)
|
|
350
|
+
return 0
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main() -> int:
|
|
354
|
+
argv = sys.argv[1:]
|
|
355
|
+
if len(argv) >= 2 and argv[0] == "--worker":
|
|
356
|
+
return run_worker(argv[1])
|
|
357
|
+
|
|
358
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
359
|
+
index = 0
|
|
360
|
+
while index < len(argv) and argv[index] != "--":
|
|
361
|
+
arg = argv[index]
|
|
362
|
+
raw = None
|
|
363
|
+
if arg == "--timeout":
|
|
364
|
+
if index + 1 >= len(argv):
|
|
365
|
+
print("error: --timeout needs a duration", file=sys.stderr)
|
|
366
|
+
return 2
|
|
367
|
+
raw = argv[index + 1]
|
|
368
|
+
index += 2
|
|
369
|
+
elif arg.startswith("--timeout="):
|
|
370
|
+
raw = arg[len("--timeout="):]
|
|
371
|
+
index += 1
|
|
372
|
+
else:
|
|
373
|
+
print(f"error: unknown option: {arg}", file=sys.stderr)
|
|
374
|
+
return 2
|
|
375
|
+
try:
|
|
376
|
+
timeout_seconds = parse_duration(raw)
|
|
377
|
+
except ValueError:
|
|
378
|
+
print(f"error: invalid duration: {raw}", file=sys.stderr)
|
|
379
|
+
return 2
|
|
380
|
+
|
|
381
|
+
if index >= len(argv) or argv[index] != "--" or index + 1 >= len(argv):
|
|
382
|
+
print(
|
|
383
|
+
"Usage: .ai/tools/run-long-check [--timeout <duration>] -- <command> [args...]",
|
|
384
|
+
file=sys.stderr,
|
|
385
|
+
)
|
|
386
|
+
return 2
|
|
387
|
+
if timeout_seconds <= 0:
|
|
388
|
+
print("error: timeout must be positive", file=sys.stderr)
|
|
389
|
+
return 2
|
|
390
|
+
if timeout_seconds > MAX_TIMEOUT_SECONDS:
|
|
391
|
+
print(
|
|
392
|
+
"error: timeout exceeds maximum 60m; split the work or get user approval",
|
|
393
|
+
file=sys.stderr,
|
|
394
|
+
)
|
|
395
|
+
return 2
|
|
396
|
+
|
|
397
|
+
return start_job(argv[index + 1:], timeout_seconds)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""VCM PreToolUse guard: deny background Bash inside VCM role sessions.
|
|
3
|
+
|
|
4
|
+
Reads the Claude Code PreToolUse hook payload on stdin. When the Bash tool
|
|
5
|
+
call would start background work (run_in_background, nohup, setsid, disown,
|
|
6
|
+
or a lone '&'), it prints a deny decision that redirects the role to the
|
|
7
|
+
vcm-long-running-validation skill. Anything else is allowed by staying
|
|
8
|
+
silent.
|
|
9
|
+
|
|
10
|
+
Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
|
|
11
|
+
shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
|
|
12
|
+
only sanctioned detached worker; the command it runs must still stay in the
|
|
13
|
+
supervised foreground process group.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
SKILL_HINT = (
|
|
20
|
+
"Use the vcm-long-running-validation skill instead: "
|
|
21
|
+
"`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
|
|
22
|
+
"`.ai/tools/watch-job <job-id>` in the same turn, repeating watch-job "
|
|
23
|
+
"until it reports a terminal result."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MAX_NESTED_SHELL_DEPTH = 3
|
|
27
|
+
QUOTED = re.compile(r"'([^']*)'|\"([^\"]*)\"")
|
|
28
|
+
SHELL_DASH_C = re.compile(r"(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\w*(?:\s|$)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def strip_quoted(command: str) -> str:
|
|
32
|
+
return QUOTED.sub(" ", command)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def quoted_segments(command: str) -> list[str]:
|
|
36
|
+
return [single or double for single, double in QUOTED.findall(command)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unquoted_ampersand(command_without_quotes: str) -> bool:
|
|
40
|
+
cleaned = re.sub(r"\d*>&\d*", " ", command_without_quotes)
|
|
41
|
+
cleaned = re.sub(r"<&\d*", " ", cleaned)
|
|
42
|
+
cleaned = cleaned.replace("&&", " ")
|
|
43
|
+
return bool(re.search(r"&\s*(?:$|[;\n)])", cleaned) or re.search(r"\s&\s", cleaned))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan_shell_command(command: str, depth: int = 0) -> list[str]:
|
|
47
|
+
reasons = []
|
|
48
|
+
stripped = strip_quoted(command)
|
|
49
|
+
if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
|
|
50
|
+
reasons.append("nohup/setsid detach is forbidden")
|
|
51
|
+
if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
|
|
52
|
+
reasons.append("disown is forbidden")
|
|
53
|
+
if unquoted_ampersand(stripped):
|
|
54
|
+
reasons.append("'&' background execution is forbidden")
|
|
55
|
+
|
|
56
|
+
# `sh -c '...'` quoted payloads are shell code, not plain strings.
|
|
57
|
+
if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
|
|
58
|
+
for segment in quoted_segments(command):
|
|
59
|
+
reasons.extend(scan_shell_command(segment, depth + 1))
|
|
60
|
+
return reasons
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def background_reasons(tool_input: dict) -> list[str]:
|
|
64
|
+
reasons = []
|
|
65
|
+
if tool_input.get("run_in_background"):
|
|
66
|
+
reasons.append("Bash run_in_background is forbidden")
|
|
67
|
+
|
|
68
|
+
command = tool_input.get("command")
|
|
69
|
+
command = command if isinstance(command, str) else ""
|
|
70
|
+
|
|
71
|
+
reasons.extend(scan_shell_command(command))
|
|
72
|
+
return list(dict.fromkeys(reasons))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
raw = sys.stdin.read()
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
79
|
+
except ValueError:
|
|
80
|
+
return 0
|
|
81
|
+
if payload.get("tool_name") != "Bash":
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
tool_input = payload.get("tool_input")
|
|
85
|
+
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
86
|
+
reasons = background_reasons(tool_input)
|
|
87
|
+
if not reasons:
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
print(
|
|
91
|
+
json.dumps(
|
|
92
|
+
{
|
|
93
|
+
"hookSpecificOutput": {
|
|
94
|
+
"hookEventName": "PreToolUse",
|
|
95
|
+
"permissionDecision": "deny",
|
|
96
|
+
"permissionDecisionReason": (
|
|
97
|
+
"VCM forbids background Bash (" + "; ".join(reasons) + "). " + SKILL_HINT
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|