Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8ac64f5
docs(agent): spec for permission layer + LLM-invocable skills
AperturePlus Jul 9, 2026
1d82f7f
docs(agent): implementation plan for permission layer + LLM-invocable…
AperturePlus Jul 9, 2026
3978b8e
docs(agent): fix plan defects (dead code in Task 2, dup test in Task …
AperturePlus Jul 9, 2026
2236661
feat(agent): add PermissionResolver and EffectivePermission
AperturePlus Jul 9, 2026
d190ecd
feat(agent): widen PolicyGuard.evaluate to single permission choke point
AperturePlus Jul 9, 2026
64c162b
fix(agent): add evaluate_tool_call backward-compat shim to PolicyGuard
AperturePlus Jul 9, 2026
7e7e52f
fix(agent): bound max_read_bytes by bytes read not position; add cove…
AperturePlus Jul 9, 2026
4ffd96c
feat(agent): add drive.readFile tool with dataPolicy-aware binary guard
AperturePlus Jul 9, 2026
98d113c
fix(agent): guard readFile against empty file and offset beyond size
AperturePlus Jul 9, 2026
8f3cb95
feat(agent): add read_file/skill_candidate_k settings and setting def…
AperturePlus Jul 9, 2026
bea08c0
feat(agent): add agent.useSkill meta-tool and planner bind interceptor
AperturePlus Jul 9, 2026
35a050b
feat(agent): inject skill menu, intercept useSkill, gate planning via…
AperturePlus Jul 9, 2026
ceae4e7
fix(agent): refresh exploration tool list on skill bind; clarify Poli…
AperturePlus Jul 9, 2026
6fd0353
feat(agent): enforce PolicyGuard in execute and record denied actions
AperturePlus Jul 9, 2026
c8c8f64
test(agent): cover CONTROL_DENY/APPROVE step-matching in execute runner
AperturePlus Jul 9, 2026
c93d137
docs(agent): fix Task 8 — V13 already seeds organizeByType; add only …
AperturePlus Jul 9, 2026
7690326
feat(agent): seed 2 builtin skills (dedupScan, listAndSummarize) and …
AperturePlus Jul 9, 2026
cb8aa31
refactor(agent): remove dead evaluate_tool_call shim and unused skill…
AperturePlus Jul 9, 2026
19993dd
feat(agent): add agent_chat_session table and repository (subproject B)
AperturePlus Jul 10, 2026
693c3a4
feat(agent): add AgentChatSession model, BackgroundJob columns, and s…
AperturePlus Jul 10, 2026
ec2c09f
feat(agent): wire ChatSession into services and DI; soft-delete casca…
AperturePlus Jul 10, 2026
728cc4a
feat(agent): add chat-session CRUD routes and inbox message validatio…
AperturePlus Jul 10, 2026
2970952
feat(web): add agent chat-session API client, types, and mock handler…
AperturePlus Jul 10, 2026
872a3fb
feat(web): migrate agent sessions to backend API; fix turn/reload tim…
AperturePlus Jul 10, 2026
96c3a2a
docs: rewrite README with full project overview and quick start
AperturePlus Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,107 @@
# FileFlash Monorepo
# FileFlash

FileFlash 是一个面向个人与团队的现代文件工作台:上传、管理、预览、分享、回收站、后台治理与 Agent 工作流集中在一个清爽的 Web / Desktop 体验里。

## Highlights

- 文件云盘:文件夹、批量操作、拖拽上传、分片上传、下载与回收站
- 在线预览:图片、PDF、音视频、压缩包等常见文件类型
- 安全分享:公开分享链接、访问控制、分享中心与接收列表
- 管理后台:用户、存储、内容审核、日志、通知、系统状态与注册规则
- Agent 工作区:任务会话、技能管理、计划执行与事件追踪
- 自托管优先:PostgreSQL + Redis + MinIO,后端异步 API,前端支持 Web 与 Electron

## Tech Stack

- Frontend: Vue 3, Vite, TypeScript, Pinia, Naive UI, Vitest
- Desktop: Electron
- Backend: FastAPI, Pydantic, SQLAlchemy Async, PostgreSQL
- Infra: Redis, MinIO, Flyway, uv, Bun

## Quick Start

### 1. Start dependencies

```bash
cd docker/postgresql-16
docker compose up -d

cd ../redis
docker compose up -d

cd ../minio
docker compose up -d
```

PostgreSQL compose includes Flyway migration. MinIO console runs at `http://localhost:9001`.

### 2. Configure backend

```bash
cd app
cp .env.example .env
```

Adjust `.env` if needed. For the bundled Docker services, keep database, Redis, and MinIO values aligned with the compose files.

### 3. Start backend

```bash
cd app
uv run fileflash
```

Backend API: `http://localhost:8080`
Health check: `http://localhost:8080/health`

Development seed accounts:

| Role | Username | Password |
| --- | --- | --- |
| Admin | `admin` | `admin123` |
| User | `demo` | `demo123` |

### 4. Start frontend

```bash
cd web
bun install
bun run dev
```

Frontend dev server: `http://localhost:5173`

## Common Commands

```bash
# Backend tests
cd app && uv run pytest

# Backend import smoke test
cd app && uv run python -c "from fileflash.main import app; print(app.title)"

# Frontend type check
cd web && bun run check

# Frontend build
cd web && bun run build

# Electron dev
cd web && bun run electron:dev
```

## Project Layout

```text
app/ FastAPI backend, async services, schemas, workers, tests
web/ Vue frontend, API clients, mock handlers, pages, Electron shell
docker/ PostgreSQL/Flyway, Redis, MinIO local infrastructure
docs/ Design notes, implementation plans, project memory
```

## Notes

- API responses use a unified envelope: `success`, `code`, `message`, `data`, `timestamp`.
- Request and response fields use `camelCase`.
- Refresh tokens stay in HttpOnly cookies; the frontend only persists the access token.
- Production deployments must replace secrets in `app/.env`, especially `JWT_SECRET_KEY`.
132 changes: 132 additions & 0 deletions app/src/fileflash/agents/harness/permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from ...models import AgentUserSetting
from ...schemas.agent import AgentDataPolicy, AgentExecutionPolicy, PlanAgentRequest
from .tool_registry import REGISTRY


@dataclass(frozen=True, slots=True)
class EffectivePermission:
execution_policy: AgentExecutionPolicy
data_policy: AgentDataPolicy
allowed_tools: frozenset[str]
skill_key: str | None
deny_read_content: bool
high_risk_confirmed: bool


class PermissionResolver:
async def effective(
self,
*,
request: PlanAgentRequest,
setting: AgentUserSetting | None,
skill: Any,
high_risk_confirmed: bool,
) -> EffectivePermission:
execution_policy = request.execution_policy
data_policy = _merge_data_policy(request.data_policy, setting)
skill_whitelist = _skill_tool_whitelist(skill)
allowed_tools = frozenset(skill_whitelist)
skill_key = _skill_key(skill)
deny_read_content = (
not data_policy.allow_file_content
or not data_policy.allowed_mime_types
)
return EffectivePermission(
execution_policy=execution_policy,
data_policy=data_policy,
allowed_tools=allowed_tools,
skill_key=skill_key,
deny_read_content=deny_read_content,
high_risk_confirmed=high_risk_confirmed,
)


def _merge_data_policy(
request_policy: AgentDataPolicy, setting: AgentUserSetting | None
) -> AgentDataPolicy:
if setting is None:
return request_policy
setting_policy = _setting_data_policy(setting)
allow = request_policy.allow_file_content and setting_policy.allow_file_content
max_bytes = min(request_policy.max_read_bytes, setting_policy.max_read_bytes)
allowed_mimes = _intersect_mime_globs(
request_policy.allowed_mime_types, setting_policy.allowed_mime_types
)
return AgentDataPolicy(
allow_file_content=allow,
max_read_bytes=max_bytes,
allowed_mime_types=allowed_mimes,
)


def _setting_data_policy(setting: AgentUserSetting) -> AgentDataPolicy:
raw = setting.default_data_policy_json or {}
if not isinstance(raw, dict):
raw = {}
return AgentDataPolicy.model_validate(raw)


def _intersect_mime_globs(a: list[str], b: list[str]) -> list[str]:
# ["*/*"] means "all"; intersection with X = X.
if "*/*" in a and "*/*" in b:
return ["*/*"]
if "*/*" in a:
return list(b)
if "*/*" in b:
return list(a)
return [m for m in a if m in b]


def _skill_tool_whitelist(skill: Any) -> tuple[str, ...]:
if skill is None:
return REGISTRY.all_names()
raw = getattr(skill, "tool_whitelist_json", None)
if isinstance(raw, list) and raw:
tools = tuple(str(item) for item in raw if str(item).strip())
unknown = REGISTRY.unknown_names(tools)
if unknown:
from ...core.errors import ApiError
raise ApiError(
status_code=422,
code=422,
message="Unknown agent tool in selected skill",
data={"unknownTools": sorted(unknown)},
)
return tools
return REGISTRY.all_names()


def _skill_key(skill: Any) -> str | None:
if skill is None:
return None
return str(getattr(skill, "skill_key", None) or "")


def _apply_setting_defaults(
request: PlanAgentRequest, setting: AgentUserSetting | None
) -> PlanAgentRequest:
if setting is None:
return request
merged_policy = _merge_data_policy(request.data_policy, setting)
budget = request.hints.budget_tokens
if budget == 8000 and setting.default_budget_tokens:
budget = int(setting.default_budget_tokens)
max_steps = request.hints.max_steps
if max_steps == 12 and setting.default_max_steps:
max_steps = int(setting.default_max_steps)
return request.model_copy(
update={
"data_policy": merged_policy,
"hints": request.hints.model_copy(
update={"budget_tokens": budget, "max_steps": max_steps}
),
}
)


__all__ = ["EffectivePermission", "PermissionResolver", "_apply_setting_defaults"]
122 changes: 114 additions & 8 deletions app/src/fileflash/agents/harness/policy.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
from __future__ import annotations

import fnmatch
from dataclasses import dataclass, field
from typing import Any, Literal

from sqlalchemy import and_, select

from ...core.mime import resolve_file_mime_type
from ...models import File
from ...models.enums import FileStatus
from ...schemas.agent import AgentProposedAction
from .tool_registry import REGISTRY
from .permission import EffectivePermission
from .tool_registry import REGISTRY, ToolContext

_CONTENT_READ_TOOLS = frozenset({"drive.readFile"})
_Phase = Literal["planning", "executing"]


@dataclass(slots=True)
Expand Down Expand Up @@ -45,22 +56,117 @@ def normalize_action_risk(action: AgentProposedAction) -> AgentProposedAction:


class PolicyGuard:
async def evaluate_tool_call(
async def evaluate(
self,
*,
tool_name: str,
high_risk_confirmed: bool = False,
ctx: ToolContext,
action: AgentProposedAction,
permission: EffectivePermission,
phase: _Phase,
) -> PolicyDecision:
try:
REGISTRY.get(tool_name)
spec = REGISTRY.get(action.tool)
except KeyError:
return PolicyDecision(
allowed=False,
reasons=[f"Unsupported agent tool: {tool_name}"],
reasons=[f"Unsupported agent tool: {action.tool}"],
)
if action.tool not in permission.allowed_tools:
return PolicyDecision(
allowed=False,
reasons=[f"Tool not permitted by active skill/policy: {action.tool}"],
)
if spec.side_effect == "read" and action.tool in _CONTENT_READ_TOOLS:
decision = await self._check_content_read(
ctx=ctx, action=action, permission=permission
)
if decision is not None:
return decision
if spec.risk_level == "high" and not permission.high_risk_confirmed:
return PolicyDecision(
allowed=False,
reasons=["High-risk action requires explicit confirmation."],
)
if classify_tool_risk(tool_name) == "high" and not high_risk_confirmed:
if permission.execution_policy == "planOnly" and phase == "executing":
return PolicyDecision(
allowed=False,
reasons=["High-risk delete action requires explicit user confirmation."],
reasons=["planOnly policy forbids execution."],
)
return PolicyDecision(allowed=True)

async def _check_content_read(
self,
*,
ctx: ToolContext,
action: AgentProposedAction,
permission: EffectivePermission,
) -> PolicyDecision | None:
if permission.deny_read_content:
return PolicyDecision(
allowed=False,
reasons=["File content access disabled by dataPolicy."],
)
bytes_requested = self._bytes_requested(action.input)
if bytes_requested > permission.data_policy.max_read_bytes:
return PolicyDecision(
allowed=False,
reasons=[
f"Requested bytes ({bytes_requested}) exceed max_read_bytes "
f"({permission.data_policy.max_read_bytes})."
],
)
mime = await _resolve_target_mime(ctx=ctx, action=action)
if mime is not None and not _mime_allowed(
mime, permission.data_policy.allowed_mime_types
):
return PolicyDecision(
allowed=False,
reasons=[f"File mime '{mime}' not in allowed_mime_types."],
)
return None

def _bytes_requested(self, action_input: dict[str, Any]) -> int:
max_bytes = int(action_input.get("maxBytes", 262144) or 262144)
return max_bytes


def _mime_allowed(mime: str, allowed: list[str]) -> bool:
lowered = mime.lower()
return any(fnmatch.fnmatch(lowered, pattern.lower()) for pattern in allowed)


async def _resolve_target_mime(
*, ctx: ToolContext, action: AgentProposedAction
) -> str | None:
file_id = action.input.get("fileId") or action.input.get("id")
if file_id is None:
return None
try:
parsed = int(str(file_id))
except (TypeError, ValueError):
return None
row = await ctx.db.scalar(
select(File).where(
and_(
File.file_id == parsed,
File.owner_id == ctx.user_id,
File.status == FileStatus.ACTIVE,
)
)
)
if row is None:
return None
return resolve_file_mime_type(
mime_type=row.mime_type,
file_ext=row.file_ext,
file_name=row.file_name,
)


__all__ = [
"PolicyDecision",
"PolicyGuard",
"classify_tool_risk",
"classify_tool_side_effect",
"normalize_action_risk",
]
Loading
Loading