From 3cf939c403a82657e0b7419750700664008ef7e2 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:08:16 +0000 Subject: [PATCH 01/10] Improve error handling: propagate corrupt-JSON errors, stop swallowing failures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scheduler/scheduler.py | 40 ++++++--- server.py | 188 ++++++++++++++++++++++++----------------- 2 files changed, 140 insertions(+), 88 deletions(-) diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index 61638d3..b8cba8a 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -24,24 +24,40 @@ def run_skill(skill_name: str): "skill": skill_name, "timestamp": datetime.now(timezone.utc).isoformat(), } - with open(audit_file, "a") as f: - f.write(json.dumps(entry) + "\n") + try: + audit_file.parent.mkdir(parents=True, exist_ok=True) + with open(audit_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError as e: + print(f" [audit] failed to record run of {skill_name!r}: {e}") print(f"[{datetime.now().isoformat()}] Ran skill: {skill_name}") def load_jobs(scheduler: BackgroundScheduler): - """Load job definitions from jobs/ directory.""" + """Load job definitions from jobs/ directory. + + A single malformed job file is logged and skipped rather than being allowed + to abort loading of every other job. + """ for job_file in JOBS_DIR.glob("*.json"): - data = json.loads(job_file.read_text()) + try: + data = json.loads(job_file.read_text()) + except (json.JSONDecodeError, OSError) as e: + print(f" Skipping {job_file.name}: could not read job ({e})") + continue if not data.get("enabled", True): continue - scheduler.add_job( - run_skill, - CronTrigger.from_crontab(data["cron"]), - args=[data["skill"]], - id=data.get("id", data["name"]), - name=data["name"], - replace_existing=True, - ) + try: + scheduler.add_job( + run_skill, + CronTrigger.from_crontab(data["cron"]), + args=[data["skill"]], + id=data.get("id", data["name"]), + name=data["name"], + replace_existing=True, + ) + except (KeyError, ValueError) as e: + print(f" Skipping {job_file.name}: invalid job definition ({e})") + continue print(f" Scheduled: {data['name']} ({data['cron']})") def main(): diff --git a/server.py b/server.py index 41bfe56..4a139c2 100644 --- a/server.py +++ b/server.py @@ -115,6 +115,24 @@ def write_file(path: Path, content: str): path.write_text(content, encoding="utf-8") return True +_MISSING = object() + +def load_json_file(path: Path, default=_MISSING): + """Read and parse a JSON file. + + Raises a descriptive HTTPException instead of leaking an opaque 500 when the + file is missing or corrupt, so callers propagate a clear error to the client. + If ``default`` is provided it is returned when the file does not exist. + """ + if not path.exists(): + if default is not _MISSING: + return default + raise HTTPException(404, f"{path.name} not found") + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + raise HTTPException(500, f"Failed to read {path.name}: {e}") + def list_dir(path: Path): if not path.exists(): return [] @@ -127,29 +145,37 @@ def append_audit(entry: dict): audit_file = BASE_DIR / "audit" / "audit.log" entry["timestamp"] = get_timestamp() entry["id"] = str(uuid.uuid4())[:8] - with open(audit_file, "a") as f: - f.write(json.dumps(entry) + "\n") + try: + audit_file.parent.mkdir(parents=True, exist_ok=True) + with open(audit_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + except OSError as e: + # Auditing is best-effort: never let a logging failure abort the + # underlying operation, but surface it on the server console. + print(f"[audit] failed to write entry {entry.get('action')!r}: {e}") # ─── Agent Discovery (instant filesystem checks) ──────────────────── def check_agent(name: str) -> dict: """Instant filesystem-based check. No subprocess needed.""" - try: - if name == "opencode": - exists = shutil.which("opencode") is not None - status = "online" if exists else "offline" - elif name == "hermes": - exists = shutil.which("hermes") is not None - status = "online" if exists else "offline" - elif name == "gemini": - # Gemini has valid OAuth tokens logged in - oauth = Path.home() / ".gemini" / "oauth_creds.json" - exists = shutil.which("gemini") is not None - logged_in = oauth.exists() and "ya29" in oauth.read_text() - status = "online" if exists and logged_in else "offline" if not exists else "warning" - else: - status = "offline" - except Exception: + if name == "opencode": + status = "online" if shutil.which("opencode") is not None else "offline" + elif name == "hermes": + status = "online" if shutil.which("hermes") is not None else "offline" + elif name == "gemini": + exists = shutil.which("gemini") is not None + # Gemini needs a valid OAuth token on disk to be usable. + logged_in = False + oauth = Path.home() / ".gemini" / "oauth_creds.json" + if oauth.exists(): + try: + logged_in = "ya29" in oauth.read_text() + except OSError as e: + # Distinguish an unreadable credential file from "not logged in" + # instead of silently reporting the agent as offline. + print(f"[agent-health] could not read gemini credentials: {e}") + status = "online" if exists and logged_in else "offline" if not exists else "warning" + else: status = "offline" return {"name": name, "status": status} @@ -201,14 +227,8 @@ def list_skills(): if d.is_dir() and not d.name.startswith("_"): skill_md = read_file(d / "SKILL.md") learnings = read_file(d / "learnings.md") - eval_data = {} - eval_path = d / "eval.json" - if eval_path.exists(): - eval_data = json.loads(eval_path.read_text()) - score_history = [] - score_path = d / "score-history.json" - if score_path.exists(): - score_history = json.loads(score_path.read_text()) + eval_data = load_json_file(d / "eval.json", default={}) + score_history = load_json_file(d / "score-history.json", default=[]) skills.append({ "name": d.name, "description": skill_md[:200] if skill_md else "", @@ -227,8 +247,8 @@ def get_skill(name: str): "name": name, "skill": read_file(path / "SKILL.md"), "learnings": read_file(path / "learnings.md"), - "eval": json.loads((path / "eval.json").read_text()) if (path / "eval.json").exists() else {}, - "score_history": json.loads((path / "score-history.json").read_text()) if (path / "score-history.json").exists() else [], + "eval": load_json_file(path / "eval.json", default={}), + "score_history": load_json_file(path / "score-history.json", default=[]), "context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [], } @@ -318,9 +338,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): path = BASE_DIR / "skills" / name / "score-history.json" - if not path.exists(): - return {"scores": []} - return {"scores": json.loads(path.read_text())} + return {"scores": load_json_file(path, default=[])} # ─── Routes: Scheduler ──────────────────────────────────────────── @@ -329,7 +347,7 @@ def list_jobs(): jobs_dir = BASE_DIR / "scheduler" / "jobs" jobs = [] for f in sorted(jobs_dir.glob("*.json")): - jobs.append(json.loads(f.read_text())) + jobs.append(load_json_file(f)) return jobs @app.post("/api/scheduler/jobs") @@ -356,7 +374,7 @@ def create_job(job: ScheduleJobRequest): def delete_job(job_id: str): jobs_dir = BASE_DIR / "scheduler" / "jobs" for f in jobs_dir.glob("*.json"): - data = json.loads(f.read_text()) + data = load_json_file(f) if data.get("id") == job_id: f.unlink() append_audit({"action": "job_deleted", "job_id": job_id}) @@ -371,7 +389,15 @@ def get_audit(limit: int = Query(100, le=500)): if not audit_file.exists(): return {"entries": []} lines = audit_file.read_text().strip().split("\n") - entries = [json.loads(l) for l in lines if l.strip()] + entries = [] + for l in lines: + if not l.strip(): + continue + try: + entries.append(json.loads(l)) + except json.JSONDecodeError: + # Skip a corrupt line rather than failing the whole audit view. + continue return {"entries": entries[-limit:]} # ─── Routes: Cost Analytics ─────────────────────────────────────── @@ -379,15 +405,18 @@ def get_audit(limit: int = Query(100, le=500)): @app.get("/api/cost") def get_cost(): cost_file = BASE_DIR / "data" / "cost-history.json" - if not cost_file.exists(): - return {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []} - return json.loads(cost_file.read_text()) + return load_json_file( + cost_file, + default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}, + ) @app.post("/api/cost/record") def record_cost(data: dict): cost_file = BASE_DIR / "data" / "cost-history.json" - cost_data = json.loads(cost_file.read_text()) if cost_file.exists() else \ - {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []} + cost_data = load_json_file( + cost_file, + default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []}, + ) cost_data["entries"].append({ "timestamp": get_timestamp(), "agent": data.get("agent", "unknown"), @@ -403,9 +432,7 @@ def record_cost(data: dict): @app.get("/api/plugins") def list_plugins(): reg_file = BASE_DIR / "registry" / "plugins.json" - if not reg_file.exists(): - return {"plugins": []} - return json.loads(reg_file.read_text()) + return load_json_file(reg_file, default={"plugins": []}) @app.post("/api/plugins/install") def install_plugin(data: dict): @@ -413,7 +440,7 @@ def install_plugin(data: dict): if not name: raise HTTPException(400, "Plugin name required") reg_file = BASE_DIR / "registry" / "plugins.json" - reg = json.loads(reg_file.read_text()) if reg_file.exists() else {"plugins": []} + reg = load_json_file(reg_file, default={"plugins": []}) if any(p["name"] == name for p in reg["plugins"]): return {"status": "already_installed"} reg["plugins"].append({ @@ -478,15 +505,13 @@ def list_prompts(): @app.get("/api/settings") def get_settings(): sf = BASE_DIR / "data" / "settings.json" - if not sf.exists(): - return {} - return json.loads(sf.read_text()) + return load_json_file(sf, default={}) @app.put("/api/settings") def update_settings(data: SettingsUpdate): sf = BASE_DIR / "data" / "settings.json" # Merge with existing - existing = json.loads(sf.read_text()) if sf.exists() else {} + existing = load_json_file(sf, default={}) existing.update(data.settings) sf.write_text(json.dumps(existing, indent=2)) append_audit({"action": "settings_updated"}) @@ -520,9 +545,7 @@ def discover_standards(): CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json" def load_chat_history(): - if CHAT_HISTORY_FILE.exists(): - return json.loads(CHAT_HISTORY_FILE.read_text()) - return {"messages": []} + return load_json_file(CHAT_HISTORY_FILE, default={"messages": []}) def save_chat_message(msg: dict): history = load_chat_history() @@ -771,7 +794,7 @@ def load_kanban_tasks(): ensure_dir(KANBAN_DIR) tasks = [] for f in sorted(KANBAN_DIR.glob("*.json")): - tasks.append(json.loads(f.read_text())) + tasks.append(load_json_file(f)) return tasks KANBAN_ID_RE = re.compile(r"^[0-9a-f]{6,16}$") @@ -809,38 +832,51 @@ def dispatch_kanban_task(task_id: str): threading.Thread(target=_run_kanban_agent, args=(task_id,), daemon=True).start() def _run_kanban_agent(task_id: str): + # Runs in a daemon thread: any unhandled exception would be lost and leave + # the task stuck in "in_progress" forever, so catch failures and surface + # them by marking the task blocked with the error. path = kanban_task_path(task_id) if not path.exists(): return - task = json.loads(path.read_text()) - agent = task.get("assignee") - prompt = task["title"] if not task.get("body") else f"{task['title']}\n\n{task['body']}" + try: + task = json.loads(path.read_text()) + agent = task.get("assignee") + prompt = task["title"] if not task.get("body") else f"{task['title']}\n\n{task['body']}" - response = execute_agent(agent, prompt) - failed = response.startswith(("⏱", "⚠", "Unknown agent")) + response = execute_agent(agent, prompt) + failed = response.startswith(("⏱", "⚠", "Unknown agent")) - task = json.loads(path.read_text()) # reload in case it changed while the agent ran - task.setdefault("comments", []).append({ - "id": str(uuid.uuid4())[:8], - "message": f"🤖 **{agent}**\n\n{response}", - "timestamp": get_timestamp(), - }) - if failed: - task["status"] = "blocked" - task["block_reason"] = response[:300] - append_audit({"action": "kanban_task_dispatch_failed", "task_id": task_id, "agent": agent}) - else: - task["status"] = "done" - task["summary"] = response[:300] - task["completed_at"] = get_timestamp() - append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent}) - task["updated"] = get_timestamp() - save_kanban_task(task) + task = json.loads(path.read_text()) # reload in case it changed while the agent ran + task.setdefault("comments", []).append({ + "id": str(uuid.uuid4())[:8], + "message": f"🤖 **{agent}**\n\n{response}", + "timestamp": get_timestamp(), + }) + if failed: + task["status"] = "blocked" + task["block_reason"] = response[:300] + append_audit({"action": "kanban_task_dispatch_failed", "task_id": task_id, "agent": agent}) + else: + task["status"] = "done" + task["summary"] = response[:300] + task["completed_at"] = get_timestamp() + append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent}) + task["updated"] = get_timestamp() + save_kanban_task(task) + except Exception as e: + print(f"[kanban] dispatch for task {task_id} crashed: {e}") + try: + task = json.loads(path.read_text()) + task["status"] = "blocked" + task["block_reason"] = f"Dispatch crashed: {e}"[:300] + task["updated"] = get_timestamp() + save_kanban_task(task) + append_audit({"action": "kanban_task_dispatch_error", "task_id": task_id, "error": str(e)[:200]}) + except Exception as inner: + print(f"[kanban] could not mark task {task_id} as blocked: {inner}") def load_goals(): - if GOALS_FILE.exists(): - return json.loads(GOALS_FILE.read_text()) - return [] + return load_json_file(GOALS_FILE, default=[]) def save_goals(goals: list): GOALS_FILE.write_text(json.dumps(goals, indent=2)) From 5a7bc1639d88d736ec71e3a41bd803b48dd65f63 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:11:13 +0000 Subject: [PATCH 02/10] Validate skill name to prevent path traversal (CodeQL) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 4a139c2..fe57005 100644 --- a/server.py +++ b/server.py @@ -133,6 +133,18 @@ def load_json_file(path: Path, default=_MISSING): except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: raise HTTPException(500, f"Failed to read {path.name}: {e}") +SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +def skill_dir(name: str) -> Path: + """Resolve a user-supplied skill name to its directory, rejecting path traversal.""" + if not SKILL_NAME_RE.fullmatch(name or ""): + raise HTTPException(404, "Skill not found") + base = (BASE_DIR / "skills").resolve() + candidate = (base / name).resolve() + if candidate.parent != base: + raise HTTPException(404, "Skill not found") + return candidate + def list_dir(path: Path): if not path.exists(): return [] @@ -240,7 +252,7 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): - path = BASE_DIR / "skills" / name + path = skill_dir(name) if not path.exists(): raise HTTPException(404, "Skill not found") return { @@ -254,7 +266,7 @@ def get_skill(name: str): @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): - path = BASE_DIR / "skills" / name + path = skill_dir(name) if not path.exists(): raise HTTPException(404, "Skill not found") @@ -337,7 +349,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): - path = BASE_DIR / "skills" / name / "score-history.json" + path = skill_dir(name) / "score-history.json" return {"scores": load_json_file(path, default=[])} # ─── Routes: Scheduler ──────────────────────────────────────────── From b7c7c254b0075ceab1d461b4bc6d7fed2eaea621 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:13:23 +0000 Subject: [PATCH 03/10] Tighten skill name allowlist to exclude '.' (path traversal) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.py b/server.py index fe57005..a76e4f5 100644 --- a/server.py +++ b/server.py @@ -133,7 +133,7 @@ def load_json_file(path: Path, default=_MISSING): except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: raise HTTPException(500, f"Failed to read {path.name}: {e}") -SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") def skill_dir(name: str) -> Path: """Resolve a user-supplied skill name to its directory, rejecting path traversal.""" From ba0e3d8e0bbfd864686cd19c02c755fde2d3c7e2 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:16:47 +0000 Subject: [PATCH 04/10] Resolve skill name via directory match to break path-injection taint (CodeQL) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/server.py b/server.py index a76e4f5..b05f7d0 100644 --- a/server.py +++ b/server.py @@ -133,17 +133,19 @@ def load_json_file(path: Path, default=_MISSING): except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: raise HTTPException(500, f"Failed to read {path.name}: {e}") -SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") - def skill_dir(name: str) -> Path: - """Resolve a user-supplied skill name to its directory, rejecting path traversal.""" - if not SKILL_NAME_RE.fullmatch(name or ""): - raise HTTPException(404, "Skill not found") - base = (BASE_DIR / "skills").resolve() - candidate = (base / name).resolve() - if candidate.parent != base: - raise HTTPException(404, "Skill not found") - return candidate + """Resolve a user-supplied skill name to its directory. + + The name is matched against the actual directory entries rather than used to + build a path, so traversal input (``..``, ``/``) can never escape the skills + directory. + """ + base = BASE_DIR / "skills" + if base.exists(): + for entry in base.iterdir(): + if entry.is_dir() and entry.name == name: + return entry + raise HTTPException(404, "Skill not found") def list_dir(path: Path): if not path.exists(): From 031884ab6cb7d36940ff8072a8eaa566c8346499 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:27:58 +0000 Subject: [PATCH 05/10] Make aggregate listings tolerate a corrupt file (best_effort), keep single GETs strict Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index b05f7d0..08c2427 100644 --- a/server.py +++ b/server.py @@ -117,12 +117,16 @@ def write_file(path: Path, content: str): _MISSING = object() -def load_json_file(path: Path, default=_MISSING): +def load_json_file(path: Path, default=_MISSING, best_effort=False): """Read and parse a JSON file. Raises a descriptive HTTPException instead of leaking an opaque 500 when the file is missing or corrupt, so callers propagate a clear error to the client. If ``default`` is provided it is returned when the file does not exist. + + Set ``best_effort=True`` (with a ``default``) for aggregate/listing callers + that should tolerate one corrupt file rather than aborting the whole view: + the corruption is logged and ``default`` is returned instead of raising. """ if not path.exists(): if default is not _MISSING: @@ -131,6 +135,9 @@ def load_json_file(path: Path, default=_MISSING): try: return json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e: + if best_effort and default is not _MISSING: + print(f"[load] skipping corrupt {path.name}: {e}") + return default raise HTTPException(500, f"Failed to read {path.name}: {e}") def skill_dir(name: str) -> Path: @@ -241,8 +248,8 @@ def list_skills(): if d.is_dir() and not d.name.startswith("_"): skill_md = read_file(d / "SKILL.md") learnings = read_file(d / "learnings.md") - eval_data = load_json_file(d / "eval.json", default={}) - score_history = load_json_file(d / "score-history.json", default=[]) + eval_data = load_json_file(d / "eval.json", default={}, best_effort=True) + score_history = load_json_file(d / "score-history.json", default=[], best_effort=True) skills.append({ "name": d.name, "description": skill_md[:200] if skill_md else "", @@ -361,7 +368,9 @@ def list_jobs(): jobs_dir = BASE_DIR / "scheduler" / "jobs" jobs = [] for f in sorted(jobs_dir.glob("*.json")): - jobs.append(load_json_file(f)) + job = load_json_file(f, default=None, best_effort=True) + if job is not None: + jobs.append(job) return jobs @app.post("/api/scheduler/jobs") @@ -388,8 +397,8 @@ def create_job(job: ScheduleJobRequest): def delete_job(job_id: str): jobs_dir = BASE_DIR / "scheduler" / "jobs" for f in jobs_dir.glob("*.json"): - data = load_json_file(f) - if data.get("id") == job_id: + data = load_json_file(f, default=None, best_effort=True) + if data and data.get("id") == job_id: f.unlink() append_audit({"action": "job_deleted", "job_id": job_id}) return {"status": "deleted"} @@ -808,7 +817,9 @@ def load_kanban_tasks(): ensure_dir(KANBAN_DIR) tasks = [] for f in sorted(KANBAN_DIR.glob("*.json")): - tasks.append(load_json_file(f)) + task = load_json_file(f, default=None, best_effort=True) + if task is not None: + tasks.append(task) return tasks KANBAN_ID_RE = re.compile(r"^[0-9a-f]{6,16}$") From 467419fdf4501df139d9f72419e55e07fbe1c755 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:59:40 +0000 Subject: [PATCH 06/10] Move kanban_task_path inside try block in daemon thread --- server.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index 08c2427..6765122 100644 --- a/server.py +++ b/server.py @@ -262,8 +262,6 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): path = skill_dir(name) - if not path.exists(): - raise HTTPException(404, "Skill not found") return { "name": name, "skill": read_file(path / "SKILL.md"), @@ -276,8 +274,6 @@ def get_skill(name: str): @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): path = skill_dir(name) - if not path.exists(): - raise HTTPException(404, "Skill not found") agent_choice = req.agent if req else "auto" skill_input = req.input if req else "" @@ -860,10 +856,10 @@ def _run_kanban_agent(task_id: str): # Runs in a daemon thread: any unhandled exception would be lost and leave # the task stuck in "in_progress" forever, so catch failures and surface # them by marking the task blocked with the error. - path = kanban_task_path(task_id) - if not path.exists(): - return try: + path = kanban_task_path(task_id) + if not path.exists(): + return task = json.loads(path.read_text()) agent = task.get("assignee") prompt = task["title"] if not task.get("body") else f"{task['title']}\n\n{task['body']}" From 7b8c81ea4954afcaa0bbec4a3c38bb7ed6b218cc Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Thu, 9 Jul 2026 00:13:39 +0000 Subject: [PATCH 07/10] Resolve existing skills via iterdir match to break path-injection taint (CodeQL) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index a2e30cc..60f87bf 100644 --- a/server.py +++ b/server.py @@ -280,6 +280,21 @@ def skill_dir_path(name: str) -> Path: raise HTTPException(400, "Invalid skill name") return candidate +def resolve_skill_dir(name: str) -> Path: + """Return the directory of an existing skill by matching ``name`` against the + actual directory entries. + + Using the entry from ``iterdir()`` (rather than a path built from ``name``) + means traversal input can never escape the skills directory. Raises 404 if no + skill matches. + """ + base = BASE_DIR / "skills" + if base.exists(): + for entry in base.iterdir(): + if entry.is_dir() and entry.name == name: + return entry + raise HTTPException(404, "Skill not found") + def skill_context_file_path(name: str, filename: str) -> Path: if not SKILL_CONTEXT_FILENAME_RE.fullmatch(filename or ""): raise HTTPException(400, "Invalid file name") @@ -309,9 +324,7 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): - path = skill_dir_path(name) - if not path.exists(): - raise HTTPException(404, "Skill not found") + path = resolve_skill_dir(name) return { "name": name, "skill": read_file(path / "SKILL.md"), @@ -369,9 +382,7 @@ def delete_skill_context_file(name: str, filename: str): @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): - path = skill_dir_path(name) - if not path.exists(): - raise HTTPException(404, "Skill not found") + path = resolve_skill_dir(name) agent_choice = req.agent if req else "auto" skill_input = req.input if req else "" @@ -452,7 +463,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): - path = skill_dir_path(name) / "score-history.json" + path = resolve_skill_dir(name) / "score-history.json" return {"scores": load_json_file(path, default=[])} # ─── Routes: Scheduler ──────────────────────────────────────────── From 30d7366a31dba7e28b9107130568c546063e8082 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Thu, 9 Jul 2026 01:18:05 +0000 Subject: [PATCH 08/10] Recompute path in kanban dispatch error handler to avoid unbound local Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server.py b/server.py index bfd6f12..c99503a 100644 --- a/server.py +++ b/server.py @@ -1081,6 +1081,7 @@ def _run_kanban_agent(task_id: str): except Exception as e: print(f"[kanban] dispatch for task {task_id} crashed: {e}") try: + path = kanban_task_path(task_id) task = json.loads(path.read_text()) task["status"] = "blocked" task["block_reason"] = f"Dispatch crashed: {e}"[:300] From b93c6195515c429c0b70300afab4a7974283cfc9 Mon Sep 17 00:00:00 2001 From: zumayaaustin-creator Date: Thu, 9 Jul 2026 01:24:32 +0000 Subject: [PATCH 09/10] Fix unbound variable in _run_kanban_agent error handler --- server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index c99503a..7a2c9a9 100644 --- a/server.py +++ b/server.py @@ -1074,8 +1074,9 @@ def _run_kanban_agent(task_id: str): else: task["status"] = "done" task["summary"] = response[:300] - task["completed_at"] = get_timestamp() - append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent}) + try: + path = kanban_task_path(task_id) + task = json.loads(path.read_text()) task["updated"] = get_timestamp() save_kanban_task(task) except Exception as e: From 4604b433e865ec9b12d26b61517cf41c7e480e06 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Thu, 9 Jul 2026 01:25:29 +0000 Subject: [PATCH 10/10] Restore kanban dispatch success branch mangled by misapplied review suggestion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 7a2c9a9..c99503a 100644 --- a/server.py +++ b/server.py @@ -1074,9 +1074,8 @@ def _run_kanban_agent(task_id: str): else: task["status"] = "done" task["summary"] = response[:300] - try: - path = kanban_task_path(task_id) - task = json.loads(path.read_text()) + task["completed_at"] = get_timestamp() + append_audit({"action": "kanban_task_dispatch_completed", "task_id": task_id, "agent": agent}) task["updated"] = get_timestamp() save_kanban_task(task) except Exception as e: