Fix 10 audit bugs: path prefix matching, verdict parsing, CORS, stale-task detection, phase mapping
CI / build (push) Has been cancelled

Batch 1 (High severity):
- Bug 1: --audit cat3 now checks .automaton/tasks/ paths
- Bug 4: Verdict PASS/FAIL uses structured ## Status: line parsing
- Bug 5: register-guards.sh checks .json/.jsonc, writes plugin key, strips comments
- Bug 7: --can-edit/--scope-check path prefix uses os.sep boundary

Batch 2 (Medium/Low severity):
- Bug 2: migrate-project.sh find command parentheses for -prune binding
- Bug 3: vram_detect model prefix matching with known-suffix whitelist
- Bug 6: dashboard reads .state file before artifact heuristic fallback
- Bug 8: removed wildcard CORS, added security headers (nosniff, DENY)
- Bug 9: stale-task detection uses .state.lastedit instead of .state mtime
- Bug 10: TEST_PLAN.md maps to test_design (was implement)

249 tests pass (up from 235). All 10 tasks driven through full workflow to completion.
This commit is contained in:
Lap Tran
2026-06-22 10:40:58 -04:00
parent f32f98575b
commit 81ccf548e5
106 changed files with 1643 additions and 436 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ while IFS= read -r -d '' project_file; do
MOVED+=("$rel_path -> extensions/$(basename "$rel_path")")
echo " MOVED $rel_path → extensions/$(basename "$rel_path") (customized)"
fi
done < <(find "$PROJECT_AUTOMATON" -maxdepth 1 -type f -name "*.md" -o -name "*.sh" -print0 2>/dev/null || true)
done < <(find "$PROJECT_AUTOMATON" -maxdepth 1 -type f \( -name "*.md" -o -name "*.sh" \) -print0 2>/dev/null || true)
# Task directory — tasks always live in .automaton/tasks/ for all modes
ROOT_TASKS="$PROJECT_DIR/tasks"
+20 -8
View File
@@ -5,7 +5,7 @@
# Called by install.sh and update.sh during framework setup.
#
# Detection:
# - OpenCode: checks for ~/.config/opencode/opencode.jsonc
# - OpenCode: checks for ~/.config/opencode/opencode.json or .jsonc
# - Pi Dev: checks for `pi` in PATH
#
# Usage: bash ~/.automaton/scripts/register-guards.sh
@@ -20,18 +20,30 @@ echo ""
echo "=== Pre-Edit Guard Registration ==="
# OpenCode guard
OPENCODE_CONFIG="$HOME/.config/opencode/opencode.jsonc"
# Check for both opencode.json (default) and opencode.jsonc
OPENCODE_CONFIG=""
for candidate in "$HOME/.config/opencode/opencode.json" "$HOME/.config/opencode/opencode.jsonc"; do
if [ -f "$candidate" ]; then
OPENCODE_CONFIG="$candidate"
break
fi
done
OPENCODE_SOURCE="$FRAMEWORK_DIR/plugins/automaton-guard"
if [ -f "$OPENCODE_CONFIG" ]; then
if [ -n "$OPENCODE_CONFIG" ]; then
if grep -q "automaton-guard" "$OPENCODE_CONFIG" 2>/dev/null; then
echo "OpenCode: already registered"
else
echo "OpenCode: registering guard plugin..."
# Use 'plugin' key (singular) per opencode config schema.
# Strip // comments (JSONC) before parsing for robustness.
python3 -c "
import json
import json, re
with open('$OPENCODE_CONFIG') as f:
cfg = json.load(f)
cfg.setdefault('plugins', []).append('$OPENCODE_SOURCE')
text = f.read()
# Strip single-line // comments (JSONC) outside of strings
text = re.sub(r'//.*?\$', '', text)
cfg = json.loads(text)
cfg.setdefault('plugin', []).append('$OPENCODE_SOURCE')
with open('$OPENCODE_CONFIG', 'w') as f:
json.dump(cfg, f, indent=2)
"
@@ -39,7 +51,7 @@ with open('$OPENCODE_CONFIG', 'w') as f:
echo "OpenCode: registered (restart opencode to activate)"
fi
else
echo "OpenCode: not detected (no $OPENCODE_CONFIG)"
echo "OpenCode: not detected (no ~/.config/opencode/opencode.json or .jsonc)"
fi
# Pi Dev guard
@@ -64,7 +76,7 @@ if $INSTALLED_OPENCODE || $INSTALLED_PI; then
fi
if ! $INSTALLED_OPENCODE && ! $INSTALLED_PI; then
echo "No harness detected. To install a guard manually:"
echo " OpenCode: add '\"plugins\": [\"$OPENCODE_SOURCE\"]' to $OPENCODE_CONFIG"
echo " OpenCode: add '\"plugin\": [\"$OPENCODE_SOURCE\"]' to ~/.config/opencode/opencode.json"
echo " Pi Dev: pi install $PI_SOURCE"
echo ""
echo "Without a pre-edit guard, git hooks (pre-commit + pre-push)"
+95 -20
View File
@@ -149,7 +149,7 @@ FORBIDDEN_ARTIFACTS = {
}
NON_ARTIFACT_FILES = {".state", ".state.tmp", ".state.lock", ".state.approvals",
".state.implementer", "VRAM_CONFIG.md", "PARENT_SPEC.md", "REVIEW.md"}
".state.implementer", ".state.lastedit", "VRAM_CONFIG.md", "PARENT_SPEC.md", "REVIEW.md"}
PHASE_PRIORITY = {
"referee": 12, "doc_review": 11, "adversarial_bug_find": 10,
@@ -298,6 +298,26 @@ def _append_approval(task_path: Path, phase: str, approver: str) -> None:
f.write(line)
def _parse_verdict_status_line(content: str) -> Optional[str]:
"""Parse verdict status from structured header lines only.
Looks for ``## Status: PASS/FAIL/NEEDS_REVIEW`` or ``- **Status**: PASS/FAIL/NEEDS_REVIEW``
header lines. Returns None if no structured header is found.
Deliberately does NOT do substring search across the full file, because
body text may mention status keywords without reflecting the actual verdict.
"""
for line in content.splitlines():
stripped = line.strip()
low = stripped.lower()
if low.startswith("## status") or low.startswith("- **status**"):
after_colon = stripped.split(":", 1)[-1].strip() if ":" in stripped else ""
for label in ("PASS", "FAIL", "NEEDS_REVIEW"):
if after_colon.upper() == label or label in after_colon.upper():
return label
return None
def _infer_state_from_artifacts(task_path: Path) -> Optional[str]:
artifacts = {}
for name in ["SPEC.md", "DECOMPOSITION.md", "DESIGN.md", "TEST_PLAN.md",
@@ -308,8 +328,12 @@ def _infer_state_from_artifacts(task_path: Path) -> Optional[str]:
artifacts[name] = True
if "VERDICT.md" in artifacts:
content = (task_path / "VERDICT.md").read_text()
if "PASS" in content:
verdict_status = _parse_verdict_status_line(content)
if verdict_status == "PASS":
return "complete"
if verdict_status in ("FAIL", "NEEDS_REVIEW"):
return "human_intervention"
# Verdict exists but status header unparseable — fall back to human review
return "human_intervention"
if "DOC_REVIEW.md" in artifacts:
return "referee"
@@ -322,7 +346,7 @@ def _infer_state_from_artifacts(task_path: Path) -> Optional[str]:
if "IMPLEMENTATION.md" in artifacts:
return "code_review"
if "TEST_PLAN.md" in artifacts:
return "implement"
return "test_design"
if "DESIGN.md" in artifacts:
return "test_design"
if "DECOMPOSITION.md" in artifacts and "SPEC.md" in artifacts:
@@ -693,7 +717,18 @@ def _audit_category3(project_dir, tasks):
for changed_file in all_changed:
parts = Path(changed_file).parts
is_in_task_folder = len(parts) >= 2 and parts[0] == "tasks" and parts[1] in task_names
# Framework mode: paths like "tasks/mytask/..."
is_in_task_folder = (
len(parts) >= 2 and parts[0] == "tasks" and parts[1] in task_names
)
# Regular project mode: paths like ".automaton/tasks/mytask/..."
if not is_in_task_folder:
is_in_task_folder = (
len(parts) >= 3
and parts[0] == ".automaton"
and parts[1] == "tasks"
and parts[2] in task_names
)
if not is_in_task_folder:
unauthorized.add(changed_file)
@@ -922,6 +957,36 @@ def cmd_upgrade(args):
return 0
def _get_edit_timestamp(task_path: Path) -> float:
"""Return the mtime to use for stale-task detection.
Uses ``.state.lastedit`` if it exists (updated by --can-edit on ALLOWED).
Falls back to ``.state`` mtime for backward compatibility.
"""
lastedit = task_path / ".state.lastedit"
if lastedit.exists():
try:
return lastedit.stat().st_mtime
except OSError:
pass
state_file = task_path / ".state"
if state_file.exists():
try:
return state_file.stat().st_mtime
except OSError:
pass
return 0
def _touch_lastedit(task_path: Path) -> None:
"""Update .state.lastedit timestamp to mark edit activity."""
lastedit = task_path / ".state.lastedit"
try:
lastedit.touch()
except OSError:
pass
def cmd_can_edit(args):
project_dir = _find_project_dir(args.project)
@@ -934,9 +999,8 @@ def cmd_can_edit(args):
continue
base = _base_phase(phase)
if base in ("implement", "doc_review"):
state_file = path / ".state"
state_mtime = state_file.stat().st_mtime if state_file.exists() else 0
edit_tasks.append((name, base, path, state_mtime))
edit_mtime = _get_edit_timestamp(path)
edit_tasks.append((name, base, path, edit_mtime))
if not edit_tasks:
print("DENIED: No tasks in implement or doc_review phase. Create a task and transition it to implement before editing files.")
if args.json_output:
@@ -948,8 +1012,8 @@ def cmd_can_edit(args):
scope_tasks = []
out_of_scope = []
for name, base, path, state_mtime in edit_tasks:
if str(file_path).startswith(proj_str):
scope_tasks.append({"task": name, "phase": base, "state_mtime": state_mtime})
if str(file_path).startswith(proj_str + os.sep) or str(file_path) == proj_str:
scope_tasks.append({"task": name, "phase": base, "state_mtime": state_mtime, "path": path})
else:
out_of_scope.append({"task": name, "phase": base, "file": str(file_path)})
if not scope_tasks:
@@ -971,6 +1035,7 @@ def cmd_can_edit(args):
print(f"ALLOWED: Task '{primary['task']}' is in {primary['phase']} phase and file '{file_path}' is within project '{project_dir}'.")
if args.json_output:
print(json.dumps({"allowed": True, "reason": "edit_task_in_scope", "primary_task": {"task": primary["task"], "phase": primary["phase"]}, "all_edit_tasks": [{"task": t["task"], "phase": t["phase"]} for t in scope_tasks]}))
_touch_lastedit(primary["path"])
return 0
import time as _time
now = _time.time()
@@ -986,6 +1051,7 @@ def cmd_can_edit(args):
print(f"ALLOWED: Task '{primary[0]}' is in {primary[1]} phase — code edits are permitted.")
if args.json_output:
print(json.dumps({"allowed": True, "reason": "edit_task", "primary_task": {"task": primary[0], "phase": primary[1]}, "all_edit_tasks": [{"task": n, "phase": b} for n, b, _, _ in edit_tasks]}))
_touch_lastedit(primary[2])
return 0
task_path = _task_dir(args.task, args.project)
@@ -1001,21 +1067,31 @@ def cmd_can_edit(args):
proj_str = str(project_dir.resolve())
auto_str = str(AUTOMATON_DIR)
if project_dir == AUTOMATON_DIR:
if not str(file_path).startswith(auto_str):
if not (str(file_path).startswith(auto_str + os.sep) or str(file_path) == auto_str):
print(f"OUT_OF_SCOPE: File '{file_path}' is outside the framework directory")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "out_of_scope", "task": args.task, "phase": base}))
return 1
else:
if not str(file_path).startswith(proj_str):
if not (str(file_path).startswith(proj_str + os.sep) or str(file_path) == proj_str):
print(f"OUT_OF_SCOPE: File '{file_path}' is outside project '{project_dir}'. Only framework project can modify framework files.")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "out_of_scope", "task": args.task, "phase": base, "file": str(file_path)}))
return 1
if base in ("implement", "doc_review"):
import time as _time
edit_mtime = _get_edit_timestamp(task_path)
now = _time.time()
age_minutes = (now - edit_mtime) / 60
if age_minutes > 30:
print(f"DENIED: Task '{args.task}' has been in {base} phase for {age_minutes:.0f} minutes (stale). Create a new task for new work.")
if args.json_output:
print(json.dumps({"allowed": False, "reason": "stale_task", "stale_task": args.task, "stale_minutes": round(age_minutes)}))
return 1
print(f"ALLOWED: Task '{args.task}' is in {base} phase — code edits are permitted.")
if args.json_output:
print(json.dumps({"allowed": True, "reason": "edit_phase", "task": args.task, "phase": base}))
_touch_lastedit(task_path)
return 0
print(f"DENIED: Task '{args.task}' is in {base} phase. Code edits require implement or doc_review phase.")
if args.json_output:
@@ -1024,7 +1100,7 @@ def cmd_can_edit(args):
def cmd_touch(args):
"""Update .state mtime to reset stale-task timer without changing phase."""
"""Update .state.lastedit to reset stale-task timer without changing phase."""
task_path = _task_dir(args.task, args.project)
if not task_path.exists():
print(f"ERROR: Task '{args.task}' not found")
@@ -1033,8 +1109,7 @@ def cmd_touch(args):
if not state_file.exists():
print(f"ERROR: Task '{args.task}' has no .state file. Run --upgrade first.")
return 1
import os
os.utime(str(state_file), None)
_touch_lastedit(task_path)
phase = _read_state(task_path)
print(f"Touched task '{args.task}' (phase: {phase}) — activity clock reset.")
return 0
@@ -1044,12 +1119,12 @@ def cmd_scope_check(args):
project_dir = _find_project_dir(args.project)
file_path = Path(args.file).resolve()
proj_str = str(project_dir.resolve())
if str(file_path).startswith(proj_str):
if str(file_path).startswith(proj_str + os.sep) or str(file_path) == proj_str:
print(f"IN_SCOPE: File '{file_path}' is within project '{project_dir}'")
return 0
if project_dir != AUTOMATON_DIR:
auto_str = str(AUTOMATON_DIR)
if str(file_path).startswith(auto_str):
if str(file_path).startswith(auto_str + os.sep) or str(file_path) == auto_str:
print(f"OUT_OF_SCOPE: File '{file_path}' is in the framework directory, but current project is '{project_dir}'. Only framework project can modify framework files.")
return 1
print(f"OUT_OF_SCOPE: File '{file_path}' is outside project '{project_dir}'")
@@ -1066,13 +1141,13 @@ def cmd_same_session(args):
print(f"DIFFERENT_SESSION: Task '{args.task}' has no .state file")
return 0
import time
mtime = state_file.stat().st_mtime
mtime = _get_edit_timestamp(task_path)
age_minutes = (time.time() - mtime) / 60
threshold = 30
if age_minutes < threshold:
print(f"SAME_SESSION: Task '{args.task}' .state was modified {age_minutes:.0f} minutes ago (threshold: {threshold} min)")
print(f"SAME_SESSION: Task '{args.task}' last edit activity {age_minutes:.0f} minutes ago (threshold: {threshold} min)")
return 1
print(f"DIFFERENT_SESSION: Task '{args.task}' .state was modified {age_minutes:.0f} minutes ago (threshold: {threshold} min)")
print(f"DIFFERENT_SESSION: Task '{args.task}' last edit activity {age_minutes:.0f} minutes ago (threshold: {threshold} min)")
return 0
@@ -1292,7 +1367,7 @@ def main():
parser.add_argument("--file", help="File path for scope check or can-edit file scope check")
parser.add_argument("--same-session", action="store_true", help="Check if task was created in current session")
parser.add_argument("--list-states", action="store_true", help="List all valid phase names")
parser.add_argument("--touch", action="store_true", help="Update .state mtime to reset stale-task timer without changing phase")
parser.add_argument("--touch", action="store_true", help="Update .state.lastedit to reset stale-task timer without changing phase")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output machine-readable JSON on last line (for harness integration)")
args = parser.parse_args()
+28 -4
View File
@@ -389,14 +389,38 @@ def detect_model_context(
return 0
_KNOWN_MODEL_SUFFIXES = {"instruct", "chat", "it", "fp16", "f16", "bf16"}
def _lookup_model_context(model_name: str) -> int:
"""Look up context window for a known model name."""
# Strip common version/date suffixes for lookup.
for key in MODEL_CONTEXT_WINDOWS:
if model_name.lower().startswith(key.lower()):
"""Look up context window for a known model name.
Tries exact match first, then:
- ``key + ":"`` prefix (Ollama parameter tag, e.g. ``deepseek-r1:7b``)
- ``key + "-"`` prefix only if the next segment is a known instruction-tuning
suffix (e.g. ``llama-3.1-8b-instruct`` matches ``llama-3.1-8b``)
This prevents false matches like ``phi-4`` matching ``phi-4-mini-instruct``
or ``gpt-4o`` matching ``gpt-4o-foo-unknown``.
Longer keys are tried first so the most specific match wins.
"""
name_lower = model_name.lower()
for key in sorted(MODEL_CONTEXT_WINDOWS, key=len, reverse=True):
key_lower = key.lower()
if name_lower == key_lower:
print(f"Model: {model_name}")
print(f"Context window: {MODEL_CONTEXT_WINDOWS[key] // 1000}k tokens")
return MODEL_CONTEXT_WINDOWS[key]
if name_lower.startswith(key_lower + ":"):
print(f"Model: {model_name}")
print(f"Context window: {MODEL_CONTEXT_WINDOWS[key] // 1000}k tokens")
return MODEL_CONTEXT_WINDOWS[key]
if name_lower.startswith(key_lower + "-"):
next_segment = name_lower[len(key_lower) + 1:].split("-")[0]
if next_segment in _KNOWN_MODEL_SUFFIXES:
print(f"Model: {model_name}")
print(f"Context window: {MODEL_CONTEXT_WINDOWS[key] // 1000}k tokens")
return MODEL_CONTEXT_WINDOWS[key]
print(f"Model: {model_name} (unknown context window)")
return 0