diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 3a348cbe52a..7c947ea8a71 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -43,6 +43,12 @@ This mode is ideal for local development and one-command installs. Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`). +Human-directed low-trust work uses `issue_human_work_grants`, keyed by task with +company, current agent, authorizing user, and timestamp. Receipts commit with the +human write. A database trigger deletes them on assignee changes. The migration +does not infer authority from historical creator or comment attribution; existing +Agent Chat conversations use their immutable human/agent identity instead. + ## 2. Local PostgreSQL (Docker) For a full PostgreSQL server locally, use the included Docker Compose setup: diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 91ac628904b..e6968a6565f 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -41,6 +41,27 @@ Low-trust agents cannot read or mutate agent configuration, instruction bundles, or company skill configuration through direct grants. Configuration changes from low-trust work must go through higher-trust review and promotion paths instead. +## Human-directed work + +An authenticated board user can talk to a low-trust agent in their own Agent +Chat or assign it a task outside its default intake boundary. Creating or +assigning a task, or posting a new board comment to its current agent assignee, +records a server-owned `issue_human_work_grants` receipt in the same transaction. +External sender attribution and agent claims of human authority do not qualify. +Existing owner conversations use their immutable conversation identity. + +At dispatch and API authorization, Paperclip checks the current assignment and +the actual run's task. The exception permits reading, commenting on, and updating +only that exact task. It does not extend to another task, a child, a whole project, +configuration, instructions, secrets, or runtime management. Normal responsible +user checks still apply. The exception is never stored in the inherited trust +boundary; retries and resumed runs recheck the receipt and current assignment. + +Changing the assignee revokes the receipt, including a change away and back. +Retrying an old comment does not grant new authority. A new human assignment or +comment can authorize the current assignee again. Sandbox and isolated workspace +requirements still apply, as do malformed-policy and company-boundary checks. + ## Child→Parent Reporting Under Containment The direct-parent report comment (`doc/execution-semantics.md` §6, "Child→Parent @@ -59,7 +80,8 @@ runtime boundary: - the selected execution environment must use the `sandbox` driver - the effective execution workspace mode must be `isolated_workspace` -- the issue being run must be inside the resolved low-trust boundary +- the issue being run must be inside the resolved low-trust boundary or be the + exact human-directed task described above - secret references must use binding ids explicitly allowed by the boundary - inline sensitive environment values such as API keys and tokens are rejected - workspace runtime-service mutations are denied unless the boundary explicitly diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 782f4c08b6d..b56d7aa4657 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -626,6 +626,12 @@ budget gates, and pause gates remain independently enforced. Comment access is structurally downstream of issue read access (`issue:comment` is a subset of `issue:read`). +Authenticated board direction permits a low-trust agent to execute its own +human conversation or the exact task explicitly assigned or addressed by a human. +This server-owned exception is bound to the current assignee and the run's task; +it does not expand inherited boundaries or grant privileged tools. Reassignment +revokes task receipts. See `doc/LOW-TRUST-PRESETS.md` for containment details. + Cross-issue writes are contained per heartbeat run. An agent-authored comment may wake the target assignee, including an explicit `resume: true` comment on a `done` or `cancelled` issue, but the wake remains agent-class and is subject to diff --git a/packages/db/src/migrations/0294_equal_echo.sql b/packages/db/src/migrations/0294_equal_echo.sql new file mode 100644 index 00000000000..20f0e5018a1 --- /dev/null +++ b/packages/db/src/migrations/0294_equal_echo.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS "issue_human_work_grants" ( + "issue_id" uuid PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "agent_id" uuid NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "issue_human_work_grants" ADD CONSTRAINT "issue_human_work_grants_company_id_issue_id_issues_company_id_id_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "issue_human_work_grants" ADD CONSTRAINT "issue_human_work_grants_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +--> statement-breakpoint +CREATE OR REPLACE FUNCTION revoke_issue_human_work_grant() RETURNS trigger AS $$ +BEGIN + DELETE FROM issue_human_work_grants WHERE issue_id = OLD.id AND company_id = OLD.company_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +--> statement-breakpoint +DROP TRIGGER IF EXISTS issue_human_work_grant_reassignment ON issues; + +--> statement-breakpoint +CREATE TRIGGER issue_human_work_grant_reassignment +AFTER UPDATE OF assignee_agent_id, assignee_user_id ON issues +FOR EACH ROW WHEN (OLD.assignee_agent_id IS DISTINCT FROM NEW.assignee_agent_id + OR OLD.assignee_user_id IS DISTINCT FROM NEW.assignee_user_id) +EXECUTE FUNCTION revoke_issue_human_work_grant(); diff --git a/packages/db/src/migrations/meta/0289_snapshot.json b/packages/db/src/migrations/meta/0294_snapshot.json similarity index 97% rename from packages/db/src/migrations/meta/0289_snapshot.json rename to packages/db/src/migrations/meta/0294_snapshot.json index 1b3fb2249ad..a7ea7df88e9 100644 --- a/packages/db/src/migrations/meta/0289_snapshot.json +++ b/packages/db/src/migrations/meta/0294_snapshot.json @@ -1,6 +1,6 @@ { - "id": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19", - "prevId": "770b6d59-6346-4c92-8e49-0b71cf4819ab", + "id": "2b8dba39-47e5-48e9-98e9-9fb2ceab312e", + "prevId": "d0c40f4e-c2c5-4efd-bc15-c6ae5632047d", "version": "7", "dialect": "postgresql", "tables": { @@ -3975,6 +3975,599 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.browser_use_browsers": { + "name": "browser_use_browsers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_browser_id": { + "name": "provider_browser_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_use_browsers_provider_uq": { + "name": "browser_use_browsers_provider_uq", + "columns": [ + { + "expression": "provider_browser_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_use_browsers_session_idx": { + "name": "browser_use_browsers_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_use_browsers_company_id_companies_id_fk": { + "name": "browser_use_browsers_company_id_companies_id_fk", + "tableFrom": "browser_use_browsers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_browsers_session_id_browser_use_sessions_id_fk": { + "name": "browser_use_browsers_session_id_browser_use_sessions_id_fk", + "tableFrom": "browser_use_browsers", + "tableTo": "browser_use_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_use_runs": { + "name": "browser_use_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_id": { + "name": "invocation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_run_id": { + "name": "provider_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recovery_cursor": { + "name": "recovery_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "detached_until": { + "name": "detached_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_cursor": { + "name": "event_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "events_drained": { + "name": "events_drained", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accounted_cents": { + "name": "accounted_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_use_runs_invocation_uq": { + "name": "browser_use_runs_invocation_uq", + "columns": [ + { + "expression": "invocation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_use_runs_provider_uq": { + "name": "browser_use_runs_provider_uq", + "columns": [ + { + "expression": "provider_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_use_runs_session_idx": { + "name": "browser_use_runs_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_use_runs_company_id_companies_id_fk": { + "name": "browser_use_runs_company_id_companies_id_fk", + "tableFrom": "browser_use_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_runs_session_id_browser_use_sessions_id_fk": { + "name": "browser_use_runs_session_id_browser_use_sessions_id_fk", + "tableFrom": "browser_use_runs", + "tableTo": "browser_use_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "browser_use_runs_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "browser_use_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_use_sessions": { + "name": "browser_use_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_session_id": { + "name": "provider_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starting'" + }, + "idle_deadline": { + "name": "idle_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_requested": { + "name": "stop_requested", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_use_sessions_task_idx": { + "name": "browser_use_sessions_task_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_use_sessions_provider_uq": { + "name": "browser_use_sessions_provider_uq", + "columns": [ + { + "expression": "provider_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_use_sessions_company_id_companies_id_fk": { + "name": "browser_use_sessions_company_id_companies_id_fk", + "tableFrom": "browser_use_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_sessions_issue_id_issues_id_fk": { + "name": "browser_use_sessions_issue_id_issues_id_fk", + "tableFrom": "browser_use_sessions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_sessions_agent_id_agents_id_fk": { + "name": "browser_use_sessions_agent_id_agents_id_fk", + "tableFrom": "browser_use_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_sessions_connection_id_tool_connections_id_fk": { + "name": "browser_use_sessions_connection_id_tool_connections_id_fk", + "tableFrom": "browser_use_sessions", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_sessions_grant_id_connection_grants_id_fk": { + "name": "browser_use_sessions_grant_id_connection_grants_id_fk", + "tableFrom": "browser_use_sessions", + "tableTo": "connection_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_use_settings": { + "name": "browser_use_settings", + "schema": "", + "columns": { + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allowed_profile_ids": { + "name": "allowed_profile_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "max_cost_usd": { + "name": "max_cost_usd", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "browser_use_settings_grant_id_connection_grants_id_fk": { + "name": "browser_use_settings_grant_id_connection_grants_id_fk", + "tableFrom": "browser_use_settings", + "tableTo": "connection_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "browser_use_settings_company_id_companies_id_fk": { + "name": "browser_use_settings_company_id_companies_id_fk", + "tableFrom": "browser_use_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.budget_incidents": { "name": "budget_incidents", "schema": "", @@ -12144,6 +12737,373 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_source_entries": { + "name": "company_skill_source_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inspection": { + "name": "inspection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_skill_source_entries_source_path_idx": { + "name": "company_skill_source_entries_source_path_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_source_entries_company_skill_idx": { + "name": "company_skill_source_entries_company_skill_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_source_entries_company_id_companies_id_fk": { + "name": "company_skill_source_entries_company_id_companies_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_source_id_company_skill_sources_id_fk": { + "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skill_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_skill_id_company_skills_id_fk": { + "name": "company_skill_source_entries_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_sources": { + "name": "company_skill_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_ref": { + "name": "tracking_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "excluded_folders": { + "name": "excluded_folders", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scan_commit": { + "name": "last_scan_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_sources_repository_ref_idx": { + "name": "company_skill_sources_repository_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_sources_identity_ref_idx": { + "name": "company_skill_sources_identity_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_sources_company_id_companies_id_fk": { + "name": "company_skill_sources_company_id_companies_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_sources_connection_id_tool_connections_id_fk": { + "name": "company_skill_sources_connection_id_tool_connections_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_comments": { "name": "company_skill_comments", "schema": "", @@ -26872,6 +27832,81 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.issue_human_work_grants": { + "name": "issue_human_work_grants", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "issue_human_work_grants_company_id_issue_id_issues_company_id_id_fk": { + "name": "issue_human_work_grants_company_id_issue_id_issues_company_id_id_fk", + "tableFrom": "issue_human_work_grants", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_human_work_grants_company_id_agent_id_agents_company_id_id_fk": { + "name": "issue_human_work_grants_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "issue_human_work_grants", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.issue_inbox_archives": { "name": "issue_inbox_archives", "schema": "", @@ -30505,6 +31540,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 71acfdde0c6..6877700bb5b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2045,6 +2045,13 @@ "when": 1790796608171, "tag": "0293_broad_rattler", "breakpoints": true + }, + { + "idx": 294, + "version": "7", + "when": 1790806258326, + "tag": "0294_equal_echo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 19df9235905..585b038189c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -218,3 +218,4 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows export * from "./company_skill_sources.js"; +export { issueHumanWorkGrants } from "./issue_human_work_grants.js"; diff --git a/packages/db/src/schema/issue_human_work_grants.ts b/packages/db/src/schema/issue_human_work_grants.ts new file mode 100644 index 00000000000..10b6bbde52d --- /dev/null +++ b/packages/db/src/schema/issue_human_work_grants.ts @@ -0,0 +1,15 @@ +import { foreignKey, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { issues } from "./issues.js"; + +/** Server-owned receipt of a human directing the current assignee's work. */ +export const issueHumanWorkGrants = pgTable("issue_human_work_grants", { + issueId: uuid("issue_id").primaryKey(), + companyId: uuid("company_id").notNull(), + agentId: uuid("agent_id").notNull(), + userId: text("user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + issueFk: foreignKey({ columns: [table.companyId, table.issueId], foreignColumns: [issues.companyId, issues.id] }).onDelete("cascade"), + agentFk: foreignKey({ columns: [table.companyId, table.agentId], foreignColumns: [agents.companyId, agents.id] }).onDelete("cascade"), +})); diff --git a/server/src/__tests__/human-directed-work.test.ts b/server/src/__tests__/human-directed-work.test.ts new file mode 100644 index 00000000000..00b3b63c4d9 --- /dev/null +++ b/server/src/__tests__/human-directed-work.test.ts @@ -0,0 +1,170 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns, issueHumanWorkGrants, issues, projects } from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET, createIssueSchema, updateIssueSchema, addIssueCommentSchema } from "@paperclipai/shared"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { issueService } from "../services/issues.js"; +import { authorizationService } from "../services/authorization.js"; +import { resolveAndRetainRunTrustPreset } from "../services/run-trust-preset.js"; +import { assertLowTrustWorkspaceIsolation } from "../services/low-trust-runtime-containment.js"; +import { withHumanDirectedWork } from "../services/human-directed-work.js"; +import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("human-directed low-trust work", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-human-work-"); + db = createDb(database.connectionString); + }, 30_000); + afterAll(async () => { await database?.cleanup(); }, 60_000); + + async function seed() { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: companyId, issuePrefix: companyId.slice(0, 8) }); + const [project] = await db.insert(projects).values({ companyId, name: "Contained intake" }).returning(); + const [agent] = await db.insert(agents).values({ + companyId, name: "Contained agent", role: "engineer", adapterType: "paperclip_runner", + permissions: { authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET, + trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, companyId, projectIds: [project.id] } } }, + }).returning(); + const [other] = await db.insert(agents).values({ companyId, name: "Other", role: "engineer" }).returning(); + return { companyId, agent, other }; + } + async function task(f: Awaited>, human = false) { + return issueService(db).create(f.companyId, { + title: `Direct task ${randomUUID()}`, assigneeAgentId: f.agent.id, + createdByUserId: "owner", ...(human ? { humanDirectedByUserId: "owner" } : {}), + }); + } + async function launch(f: Awaited>, issue: Awaited>, context = {}) { + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agent.id, + status: "running", contextSnapshot: { issueId: issue.id, ...context } }).returning(); + const result = await resolveAndRetainRunTrustPreset(db, { + companyId: f.companyId, agentId: f.agent.id, runId: run.id, agent: f.agent, issue, + }); + return { ...result, run, actor: { type: "agent" as const, companyId: f.companyId, + agentId: f.agent.id, source: "agent_key" as const, runId: run.id } }; + } + const resource = (issue: Awaited>) => ({ type: "issue" as const, + companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, + parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, status: issue.status }); + + it("allows the exact human-assigned task at dispatch and tool access; retains containment on retries", async () => { + const f = await seed(); const issue = await task(f, true); + const first = await launch(f, issue); + expect(first.trustPreset).toMatchObject({ kind: "low_trust_review", humanDirectedIssueId: issue.id }); + expect(JSON.stringify(first.executionPolicy)).not.toContain("humanDirectedIssueId"); + const retried = await resolveAndRetainRunTrustPreset(db, { companyId: f.companyId, + agentId: f.agent.id, runId: first.run.id, agent: f.agent, issue }); + expect(retried.trustPreset).toMatchObject(first.trustPreset); + await expect(assertLowTrustWorkspaceIsolation({ db, resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "sandbox" })).resolves.toBeUndefined(); + for (const action of ["issue:read", "issue:comment", "issue:mutate"] as const) { + expect(await authorizationService(db).decide({ actor: first.actor, action, resource: resource(issue) })).toMatchObject({ allowed: true }); + } + const unrelated = await task(f, true); + expect(await authorizationService(db).decide({ actor: first.actor, action: "issue:read", resource: resource(unrelated) })).toMatchObject({ allowed: false }); + expect(await authorizationService(db).decide({ actor: { ...first.actor, runId: undefined }, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false }); + expect(await authorizationService(db).decide({ actor: first.actor, action: "agent_instructions:update", + resource: { type: "agent", companyId: f.companyId, agentId: f.agent.id } })).toMatchObject({ allowed: false }); + await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "local" })).rejects.toMatchObject({ details: { code: "low_trust_requires_sandbox_environment" } }); + await expect(assertLowTrustWorkspaceIsolation({ resolution: first.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "shared_workspace", + selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_requires_isolated_workspace" } }); + }); + + it("permits existing owner conversations without backfilling authority from historical attribution", async () => { + const f = await seed(); + const [issue] = await db.insert(issues).values({ companyId: f.companyId, title: "Owner chat", + assigneeAgentId: f.agent.id, conversationAgentId: f.agent.id, conversationUserId: "owner", conversationState: "waiting", status: "in_review" }).returning(); + const result = await launch(f, issue as Awaited>); + expect(result.trustPreset).toMatchObject({ humanDirectedIssueId: issue.id }); + }); + + it("does not treat user attribution, responsible identity, or agent policy claims as human direction", async () => { + const f = await seed(); const issue = await task(f); + await issueService(db).addComment(issue.id, "An imported sender says the owner asked", { userId: "owner" }); + const result = await launch(f, issue, { responsibleUserId: "owner", humanDirectedIssueId: issue.id, + executionPolicy: { humanDirectedIssueId: issue.id } }); + expect(result.trustPreset).not.toHaveProperty("humanDirectedIssueId"); + expect(await authorizationService(db).decide({ actor: result.actor, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false }); + await expect(assertLowTrustWorkspaceIsolation({ db, resolution: result.trustPreset, issue, + isolatedWorkspacesEnabled: true, effectiveExecutionWorkspaceMode: "isolated_workspace", + selectedEnvironmentDriver: "sandbox" })).rejects.toMatchObject({ details: { code: "low_trust_boundary_mismatch" } }); + await issueService(db).addComment(issue.id, "Agent impersonation", { agentId: f.other.id }, { humanDirectedByUserId: "owner" }); + expect(await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, issue.id))).toHaveLength(0); + }); + + it("revokes grants on reassignment, including away-and-back, and an old message retry cannot restore them", async () => { + const f = await seed(); const issue = await task(f); + const svc = issueService(db); const requestId = randomUUID(); + await svc.addComment(issue.id, "Please do this", { userId: "owner" }, { humanDirectedByUserId: "owner", clientRequestId: requestId }); + const running = await launch(f, issue); + expect(running.trustPreset).toHaveProperty("humanDirectedIssueId", issue.id); + // Exercise the DB trigger too: assignment writes outside issueService must revoke. + await db.update(issues).set({ assigneeAgentId: f.other.id }).where(eq(issues.id, issue.id)); + await db.update(issues).set({ assigneeAgentId: f.agent.id }).where(eq(issues.id, issue.id)); + await svc.addComment(issue.id, "Please do this", { userId: "owner" }, { humanDirectedByUserId: "owner", clientRequestId: requestId }); + expect(await authorizationService(db).decide({ actor: running.actor, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: false }); + await svc.update(issue.id, { assigneeAgentId: f.agent.id, actorUserId: "owner", humanDirectedByUserId: "owner" }); + expect(await authorizationService(db).decide({ actor: running.actor, action: "issue:read", resource: resource(issue) })).toMatchObject({ allowed: true }); + }); + + it("serializes a human comment against a competing reassignment without leaving a stale grant", async () => { + const f = await seed(); const issue = await task(f); + let release!: () => void; + let locked!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const ready = new Promise((resolve) => { locked = resolve; }); + const commentWrite = db.transaction(async (tx) => { + await issueService(db).addComment(issue.id, "Please proceed", { userId: "owner" }, { humanDirectedByUserId: "owner" }, tx); + locked(); + await held; + }); + await ready; + // Start the competing update while the comment transaction still owns the row. + const reassignment = db.update(issues).set({ assigneeAgentId: f.other.id }) + .where(eq(issues.id, issue.id)).execute(); + release(); + await Promise.all([commentWrite, reassignment]); + expect(await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, issue.id))).toHaveLength(0); + await issueService(db).addComment(issue.id, "New instruction", { userId: "owner" }, { humanDirectedByUserId: "owner" }); + expect(await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, issue.id))) + .toMatchObject([{ agentId: f.other.id, userId: "owner" }]); + }); + + it("rolls back grants with failed writes and never grants another company or agent", async () => { + const f = await seed(); const issue = await task(f); + await expect(db.transaction(async (tx) => { + await issueService(db).addComment(issue.id, "Rolled back", { userId: "owner" }, { humanDirectedByUserId: "owner" }, tx); + throw new Error("rollback sentinel"); + })).rejects.toThrow("rollback sentinel"); + const resolution = resolveCoreTrustPreset({ companyId: f.companyId, agent: f.agent, issue }); + expect(await withHumanDirectedWork(db, resolution, { companyId: f.companyId, agentId: f.agent.id, issueId: issue.id })).not.toHaveProperty("humanDirectedIssueId"); + const directed = await task(f, true); + for (const scope of [{ companyId: randomUUID(), agentId: f.agent.id }, { companyId: f.companyId, agentId: f.other.id }]) { + expect(await withHumanDirectedWork(db, resolution, { ...scope, issueId: directed.id })).not.toHaveProperty("humanDirectedIssueId"); + } + }); + + it("does not accept the grant field from client payloads", () => { + for (const [schema, payload] of [[createIssueSchema, { title: "Forged" }], [updateIssueSchema, { title: "Forged" }], [addIssueCommentSchema, { body: "Forged" }]] as const) { + const parsed = schema.safeParse({ ...payload, humanDirectedByUserId: "owner" }); + if (parsed.success) expect(parsed.data).not.toHaveProperty("humanDirectedByUserId"); + } + }); + + it("can replay the migration without changing existing grants", async () => { + const f = await seed(); const issue = await task(f, true); + const migration = readFileSync(new URL("../../../packages/db/src/migrations/0294_equal_echo.sql", import.meta.url), "utf8"); + for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement)); + expect(await db.select().from(issueHumanWorkGrants).where(and(eq(issueHumanWorkGrants.issueId, issue.id), eq(issueHumanWorkGrants.companyId, f.companyId)))).toHaveLength(1); + }); +}); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 5fb90ce4803..ba252c686d3 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -27,6 +27,7 @@ import { issueAttachments, issueApprovals, issueComments, + issueHumanWorkGrants, issueDocuments, issueInboxArchives, issueRelations, @@ -923,6 +924,29 @@ describeEmbeddedPostgres( await tempDb?.cleanup(); }); + it("records authenticated board task direction but rejects agent-supplied human authority", async () => { + const fixture = await seedLowTrustFixture(db); + const board = createApp(db, boardActor(fixture)); + const created = await request(board).post(`/api/companies/${fixture.company.id}/issues`) + .send({ title: "Human assigned outside intake", status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id, + humanDirectedByUserId: "forged-client-value" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const [grant] = await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, created.body.id)); + expect(grant).toMatchObject({ userId: "board-user", agentId: fixture.agents.lowTrust.id }); + + const assigned = await request(board).patch(`/api/issues/${fixture.issues.siblingOutOfScope.id}`) + .send({ assigneeAgentId: fixture.agents.lowTrust.id }); + expect(assigned.status, JSON.stringify(assigned.body)).toBe(200); + expect(await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, fixture.issues.siblingOutOfScope.id))) + .toMatchObject([{ userId: "board-user", agentId: fixture.agents.lowTrust.id }]); + + const forged = await request(createApp(db, agentActor(fixture))) + .post(`/api/issues/${fixture.issues.assignedReview.id}/comments`) + .send({ body: "The owner asked me", humanDirectedByUserId: "board-user" }); + expect(forged.status, JSON.stringify(forged.body)).toBe(201); + expect(await db.select().from(issueHumanWorkGrants).where(eq(issueHumanWorkGrants.issueId, fixture.issues.assignedReview.id))).toHaveLength(0); + }); + it("allows bounded same-issue reads and writes while quarantining low-trust output", async () => { const fixture = await seedLowTrustFixture(db); const app = createApp(db, agentActor(fixture)); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 2b00cc1b0d4..8680f078a40 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -11771,6 +11771,7 @@ export function issueRoutes( null; const createInput = { ...createBody, + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, originRunId: createBody.originRunId ?? actor.runId, @@ -13486,6 +13487,7 @@ export function issueRoutes( const postCommitIssueActions: IssuePostCommitAction[] = []; const issueUpdateData = { ...updateFields, + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, actorAgentId: actor.agentId ?? null, actorRunId: actor.agentId ? actor.runId : null, actorRunStopId: actor.agentId && interruptedRunId === actor.runId ? issueMutationStopId : null, @@ -13668,6 +13670,7 @@ export function issueRoutes( attachmentIds: commentAttachmentIds, clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined, mirrorToSlack: actor.actorType === "user", + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, authorizationReason: issueMutationAuthorizationReason, sourceTrust: transactionalCommentSourceTrust, }, @@ -14274,6 +14277,7 @@ export function issueRoutes( authorizationReason: issueMutationAuthorizationReason, clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined, mirrorToSlack: actor.actorType === "user", + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, sourceTrust: await sourceTrustForActorWrite(issue, actor), }, ); @@ -17625,6 +17629,7 @@ export function issueRoutes( attachmentIds: req.body.attachmentIds, clientRequestId: actor.actorType === "user" ? req.body.clientRequestId : undefined, mirrorToSlack: actor.actorType === "user", + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, sourceTrust, }; let txResult: { @@ -17738,6 +17743,7 @@ export function issueRoutes( attachmentIds: req.body.attachmentIds, clientRequestId: actor.actorType === "user" ? req.body.clientRequestId : undefined, mirrorToSlack: actor.actorType === "user", + humanDirectedByUserId: req.actor.type === "board" ? req.actor.userId : null, authorizationReason: commentAuthorizationReason, sourceTrust: await sourceTrustForActorWrite(currentIssue, actor), }; diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index ed1719e0280..c208f8ed0e4 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -1,5 +1,6 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; +import { withHumanDirectedWork } from "./human-directed-work.js"; import { agents, authUsers, @@ -873,13 +874,18 @@ export function authorizationService(db: Db | DbTransaction) { }): Promise { const { issue, project } = await loadResourceContext(input.resource); const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id); - return resolveCoreTrustPreset({ + const resolution = resolveCoreTrustPreset({ companyId: input.companyId, agent: input.actorAgent, project, issue, run, }); + if (!input.actor.runId || resolution.kind !== "low_trust_review") return resolution; + const issueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgent.id); + return withHumanDirectedWork(db, resolution, { + companyId: input.companyId, agentId: input.actorAgent.id, issueId, + }); } async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) { @@ -1036,6 +1042,10 @@ export function authorizationService(db: Db | DbTransaction) { if (input.resource.type !== "issue") { return lowTrustDeny("Low-trust issue access is missing an issue resource."); } + if (input.resolution.humanDirectedIssueId && input.resource.companyId === boundary.companyId && + input.resource.issueId === input.resolution.humanDirectedIssueId) { + return lowTrustAllow("Allowed on the exact task directed by an authenticated human."); + } if (input.action === "issue:comment" && input.directParentReportTarget) { if ( input.resource.issueId && diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index a4c67d20b37..6f70cdd6981 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -30651,7 +30651,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { conversation.issueId, body, { userId }, - { authorType: "user", attachmentIds }, + { authorType: "user", attachmentIds, humanDirectedByUserId: userId }, inner, ), ); diff --git a/server/src/services/human-directed-work.ts b/server/src/services/human-directed-work.ts new file mode 100644 index 00000000000..098d1db56e9 --- /dev/null +++ b/server/src/services/human-directed-work.ts @@ -0,0 +1,59 @@ +import { and, eq, isNotNull, or } from "drizzle-orm"; +import { issueHumanWorkGrants, issues, type Db } from "@paperclipai/db"; +import type { TrustPresetResolution } from "./trust-preset-resolver.js"; + +type Writer = Pick; +type Reader = Pick; + +/** Called within the task write transaction, with authenticated server actor fields. */ +export async function recordHumanDirectedWork( + db: Writer, + issue: { id: string; companyId: string; assigneeAgentId: string | null }, + actor: { userId?: string | null; agentId?: string | null }, +) { + if (!actor.userId || actor.agentId || !issue.assigneeAgentId) return; + await db.insert(issueHumanWorkGrants).values({ + issueId: issue.id, + companyId: issue.companyId, + agentId: issue.assigneeAgentId, + userId: actor.userId, + }).onConflictDoUpdate({ + target: issueHumanWorkGrants.issueId, + set: { agentId: issue.assigneeAgentId, userId: actor.userId, createdAt: new Date() }, + }); +} + +/** + * This exception belongs only to the current task. It is never serialized into + * trustBoundary (which would make it inherited authority for unrelated work). + * The caller supplies a task identity loaded from the bound run, not a tool's + * target or a claimed responsible user. Normal low-trust powers stay unchanged. + */ +export async function withHumanDirectedWork( + db: Reader, + resolution: TrustPresetResolution, + input: { companyId: string; agentId: string; issueId: string | null }, +): Promise { + if (resolution.kind !== "low_trust_review" || !input.issueId || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.issueId) || + resolution.boundary.companyId !== input.companyId) return resolution; + // Read the current assignment and receipt in one snapshot. Conversation + // identity is immutable and established by the authenticated first-write route. + const [issue] = await db.select({ id: issues.id }) + .from(issues) + .leftJoin(issueHumanWorkGrants, and( + eq(issueHumanWorkGrants.issueId, issues.id), + eq(issueHumanWorkGrants.companyId, issues.companyId), + eq(issueHumanWorkGrants.agentId, input.agentId), + )) + .where(and( + eq(issues.id, input.issueId), + eq(issues.companyId, input.companyId), + eq(issues.assigneeAgentId, input.agentId), + or( + and(eq(issues.conversationAgentId, input.agentId), isNotNull(issues.conversationUserId)), + isNotNull(issueHumanWorkGrants.issueId), + ), + )); + return issue ? { ...resolution, humanDirectedIssueId: issue.id } : resolution; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 18585836a40..e6823040695 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,4 +1,5 @@ import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js"; +import { recordHumanDirectedWork } from "./human-directed-work.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js"; @@ -1939,6 +1940,8 @@ type IssueCreateInput = Omit) => rows[0] ?? null); if (!updated) return null; + if (issueData.assigneeAgentId !== undefined) { + await recordHumanDirectedWork(tx, updated, { userId: humanDirectedByUserId, agentId: actorAgentId }); + } await recordChatCompletion(tx, receiptExisting, updated); // An operator explicitly choosing a disposition owns that decision, // including choosing In Review while the conversation is Idle. @@ -12142,6 +12152,8 @@ export function issueService(db: Db) { sourceTrust?: typeof issueComments.$inferInsert.sourceTrust; createdAt?: Date | string | null; clientRequestId?: string; + /** Server-only authenticated board direction, never sender attribution. */ + humanDirectedByUserId?: string | null; /** Server-only: authenticated Paperclip messages also belong in the Slack thread. */ mirrorToSlack?: boolean; }, @@ -12169,13 +12181,13 @@ export function issueService(db: Db) { // The query below locks human comments on caller-owned transactions too, // sharing the fence with both question creation and Slack settlement. const issueQuery = dbOrTx - .select({ companyId: issues.companyId, conversationAgentId: issues.conversationAgentId }) + .select({ companyId: issues.companyId, conversationAgentId: issues.conversationAgentId, assigneeAgentId: issues.assigneeAgentId }) .from(issues) .where(eq(issues.id, issueId)); // Caller-owned transactions (including chat and review comments) must // serialize with question creation before inserting the human comment. const issue = await (actor.userId || (actor.runId && dbOrTx !== db) ? issueQuery.for("update") : issueQuery) - .then((rows: Array<{ companyId: string; conversationAgentId: string | null }>) => rows[0] ?? null); + .then((rows: Array<{ companyId: string; conversationAgentId: string | null; assigneeAgentId: string | null }>) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); @@ -12443,6 +12455,7 @@ export function issueService(db: Db) { .returning(); } if (!comment) throw new Error("Failed to create issue comment"); + await recordHumanDirectedWork(dbOrTx, { ...issue, id: issueId }, { userId: options?.humanDirectedByUserId, agentId: actor.agentId }); if (options?.completionReply && actor.agentId && createdByRunId) await acknowledgeChatCompletionReply(dbOrTx, createdByRunId, comment.id); const boundAttachments: Array<{ diff --git a/server/src/services/low-trust-runtime-containment.ts b/server/src/services/low-trust-runtime-containment.ts index 22ce04d6d9d..613123f5620 100644 --- a/server/src/services/low-trust-runtime-containment.ts +++ b/server/src/services/low-trust-runtime-containment.ts @@ -69,6 +69,8 @@ export async function assertLowTrustWorkspaceIsolation(input: { } if ( !input.issue || + !(input.resolution.humanDirectedIssueId && input.issue.companyId === input.resolution.boundary.companyId && + input.issue.id === input.resolution.humanDirectedIssueId) && !(await workspaceIssueWithinLowTrustBoundary({ db: input.db, boundary: input.resolution.boundary, diff --git a/server/src/services/run-trust-preset.ts b/server/src/services/run-trust-preset.ts index 20cf0da92b8..fb6d2b12d62 100644 --- a/server/src/services/run-trust-preset.ts +++ b/server/src/services/run-trust-preset.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from "drizzle-orm"; import { heartbeatRuns, type Db } from "@paperclipai/db"; import { conflict } from "../errors.js"; +import { withHumanDirectedWork } from "./human-directed-work.js"; import { resolveCoreTrustPreset, type ResolveCoreTrustPresetInput, @@ -61,6 +62,10 @@ export async function resolveAndRetainRunTrustPreset( updatedAt: new Date(), }) .where(scope); - return { trustPreset, executionPolicy }; + const runIssueId = run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId; + return { trustPreset: await withHumanDirectedWork(tx, trustPreset, { + companyId: input.companyId, agentId: input.agentId, + issueId: typeof runIssueId === "string" ? runIssueId : null, + }), executionPolicy }; }); } diff --git a/server/src/services/trust-preset-resolver.ts b/server/src/services/trust-preset-resolver.ts index 97529840bcd..7b0ab3b7f6b 100644 --- a/server/src/services/trust-preset-resolver.ts +++ b/server/src/services/trust-preset-resolver.ts @@ -53,6 +53,8 @@ export type TrustPresetResolution = | { kind: "low_trust_review"; preset: typeof LOW_TRUST_REVIEW_PRESET; + /** Server-derived authority for this run's exact task; never a policy input. */ + humanDirectedIssueId?: string; boundary: LowTrustBoundary & { companyId: string }; sourcePresets: Partial>; }