vibe-coding-master 0.7.50 → 0.8.1
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/dist/backend/adapters/filesystem.js +6 -0
- package/dist/backend/api/task-routes.js +20 -1
- package/dist/backend/server.js +4 -2
- package/dist/backend/services/auto-memory-service.js +111 -34
- package/dist/backend/services/claude-hook-service.js +5 -3
- package/dist/backend/services/gate-review-service.js +21 -2
- package/dist/backend/services/harness-feedback-service.js +53 -15
- package/dist/backend/services/message-service.js +10 -0
- package/dist/backend/services/runtime-recovery-service.js +9 -0
- package/dist/backend/services/translation-service.js +74 -3
- package/dist/backend/services/workflow-control-service.js +436 -75
- package/dist/backend/templates/handoff.js +14 -2
- package/dist/backend/templates/harness/architect-agent.js +6 -5
- package/dist/backend/templates/harness/architect-evidence-worker-agent.js +2 -0
- package/dist/backend/templates/harness/architect-validation-worker-agent.js +1 -1
- package/dist/backend/templates/harness/claude-root.js +3 -3
- package/dist/backend/templates/harness/coder-agent.js +1 -0
- package/dist/backend/templates/harness/harness-engineer-agent.js +14 -11
- package/dist/backend/templates/harness/memory-block.js +10 -0
- package/dist/backend/templates/harness/project-manager-agent.js +5 -1
- package/dist/backend/templates/harness/tester-agent.js +6 -0
- package/dist/backend/templates/harness/vcm-workflow-review-skill.js +17 -1
- package/dist/shared/validation/artifact-check.js +36 -3
- package/dist/shared/validation/artifact-contract.js +7 -0
- package/dist/shared/validation/artifact-registry.js +4 -1
- package/dist-frontend/assets/{index-DLsIPTvK.js → index-Dh7uVCmk.js} +1 -1
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
- package/scripts/harness-tools/vcm-bash-guard +245 -21
package/dist-frontend/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>VibeCodingMaster</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Dh7uVCmk.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DDmygnV6.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""VCM PreToolUse guard for supervised tools inside VCM role sessions.
|
|
2
|
+
"""VCM PreToolUse guard for supervised tools inside VCM workflow-role sessions.
|
|
3
3
|
|
|
4
4
|
Reads the Claude Code PreToolUse hook payload on stdin. It enforces supervised
|
|
5
|
-
Bash execution for
|
|
5
|
+
Bash execution and managed-artifact writes for VCM workflow roles. Tool roles
|
|
6
|
+
are outside the workflow and bypass this guard.
|
|
6
7
|
|
|
7
8
|
Quoted payloads of `sh -c` / `bash -lc` style invocations are executable
|
|
8
9
|
shell code, so they are scanned recursively. `.ai/tools/run-long-check` is the
|
|
9
10
|
only sanctioned detached worker; the command it runs must still stay in the
|
|
10
11
|
supervised foreground process group.
|
|
11
12
|
"""
|
|
13
|
+
import ast
|
|
12
14
|
import json
|
|
15
|
+
import os
|
|
13
16
|
import re
|
|
14
17
|
import shlex
|
|
15
18
|
import sys
|
|
16
19
|
|
|
20
|
+
WORKFLOW_ROLES = {"project-manager", "architect", "coder", "tester", "reviewer"}
|
|
21
|
+
|
|
17
22
|
SKILL_HINT = (
|
|
18
23
|
"Use the vcm-long-running-validation skill instead: "
|
|
19
24
|
"`.ai/tools/run-long-check --timeout <duration> -- <command>` then "
|
|
@@ -39,8 +44,9 @@ HEREDOC_START = re.compile(
|
|
|
39
44
|
EXECUTABLE_HEREDOC_RECEIVER = re.compile(
|
|
40
45
|
r"(?:^|[;&|()])\s*"
|
|
41
46
|
r"(?:(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+\s+)*)"
|
|
42
|
-
r"(?:[^\s;&|()]*/)?(
|
|
47
|
+
r"(?:[^\s;&|()]*/)?(?P<receiver>sh|bash|zsh|dash|ksh|python\d*(?:\.\d+)?|node)(?=\s|$)"
|
|
43
48
|
)
|
|
49
|
+
SHELL_HEREDOC_RECEIVERS = {"sh", "bash", "zsh", "dash", "ksh"}
|
|
44
50
|
SHELL_CONTROL_OPERATOR = re.compile(r"(?:\|&?|&&|\|\||;|\n|\(|\)|`)")
|
|
45
51
|
RUN_LONG_CHECK_SHELL_STRING = re.compile(
|
|
46
52
|
r"(?:^|[;&|()\n])\s*"
|
|
@@ -106,34 +112,70 @@ def strip_escaped_characters(command: str) -> str:
|
|
|
106
112
|
return "".join(cleaned)
|
|
107
113
|
|
|
108
114
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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]]] = []
|
|
113
133
|
for line in command.splitlines(keepends=True):
|
|
114
134
|
if pending:
|
|
115
|
-
delimiter, strip_tabs,
|
|
135
|
+
delimiter, strip_tabs, language, body = pending[0]
|
|
116
136
|
candidate = line.rstrip("\r\n")
|
|
117
137
|
if strip_tabs:
|
|
118
138
|
candidate = candidate.lstrip("\t")
|
|
119
139
|
if candidate == delimiter:
|
|
120
140
|
pending.pop(0)
|
|
121
|
-
elif
|
|
122
|
-
|
|
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")
|
|
123
150
|
elif line.endswith(("\n", "\r")):
|
|
124
|
-
|
|
151
|
+
shell_executable.append("\n")
|
|
152
|
+
all_executable.append("\n")
|
|
125
153
|
continue
|
|
126
154
|
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
shell_executable.append(line)
|
|
156
|
+
all_executable.append(line)
|
|
157
|
+
language = heredoc_receiver_language(line)
|
|
129
158
|
for match in HEREDOC_START.finditer(line):
|
|
130
159
|
delimiter = match.group(3) or match.group(4)
|
|
131
|
-
|
|
132
|
-
|
|
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]
|
|
133
174
|
|
|
134
175
|
|
|
135
176
|
def shell_segments(command: str) -> list[list[str]]:
|
|
136
|
-
|
|
177
|
+
shell_command, _, _ = parse_heredoc_content(command)
|
|
178
|
+
normalized = shell_command.replace("\\\n", " ").replace("\n", " ; ")
|
|
137
179
|
try:
|
|
138
180
|
lexer = shlex.shlex(normalized, posix=True, punctuation_chars=";&|()<>")
|
|
139
181
|
lexer.whitespace_split = True
|
|
@@ -290,16 +332,190 @@ def protected_tool_invoked(command_without_quotes: str) -> bool:
|
|
|
290
332
|
return bool(PROTECTED_TOOL_INVOCATION.search(command_without_quotes))
|
|
291
333
|
|
|
292
334
|
|
|
293
|
-
def
|
|
335
|
+
def shell_background_reasons(command: str) -> list[str]:
|
|
336
|
+
stripped = strip_escaped_characters(strip_quoted(command))
|
|
294
337
|
reasons = []
|
|
295
|
-
executable_command = strip_heredoc_bodies(command)
|
|
296
|
-
stripped = strip_escaped_characters(strip_quoted(executable_command))
|
|
297
338
|
if re.search(r"(?:^|[\s;&|(])(?:nohup|setsid)(?:\s|$)", stripped):
|
|
298
339
|
reasons.append("nohup/setsid detach is forbidden")
|
|
299
340
|
if re.search(r"(?:^|[\s;&|(])disown(?:\s|$)", stripped):
|
|
300
341
|
reasons.append("disown is forbidden")
|
|
301
342
|
if unquoted_ampersand(stripped):
|
|
302
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))
|
|
303
519
|
if protected_tool_invoked(stripped):
|
|
304
520
|
if SHELL_CONTROL_OPERATOR.search(stripped):
|
|
305
521
|
reasons.append("run-long-check and watch-job must be standalone Bash commands")
|
|
@@ -308,16 +524,21 @@ def scan_shell_command(command: str, depth: int = 0) -> list[str]:
|
|
|
308
524
|
|
|
309
525
|
# `sh -c '...'` quoted payloads are shell code, not plain strings.
|
|
310
526
|
if depth < MAX_NESTED_SHELL_DEPTH and SHELL_DASH_C.search(stripped):
|
|
311
|
-
for segment in quoted_segments(
|
|
527
|
+
for segment in quoted_segments(shell_command):
|
|
312
528
|
if protected_tool_invoked(strip_escaped_characters(strip_quoted(segment))):
|
|
313
529
|
reasons.append("run-long-check and watch-job must not be invoked through a shell command string")
|
|
314
530
|
reasons.extend(scan_shell_command(segment, depth + 1))
|
|
315
531
|
if depth < MAX_NESTED_SHELL_DEPTH:
|
|
316
|
-
for substitution in double_quoted_command_substitutions(
|
|
532
|
+
for substitution in double_quoted_command_substitutions(shell_command):
|
|
317
533
|
nested_stripped = strip_escaped_characters(strip_quoted(substitution))
|
|
318
534
|
if protected_tool_invoked(nested_stripped):
|
|
319
535
|
reasons.append("run-long-check and watch-job must not be invoked through command substitution")
|
|
320
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))
|
|
321
542
|
return reasons
|
|
322
543
|
|
|
323
544
|
|
|
@@ -337,6 +558,9 @@ def guard_reasons(tool_input: dict) -> list[str]:
|
|
|
337
558
|
|
|
338
559
|
def main() -> int:
|
|
339
560
|
raw = sys.stdin.read()
|
|
561
|
+
if os.environ.get("VCM_ROLE", "").strip() not in WORKFLOW_ROLES:
|
|
562
|
+
return 0
|
|
563
|
+
|
|
340
564
|
try:
|
|
341
565
|
payload = json.loads(raw) if raw.strip() else {}
|
|
342
566
|
except ValueError:
|