vibe-coding-master 0.8.12 → 0.9.0

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 (50) hide show
  1. package/README.md +7 -3
  2. package/dist/backend/cli/install-vcm-harness.js +57 -18
  3. package/dist/backend/gateway/gateway-service.js +5 -3
  4. package/dist/backend/runtime/terminal-submit.js +1 -0
  5. package/dist/backend/server.js +14 -4
  6. package/dist/backend/services/app-settings-service.js +3 -2
  7. package/dist/backend/services/auto-memory-service.js +6 -4
  8. package/dist/backend/services/claude-hook-service.js +6 -0
  9. package/dist/backend/services/claude-transcript-service.js +2 -13
  10. package/dist/backend/services/claude-version-check.js +46 -0
  11. package/dist/backend/services/gate-review-service.js +47 -8
  12. package/dist/backend/services/harness-service.js +41 -20
  13. package/dist/backend/services/message-service.js +30 -6
  14. package/dist/backend/services/pm-user-question.js +2 -1
  15. package/dist/backend/services/project-service.js +8 -2
  16. package/dist/backend/services/runtime-recovery-service.js +36 -9
  17. package/dist/backend/services/session-service.js +10 -19
  18. package/dist/backend/services/task-close-service.js +1 -0
  19. package/dist/backend/services/terminal-process-exit-service.js +4 -1
  20. package/dist/backend/services/workflow-control-service.js +55 -11
  21. package/dist/backend/templates/harness/architect-agent.js +2 -1
  22. package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -1
  23. package/dist/backend/templates/harness/architect-scaffold-worker-agent.js +8 -2
  24. package/dist/backend/templates/harness/architect-validation-worker-agent.js +2 -1
  25. package/dist/backend/templates/harness/architect-worker-frontmatter.js +33 -0
  26. package/dist/backend/templates/harness/check-scaffold-ledger.js +480 -511
  27. package/dist/backend/templates/harness/coder-worker-agent.js +1 -0
  28. package/dist/backend/templates/harness/gate-review.js +676 -643
  29. package/dist/backend/templates/harness/node-tool-runtime.js +102 -0
  30. package/dist/backend/templates/harness/resolve-durable-doc-assignment.js +49 -55
  31. package/dist/backend/templates/harness/restart-architect-skill.js +49 -58
  32. package/dist/backend/templates/harness/vcm-ask-user-skill.js +57 -59
  33. package/dist/backend/templates/harness/vcm-propose-memory-skill.js +4 -0
  34. package/dist/backend/templates/harness/vcm-report-harness-issue-skill.js +6 -2
  35. package/dist/backend/templates/harness/vcm-task-state-skill.js +66 -72
  36. package/dist/shared/types/app-settings.js +31 -0
  37. package/dist-frontend/assets/index-CShYYQQa.css +32 -0
  38. package/dist-frontend/assets/{index-DqANomt1.js → index-DOvvS76Q.js} +41 -41
  39. package/dist-frontend/index.html +2 -2
  40. package/package.json +1 -1
  41. package/scripts/harness-tools/check-durable-docs +332 -298
  42. package/scripts/harness-tools/generate-module-index +1047 -512
  43. package/scripts/harness-tools/generate-public-surface +1231 -1069
  44. package/scripts/harness-tools/package.json +3 -0
  45. package/scripts/harness-tools/run-long-check +608 -531
  46. package/scripts/harness-tools/vcm-artifact +181 -113
  47. package/scripts/harness-tools/vcm-bash-guard +839 -605
  48. package/scripts/harness-tools/vcm-subagent-guard +67 -63
  49. package/scripts/harness-tools/watch-job +313 -238
  50. package/dist-frontend/assets/index-xgiQeUD5.css +0 -32
@@ -1,605 +1,839 @@
1
- #!/usr/bin/env python3
2
- """VCM PreToolUse guard for supervised tools inside VCM workflow-role sessions.
3
-
4
- Reads the Claude Code PreToolUse hook payload on stdin. It enforces supervised
5
- Bash execution and managed-artifact writes for VCM workflow roles. Tool roles
6
- are outside the workflow and bypass this guard.
7
-
8
- Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
9
- shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
10
- only sanctioned detached worker; the command it runs must still stay in the
11
- supervised foreground process group.
12
- """
13
- import ast
14
- import json
15
- import os
16
- import re
17
- import shlex
18
- import sys
19
-
20
- WORKFLOW_ROLES = {"project-manager", "architect", "coder", "tester", "reviewer"}
21
-
22
- SKILL_HINT = (
23
- "Use the vcm-long-running-validation skill instead: "
24
- "`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
25
- "`.ai/tools/watch-job <job-id>` in the same turn, repeating watch-job "
26
- "until it reports a terminal result."
27
- )
28
- ARTIFACT_HINT = (
29
- "VCM-managed Markdown cannot be written directly. Write a candidate outside "
30
- ".ai/vcm, then run .ai/tools/vcm-artifact <kind> --file <candidate> "
31
- "--mode <draft|final>."
32
- )
33
- MAX_NESTED_SHELL_DEPTH = 3
34
- QUOTED = re.compile(r"'([^']*)'|\"([^\"]*)\"")
35
- SHELL_DASH_C = re.compile(r"(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\w*(?:\s|$)")
36
- PROTECTED_TOOL_INVOCATION = re.compile(
37
- r"(?:^|[;&|()\n`])\s*"
38
- r"(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+)\s+)*"
39
- r"(?:[^\s;&|()]*/)?(?:run-long-check|watch-job)(?=\s|$)"
40
- )
41
- HEREDOC_START = re.compile(
42
- r"<<(?!<)(-?)\s*(?:(['\"])([^'\"]+)\2|([^\s;&|()<>]+))"
43
- )
44
- EXECUTABLE_HEREDOC_RECEIVER = re.compile(
45
- r"(?:^|[;&|()])\s*"
46
- r"(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+\s+)*)"
47
- r"(?:[^\s;&|()]*/)?(?P<receiver>sh|bash|zsh|dash|ksh|python\d*(?:\.\d+)?|node)(?=\s|$)"
48
- )
49
- SHELL_HEREDOC_RECEIVERS = {"sh", "bash", "zsh", "dash", "ksh"}
50
- SHELL_CONTROL_OPERATOR = re.compile(r"(?:\|&?|&&|\|\||;|\n|\(|\)|`)")
51
- RUN_LONG_CHECK_SHELL_STRING = re.compile(
52
- r"(?:^|[;&|()\n])\s*"
53
- r"(?:[^\s;&|()]*/)?run-long-check\b.*?\s--\s+"
54
- r"(?:env(?:\s+[A-Za-z_][A-Za-z0-9_]*=[^\s]+)*\s+)?"
55
- r"(?:[^\s;&|()]*/)?(?:sh|bash|zsh|dash|ksh)\s+"
56
- r"(?:-\w+\s+)*-\w*c\w*(?:\s|$)"
57
- )
58
- COMMAND_SUBSTITUTION = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")
59
- MANAGED_ARTIFACT_PATH = re.compile(
60
- r"(?:^|[\s/\\])\.ai[/\\]vcm[/\\](?:"
61
- r"handoffs[/\\].*\.md|"
62
- r"gate-reviews[/\\]requests[/\\].*\.report\.md|"
63
- r"memory-review[/\\].*\.md|"
64
- r"coder-workers[/\\]reports[/\\].*\.md|"
65
- r"harness-feedback[/\\]pending[/\\].*\.md"
66
- r")"
67
- )
68
- SHELL_SEPARATOR_TOKEN = re.compile(r"^[;&|()]+$")
69
- SHELL_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
70
- PYTHON_PATH_MUTATION = re.compile(
71
- r"\bPath\s*\(\s*(['\"])(?P<path>[^'\"]+)\1\s*\)\s*\.\s*"
72
- r"(?:write_text|write_bytes|unlink|rmdir|touch|rename|replace)\s*\("
73
- )
74
- PYTHON_OPEN = re.compile(
75
- r"\bopen\s*\(\s*(['\"])(?P<path>[^'\"]+)\1\s*,\s*"
76
- r"(['\"])(?P<mode>[^'\"]+)\3"
77
- )
78
- NODE_PATH_MUTATION = re.compile(
79
- r"\b(?:writeFile|writeFileSync|appendFile|appendFileSync|unlink|unlinkSync|"
80
- r"rm|rmSync|rmdir|rmdirSync|createWriteStream)\s*\(\s*"
81
- r"(['\"])(?P<path>[^'\"]+)\1"
82
- )
83
-
84
-
85
- def strip_quoted(command: str) -> str:
86
- return QUOTED.sub(" ", command)
87
-
88
-
89
- def quoted_segments(command: str) -> list[str]:
90
- return [single or double for single, double in QUOTED.findall(command)]
91
-
92
-
93
- def double_quoted_command_substitutions(command: str) -> list[str]:
94
- substitutions = []
95
- for single, double in QUOTED.findall(command):
96
- if single or not double:
97
- continue
98
- substitutions.extend(paren or backtick for paren, backtick in COMMAND_SUBSTITUTION.findall(double))
99
- return substitutions
100
-
101
-
102
- def strip_escaped_characters(command: str) -> str:
103
- cleaned = []
104
- index = 0
105
- while index < len(command):
106
- if command[index] == "\\" and index + 1 < len(command):
107
- cleaned.extend((" ", " "))
108
- index += 2
109
- continue
110
- cleaned.append(command[index])
111
- index += 1
112
- return "".join(cleaned)
113
-
114
-
115
- def heredoc_receiver_language(line: str):
116
- match = EXECUTABLE_HEREDOC_RECEIVER.search(line)
117
- if not match:
118
- return None
119
- receiver = match.group("receiver")
120
- if receiver in SHELL_HEREDOC_RECEIVERS:
121
- return "shell"
122
- if receiver.startswith("python"):
123
- return "python"
124
- return "node"
125
-
126
-
127
- def parse_heredoc_content(command: str) -> tuple[str, str, list[tuple[str, str]]]:
128
- """Return shell-only text, all executable text, and typed interpreter bodies."""
129
- pending = []
130
- shell_executable = []
131
- all_executable = []
132
- interpreter_sections: list[tuple[str, list[str]]] = []
133
- for line in command.splitlines(keepends=True):
134
- if pending:
135
- delimiter, strip_tabs, language, body = pending[0]
136
- candidate = line.rstrip("\r\n")
137
- if strip_tabs:
138
- candidate = candidate.lstrip("\t")
139
- if candidate == delimiter:
140
- pending.pop(0)
141
- elif language:
142
- content_line = line.lstrip("\t") if strip_tabs else line
143
- all_executable.append(content_line)
144
- if language == "shell":
145
- shell_executable.append(content_line)
146
- else:
147
- body.append(content_line)
148
- if content_line.endswith(("\n", "\r")):
149
- shell_executable.append("\n")
150
- elif line.endswith(("\n", "\r")):
151
- shell_executable.append("\n")
152
- all_executable.append("\n")
153
- continue
154
-
155
- shell_executable.append(line)
156
- all_executable.append(line)
157
- language = heredoc_receiver_language(line)
158
- for match in HEREDOC_START.finditer(line):
159
- delimiter = match.group(3) or match.group(4)
160
- body: list[str] = []
161
- pending.append((delimiter, bool(match.group(1)), language, body))
162
- if language in ("python", "node"):
163
- interpreter_sections.append((language, body))
164
- return (
165
- "".join(shell_executable),
166
- "".join(all_executable),
167
- [(language, "".join(body)) for language, body in interpreter_sections],
168
- )
169
-
170
-
171
- def strip_heredoc_bodies(command: str) -> str:
172
- """Remove data heredocs while preserving all executable interpreter bodies."""
173
- return parse_heredoc_content(command)[1]
174
-
175
-
176
- def shell_segments(command: str) -> list[list[str]]:
177
- shell_command, _, _ = parse_heredoc_content(command)
178
- normalized = shell_command.replace("\\\n", " ").replace("\n", " ; ")
179
- try:
180
- lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|()<>")
181
- lexer.whitespace_split = True
182
- lexer.commenters = ""
183
- tokens = list(lexer)
184
- except ValueError:
185
- return []
186
-
187
- segments: list[list[str]] = []
188
- current: list[str] = []
189
- for token in tokens:
190
- if SHELL_SEPARATOR_TOKEN.fullmatch(token):
191
- if current:
192
- segments.append(current)
193
- current = []
194
- continue
195
- current.append(token)
196
- if current:
197
- segments.append(current)
198
- return segments
199
-
200
-
201
- def is_managed_artifact_path(value: str) -> bool:
202
- return bool(MANAGED_ARTIFACT_PATH.search(value))
203
-
204
-
205
- def command_index(segment: list[str]):
206
- index = 0
207
- while index < len(segment) and SHELL_ASSIGNMENT.match(segment[index]):
208
- index += 1
209
- if index < len(segment) and segment[index].rsplit("/", 1)[-1] == "env":
210
- index += 1
211
- while index < len(segment) and (
212
- segment[index].startswith("-") or SHELL_ASSIGNMENT.match(segment[index])
213
- ):
214
- index += 1
215
- return index if index < len(segment) else None
216
-
217
-
218
- def redirection_details(segment: list[str]) -> tuple[list[str], set[int]]:
219
- targets: list[str] = []
220
- consumed: set[int] = set()
221
- for index, token in enumerate(segment):
222
- if ">" not in token or not all(character in "<>&|" for character in token):
223
- continue
224
- consumed.add(index)
225
- if index > 0 and segment[index - 1].isdigit():
226
- consumed.add(index - 1)
227
- if index + 1 >= len(segment):
228
- continue
229
- consumed.add(index + 1)
230
- target = segment[index + 1]
231
- if token in (">&", "<&") and (target.isdigit() or target == "-"):
232
- continue
233
- targets.append(target)
234
- return targets, consumed
235
-
236
-
237
- def command_operands(segment: list[str], start: int, consumed: set[int]) -> list[str]:
238
- operands = []
239
- options_done = False
240
- for index in range(start, len(segment)):
241
- if index in consumed:
242
- continue
243
- token = segment[index]
244
- if token == "--":
245
- options_done = True
246
- continue
247
- if not options_done and token.startswith("-"):
248
- continue
249
- operands.append(token)
250
- return operands
251
-
252
-
253
- def explicit_interpreter_mutation(command_names: set[str], source: str) -> bool:
254
- if any(re.fullmatch(r"python\d*(?:\.\d+)?", name) for name in command_names):
255
- for match in PYTHON_PATH_MUTATION.finditer(source):
256
- if is_managed_artifact_path(match.group("path")):
257
- return True
258
- for match in PYTHON_OPEN.finditer(source):
259
- if is_managed_artifact_path(match.group("path")) and any(
260
- flag in match.group("mode") for flag in "wax+"
261
- ):
262
- return True
263
- if "node" in command_names:
264
- return any(
265
- is_managed_artifact_path(match.group("path"))
266
- for match in NODE_PATH_MUTATION.finditer(source)
267
- )
268
- return False
269
-
270
-
271
- def mutates_managed_artifact(command: str) -> bool:
272
- executable_command = strip_heredoc_bodies(command)
273
- segments = shell_segments(command)
274
- command_names = {
275
- segment[index].rsplit("/", 1)[-1]
276
- for segment in segments
277
- if (index := command_index(segment)) is not None
278
- }
279
- if explicit_interpreter_mutation(command_names, executable_command):
280
- return True
281
-
282
- for segment in segments:
283
- redirects, consumed = redirection_details(segment)
284
- if any(is_managed_artifact_path(target) for target in redirects):
285
- return True
286
-
287
- executable_index = command_index(segment)
288
- if executable_index is None:
289
- continue
290
- command_name = segment[executable_index].rsplit("/", 1)[-1]
291
- argument_start = executable_index + 1
292
- arguments = segment[argument_start:]
293
- operands = command_operands(segment, argument_start, consumed)
294
-
295
- target_directory = None
296
- for index, argument in enumerate(arguments):
297
- if argument in ("-t", "--target-directory") and index + 1 < len(arguments):
298
- target_directory = arguments[index + 1]
299
- elif argument.startswith("--target-directory="):
300
- target_directory = argument.split("=", 1)[1]
301
-
302
- if command_name == "cp":
303
- destination = target_directory or (operands[-1] if len(operands) >= 2 else None)
304
- if destination and is_managed_artifact_path(destination):
305
- return True
306
- elif command_name == "mv":
307
- if target_directory and is_managed_artifact_path(target_directory):
308
- return True
309
- if any(is_managed_artifact_path(operand) for operand in operands):
310
- return True
311
- elif command_name in ("rm", "tee"):
312
- if any(is_managed_artifact_path(operand) for operand in operands):
313
- return True
314
- elif command_name in ("sed", "perl"):
315
- in_place = any(
316
- argument.startswith("-") and "i" in argument[1:]
317
- for argument in arguments
318
- )
319
- if in_place and any(is_managed_artifact_path(argument) for argument in arguments):
320
- return True
321
- return False
322
-
323
-
324
- def unquoted_ampersand(command_without_quotes: str) -> bool:
325
- cleaned = re.sub(r"\d*>&\d*", " ", command_without_quotes)
326
- cleaned = re.sub(r"<&\d*", " ", cleaned)
327
- cleaned = cleaned.replace("&&", " ")
328
- return bool(re.search(r"&\s*(?:$|[;\n)])", cleaned) or re.search(r"\s&\s", cleaned))
329
-
330
-
331
- def protected_tool_invoked(command_without_quotes: str) -> bool:
332
- return bool(PROTECTED_TOOL_INVOCATION.search(command_without_quotes))
333
-
334
-
335
- def shell_background_reasons(command: str) -> list[str]:
336
- stripped = strip_escaped_characters(strip_quoted(command))
337
- reasons = []
338
- if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
339
- reasons.append("nohup/setsid detach is forbidden")
340
- if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
341
- reasons.append("disown is forbidden")
342
- if unquoted_ampersand(stripped):
343
- reasons.append("'&' background execution is forbidden")
344
- return reasons
345
-
346
-
347
- def python_dotted_name(node, aliases: dict[str, str]):
348
- parts = []
349
- current = node
350
- while isinstance(current, ast.Attribute):
351
- parts.append(current.attr)
352
- current = current.value
353
- if not isinstance(current, ast.Name):
354
- return None
355
- parts.append(current.id)
356
- parts.reverse()
357
- if parts[0] in aliases:
358
- parts = aliases[parts[0]].split(".") + parts[1:]
359
- return ".".join(parts)
360
-
361
-
362
- def python_import_aliases(tree) -> dict[str, str]:
363
- aliases = {}
364
- for node in ast.walk(tree):
365
- if isinstance(node, ast.Import):
366
- for imported in node.names:
367
- aliases[imported.asname or imported.name.split(".")[0]] = imported.name
368
- elif isinstance(node, ast.ImportFrom) and node.module:
369
- for imported in node.names:
370
- aliases[imported.asname or imported.name] = f"{node.module}.{imported.name}"
371
- return aliases
372
-
373
-
374
- def literal_string(node):
375
- return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
376
-
377
-
378
- def scan_python_backgrounding(source: str) -> list[str]:
379
- try:
380
- tree = ast.parse(source)
381
- except SyntaxError:
382
- return []
383
- aliases = python_import_aliases(tree)
384
- reasons = []
385
- asynchronous_calls = {
386
- "subprocess.Popen",
387
- "os.fork",
388
- "os.popen",
389
- "asyncio.create_subprocess_exec",
390
- "asyncio.create_subprocess_shell",
391
- }
392
- shell_calls = {
393
- "os.system",
394
- "subprocess.getoutput",
395
- "subprocess.getstatusoutput",
396
- }
397
- for node in ast.walk(tree):
398
- if not isinstance(node, ast.Call):
399
- continue
400
- name = python_dotted_name(node.func, aliases)
401
- if not name:
402
- continue
403
- if name in asynchronous_calls or name.startswith("os.spawn"):
404
- reasons.append("Python asynchronous process creation is forbidden")
405
- continue
406
- command = literal_string(node.args[0]) if node.args else None
407
- if name in shell_calls and command and shell_background_reasons(command):
408
- reasons.append("Python shell background execution is forbidden")
409
- continue
410
- if name in {"subprocess.run", "subprocess.call", "subprocess.check_call", "subprocess.check_output"}:
411
- uses_shell = any(
412
- keyword.arg == "shell" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True
413
- for keyword in node.keywords
414
- )
415
- if uses_shell and command and shell_background_reasons(command):
416
- reasons.append("Python shell background execution is forbidden")
417
- return reasons
418
-
419
-
420
- def mask_javascript(source: str, mask_strings: bool) -> str:
421
- output = []
422
- state = "code"
423
- quote = ""
424
- index = 0
425
- while index < len(source):
426
- character = source[index]
427
- following = source[index + 1] if index + 1 < len(source) else ""
428
- if state == "code":
429
- if character == "/" and following == "/":
430
- output.extend((" ", " "))
431
- state = "line-comment"
432
- index += 2
433
- continue
434
- if character == "/" and following == "*":
435
- output.extend((" ", " "))
436
- state = "block-comment"
437
- index += 2
438
- continue
439
- if character in ("'", '"', "`"):
440
- quote = character
441
- state = "string"
442
- output.append(" " if mask_strings else character)
443
- index += 1
444
- continue
445
- output.append(character)
446
- index += 1
447
- continue
448
- if state == "line-comment":
449
- output.append("\n" if character == "\n" else " ")
450
- if character == "\n":
451
- state = "code"
452
- index += 1
453
- continue
454
- if state == "block-comment":
455
- if character == "*" and following == "/":
456
- output.extend((" ", " "))
457
- state = "code"
458
- index += 2
459
- else:
460
- output.append("\n" if character == "\n" else " ")
461
- index += 1
462
- continue
463
- if character == "\\" and following:
464
- output.extend((" ", " ") if mask_strings else (character, following))
465
- index += 2
466
- continue
467
- output.append(" " if mask_strings and character != "\n" else character)
468
- if character == quote:
469
- state = "code"
470
- index += 1
471
- return "".join(output)
472
-
473
-
474
- def scan_node_backgrounding(source: str) -> list[str]:
475
- without_comments = mask_javascript(source, False)
476
- executable = mask_javascript(source, True)
477
- module_aliases = {"child_process"}
478
- function_aliases = set()
479
- module_pattern = r"(?:node:)?child_process"
480
- for match in re.finditer(
481
- rf"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['\"]{module_pattern}['\"]\s*\)",
482
- without_comments,
483
- ):
484
- module_aliases.add(match.group(1))
485
- for match in re.finditer(
486
- rf"\bimport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+['\"]{module_pattern}['\"]",
487
- without_comments,
488
- ):
489
- module_aliases.add(match.group(1))
490
- destructured_patterns = [
491
- rf"\b(?:const|let|var)\s*\{{([^}}]+)\}}\s*=\s*require\(\s*['\"]{module_pattern}['\"]\s*\)",
492
- rf"\bimport\s*\{{([^}}]+)\}}\s*from\s*['\"]{module_pattern}['\"]",
493
- ]
494
- asynchronous = {"spawn", "exec", "execFile", "fork"}
495
- for pattern in destructured_patterns:
496
- for match in re.finditer(pattern, without_comments):
497
- for item in match.group(1).split(","):
498
- parts = re.split(r"\s+(?:as)\s+|\s*:\s*", item.strip())
499
- if parts and parts[0] in asynchronous:
500
- function_aliases.add(parts[-1])
501
- for alias in module_aliases:
502
- if re.search(rf"\b{re.escape(alias)}\s*\.\s*(?:spawn|exec|execFile|fork)\s*\(", executable):
503
- return ["Node asynchronous process creation is forbidden"]
504
- for alias in function_aliases:
505
- if re.search(rf"\b{re.escape(alias)}\s*\(", executable):
506
- return ["Node asynchronous process creation is forbidden"]
507
- if re.search(
508
- rf"\brequire\(\s*['\"]{module_pattern}['\"]\s*\)\s*\.\s*(?:spawn|exec|execFile|fork)\s*\(",
509
- without_comments,
510
- ):
511
- return ["Node asynchronous process creation is forbidden"]
512
- return []
513
-
514
-
515
- def scan_shell_command(command: str, depth: int = 0) -> list[str]:
516
- shell_command, _, interpreter_sections = parse_heredoc_content(command)
517
- reasons = shell_background_reasons(shell_command)
518
- stripped = strip_escaped_characters(strip_quoted(shell_command))
519
- if protected_tool_invoked(stripped):
520
- if SHELL_CONTROL_OPERATOR.search(stripped):
521
- reasons.append("run-long-check and watch-job must be standalone Bash commands")
522
- if RUN_LONG_CHECK_SHELL_STRING.search(stripped):
523
- reasons.append("run-long-check must receive a direct executable or script, not a shell command string")
524
-
525
- # `sh -c '...'` quoted payloads are shell code, not plain strings.
526
- if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
527
- for segment in quoted_segments(shell_command):
528
- if protected_tool_invoked(strip_escaped_characters(strip_quoted(segment))):
529
- reasons.append("run-long-check and watch-job must not be invoked through a shell command string")
530
- reasons.extend(scan_shell_command(segment, depth + 1))
531
- if depth < MAX_NESTED_SHELL_DEPTH:
532
- for substitution in double_quoted_command_substitutions(shell_command):
533
- nested_stripped = strip_escaped_characters(strip_quoted(substitution))
534
- if protected_tool_invoked(nested_stripped):
535
- reasons.append("run-long-check and watch-job must not be invoked through command substitution")
536
- reasons.extend(scan_shell_command(substitution, depth + 1))
537
- for language, source in interpreter_sections:
538
- if language == "python":
539
- reasons.extend(scan_python_backgrounding(source))
540
- elif language == "node":
541
- reasons.extend(scan_node_backgrounding(source))
542
- return reasons
543
-
544
-
545
- def guard_reasons(tool_input: dict) -> list[str]:
546
- reasons = []
547
- if tool_input.get("run_in_background"):
548
- reasons.append("Bash run_in_background is forbidden")
549
-
550
- command = tool_input.get("command")
551
- command = command if isinstance(command, str) else ""
552
-
553
- reasons.extend(scan_shell_command(command))
554
- if mutates_managed_artifact(command):
555
- reasons.append("direct Bash writes to VCM-managed Markdown are forbidden")
556
- return list(dict.fromkeys(reasons))
557
-
558
-
559
- def main() -> int:
560
- raw = sys.stdin.read()
561
- if os.environ.get("VCM_ROLE", "").strip() not in WORKFLOW_ROLES:
562
- return 0
563
-
564
- try:
565
- payload = json.loads(raw) if raw.strip() else {}
566
- except ValueError:
567
- return 0
568
- tool_name = payload.get("tool_name")
569
- if tool_name == "Bash":
570
- tool_input = payload.get("tool_input")
571
- tool_input = tool_input if isinstance(tool_input, dict) else {}
572
- reasons = guard_reasons(tool_input)
573
- elif tool_name in ("Write", "Edit"):
574
- tool_input = payload.get("tool_input")
575
- tool_input = tool_input if isinstance(tool_input, dict) else {}
576
- file_path = tool_input.get("file_path") or tool_input.get("path")
577
- reasons = (
578
- ["direct Write/Edit of VCM-managed Markdown is forbidden"]
579
- if isinstance(file_path, str) and MANAGED_ARTIFACT_PATH.search(file_path)
580
- else []
581
- )
582
- else:
583
- return 0
584
- if not reasons:
585
- return 0
586
-
587
- print(
588
- json.dumps(
589
- {
590
- "hookSpecificOutput": {
591
- "hookEventName": "PreToolUse",
592
- "permissionDecision": "deny",
593
- "permissionDecisionReason": (
594
- "VCM denied this tool call (" + "; ".join(reasons) + "). "
595
- + (ARTIFACT_HINT if any("Markdown" in reason for reason in reasons) else SKILL_HINT)
596
- ),
597
- }
598
- }
599
- )
600
- )
601
- return 0
602
-
603
-
604
- if __name__ == "__main__":
605
- raise SystemExit(main())
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // VCM PreToolUse guard for supervised tools inside VCM workflow-role sessions.
4
+ //
5
+ // Reads the Claude Code PreToolUse hook payload on stdin. It enforces supervised
6
+ // Bash execution and managed-artifact writes for VCM workflow roles. Tool roles
7
+ // are outside the workflow and bypass this guard.
8
+ //
9
+ // Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
10
+ // shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
11
+ // only sanctioned detached worker; the command it runs must still stay in the
12
+ // supervised foreground process group.
13
+
14
+ const fs = require("node:fs");
15
+
16
+ const WORKFLOW_ROLES = new Set(["project-manager", "architect", "coder", "tester", "reviewer"]);
17
+
18
+ const SKILL_HINT = "Use the vcm-long-running-validation skill instead: "
19
+ + "`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
20
+ + "`.ai/tools/watch-job <job-id>` in the same turn, repeating watch-job "
21
+ + "until it reports a terminal result.";
22
+ const ARTIFACT_HINT = "VCM-managed Markdown cannot be written directly. Write a candidate outside "
23
+ + ".ai/vcm, then run .ai/tools/vcm-artifact <kind> --file <candidate> "
24
+ + "--mode <draft|final>.";
25
+ const MAX_NESTED_SHELL_DEPTH = 3;
26
+ const QUOTED = /'([^']*)'|"([^"]*)"/g;
27
+ const SHELL_DASH_C = /(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh)\s+(?:-\w+\s+)*-\w*c\w*(?:\s|$)/;
28
+ const PROTECTED_TOOL_INVOCATION = new RegExp(
29
+ "(?:^|[;&|()\\n`])\\s*"
30
+ + "(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\\s;&|()]+)\\s+)*"
31
+ + "(?:[^\\s;&|()]*/)?(?:run-long-check|watch-job)(?=\\s|$)"
32
+ );
33
+ const HEREDOC_START = /<<(?!<)(-?)\s*(?:(['"])([^'"]+)\2|([^\s;&|()<>]+))/g;
34
+ const EXECUTABLE_HEREDOC_RECEIVER = new RegExp(
35
+ "(?:^|[;&|()])\\s*"
36
+ + "(?:(?:env\\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\\s;&|()]+\\s+)*)"
37
+ + "(?:[^\\s;&|()]*/)?(?<receiver>sh|bash|zsh|dash|ksh|python\\d*(?:\\.\\d+)?|node)(?=\\s|$)"
38
+ );
39
+ const SHELL_HEREDOC_RECEIVERS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
40
+ const SHELL_CONTROL_OPERATOR = /(?:\|&?|&&|\|\||;|\n|\(|\)|`)/;
41
+ const RUN_LONG_CHECK_SHELL_STRING = new RegExp(
42
+ "(?:^|[;&|()\\n])\\s*"
43
+ + "(?:[^\\s;&|()]*/)?run-long-check\\b.*?\\s--\\s+"
44
+ + "(?:env(?:\\s+[A-Za-z_][A-Za-z0-9_]*=[^\\s]+)*\\s+)?"
45
+ + "(?:[^\\s;&|()]*/)?(?:sh|bash|zsh|dash|ksh)\\s+"
46
+ + "(?:-\\w+\\s+)*-\\w*c\\w*(?:\\s|$)"
47
+ );
48
+ const COMMAND_SUBSTITUTION = /\$\(([^()]*)\)|`([^`]*)`/g;
49
+ const MANAGED_ARTIFACT_PATH = new RegExp(
50
+ "(?:^|[\\s/\\\\])\\.ai[/\\\\]vcm[/\\\\](?:"
51
+ + "handoffs[/\\\\].*\\.md|"
52
+ + "gate-reviews[/\\\\]requests[/\\\\].*\\.report\\.md|"
53
+ + "memory-review[/\\\\].*\\.md|"
54
+ + "coder-workers[/\\\\]reports[/\\\\].*\\.md|"
55
+ + "harness-feedback[/\\\\]pending[/\\\\].*\\.md"
56
+ + ")"
57
+ );
58
+ const SHELL_SEPARATOR_TOKEN = /^[;&|()]+$/;
59
+ const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
60
+ const PYTHON_PATH_MUTATION = /\bPath\s*\(\s*(['"])(?<path>[^'"]+)\1\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|rmdir|touch|rename|replace)\s*\(/g;
61
+ const PYTHON_OPEN = /\bopen\s*\(\s*(['"])(?<path>[^'"]+)\1\s*,\s*(['"])(?<mode>[^'"]+)\3/g;
62
+ const NODE_PATH_MUTATION = new RegExp(
63
+ "\\b(?:writeFile|writeFileSync|appendFile|appendFileSync|unlink|unlinkSync|"
64
+ + "rm|rmSync|rmdir|rmdirSync|createWriteStream)\\s*\\(\\s*"
65
+ + "(['\"])(?<path>[^'\"]+)\\1",
66
+ "g"
67
+ );
68
+
69
+ function stripQuoted(command) {
70
+ return command.replace(QUOTED, " ");
71
+ }
72
+
73
+ function quotedSegments(command) {
74
+ return [...command.matchAll(QUOTED)].map((match) => match[1] ?? match[2] ?? "");
75
+ }
76
+
77
+ function doubleQuotedCommandSubstitutions(command) {
78
+ const substitutions = [];
79
+ for (const match of command.matchAll(QUOTED)) {
80
+ const double = match[2];
81
+ if (match[1] !== undefined || !double) {
82
+ continue;
83
+ }
84
+ for (const substitution of double.matchAll(COMMAND_SUBSTITUTION)) {
85
+ substitutions.push(substitution[1] ?? substitution[2] ?? "");
86
+ }
87
+ }
88
+ return substitutions;
89
+ }
90
+
91
+ function stripEscapedCharacters(command) {
92
+ let cleaned = "";
93
+ let index = 0;
94
+ while (index < command.length) {
95
+ if (command[index] === "\\" && index + 1 < command.length) {
96
+ cleaned += " ";
97
+ index += 2;
98
+ continue;
99
+ }
100
+ cleaned += command[index];
101
+ index += 1;
102
+ }
103
+ return cleaned;
104
+ }
105
+
106
+ function heredocReceiverLanguage(line) {
107
+ const match = EXECUTABLE_HEREDOC_RECEIVER.exec(line);
108
+ if (!match) {
109
+ return null;
110
+ }
111
+ const receiver = match.groups.receiver;
112
+ if (SHELL_HEREDOC_RECEIVERS.has(receiver)) {
113
+ return "shell";
114
+ }
115
+ return receiver.startsWith("python") ? "python" : "node";
116
+ }
117
+
118
+ function splitLinesKeepEnds(text) {
119
+ return text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
120
+ }
121
+
122
+ function endsWithNewline(text) {
123
+ return text.endsWith("\n") || text.endsWith("\r");
124
+ }
125
+
126
+ // Returns shell-only text, all executable text, and typed interpreter bodies.
127
+ function parseHeredocContent(command) {
128
+ const pending = [];
129
+ let shellExecutable = "";
130
+ let allExecutable = "";
131
+ const interpreterSections = [];
132
+ for (const line of splitLinesKeepEnds(command)) {
133
+ if (pending.length > 0) {
134
+ const { delimiter, stripTabs, language, body } = pending[0];
135
+ let candidate = line.replace(/[\r\n]+$/, "");
136
+ if (stripTabs) {
137
+ candidate = candidate.replace(/^\t+/, "");
138
+ }
139
+ if (candidate === delimiter) {
140
+ pending.shift();
141
+ } else if (language) {
142
+ const contentLine = stripTabs ? line.replace(/^\t+/, "") : line;
143
+ allExecutable += contentLine;
144
+ if (language === "shell") {
145
+ shellExecutable += contentLine;
146
+ } else {
147
+ body.push(contentLine);
148
+ if (endsWithNewline(contentLine)) {
149
+ shellExecutable += "\n";
150
+ }
151
+ }
152
+ } else if (endsWithNewline(line)) {
153
+ shellExecutable += "\n";
154
+ allExecutable += "\n";
155
+ }
156
+ continue;
157
+ }
158
+
159
+ shellExecutable += line;
160
+ allExecutable += line;
161
+ const language = heredocReceiverLanguage(line);
162
+ for (const match of line.matchAll(HEREDOC_START)) {
163
+ const body = [];
164
+ pending.push({ delimiter: match[3] ?? match[4], stripTabs: Boolean(match[1]), language, body });
165
+ if (language === "python" || language === "node") {
166
+ interpreterSections.push({ language, body });
167
+ }
168
+ }
169
+ }
170
+ return {
171
+ shellCommand: shellExecutable,
172
+ executable: allExecutable,
173
+ interpreterSections: interpreterSections.map(({ language, body }) => ({ language, source: body.join("") }))
174
+ };
175
+ }
176
+
177
+ // Removes data heredocs while preserving all executable interpreter bodies.
178
+ function stripHeredocBodies(command) {
179
+ return parseHeredocContent(command).executable;
180
+ }
181
+
182
+ // Mirrors Python shlex in posix mode with punctuation_chars=";&|()<>" and
183
+ // whitespace_split: quotes are removed, runs of punctuation become tokens, and
184
+ // an unclosed quote or trailing escape makes the command unparsable.
185
+ function shellTokens(text) {
186
+ const whitespace = " \t\r\n";
187
+ const punctuation = ";&|()<>";
188
+ const tokens = [];
189
+ let current = "";
190
+ let inWord = false;
191
+ let index = 0;
192
+ const finishWord = () => {
193
+ if (inWord) {
194
+ tokens.push(current);
195
+ current = "";
196
+ inWord = false;
197
+ }
198
+ };
199
+ while (index < text.length) {
200
+ const character = text[index];
201
+ if (whitespace.includes(character)) {
202
+ finishWord();
203
+ index += 1;
204
+ continue;
205
+ }
206
+ if (punctuation.includes(character)) {
207
+ finishWord();
208
+ let run = "";
209
+ while (index < text.length && punctuation.includes(text[index])) {
210
+ run += text[index];
211
+ index += 1;
212
+ }
213
+ tokens.push(run);
214
+ continue;
215
+ }
216
+ if (character === "\\") {
217
+ if (index + 1 >= text.length) {
218
+ throw new Error("No escaped character");
219
+ }
220
+ current += text[index + 1];
221
+ inWord = true;
222
+ index += 2;
223
+ continue;
224
+ }
225
+ if (character === "'") {
226
+ const end = text.indexOf("'", index + 1);
227
+ if (end < 0) {
228
+ throw new Error("No closing quotation");
229
+ }
230
+ current += text.slice(index + 1, end);
231
+ inWord = true;
232
+ index = end + 1;
233
+ continue;
234
+ }
235
+ if (character === "\"") {
236
+ index += 1;
237
+ let closed = false;
238
+ while (index < text.length) {
239
+ const next = text[index];
240
+ if (next === "\"") {
241
+ closed = true;
242
+ index += 1;
243
+ break;
244
+ }
245
+ if (next === "\\" && index + 1 < text.length && (text[index + 1] === "\"" || text[index + 1] === "\\")) {
246
+ current += text[index + 1];
247
+ index += 2;
248
+ continue;
249
+ }
250
+ current += next;
251
+ index += 1;
252
+ }
253
+ if (!closed) {
254
+ throw new Error("No closing quotation");
255
+ }
256
+ inWord = true;
257
+ continue;
258
+ }
259
+ current += character;
260
+ inWord = true;
261
+ index += 1;
262
+ }
263
+ finishWord();
264
+ return tokens;
265
+ }
266
+
267
+ function shellSegments(command) {
268
+ const { shellCommand } = parseHeredocContent(command);
269
+ const normalized = shellCommand.replaceAll("\\\n", " ").replaceAll("\n", " ; ");
270
+ let tokens;
271
+ try {
272
+ tokens = shellTokens(normalized);
273
+ } catch {
274
+ return [];
275
+ }
276
+
277
+ const segments = [];
278
+ let current = [];
279
+ for (const token of tokens) {
280
+ if (SHELL_SEPARATOR_TOKEN.test(token)) {
281
+ if (current.length > 0) {
282
+ segments.push(current);
283
+ current = [];
284
+ }
285
+ continue;
286
+ }
287
+ current.push(token);
288
+ }
289
+ if (current.length > 0) {
290
+ segments.push(current);
291
+ }
292
+ return segments;
293
+ }
294
+
295
+ function isManagedArtifactPath(value) {
296
+ return MANAGED_ARTIFACT_PATH.test(value);
297
+ }
298
+
299
+ function baseName(token) {
300
+ return token.split("/").pop();
301
+ }
302
+
303
+ function commandIndex(segment) {
304
+ let index = 0;
305
+ while (index < segment.length && SHELL_ASSIGNMENT.test(segment[index])) {
306
+ index += 1;
307
+ }
308
+ if (index < segment.length && baseName(segment[index]) === "env") {
309
+ index += 1;
310
+ while (index < segment.length && (segment[index].startsWith("-") || SHELL_ASSIGNMENT.test(segment[index]))) {
311
+ index += 1;
312
+ }
313
+ }
314
+ return index < segment.length ? index : null;
315
+ }
316
+
317
+ function redirectionDetails(segment) {
318
+ const targets = [];
319
+ const consumed = new Set();
320
+ segment.forEach((token, index) => {
321
+ if (!token.includes(">") || ![...token].every((character) => "<>&|".includes(character))) {
322
+ return;
323
+ }
324
+ consumed.add(index);
325
+ if (index > 0 && /^\d+$/.test(segment[index - 1])) {
326
+ consumed.add(index - 1);
327
+ }
328
+ if (index + 1 >= segment.length) {
329
+ return;
330
+ }
331
+ consumed.add(index + 1);
332
+ const target = segment[index + 1];
333
+ if ((token === ">&" || token === "<&") && (/^\d+$/.test(target) || target === "-")) {
334
+ return;
335
+ }
336
+ targets.push(target);
337
+ });
338
+ return { targets, consumed };
339
+ }
340
+
341
+ function commandOperands(segment, start, consumed) {
342
+ const operands = [];
343
+ let optionsDone = false;
344
+ for (let index = start; index < segment.length; index += 1) {
345
+ if (consumed.has(index)) {
346
+ continue;
347
+ }
348
+ const token = segment[index];
349
+ if (token === "--") {
350
+ optionsDone = true;
351
+ continue;
352
+ }
353
+ if (!optionsDone && token.startsWith("-")) {
354
+ continue;
355
+ }
356
+ operands.push(token);
357
+ }
358
+ return operands;
359
+ }
360
+
361
+ function explicitInterpreterMutation(commandNames, source) {
362
+ if ([...commandNames].some((name) => /^python\d*(?:\.\d+)?$/.test(name))) {
363
+ for (const match of source.matchAll(PYTHON_PATH_MUTATION)) {
364
+ if (isManagedArtifactPath(match.groups.path)) {
365
+ return true;
366
+ }
367
+ }
368
+ for (const match of source.matchAll(PYTHON_OPEN)) {
369
+ if (isManagedArtifactPath(match.groups.path) && [..."wax+"].some((flag) => match.groups.mode.includes(flag))) {
370
+ return true;
371
+ }
372
+ }
373
+ }
374
+ if (commandNames.has("node")) {
375
+ return [...source.matchAll(NODE_PATH_MUTATION)].some((match) => isManagedArtifactPath(match.groups.path));
376
+ }
377
+ return false;
378
+ }
379
+
380
+ function mutatesManagedArtifact(command) {
381
+ const executableCommand = stripHeredocBodies(command);
382
+ const segments = shellSegments(command);
383
+ const commandNames = new Set();
384
+ for (const segment of segments) {
385
+ const index = commandIndex(segment);
386
+ if (index !== null) {
387
+ commandNames.add(baseName(segment[index]));
388
+ }
389
+ }
390
+ if (explicitInterpreterMutation(commandNames, executableCommand)) {
391
+ return true;
392
+ }
393
+
394
+ for (const segment of segments) {
395
+ const { targets, consumed } = redirectionDetails(segment);
396
+ if (targets.some(isManagedArtifactPath)) {
397
+ return true;
398
+ }
399
+
400
+ const executableIndex = commandIndex(segment);
401
+ if (executableIndex === null) {
402
+ continue;
403
+ }
404
+ const commandName = baseName(segment[executableIndex]);
405
+ const argumentStart = executableIndex + 1;
406
+ const args = segment.slice(argumentStart);
407
+ const operands = commandOperands(segment, argumentStart, consumed);
408
+
409
+ let targetDirectory = null;
410
+ args.forEach((argument, index) => {
411
+ if ((argument === "-t" || argument === "--target-directory") && index + 1 < args.length) {
412
+ targetDirectory = args[index + 1];
413
+ } else if (argument.startsWith("--target-directory=")) {
414
+ targetDirectory = argument.split("=").slice(1).join("=");
415
+ }
416
+ });
417
+
418
+ if (commandName === "cp") {
419
+ const destination = targetDirectory || (operands.length >= 2 ? operands[operands.length - 1] : null);
420
+ if (destination && isManagedArtifactPath(destination)) {
421
+ return true;
422
+ }
423
+ } else if (commandName === "mv") {
424
+ if (targetDirectory && isManagedArtifactPath(targetDirectory)) {
425
+ return true;
426
+ }
427
+ if (operands.some(isManagedArtifactPath)) {
428
+ return true;
429
+ }
430
+ } else if (commandName === "rm" || commandName === "tee") {
431
+ if (operands.some(isManagedArtifactPath)) {
432
+ return true;
433
+ }
434
+ } else if (commandName === "sed" || commandName === "perl") {
435
+ const inPlace = args.some((argument) => argument.startsWith("-") && argument.slice(1).includes("i"));
436
+ if (inPlace && args.some(isManagedArtifactPath)) {
437
+ return true;
438
+ }
439
+ }
440
+ }
441
+ return false;
442
+ }
443
+
444
+ function unquotedAmpersand(commandWithoutQuotes) {
445
+ const cleaned = commandWithoutQuotes
446
+ .replace(/\d*>&\d*/g, " ")
447
+ .replace(/<&\d*/g, " ")
448
+ .replaceAll("&&", " ");
449
+ return /&\s*(?:$|[;\n)])/.test(cleaned) || /\s&\s/.test(cleaned);
450
+ }
451
+
452
+ function protectedToolInvoked(commandWithoutQuotes) {
453
+ return PROTECTED_TOOL_INVOCATION.test(commandWithoutQuotes);
454
+ }
455
+
456
+ function shellBackgroundReasons(command) {
457
+ const stripped = stripEscapedCharacters(stripQuoted(command));
458
+ const reasons = [];
459
+ if (/(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)/.test(stripped)) {
460
+ reasons.push("nohup/setsid detach is forbidden");
461
+ }
462
+ if (/(?:^|[\s;&|(])disown(?:\s|$)/.test(stripped)) {
463
+ reasons.push("disown is forbidden");
464
+ }
465
+ if (unquotedAmpersand(stripped)) {
466
+ reasons.push("'&' background execution is forbidden");
467
+ }
468
+ return reasons;
469
+ }
470
+
471
+ // Masks Python comments (and optionally string contents) with spaces while
472
+ // preserving offsets, so call and import scanning only sees executable code.
473
+ function maskPython(source, maskStrings) {
474
+ let output = "";
475
+ let index = 0;
476
+ while (index < source.length) {
477
+ const character = source[index];
478
+ if (character === "#") {
479
+ while (index < source.length && source[index] !== "\n") {
480
+ output += " ";
481
+ index += 1;
482
+ }
483
+ continue;
484
+ }
485
+ if (character === "'" || character === "\"") {
486
+ const triple = source.startsWith(character.repeat(3), index);
487
+ const delimiter = triple ? character.repeat(3) : character;
488
+ output += maskStrings ? " ".repeat(delimiter.length) : delimiter;
489
+ index += delimiter.length;
490
+ while (index < source.length) {
491
+ if (source[index] === "\\" && index + 1 < source.length) {
492
+ output += maskStrings ? " " : source.slice(index, index + 2);
493
+ index += 2;
494
+ continue;
495
+ }
496
+ if (source.startsWith(delimiter, index)) {
497
+ output += maskStrings ? " ".repeat(delimiter.length) : delimiter;
498
+ index += delimiter.length;
499
+ break;
500
+ }
501
+ if (!triple && source[index] === "\n") {
502
+ break;
503
+ }
504
+ output += maskStrings && source[index] !== "\n" ? " " : source[index];
505
+ index += 1;
506
+ }
507
+ continue;
508
+ }
509
+ output += character;
510
+ index += 1;
511
+ }
512
+ return output;
513
+ }
514
+
515
+ function pythonImportAliases(code) {
516
+ const aliases = new Map();
517
+ for (const statement of code.split(/[;\n]/)) {
518
+ const fromImport = /^\s*from\s+([A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(statement);
519
+ if (fromImport) {
520
+ for (const item of fromImport[2].replace(/[()]/g, " ").split(",")) {
521
+ const parts = item.trim().split(/\s+as\s+/);
522
+ if (parts[0]) {
523
+ aliases.set((parts[1] || parts[0]).trim(), `${fromImport[1]}.${parts[0].trim()}`);
524
+ }
525
+ }
526
+ continue;
527
+ }
528
+ const plainImport = /^\s*import\s+(.+)$/.exec(statement);
529
+ if (plainImport) {
530
+ for (const item of plainImport[1].split(",")) {
531
+ const parts = item.trim().split(/\s+as\s+/);
532
+ const name = parts[0].trim();
533
+ if (name) {
534
+ aliases.set(parts[1] ? parts[1].trim() : name.split(".")[0], name);
535
+ }
536
+ }
537
+ }
538
+ }
539
+ return aliases;
540
+ }
541
+
542
+ // Returns the value of a plain Python string literal starting at offset when it
543
+ // is the whole argument, mirroring an ast.Constant first argument.
544
+ function pythonLiteralArgument(source, offset) {
545
+ const match = /^\s*([rRuU]?)('''|"""|'|")/.exec(source.slice(offset));
546
+ if (!match) {
547
+ return null;
548
+ }
549
+ const delimiter = match[2];
550
+ let index = offset + match[0].length;
551
+ let value = "";
552
+ while (index < source.length) {
553
+ if (source[index] === "\\" && index + 1 < source.length) {
554
+ value += source.slice(index, index + 2);
555
+ index += 2;
556
+ continue;
557
+ }
558
+ if (source.startsWith(delimiter, index)) {
559
+ index += delimiter.length;
560
+ return /^\s*[,)]/.test(source.slice(index)) ? value : null;
561
+ }
562
+ value += source[index];
563
+ index += 1;
564
+ }
565
+ return null;
566
+ }
567
+
568
+ function callArgumentSpan(executable, openParen) {
569
+ let depth = 0;
570
+ for (let index = openParen; index < executable.length; index += 1) {
571
+ if (executable[index] === "(") {
572
+ depth += 1;
573
+ } else if (executable[index] === ")") {
574
+ depth -= 1;
575
+ if (depth === 0) {
576
+ return executable.slice(openParen + 1, index);
577
+ }
578
+ }
579
+ }
580
+ return executable.slice(openParen + 1);
581
+ }
582
+
583
+ function scanPythonBackgrounding(source) {
584
+ const withoutComments = maskPython(source, false);
585
+ const executable = maskPython(source, true);
586
+ const aliases = pythonImportAliases(withoutComments);
587
+ const reasons = [];
588
+ const asynchronousCalls = new Set([
589
+ "subprocess.Popen",
590
+ "os.fork",
591
+ "os.popen",
592
+ "asyncio.create_subprocess_exec",
593
+ "asyncio.create_subprocess_shell"
594
+ ]);
595
+ const shellCalls = new Set(["os.system", "subprocess.getoutput", "subprocess.getstatusoutput"]);
596
+ const foregroundCalls = new Set(["subprocess.run", "subprocess.call", "subprocess.check_call", "subprocess.check_output"]);
597
+ const callPattern = /(?<![\w.])([A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)\s*\(/g;
598
+ for (const match of executable.matchAll(callPattern)) {
599
+ if (/\b(?:def|class)\s+$/.test(executable.slice(0, match.index))) {
600
+ continue;
601
+ }
602
+ const parts = match[1].replace(/\s+/g, "").split(".");
603
+ const resolved = aliases.has(parts[0]) ? [...aliases.get(parts[0]).split("."), ...parts.slice(1)] : parts;
604
+ const name = resolved.join(".");
605
+ if (asynchronousCalls.has(name) || name.startsWith("os.spawn")) {
606
+ reasons.push("Python asynchronous process creation is forbidden");
607
+ continue;
608
+ }
609
+ const openParen = match.index + match[0].length - 1;
610
+ const command = pythonLiteralArgument(source, openParen + 1);
611
+ if (shellCalls.has(name) && command && shellBackgroundReasons(command).length > 0) {
612
+ reasons.push("Python shell background execution is forbidden");
613
+ continue;
614
+ }
615
+ if (foregroundCalls.has(name)) {
616
+ const usesShell = /(?:^|[,\s])shell\s*=\s*True\b/.test(callArgumentSpan(executable, openParen));
617
+ if (usesShell && command && shellBackgroundReasons(command).length > 0) {
618
+ reasons.push("Python shell background execution is forbidden");
619
+ }
620
+ }
621
+ }
622
+ return reasons;
623
+ }
624
+
625
+ function maskJavascript(source, maskStrings) {
626
+ let output = "";
627
+ let state = "code";
628
+ let quote = "";
629
+ let index = 0;
630
+ while (index < source.length) {
631
+ const character = source[index];
632
+ const following = index + 1 < source.length ? source[index + 1] : "";
633
+ if (state === "code") {
634
+ if (character === "/" && following === "/") {
635
+ output += " ";
636
+ state = "line-comment";
637
+ index += 2;
638
+ continue;
639
+ }
640
+ if (character === "/" && following === "*") {
641
+ output += " ";
642
+ state = "block-comment";
643
+ index += 2;
644
+ continue;
645
+ }
646
+ if (character === "'" || character === "\"" || character === "`") {
647
+ quote = character;
648
+ state = "string";
649
+ output += maskStrings ? " " : character;
650
+ index += 1;
651
+ continue;
652
+ }
653
+ output += character;
654
+ index += 1;
655
+ continue;
656
+ }
657
+ if (state === "line-comment") {
658
+ output += character === "\n" ? "\n" : " ";
659
+ if (character === "\n") {
660
+ state = "code";
661
+ }
662
+ index += 1;
663
+ continue;
664
+ }
665
+ if (state === "block-comment") {
666
+ if (character === "*" && following === "/") {
667
+ output += " ";
668
+ state = "code";
669
+ index += 2;
670
+ } else {
671
+ output += character === "\n" ? "\n" : " ";
672
+ index += 1;
673
+ }
674
+ continue;
675
+ }
676
+ if (character === "\\" && following) {
677
+ output += maskStrings ? " " : character + following;
678
+ index += 2;
679
+ continue;
680
+ }
681
+ output += maskStrings && character !== "\n" ? " " : character;
682
+ if (character === quote) {
683
+ state = "code";
684
+ }
685
+ index += 1;
686
+ }
687
+ return output;
688
+ }
689
+
690
+ function escapeRegExp(value) {
691
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
692
+ }
693
+
694
+ function scanNodeBackgrounding(source) {
695
+ const withoutComments = maskJavascript(source, false);
696
+ const executable = maskJavascript(source, true);
697
+ const moduleAliases = new Set(["child_process"]);
698
+ const functionAliases = new Set();
699
+ const modulePattern = "(?:node:)?child_process";
700
+ for (const match of withoutComments.matchAll(new RegExp(
701
+ `\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*['"]${modulePattern}['"]\\s*\\)`,
702
+ "g"
703
+ ))) {
704
+ moduleAliases.add(match[1]);
705
+ }
706
+ for (const match of withoutComments.matchAll(new RegExp(
707
+ `\\bimport\\s+\\*\\s+as\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+['"]${modulePattern}['"]`,
708
+ "g"
709
+ ))) {
710
+ moduleAliases.add(match[1]);
711
+ }
712
+ const destructuredPatterns = [
713
+ `\\b(?:const|let|var)\\s*\\{([^}]+)\\}\\s*=\\s*require\\(\\s*['"]${modulePattern}['"]\\s*\\)`,
714
+ `\\bimport\\s*\\{([^}]+)\\}\\s*from\\s*['"]${modulePattern}['"]`
715
+ ];
716
+ const asynchronous = new Set(["spawn", "exec", "execFile", "fork"]);
717
+ for (const pattern of destructuredPatterns) {
718
+ for (const match of withoutComments.matchAll(new RegExp(pattern, "g"))) {
719
+ for (const item of match[1].split(",")) {
720
+ const parts = item.trim().split(/\s+(?:as)\s+|\s*:\s*/);
721
+ if (parts.length > 0 && asynchronous.has(parts[0])) {
722
+ functionAliases.add(parts[parts.length - 1]);
723
+ }
724
+ }
725
+ }
726
+ }
727
+ for (const alias of moduleAliases) {
728
+ if (new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*(?:spawn|exec|execFile|fork)\\s*\\(`).test(executable)) {
729
+ return ["Node asynchronous process creation is forbidden"];
730
+ }
731
+ }
732
+ for (const alias of functionAliases) {
733
+ if (new RegExp(`\\b${escapeRegExp(alias)}\\s*\\(`).test(executable)) {
734
+ return ["Node asynchronous process creation is forbidden"];
735
+ }
736
+ }
737
+ if (new RegExp(`\\brequire\\(\\s*['"]${modulePattern}['"]\\s*\\)\\s*\\.\\s*(?:spawn|exec|execFile|fork)\\s*\\(`).test(withoutComments)) {
738
+ return ["Node asynchronous process creation is forbidden"];
739
+ }
740
+ return [];
741
+ }
742
+
743
+ function scanShellCommand(command, depth = 0) {
744
+ const { shellCommand, interpreterSections } = parseHeredocContent(command);
745
+ const reasons = shellBackgroundReasons(shellCommand);
746
+ const stripped = stripEscapedCharacters(stripQuoted(shellCommand));
747
+ if (protectedToolInvoked(stripped)) {
748
+ if (SHELL_CONTROL_OPERATOR.test(stripped)) {
749
+ reasons.push("run-long-check and watch-job must be standalone Bash commands");
750
+ }
751
+ if (RUN_LONG_CHECK_SHELL_STRING.test(stripped)) {
752
+ reasons.push("run-long-check must receive a direct executable or script, not a shell command string");
753
+ }
754
+ }
755
+
756
+ // `sh -c '...'` quoted payloads are shell code, not plain strings.
757
+ if (depth < MAX_NESTED_SHELL_DEPTH && SHELL_DASH_C.test(stripped)) {
758
+ for (const segment of quotedSegments(shellCommand)) {
759
+ if (protectedToolInvoked(stripEscapedCharacters(stripQuoted(segment)))) {
760
+ reasons.push("run-long-check and watch-job must not be invoked through a shell command string");
761
+ }
762
+ reasons.push(...scanShellCommand(segment, depth + 1));
763
+ }
764
+ }
765
+ if (depth < MAX_NESTED_SHELL_DEPTH) {
766
+ for (const substitution of doubleQuotedCommandSubstitutions(shellCommand)) {
767
+ if (protectedToolInvoked(stripEscapedCharacters(stripQuoted(substitution)))) {
768
+ reasons.push("run-long-check and watch-job must not be invoked through command substitution");
769
+ }
770
+ reasons.push(...scanShellCommand(substitution, depth + 1));
771
+ }
772
+ }
773
+ for (const { language, source } of interpreterSections) {
774
+ if (language === "python") {
775
+ reasons.push(...scanPythonBackgrounding(source));
776
+ } else if (language === "node") {
777
+ reasons.push(...scanNodeBackgrounding(source));
778
+ }
779
+ }
780
+ return reasons;
781
+ }
782
+
783
+ function guardReasons(toolInput) {
784
+ const reasons = [];
785
+ if (toolInput.run_in_background) {
786
+ reasons.push("Bash run_in_background is forbidden");
787
+ }
788
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
789
+ reasons.push(...scanShellCommand(command));
790
+ if (mutatesManagedArtifact(command)) {
791
+ reasons.push("direct Bash writes to VCM-managed Markdown are forbidden");
792
+ }
793
+ return [...new Set(reasons)];
794
+ }
795
+
796
+ function asObject(value) {
797
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
798
+ }
799
+
800
+ function main() {
801
+ const raw = fs.readFileSync(0, "utf8");
802
+ if (!WORKFLOW_ROLES.has((process.env.VCM_ROLE || "").trim())) {
803
+ return;
804
+ }
805
+
806
+ let payload;
807
+ try {
808
+ payload = raw.trim() ? JSON.parse(raw) : {};
809
+ } catch {
810
+ return;
811
+ }
812
+ payload = asObject(payload);
813
+ let reasons;
814
+ if (payload.tool_name === "Bash") {
815
+ reasons = guardReasons(asObject(payload.tool_input));
816
+ } else if (payload.tool_name === "Write" || payload.tool_name === "Edit") {
817
+ const toolInput = asObject(payload.tool_input);
818
+ const filePath = toolInput.file_path || toolInput.path;
819
+ reasons = typeof filePath === "string" && MANAGED_ARTIFACT_PATH.test(filePath)
820
+ ? ["direct Write/Edit of VCM-managed Markdown is forbidden"]
821
+ : [];
822
+ } else {
823
+ return;
824
+ }
825
+ if (reasons.length === 0) {
826
+ return;
827
+ }
828
+
829
+ process.stdout.write(`${JSON.stringify({
830
+ hookSpecificOutput: {
831
+ hookEventName: "PreToolUse",
832
+ permissionDecision: "deny",
833
+ permissionDecisionReason: `VCM denied this tool call (${reasons.join("; ")}). `
834
+ + (reasons.some((reason) => reason.includes("Markdown")) ? ARTIFACT_HINT : SKILL_HINT)
835
+ }
836
+ })}\n`);
837
+ }
838
+
839
+ main();