Files
PaperClipAI/server/src/__tests__/owner-chat-instruction-authority.test.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

292 lines
21 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agents, agentWakeupRequests, authUsers, companies, companyMemberships, createDb,
heartbeatRuns, issueComments, issues, principalPermissionGrants, runIdentityContexts,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { authorizeInstructionCommit, resolveInstructionActor } from "../services/agent-instruction-authorization.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
import { authorizationService } from "../services/authorization.js";
import { initializeRunIdentity } from "../services/run-identity.js";
describe("owner-chat instruction authority", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const previousHome = process.env.PAPERCLIP_HOME;
const initial = "# Ralph\nOriginal instructions\n";
beforeAll(async () => {
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "owner-chat-instructions-")));
process.env.PAPERCLIP_HOME = home;
database = await startEmbeddedPostgresTestDatabase("owner-chat-instructions-db-");
db = createDb(database.connectionString);
}, 60_000);
afterAll(async () => {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
await database?.cleanup();
if (home) {
const makeWritable = async (dir: string) => {
await fs.chmod(dir, 0o700);
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (entry.isDirectory()) await makeWritable(path.join(dir, entry.name));
}
};
await makeWritable(home);
await fs.rm(home, { recursive: true, force: true });
}
});
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID();
const root = resolveManagedInstructionsRoot({ companyId, id: agentId, name: "Ralph", adapterConfig: {} });
await db.insert(companies).values({ id: companyId, name: "Owner chat", issuePrefix: companyId.slice(0, 8) });
await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
const permissions = { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: randomUUID() },
} };
await db.insert(agents).values({ id: agentId, companyId, name: "Ralph", status: "active", permissions,
adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: "AGENTS.md" } });
await db.insert(companyMemberships).values([
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" },
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" },
]);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
const [chat] = await db.insert(issues).values({ companyId, title: "Owner chat", status: "in_progress",
assigneeAgentId: agentId, conversationAgentId: agentId, conversationUserId: userId,
conversationState: "active", conversationSessionGeneration: 0 }).returning();
const [message] = await db.insert(issueComments).values({ companyId, issueId: chat.id,
authorUserId: userId, clientRequestId: randomUUID(), body: "Update your AGENTS.md to remember the project conventions." }).returning();
const context = { issueId: chat.id, conversationSessionGeneration: 0, wakeCommentId: message.id, source: "issue.comment" };
const [wake] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "on_demand", triggerDetail: "manual",
idempotencyKey: `conversation-comment:${message.id}`,
reason: "issue_commented", status: "claimed", requestedByActorType: "user", requestedByActorId: userId,
payload: { issueId: chat.id, commentId: message.id, _paperclipWakeContext: context } }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", responsibleUserId: userId,
wakeupRequestId: wake.id, invocationSource: "automation", contextSnapshot: context }).returning();
await db.update(agentWakeupRequests).set({ runId: run.id }).where(eq(agentWakeupRequests.id, wake.id));
await db.update(issues).set({ executionRunId: run.id }).where(eq(issues.id, chat.id));
await initializeRunIdentity(db, { companyId, runId: run.id, issueId: chat.id, responsibleUserId: userId,
messageIds: [message.id], cause: "issue_commented" });
await fs.mkdir(root, { recursive: true });
await fs.writeFile(path.join(root, "AGENTS.md"), initial);
const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId, agentId, runId: run.id };
return { companyId, agentId, userId, root, permissions, chat, message, run, wake, actor, target: { companyId, id: agentId } };
}
type Fixture = Awaited<ReturnType<typeof seed>>;
const authorize = (f: Fixture) => authorizeInstructionCommit(db, f.actor, f.target);
const expectDenied = async (f: Fixture) => {
await expect(authorize(f)).rejects.toMatchObject({ status: 403 });
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe(initial);
};
it("persists the requested self-edit without relaxing the agent's trust or configuration permissions", async () => {
const f = await seed();
const service = agentInstructionRevisionService(db);
const target = { companyId: f.companyId, agentId: f.agentId };
const before = (await service.readCurrent(target, f.actor))!;
const saved = await service.commit({ ...target, entryFile: "AGENTS.md", baseRevisionId: before.revision.id,
content: "# Ralph\nFollow the owner's project conventions.\n", source: "api" }, f.actor);
expect(saved.changed).toBe(true);
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe(saved.content);
const [agent] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(agent.permissions).toEqual(f.permissions);
const actor = await resolveInstructionActor(db, f.actor);
for (const action of ["agent_config:update", "secrets:read"] as const) {
expect(await authorizationService(db).decide({ actor, action,
resource: { type: "agent", companyId: f.companyId, agentId: f.agentId } })).toMatchObject({ allowed: false });
}
});
it.each(["running", "succeeded", "failed", "timed_out"])("collects authorized private edits from a %s owner-chat run", async (status) => {
const f = await seed();
const copies = agentInstructionWorkingCopyService(db);
const copy = (await copies.prepare({ companyId: f.companyId, agentId: f.agentId, runId: f.run.id, cwd: home, legacy: true }))!;
await fs.writeFile(path.join(copy.localRoot, copy.entryFile), "The owner's requested edit");
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.run.id));
expect((await copies.collectStopped({ companyId: f.companyId, runId: f.run.id }))?.state).toBe("saved");
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe("The owner's requested edit");
});
it.each([false, true])("saves the current agent directory in owner chat (warm: %s)", async (warm) => {
const f = await seed();
const copies = agentInstructionWorkingCopyService(db);
const copy = (await copies.prepare({ companyId: f.companyId, agentId: f.agentId, runId: f.run.id, cwd: home, warm }))!;
await fs.writeFile(path.join(copy.localRoot, copy.entryFile), "Requested through owner chat");
expect((await copies.collectStopped({ companyId: f.companyId, runId: f.run.id }))?.state).toBe("saved");
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe("Requested through owner chat");
});
it.each([false, true])("surfaces the actual rejection when directory collection loses chat authority (warm: %s)", async (warm) => {
const f = await seed();
const copies = agentInstructionWorkingCopyService(db);
const copy = (await copies.prepare({ companyId: f.companyId, agentId: f.agentId, runId: f.run.id, cwd: home, warm }))!;
await fs.writeFile(path.join(copy.localRoot, copy.entryFile), "Must not be saved");
await db.update(agentWakeupRequests).set({ requestedByActorType: "agent", requestedByActorId: f.agentId }).where(eq(agentWakeupRequests.id, f.wake.id));
if (warm) {
expect((await copies.checkpointWarm({ companyId: f.companyId, runId: f.run.id }))?.errorMessage)
.toContain("Self-edits require a direct message");
}
expect(await copies.collectStopped({ companyId: f.companyId, runId: f.run.id })).toMatchObject({
state: "unavailable", errorMessage: expect.stringContaining("Self-edits require a direct message"),
});
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe(initial);
});
it("does not carry owner-chat instruction authority into an outside task or its subtask", async () => {
const f = await seed();
const [task] = await db.insert(issues).values({ companyId: f.companyId, title: "Outside work", assigneeAgentId: f.agentId }).returning();
const [child] = await db.insert(issues).values({ companyId: f.companyId, title: "Subtask", parentId: task.id, assigneeAgentId: f.agentId }).returning();
for (const issue of [task, child]) {
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
invocationSource: "assignment", status: "running", contextSnapshot: { issueId: issue.id } }).returning();
await initializeRunIdentity(db, { companyId: f.companyId, runId: run.id, issueId: issue.id,
responsibleUserId: f.userId, parentRunId: f.run.id, cause: "assignment" });
await expect(authorizeInstructionCommit(db, { ...f.actor, runId: run.id }, f.target)).rejects.toMatchObject({ status: 403 });
}
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe(initial);
});
it.each(["plugin", "connector", "slack board", "unknown channel", "wrong comment receipt", "missing client request", "agent", "missing receipt"])("rejects %s work attributed to the same owner", async (source) => {
const f = await seed();
if (source === "plugin") await db.update(agentWakeupRequests).set({ payload: { ...f.wake.payload,
_paperclipWakeContext: { ...f.run.contextSnapshot, source: "plugin:inbox" } } }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "connector") await db.update(agentWakeupRequests).set({ idempotencyKey: `chat-inbound:${randomUUID()}` }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "slack board") await db.update(agentWakeupRequests).set({ source: "automation", triggerDetail: "system",
idempotencyKey: `slack-board-comment:${randomUUID()}` }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "unknown channel") await db.update(agentWakeupRequests).set({ idempotencyKey: null }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "wrong comment receipt") await db.update(agentWakeupRequests).set({ idempotencyKey: `conversation-comment:${randomUUID()}` }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "missing client request") await db.update(issueComments).set({ clientRequestId: null }).where(eq(issueComments.id, f.message.id));
if (source === "agent") await db.update(agentWakeupRequests).set({ requestedByActorType: "agent", requestedByActorId: f.agentId }).where(eq(agentWakeupRequests.id, f.wake.id));
if (source === "missing receipt") await db.update(agentWakeupRequests).set({ runId: null }).where(eq(agentWakeupRequests.id, f.wake.id));
await expectDenied(f);
});
it("does not let a later external message borrow an earlier owner message's authority", async () => {
const f = await seed();
const [outside] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.chat.id,
authorUserId: f.userId, body: "Imported outside instructions" }).returning();
const [context] = await db.insert(runIdentityContexts).values({ companyId: f.companyId, runId: f.run.id,
responsibleUserId: f.userId, messageId: outside.id, revision: 3, cause: "steering", correlationId: "external-message", status: "accepted" }).returning();
await db.update(heartbeatRuns).set({ activeIdentityContextId: context.id }).where(eq(heartbeatRuns.id, f.run.id));
await expectDenied(f);
});
it("accepts a coalesced owner message only when it is the active instruction", async () => {
const f = await seed();
await db.update(agentWakeupRequests).set({ status: "coalesced", payload: { issueId: f.chat.id,
_paperclipWakeContext: { source: "issue.comment", wakeCommentIds: [f.message.id] } } }).where(eq(agentWakeupRequests.id, f.wake.id));
await expect(authorize(f)).resolves.toMatchObject({ agentId: f.agentId });
});
it("allows a fresh request after /new while rejecting messages before the reset boundary", async () => {
const f = await seed();
const [boundary] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.chat.id,
authorUserId: f.userId, body: "/new", conversationSessionGeneration: 1,
createdAt: new Date(f.message.createdAt.getTime() - 1_000) }).returning();
await db.update(issues).set({ conversationSessionGeneration: 1, conversationBoundaryCommentId: boundary.id })
.where(eq(issues.id, f.chat.id));
await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.run.contextSnapshot, conversationSessionGeneration: 1 } })
.where(eq(heartbeatRuns.id, f.run.id));
await expect(authorize(f)).resolves.toMatchObject({ agentId: f.agentId });
await db.update(issueComments).set({ createdAt: new Date(boundary.createdAt.getTime() - 1_000) })
.where(eq(issueComments.id, f.message.id));
await expectDenied(f);
});
it.each(["cancelled", "queued", "reset", "deleted message", "reassigned", "different owner"])("revokes the exception after %s", async (change) => {
const f = await seed();
if (change === "cancelled" || change === "queued") await db.update(heartbeatRuns).set({ status: change }).where(eq(heartbeatRuns.id, f.run.id));
if (change === "reset") await db.update(issues).set({ conversationSessionGeneration: 1 }).where(eq(issues.id, f.chat.id));
if (change === "deleted message") await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, f.message.id));
if (change === "reassigned") await db.update(issues).set({ assigneeAgentId: null, conversationAgentId: null,
conversationUserId: null, conversationState: null }).where(eq(issues.id, f.chat.id));
if (change === "different owner") await db.update(issues).set({ conversationUserId: randomUUID() }).where(eq(issues.id, f.chat.id));
await expectDenied(f);
});
it.each(["membership", "edit grant"])("rechecks the owner's %s when collecting edits", async (permission) => {
const f = await seed();
const copies = agentInstructionWorkingCopyService(db);
const copy = (await copies.prepare({ companyId: f.companyId, agentId: f.agentId, runId: f.run.id, cwd: home, legacy: true }))!;
await fs.writeFile(path.join(copy.localRoot, copy.entryFile), "Must remain a candidate");
if (permission === "membership") await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
else await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.userId));
const result = await copies.collectStopped({ companyId: f.companyId, runId: f.run.id });
expect(result).toMatchObject({ state: "conflict", candidateBase64: expect.any(String) });
expect(await fs.readFile(path.join(f.root, "AGENTS.md"), "utf8")).toBe(initial);
});
it("keeps peer instruction changes prohibited even with explicit grants", async () => {
const f = await seed();
const [peer] = await db.insert(agents).values({ companyId: f.companyId, name: "Peer", status: "active" }).returning();
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "agents:configure" });
await db.update(principalPermissionGrants).set({ scope: { agentIds: [f.agentId, peer.id] } }).where(eq(principalPermissionGrants.principalId, f.userId));
await expect(authorizeInstructionCommit(db, f.actor, { companyId: f.companyId, id: peer.id })).rejects.toMatchObject({ status: 403 });
});
it("continues the same request on a server-linked retry but cannot revive a cancelled request", async () => {
const f = await seed();
await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, f.run.id));
const [retry] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
invocationSource: "automation", status: "running", retryOfRunId: f.run.id,
contextSnapshot: { issueId: f.chat.id, conversationSessionGeneration: 0 } }).returning();
await initializeRunIdentity(db, { companyId: f.companyId, runId: retry.id, issueId: f.chat.id,
responsibleUserId: f.userId, parentRunId: f.run.id, cause: "retry" });
const actor = { ...f.actor, runId: retry.id };
await expect(authorizeInstructionCommit(db, actor, f.target)).resolves.toMatchObject({ runId: retry.id });
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.run.id));
await expect(authorizeInstructionCommit(db, actor, f.target)).rejects.toMatchObject({ status: 403 });
});
it("does not lend a past owner request to an automatic continuation or a retry of external steering", async () => {
const f = await seed();
const [continuation] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
invocationSource: "automation", status: "running", contextSnapshot: f.run.contextSnapshot }).returning();
await initializeRunIdentity(db, { companyId: f.companyId, runId: continuation.id, issueId: f.chat.id,
responsibleUserId: f.userId, parentRunId: f.run.id, cause: "continuation" });
await expect(authorizeInstructionCommit(db, { ...f.actor, runId: continuation.id }, f.target))
.rejects.toMatchObject({ status: 403, details: { reason: "deny_low_trust_boundary" } });
const [external] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.chat.id,
authorUserId: f.userId, body: "Outside message" }).returning();
const [identity] = await db.insert(runIdentityContexts).values({ companyId: f.companyId, runId: f.run.id,
responsibleUserId: f.userId, messageId: external.id, revision: 3, cause: "steering", correlationId: "external" }).returning();
await db.update(heartbeatRuns).set({ activeIdentityContextId: identity.id, status: "failed" }).where(eq(heartbeatRuns.id, f.run.id));
const [retry] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
invocationSource: "automation", status: "running", retryOfRunId: f.run.id, contextSnapshot: f.run.contextSnapshot }).returning();
await initializeRunIdentity(db, { companyId: f.companyId, runId: retry.id, issueId: f.chat.id,
responsibleUserId: f.userId, parentRunId: f.run.id, cause: "retry" });
await expect(authorizeInstructionCommit(db, { ...f.actor, runId: retry.id }, f.target))
.rejects.toMatchObject({ status: 403, details: { reason: "deny_low_trust_boundary" } });
});
it("still enforces explicit protected-change restrictions inside owner chat", async () => {
const f = await seed();
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
principalId: f.agentId, permissionKey: "agents:suggest-changes" });
await expect(authorize(f)).rejects.toMatchObject({ status: 403, details: { code: "reflection_coach_mutation_gate_required" } });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.agentId));
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
principalId: f.agentId, permissionKey: "agents:configure", scope: { agentIds: [randomUUID()] } });
await expect(authorize(f)).rejects.toMatchObject({ status: 403 });
});
it("cannot use forged caller identity or a malformed trust boundary", async () => {
const f = await seed();
await expect(authorizeInstructionCommit(db, { ...f.actor, onBehalfOfUserId: randomUUID() }, f.target)).rejects.toMatchObject({ status: 403 });
await db.update(agents).set({ permissions: { ...f.permissions, authorizationPolicy: {
trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, companyId: randomUUID() },
} } }).where(eq(agents.id, f.agentId));
await expectDenied(f);
});
});