feat: appove and go
This commit is contained in:
@@ -8,10 +8,12 @@ audit, or metric records.
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
@@ -36,6 +38,11 @@ SECURITY = os.path.join(BIN, "security-check.sh")
|
||||
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json")
|
||||
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
|
||||
_MANIFEST_SPEC = importlib.util.spec_from_file_location(
|
||||
"casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py")
|
||||
)
|
||||
PROJECT_MANIFEST = importlib.util.module_from_spec(_MANIFEST_SPEC)
|
||||
_MANIFEST_SPEC.loader.exec_module(PROJECT_MANIFEST)
|
||||
CONTEXT_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".prisma", ".css", ".html"}
|
||||
CONTEXT_IGNORED = {"node_modules", ".git", "dist", "build", "coverage", ".vite", "tmp", "logs", "__pycache__"}
|
||||
SENSITIVE_NAMES = {".env", ".env.local", "credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"}
|
||||
@@ -173,8 +180,35 @@ def context_excerpt_is_sensitive(text: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def restricted_context_paths(goal: str):
|
||||
"""Return an explicit user-declared file/directory boundary, if present."""
|
||||
section = re.search(
|
||||
r"(?:chỉ được đọc và thay đổi|chỉ làm việc trong|only (?:read and )?(?:modify|change)|only work (?:in|within))\s*:\s*"
|
||||
r"(.*?)(?=\n\s*(?:không sửa bất kỳ file nào khác|do not (?:modify|change) any other file|không thay đổi|không thêm|vấn đề|yêu cầu|verification|$))",
|
||||
goal,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not section:
|
||||
return []
|
||||
paths = []
|
||||
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", section.group(1), re.IGNORECASE):
|
||||
cleaned = match.rstrip(".,:;)")
|
||||
scoped_directory = cleaned.endswith(("/*", "/**"))
|
||||
normalized = cleaned.rstrip("/*")
|
||||
if normalized and all(path != normalized for path, _ in paths):
|
||||
paths.append((normalized, scoped_directory))
|
||||
return paths
|
||||
|
||||
|
||||
def context_candidates(project: dict, goal: str):
|
||||
terms = {term.lower() for term in re.findall(r"[A-Za-z0-9_-]{3,}", goal)}
|
||||
explicit_paths = []
|
||||
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", goal, re.IGNORECASE):
|
||||
cleaned = match.rstrip(".,:;)")
|
||||
scoped_directory = cleaned.endswith(("/*", "/**"))
|
||||
normalized_path = cleaned.rstrip("/*").lower()
|
||||
if normalized_path and all(path != normalized_path for path, _ in explicit_paths):
|
||||
explicit_paths.append((normalized_path, scoped_directory))
|
||||
candidates = []
|
||||
seen = set()
|
||||
for relative_root, absolute_root in project["roots"]:
|
||||
@@ -200,6 +234,22 @@ def context_candidates(project: dict, goal: str):
|
||||
continue
|
||||
haystack = (relative + "\n" + raw[:4000]).lower()
|
||||
score = sum(4 if term in relative.lower() else 1 for term in terms if term in haystack)
|
||||
relative_lower = relative.lower()
|
||||
matching_paths = [
|
||||
(path, scoped_directory)
|
||||
for path, scoped_directory in explicit_paths
|
||||
if relative_lower == path or relative_lower.startswith(path + "/")
|
||||
]
|
||||
if matching_paths:
|
||||
# Directory globs under "only work in" sections describe the
|
||||
# actual patch surface. Exact source-file mentions come next;
|
||||
# architecture/requirement references remain supporting context.
|
||||
if any(scoped_directory for _, scoped_directory in matching_paths):
|
||||
score += 3_000
|
||||
elif os.path.splitext(relative_lower)[1] in {".ts", ".tsx", ".js", ".jsx", ".py", ".prisma", ".sql", ".sh"}:
|
||||
score += 2_000
|
||||
else:
|
||||
score += 1_000
|
||||
if relative.endswith(("README.md", "architecture.md", "technical_architecture.md", "package.json")):
|
||||
score += 3
|
||||
candidates.append((score, relative, raw))
|
||||
@@ -209,6 +259,17 @@ def context_candidates(project: dict, goal: str):
|
||||
def build_context(job_path: str, project_id: str, goal: str, write_intent=False):
|
||||
project = registered_project(project_id)
|
||||
candidates = context_candidates(project, goal)
|
||||
restricted_paths = restricted_context_paths(goal)
|
||||
if restricted_paths:
|
||||
candidates = [
|
||||
candidate for candidate in candidates
|
||||
if any(
|
||||
candidate[1] == path or (scoped_directory and candidate[1].startswith(path.rstrip("/") + "/"))
|
||||
for path, scoped_directory in restricted_paths
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
raise ValueError("goal_context_explicit_scope_empty")
|
||||
excerpts, manifest_files, characters = [], [], 0
|
||||
# A diff can only apply when the model sees the exact target-file content.
|
||||
# Read-only analysis stays compact; write-intent gives the three most
|
||||
@@ -261,12 +322,39 @@ def build_context(job_path: str, project_id: str, goal: str, write_intent=False)
|
||||
|
||||
def requests_side_effect(goal: str) -> bool:
|
||||
normalized = " ".join(goal.lower().split())
|
||||
patterns = [
|
||||
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
|
||||
# The objective's leading command is authoritative. A scoped constraint
|
||||
# such as "Không thay đổi API contract" must not turn "Hoàn thiện ..."
|
||||
# into a read-only request. Conversely, a genuinely read-only audit does
|
||||
# not begin with one of these implementation commands.
|
||||
leading_write_patterns = [
|
||||
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
|
||||
r"^(please\s+)?(implement|fix|change|deploy|run|create|delete|install|commit|push)\b",
|
||||
r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b",
|
||||
]
|
||||
return any(re.search(pattern, normalized) for pattern in patterns)
|
||||
if any(re.search(pattern, normalized) for pattern in leading_write_patterns):
|
||||
return True
|
||||
|
||||
patch_requests = re.finditer(r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b", normalized)
|
||||
explicit_patch_request = any(
|
||||
not normalized[max(0, match.start() - 8):match.start()].endswith(("không ", "do not "))
|
||||
for match in patch_requests
|
||||
)
|
||||
explicit_read_only = bool(re.search(r"\b(không|do not)\s+(sửa|thay đổi|triển khai|implement|fix|tạo|apply|áp dụng)\b", normalized))
|
||||
if explicit_read_only and not explicit_patch_request:
|
||||
return False
|
||||
imperative_patterns = [r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b"]
|
||||
if any(re.search(pattern, normalized) for pattern in imperative_patterns):
|
||||
return True
|
||||
|
||||
# Structured objectives commonly start with a phase label rather than the
|
||||
# imperative itself (for example "PHASE 1 — Hoàn thiện backend..."). Keep
|
||||
# those write requests on the patch + approval path without classifying a
|
||||
# read-only audit that merely mentions source files as a side effect.
|
||||
structured_write_patterns = [
|
||||
r"\b(hoàn thiện|triển khai|implement|fix|sửa|thay đổi)\b.{0,120}\b(ứng dụng|backend|frontend|module|component|api|source|code|file)\b",
|
||||
r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b",
|
||||
r"\b(apply|áp dụng)\b.{0,40}\b(patch|thay đổi|change)\b",
|
||||
]
|
||||
return any(re.search(pattern, normalized) for pattern in structured_write_patterns)
|
||||
|
||||
|
||||
def extract_patch(text: str) -> str:
|
||||
@@ -344,6 +432,17 @@ def normalize_unified_diff(patch: str) -> str:
|
||||
return "\n".join(normalized) + "\n" if changed else patch
|
||||
|
||||
|
||||
def mechanical_patch_error(error: object) -> bool:
|
||||
"""Identify git parser errors that hunk recounting can safely repair."""
|
||||
normalized = str(error).lower()
|
||||
return any(marker in normalized for marker in (
|
||||
"corrupt patch",
|
||||
"patch fragment without header",
|
||||
"malformed patch",
|
||||
"unexpected end of file in patch",
|
||||
))
|
||||
|
||||
|
||||
def validate_write_output(text: str) -> str:
|
||||
"""Fail at the producing harness when a write-intent reply is not a diff.
|
||||
|
||||
@@ -356,7 +455,7 @@ def validate_write_output(text: str) -> str:
|
||||
validate_patch_check(patch)
|
||||
return patch
|
||||
except ValueError as original_error:
|
||||
if "corrupt patch" not in str(original_error):
|
||||
if not mechanical_patch_error(original_error):
|
||||
raise
|
||||
normalized = normalize_unified_diff(patch)
|
||||
if normalized == patch:
|
||||
@@ -387,14 +486,106 @@ def patch_repair_models(primary_model: str):
|
||||
"""Use direct cloud -> gateway -> local order for one bounded H2 recovery."""
|
||||
configured = [item.strip() for item in os.environ.get("CASAN_GOAL_PATCH_REPAIR_MODELS", "").split(",") if item.strip()]
|
||||
unique, seen = [], set()
|
||||
for candidate in configured + [primary_model]:
|
||||
ordered = [primary_model, *configured] if primary_model.startswith("account:") else [*configured, primary_model]
|
||||
for candidate in ordered:
|
||||
if candidate and candidate not in seen:
|
||||
seen.add(candidate)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def model_preflight_enabled() -> bool:
|
||||
configured = os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT")
|
||||
return configured == "1" if configured is not None else os.environ.get("CASAN_PROFILE") == "prod"
|
||||
|
||||
|
||||
def model_preflight(model: str):
|
||||
"""Probe real patch generation before assigning a model to H2/H3.
|
||||
|
||||
Model discovery only proves that an ID is advertised. This bounded probe
|
||||
verifies the generation route and unified-diff capability, and caches the
|
||||
verdict so a broken gateway is not retried for every goal.
|
||||
"""
|
||||
if not model_preflight_enabled():
|
||||
return True, "preflight_disabled"
|
||||
if model.startswith("account:"):
|
||||
endpoint = os.environ.get("CASAN_AUTH_BRIDGE_URL", "")
|
||||
elif model.startswith("openai-compatible:"):
|
||||
endpoint = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "")
|
||||
else:
|
||||
endpoint = os.environ.get("OLLAMA_HOST", "")
|
||||
cache_key = sha(f"{model}|{endpoint}")
|
||||
cache_directory = os.path.join(STATE_ROOT, "cache")
|
||||
cache_path = os.path.join(cache_directory, "goal-model-preflight.json")
|
||||
lock_path = os.path.join(cache_directory, "goal-model-preflight.lock")
|
||||
os.makedirs(cache_directory, mode=0o700, exist_ok=True)
|
||||
ttl = int(os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TTL_SEC", "600"))
|
||||
with open(lock_path, "a", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
cache = {}
|
||||
cached = cache.get(cache_key, {}) if isinstance(cache, dict) else {}
|
||||
age = time.time() - float(cached.get("checked_epoch", 0))
|
||||
if age <= max(30, ttl):
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
return bool(cached.get("healthy")), f"cached:{cached.get('reason', 'unknown')}"
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
prompt = (
|
||||
"Patch capability probe. Return ONLY a unified git diff inside a ```diff fence that changes "
|
||||
"the only line in probe.txt from old to new. No prose. The diff must begin with "
|
||||
"`diff --git a/probe.txt b/probe.txt`."
|
||||
)
|
||||
timeout_setting = (
|
||||
os.environ.get("CASAN_GOAL_LOCAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "90")
|
||||
if model.startswith("ollama:")
|
||||
else os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "30")
|
||||
)
|
||||
timeout = max(5, min(int(timeout_setting), 120))
|
||||
if model.startswith("account:"):
|
||||
ok, output, _, reason = call_account_model(model.split(":", 1)[1], prompt, timeout)
|
||||
else:
|
||||
ok, output, _, reason = call_model(
|
||||
model, prompt, model.startswith(("openai:", "anthropic:", "openai-compatible:")),
|
||||
timeout_seconds=timeout, max_output_tokens=512,
|
||||
)
|
||||
healthy = False
|
||||
if ok:
|
||||
try:
|
||||
patch = extract_patch(output)
|
||||
healthy = (
|
||||
"diff --git a/probe.txt b/probe.txt" in patch
|
||||
and "\n-old\n" in patch
|
||||
and "\n+new\n" in patch
|
||||
)
|
||||
reason = "ok" if healthy else "patch_probe_contract_invalid"
|
||||
except ValueError as error:
|
||||
reason = str(error)
|
||||
record = {
|
||||
"model": model,
|
||||
"provider": provider_for_model(model),
|
||||
"healthy": healthy,
|
||||
"reason": reason[:180],
|
||||
"checked_at": now(),
|
||||
"checked_epoch": time.time(),
|
||||
}
|
||||
with open(lock_path, "a", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
cache = {}
|
||||
cache[cache_key] = record
|
||||
atomic_json(cache_path, cache)
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
return healthy, reason
|
||||
|
||||
|
||||
def provider_for_model(model: str) -> str:
|
||||
if model.startswith("account:"):
|
||||
return f"{model.split(':', 1)[1]}-account"
|
||||
if model.startswith("openai:"):
|
||||
return "openai"
|
||||
if model.startswith("anthropic:"):
|
||||
@@ -420,7 +611,7 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
def repair_prompt_for(previous_output: str, error: str) -> str:
|
||||
hunk_instruction = (
|
||||
"The previous diff is syntactically corrupt. Recompute every `@@ -old,count +new,count @@` header from the exact added/removed/context lines that follow it; do not omit or invent any hunk line. "
|
||||
if "corrupt patch" in error.lower() else ""
|
||||
if mechanical_patch_error(error) else ""
|
||||
)
|
||||
return (
|
||||
f"Your previous response violated the required write-output contract: {error}. " +
|
||||
@@ -434,7 +625,21 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
last_metadata, last_reason = {}, "goal_patch_missing"
|
||||
attempts = []
|
||||
for attempt_number, candidate in enumerate(candidates, start=1):
|
||||
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")), max_output_tokens=patch_output_tokens())
|
||||
healthy, preflight_reason = model_preflight(candidate)
|
||||
if not healthy:
|
||||
last_reason = f"goal_patch_model_preflight_failed:{preflight_reason}"
|
||||
attempts.append({
|
||||
"attempt": attempt_number,
|
||||
"provider": provider_for_model(candidate),
|
||||
"model": candidate,
|
||||
"status": "failed",
|
||||
"reason": last_reason,
|
||||
})
|
||||
continue
|
||||
if candidate.startswith("account:"):
|
||||
ok, output, metadata, reason = call_account_model(candidate.split(":", 1)[1], repair_prompt, 300)
|
||||
else:
|
||||
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")), max_output_tokens=patch_output_tokens())
|
||||
metadata = dict(metadata)
|
||||
metadata["repair_model"] = candidate
|
||||
metadata["repair_attempt"] = attempt_number
|
||||
@@ -464,7 +669,7 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
# incomplete first diff. Give the same stronger direct model one
|
||||
# corrective pass containing its own failed patch and git error
|
||||
# before sending source context to a weaker gateway.
|
||||
if candidate.endswith("-codex") and attempt_number < patch_repair_attempts():
|
||||
if (candidate.endswith("-codex") or candidate == "account:codex") and attempt_number < patch_repair_attempts():
|
||||
candidates.insert(attempt_number, candidate)
|
||||
del candidates[patch_repair_attempts():]
|
||||
repair_prompt = repair_prompt_for(output, last_reason)
|
||||
@@ -493,8 +698,52 @@ def merge_usage(primary: dict, additional: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def validate_patch_semantics(job: dict, artifact: str, changed_files: list[str]) -> list[dict]:
|
||||
"""Build and test an applied patch in an isolated workspace before approval."""
|
||||
artifact = os.path.realpath(artifact)
|
||||
if os.path.commonpath([ROOT, artifact]) != ROOT or not os.path.isfile(artifact):
|
||||
raise ValueError("goal_patch_artifact_path_denied")
|
||||
manifest = PROJECT_MANIFEST.load(ROOT, project_id=str(job.get("project") or ""))
|
||||
commands = PROJECT_MANIFEST.verification_commands(manifest, changed_files)
|
||||
if not commands:
|
||||
raise ValueError("goal_patch_verification_unmapped")
|
||||
results = []
|
||||
with tempfile.TemporaryDirectory(prefix="casan-goal-verify-") as sandbox:
|
||||
paths = ["package.json", "package-lock.json", *manifest["source_roots"]]
|
||||
paths.extend(str(value) for value in job.get("workspace", {}).get("context_roots", []))
|
||||
for relative in dict.fromkeys(paths):
|
||||
source = os.path.realpath(os.path.join(ROOT, relative))
|
||||
if os.path.commonpath([ROOT, source]) != ROOT or not os.path.exists(source):
|
||||
continue
|
||||
destination = os.path.join(sandbox, relative)
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination, dirs_exist_ok=True, symlinks=True, ignore=shutil.ignore_patterns("node_modules", "dist", "coverage"))
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
dependencies = os.path.join(ROOT, "node_modules")
|
||||
if os.path.isdir(dependencies):
|
||||
os.symlink(dependencies, os.path.join(sandbox, "node_modules"), target_is_directory=True)
|
||||
applied = subprocess.run(
|
||||
["git", "apply", "--whitespace=error", artifact], cwd=sandbox,
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if applied.returncode != 0:
|
||||
raise ValueError("goal_patch_sandbox_apply_failed:" + (applied.stderr or applied.stdout).strip()[:300])
|
||||
timeout = max(30, int(os.environ.get("CASAN_GOAL_PATCH_VERIFY_TIMEOUT_SEC", "300")))
|
||||
for command in commands:
|
||||
result = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, timeout=timeout)
|
||||
output = (result.stdout + result.stderr)[-4000:]
|
||||
results.append({"command": " ".join(command), "exit_code": result.returncode, "output": output})
|
||||
if result.returncode != 0:
|
||||
raise ValueError("goal_patch_verification_failed:" + output[-1200:])
|
||||
return results
|
||||
|
||||
|
||||
def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
roots = [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
|
||||
explicit_scope = restricted_context_paths(str(job.get("goal") or ""))
|
||||
roots = [path.strip("/") for path, _ in explicit_scope] or [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
|
||||
directory_roots = {path.strip("/") for path, scoped_directory in explicit_scope if scoped_directory}
|
||||
changed = []
|
||||
header_paths = []
|
||||
for line in patch.splitlines():
|
||||
@@ -514,7 +763,7 @@ def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
path = path[2:]
|
||||
if path.startswith("/") or ".." in path.split("/"):
|
||||
raise ValueError("goal_patch_path_denied")
|
||||
if not any(path == root or path.startswith(root + "/") for root in roots):
|
||||
if not any(path == root or (root in directory_roots and path.startswith(root + "/")) for root in roots):
|
||||
raise ValueError(f"goal_patch_outside_workspace:{path}")
|
||||
changed.append(path)
|
||||
if not changed or len(set(changed)) > 20:
|
||||
@@ -529,7 +778,12 @@ def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
if check.returncode != 0:
|
||||
os.unlink(artifact)
|
||||
raise ValueError("goal_patch_check_failed:" + (check.stderr or check.stdout).strip()[:160])
|
||||
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000]}
|
||||
try:
|
||||
verification = validate_patch_semantics(job, artifact, sorted(set(changed)))
|
||||
except Exception:
|
||||
os.unlink(artifact)
|
||||
raise
|
||||
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000], "preapproval_verification": verification}
|
||||
|
||||
|
||||
def submit_side_effect(job: dict, manifest: dict, patch_artifact: dict) -> dict:
|
||||
@@ -702,8 +956,8 @@ def reviewer_candidates(account_provider: str, cloud_model: str, local_model: st
|
||||
candidates.append({"kind": "model", "provider": "omniroute", "model": value, "value": value})
|
||||
|
||||
local_reviewer = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_MODEL", "").strip() or local_model
|
||||
if local_reviewer:
|
||||
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"), "model": local_reviewer, "value": local_reviewer})
|
||||
if local_reviewer and os.environ.get("CASAN_GOAL_ENABLE_LOCAL_REVIEWER", "1") == "1":
|
||||
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy")), "model": local_reviewer, "value": local_reviewer})
|
||||
|
||||
unique, seen = [], set()
|
||||
for candidate in candidates:
|
||||
@@ -714,15 +968,17 @@ def reviewer_candidates(account_provider: str, cloud_model: str, local_model: st
|
||||
return unique
|
||||
|
||||
|
||||
def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_model: str, local_model: str):
|
||||
def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_model: str, local_model: str, max_output_tokens=None, excluded_models=None):
|
||||
max_attempts = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_MAX_ATTEMPTS", "5")), 10))
|
||||
deadline_seconds = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_DEADLINE_SEC", "360")), 900))
|
||||
deadline = time.monotonic() + deadline_seconds
|
||||
ledger = []
|
||||
update_job(job_path, reviewer_attempts=ledger)
|
||||
last_reason = "reviewer_candidates_unavailable"
|
||||
candidates = reviewer_candidates(account_provider, cloud_model, local_model)
|
||||
if len(candidates) > max_attempts and candidates[-1].get("provider") == os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"):
|
||||
excluded = set(excluded_models or [])
|
||||
candidates = [candidate for candidate in reviewer_candidates(account_provider, cloud_model, local_model) if candidate.get("value") not in excluded]
|
||||
local_reviewer_provider = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"))
|
||||
if len(candidates) > max_attempts and candidates[-1].get("provider") == local_reviewer_provider:
|
||||
candidates = candidates[:max_attempts - 1] + [candidates[-1]] if max_attempts > 1 else [candidates[-1]]
|
||||
else:
|
||||
candidates = candidates[:max_attempts]
|
||||
@@ -734,10 +990,26 @@ def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_
|
||||
attempt_number = len(ledger) + 1
|
||||
stage(job_path, "cloud-reviewer", "running", f"Reviewer attempt {attempt_number}/{max_attempts}", candidate["provider"], candidate["model"])
|
||||
started_at, started_clock = now(), time.monotonic()
|
||||
if candidate["kind"] == "model" and max_output_tokens is not None:
|
||||
healthy, preflight_reason = model_preflight(candidate["value"])
|
||||
if not healthy:
|
||||
reason = f"model_preflight_failed:{preflight_reason}"
|
||||
ledger.append({
|
||||
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
||||
"status": "failed", "reason": reason, "retryable": True,
|
||||
"started_at": started_at, "finished_at": now(),
|
||||
"latency_ms": int((time.monotonic() - started_clock) * 1000),
|
||||
})
|
||||
update_job(job_path, reviewer_attempts=ledger)
|
||||
last_reason = reason
|
||||
continue
|
||||
if candidate["kind"] == "account":
|
||||
ok, result, metadata, reason = call_account_model(candidate["value"], prompt, remaining)
|
||||
else:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"), remaining)
|
||||
if max_output_tokens is None:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining)
|
||||
else:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining, max_output_tokens=max_output_tokens)
|
||||
retryable = False if ok else reviewer_failure_retryable(reason)
|
||||
ledger.append({
|
||||
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
||||
@@ -846,6 +1118,12 @@ def run(job_path: str) -> int:
|
||||
})
|
||||
emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending")
|
||||
|
||||
local_healthy, local_preflight_reason = model_preflight(local_model)
|
||||
if not local_healthy:
|
||||
stage(job_path, "local-worker", "error", f"model_preflight_failed:{local_preflight_reason}", job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "error", "Local worker failed patch-generation preflight", {"provider": job.get("local_provider", ""), "model": local_model, "reason": local_preflight_reason})
|
||||
raise RuntimeError(f"local_worker_preflight_failed:{local_preflight_reason}")
|
||||
|
||||
stage(job_path, "local-worker", "running", "Local model is developing the primary solution", job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "running", "Local worker is developing a solution", {"provider": job.get("local_provider", ""), "model": local_model})
|
||||
output_contract = (
|
||||
@@ -859,7 +1137,15 @@ def run(job_path: str) -> int:
|
||||
"Do not claim to inspect any filesystem outside this snapshot and do not perform side effects.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}"
|
||||
)
|
||||
ok, local_draft, local_meta, reason = call_model(local_model, local_prompt, False)
|
||||
if local_model.startswith("account:"):
|
||||
ok, local_draft, local_meta, reason = call_account_model(local_model.split(":", 1)[1], local_prompt, 300)
|
||||
else:
|
||||
ok, local_draft, local_meta, reason = call_model(
|
||||
local_model,
|
||||
local_prompt,
|
||||
False,
|
||||
max_output_tokens=patch_output_tokens() if write_intent else None,
|
||||
)
|
||||
if not ok:
|
||||
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "error", "Local worker failed", {"reason": reason})
|
||||
@@ -897,14 +1183,18 @@ def run(job_path: str) -> int:
|
||||
stage(job_path, "local-worker", "error", reason, provider_for_model(actual_repair_model), actual_repair_model)
|
||||
emit(goal_id, "H2-tool", "error", "Worker violated patch output contract after repair", {"reason": reason, "repair_provider": provider_for_model(actual_repair_model), "repair_model": actual_repair_model, "repair_attempts": repaired_meta.get("repair_attempts", [])})
|
||||
raise ValueError(reason)
|
||||
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
|
||||
effective_local_model = str(local_meta.get("repair_model") or local_model)
|
||||
effective_local_provider = provider_for_model(effective_local_model)
|
||||
stage(job_path, "local-worker", "pass", "Primary solution prepared", effective_local_provider, effective_local_model)
|
||||
update_job(
|
||||
job_path,
|
||||
local_draft=safe_local,
|
||||
local_usage=local_meta,
|
||||
patch_repair_attempts=local_meta.get("repair_attempts", []),
|
||||
effective_local_provider=effective_local_provider,
|
||||
effective_local_model=effective_local_model,
|
||||
)
|
||||
emit(goal_id, "H2-tool", "pass", "Local solution prepared", {"provider": job.get("local_provider", ""), "model": local_model, **local_meta})
|
||||
emit(goal_id, "H2-tool", "pass", "Primary solution prepared", {"provider": effective_local_provider, "model": effective_local_model, **local_meta})
|
||||
|
||||
stage(job_path, "cloud-reviewer", "running", "Independent reviewer is challenging and improving the local solution", job.get("cloud_provider", ""), cloud_model)
|
||||
emit(goal_id, "H3-eval", "running", "Cloud reviewer is evaluating the local solution", {"provider": job.get("cloud_provider", ""), "model": cloud_model})
|
||||
@@ -923,7 +1213,13 @@ def run(job_path: str) -> int:
|
||||
f"LOCAL WORKER PROPOSAL:\n{safe_local[:5000]}"
|
||||
)
|
||||
cloud_ok, cloud_result, cloud_meta, cloud_reason, reviewer = run_reviewer_chain(
|
||||
job_path, review_prompt, account_provider, cloud_model, local_model
|
||||
job_path,
|
||||
review_prompt,
|
||||
account_provider,
|
||||
cloud_model,
|
||||
local_model,
|
||||
max_output_tokens=patch_output_tokens() if write_intent else None,
|
||||
excluded_models={effective_local_model},
|
||||
)
|
||||
reviewer_provider = str(reviewer.get("provider") or job.get("cloud_provider", ""))
|
||||
reviewer_model = str(reviewer.get("model") or cloud_model)
|
||||
@@ -965,8 +1261,13 @@ def run(job_path: str) -> int:
|
||||
patch_artifact["approval_id"] = proposal["id"]
|
||||
final_status = "requires_approval"
|
||||
metric_status = "degraded"
|
||||
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
|
||||
emit(goal_id, "H7-orchestration", "blocked", "Reviewed patch awaits approval", {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"]})
|
||||
if cloud_ok:
|
||||
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
|
||||
wait_detail = "Independently reviewed patch awaits approval"
|
||||
else:
|
||||
safe_result = "Implementation patch generated and validated. Automated H3 review was unavailable, so Independent Reviewer approval is required before applying it to the workspace."
|
||||
wait_detail = "Validated patch awaits Independent Reviewer approval after H3 degradation"
|
||||
emit(goal_id, "H7-orchestration", "blocked", wait_detail, {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"], "cloud_incorporated": cloud_ok})
|
||||
job = update_job(job_path, status=final_status, result=safe_result, patch_artifact=patch_artifact, approval=approval, cloud_usage=cloud_meta, finished_at=now())
|
||||
else:
|
||||
job = update_job(job_path, status=final_status, result=safe_result, cloud_usage=cloud_meta, finished_at=now())
|
||||
|
||||
@@ -97,14 +97,29 @@ def _manifest_for_files(files: list[str]) -> dict:
|
||||
|
||||
def verification_commands(files: list[str], manifest: dict | None = None) -> list[list[str]]:
|
||||
project = manifest or _manifest_for_files(files)
|
||||
return [["git", "diff", "--check", "--", *files], *PROJECT_MANIFEST.verification_commands(project, files)]
|
||||
# `git apply --check --whitespace=error` already validates the exact patch
|
||||
# before mutation. Runtime images intentionally do not need repository
|
||||
# metadata, so post-apply verification is limited to manifest build/tests.
|
||||
return PROJECT_MANIFEST.verification_commands(project, files)
|
||||
|
||||
|
||||
def ready_for_apply(job: dict) -> bool:
|
||||
if not job.get("patch_artifact"):
|
||||
return False
|
||||
if job.get("status") == "requires_approval":
|
||||
return True
|
||||
return (
|
||||
job.get("status") == "failed"
|
||||
and job.get("error") == "GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK"
|
||||
)
|
||||
|
||||
|
||||
def execute(job_path: str, actor: str) -> dict:
|
||||
job = load(job_path)
|
||||
if job.get("status") != "requires_approval" or not job.get("patch_artifact"):
|
||||
if not ready_for_apply(job):
|
||||
raise RuntimeError("GOAL_APPLY_JOB_NOT_READY")
|
||||
proposal = verify_approval(job)
|
||||
job.setdefault("approval", {})["status"] = "approved"
|
||||
if proposal.get("approver") != actor:
|
||||
raise PermissionError("GOAL_APPLY_APPROVER_IDENTITY_MISMATCH")
|
||||
artifact = job["patch_artifact"]
|
||||
@@ -135,7 +150,8 @@ def execute(job_path: str, actor: str) -> dict:
|
||||
rollback = run(["git", "apply", "--reverse", patch_path], 30)
|
||||
if rollback.returncode != 0:
|
||||
raise RuntimeError("GOAL_APPLY_ROLLBACK_FAILED")
|
||||
job.update(status="failed", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", verification=checks, finished_at=now(), updated_at=now())
|
||||
artifact["status"] = "awaiting_apply_retry"
|
||||
job.update(status="requires_approval", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", patch_artifact=artifact, verification=checks, finished_at=now(), updated_at=now())
|
||||
save(job_path, job)
|
||||
raise
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@ TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
SECURITY_DIR="$CASAN_HARNESS_ROOT/security"
|
||||
|
||||
# Prefer the OS Python over framework/shim installations that may exist in a
|
||||
# developer shell but cannot execute. The runtime image also exposes this path.
|
||||
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
|
||||
if [[ -z "$PYTHON_BIN" ]]; then
|
||||
for candidate in /usr/bin/python3 python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
|
||||
PYTHON_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$PYTHON_BIN" ]]; then
|
||||
echo "SECURITY_RUNTIME_UNAVAILABLE: working Python 3 interpreter not found" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# Shared log taxonomy (error<warn<info<debug<trace via CASAN_LOG_LEVEL). Used to
|
||||
# make semantic skips loud (never silent) — stderr only, stdout contract intact.
|
||||
# shellcheck source=casan-log.sh
|
||||
@@ -59,7 +75,7 @@ new_trace_id() {
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
"$PYTHON_BIN" -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
@@ -85,7 +101,7 @@ load_yaml_values() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
python - "$file" "$key" <<'PY'
|
||||
"$PYTHON_BIN" - "$file" "$key" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
path, key = sys.argv[1], sys.argv[2]
|
||||
@@ -98,6 +114,45 @@ with open(path, encoding="utf-8") as f:
|
||||
PY
|
||||
}
|
||||
|
||||
# Load only rule patterns whose declared action matches the requested action.
|
||||
# The previous generic loader returned every `pattern:` in prompt-filter.yaml,
|
||||
# which accidentally promoted `require_approval`, `alert`, and `log` rules to
|
||||
# hard blocks. That made ordinary source code containing methods such as
|
||||
# `delete()` fail the workspace-context scan as prompt injection.
|
||||
load_yaml_rule_patterns() {
|
||||
local file="$1"
|
||||
local requested_action="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
"$PYTHON_BIN" - "$file" "$requested_action" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path, requested_action = sys.argv[1], sys.argv[2]
|
||||
pattern = None
|
||||
action = None
|
||||
|
||||
def flush():
|
||||
if pattern is not None and action == requested_action:
|
||||
print(pattern)
|
||||
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if re.match(r"^\s*-\s+id:\s*", line):
|
||||
flush()
|
||||
pattern = None
|
||||
action = None
|
||||
continue
|
||||
pattern_match = re.match(r'^\s*pattern:\s*"(.*)"\s*$', line)
|
||||
if pattern_match:
|
||||
pattern = pattern_match.group(1)
|
||||
continue
|
||||
action_match = re.match(r"^\s*action:\s*([A-Za-z_]+)\s*$", line)
|
||||
if action_match:
|
||||
action = action_match.group(1)
|
||||
flush()
|
||||
PY
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
CONTENT="$(cat "$INPUT_FILE")"
|
||||
@@ -126,7 +181,7 @@ BLOCK_PATTERNS=(
|
||||
|
||||
while IFS= read -r pattern; do
|
||||
[[ -n "$pattern" ]] && BLOCK_PATTERNS+=("$pattern")
|
||||
done < <(load_yaml_values "$SECURITY_DIR/prompt-filter.yaml" "pattern")
|
||||
done < <(load_yaml_rule_patterns "$SECURITY_DIR/prompt-filter.yaml" "block")
|
||||
|
||||
APPROVAL_PATTERNS=(
|
||||
"delete[[:space:]].*"
|
||||
@@ -169,8 +224,8 @@ NORM_CONTENT="$(normalize_for_match "$CONTENT")"
|
||||
# fullwidth/zero-width/Cyrillic-lookalike obfuscation cannot split or disguise
|
||||
# a blocked phrase (V3). Falls back to the raw content if python is missing.
|
||||
UNI_CONTENT="$CONTENT"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
UNI_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
|
||||
if [[ -n "$PYTHON_BIN" ]]; then
|
||||
UNI_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
|
||||
[[ -n "$UNI_CONTENT" ]] || UNI_CONTENT="$CONTENT"
|
||||
fi
|
||||
UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
|
||||
@@ -180,8 +235,8 @@ UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
|
||||
# Only mostly-printable decodes survive, so random base64-looking words never
|
||||
# create a false positive.
|
||||
DECODED_CONTENT=""
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
DECODED_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
|
||||
if [[ -n "$PYTHON_BIN" ]]; then
|
||||
DECODED_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# Matches a pattern against the raw (case-insensitive), leetspeak-folded,
|
||||
@@ -279,7 +334,7 @@ if [[ "$MODE" == "input" ]]; then
|
||||
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
|
||||
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
|
||||
if [[ -f "$SEM_JSON" ]]; then
|
||||
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
SEM_VERDICT="$("$PYTHON_BIN" -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
fi
|
||||
fi
|
||||
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
|
||||
@@ -302,8 +357,8 @@ fi
|
||||
SAFE_CONTENT="$CONTENT"
|
||||
# Policy-driven PII masking (source of truth: pii-rules.yaml). Built-in sed
|
||||
# masking below remains as defense-in-depth if the policy file is unavailable.
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && command -v python >/dev/null 2>&1; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | python "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && [[ -n "$PYTHON_BIN" ]]; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
fi
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$EMAIL_REGEX/***MASKED_EMAIL***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PHONE_REGEX/***MASKED_PHONE***/g")"
|
||||
@@ -333,7 +388,7 @@ fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)"
|
||||
OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | "$PYTHON_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
|
||||
Reference in New Issue
Block a user