Files
PaperClipAI/server/src/services/owner-chat-instruction-authority.ts
T
DottaandPaperclip 4039d4f06b fix(auth): allow scoped low-trust work and owner-chat instruction edits (#14870)
## 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>
2026-10-01 15:42:40 -05:00

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);
}