Skip to the content

Capsid menu

src/agents-schema.ts

180 lines and 19 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of agents-schema.ts
FunctionLinesComplexityCoveredCRAPMutation
isAgentKind60 to 622100%OkCRAP 2No data: mutation testing has not run on this repo yet
noFlags64 to 692100%OkCRAP 2No data: mutation testing has not run on this repo yet
emptyScopes72 to 741100%OkCRAP 1No data: mutation testing has not run on this repo yet
defaultScopes80 to 821100%OkCRAP 1No data: mutation testing has not run on this repo yet
parseList84 to 883100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:87>87 to 872100%OkCRAP 2No data: mutation testing has not run on this repo yet
parseScopes93 to 11810100%WatchCRAP 10No data: mutation testing has not run on this repo yet
<anonymous:104>104 to 1041100%OkCRAP 1No data: mutation testing has not run on this repo yet
serializeScopes120 to 1221100%OkCRAP 1No data: mutation testing has not run on this repo yet
allowsScope126 to 1282100%OkCRAP 2No data: mutation testing has not run on this repo yet
allowsToolAction142 to 1575100%OkCRAP 5No data: mutation testing has not run on this repo yet
<anonymous:146>146 to 1461100%OkCRAP 1No data: mutation testing has not run on this repo yet
describeScope160 to 1633100%OkCRAP 3No data: mutation testing has not run on this repo yet
mintAgentId166 to 1681100%OkCRAP 1No data: mutation testing has not run on this repo yet
mintAgentKey173 to 1751100%OkCRAP 1No data: mutation testing has not run on this repo yet
agentActor180 to 1821100%OkCRAP 1No data: mutation testing has not run on this repo yet
driverAgentName187 to 1891100%OkCRAP 1No data: mutation testing has not run on this repo yet
driverKeyPath191 to 1931100%OkCRAP 1No data: mutation testing has not run on this repo yet
driverMintInstruction195 to 2031100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/encoding.ts. Imported by src/agent-record.ts, src/agents-admin.ts, src/agents.ts, src/bench-run-key.ts, src/bench.ts, src/controls-plan.ts, src/jobs-schema.ts, src/review.ts, src/scope.ts, src/skills-refresh.ts, src/status.ts, src/tools/agents.ts, src/tools/jobs.ts, src/tools/namespaces.ts, src/watcher.ts.

Source

import { bytesToHex } from "./encoding";

// The vocabulary of a scoped credential, in one module so the table
// (migrations/0008_agents.sql), the tool (src/tools/agents.ts) and the enforcement
// point (src/scope.ts) cannot disagree about it: a list spelled twice drifts, and the
// copy nobody looks at ends up checking five flags out of six. Free of Worker and MCP
// imports so the scope logic is unit-testable under node.

// Descriptive, not authorizing. What an agent may do lives in its scopes and
// nowhere else, so a kind cannot quietly become a second permission system.
export const AGENT_KINDS = ["session", "driver", "seat", "cron"] as const;
export type AgentKind = (typeof AGENT_KINDS)[number];

export const AGENT_GRANTS = ["read", "write"] as const;
export type AgentGrant = (typeof AGENT_GRANTS)[number];

// Each flag names an action whose consequence leaves this Worker, so the write grant
// alone is not enough for any of them. The reasons are FLAG_REASON in src/scope.ts.
export const SCOPE_FLAGS = [
  "can_merge",
  "can_direct_write",
  "can_dispatch",
  "can_write_workflows",
  "can_touch_protected",
  "money_paths",
  // Commenting goes through capsid_open_pr action comment, a write tool, so a reviewer holds the write
  // grant; without a flag of its own that grant would also let it merge and close.
  // This flag makes "may comment on this PR" smaller than "may decide this PR".
  "can_comment_pr",
] as const;
export type ScopeFlag = (typeof SCOPE_FLAGS)[number];

// A list of names, or "*" for every name. "*" inside a list is not a wildcard, so a
// typo in a mint command cannot grant everything.
export type ScopeList = "*" | string[];

export interface AgentScopes {
  namespaces: ScopeList;
  repos: ScopeList;
  tools: ScopeList;
  grants: AgentGrant[];
  flags: Record<ScopeFlag, boolean>;
}

export interface AgentRow {
  id: string;
  name: string;
  kind: AgentKind;
  key_hash: string;
  scopes: string;
  created_by: string;
  created_at: string;
  revoked_at: string | null;
  last_seen: string | null;
  // The one job an old runner key existed to work (migrations/0021), or null; no key is minted with one now. Optional
  // because a row read before that migration has no such column.
  job_id?: string | null;
}

export function isAgentKind(value: unknown): value is AgentKind {
  return typeof value === "string" && (AGENT_KINDS as readonly string[]).includes(value);
}

export function noFlags(): Record<ScopeFlag, boolean> {
  // Built fresh each time, so a flag set on one agent cannot leak to another.
  const flags = {} as Record<ScopeFlag, boolean>;
  for (const flag of SCOPE_FLAGS) flags[flag] = false;
  return flags;
}

// Nothing allowed: what parseScopes falls back to on a corrupt row.
function emptyScopes(): AgentScopes {
  return { namespaces: [], repos: [], tools: [], grants: [], flags: noFlags() };
}

// The default for a new agent: read, on the namespaces it was named for, and no
// flags. Tools and repos are unrestricted because the grant already limits them (a
// read grant cannot reach a write tool, and a repo read is a read); the axes that
// must be narrowed at mint time are the ones the mint command asks for.
export function defaultScopes(namespaces: string[]): AgentScopes {
  return { namespaces: [...namespaces], repos: "*", tools: "*", grants: ["read"], flags: noFlags() };
}

function parseList(value: unknown): ScopeList {
  if (value === "*") return "*";
  if (!Array.isArray(value)) return [];
  return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
}

// Fails closed: a scopes column that is null, empty, truncated, an array, a bare
// string or an object with none of the keys resolves to emptyScopes(). A permissive
// parse of a corrupt row looks like a working one until the row is corrupt.
export function parseScopes(json: string | null | undefined): AgentScopes {
  if (!json) return emptyScopes();
  let raw: unknown;
  try {
    raw = JSON.parse(json);
  } catch {
    return emptyScopes();
  }
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return emptyScopes();
  const record = raw as Record<string, unknown>;
  const grants = Array.isArray(record.grants)
    ? (record.grants.filter((g): g is AgentGrant => (AGENT_GRANTS as readonly unknown[]).includes(g)) as AgentGrant[])
    : [];
  const flags = noFlags();
  // Only the flags this system has, and only the boolean true, so a hand-written
  // scopes blob cannot add an axis the enforcement point does not know.
  const rawFlags = typeof record.flags === "object" && record.flags !== null ? (record.flags as Record<string, unknown>) : {};
  for (const flag of SCOPE_FLAGS) flags[flag] = rawFlags[flag] === true;
  return {
    namespaces: parseList(record.namespaces),
    repos: parseList(record.repos),
    tools: parseList(record.tools),
    grants,
    flags,
  };
}

export function serializeScopes(scopes: AgentScopes): string {
  return JSON.stringify(scopes);
}

// The one list comparison. "*" is the only wildcard and it is the whole value, never
// an entry.
export function allowsScope(list: ScopeList, value: string): boolean {
  return list === "*" ? true : list.includes(value);
}

// The same comparison for a tool whose action decides what it does (jobs: "may post
// a job" and "may claim one" are different authorities). An entry may be qualified,
// `jobs.post`, and the rule is:
//
//   - "*" allows everything.
//   - A list naming at least one action of this tool is narrowed to the actions it
//     names. Anything else of that tool is refused.
//   - A bare tool name with no qualified sibling means the whole tool.
//   - The narrowing reaches only the tool it names.
//
// Kept beside allowsScope rather than in the enforcement point so the rule is one
// pure function the tests can drive without an agent or a server.
export function allowsToolAction(list: ScopeList, tool: string, action: string | undefined): boolean {
  if (list === "*") return true;
  if (!list.includes(tool)) return false;
  const prefix = `${tool}.`;
  const qualified = list.some((entry) => entry.startsWith(prefix));
  if (!qualified) return true;
  // An unknown action on a narrowed tool is refused, not read as the whole tool:
  // otherwise a reviewer minted ["capsid_open_pr", "capsid_open_pr.comment"] could open a pull
  // request through a call path that names no action.
  //
  // It stays opt-in: a list with no qualified sibling for this tool is untouched.
  // Opting in means the call path has to say what it is doing, and a path that cannot
  // is refused.
  if (action === undefined) return false;
  return list.includes(`${prefix}${action}`);
}

// How a scope list reads in a refusal, so the refusal says what the scope is.
export function describeScope(list: ScopeList): string {
  if (list === "*") return "*";
  return list.length === 0 ? "(none)" : list.join(", ");
}

// agent_<12 hex>. See the migration for why it is not a sequence.
export function mintAgentId(): string {
  return `agent_${bytesToHex(crypto.getRandomValues(new Uint8Array(6)))}`;
}

// 32 bytes of entropy behind a greppable prefix, so a key pasted into a file, a log
// or a commit is findable by searching for one string. Returned ONCE by the mint
// action and stored nowhere: what the table holds is its sha256.
export function mintAgentKey(): string {
  return `capsid_agent_${bytesToHex(crypto.getRandomValues(new Uint8Array(32)))}`;
}

// The audit identity, in the vocabulary audit_log.actor and jobs.claimed_by already
// speak (`github:<login>`, `opkey:<fingerprint>`). The name is UNIQUE in the table,
// so this string identifies exactly one agent row.
export function agentActor(name: string): string {
  return `agent:${name}`;
}

// The driver bootstrap instruction. capsid_admin_manage_namespaces action register returns the mint command
// rather than minting, so no credential crosses the tool boundary.
// test/register-namespace-mint.test.ts asserts the script it names can parse it.
export function driverAgentName(namespace: string): string {
  return `${namespace}-driver`;
}

export function driverKeyPath(namespace: string): string {
  return `~/.capsid/agent-${driverAgentName(namespace)}.key`;
}

export function driverMintInstruction(namespace: string): string {
  return (
    `Mint its driver agent as the admin: node scripts/mint-agents.ts --namespace ${namespace} --apply. ` +
    `The key is returned once and lands in ${driverKeyPath(namespace)} at mode 0600. ` +
    // Cannot import TOOL_GRANTS (src/scope.ts imports this module), so
    // test/audit-2026-09-16.test.ts holds the sentence and the table together.
    `capsid_admin_manage_namespaces action register does not mint it: minting is admin only, and capsid_admin_manage_namespaces action register is itself admin only.`
  );
}