From 9b815bcbd265c21192423d8b1ce574919125f395 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 1 Jun 2026 21:32:50 -0400 Subject: [PATCH] feat(core): add location-based permission service (#30287) --- .../migration.sql | 1 + .../snapshot.json | 1566 +++++++++++++++ .../migration.sql | 11 + .../snapshot.json | 1676 +++++++++++++++++ packages/core/src/agent.ts | 4 +- packages/core/src/database/migration.gen.ts | 2 + .../20260601202201_amazing_prowler.ts | 11 + .../20260602002951_lowly_union_jack.ts | 22 + packages/core/src/location-layer.ts | 8 + packages/core/src/permission.ts | 313 ++- packages/core/src/permission/legacy.ts | 96 + packages/core/src/permission/saved.ts | 78 + packages/core/src/permission/schema.ts | 16 + packages/core/src/permission/sql.ts | 20 + packages/core/src/plugin/agent.ts | 68 +- packages/core/src/session/legacy.ts | 4 +- packages/core/src/session/sql.ts | 12 +- packages/core/test/config/agent.test.ts | 22 +- packages/core/test/config/config.test.ts | 12 +- packages/core/test/database-migration.test.ts | 2 +- packages/core/test/permission.test.ts | 176 ++ packages/opencode/src/agent/agent.ts | 3 +- .../src/agent/subagent-permissions.ts | 5 +- packages/opencode/src/cli/cmd/debug/agent.ts | 5 +- packages/opencode/src/cli/cmd/run.ts | 3 +- packages/opencode/src/permission/evaluate.ts | 2 +- packages/opencode/src/permission/index.ts | 186 +- packages/opencode/src/permission/schema.ts | 13 - packages/opencode/src/project/project.ts | 36 +- .../instance/httpapi/groups/permission.ts | 8 +- .../routes/instance/httpapi/groups/session.ts | 8 +- .../routes/instance/httpapi/groups/v2.ts | 4 + .../instance/httpapi/groups/v2/location.ts | 5 +- .../instance/httpapi/groups/v2/permission.ts | 90 + .../instance/httpapi/handlers/permission.ts | 6 +- .../instance/httpapi/handlers/session.ts | 4 +- .../routes/instance/httpapi/handlers/v2.ts | 15 +- .../httpapi/handlers/v2/permission.ts | 105 ++ packages/opencode/src/session/llm.ts | 6 +- packages/opencode/src/session/llm/request.ts | 3 +- packages/opencode/src/session/processor.ts | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/session/session.ts | 19 +- .../opencode/src/storage/json-migration.ts | 32 +- packages/opencode/src/storage/schema.ts | 2 +- packages/opencode/src/tool/tool.ts | 3 +- packages/opencode/test/agent/agent.test.ts | 3 +- .../agent/plan-mode-subagent-bypass.test.ts | 7 +- .../opencode/test/permission-task.test.ts | 5 +- .../opencode/test/permission/next.test.ts | 100 +- .../opencode/test/project/project.test.ts | 20 +- .../test/server/httpapi-exercise/index.ts | 26 + .../test/server/httpapi-instance.test.ts | 4 +- .../test/server/httpapi-session.test.ts | 4 +- packages/opencode/test/session/llm.test.ts | 3 +- .../test/storage/json-migration.test.ts | 19 +- .../test/tool/external-directory.test.ts | 3 +- packages/opencode/test/tool/glob.test.ts | 5 +- packages/opencode/test/tool/grep.test.ts | 5 +- packages/opencode/test/tool/lsp.test.ts | 5 +- packages/opencode/test/tool/read.test.ts | 9 +- packages/opencode/test/tool/shell.test.ts | 65 +- packages/opencode/test/tool/skill.test.ts | 3 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 177 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 370 +++- 65 files changed, 4970 insertions(+), 552 deletions(-) create mode 100644 packages/core/migration/20260601202201_amazing_prowler/migration.sql create mode 100644 packages/core/migration/20260601202201_amazing_prowler/snapshot.json create mode 100644 packages/core/migration/20260602002951_lowly_union_jack/migration.sql create mode 100644 packages/core/migration/20260602002951_lowly_union_jack/snapshot.json create mode 100644 packages/core/src/database/migration/20260601202201_amazing_prowler.ts create mode 100644 packages/core/src/database/migration/20260602002951_lowly_union_jack.ts create mode 100644 packages/core/src/permission/legacy.ts create mode 100644 packages/core/src/permission/saved.ts create mode 100644 packages/core/src/permission/schema.ts create mode 100644 packages/core/src/permission/sql.ts create mode 100644 packages/core/test/permission.test.ts delete mode 100644 packages/opencode/src/permission/schema.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts diff --git a/packages/core/migration/20260601202201_amazing_prowler/migration.sql b/packages/core/migration/20260601202201_amazing_prowler/migration.sql new file mode 100644 index 000000000..92405490f --- /dev/null +++ b/packages/core/migration/20260601202201_amazing_prowler/migration.sql @@ -0,0 +1 @@ +DROP TABLE `permission`; \ No newline at end of file diff --git a/packages/core/migration/20260601202201_amazing_prowler/snapshot.json b/packages/core/migration/20260601202201_amazing_prowler/snapshot.json new file mode 100644 index 000000000..3c5e0ae6c --- /dev/null +++ b/packages/core/migration/20260601202201_amazing_prowler/snapshot.json @@ -0,0 +1,1566 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "226375f1-a19f-4c7b-8aa2-ccc5513d3b0d", + "prevIds": [ + "bf93c73b-5a48-4d63-9909-3c36a79b9788" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/migration/20260602002951_lowly_union_jack/migration.sql b/packages/core/migration/20260602002951_lowly_union_jack/migration.sql new file mode 100644 index 000000000..aea79762f --- /dev/null +++ b/packages/core/migration/20260602002951_lowly_union_jack/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE `permission` ( + `id` text PRIMARY KEY, + `project_id` text NOT NULL, + `action` text NOT NULL, + `resource` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE UNIQUE INDEX `permission_project_action_resource_idx` ON `permission` (`project_id`,`action`,`resource`); \ No newline at end of file diff --git a/packages/core/migration/20260602002951_lowly_union_jack/snapshot.json b/packages/core/migration/20260602002951_lowly_union_jack/snapshot.json new file mode 100644 index 000000000..77777d4aa --- /dev/null +++ b/packages/core/migration/20260602002951_lowly_union_jack/snapshot.json @@ -0,0 +1,1676 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "80d6efb8-93fd-4ce5-b320-45a05aaebdd7", + "prevIds": [ + "226375f1-a19f-4c7b-8aa2-ccc5513d3b0d" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index c4971b272..d7c249031 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -3,7 +3,7 @@ export * as AgentV2 from "./agent" import { Array, Context, Effect, Layer, Schema, Scope } from "effect" import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" -import { PermissionV2 } from "./permission" +import { PermissionSchema } from "./permission/schema" import { ProviderV2 } from "./provider" import { PositiveInt } from "./schema" import { State } from "./state" @@ -26,7 +26,7 @@ export class Info extends Schema.Class("AgentV2.Info")({ hidden: Schema.Boolean, color: Color.pipe(Schema.optional), steps: PositiveInt.pipe(Schema.optional), - permissions: PermissionV2.Ruleset, + permissions: PermissionSchema.Ruleset, }) { static empty(id: ID) { return new Info({ diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index c1222bcc1..172d35aff 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -24,5 +24,7 @@ export const migrations = ( import("./migration/20260511000411_data_migration_state"), import("./migration/20260511173437_session-metadata"), import("./migration/20260601010001_normalize_storage_paths"), + import("./migration/20260601202201_amazing_prowler"), + import("./migration/20260602002951_lowly_union_jack"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260601202201_amazing_prowler.ts b/packages/core/src/database/migration/20260601202201_amazing_prowler.ts new file mode 100644 index 000000000..84b619d2f --- /dev/null +++ b/packages/core/src/database/migration/20260601202201_amazing_prowler.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260601202201_amazing_prowler", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DROP TABLE \`permission\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260602002951_lowly_union_jack.ts b/packages/core/src/database/migration/20260602002951_lowly_union_jack.ts new file mode 100644 index 000000000..45cd8aafa --- /dev/null +++ b/packages/core/src/database/migration/20260602002951_lowly_union_jack.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260602002951_lowly_union_jack", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`permission\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`action\` text NOT NULL, + \`resource\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index a43486fa2..43b05a596 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -13,6 +13,10 @@ import { Npm } from "./npm" import { ModelsDev } from "./models-dev" import { AppFileSystem } from "./filesystem" import { Global } from "./global" +import { Database } from "./database/database" +import { PermissionV2 } from "./permission" +import { PermissionSaved } from "./permission/saved" +import { SessionV2 } from "./session" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { @@ -25,6 +29,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Catalog.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, + PermissionV2.locationLayer, ).pipe(Layer.provideMerge(location), Layer.fresh) }, idleTimeToLive: "60 minutes", @@ -36,5 +41,8 @@ export class LocationServiceMap extends LayerMap.Service()(" ModelsDev.defaultLayer, AppFileSystem.defaultLayer, Global.defaultLayer, + Database.defaultLayer, + SessionV2.defaultLayer, + PermissionSaved.defaultLayer, ], }) {} diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 95c2745b9..d78f45c56 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,42 +1,110 @@ export * as PermissionV2 from "./permission" -import { Schema } from "effect" +import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" +import { EventV2 } from "./event" +import { Location } from "./location" +import { AgentV2 } from "./agent" +import { SessionV2 } from "./session" +import { withStatics } from "./schema" +import { Identifier } from "./util/identifier" import { Wildcard } from "./util/wildcard" -import { Identifier } from "./id/id" -import { Newtype } from "./schema" +import { PermissionSchema } from "./permission/schema" +import { PermissionSaved } from "./permission/saved" -export class PermissionID extends Newtype()( - "PermissionID", - Schema.String.check(Schema.isStartsWith("per")), -) { - static ascending(id?: string): PermissionID { - return this.make(Identifier.ascending("permission", id)) - } +export { Effect, Rule, Ruleset } from "./permission/schema" +type Effect = PermissionSchema.Effect +type Rule = PermissionSchema.Rule +type Ruleset = PermissionSchema.Ruleset + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionV2.ID"), + withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const Source = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("tool"), + messageID: Schema.String, + callID: Schema.String, + }), +]).annotate({ identifier: "PermissionV2.Source" }) +export type Source = typeof Source.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionV2.ID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}).annotate({ identifier: "PermissionV2.Request" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" }) +export type Reply = typeof Reply.Type + +export const AssertInput = Schema.Struct({ + id: ID.pipe(Schema.optional), + sessionID: SessionV2.ID, + action: Schema.String, + resources: Schema.Array(Schema.String), + save: Schema.Array(Schema.String).pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}).annotate({ identifier: "PermissionV2.AssertInput" }) +export type AssertInput = typeof AssertInput.Type + +export const ReplyInput = Schema.Struct({ + requestID: ID, + reply: Reply, + message: Schema.String.pipe(Schema.optional), +}).annotate({ identifier: "PermissionV2.ReplyInput" }) +export type ReplyInput = typeof ReplyInput.Type + +export const AskResult = Schema.Struct({ + id: ID, + effect: PermissionSchema.Effect, +}).annotate({ identifier: "PermissionV2.AskResult" }) +export type AskResult = typeof AskResult.Type + +export const Event = { + Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }), + Replied: EventV2.define({ + type: "permission.v2.replied", + schema: { + sessionID: SessionV2.ID, + requestID: ID, + reply: Reply, + }, + }), } -export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" }) -export type Action = typeof Action.Type +export class RejectedError extends Schema.TaggedErrorClass()("PermissionV2.RejectedError", {}) {} -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "Permission.Rule" }) -export type Rule = typeof Rule.Type +export class CorrectedError extends Schema.TaggedErrorClass()("PermissionV2.CorrectedError", { + feedback: Schema.String, +}) {} -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" }) -export type Ruleset = typeof Ruleset.Type +export class DeniedError extends Schema.TaggedErrorClass()("PermissionV2.DeniedError", { + rules: PermissionSchema.Ruleset, +}) {} -const EDIT_TOOLS = ["edit", "write", "apply_patch"] +export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { + requestID: ID, +}) {} -export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { +export type Error = DeniedError | RejectedError | CorrectedError + +export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule { return ( rulesets .flat() - .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { - action: "ask", - permission, - pattern: "*", + .findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? { + action, + resource: "*", + effect: "ask", } ) } @@ -45,12 +113,189 @@ export function merge(...rulesets: Ruleset[]): Ruleset { return rulesets.flat() } -export function disabled(tools: string[], ruleset: Ruleset): Set { - return new Set( - tools.filter((tool) => { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - return rule?.pattern === "*" && rule.action === "deny" - }), - ) +export interface Interface { + readonly ask: (input: AssertInput) => EffectRuntime.Effect + readonly assert: (input: AssertInput) => EffectRuntime.Effect + readonly reply: (input: ReplyInput) => EffectRuntime.Effect + readonly get: (id: ID) => EffectRuntime.Effect + readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect> + readonly list: () => EffectRuntime.Effect> } + +export class Service extends Context.Service()("@opencode/v2/Permission") {} + +interface Pending { + readonly request: Request + readonly deferred: Deferred.Deferred +} + +export const layer = Layer.effect( + Service, + EffectRuntime.gen(function* () { + const events = yield* EventV2.Service + const location = yield* Location.Service + const agents = yield* AgentV2.Service + const sessions = yield* SessionV2.Service + const saved = yield* PermissionSaved.Service + const pending = new Map() + + yield* EffectRuntime.addFinalizer(() => + EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), { + discard: true, + }).pipe( + EffectRuntime.ensuring( + EffectRuntime.sync(() => { + pending.clear() + }), + ), + ), + ) + + const savedRules = EffectRuntime.fnUntraced(function* () { + return (yield* saved.list({ projectID: location.project.id })).map( + (item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), + ) + }) + + const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) { + const session = yield* sessions.get(sessionID) + if (!session.agent) return [] + return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? [] + }) + + function denied(input: AssertInput, rules: Ruleset) { + return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny") + } + + function relevant(input: AssertInput, rules: Ruleset) { + return rules.filter((rule) => Wildcard.match(input.action, rule.action)) + } + + const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) { + const rules = yield* configured(input.sessionID) + if (denied(input, rules)) return { effect: "deny" as const, rules } + const all = [...rules, ...(yield* savedRules())] + const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect) + const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow" + return { effect, rules: all } + }) + + function request(input: AssertInput): Request { + return { + id: input.id ?? ID.create(), + sessionID: input.sessionID, + action: input.action, + resources: input.resources, + save: input.save, + metadata: input.metadata, + source: input.source, + } + } + + const create = EffectRuntime.fnUntraced(function* (request: Request) { + const deferred = yield* Deferred.make() + const item = { request, deferred } + pending.set(request.id, item) + yield* events.publish(Event.Asked, request) + return item + }) + + const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) { + const result = yield* evaluateInput(input) + const value = request(input) + if (result.effect === "ask") yield* create(value) + return { id: value.id, effect: result.effect } + }) + + const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) { + const result = yield* evaluateInput(input) + if (result.effect === "deny") { + return yield* new DeniedError({ + rules: relevant(input, result.rules), + }) + } + if (result.effect === "allow") return + const item = yield* create(request(input)) + return yield* Deferred.await(item.deferred).pipe( + EffectRuntime.ensuring( + EffectRuntime.sync(() => { + pending.delete(item.request.id) + }), + ), + ) + }) + + const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) { + const existing = pending.get(input.requestID) + if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + pending.delete(input.requestID) + yield* events.publish(Event.Replied, { + sessionID: existing.request.sessionID, + requestID: existing.request.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + for (const [id, item] of pending) { + if (item.request.sessionID !== existing.request.sessionID) continue + pending.delete(id) + yield* events.publish(Event.Replied, { + sessionID: item.request.sessionID, + requestID: item.request.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + if (input.reply === "always" && existing.request.save?.length) { + yield* saved.add({ projectID: location.project.id, action: existing.request.action, resources: existing.request.save }) + } + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply !== "always" || !existing.request.save?.length) return + + const rememberedRules = yield* savedRules() + for (const [id, item] of pending) { + const input = { ...item.request } + const rules = yield* configured(item.request.sessionID).pipe( + EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)), + ) + if (!rules) continue + if (denied(input, rules)) continue + const effective = [...rules, ...rememberedRules] + if ( + !item.request.resources.every((resource) => evaluate(item.request.action, resource, effective).effect === "allow") + ) + continue + pending.delete(id) + yield* events.publish(Event.Replied, { + sessionID: item.request.sessionID, + requestID: item.request.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + }) + + const list = EffectRuntime.fn("PermissionV2.list")(function* () { + return Array.from(pending.values(), (item) => item.request) + }) + + const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) { + return pending.get(id)?.request + }) + + const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) { + return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID) + }) + + return Service.of({ ask, assert, reply, get, forSession, list }) + }), +) + +export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) diff --git a/packages/core/src/permission/legacy.ts b/packages/core/src/permission/legacy.ts new file mode 100644 index 000000000..44f07627b --- /dev/null +++ b/packages/core/src/permission/legacy.ts @@ -0,0 +1,96 @@ +export * as PermissionLegacy from "./legacy" + +import { Schema } from "effect" +import { ProjectV2 } from "../project" +import { withStatics } from "../schema" +import { SessionSchema } from "../session/schema" +import { Identifier } from "../util/identifier" + +export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe( + Schema.brand("PermissionID"), + withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) +export type Action = typeof Action.Type + +export const Rule = Schema.Struct({ + permission: Schema.String, + pattern: Schema.String, + action: Action, +}).annotate({ identifier: "PermissionRule" }) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) +export type Ruleset = typeof Ruleset.Type + +export const Request = Schema.Struct({ + id: ID, + sessionID: SessionSchema.ID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.Struct({ + messageID: Schema.String, + callID: Schema.String, + }).pipe(Schema.optional), +}).annotate({ identifier: "PermissionRequest" }) +export type Request = typeof Request.Type + +export const Reply = Schema.Literals(["once", "always", "reject"]) +export type Reply = typeof Reply.Type + +export const ReplyBody = Schema.Struct({ + reply: Reply, + message: Schema.String.pipe(Schema.optional), +}).annotate({ identifier: "PermissionReplyBody" }) +export type ReplyBody = typeof ReplyBody.Type + +export const Approval = Schema.Struct({ + projectID: ProjectV2.ID, + patterns: Schema.Array(Schema.String), +}).annotate({ identifier: "PermissionApproval" }) +export type Approval = typeof Approval.Type + +export const AskInput = Schema.Struct({ + ...Request.fields, + id: ID.pipe(Schema.optional), + ruleset: Ruleset, +}).annotate({ identifier: "PermissionAskInput" }) +export type AskInput = typeof AskInput.Type + +export const ReplyInput = Schema.Struct({ + requestID: ID, + ...ReplyBody.fields, +}).annotate({ identifier: "PermissionReplyInput" }) +export type ReplyInput = typeof ReplyInput.Type + +export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } +} + +export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, +}) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } +} + +export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, +}) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Permission.NotFoundError", { + requestID: ID, +}) {} + +export type Error = DeniedError | RejectedError | CorrectedError diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts new file mode 100644 index 000000000..110f3d23b --- /dev/null +++ b/packages/core/src/permission/saved.ts @@ -0,0 +1,78 @@ +export * as PermissionSaved from "./saved" + +import { eq } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { Database } from "../database/database" +import { ProjectV2 } from "../project" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" +import { PermissionTable } from "./sql" + +export const ID = Schema.String.pipe( + Schema.brand("PermissionSaved.ID"), + withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + projectID: ProjectV2.ID, + action: Schema.String, + resource: Schema.String, +}).annotate({ identifier: "PermissionSaved.Info" }) +export type Info = typeof Info.Type + +export const ListInput = Schema.Struct({ + projectID: ProjectV2.ID.pipe(Schema.optional), +}).annotate({ identifier: "PermissionSaved.ListInput" }) +export type ListInput = typeof ListInput.Type + +export const AddInput = Schema.Struct({ + projectID: ProjectV2.ID, + action: Schema.String, + resources: Schema.Array(Schema.String), +}).annotate({ identifier: "PermissionSaved.AddInput" }) +export type AddInput = typeof AddInput.Type + +export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect> + readonly add: (input: AddInput) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/PermissionSaved") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + + const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) { + const rows = yield* db + .select() + .from(PermissionTable) + .where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined) + .all() + .pipe(Effect.orDie) + return rows.map((row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource })) + }) + + const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) { + if (!input.resources.length) return + yield* db + .insert(PermissionTable) + .values(input.resources.map((resource) => ({ id: ID.create(), project_id: input.projectID, action: input.action, resource }))) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + + const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) { + yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie) + }) + + return Service.of({ list, add, remove }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/permission/schema.ts b/packages/core/src/permission/schema.ts new file mode 100644 index 000000000..2d806dbd8 --- /dev/null +++ b/packages/core/src/permission/schema.ts @@ -0,0 +1,16 @@ +export * as PermissionSchema from "./schema" + +import { Schema } from "effect" + +export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" }) +export type Effect = typeof Effect.Type + +export const Rule = Schema.Struct({ + action: Schema.String, + resource: Schema.String, + effect: Effect, +}).annotate({ identifier: "PermissionV2.Rule" }) +export type Rule = typeof Rule.Type + +export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" }) +export type Ruleset = typeof Ruleset.Type diff --git a/packages/core/src/permission/sql.ts b/packages/core/src/permission/sql.ts new file mode 100644 index 000000000..c395555d7 --- /dev/null +++ b/packages/core/src/permission/sql.ts @@ -0,0 +1,20 @@ +import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import { ProjectV2 } from "../project" +import { ProjectTable } from "../project/sql" +import type { PermissionSaved } from "./saved" + +export const PermissionTable = sqliteTable( + "permission", + { + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + action: text().notNull(), + resource: text().notNull(), + ...Timestamps, + }, + (table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)], +) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 9baba75c3..9e568dac7 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({ const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ - { permission: "external_directory", pattern: "*", action: "ask" }, + { action: "external_directory", resource: "*", effect: "ask" }, ...whitelistedDirs.map( - (pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }), + (resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }), ), ] const defaults: PermissionV2.Ruleset = [ - { permission: "*", pattern: "*", action: "allow" }, + { action: "*", resource: "*", effect: "allow" }, ...readonlyExternalDirectory, - { permission: "question", pattern: "*", action: "deny" }, - { permission: "plan_enter", pattern: "*", action: "deny" }, - { permission: "plan_exit", pattern: "*", action: "deny" }, - { permission: "repo_clone", pattern: "*", action: "deny" }, - { permission: "repo_overview", pattern: "*", action: "deny" }, - { permission: "read", pattern: "*", action: "allow" }, - { permission: "read", pattern: "*.env", action: "ask" }, - { permission: "read", pattern: "*.env.*", action: "ask" }, - { permission: "read", pattern: "*.env.example", action: "allow" }, + { action: "question", resource: "*", effect: "deny" }, + { action: "plan_enter", resource: "*", effect: "deny" }, + { action: "plan_exit", resource: "*", effect: "deny" }, + { action: "repo_clone", resource: "*", effect: "deny" }, + { action: "repo_overview", resource: "*", effect: "deny" }, + { action: "read", resource: "*", effect: "allow" }, + { action: "read", resource: "*.env", effect: "ask" }, + { action: "read", resource: "*.env.*", effect: "ask" }, + { action: "read", resource: "*.env.example", effect: "allow" }, ] yield* agent.update((editor) => { @@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({ item.mode = "primary" item.permissions.push( ...PermissionV2.merge(defaults, [ - { permission: "question", pattern: "*", action: "allow" }, - { permission: "plan_enter", pattern: "*", action: "allow" }, + { action: "question", resource: "*", effect: "allow" }, + { action: "plan_enter", resource: "*", effect: "allow" }, ]), ) }) @@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({ item.mode = "primary" item.permissions.push( ...PermissionV2.merge(defaults, [ - { permission: "question", pattern: "*", action: "allow" }, - { permission: "plan_exit", pattern: "*", action: "allow" }, - { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, - { permission: "edit", pattern: "*", action: "deny" }, - { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, + { action: "question", resource: "*", effect: "allow" }, + { action: "plan_exit", resource: "*", effect: "allow" }, + { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" }, + { action: "edit", resource: "*", effect: "deny" }, + { action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" }, { - permission: "edit", - pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), - action: "allow", + action: "edit", + resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), + effect: "allow", }, ]), ) @@ -159,7 +159,7 @@ export const Plugin = PluginV2.define({ "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" item.permissions.push( - ...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]), + ...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]), ) }) @@ -172,14 +172,14 @@ export const Plugin = PluginV2.define({ ...PermissionV2.merge( defaults, [ - { permission: "*", pattern: "*", action: "deny" }, - { permission: "grep", pattern: "*", action: "allow" }, - { permission: "glob", pattern: "*", action: "allow" }, - { permission: "list", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "webfetch", pattern: "*", action: "allow" }, - { permission: "websearch", pattern: "*", action: "allow" }, - { permission: "read", pattern: "*", action: "allow" }, + { action: "*", resource: "*", effect: "deny" }, + { action: "grep", resource: "*", effect: "allow" }, + { action: "glob", resource: "*", effect: "allow" }, + { action: "list", resource: "*", effect: "allow" }, + { action: "bash", resource: "*", effect: "allow" }, + { action: "webfetch", resource: "*", effect: "allow" }, + { action: "websearch", resource: "*", effect: "allow" }, + { action: "read", resource: "*", effect: "allow" }, ], readonlyExternalDirectory, ), @@ -190,21 +190,21 @@ export const Plugin = PluginV2.define({ item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION - item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) editor.update(AgentV2.ID.make("title"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE - item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) editor.update(AgentV2.ID.make("summary"), (item) => { item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY - item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) + item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }])) }) }) }), diff --git a/packages/core/src/session/legacy.ts b/packages/core/src/session/legacy.ts index a1896a9de..db5a02c0a 100644 --- a/packages/core/src/session/legacy.ts +++ b/packages/core/src/session/legacy.ts @@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy" import { Effect, Schema, Types } from "effect" import { EventV2 } from "../event" -import { PermissionV2 } from "../permission" +import { PermissionLegacy } from "../permission/legacy" import { ProjectV2 } from "../project" import { ProviderV2 } from "../provider" import { optionalOmitUndefined, withStatics } from "../schema" @@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({ compacting: optionalOmitUndefined(NonNegativeInt), archived: optionalOmitUndefined(Schema.Finite), }), - permission: optionalOmitUndefined(PermissionV2.Ruleset), + permission: optionalOmitUndefined(PermissionLegacy.Ruleset), revert: optionalOmitUndefined(SessionRevert), }).annotate({ identifier: "Session" }) export type SessionInfo = typeof SessionInfo.Type diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 31a0a807c..f901b768b 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -3,7 +3,7 @@ import * as DatabasePath from "../database/path" import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Snapshot } from "../snapshot" -import { PermissionV2 } from "../permission" +import { PermissionLegacy } from "../permission/legacy" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy" @@ -42,7 +42,7 @@ export const SessionTable = sqliteTable( tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), - permission: text({ mode: "json" }).$type(), + permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ id: string @@ -129,11 +129,3 @@ export const SessionMessageTable = sqliteTable( index("session_message_time_created_idx").on(table.time_created), ], ) - -export const PermissionTable = sqliteTable("permission", { - project_id: text() - .primaryKey() - .references(() => ProjectTable.id, { onDelete: "cascade" }), - ...Timestamps, - data: text({ mode: "json" }).notNull().$type(), -}) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 34ca99b09..dac2933cc 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => { yield* defaults((editor) => editor.update(build, (agent) => { agent.mode = "primary" - agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" }) + agent.permissions.push({ action: "bash", resource: "*", effect: "allow" }) }), ) @@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => { new Config.Loaded({ source: { type: "memory" }, info: decode({ - permissions: [{ permission: "bash", pattern: "*", action: "ask" }], + permissions: [{ action: "bash", resource: "*", effect: "ask" }], agents: { build: { - permissions: [{ permission: "bash", pattern: "git *", action: "allow" }], + permissions: [{ action: "bash", resource: "git *", effect: "allow" }], }, reviewer: { model: "openrouter/openai/gpt-5", description: "Review changes", mode: "subagent", - permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + permissions: [{ action: "edit", resource: "*", effect: "deny" }], }, removed: { description: "Removed later" }, }, @@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => { const buildAgent = yield* agents.get(build) if (!buildAgent) throw new Error("expected configured build agent") expect(buildAgent.permissions).toEqual([ - { permission: "bash", pattern: "*", action: "allow" }, - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "bash", pattern: "git *", action: "allow" }, + { action: "bash", resource: "*", effect: "allow" }, + { action: "bash", resource: "*", effect: "ask" }, + { action: "bash", resource: "git *", effect: "allow" }, ]) - expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow") - expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask") + expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow") + expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask") const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) if (!reviewer) throw new Error("expected configured reviewer agent") @@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => { model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, }) expect(reviewer.permissions).toEqual([ - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: "deny" }, + { action: "bash", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "deny" }, ]) expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() }), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 47e0206d4..17c952b81 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -170,8 +170,8 @@ describe("Config", () => { enterprise: { url: "https://share.example.com" }, username: "test-user", permissions: [ - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "bash", pattern: "git status", action: "allow" }, + { action: "bash", resource: "*", effect: "ask" }, + { action: "bash", resource: "git status", effect: "allow" }, ], agents: { reviewer: { @@ -188,7 +188,7 @@ describe("Config", () => { color: "warning", steps: 12, disabled: false, - permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + permissions: [{ action: "edit", resource: "*", effect: "deny" }], }, }, snapshots: false, @@ -254,8 +254,8 @@ describe("Config", () => { expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) expect(documents[0]?.info.username).toBe("test-user") expect(documents[0]?.info.permissions).toEqual([ - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "bash", pattern: "git status", action: "allow" }, + { action: "bash", resource: "*", effect: "ask" }, + { action: "bash", resource: "git status", effect: "allow" }, ]) expect(documents[0]?.info.agents?.reviewer).toEqual({ model: "openrouter/openai/gpt-5", @@ -271,7 +271,7 @@ describe("Config", () => { color: "warning", steps: 12, disabled: false, - permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + permissions: [{ action: "edit", resource: "*", effect: "deny" }], }) expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 8e046ac1e..d9251ca66 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -43,7 +43,7 @@ describe("DatabaseMigration", () => { expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ name: "session", }) - expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 }) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 }) }), ) }) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts new file mode 100644 index 000000000..6bb9211f9 --- /dev/null +++ b/packages/core/test/permission.test.ts @@ -0,0 +1,176 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Fiber, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { PermissionTable } from "@opencode-ai/core/permission/sql" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { eq } from "drizzle-orm" +import { location } from "./fixture/location" +import { testEffect } from "./lib/effect" + +const database = Database.layerFromPath(":memory:") +const current = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/project") })), +) +const events = EventV2.layer.pipe(Layer.provide(database)) +const sessions = SessionV2.layer.pipe(Layer.provide(database)) +const saved = PermissionSaved.layer.pipe(Layer.provide(database)) +const layer = PermissionV2.locationLayer.pipe( + Layer.provideMerge(database), + Layer.provideMerge(events), + Layer.provideMerge(current), + Layer.provideMerge(sessions), + Layer.provideMerge(saved), +) +const it = testEffect(layer) + +function setup(rules: PermissionV2.Ruleset = []) { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: SessionV2.ID.make("ses_test"), + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + agent: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* setRules(rules) + }) +} + +function setRules(rules: PermissionV2.Ruleset) { + return Effect.gen(function* () { + const agents = yield* AgentV2.Service + const update = yield* agents.transform() + yield* update((editor) => + editor.update(AgentV2.ID.make("test"), (agent) => { + agent.permissions = [...rules] + }), + ) + }) +} + +function assertion(input: Partial = {}) { + return { + id: PermissionV2.ID.create("per_test"), + sessionID: SessionV2.ID.make("ses_test"), + action: "read", + resources: ["src/index.ts"], + ...input, + } satisfies PermissionV2.AssertInput +} + +function waitForRequest() { + return Effect.gen(function* () { + const service = yield* PermissionV2.Service + const events = yield* EventV2.Service + const asked = yield* Deferred.make() + const unsubscribe = yield* events.listen((event) => + event.type === PermissionV2.Event.Asked.type + ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) + : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped) + const request = yield* Deferred.await(asked) + return { service, fiber, request } + }) +} + +describe("PermissionV2", () => { + it.effect("returns the evaluated effect and only queues prompts", () => + Effect.gen(function* () { + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + const service = yield* PermissionV2.Service + expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" }) + expect(yield* service.list()).toEqual([]) + yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) + expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" }) + expect(yield* service.list()).toEqual([]) + yield* setRules([]) + expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" }) + expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined() + }), + ) + + it.effect("allows and denies from explicit rules without asking", () => + Effect.gen(function* () { + yield* setup([{ action: "read", resource: "*", effect: "allow" }]) + const service = yield* PermissionV2.Service + yield* service.assert(assertion()) + yield* setRules([{ action: "read", resource: "*", effect: "deny" }]) + const denied = yield* service.assert(assertion()).pipe(Effect.flip) + expect(denied).toBeInstanceOf(PermissionV2.DeniedError) + expect(yield* service.list()).toEqual([]) + }), + ) + + it.effect("resolves an asked permission once", () => + Effect.gen(function* () { + yield* setup() + const { service, fiber, request } = yield* waitForRequest() + expect(yield* service.list()).toEqual([request]) + expect(yield* service.forSession(request.sessionID)).toEqual([request]) + expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([]) + expect(yield* service.get(request.id)).toEqual(request) + yield* service.reply({ requestID: request.id, reply: "once" }) + yield* Fiber.join(fiber) + expect(yield* service.list()).toEqual([]) + expect(yield* service.get(request.id)).toBeUndefined() + }), + ) + + it.effect("stores and removes saved resources for a project", () => + Effect.gen(function* () { + yield* setup() + const service = yield* PermissionV2.Service + const asked = yield* Deferred.make() + const events = yield* EventV2.Service + const unsubscribe = yield* events.listen((event) => + event.type === PermissionV2.Event.Asked.type + ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid) + : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped) + const request = yield* Deferred.await(asked) + yield* service.reply({ requestID: request.id, reply: "always" }) + yield* Fiber.join(fiber) + + const { db } = yield* Database.Service + expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all()).toMatchObject([ + { action: "read", resource: "src/*" }, + ]) + const saved = yield* PermissionSaved.Service + const id = (yield* saved.list())[0]!.id + expect(yield* saved.list()).toEqual([ + { id, projectID: Project.ID.global, action: "read", resource: "src/*" }, + ]) + yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] })) + yield* saved.remove(id) + expect(yield* saved.list()).toEqual([]) + }), + ) +}) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 9dba3445b..57a66f89a 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Config } from "@/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Provider } from "@/provider/provider" @@ -36,7 +37,7 @@ export const Info = Schema.Struct({ topP: Schema.optional(Schema.Finite), temperature: Schema.optional(Schema.Finite), color: Schema.optional(Schema.String), - permission: Permission.Ruleset, + permission: PermissionLegacy.Ruleset, model: Schema.optional( Schema.Struct({ modelID: ProviderV2.ModelID, diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts index 051f42e37..5daaa8026 100644 --- a/packages/opencode/src/agent/subagent-permissions.ts +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import type { Permission } from "../permission" import type { Agent } from "./agent" @@ -15,10 +16,10 @@ import type { Agent } from "./agent" * doesn't already permit them. */ export function deriveSubagentSessionPermission(input: { - parentSessionPermission: Permission.Ruleset + parentSessionPermission: PermissionLegacy.Ruleset parentAgent: Agent.Info | undefined subagent: Agent.Info -}): Permission.Ruleset { +}): PermissionLegacy.Ruleset { const canTask = input.subagent.permission.some((rule) => rule.permission === "task") const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") const parentAgentDenies = diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 0c310474e..6eea845ec 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { EOL } from "os" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { basename } from "path" @@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio abort: new AbortController().signal, messages: [], metadata: () => Effect.void, - ask(req: Omit) { + ask(req: Omit) { return Effect.sync(() => { for (const pattern of req.patterns) { const rule = Permission.evaluate(req.permission, pattern, ruleset) if (rule.action === "deny") { - throw new Permission.DeniedError({ ruleset }) + throw new PermissionLegacy.DeniedError({ ruleset }) } } }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index cdbf4562d..a1014500f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" // CLI entry point for `opencode run`. // // Handles three modes: @@ -367,7 +368,7 @@ export const RunCommand = effectCmd({ process.exit(1) } - const rules: Permission.Ruleset = args.interactive + const rules: PermissionLegacy.Ruleset = args.interactive ? [] : [ { diff --git a/packages/opencode/src/permission/evaluate.ts b/packages/opencode/src/permission/evaluate.ts index 6fd0576e9..a7fc5e170 100644 --- a/packages/opencode/src/permission/evaluate.ts +++ b/packages/opencode/src/permission/evaluate.ts @@ -1 +1 @@ -export { evaluate } from "@opencode-ai/core/permission" +export { evaluate } from "." diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 220abc834..f14e6efd2 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,142 +1,53 @@ import { ConfigPermission } from "@/config/permission" import { InstanceState } from "@/effect/instance-state" -import { ProjectV2 } from "@opencode-ai/core/project" -import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@opencode-ai/core/session/sql" -import { Database } from "@opencode-ai/core/database/database" -import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Wildcard } from "@opencode-ai/core/util/wildcard" -import { Deferred, Effect, Layer, Schema, Context } from "effect" +import { Deferred, Effect, Layer, Context } from "effect" import os from "os" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { PermissionID } from "./schema" +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "permission" }) -export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" }) -export type Action = Schema.Schema.Type - -export const Rule = Schema.Struct({ - permission: Schema.String, - pattern: Schema.String, - action: Action, -}).annotate({ identifier: "PermissionRule" }) -export type Rule = Schema.Schema.Type - -export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" }) -export type Ruleset = Schema.Schema.Type - -// Pure data; nothing checks class identity. As `Schema.Struct` + type alias, -// `Permission.ask` can trust its already-typed input and skip the inner -// `decodeUnknownSync` that would otherwise throw uncaught on any structural -// mismatch. Same pattern as `Question.Request` in PR #28570. -export const Request = Schema.Struct({ - id: PermissionID, - sessionID: SessionID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.optional( - Schema.Struct({ - messageID: MessageID, - callID: Schema.String, - }), - ), -}).annotate({ identifier: "PermissionRequest" }) -export type Request = Schema.Schema.Type - -export const Reply = Schema.Literals(["once", "always", "reject"]) -export type Reply = Schema.Schema.Type - -const reply = { - reply: Reply, - message: Schema.optional(Schema.String), -} - -export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" }) -export type ReplyBody = Schema.Schema.Type - -export const Approval = Schema.Struct({ - projectID: ProjectV2.ID, - patterns: Schema.Array(Schema.String), -}).annotate({ identifier: "PermissionApproval" }) -export type Approval = Schema.Schema.Type - export const Event = { - Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }), + Asked: EventV2.define({ type: "permission.asked", schema: PermissionLegacy.Request.fields }), Replied: EventV2.define({ type: "permission.replied", schema: { - sessionID: SessionID, - requestID: PermissionID, - reply: Reply, + sessionID: PermissionLegacy.Request.fields.sessionID, + requestID: PermissionLegacy.ID, + reply: PermissionLegacy.Reply, }, }), } -export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } -} - -export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { - feedback: Schema.String, -}) { - override get message() { - return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` - } -} - -export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { - ruleset: Schema.Any, -}) { - override get message() { - return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` - } -} - -export class NotFoundError extends Schema.TaggedErrorClass()("Permission.NotFoundError", { - requestID: PermissionID, -}) {} - -export type Error = DeniedError | RejectedError | CorrectedError - -export const AskInput = Schema.Struct({ - ...Request.fields, - id: Schema.optional(PermissionID), - ruleset: Ruleset, -}).annotate({ identifier: "PermissionAskInput" }) -export type AskInput = Schema.Schema.Type - -export const ReplyInput = Schema.Struct({ - requestID: PermissionID, - ...reply, -}).annotate({ identifier: "PermissionReplyInput" }) -export type ReplyInput = Schema.Schema.Type - export interface Interface { - readonly ask: (input: AskInput) => Effect.Effect - readonly reply: (input: ReplyInput) => Effect.Effect - readonly list: () => Effect.Effect> + readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect + readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect + readonly list: () => Effect.Effect> } interface PendingEntry { - info: Request - deferred: Deferred.Deferred + info: PermissionLegacy.Request + deferred: Deferred.Deferred } interface State { - pending: Map - approved: Rule[] + pending: Map + approved: PermissionLegacy.Rule[] } -export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - return PermissionV2.evaluate(permission, pattern, ...rulesets) +export function evaluate(permission: string, pattern: string, ...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule { + return ( + rulesets + .flat() + .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { + action: "ask", + permission, + pattern: "*", + } + ) } export class Service extends Context.Service()("@opencode/Permission") {} @@ -145,24 +56,18 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service - const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - const row = yield* db - .select() - .from(PermissionTable) - .where(eq(PermissionTable.project_id, ctx.project.id)) - .get() - .pipe(Effect.orDie) + void ctx const state = { - pending: new Map(), - approved: [...(row?.data ?? [])], + pending: new Map(), + approved: [], } yield* Effect.addFinalizer(() => Effect.gen(function* () { for (const item of state.pending.values()) { - yield* Deferred.fail(item.deferred, new RejectedError()) + yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError()) } state.pending.clear() }), @@ -172,7 +77,7 @@ export const layer = Layer.effect( }), ) - const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { + const ask = Effect.fn("Permission.ask")(function* (input: PermissionLegacy.AskInput) { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false @@ -181,7 +86,7 @@ export const layer = Layer.effect( const rule = evaluate(request.permission, pattern, ruleset, approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") { - return yield* new DeniedError({ + return yield* new PermissionLegacy.DeniedError({ ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), }) } @@ -191,8 +96,8 @@ export const layer = Layer.effect( if (!needsAsk) return - const id = request.id ?? PermissionID.ascending() - const info: Request = { + const id = request.id ?? PermissionLegacy.ID.ascending() + const info: PermissionLegacy.Request = { id, sessionID: request.sessionID, permission: request.permission, @@ -203,7 +108,7 @@ export const layer = Layer.effect( } log.info("asking", { id, permission: info.permission, patterns: info.patterns }) - const deferred = yield* Deferred.make() + const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( @@ -214,10 +119,10 @@ export const layer = Layer.effect( ) }) - const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { + const reply = Effect.fn("Permission.reply")(function* (input: PermissionLegacy.ReplyInput) { const { approved, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) - if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + if (!existing) return yield* new PermissionLegacy.NotFoundError({ requestID: input.requestID }) pending.delete(input.requestID) yield* events.publish(Event.Replied, { @@ -229,7 +134,7 @@ export const layer = Layer.effect( if (input.reply === "reject") { yield* Deferred.fail( existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + input.message ? new PermissionLegacy.CorrectedError({ feedback: input.message }) : new PermissionLegacy.RejectedError(), ) for (const [id, item] of pending.entries()) { @@ -240,7 +145,7 @@ export const layer = Layer.effect( requestID: item.info.id, reply: "reject", }) - yield* Deferred.fail(item.deferred, new RejectedError()) + yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError()) } return } @@ -290,7 +195,7 @@ function expand(pattern: string): string { } export function fromConfig(permission: ConfigPermission.Info) { - const ruleset: Rule[] = [] + const ruleset: PermissionLegacy.Rule[] = [] for (const [key, value] of Object.entries(permission)) { if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) @@ -303,14 +208,21 @@ export function fromConfig(permission: ConfigPermission.Info) { return ruleset } -export function merge(...rulesets: Ruleset[]): Rule[] { - return [...PermissionV2.merge(...rulesets)] +export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] { + return rulesets.flat() } -export function disabled(tools: string[], ruleset: Ruleset): Set { - return PermissionV2.disabled(tools, ruleset) +export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set { + const edits = ["edit", "write", "apply_patch"] + return new Set( + tools.filter((tool) => { + const permission = edits.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + return rule?.pattern === "*" && rule.action === "deny" + }), + ) } -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as Permission from "." diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts deleted file mode 100644 index 58ef0a8a7..000000000 --- a/packages/opencode/src/permission/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Schema } from "effect" - -import { Identifier } from "@/id/id" -import { Newtype } from "@opencode-ai/core/schema" - -export class PermissionID extends Newtype()( - "PermissionID", - Schema.String.check(Schema.isStartsWith("per")), -) { - static ascending(id?: string): PermissionID { - return this.make(Identifier.ascending("permission", id)) - } -} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 5712d1fc1..e2dd4a5b4 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,7 +1,7 @@ import { and, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" @@ -86,10 +86,6 @@ export function fromRow(row: Row): Info { } } -function mergePermissionRules(oldRules: T, newRules: T): T { - return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T -} - export const UpdateInput = Schema.Struct({ projectID: ProjectV2.ID, name: Schema.optional(Schema.String), @@ -201,36 +197,6 @@ export const layer = Layer.effect( .run() } - const oldPermission = yield* d - .select() - .from(PermissionTable) - .where(eq(PermissionTable.project_id, oldID)) - .get() - const newPermission = yield* d - .select() - .from(PermissionTable) - .where(eq(PermissionTable.project_id, newID)) - .get() - if (oldPermission && newPermission) { - yield* d - .update(PermissionTable) - .set({ - data: mergePermissionRules(oldPermission.data, newPermission.data), - time_created: Math.min(oldPermission.time_created, newPermission.time_created), - time_updated: Date.now(), - }) - .where(eq(PermissionTable.project_id, newID)) - .run() - yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run() - } - if (oldPermission && !newPermission) { - yield* d - .update(PermissionTable) - .set({ project_id: newID }) - .where(eq(PermissionTable.project_id, oldID)) - .run() - } - yield* d .update(SessionTable) .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 103d7aa24..592dd2043 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -1,5 +1,5 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { PermissionNotFoundError } from "../errors" @@ -10,7 +10,7 @@ import { described } from "./metadata" const root = "/permission" const ReplyPayload = Schema.Struct({ - reply: Permission.Reply, + reply: PermissionLegacy.Reply, message: Schema.optional(Schema.String), }) @@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission") .add( HttpApiEndpoint.get("list", root, { query: WorkspaceRoutingQuery, - success: described(Schema.Array(Permission.Request), "List of pending permissions"), + success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ identifier: "permission.list", @@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission") }), ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { - params: { requestID: PermissionID }, + params: { requestID: PermissionLegacy.ID }, query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index c648572b6..8345623b1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -1,6 +1,6 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Permission } from "@/permission" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { PermissionID } from "@/permission/schema" import { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" @@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ title: Schema.optional(Schema.String), metadata: Schema.optional(Session.Metadata), - permission: Schema.optional(Permission.Ruleset), + permission: Schema.optional(PermissionLegacy.Ruleset), time: Schema.optional( Schema.Struct({ archived: Schema.optional(Session.ArchivedTimestamp), @@ -71,7 +71,7 @@ export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInp export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])) export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])) export const PermissionResponsePayload = Schema.Struct({ - response: Permission.Reply, + response: PermissionLegacy.Reply, }) export const SessionPaths = { @@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { - params: { sessionID: SessionID, permissionID: PermissionID }, + params: { sessionID: SessionID, permissionID: PermissionLegacy.ID }, query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index 532ccce51..d65e0a380 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message" import { ModelGroup } from "./v2/model" import { ProviderGroup } from "./v2/provider" import { SessionGroup } from "./v2/session" +import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission" export const V2Api = HttpApi.make("v2") .add(SessionGroup) .add(MessageGroup) .add(ModelGroup) .add(ProviderGroup) + .add(PermissionGroup) + .add(SessionPermissionGroup) + .add(PermissionSavedGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index c9b21b5ad..6f1e47bca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,6 +1,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect, Layer, Schema } from "effect" @@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({ export class V2LocationMiddleware extends HttpApiMiddleware.Service< V2LocationMiddleware, { - provides: Catalog.Service | PluginBoot.Service + provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service } >()("@opencode/ExperimentalHttpApiV2Location") {} @@ -59,4 +60,4 @@ export const layer = Layer.effect( }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts new file mode 100644 index 000000000..0dd518b46 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts @@ -0,0 +1,90 @@ +import { PermissionV2 } from "@opencode-ai/core/permission" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { ProjectV2 } from "@opencode-ai/core/project" +import { SessionV2 } from "@opencode-ai/core/session" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" +import { V2Authorization } from "../../middleware/authorization" +import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" + +export const PermissionGroup = HttpApiGroup.make("v2.permission") + .add( + HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { + query: LocationQuery, + success: Schema.Array(PermissionV2.Request), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.request.list", + summary: "List pending permission requests", + description: "Retrieve pending permission requests for a location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." })) + .middleware(V2LocationMiddleware) + .middleware(V2Authorization) + +export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission") + .add( + HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", { + params: { sessionID: SessionV2.ID }, + success: Schema.Array(PermissionV2.Request), + error: SessionNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", { + params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID }, + payload: Schema.Struct({ + reply: PermissionV2.Reply, + message: Schema.String.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, PermissionNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.permission.reply", + summary: "Reply to pending permission request", + description: "Respond to a pending permission request owned by a session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." })) + .middleware(V2Authorization) + +export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved") + .add( + HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", { + query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), + success: Schema.Array(PermissionSaved.Info), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.list", + summary: "List saved permissions", + description: "Retrieve saved permissions, optionally filtered by project.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", { + params: { id: PermissionSaved.ID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.permission.saved.remove", + summary: "Remove saved permission", + description: "Remove a saved permission by ID.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." })) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts index 281e78e3c..17b4cc9ab 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -1,5 +1,5 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" @@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss }) const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody + params: { requestID: PermissionLegacy.ID } + payload: PermissionLegacy.ReplyBody }) { yield* svc .reply({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 773fb4123..bb18b34b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,9 +1,9 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Agent } from "@/agent/agent" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { Command } from "@/command" import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { - params: { sessionID: SessionID; permissionID: PermissionID } + params: { sessionID: SessionID; permissionID: PermissionLegacy.ID } payload: typeof PermissionResponsePayload.Type }) { yield* requireSession(ctx.params.sessionID) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index 0514ea56a..d9f1bb7b8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -1,12 +1,25 @@ import { SessionV2 } from "@opencode-ai/core/session" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Layer } from "effect" import { layer as v2LocationLayer } from "../groups/v2/location" import { messageHandlers } from "./v2/message" import { modelHandlers } from "./v2/model" import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" +import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission" -export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe( +export const v2Handlers = Layer.mergeAll( + sessionHandlers, + messageHandlers, + modelHandlers, + providerHandlers, + permissionHandlers, + sessionPermissionHandlers, + savedPermissionHandlers, +).pipe( Layer.provide(v2LocationLayer), + Layer.provide(LocationServiceMap.layer), + Layer.provide(PermissionSaved.layer), Layer.provide(SessionV2.defaultLayer), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts new file mode 100644 index 000000000..8808042a1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts @@ -0,0 +1,105 @@ +import { Database } from "@opencode-ai/core/database/database" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { eq } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" +import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" + +function missingRequest(id: PermissionV2.ID) { + return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) +} + +export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) => + Effect.gen(function* () { + return handlers.handle( + "permissionRequests", + Effect.fn(function* () { + return yield* (yield* PermissionV2.Service).list() + }), + ) + }), +) + +export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) => + Effect.gen(function* () { + const { db } = yield* Database.Service + const locations = yield* LocationServiceMap + + const withSessionPermission = Effect.fnUntraced(function* ( + sessionID: Parameters[0], + use: (permission: PermissionV2.Interface) => Effect.Effect, + ) { + const row = yield* db + .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) + return yield* new SessionNotFoundError({ + sessionID, + message: `Session not found: ${sessionID}`, + }) + + return yield* Effect.gen(function* () { + return yield* use(yield* PermissionV2.Service) + }).pipe( + Effect.scoped, + Effect.provide( + locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }), + ), + ) + }) + + return handlers + .handle( + "sessionPermissionRequests", + Effect.fn(function* (ctx) { + return yield* withSessionPermission(ctx.params.sessionID, (permission) => + permission.forSession(ctx.params.sessionID), + ) + }), + ) + .handle( + "permissionRequestReply", + Effect.fn(function* (ctx) { + yield* withSessionPermission(ctx.params.sessionID, (permission) => + Effect.gen(function* () { + const request = yield* permission.get(ctx.params.requestID) + if (!request || request.sessionID !== ctx.params.sessionID) + return yield* missingRequest(ctx.params.requestID) + yield* permission + .reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message }) + .pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID))) + }), + ) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) + +export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) => + Effect.gen(function* () { + const saved = yield* PermissionSaved.Service + return handlers + .handle( + "savedPermissions", + Effect.fn(function* (ctx) { + return yield* saved.list({ projectID: ctx.query.projectID }) + }), + ) + .handle( + "removeSavedPermission", + Effect.fn(function* (ctx) { + yield* saved.remove(ctx.params.id) + return HttpApiSchema.NoContent.make() + }), + ) + }), +) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 1851df2d7..ae790a50f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Provider } from "@/provider/provider" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -15,7 +16,6 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Wildcard } from "@/util/wildcard" @@ -38,7 +38,7 @@ export type StreamInput = { parentSessionID?: string model: Provider.Model agent: Agent.Info - permission?: Permission.Ruleset + permission?: PermissionLegacy.Ruleset system: string[] messages: ModelMessage[] small?: boolean @@ -165,7 +165,7 @@ const live: Layer.Layer< return { approved: true } } - const id = PermissionID.ascending() + const id = PermissionLegacy.ID.ascending() let unsub: EventV2.Unsubscribe | undefined try { unsub = await bridge.promise( diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 60847dab3..92640c7ba 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import type { Auth } from "@/auth" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -22,7 +23,7 @@ type PrepareInput = { readonly parentSessionID?: string readonly model: Provider.Model readonly agent: Agent.Info - readonly permission?: Permission.Ruleset + readonly permission?: PermissionLegacy.Ruleset readonly system: string[] readonly messages: ModelMessage[] readonly small?: boolean diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8f9b83a79..081df9e8f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Image } from "@/image/image" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" @@ -204,7 +205,7 @@ export const layer = Layer.effect( time: { start: match.part.state.time.start, end: Date.now() }, }, }) - if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { + if (error instanceof PermissionLegacy.RejectedError || error instanceof Question.RejectedError) { ctx.blocked = ctx.shouldBreak } yield* settleToolCall(toolCallID) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e7c6a6236..6ae1028f1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import path from "path" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import os from "os" @@ -1220,7 +1221,7 @@ export const layer = Layer.effect( const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) - const permissions: Permission.Rule[] = [] + const permissions: PermissionLegacy.Rule[] = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) } diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index fde7100b1..3371652b0 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Slug } from "@opencode-ai/core/util/slug" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -233,7 +234,7 @@ export const Info = Schema.Struct({ version: Schema.String, metadata: optionalOmitUndefined(Metadata), time: Time, - permission: optionalOmitUndefined(Permission.Ruleset), + permission: optionalOmitUndefined(PermissionLegacy.Ruleset), revert: optionalOmitUndefined(Revert), }).annotate({ identifier: "Session" }) export type Info = Types.DeepMutable> @@ -258,7 +259,7 @@ export const CreateInput = Schema.optional( agent: Schema.optional(Schema.String), model: Schema.optional(Model), metadata: Schema.optional(Metadata), - permission: Schema.optional(Permission.Ruleset), + permission: Schema.optional(PermissionLegacy.Ruleset), workspaceID: Schema.optional(WorkspaceV2.ID), }), ) @@ -282,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({ }) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, - permission: Permission.Ruleset, + permission: PermissionLegacy.Ruleset, }) export const SetRevertInput = Schema.Struct({ sessionID: SessionID, @@ -348,7 +349,7 @@ const UpdatedInfo = Schema.Struct({ version: Schema.optional(Schema.NullOr(Schema.String)), metadata: Schema.optional(Schema.NullOr(Metadata)), time: Schema.optional(UpdatedTime), - permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), + permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)), revert: Schema.optional(Schema.NullOr(Revert)), }) @@ -472,7 +473,7 @@ export interface Interface { agent?: string model?: Schema.Schema.Type metadata?: typeof Metadata.Type - permission?: Permission.Ruleset + permission?: PermissionLegacy.Ruleset workspaceID?: WorkspaceV2.ID }) => Effect.Effect readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect @@ -481,7 +482,7 @@ export interface Interface { readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect - readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect + readonly setPermission: (input: { sessionID: SessionID; permission: PermissionLegacy.Ruleset }) => Effect.Effect readonly setRevert: (input: { sessionID: SessionID revert: Info["revert"] @@ -570,7 +571,7 @@ export const layer: Layer.Layer< directory: string path?: string metadata?: typeof Metadata.Type - permission?: Permission.Ruleset + permission?: PermissionLegacy.Ruleset }) { const ctx = yield* InstanceState.context const result: Info = { @@ -748,7 +749,7 @@ export const layer: Layer.Layer< agent?: string model?: Schema.Schema.Type metadata?: typeof Metadata.Type - permission?: Permission.Ruleset + permission?: PermissionLegacy.Ruleset workspaceID?: WorkspaceV2.ID }) { const ctx = yield* InstanceState.context @@ -842,7 +843,7 @@ export const layer: Layer.Layer< const setPermission = Effect.fn("Session.setPermission")(function* (input: { sessionID: SessionID - permission: Permission.Ruleset + permission: PermissionLegacy.Ruleset }) { yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe( Effect.orDie, diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 00a10e6d9..9dd88054f 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionShareTable } from "@opencode-ai/core/share/sql" import path from "path" import { existsSync } from "fs" @@ -108,13 +108,12 @@ export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase | NodeSQLiteDatabase | NodeSQLiteDatabase | NodeSQLiteDatabase path.basename(file, ".json")) - const permValues: unknown[] = [] - for (let i = 0; i < permFiles.length; i += batchSize) { - const end = Math.min(i + batchSize, permFiles.length) - const batch = await read(permFiles, i, end) - permValues.length = 0 - for (let j = 0; j < batch.length; j++) { - const data = batch[j] - if (!data) continue - const projectID = permProjects[i + j] - if (!projectIds.has(projectID)) { - orphans.permissions++ - continue - } - permValues.push({ project_id: projectID, data }) - } - stats.permissions += insert(permValues, PermissionTable, "permission") - step("permissions", end - i) - } - log.info("migrated permissions", { count: stats.permissions }) - if (orphans.permissions > 0) { - log.warn("skipped orphaned permissions", { count: orphans.permissions }) - } - // Migrate session shares const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) const shareValues: unknown[] = [] diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 01d47fcb5..06d095f06 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" export { ProjectTable } from "@opencode-ai/core/project/sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql" export { SessionShareTable } from "@opencode-ai/core/share/sql" export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4edbec94c..5b4a28380 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Effect, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import type { JSONSchema7 } from "@ai-sdk/provider" @@ -41,7 +42,7 @@ export type Context = { extra?: { [key: string]: unknown } messages: SessionLegacy.WithParts[] metadata(input: { title?: string; metadata?: M }): Effect.Effect - ask(input: Omit): Effect.Effect + ask(input: Omit): Effect.Effect } export interface ExecuteResult { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index e0defc138..660656caa 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -9,6 +9,7 @@ import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Global } from "@opencode-ai/core/global" import { Permission } from "../../src/permission" +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" @@ -28,7 +29,7 @@ const it = testEffect(agentLayer()) const scout = testEffect(agentLayer({ experimentalScout: true })) // Helper to evaluate permission for a tool with wildcard pattern -function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { +function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined { if (!agent) return undefined return Permission.evaluate(permission, "*", agent.permission).action } diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index 07fb9a64d..df8be6b6b 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" /** * Reproducer for opencode issue #26514: * @@ -60,7 +61,7 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric // session's `permission` field is empty (Plan Mode lives on the agent // ruleset, not the session). So we pass [] through as the parent // session permission, exactly like the actual code path. - const parentSessionPermission: Permission.Ruleset = [] + const parentSessionPermission: PermissionLegacy.Ruleset = [] const subagentSessionPermission = deriveSubagentSessionPermission({ parentSessionPermission, @@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o expect(planAgent).toBeDefined() expect(explore).toBeDefined() - const parentSessionPermission: Permission.Ruleset = [] + const parentSessionPermission: PermissionLegacy.Ruleset = [] const subagentSessionPermission = deriveSubagentSessionPermission({ parentSessionPermission, parentAgent: planAgent, @@ -113,7 +114,7 @@ it.instance( expect(planAgent).toBeDefined() expect(my).toBeDefined() - const parentSessionPermission: Permission.Ruleset = [] + const parentSessionPermission: PermissionLegacy.Ruleset = [] const subagentSessionPermission = deriveSubagentSessionPermission({ parentSessionPermission, parentAgent: planAgent, diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 1ee8b5488..adac2ca68 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { describe, test, expect } from "bun:test" import { Effect } from "effect" import { Permission } from "../src/permission" @@ -9,7 +10,7 @@ const it = testEffect(Config.defaultLayer) const load = Config.use.get() describe("Permission.evaluate for permission.task", () => { - const createRuleset = (rules: Record): Permission.Ruleset => + const createRuleset = (rules: Record): PermissionLegacy.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, @@ -75,7 +76,7 @@ describe("Permission.disabled for task tool", () => { // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. - const createRuleset = (rules: Record): Permission.Ruleset => + const createRuleset = (rules: Record): PermissionLegacy.Ruleset => Object.entries(rules).map(([pattern, action]) => ({ permission: "task", pattern, diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index a6d7e2ead..f26dba2d7 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { test, expect } from "bun:test" import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" @@ -5,7 +6,6 @@ import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database } from "@opencode-ai/core/database/database" import { Permission } from "../../src/permission" -import { PermissionID } from "../../src/permission/schema" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { TestInstance, tmpdirScoped } from "../fixture/fixture" @@ -261,8 +261,8 @@ test("merge - preserves rule order", () => { }) test("merge - config permission overrides default ask", () => { - const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] - const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] + const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const merged = Permission.merge(defaults, config) expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") @@ -270,8 +270,8 @@ test("merge - config permission overrides default ask", () => { }) test("merge - config ask overrides default allow", () => { - const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] + const defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const merged = Permission.merge(defaults, config) expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") @@ -443,8 +443,8 @@ test("evaluate - later wildcard permission can override earlier specific permiss }) test("evaluate - merges multiple rulesets", () => { - const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] - const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] + const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] + const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] const result = Permission.evaluate("bash", "rm", config, approved) expect(result.action).toBe("deny") }) @@ -588,7 +588,7 @@ it.instance( ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), ) - expect(err).toBeInstanceOf(Permission.DeniedError) + expect(err).toBeInstanceOf(PermissionLegacy.DeniedError) }), { git: true }, ) @@ -655,10 +655,10 @@ it.instance( () => Effect.gen(function* () { const events = yield* EventV2Bridge.Service - const seen = yield* Deferred.make() + const seen = yield* Deferred.make() const unsub = yield* events.listen((event) => { if (event.type === Permission.Event.Asked.type) - Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) + Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionLegacy.Request)) return Effect.void }) yield* Effect.addFinalizer(() => unsub) @@ -703,7 +703,7 @@ it.instance( () => Effect.gen(function* () { const fiber = yield* ask({ - id: PermissionID.make("per_test1"), + id: PermissionLegacy.ID.make("per_test1"), sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -713,7 +713,7 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(1) - yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test1"), reply: "once" }) yield* Fiber.join(fiber) }), { git: true }, @@ -724,7 +724,7 @@ it.instance( () => Effect.gen(function* () { const fiber = yield* ask({ - id: PermissionID.make("per_test2"), + id: PermissionLegacy.ID.make("per_test2"), sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -734,11 +734,11 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(1) - yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test2"), reply: "reject" }) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) }), { git: true }, ) @@ -748,7 +748,7 @@ it.instance( () => Effect.gen(function* () { const fiber = yield* ask({ - id: PermissionID.make("per_test2b"), + id: PermissionLegacy.ID.make("per_test2b"), sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -759,7 +759,7 @@ it.instance( yield* waitForPending(1) yield* reply({ - requestID: PermissionID.make("per_test2b"), + requestID: PermissionLegacy.ID.make("per_test2b"), reply: "reject", message: "Use a safer command", }) @@ -768,7 +768,7 @@ it.instance( expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const err = Cause.squash(exit.cause) - expect(err).toBeInstanceOf(Permission.CorrectedError) + expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError) expect(String(err)).toContain("Use a safer command") } }), @@ -780,7 +780,7 @@ it.instance( () => Effect.gen(function* () { const fiber = yield* ask({ - id: PermissionID.make("per_test3"), + id: PermissionLegacy.ID.make("per_test3"), sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -790,7 +790,7 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(1) - yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test3"), reply: "always" }) yield* Fiber.join(fiber) const result = yield* ask({ @@ -811,7 +811,7 @@ it.instance( () => Effect.gen(function* () { const a = yield* ask({ - id: PermissionID.make("per_test4a"), + id: PermissionLegacy.ID.make("per_test4a"), sessionID: SessionID.make("session_same"), permission: "bash", patterns: ["ls"], @@ -821,7 +821,7 @@ it.instance( }).pipe(Effect.forkScoped) const b = yield* ask({ - id: PermissionID.make("per_test4b"), + id: PermissionLegacy.ID.make("per_test4b"), sessionID: SessionID.make("session_same"), permission: "edit", patterns: ["foo.ts"], @@ -831,13 +831,13 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(2) - yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test4a"), reply: "reject" }) const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) expect(Exit.isFailure(ea)).toBe(true) expect(Exit.isFailure(eb)).toBe(true) - if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError) - if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) + if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) }), { git: true }, ) @@ -847,7 +847,7 @@ it.instance( () => Effect.gen(function* () { const a = yield* ask({ - id: PermissionID.make("per_test5a"), + id: PermissionLegacy.ID.make("per_test5a"), sessionID: SessionID.make("session_same"), permission: "bash", patterns: ["ls"], @@ -857,7 +857,7 @@ it.instance( }).pipe(Effect.forkScoped) const b = yield* ask({ - id: PermissionID.make("per_test5b"), + id: PermissionLegacy.ID.make("per_test5b"), sessionID: SessionID.make("session_same"), permission: "bash", patterns: ["ls"], @@ -867,7 +867,7 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(2) - yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test5a"), reply: "always" }) yield* Fiber.join(a) yield* Fiber.join(b) @@ -881,7 +881,7 @@ it.instance( () => Effect.gen(function* () { const a = yield* ask({ - id: PermissionID.make("per_test6a"), + id: PermissionLegacy.ID.make("per_test6a"), sessionID: SessionID.make("session_a"), permission: "bash", patterns: ["ls"], @@ -891,7 +891,7 @@ it.instance( }).pipe(Effect.forkScoped) const b = yield* ask({ - id: PermissionID.make("per_test6b"), + id: PermissionLegacy.ID.make("per_test6b"), sessionID: SessionID.make("session_b"), permission: "bash", patterns: ["ls"], @@ -901,10 +901,10 @@ it.instance( }).pipe(Effect.forkScoped) yield* waitForPending(2) - yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test6a"), reply: "always" }) yield* Fiber.join(a) - expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) + expect((yield* list()).map((item) => item.id)).toEqual([PermissionLegacy.ID.make("per_test6b")]) yield* rejectAll() yield* Fiber.await(b) @@ -917,10 +917,10 @@ it.instance( () => Effect.gen(function* () { const events = yield* EventV2Bridge.Service - const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>() + const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }>() const fiber = yield* ask({ - id: PermissionID.make("per_test7"), + id: PermissionLegacy.ID.make("per_test7"), sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -935,13 +935,13 @@ it.instance( if (event.type === Permission.Event.Replied.type) Deferred.doneUnsafe( seen, - Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }), + Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }), ) return Effect.void }) yield* Effect.addFinalizer(() => unsub) - yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) + yield* reply({ requestID: PermissionLegacy.ID.make("per_test7"), reply: "once" }) yield* Fiber.join(fiber) expect( yield* Deferred.await(seen).pipe( @@ -952,7 +952,7 @@ it.instance( ), ).toEqual({ sessionID: SessionID.make("session_test"), - requestID: PermissionID.make("per_test7"), + requestID: PermissionLegacy.ID.make("per_test7"), reply: "once", }) }), @@ -969,7 +969,7 @@ it.live("permission requests stay isolated by directory", () => .provide( { directory: one }, ask({ - id: PermissionID.make("per_dir_a"), + id: PermissionLegacy.ID.make("per_dir_a"), sessionID: SessionID.make("session_dir_a"), permission: "bash", patterns: ["ls"], @@ -984,7 +984,7 @@ it.live("permission requests stay isolated by directory", () => .provide( { directory: two }, ask({ - id: PermissionID.make("per_dir_b"), + id: PermissionLegacy.ID.make("per_dir_b"), sessionID: SessionID.make("session_dir_b"), permission: "bash", patterns: ["pwd"], @@ -1000,8 +1000,8 @@ it.live("permission requests stay isolated by directory", () => expect(onePending).toHaveLength(1) expect(twoPending).toHaveLength(1) - expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) - expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) + expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a")) + expect(twoPending[0].id).toBe(PermissionLegacy.ID.make("per_dir_b")) yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" })) yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" })) @@ -1018,7 +1018,7 @@ it.instance( const test = yield* TestInstance const store = yield* InstanceStore.Service const fiber = yield* ask({ - id: PermissionID.make("per_dispose"), + id: PermissionLegacy.ID.make("per_dispose"), sessionID: SessionID.make("session_dispose"), permission: "bash", patterns: ["ls"], @@ -1033,7 +1033,7 @@ it.instance( const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) }), { git: true }, ) @@ -1045,7 +1045,7 @@ it.instance( const test = yield* TestInstance const store = yield* InstanceStore.Service const fiber = yield* ask({ - id: PermissionID.make("per_reload"), + id: PermissionLegacy.ID.make("per_reload"), sessionID: SessionID.make("session_reload"), permission: "bash", patterns: ["ls"], @@ -1059,7 +1059,7 @@ it.instance( const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) }), { git: true }, ) @@ -1068,7 +1068,7 @@ it.instance( "reply - fails for unknown requestID", () => Effect.gen(function* () { - const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit) + const exit = yield* reply({ requestID: PermissionLegacy.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" }) @@ -1095,7 +1095,7 @@ it.instance( ], }), ) - expect(err).toBeInstanceOf(Permission.DeniedError) + expect(err).toBeInstanceOf(PermissionLegacy.DeniedError) }), { git: true }, ) @@ -1135,7 +1135,7 @@ it.instance( }), ) - expect(err).toBeInstanceOf(Permission.DeniedError) + expect(err).toBeInstanceOf(PermissionLegacy.DeniedError) expect(yield* list()).toHaveLength(0) }), { git: true }, @@ -1149,7 +1149,7 @@ it.instance( const store = yield* InstanceStore.Service const fiber = yield* ask({ - id: PermissionID.make("per_reload"), + id: PermissionLegacy.ID.make("per_reload"), sessionID: SessionID.make("session_reload"), permission: "bash", patterns: ["ls"], @@ -1164,7 +1164,7 @@ it.instance( const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError) }), { git: true }, ) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index c10c42337..7a837ecf1 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -8,7 +8,7 @@ import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { Database } from "@opencode-ai/core/database/database" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { eq } from "drizzle-orm" import { Hash } from "@opencode-ai/core/util/hash" @@ -218,16 +218,6 @@ describe("Project.fromDirectory", () => { }) .run() .pipe(Effect.orDie) - yield* db - .insert(PermissionTable) - .values({ - project_id: rootProject.id, - data: [{ permission: "edit", pattern: "*", action: "allow" }], - time_created: Date.now(), - time_updated: Date.now(), - }) - .run() - .pipe(Effect.orDie) yield* db .insert(WorkspaceTable) .values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id }) @@ -245,14 +235,6 @@ describe("Project.fromDirectory", () => { (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)) ?.project_id, ).toBe(remoteID) - expect( - yield* db - .select() - .from(PermissionTable) - .where(eq(PermissionTable.project_id, remoteID)) - .get() - .pipe(Effect.orDie), - ).toBeDefined() expect( (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) ?.project_id, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index f2b132cb7..19c2aa4c1 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -579,6 +579,32 @@ const scenarios: Scenario[] = [ .get("/api/provider/{providerID}", "v2.provider.get") .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) .json(404, object, "status"), + http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array), + http.protected + .get("/api/session/{sessionID}/permission/request", "v2.session.permission.list") + .seeded((ctx) => ctx.session({ title: "Permission list owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, array), + http.protected + .post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply") + .seeded((ctx) => ctx.session({ title: "Permission owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/permission/request/{requestID}/reply", { + sessionID: ctx.state.id, + requestID: "per_httpapi_missing", + }), + headers: ctx.headers(), + body: { reply: "once" }, + })) + .json(404, object, "status"), + http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array), + http.protected + .delete("/api/permission/saved/{id}", "v2.permission.saved.remove") + .at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() })) + .status(204, undefined, "status"), http.protected .get("/api/session", "v2.session.list") .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 65bdfa7c5..9e2687a58 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" @@ -8,7 +9,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" -import { PermissionID } from "../../src/permission/schema" import { ProjectV2 } from "@opencode-ai/core/project" import { QuestionID } from "../../src/question/schema" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" @@ -167,7 +167,7 @@ describe("instance HttpApi", () => { handlerContext, ), ) - const permissionID = PermissionID.ascending() + const permissionID = PermissionLegacy.ID.ascending() const questionReplyID = QuestionID.ascending() const questionRejectID = QuestionID.ascending() const [permission, questionReply, questionReject] = yield* Effect.all( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7eac7d4f3..63ec95784 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { afterEach, describe, expect } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { SessionLegacy } from "@opencode-ai/core/session/legacy" @@ -11,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { PermissionID } from "../../src/permission/schema" import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" @@ -913,7 +913,7 @@ describe("session HttpApi", () => { }), ).toMatchObject({ id: session.id }) - const permissionID = String(PermissionID.ascending()) + const permissionID = String(PermissionLegacy.ID.ascending()) const permission = yield* request( pathFor(SessionPaths.permissions, { sessionID: session.id, diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4a465c2ab..6639aeaf8 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import path from "path" @@ -332,7 +333,7 @@ describe("session.llm.ai-sdk adapter", () => { }) test("preserves tool-error cause", async () => { - const error = new Permission.RejectedError() + const error = new PermissionLegacy.RejectedError() const events = await Effect.runPromise( LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), { type: "tool-error", diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 41d70d35d..7e3670753 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -10,7 +10,7 @@ import { Global } from "@opencode-ai/core/global" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" -import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql" +import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql" import { SessionShareTable } from "@opencode-ai/core/share/sql" import { SessionID, MessageID, PartID } from "../../src/session/schema" @@ -574,7 +574,7 @@ describe("JSON to SQLite migration", () => { expect(todos[2].position).toBe(2) }) - test("migrates permissions", async () => { + test("does not migrate legacy permissions", async () => { await writeProject(storageDir, { id: "proj_test123abc", worktree: "/", @@ -592,12 +592,7 @@ describe("JSON to SQLite migration", () => { const stats = await JsonMigration.run(db) - expect(stats?.permissions).toBe(1) - - const permissions = db.select().from(PermissionTable).all() - expect(permissions.length).toBe(1) - expect(permissions[0].project_id).toBe("proj_test123abc") - expect(permissions[0].data).toEqual(permissionData) + expect(stats?.permissions).toBe(0) }) test("migrates session shares", async () => { @@ -694,7 +689,7 @@ describe("JSON to SQLite migration", () => { expect(todos[1].position).toBe(2) }) - test("skips orphaned todos, permissions, and shares", async () => { + test("skips orphaned todos and shares", async () => { await writeProject(storageDir, { id: "proj_test123abc", worktree: "/", @@ -733,11 +728,10 @@ describe("JSON to SQLite migration", () => { const stats = await JsonMigration.run(db) expect(stats.todos).toBe(1) - expect(stats.permissions).toBe(1) + expect(stats.permissions).toBe(0) expect(stats.shares).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1) - expect(db.select().from(PermissionTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1) }) @@ -848,7 +842,7 @@ describe("JSON to SQLite migration", () => { expect(stats.messages).toBe(1) expect(stats.parts).toBe(1) expect(stats.todos).toBe(1) - expect(stats.permissions).toBe(1) + expect(stats.permissions).toBe(0) expect(stats.shares).toBe(1) expect(stats.errors.length).toBeGreaterThanOrEqual(6) @@ -857,7 +851,6 @@ describe("JSON to SQLite migration", () => { expect(db.select().from(MessageTable).all().length).toBe(1) expect(db.select().from(PartTable).all().length).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1) - expect(db.select().from(PermissionTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1) }) }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 06019001f..e3092eee6 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" @@ -26,7 +27,7 @@ const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") function makeCtx() { - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: (req) => diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index bfe9b75d4..159660ad5 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { describe, expect } from "bun:test" import path from "path" import { Cause, Effect, Exit, Layer } from "effect" @@ -52,12 +53,12 @@ const ctx = { } const asks = () => { - const items: Array> = [] + const items: Array> = [] return { items, next: { ...ctx, - ask: (req: Omit) => + ask: (req: Omit) => Effect.sync(() => { items.push(req) }), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a8cf5c9a3..68a4b159d 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { describe, expect } from "bun:test" import fs from "fs/promises" import os from "os" @@ -186,7 +187,7 @@ describe("tool.grep", () => { [path.join(alias, "*")]: "allow", }, }) - const requests: Array> = [] + const requests: Array> = [] const next: Tool.Context = { ...ctx, ask: (req) => @@ -234,7 +235,7 @@ describe("tool.grep", () => { yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) - const requests: Array> = [] + const requests: Array> = [] const next: Tool.Context = { ...ctx, ask: (req) => diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index c456ae6cc..a533c6038 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" @@ -83,12 +84,12 @@ const put = Effect.fn("LspToolTest.put")(function* (file: string) { }) const asks = () => { - const items: Array> = [] + const items: Array> = [] return { items, next: { ...ctx, - ask: (req: Omit) => + ask: (req: Omit) => Effect.sync(() => { items.push(req) }), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index b42bd80e7..9823ea534 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { afterEach, describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer, Stream } from "effect" import path from "path" @@ -140,12 +141,12 @@ const load = Effect.fn("ReadToolTest.load")(function* (p: string) { return yield* fs.readFileString(p) }) const asks = () => { - const items: Array> = [] + const items: Array> = [] return { items, next: { ...ctx, - ask: (req: Omit) => + ask: (req: Omit) => Effect.sync(() => { items.push(req) }), @@ -328,7 +329,7 @@ describe("tool.read env file permissions", () => { let asked = false const next = { ...ctx, - ask: (req: Omit) => + ask: (req: Omit) => Effect.sync(() => { for (const pattern of req.patterns) { const rule = Permission.evaluate(req.permission, pattern, info.permission) @@ -336,7 +337,7 @@ describe("tool.read env file permissions", () => { asked = true } if (rule.action === "deny") { - throw new Permission.DeniedError({ ruleset: info.permission }) + throw new PermissionLegacy.DeniedError({ ruleset: info.permission }) } } }), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index fb8f95882..08251f9de 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" @@ -155,9 +156,9 @@ const each = ( } } -const capture = (requests: Array>, stop?: Error) => ({ +const capture = (requests: Array>, stop?: Error) => ({ ...ctx, - ask: (req: Omit) => + ask: (req: Omit) => Effect.sync(() => { requests.push(req) if (stop) throw stop @@ -222,7 +223,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "echo hello", @@ -244,7 +245,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "echo foo && echo bar", @@ -268,7 +269,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", @@ -297,7 +298,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -323,7 +324,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" expect( @@ -354,7 +355,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/") - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: `echo $(cat "${file}")`, @@ -383,7 +384,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -409,7 +410,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` yield* run( { @@ -440,7 +441,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -468,7 +469,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -497,7 +498,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -525,7 +526,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -560,7 +561,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") expect( yield* fail( @@ -593,7 +594,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "Get-Content $env:WINDIR/win.ini", @@ -620,7 +621,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -649,7 +650,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -677,7 +678,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "Set-Location C:/Windows", @@ -705,7 +706,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "Write-Output ('a' * 3)", @@ -731,7 +732,7 @@ describe("tool.shell permissions", () => { runIn( projectRoot, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, @@ -755,7 +756,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -779,7 +780,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -810,7 +811,7 @@ describe("tool.shell permissions", () => { const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*")) for (const dir of forms(outerTmp)) { - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { @@ -842,7 +843,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) expect( yield* fail( @@ -871,7 +872,7 @@ describe("tool.shell permissions", () => { projectRoot, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] const want = glob(path.join(os.tmpdir(), "*")) expect( yield* fail( @@ -903,7 +904,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] const filepath = path.join(outerTmp, "outside.txt") expect( yield* fail( @@ -931,7 +932,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, @@ -952,7 +953,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "git log --oneline -5", @@ -974,7 +975,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run( { command: "cd .", @@ -996,7 +997,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const err = new Error("stop after permission") - const requests: Array> = [] + const requests: Array> = [] expect( yield* fail( { command: "echo test > output.txt", description: "Redirect test output" }, @@ -1017,7 +1018,7 @@ describe("tool.shell permissions", () => { yield* runIn( tmp, Effect.gen(function* () { - const requests: Array> = [] + const requests: Array> = [] yield* run({ command: "ls -la", description: "List" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 73f1ae180..3f4b0df2b 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,3 +1,4 @@ +import { PermissionLegacy } from "@opencode-ai/core/permission/legacy" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" @@ -67,7 +68,7 @@ Use this skill. })).find((tool) => tool.id === SkillTool.id) if (!tool) throw new Error("Skill tool not found") - const requests: Array> = [] + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, ask: (req) => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index be1e4abc6..d99cc4b18 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -117,6 +117,7 @@ import type { PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, + PermissionV2Reply, ProjectCurrentErrors, ProjectCurrentResponses, ProjectInitGitErrors, @@ -248,6 +249,12 @@ import type { TuiSubmitPromptResponses, V2ModelListErrors, V2ModelListResponses, + V2PermissionRequestListErrors, + V2PermissionRequestListResponses, + V2PermissionSavedListErrors, + V2PermissionSavedListResponses, + V2PermissionSavedRemoveErrors, + V2PermissionSavedRemoveResponses, V2ProviderGetErrors, V2ProviderGetResponses, V2ProviderListErrors, @@ -260,6 +267,10 @@ import type { V2SessionListResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, + V2SessionPermissionListErrors, + V2SessionPermissionListResponses, + V2SessionPermissionReplyErrors, + V2SessionPermissionReplyResponses, V2SessionPromptErrors, V2SessionPromptResponses, V2SessionWaitErrors, @@ -4255,6 +4266,74 @@ export class Sync extends HeyApiClient { } } +export class Permission2 extends HeyApiClient { + /** + * List session permission requests + * + * Retrieve pending permission requests owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionPermissionListResponses, + V2SessionPermissionListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/request", + ...options, + ...params, + }) + } + + /** + * Reply to pending permission request + * + * Respond to a pending permission request owned by a session. + */ + public reply( + parameters: { + sessionID: string + requestID: string + reply?: PermissionV2Reply + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionReplyResponses, + V2SessionPermissionReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/request/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Session3 extends HeyApiClient { /** * List v2 sessions @@ -4474,6 +4553,11 @@ export class Session3 extends HeyApiClient { ...params, }) } + + private _permission?: Permission2 + get permission(): Permission2 { + return (this._permission ??= new Permission2({ client: this.client })) + } } export class Model extends HeyApiClient { @@ -4557,6 +4641,94 @@ export class Provider2 extends HeyApiClient { } } +export class Request extends HeyApiClient { + /** + * List pending permission requests + * + * Retrieve pending permission requests for a location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2PermissionRequestListResponses, + V2PermissionRequestListErrors, + ThrowOnError + >({ + url: "/api/permission/request", + ...options, + ...params, + }) + } +} + +export class Saved extends HeyApiClient { + /** + * List saved permissions + * + * Retrieve saved permissions, optionally filtered by project. + */ + public list( + parameters?: { + projectID?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }]) + return (options?.client ?? this.client).get< + V2PermissionSavedListResponses, + V2PermissionSavedListErrors, + ThrowOnError + >({ + url: "/api/permission/saved", + ...options, + ...params, + }) + } + + /** + * Remove saved permission + * + * Remove a saved permission by ID. + */ + public remove( + parameters: { + id: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }]) + return (options?.client ?? this.client).delete< + V2PermissionSavedRemoveResponses, + V2PermissionSavedRemoveErrors, + ThrowOnError + >({ + url: "/api/permission/saved/{id}", + ...options, + ...params, + }) + } +} + +export class Permission3 extends HeyApiClient { + private _request?: Request + get request(): Request { + return (this._request ??= new Request({ client: this.client })) + } + + private _saved?: Saved + get saved(): Saved { + return (this._saved ??= new Saved({ client: this.client })) + } +} + export class V2 extends HeyApiClient { private _session?: Session3 get session(): Session3 { @@ -4572,6 +4744,11 @@ export class V2 extends HeyApiClient { get provider(): Provider2 { return (this._provider ??= new Provider2({ client: this.client })) } + + private _permission?: Permission3 + get permission(): Permission3 { + return (this._permission ??= new Permission3({ client: this.client })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3be97a5cf..61d106851 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -44,10 +44,10 @@ export type Event = | EventMessagePartUpdated | EventMessagePartRemoved | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied | EventSessionDiff | EventSessionError + | EventPermissionAsked + | EventPermissionReplied | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -78,6 +78,8 @@ export type Event = | EventInstallationUpdateAvailable | EventServerConnected | EventGlobalDisposed + | EventPermissionV2Asked + | EventPermissionV2Replied | EventAccountAdded | EventAccountRemoved | EventAccountSwitched @@ -145,6 +147,16 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } +export type PermissionAction = "allow" | "deny" | "ask" + +export type PermissionRule = { + permission: string + pattern: string + action: PermissionAction +} + +export type PermissionRuleset = Array + export type Session = { id: string slug: string @@ -1094,6 +1106,29 @@ export type GlobalEvent = { delta: string } } + | { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } + } + | { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } + } | { id: string type: "permission.asked" @@ -1121,29 +1156,6 @@ export type GlobalEvent = { reply: "once" | "always" | "reject" } } - | { - id: string - type: "session.diff" - properties: { - sessionID: string - diff: Array - } - } - | { - id: string - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } - } | { id: string type: "question.asked" @@ -1415,6 +1427,30 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } + } + | { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } + } | { id: string type: "account.added" @@ -2029,16 +2065,6 @@ export type WorktreeResetInput = { directory: string } -export type PermissionAction = "allow" | "deny" | "ask" - -export type PermissionRule = { - permission: string - pattern: string - action: PermissionAction -} - -export type PermissionRuleset = Array - export type ProjectSummary = { id: string name?: string @@ -2811,6 +2837,14 @@ export type SessionNextRetryError = { } } +export type PermissionV2Source = { + type: "tool" + messageID: string + callID: string +} + +export type PermissionV2Reply = "once" | "always" | "reject" + export type AuthOAuthCredential = { type: "oauth" refresh: string @@ -3637,6 +3671,25 @@ export type ProviderV2Info = { } } +export type PermissionV2Request = { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source +} + +export type PermissionSavedInfo = { + id: string + projectID: string + action: string + resource: string +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -4173,6 +4226,31 @@ export type EventMessagePartDelta = { } } +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + export type EventPermissionAsked = { id: string type: "permission.asked" @@ -4202,31 +4280,6 @@ export type EventPermissionReplied = { } } -export type EventSessionDiff = { - id: string - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - -export type EventSessionError = { - id: string - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - export type EventQuestionAsked = { id: string type: "question.asked" @@ -4473,6 +4526,32 @@ export type EventGlobalDisposed = { } } +export type EventPermissionV2Asked = { + id: string + type: "permission.v2.asked" + properties: { + id: string + sessionID: string + action: string + resources: Array + save?: Array + metadata?: { + [key: string]: unknown + } + source?: PermissionV2Source + } +} + +export type EventPermissionV2Replied = { + id: string + type: "permission.v2.replied" + properties: { + sessionID: string + requestID: string + reply: PermissionV2Reply + } +} + export type EventAccountAdded = { id: string type: "account.added" @@ -8262,6 +8341,177 @@ export type V2ProviderGetResponses = { export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] +export type V2PermissionRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/permission/request" +} + +export type V2PermissionRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] + +export type V2PermissionRequestListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses] + +export type V2SessionPermissionListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission/request" +} + +export type V2SessionPermissionListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] + +export type V2SessionPermissionListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] + +export type V2SessionPermissionReplyData = { + body?: { + reply: PermissionV2Reply + message?: string + } + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/request/{requestID}/reply" +} + +export type V2SessionPermissionReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: SessionNotFoundError | PermissionNotFoundError +} + +export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] + +export type V2SessionPermissionReplyResponses = { + /** + * + */ + 204: void +} + +export type V2SessionPermissionReplyResponse = + V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses] + +export type V2PermissionSavedListData = { + body?: never + path?: never + query?: { + projectID?: string + } + url: "/api/permission/saved" +} + +export type V2PermissionSavedListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] + +export type V2PermissionSavedListResponses = { + /** + * Success + */ + 200: Array +} + +export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses] + +export type V2PermissionSavedRemoveData = { + body?: never + path: { + id: string + } + query?: never + url: "/api/permission/saved/{id}" +} + +export type V2PermissionSavedRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] + +export type V2PermissionSavedRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses] + export type TuiAppendPromptData = { body?: { text: string