feat: updade workspace

This commit is contained in:
thanhnv
2026-07-11 15:56:31 +09:00
parent 4fc72332f5
commit 193a449829
120 changed files with 868 additions and 350 deletions
@@ -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)