mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust agents must work within their assigned scope. > - Task creation currently rejects these agents before checking assignment permission or scope. > - Persistent instruction saves also reject direct requests from authorized chat owners. > - This PR checks the requested action and its recorded authority instead of denying all such work. > - Agents can organize permitted work and follow their owner's instruction-edit requests while outside work stays restricted. ## Linked Issues or Issue Description **What happened?** Low-trust agents cannot create self-assigned tasks or subtasks, even within their allowed scope. An authorized user also cannot ask an agent in their own Agent Chat to update its managed `AGENTS.md`. Agent-folder collection can hide the permission rejection behind a generic save failure. **Expected behavior** Allow task creation when assignment permissions and project or root-task scope permit it. Allow instruction self-edits during authenticated owner-chat execution, subject to the user's current edit permission. Outside tasks, subtasks, connector messages, and peer agents do not inherit that instruction authority. Explain the actual denial when a save fails. **Steps to reproduce** 1. Configure an active agent with `low_trust_review` and a project or root-task boundary. 2. Ask it to create an in-scope task assigned to itself, or a subtask of its own task. 3. As a user with permission to configure that agent, ask it in your Agent Chat to update its managed `AGENTS.md`. 4. Observe blanket permission denials rather than action-specific checks. **Paperclip version or commit** Rebased onto master at `8ec4b84e1`. This is a core authorization change, independent of adapter choice. **Deployment mode** Authenticated server. Regression coverage uses the server services, HTTP routes, native tool authority, and embedded PostgreSQL. Related work: #14775 adds human-directed task execution. #13599 concerns instruction-path configuration; this PR leaves that configuration restricted. #11988 proposes separate active-review instruction protection. #10693 reports unclear authorization denials on a different API surface. ## What Changed - Apply task-assignment checks to both HTTP creation routes and native task creation, including unassigned work. Preserve low-trust policy and source attribution on the created task and its initial plan. - Allow self-assigned decomposition within the permitted project or root-task tree. Resolve workspace-derived project scope before authorization, and reauthorize existing tasks before duplicate detection returns them. Keep cross-project and peer-assignment checks. - Derive instruction self-edit authority from the accepted run identity and authenticated owner-message wake. Recheck current permissions at save time. Bind retries to the same request and chat session. - Reject inherited instruction authority from outside tasks, subtasks, plugins, connectors, stale sessions, cancelled runs, and peer edits. - Surface permission errors in instruction and agent-folder save receipts. Tell chat agents to explain the rejected action and the specific restriction. - Update the low-trust policy and implementation documentation. ## Verification - All 297 tests in 11 focused server suites pass after the rebase. These cover owner-chat saves, private copies, warm agent directories, reset and retry boundaries, permission revocation, task creation routes, and native tool authority. - After review fixes, all 126 tests in the four affected authorization/chat suites pass. Workspace scope regressions and 146 existing creation/ownership/workspace-route tests also pass. - The final duplicate-task and CI fixes pass all 39 tests across chat-project tools, duplicate creation, environment-selection guards, and assignee-invokability routes. The duplicate-task test reproduced an unauthorized response before the fix and verifies denial plus permitted reuse afterward. - `pnpm --filter @paperclipai/server typecheck` passes after rebasing; `pnpm --filter @paperclipai/server exec tsc --noEmit` also passes after the review fixes. - `git diff --check origin/master...HEAD` passes. - Final head `7e73270b86748792649e4ae6fbc6879f73b42b73`: all 54 checks passed, with two expected skips and no pending or failed checks. This includes builds, typechecking, the full test matrix, end-to-end tests, runner verification, the canary dry run, and security scans. - Greptile is 5/5 on that exact head, with no unresolved review threads. This change has not been deployed to staging. ## Risks This changes authorization behavior. The instruction exception must not become an inherited task permission. The check uses server-owned execution records, requires the agent's own chat and instructions, and keeps normal protected-change and responsible-user checks. Saves fail closed when current provenance or permission is missing. Owner chat grants a turn-scoped capability; the server does not classify the message intent or require approval of the exact new file bytes. Prompt injection within an authorized owner-chat turn remains a model-level risk. This is the requested owner-chat trust boundary, without a new per-edit confirmation flow. No database migration or broad trust-preset change is required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code editing, shell tools, and test execution. The exact runtime model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
88 lines
5.6 KiB
TypeScript
88 lines
5.6 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
/**
|
|
* A direct owner-chat turn may edit its own instructions, subject to the normal
|
|
* instruction permissions. Inherited responsible-user identity is not consent.
|
|
* Keep this authority out of trust policies and task inheritance entirely.
|
|
*/
|
|
export async function hasOwnerChatInstructionAuthority(
|
|
db: Pick<Db, "execute">,
|
|
input: { companyId: string; agentId: string; runId?: string | null; userId?: string | null },
|
|
): Promise<boolean> {
|
|
if (!input.runId || !input.userId) return false;
|
|
const [authorized] = await db.execute<{ id: string }>(sql`
|
|
WITH RECURSIVE current_chat AS (
|
|
SELECT i.id, i.conversation_session_generation AS generation,
|
|
i.conversation_boundary_comment_id AS boundary_comment_id,
|
|
r.id AS run_id, identity.id AS identity_id
|
|
FROM heartbeat_runs r
|
|
JOIN issues i ON i.id::text = coalesce(r.context_snapshot->>'issueId', r.context_snapshot->>'taskId')
|
|
AND i.company_id = r.company_id AND i.assignee_agent_id = r.agent_id
|
|
JOIN run_identity_contexts identity ON identity.id = r.active_identity_context_id
|
|
AND identity.run_id = r.id AND identity.company_id = r.company_id AND identity.status = 'accepted'
|
|
WHERE r.id = ${input.runId}::uuid AND r.company_id = ${input.companyId}::uuid
|
|
AND r.agent_id = ${input.agentId}::uuid
|
|
-- Completed executions still collect their registered private edits.
|
|
-- Cancellation and a chat reset revoke this exception.
|
|
AND r.status IN ('running', 'succeeded', 'failed', 'timed_out')
|
|
AND i.conversation_agent_id = r.agent_id AND i.conversation_user_id = ${input.userId}
|
|
AND identity.responsible_user_id = ${input.userId} AND r.responsible_user_id = ${input.userId}
|
|
AND coalesce(r.context_snapshot->>'conversationSessionGeneration', '0') = i.conversation_session_generation::text
|
|
), task_runs AS (
|
|
SELECT r.id, r.retry_of_run_id
|
|
FROM heartbeat_runs r JOIN current_chat c ON c.run_id = r.id
|
|
UNION
|
|
SELECT parent.id, parent.retry_of_run_id
|
|
FROM heartbeat_runs parent JOIN task_runs child ON parent.id = child.retry_of_run_id
|
|
JOIN current_chat c ON c.id::text = coalesce(parent.context_snapshot->>'issueId', parent.context_snapshot->>'taskId')
|
|
WHERE parent.company_id = ${input.companyId}::uuid AND parent.agent_id = ${input.agentId}::uuid
|
|
AND parent.status IN ('running', 'succeeded', 'failed', 'timed_out')
|
|
AND coalesce(parent.context_snapshot->>'conversationSessionGeneration', '0') = c.generation::text
|
|
), instruction_identity AS (
|
|
SELECT identity.id, identity.parent_context_id, identity.message_id
|
|
FROM run_identity_contexts identity JOIN current_chat c ON c.identity_id = identity.id
|
|
UNION
|
|
SELECT parent.id, parent.parent_context_id, parent.message_id
|
|
FROM run_identity_contexts parent JOIN instruction_identity child ON parent.id = child.parent_context_id
|
|
WHERE child.message_id IS NULL AND parent.run_id IN (SELECT id FROM task_runs)
|
|
AND parent.company_id = ${input.companyId}::uuid AND parent.status = 'accepted'
|
|
AND parent.responsible_user_id = ${input.userId}
|
|
)
|
|
SELECT c.id FROM current_chat c
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM agent_wakeup_requests w
|
|
JOIN issue_comments message ON message.company_id = w.company_id AND message.issue_id = c.id
|
|
AND message.author_user_id = ${input.userId} AND message.author_agent_id IS NULL
|
|
AND message.deleted_at IS NULL AND message.client_request_id IS NOT NULL
|
|
-- Ordinary messages have no generation; /new records the boundary.
|
|
AND (message.conversation_session_generation IS NULL OR message.conversation_session_generation = c.generation)
|
|
AND (c.boundary_comment_id IS NULL OR EXISTS (
|
|
SELECT 1 FROM issue_comments boundary WHERE boundary.id = c.boundary_comment_id
|
|
AND boundary.company_id = w.company_id AND boundary.issue_id = c.id
|
|
AND (message.created_at, message.id) >= (boundary.created_at, boundary.id)
|
|
))
|
|
AND (message.id::text = coalesce(w.payload->>'commentId', w.payload->'_paperclipWakeContext'->>'wakeCommentId')
|
|
OR (jsonb_typeof(w.payload #> '{_paperclipWakeContext,wakeCommentIds}') = 'array'
|
|
AND (w.payload #> '{_paperclipWakeContext,wakeCommentIds}') ? message.id::text))
|
|
WHERE w.run_id IN (SELECT id FROM task_runs)
|
|
AND w.company_id = ${input.companyId}::uuid AND w.agent_id = ${input.agentId}::uuid
|
|
AND w.requested_by_actor_type = 'user' AND w.requested_by_actor_id = ${input.userId}
|
|
AND w.status NOT IN ('skipped', 'cancelled')
|
|
AND w.payload->'_paperclipWakeContext'->>'source' = 'issue.comment'
|
|
AND coalesce(w.payload->>'issueId', w.payload->>'taskId',
|
|
w.payload->'_paperclipWakeContext'->>'issueId', w.payload->'_paperclipWakeContext'->>'taskId') = c.id::text
|
|
-- Only the authenticated Agent Chat outbox mints this receipt. Other
|
|
-- channels (including Slack Board) also attribute issue.comment wakes
|
|
-- to users, so an external-channel denylist is insufficient.
|
|
AND w.idempotency_key = 'conversation-comment:' || message.id::text
|
|
AND w.source = 'on_demand' AND w.trigger_detail = 'manual'
|
|
-- Use the current instruction, never an earlier message superseded by
|
|
-- another sender. Retries follow their precise accepted identity chain,
|
|
-- and only through server-linked retries of this same chat session.
|
|
AND message.id IN (SELECT message_id FROM instruction_identity)
|
|
)
|
|
`);
|
|
return Boolean(authorized);
|
|
}
|