feat: updade workspace
This commit is contained in:
@@ -33,6 +33,11 @@ BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
||||
MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh")
|
||||
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")
|
||||
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"}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
@@ -111,6 +116,167 @@ def stage(path: str, stage_id: str, status: str, detail: str, provider="", model
|
||||
atomic_json(path, job)
|
||||
|
||||
|
||||
def registered_project(project_id: str) -> dict:
|
||||
registry = load_json(PROJECT_REGISTRY)
|
||||
entry = next((item for item in registry.get("projects", [])
|
||||
if item.get("project_id") == project_id and item.get("status") == "active"), None)
|
||||
if not entry:
|
||||
raise ValueError("goal_project_not_allowed")
|
||||
raw_roots = entry.get("context_roots") or [entry.get("domain_root")]
|
||||
root_real = os.path.realpath(ROOT)
|
||||
resolved = []
|
||||
for relative in raw_roots:
|
||||
if not isinstance(relative, str) or not relative:
|
||||
raise ValueError("goal_context_root_invalid")
|
||||
absolute = os.path.realpath(os.path.join(ROOT, relative))
|
||||
if absolute != root_real and not absolute.startswith(root_real + os.sep):
|
||||
raise ValueError("goal_context_root_denied")
|
||||
if not os.path.exists(absolute):
|
||||
raise ValueError("goal_context_root_missing")
|
||||
resolved.append((relative, absolute))
|
||||
return {"project_id": project_id, "domain": entry.get("domain", project_id), "domain_root": entry.get("domain_root", ""), "roots": resolved}
|
||||
|
||||
|
||||
def redact_context(text: str) -> str:
|
||||
patterns = [
|
||||
(r"(?im)^[^\n]*(?:api[_-]?key|secret|password|token)[^:=\n]*[:=]\s*[^\s\n]+", "[REDACTED SECRET]"),
|
||||
(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{12,}", "Bearer [REDACTED]"),
|
||||
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
|
||||
]
|
||||
redacted = text
|
||||
for pattern, replacement in patterns:
|
||||
redacted = re.sub(pattern, replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def context_excerpt_is_sensitive(text: str) -> bool:
|
||||
"""Exclude source excerpts that would turn the shared snapshot into unsafe input."""
|
||||
normalized = " ".join(text.lower().split())
|
||||
blocked_phrases = (
|
||||
"ignore previous instruction",
|
||||
"ignore prior instruction",
|
||||
"drop table",
|
||||
"shutdown system",
|
||||
"export secrets",
|
||||
"dump database",
|
||||
# The security gate normalizes identifiers, so Prisma's `onDelete`
|
||||
# otherwise looks like an imperative delete request.
|
||||
"ondelete",
|
||||
)
|
||||
if any(phrase in normalized for phrase in blocked_phrases):
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"(?i)(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|jwt[_-]?secret|secret)"
|
||||
r"\s*[:=]\s*(?!\[REDACTED\])\S+",
|
||||
text,
|
||||
))
|
||||
|
||||
|
||||
def context_candidates(project: dict, goal: str):
|
||||
terms = {term.lower() for term in re.findall(r"[A-Za-z0-9_-]{3,}", goal)}
|
||||
candidates = []
|
||||
seen = set()
|
||||
for relative_root, absolute_root in project["roots"]:
|
||||
if os.path.isfile(absolute_root):
|
||||
paths = [absolute_root]
|
||||
else:
|
||||
paths = []
|
||||
for base, directories, files in os.walk(absolute_root):
|
||||
directories[:] = [item for item in directories if item not in CONTEXT_IGNORED and not item.startswith('.')]
|
||||
paths.extend(os.path.join(base, name) for name in files)
|
||||
for path in paths:
|
||||
relative = os.path.relpath(path, ROOT)
|
||||
if relative in seen or os.path.basename(path).lower() in SENSITIVE_NAMES:
|
||||
continue
|
||||
seen.add(relative)
|
||||
extension = os.path.splitext(path)[1].lower()
|
||||
if extension not in CONTEXT_EXTENSIONS or os.path.getsize(path) > 256_000:
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
raw = handle.read(16_000)
|
||||
except OSError:
|
||||
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)
|
||||
if relative.endswith(("README.md", "architecture.md", "technical_architecture.md", "package.json")):
|
||||
score += 3
|
||||
candidates.append((score, relative, raw))
|
||||
return sorted(candidates, key=lambda item: (-item[0], item[1]))
|
||||
|
||||
|
||||
def build_context(job_path: str, project_id: str, goal: str):
|
||||
project = registered_project(project_id)
|
||||
candidates = context_candidates(project, goal)
|
||||
excerpts, manifest_files, characters = [], [], 0
|
||||
max_files, max_characters, per_file = 16, 7_000, 1_200
|
||||
for score, relative, raw in candidates:
|
||||
if len(excerpts) >= max_files or characters >= max_characters:
|
||||
break
|
||||
excerpt = redact_context(raw[:min(per_file, max_characters - characters)]).strip()
|
||||
if not excerpt or context_excerpt_is_sensitive(excerpt):
|
||||
continue
|
||||
excerpts.append(f"### FILE: {relative}\n{excerpt}")
|
||||
characters += len(excerpt)
|
||||
manifest_files.append({"path": relative, "sha256": sha(raw), "characters": len(excerpt), "relevance": score})
|
||||
bundle = "\n\n".join(excerpts)
|
||||
allowed, safe_bundle = scan(bundle, "input")
|
||||
if not allowed:
|
||||
raise ValueError("goal_context_security_blocked")
|
||||
bundle = safe_bundle
|
||||
base = os.path.splitext(job_path)[0]
|
||||
bundle_path, manifest_path = base + ".context.txt", base + ".context.json"
|
||||
with open(bundle_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(bundle + "\n")
|
||||
os.chmod(bundle_path, 0o600)
|
||||
manifest = {
|
||||
"project_id": project_id,
|
||||
"domain": project["domain"],
|
||||
"domain_root": project["domain_root"],
|
||||
"generated_at": now(),
|
||||
"files": manifest_files,
|
||||
"file_count": len(manifest_files),
|
||||
"characters": characters,
|
||||
"truncated": len(manifest_files) < len(candidates),
|
||||
"bundle_sha256": sha(bundle),
|
||||
}
|
||||
atomic_json(manifest_path, manifest)
|
||||
return bundle, manifest, os.path.relpath(manifest_path, ROOT)
|
||||
|
||||
|
||||
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|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)
|
||||
|
||||
|
||||
def submit_side_effect(job: dict, manifest: dict) -> dict:
|
||||
payload = {
|
||||
"goal_id": job["id"],
|
||||
"project_id": job["project"],
|
||||
"context_manifest_hash": manifest["bundle_sha256"],
|
||||
"requested_operation": job["goal"],
|
||||
}
|
||||
result = subprocess.run([
|
||||
"python3", APPROVAL_INBOX, "submit",
|
||||
"--project", job["project"],
|
||||
"--action", "goal.workspace.execute",
|
||||
"--target", job["project"],
|
||||
"--risk", "high",
|
||||
"--sensitive",
|
||||
"--proposer", job["actor"],
|
||||
"--reason", "Goal requests a workspace side effect; execution remains disabled until approval",
|
||||
"--payload", json.dumps(payload, ensure_ascii=False),
|
||||
], cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(), timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout or "goal_approval_submit_failed").strip())
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def scan(text: str, mode: str):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = os.path.join(directory, "input.txt")
|
||||
@@ -282,22 +448,54 @@ def run(job_path: str) -> int:
|
||||
emit(goal_id, "H1-context", "running", "Validating objective contract")
|
||||
if len(goal) < 10 or len(goal) > 8000:
|
||||
raise ValueError("goal_length_invalid")
|
||||
emit(goal_id, "H1-context", "pass", "Objective accepted", {"goal_hash": sha(goal), "characters": len(goal)})
|
||||
project_id = str(job.get("project") or "")
|
||||
|
||||
emit(goal_id, "H4-security", "running", "Scanning objective before model routing")
|
||||
allowed, safe_goal = scan(goal, "input")
|
||||
if not allowed:
|
||||
emit(goal_id, "H4-security", "blocked", "Objective rejected by security boundary")
|
||||
raise ValueError("goal_security_blocked")
|
||||
emit(goal_id, "H4-security", "running", "Objective passed; model outputs pending")
|
||||
context_bundle, context_manifest, context_manifest_path = build_context(job_path, project_id, safe_goal)
|
||||
context_summary = {
|
||||
"files": context_manifest["file_count"],
|
||||
"characters": context_manifest["characters"],
|
||||
"truncated": context_manifest["truncated"],
|
||||
"path": context_manifest_path,
|
||||
}
|
||||
update_job(job_path, context_manifest=context_summary)
|
||||
emit(goal_id, "H1-context", "pass", "Allowlisted workspace snapshot prepared", {
|
||||
"goal_hash": sha(goal), "project_id": project_id, **context_summary,
|
||||
"bundle_sha256": context_manifest["bundle_sha256"],
|
||||
})
|
||||
emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending")
|
||||
|
||||
if requests_side_effect(safe_goal):
|
||||
proposal = submit_side_effect(job, context_manifest)
|
||||
approval = {"id": proposal["id"], "status": proposal["status"], "action": proposal["action"]}
|
||||
stage(job_path, "local-worker", "blocked", "Workspace execution requires approval; no model or tool was allowed to write", job.get("local_provider", ""), local_model)
|
||||
stage(job_path, "cloud-reviewer", "blocked", "Reviewer is not an execution channel", job.get("cloud_provider", ""), cloud_model)
|
||||
update_job(job_path, status="requires_approval", approval=approval, result="This objective requests a workspace side effect. CASAN created a governed approval proposal and did not execute or modify files.", finished_at=now())
|
||||
emit(goal_id, "H2-tool", "blocked", "Side effect withheld pending approval", {"proposal_id": proposal["id"], "action": proposal["action"]})
|
||||
emit(goal_id, "H3-eval", "blocked", "Cloud reviewer cannot bypass the approval boundary")
|
||||
emit(goal_id, "H4-security", "pass", "Workspace remained read-only")
|
||||
gated_job = load_json(job_path)
|
||||
audit_hash = audit(gated_job, "requires_approval")
|
||||
emit(goal_id, "H5-governance", "pass", "Approval proposal and decision anchored", {"audit_hash": audit_hash, "proposal_id": proposal["id"]})
|
||||
metric(gated_job, "degraded", started, {}, {})
|
||||
emit(goal_id, "H6-agentops", "pass", "Approval routing telemetry recorded")
|
||||
emit(goal_id, "H7-orchestration", "blocked", "Awaiting governed approval", {"proposal_id": proposal["id"]})
|
||||
update_job(job_path, audit_hash=audit_hash)
|
||||
return 0
|
||||
|
||||
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})
|
||||
local_prompt = (
|
||||
"You are the local CASAN worker. Solve the user's objective concretely. "
|
||||
"Produce: clarified outcome, assumptions, ordered implementation plan, risks, "
|
||||
"and verifiable acceptance checks. Respond in the same language as the objective.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}"
|
||||
"and verifiable acceptance checks. Respond in the same language as the objective. "
|
||||
"Use only the bounded, redacted workspace snapshot below as repository evidence. "
|
||||
"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 not ok:
|
||||
@@ -318,8 +516,11 @@ def run(job_path: str) -> int:
|
||||
"You are the cloud CASAN reviewer. Critically review the local worker's proposal "
|
||||
"against the objective. Correct gaps, remove unsafe or unverifiable claims, and "
|
||||
"return one final actionable solution with ordered steps and acceptance checks. "
|
||||
"Respond in the same language as the objective.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}\n\nLOCAL WORKER PROPOSAL:\n{safe_local[:16000]}"
|
||||
"Respond in the same language as the objective. Use only the exact bounded, redacted "
|
||||
"workspace snapshot provided below; your execution directory is intentionally empty. "
|
||||
"Do not inspect or infer from any other filesystem and do not perform side effects.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}\n\n"
|
||||
f"LOCAL WORKER PROPOSAL:\n{safe_local[:5000]}"
|
||||
)
|
||||
if account_provider:
|
||||
cloud_ok, cloud_result, cloud_meta, cloud_reason = call_account_model(account_provider, review_prompt)
|
||||
|
||||
Reference in New Issue
Block a user