Skip to content

Commit 759478c

Browse files
committed
release: merge develop into main for v0.20.0
2 parents f557f66 + 3c5eab4 commit 759478c

12 files changed

Lines changed: 327 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.20.0] - 2026-04-13
9+
10+
### Added
11+
12+
- **Workspace folder permissions** — roles can now restrict access to specific workspace folders (finance, marketing, personal, etc.). Three modes: All, Selected (checkbox grid), None. Admin always bypasses. Enforced on all workspace browser endpoints: tree, read, write, create, rename, delete, upload, download, recent, and file share creation
13+
- **Role editor UI for folder access** — Settings → Roles now has a "Pastas do Workspace" section with radio buttons for mode and a dynamic checkbox grid that scans existing folders from disk
14+
- **Dynamic folder scan endpoint**`GET /api/roles/workspace-folders` lists all top-level directories under `workspace/` without hardcoding
15+
- **SendMessage tool card** — chat UI now renders `SendMessage` tool calls with subagent avatar and description, same as Agent tool cards
16+
17+
### Fixed
18+
19+
- **SQLite auto-migration** — added `ALTER TABLE roles ADD COLUMN workspace_folders_json` to `app.py` startup migration, preventing crash on existing databases
20+
- **Chat textarea height** — input area resets to single line after sending (carried over from v0.19.1)
21+
822
## [0.19.1] - 2026-04-13
923

1024
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.19.1",
3+
"version": "0.20.0",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@
5858
if "agent_access_json" not in _existing_cols:
5959
_cur.execute("ALTER TABLE roles ADD COLUMN agent_access_json TEXT DEFAULT '{\"mode\": \"all\"}'")
6060
_conn.commit()
61+
if "workspace_folders_json" not in _existing_cols:
62+
_cur.execute("ALTER TABLE roles ADD COLUMN workspace_folders_json TEXT DEFAULT '{\"mode\": \"all\"}'")
63+
_conn.commit()
6164

6265
# Fix corrupted datetime columns (NULL or non-string values crash SQLAlchemy)
6366
for _tbl, _col in [("roles", "created_at"), ("users", "created_at"), ("users", "last_login")]:

dashboard/backend/models.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,12 @@ def to_dict(self):
142142
"description": "Full access to all resources",
143143
"permissions": {r: actions[:] for r, actions in ALL_RESOURCES.items()},
144144
"agent_access": {"mode": "all"},
145+
"workspace_folders": {"mode": "all"},
145146
},
146147
"operator": {
147148
"description": "Can view and execute, but not manage users or audit",
148149
"agent_access": {"mode": "all"},
150+
"workspace_folders": {"mode": "all"},
149151
"permissions": {
150152
"chat": ["view", "execute"],
151153
"services": ["view", "execute"],
@@ -168,6 +170,7 @@ def to_dict(self):
168170
"viewer": {
169171
"description": "Read-only access to dashboards",
170172
"agent_access": {"mode": "none"},
173+
"workspace_folders": {"mode": "all"},
171174
"permissions": {
172175
"workspace": ["view"],
173176
"agents": ["view"],
@@ -395,6 +398,7 @@ class Role(db.Model):
395398
description = db.Column(db.String(200))
396399
permissions_json = db.Column(db.Text, nullable=False, default="{}")
397400
agent_access_json = db.Column(db.Text, nullable=True, default='{"mode": "all"}')
401+
workspace_folders_json = db.Column(db.Text, nullable=True, default='{"mode": "all"}')
398402
is_builtin = db.Column(db.Boolean, default=False)
399403
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
400404

@@ -421,13 +425,26 @@ def agent_access(self) -> dict:
421425
def agent_access(self, value: dict):
422426
self.agent_access_json = json.dumps(value)
423427

428+
@property
429+
def workspace_folders(self) -> dict:
430+
try:
431+
result = json.loads(self.workspace_folders_json) if self.workspace_folders_json else None
432+
return result if result else {"mode": "all"}
433+
except (json.JSONDecodeError, TypeError):
434+
return {"mode": "all"}
435+
436+
@workspace_folders.setter
437+
def workspace_folders(self, value: dict):
438+
self.workspace_folders_json = json.dumps(value)
439+
424440
def to_dict(self):
425441
return {
426442
"id": self.id,
427443
"name": self.name,
428444
"description": self.description,
429445
"permissions": self.permissions,
430446
"agent_access": self.agent_access,
447+
"workspace_folders": self.workspace_folders,
431448
"is_builtin": self.is_builtin,
432449
"created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.created_at else None,
433450
}
@@ -460,6 +477,11 @@ def seed_roles():
460477
if existing.agent_access_json is None:
461478
existing.agent_access = config.get("agent_access", {"mode": "all"})
462479
# --- fim migração ---
480+
481+
# --- Migração workspace_folders: set default for existing roles ---
482+
if existing.workspace_folders_json is None:
483+
existing.workspace_folders = config.get("workspace_folders", {"mode": "all"})
484+
# --- fim migração ---
463485
else:
464486
role = Role(
465487
name=name,
@@ -468,6 +490,7 @@ def seed_roles():
468490
)
469491
role.permissions = config["permissions"]
470492
role.agent_access = config.get("agent_access", {"mode": "all"})
493+
role.workspace_folders = config.get("workspace_folders", {"mode": "all"})
471494
db.session.add(role)
472495
db.session.commit()
473496

@@ -539,6 +562,65 @@ def has_agent_access(role_name: str, agent_name: str) -> bool:
539562
return True
540563

541564

565+
def get_role_workspace_folders(role_name: str) -> dict:
566+
"""Get workspace_folders config for a role from DB, fallback to builtin defaults."""
567+
role = Role.query.filter_by(name=role_name).first()
568+
if role:
569+
return role.workspace_folders
570+
builtin = BUILTIN_ROLES.get(role_name)
571+
if builtin:
572+
return builtin.get("workspace_folders", {"mode": "all"})
573+
return {"mode": "all"}
574+
575+
576+
def has_workspace_folder_access(role_name: str, path: str) -> bool:
577+
"""Check if a role has access to a specific workspace folder.
578+
579+
Only enforces top-level folder access (e.g. workspace/finance/).
580+
Subfolders inherit parent access.
581+
582+
NOTE: This check applies only to the Flask API workspace endpoints.
583+
Terminal-server / agent sessions (Claude Code CLI) do NOT enforce folder
584+
restrictions — they bypass the Flask API entirely. This is a known
585+
limitation to be addressed in a future iteration.
586+
587+
Args:
588+
role_name: The role name string.
589+
path: A repo-relative path string (e.g. "workspace/finance/report.md").
590+
591+
Returns:
592+
True if access is allowed, False otherwise.
593+
"""
594+
if role_name == "admin":
595+
return True
596+
597+
parts = path.strip("/").split("/")
598+
599+
# Root "workspace" listing — always allowed (filtering happens on children)
600+
if len(parts) == 0 or parts[0] != "workspace":
601+
return True # Non-workspace paths are unaffected by folder permissions
602+
if len(parts) < 2 or parts[1] == "":
603+
return True # Browsing workspace root is allowed; children are filtered
604+
605+
folder = parts[1]
606+
607+
config = get_role_workspace_folders(role_name)
608+
mode = config.get("mode", "all")
609+
610+
if mode == "all":
611+
return True
612+
if mode == "none":
613+
return False
614+
if mode == "selected":
615+
folders = config.get("folders", [])
616+
# Empty selected list behaves as none
617+
if not folders:
618+
return False
619+
return folder in [f.strip("/") for f in folders]
620+
# Unknown mode — default to all
621+
return True
622+
623+
542624
def audit(user, action: str, resource: str = None, detail: str = None):
543625
"""Log an action to the audit trail."""
544626
from flask import request

dashboard/backend/routes/auth_routes.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from functools import wraps
55
from flask import Blueprint, request, jsonify, abort
66
from flask_login import login_user, logout_user, login_required, current_user
7-
from models import db, User, AuditLog, Role, has_permission, audit, needs_setup, get_role_permissions, get_role_agent_access, ALL_RESOURCES, AGENT_LAYERS
7+
from models import db, User, AuditLog, Role, has_permission, audit, needs_setup, get_role_permissions, get_role_agent_access, get_role_workspace_folders, ALL_RESOURCES, AGENT_LAYERS
88

99
bp = Blueprint("auth", __name__)
1010

@@ -189,10 +189,12 @@ def logout():
189189
def me():
190190
perms = get_role_permissions(current_user.role)
191191
agent_access = get_role_agent_access(current_user.role)
192+
workspace_folders = get_role_workspace_folders(current_user.role)
192193
return jsonify({
193194
"user": current_user.to_dict(),
194195
"permissions": perms,
195196
"agent_access": agent_access,
197+
"workspace_folders": workspace_folders,
196198
})
197199

198200

@@ -350,6 +352,28 @@ def list_agent_layers():
350352
return jsonify(AGENT_LAYERS)
351353

352354

355+
@bp.route("/api/roles/workspace-folders")
356+
@login_required
357+
@require_permission("users", "view")
358+
def list_workspace_folders():
359+
"""Return sorted list of top-level workspace folder names from disk."""
360+
import os
361+
from pathlib import Path
362+
workspace_path = Path(__file__).resolve().parents[3] / "workspace"
363+
folders = []
364+
if workspace_path.is_dir():
365+
blocklist = {
366+
".git", "node_modules", "dist", ".venv", "__pycache__",
367+
"backups", ".mypy_cache", ".pytest_cache", "target", "build",
368+
".next", ".turbo", "coverage", ".trash",
369+
}
370+
for name in sorted(os.listdir(workspace_path)):
371+
full = workspace_path / name
372+
if full.is_dir() and not name.startswith('.') and name not in blocklist:
373+
folders.append(name)
374+
return jsonify({"folders": folders})
375+
376+
353377
@bp.route("/api/roles", methods=["POST"])
354378
@login_required
355379
@require_permission("users", "manage")
@@ -369,6 +393,7 @@ def create_role():
369393
role = Role(name=name, description=description)
370394
role.permissions = permissions
371395
role.agent_access = data.get("agent_access", {"mode": "all"})
396+
role.workspace_folders = data.get("workspace_folders", {"mode": "all"})
372397
db.session.add(role)
373398
db.session.commit()
374399

@@ -389,6 +414,8 @@ def update_role(role_id):
389414
role.permissions = data["permissions"]
390415
if "agent_access" in data:
391416
role.agent_access = data["agent_access"]
417+
if "workspace_folders" in data:
418+
role.workspace_folders = data["workspace_folders"]
392419
if "name" in data and not role.is_builtin:
393420
new_name = data["name"].strip().lower()
394421
if new_name != role.name and Role.query.filter_by(name=new_name).first():

dashboard/backend/routes/shares.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from flask import Blueprint, jsonify, request, Response
99
from flask_login import login_required, current_user
1010

11-
from models import db, FileShare, audit
11+
from models import db, FileShare, audit, has_workspace_folder_access
1212
from routes.auth_routes import require_permission
1313

1414
bp = Blueprint("shares", __name__)
@@ -90,6 +90,10 @@ def create_share():
9090
if not full.exists() or not full.is_file():
9191
return jsonify({"error": "File not found", "code": "not_found"}), 404
9292

93+
# Enforce folder access before creating a share
94+
if not has_workspace_folder_access(current_user.role, path):
95+
return jsonify({"error": "Access to this workspace folder is restricted", "code": "forbidden"}), 403
96+
9397
# Calculate expiry
9498
expires_at = None
9599
if expires_in and expires_in in _EXPIRY_MAP:

dashboard/backend/routes/workspace.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from flask import Blueprint, jsonify, request, abort, send_file, current_app
1212
from flask_login import login_required, current_user
1313

14-
from models import has_permission
14+
from models import has_permission, has_workspace_folder_access
1515
from routes.auth_routes import require_permission
1616

1717
bp = Blueprint("workspace", __name__)
@@ -186,6 +186,23 @@ def _is_blocklisted(name: str) -> bool:
186186
return False
187187

188188

189+
def _check_folder_access(path_str: str):
190+
"""Abort 403 if user's role cannot access this workspace folder.
191+
192+
Only enforced for paths inside WORKSPACE_DIR. Admin paths are skipped
193+
(they are gated by config:manage permission already).
194+
"""
195+
if not current_user.is_authenticated:
196+
return
197+
# Only enforce for workspace/ paths
198+
parts = path_str.strip("/").split("/")
199+
if not parts or parts[0] != "workspace":
200+
return
201+
if not has_workspace_folder_access(current_user.role, path_str):
202+
_audit("denied", path_str, result="denied", reason="folder_restricted")
203+
abort(403, description="Access to this workspace folder is restricted")
204+
205+
189206
# ── Endpoints ──────────────────────────────────────────────────────────────
190207

191208
@bp.route("/api/workspace/tree")
@@ -236,6 +253,16 @@ def _build_entries(directory: Path, current_depth: int) -> list:
236253

237254
entries = _build_entries(full, depth)
238255

256+
# Filter top-level workspace folders based on role access
257+
rel_check = _repo_rel(full)
258+
if rel_check == "workspace" and current_user.is_authenticated:
259+
entries = [
260+
e for e in entries
261+
if not e.get("is_dir") or has_workspace_folder_access(
262+
current_user.role, f"workspace/{e['name']}"
263+
)
264+
]
265+
239266
# Build breadcrumbs from repo-rel path
240267
rel = _repo_rel(full)
241268
parts = [p for p in rel.split("/") if p]
@@ -266,6 +293,7 @@ def workspace_file_read():
266293
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
267294
)
268295
full = _resolve_safe(path, require_admin=require_admin)
296+
_check_folder_access(_repo_rel(full))
269297

270298
if not full.exists():
271299
return jsonify({"error": "File not found", "code": "not_found"}), 404
@@ -314,6 +342,7 @@ def workspace_file_write():
314342
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
315343
)
316344
full = _resolve_safe(path, require_admin=require_admin)
345+
_check_folder_access(_repo_rel(full))
317346

318347
if full.is_dir():
319348
return jsonify({"error": "Path is a directory", "code": "bad_path"}), 409
@@ -351,6 +380,7 @@ def workspace_file_create():
351380
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
352381
)
353382
full = _resolve_safe(path, require_admin=require_admin)
383+
_check_folder_access(_repo_rel(full))
354384

355385
if full.exists():
356386
return jsonify({"error": "File already exists", "code": "conflict"}), 409
@@ -385,6 +415,7 @@ def workspace_folder_create():
385415
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
386416
)
387417
full = _resolve_safe(path, require_admin=require_admin)
418+
_check_folder_access(_repo_rel(full))
388419

389420
if full.exists():
390421
return jsonify({"error": "Directory already exists", "code": "conflict"}), 409
@@ -422,6 +453,8 @@ def workspace_rename():
422453
)
423454
full_from = _resolve_safe(from_path, require_admin=require_admin)
424455
full_to = _resolve_safe(to_path, require_admin=require_admin)
456+
_check_folder_access(_repo_rel(full_from))
457+
_check_folder_access(_repo_rel(full_to))
425458

426459
if not full_from.exists():
427460
return jsonify({"error": "Source not found", "code": "not_found"}), 404
@@ -456,6 +489,7 @@ def workspace_file_delete():
456489
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
457490
)
458491
full = _resolve_safe(path, require_admin=require_admin)
492+
_check_folder_access(_repo_rel(full))
459493

460494
if not full.exists():
461495
return jsonify({"error": "File not found", "code": "not_found"}), 404
@@ -515,6 +549,7 @@ def workspace_upload():
515549
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
516550
)
517551
dir_full = _resolve_safe(path, require_admin=require_admin)
552+
_check_folder_access(_repo_rel(dir_full))
518553

519554
if not dir_full.is_dir():
520555
return jsonify({"error": "Target path is not a directory", "code": "bad_path"}), 400
@@ -549,6 +584,7 @@ def workspace_download():
549584
(REPO_ROOT / path).resolve(), WORKSPACE_DIR.resolve()
550585
)
551586
full = _resolve_safe(path, require_admin=require_admin)
587+
_check_folder_access(_repo_rel(full))
552588

553589
if not full.exists():
554590
return jsonify({"error": "File not found", "code": "not_found"}), 404
@@ -585,7 +621,11 @@ def workspace_recent():
585621
continue
586622
if _is_blocklisted(f.name):
587623
continue
588-
if any(_is_blocklisted(part) for part in f.relative_to(WORKSPACE_DIR).parts):
624+
rel_parts = f.relative_to(WORKSPACE_DIR).parts
625+
if any(_is_blocklisted(part) for part in rel_parts):
626+
continue
627+
# Enforce folder access: check top-level folder
628+
if rel_parts and not has_workspace_folder_access(current_user.role, f"workspace/{rel_parts[0]}"):
589629
continue
590630
try:
591631
stat = f.stat()

0 commit comments

Comments
 (0)