Fix 10 audit bugs: path prefix matching, verdict parsing, CORS, stale-task detection, phase mapping
CI / build (push) Has been cancelled
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user