fix: allow human-directed work for low-trust agents

Bind direct board instructions to the current task and assignee while preserving sandbox and privileged-tool restrictions. Revoke grants on reassignment and cover spoofing, retries, and transaction races.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 17:18:28 -05:00
1 parent 018993140f
commit 3f6f2e78b6
18 files changed
+1435 -8

No files matched your search

+6
View File
@@ -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:
+23 -1
View File
@@ -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
+6
View File
@@ -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
@@ -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();
+7
View File
@@ -2045,6 +2045,13 @@
"when": 1790796608171,
"tag": "0293_broad_rattler",
"breakpoints": true
},
{
"idx": 294,
"version": "7",
"when": 1790806258326,
"tag": "0294_equal_echo",
"breakpoints": true
}
]
}
+1
View File
@@ -218,3 +218,4 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows
export * from "./company_skill_sources.js";
export { issueHumanWorkGrants } from "./issue_human_work_grants.js";
@@ -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"),
}));
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
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<ReturnType<typeof seed>>, 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<ReturnType<typeof seed>>, issue: Awaited<ReturnType<typeof task>>, 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<ReturnType<typeof task>>) => ({ 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<ReturnType<typeof task>>);
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<void>((resolve) => { release = resolve; });
const ready = new Promise<void>((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);
});
});
@@ -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));
+6
View File
@@ -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),
};
+11 -1
View File
@@ -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<TrustPresetResolution> {
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 &&
+1 -1
View File
@@ -30651,7 +30651,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
conversation.issueId,
body,
{ userId },
{ authorType: "user", attachmentIds },
{ authorType: "user", attachmentIds, humanDirectedByUserId: userId },
inner,
),
);
@@ -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<Db, "insert">;
type Reader = Pick<Db, "select">;
/** 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<TrustPresetResolution> {
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;
}
+15 -2
View File
@@ -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<typeof issues.$inferInsert, "companyId" | "title" |
watchdog?: { agentId: string; instructions?: string | null } | null;
watchdogActorRunId?: string | null;
actorRunId?: string | null;
/** Server-only, authenticated board instruction; attribution alone is insufficient. */
humanDirectedByUserId?: string | null;
actorResponsibleUserId?: string | null;
trustExplicitResponsibleUserId?: boolean;
idempotencyKey?: string | null;
@@ -9750,6 +9753,7 @@ export function issueService(db: Db) {
actorRunId,
actorResponsibleUserId,
trustExplicitResponsibleUserId,
humanDirectedByUserId,
idempotencyKey: rawIdempotencyKey,
allowDuplicate,
onDeduplicated,
@@ -10178,6 +10182,7 @@ export function issueService(db: Db) {
);
const [issue] = await tx.insert(issues).values(values).returning();
await recordHumanDirectedWork(tx, issue, { userId: humanDirectedByUserId, agentId: issueData.createdByAgentId });
await recordChatHandoff(tx, issue, actorRunId);
if (idempotencyKey) {
await tx.insert(issueCreateIdempotencyKeys).values({
@@ -10583,6 +10588,7 @@ export function issueService(db: Db) {
actorRunId?: string | null;
actorRunStopId?: string | null;
actorUserId?: string | null;
humanDirectedByUserId?: string | null;
companyGuard?: string;
},
dbOrTx: any = db,
@@ -10630,6 +10636,7 @@ export function issueService(db: Db) {
actorRunId,
actorRunStopId,
actorUserId,
humanDirectedByUserId,
companyGuard,
...issueData
} = data;
@@ -10967,6 +10974,9 @@ export function issueService(db: Db) {
.returning()
.then((rows: Array<typeof issues.$inferSelect>) => 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<{
@@ -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,
+6 -1
View File
@@ -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 };
});
}
@@ -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<Record<TrustPresetPolicySource, TrustPreset>>;
}