feat: agent instructions path API with chain-of-command authz

Add PATCH /agents/:id/instructions-path endpoint for setting or clearing
an agent's instructions file path. Supports relative path resolution
against adapterConfig.cwd. Enforce chain-of-command authorization —
only the agent itself, board members, or ancestor managers can update.
Guard instructions path keys in the general agent update endpoint too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-02 14:21:24 -06:00
parent 633885b57a
commit bcc64f6533
4 changed files with 142 additions and 1 deletions

View File

@@ -131,6 +131,7 @@ export {
createAgentSchema,
createAgentHireSchema,
updateAgentSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
wakeAgentSchema,
resetAgentSessionSchema,
@@ -140,6 +141,7 @@ export {
type CreateAgent,
type CreateAgentHire,
type UpdateAgent,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type WakeAgent,
type ResetAgentSession,

View File

@@ -59,6 +59,13 @@ export const updateAgentSchema = createAgentSchema
export type UpdateAgent = z.infer<typeof updateAgentSchema>;
export const updateAgentInstructionsPathSchema = z.object({
path: z.string().trim().min(1).nullable(),
adapterConfigKey: z.string().trim().min(1).optional(),
});
export type UpdateAgentInstructionsPath = z.infer<typeof updateAgentInstructionsPathSchema>;
export const createAgentKeySchema = z.object({
name: z.string().min(1).default("default"),
});

View File

@@ -9,6 +9,7 @@ export {
createAgentSchema,
createAgentHireSchema,
updateAgentSchema,
updateAgentInstructionsPathSchema,
createAgentKeySchema,
wakeAgentSchema,
resetAgentSessionSchema,
@@ -18,6 +19,7 @@ export {
type CreateAgent,
type CreateAgentHire,
type UpdateAgent,
type UpdateAgentInstructionsPath,
type CreateAgentKey,
type WakeAgent,
type ResetAgentSession,

View File

@@ -1,5 +1,6 @@
import { Router, type Request } from "express";
import { randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclip/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
@@ -10,6 +11,7 @@ import {
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
wakeAgentSchema,
updateAgentSchema,
} from "@paperclip/shared";
@@ -24,13 +26,19 @@ import {
logActivity,
secretService,
} from "../services/index.js";
import { forbidden } from "../errors.js";
import { forbidden, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { runClaudeLogin } from "@paperclip/adapter-claude-local/server";
export function agentRoutes(db: Db) {
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
claude_local: "instructionsFilePath",
codex_local: "instructionsFilePath",
};
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
const router = Router();
const svc = agentService(db);
const access = accessService(db);
@@ -123,6 +131,45 @@ export function agentRoutes(db: Db) {
return value as Record<string, unknown>;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
const trimmed = candidatePath.trim();
if (path.isAbsolute(trimmed)) return trimmed;
const cwd = asNonEmptyString(adapterConfig.cwd);
if (!cwd) {
throw unprocessable(
"Relative instructions path requires adapterConfig.cwd to be set to an absolute path",
);
}
if (!path.isAbsolute(cwd)) {
throw unprocessable("adapterConfig.cwd must be an absolute path to resolve relative instructions path");
}
return path.resolve(cwd, trimmed);
}
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.id === targetAgent.id) return;
const chainOfCommand = await svc.getChainOfCommand(targetAgent.id);
if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return;
throw forbidden("Only the target agent or an ancestor manager can update instructions path");
}
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
const changedTopLevelKeys = Object.keys(patch).sort();
const details: Record<string, unknown> = { changedTopLevelKeys };
@@ -661,6 +708,83 @@ export function agentRoutes(db: Db) {
res.json(agent);
});
router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
const explicitKey = asNonEmptyString(req.body.adapterConfigKey);
const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null;
const adapterConfigKey = explicitKey ?? defaultKey;
if (!adapterConfigKey) {
res.status(422).json({
error: `No default instructions path key for adapter type '${existing.adapterType}'. Provide adapterConfigKey.`,
});
return;
}
const nextAdapterConfig: Record<string, unknown> = { ...existingAdapterConfig };
if (req.body.path === null) {
delete nextAdapterConfig[adapterConfigKey];
} else {
nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig);
}
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
nextAdapterConfig,
{ strictMode: strictSecretsMode },
);
const actor = getActorInfo(req);
const agent = await svc.update(
id,
{ adapterConfig: normalizedAdapterConfig },
{
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "instructions_path_patch",
},
},
);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
const updatedAdapterConfig = asRecord(agent.adapterConfig) ?? {};
const pathValue = asNonEmptyString(updatedAdapterConfig[adapterConfigKey]);
await logActivity(db, {
companyId: agent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_path_updated",
entityType: "agent",
entityId: agent.id,
details: {
adapterConfigKey,
path: pathValue,
cleared: req.body.path === null,
},
});
res.json({
agentId: agent.id,
adapterType: agent.adapterType,
adapterConfigKey,
path: pathValue,
});
});
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
@@ -682,6 +806,12 @@ export function agentRoutes(db: Db) {
res.status(422).json({ error: "adapterConfig must be an object" });
return;
}
const changingInstructionsPath = Object.keys(adapterConfig).some((key) =>
KNOWN_INSTRUCTIONS_PATH_KEYS.has(key),
);
if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,