// approvals: things the agent prepared and may not do until the owner says yes (.intentic/config/approvals/.json) import { z } from "zod"; import { entryId } from "./internal.js"; // One queue for anything that needs a yes-click before a machine carries it out and records the outcome: an envelope // (who, when, status, result) plus a kind-specific payload, a discriminated union. Excludes a facts inbox and a hold a // running turn is blocked on. One JSON file per approval; the agent writes it, the daemon administers it. export const ApprovalKindSchema = z.enum(["post", "action"]); export type ApprovalKind = z.infer; // proposed (agent) → approved (owner) → running (set before acting, so a dead turn can't double-act) → done | failed. // Reject deletes the file; retry re-approves a failed one. export const ApprovalStatusSchema = z.enum(["proposed", "approved", "running", "done", "failed"]); export type ApprovalStatus = z.infer; // The envelope, spread into every variant (zod's discriminated union wants flat objects): a variant's own fields sit // beside these, not under a `payload` key, so the file an agent writes stays one level deep. const envelope = { // Whose name it acts under, required for anything needing a login; never guessed by the daemon. actsAs: entryId .optional() .describe( "Whose name it acts under. Needed for anything that requires being logged in, because an unwatched turn naming nobody is allowed no account at all. Never guessed: one site can be connected five times over, and picking for you means picking wrong in public with no undo.", ), // Epoch ms, the at/nextRun convention; an approved item with none is scheduled a short hold ahead by the daemon. scheduledAt: z .number() .optional() .describe( "When it should happen, in milliseconds. An agent may propose without one and you set it when approving; an approved item with no time goes after a short countdown you can still stop.", ), // Defaults to "proposed"; every bookkeeping field here is optional so a well-formed write never lands in `invalid`. status: ApprovalStatusSchema.default("proposed").describe( "Where it is: proposed by the agent, approved by you, being carried out, done, or failed. Rejecting is deleting it; retrying is approving a failed one again.", ), createdAt: z.number().optional().describe("When it was written, in milliseconds."), // Stamped with status "running"; distinguishes a run under way from one whose turn died mid-flight, which the due // time alone can't. startedAt: z .number() .optional() .describe( "When it started being carried out, in milliseconds. Needed to tell a run that is under way from one whose turn died mid-flight, which the scheduled time cannot.", ), finishedAt: z.number().optional().describe("When it was done, in milliseconds."), // What came back, when something did; a URL renders as a link, anything else as the plain sentence it is. result: z .string() .optional() .describe( "What came back, when something did: the post's own address, a confirmation number. The one thing a finished item can offer that reading it cannot.", ), error: z.string().optional().describe("Why it failed, written as a sentence for a person to read rather than as a code."), }; /* A POST, the first kind: words that go out in public under the owner's name and cannot be recalled. */ export const PostApprovalSchema = z.object({ kind: z.literal("post").describe("A post to publish somewhere."), // Bare string, not an enum, so a new platform needs no contract change. platform: z .string() .min(1) .describe("Where it should go. A plain name, so a new site needs no change here; an unknown one simply fails when it tries to post."), content: z.string().min(1).describe("The post itself."), // Reddit posts / YouTube uploads need one. title: z.string().optional().describe("A title, where the site wants one."), // A community/channel, or the URL of what this replies to; on Reddit, a thread vs a comment permalink is the room // vs the person. target: z .string() .optional() .describe( "Where on the site: a community, a channel. Or the address of the thing this replies to, in which case it is a reply, and on some sites the difference between a thread's address and one comment's is the difference between talking to the room and answering the person.", ), // Workspace-relative attachment paths, e.g. ".intentic/config/approvals/media/chart.png". media: z.array(z.string()).optional().describe("Anything to attach, as workspace paths."), ...envelope, }); export type PostApproval = z.infer; // Anything else the agent shouldn't do unasked (a booking, a purchase, a deletion). `summary`/`details` are for the // owner; `instructions` is what the agent tells its future self, since the executing turn is fresh and has none of this // conversation. export const ActionApprovalSchema = z.object({ kind: z.literal("action").describe("Something the agent will do once you say so."), summary: z.string().min(1).max(200).describe("What will happen, in one line: the row's headline and the confirm dialog's item."), // Markdown; everything the owner needs to see to say no, not just yes. details: z.string().optional().describe("The specifics, as Markdown: everything you would want to see before saying yes."), // Written for the fresh turn that executes it: names files and ids rather than "do what we discussed". instructions: z .string() .min(1) .describe( "What to do once approved, written for the fresh turn that will do it: names, ids and steps, since it has none of this conversation.", ), // Written by a connection's own command when it holds an act (e-urzad's office acts), never by hand. Part of what // the yes pins, like every field but the bookkeeping, so an item edited after the yes no longer matches it. performer: z .object({ extension: z.string().min(1).describe("The extension that carries it out, by its publisher.name (\"intentic.e-urzad\")."), digest: z .string() .regex(/^[0-9a-f]{64}$/) .describe("The SHA-256 of exactly what it will do, which the extension recomputes before acting and refuses on any difference."), }) .optional() .describe( "Who carries this out instead of an agent turn: once you approve it, the sandbox hands it to that extension's backend, which acts only if what it holds still matches the digest. Written by the extension's own command when it holds an act; an item written by hand never needs one.", ), ...envelope, }); export type ActionApproval = z.infer; // The on-disk file body, whichever kind it is. export const ApprovalSchema = z.discriminatedUnion("kind", [PostApprovalSchema, ActionApprovalSchema]); export type Approval = z.infer; // The list row / upsert input: the file body plus its filename id. const withId = { id: entryId.describe("The approval's id.") }; export const PostApprovalSummarySchema = PostApprovalSchema.extend(withId); export type PostApprovalSummary = z.infer; export const ActionApprovalSummarySchema = ActionApprovalSchema.extend(withId); export type ActionApprovalSummary = z.infer; export const ApprovalSummarySchema = z.discriminatedUnion("kind", [PostApprovalSummarySchema, ActionApprovalSummarySchema]); export type ApprovalSummary = z.infer; // Filenames that failed to parse; agent-written files are a trust boundary, so these are surfaced, not silently // dropped. export const ApprovalsListSchema = z.object({ approvals: z.array(ApprovalSummarySchema).describe("The queue."), invalid: z .array(z.string()) .describe( "Files that could not be read at all, or name a kind this daemon does not know. Listed rather than skipped, because an agent writes these files directly and a malformed one would otherwise never run and never say why.", ), }); export type ApprovalsList = z.infer; // entryId, not a bare string: the id becomes a filename under .intentic/config/approvals/. export const ApprovalIdParamSchema = z.object({ id: entryId.describe("Which approval.") }); /* WORKSPACE HOOKS: what Claude Code would run on a turn from its settings files and skill or subagent definitions, held * off until the owner approves that exact set by its digest. Never a workspace file: whoever can write the hooks must not * also be able to write their yes, so the record lives with the daemon and only the owner's routes below move it. */ const hookDigest = z.string().regex(/^[0-9a-f]{64}$/); export const SettingsHookSchema = z.object({ source: z .enum(["user", "project", "plugin"]) .describe( "Whose configuration declares it: the sandbox's own (~/.claude), the workspace's (.claude/ in the project), or a Claude Code plugin the turn loads.", ), plugin: z.string().optional().describe("The plugin that declares it, by the name the request's plugin list gives it. Present only for a plugin's hook."), declaredIn: z .string() .optional() .describe( "The skill, subagent or command whose frontmatter declares it, spelled like a script path. Absent when it comes from the settings.json of its source.", ), event: z.string().describe("When it runs, in Claude Code's own words: before a tool, after one, when a prompt is sent, when a session starts."), matcher: z.string().optional().describe("Which tools it is limited to, when it is limited at all."), type: z .string() .describe( "What kind of hook it is: a shell command, an address it calls, a prompt it asks a model, or a module: a plugin's code that runs inside Claude Code itself.", ), run: z.string().describe("Exactly what it runs: the command line, the address, the prompt."), }); export type SettingsHook = z.infer; export const HookScriptSchema = z.object({ path: z .string() .describe( "A file one of the hooks runs, spelled the way the hook names it: inside the workspace as $CLAUDE_PROJECT_DIR/…, under the home directory as ~/…, otherwise absolute.", ), sha256: z .string() .describe( "Its contents when the hooks were found. The approval covers these bytes, so editing the file asks again, the same as editing the command would.", ), }); export type HookScript = z.infer; export const HookModuleSchema = z.object({ path: z.string().describe("The plugin's hooks module, as its hooks.json names it."), hooks: z .array(z.string()) .describe("The moments it acts on, in Claude Code's own words: a tool call, a prompt being sent, the system prompt being put together. Empty when it could not be read."), calls: z .array(z.string()) .describe( "What it reaches for while it runs, as Claude Code names its methods: starting programs, network requests, reading and writing files. Empty when it could not be read, or when it reaches for nothing.", ), unreadable: z .string() .optional() .describe("Why the sandbox could not tell what the module does. The approval still covers its code byte for byte. Present only when that is the case."), }); export type HookModule = z.infer; export const HookPluginSchema = z.object({ name: z.string().describe("The plugin, as the sandbox lists it: the connection, extension or persona that brought it, or the name settings enable it under."), from: z .enum(["plugin", "extension", "persona", "skills", "settings"]) .describe( "What brought it into the turn: a plugin connection, an extension, a persona's kit, a plugin folder among Claude Code's skills, or Claude Code's own settings enabling it.", ), source: z .enum(["user", "project"]) .optional() .describe("Which settings enable it or hold it, the sandbox's own or the workspace's. Present only for a plugin settings or a skills folder bring in."), dir: z.string().optional().describe("Where its files were read. Absent for one settings enable that this sandbox has not installed."), module: HookModuleSchema.optional().describe("Its hooks module, code that runs inside Claude Code with everything the agent's session holds. Absent when it has none."), }); export type HookPlugin = z.infer; export const HookMarketplaceSchema = z.object({ source: z.enum(["user", "project"]).describe("Whose settings add it: the sandbox's own or the workspace's."), name: z.string().describe("The marketplace's name in those settings."), location: z.string().describe("Where Claude Code fetches it from, as the settings spell it."), }); export type HookMarketplace = z.infer; export const HookRequestSchema = z.object({ digest: hookDigest.describe( "The set's fingerprint: every hook as declared plus the bytes of every file they run. Approving it approves exactly this, and nothing that differs from it by a character.", ), seenAt: z.number().describe("When a turn first found this set, in milliseconds."), conversationId: z.string().optional().describe("The conversation whose turn found it, when one did."), hooks: z .array(SettingsHookSchema) .describe("Every hook in the set. Until it is approved, turns run with all of them off, and so with every other hook the agent would load."), scripts: z.array(HookScriptSchema).describe("The files those hooks run by name, and a plugin module's code, which the approval pins byte for byte."), plugins: z .array(HookPluginSchema) .optional() .describe("The plugins whose hooks or modules are in the set, and those Claude Code's settings enable. Absent when none is."), marketplaces: z .array(HookMarketplaceSchema) .optional() .describe("Plugin marketplaces Claude Code's settings add, which it may install enabled plugins from. Absent when none is."), dismissed: z .boolean() .optional() .describe("Kept off on purpose: no longer counted as waiting, and still approvable. Present only when it was dismissed."), }); export type HookRequest = z.infer; export const HookRequestsSchema = z.object({ requests: z.array(HookRequestSchema).describe("Hook sets waiting for a yes, newest first, then the dismissed ones."), ledgerUnreadable: z .boolean() .optional() .describe( "The record of what was approved could not be read, so no settings-file hook runs anywhere until a set is approved again. Present only when that is the case.", ), }); export type HookRequests = z.infer; export const HookDigestParamSchema = z.object({ digest: hookDigest.describe("Which hook set, by its fingerprint.") });