mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
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:
1 parent
018993140f
commit
3f6f2e78b6
18 files changed
+1435
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
+1044
-2
File diff suppressed because it is too large.
Load diff
+7
@@ -2045,6 +2045,13 @@
|
||||
"when": 1790796608171,
|
||||
"tag": "0293_broad_rattler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 294,
|
||||
"version": "7",
|
||||
"when": 1790806258326,
|
||||
"tag": "0294_equal_echo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user