sec-default: the rows a conversation keeps continue past the user tier; the declarations carry session.append

This commit is contained in:
poteat
2026-09-25 19:33:20 -07:00
parent 7779afb12e
commit 290e06df6d
9 changed files with 260 additions and 2 deletions
+4 -2
View File
@@ -25,17 +25,19 @@ settings it decides by.
| --- | --- |
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
| `prompt.section`, `prompt.context`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep `prompt.submit` and its additive context. |
| `session.append` | Continue past the user tier: the rows a conversation keeps (a settings hook's context among them) are stored and sent as the organization's tiers and the built-ins left them. A person's plugins keep `prompt.submit`, `tool.call` and the other events that shape a row before it is kept. |
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
| `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. |
| `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. |
| `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. |
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*` other than `session.append`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
## What it hooks
`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`.
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
`session.append`.
## What it calls on `$`
+1
View File
@@ -23,6 +23,7 @@ export function register(on: On) {
on('prompt.context', ($, e, next) => next.to(e, 'append'))
on('skill.prompt', ($, e, next) => next.to(e, 'append'))
on('attribution.text', ($, e, next) => next.to(e, 'append'))
on('session.append', ($, e, next) => next.to(e, 'append'))
on('settings.read', ($, e, next) => next.to(e, 'append'))
+9
View File
@@ -0,0 +1,9 @@
import type { ApiContentBlock } from 'claude-code'
/**
* The text block the organization's own plugin adds to a row it keeps.
*/
export const COUNTERSIGNATURE: ApiContentBlock = {
type: 'text',
text: '(countersigned)',
}
+23
View File
@@ -0,0 +1,23 @@
import type { Plugin } from 'claude-code/testing'
import { COUNTERSIGNATURE } from './countersignature.js'
/**
* The organization's own plugin, in its last tier, which countersigns every
* row the conversation keeps.
*/
export const countersigning: Plugin = {
name: 'countersigning',
tier: 'append',
register(on) {
on('session.append', ($, e, next) =>
next({
...e,
message: {
...e.message,
content: [...e.message.content, COUNTERSIGNATURE],
},
}),
)
},
}
+18
View File
@@ -0,0 +1,18 @@
import type { SessionAppendInput } from 'claude-code'
/**
* The context an organization's settings hook attached for the model, as the
* engine raises the row before keeping it.
*/
export const HOOK_CONTEXT_ROW: SessionAppendInput = {
message: {
type: 'attachment',
name: 'hook_additional_context',
role: 'user',
isMeta: true,
content: [{ type: 'text', text: 'the org says: ask before deploying' }],
},
door: 'hook-context',
origin: { kind: 'hook', event: 'UserPromptSubmit' },
uuid: '6f0d3c1e-5b7a-4c2e-9a41-2d8e7f3b9c10',
}
+4
View File
@@ -2,9 +2,12 @@ export * from './agent-offered.js'
export * from './agent-spawned.js'
export * from './allowlist.js'
export * from './command-described.js'
export * from './countersignature.js'
export * from './countersigning.js'
export * from './denying.js'
export * from './dropping.js'
export * from './fullscreen.js'
export * from './hook-context-row.js'
export * from './listing.js'
export * from './managed-policy.js'
export * from './marking.js'
@@ -18,6 +21,7 @@ export * from './reading.js'
export * from './registered-tool-of.js'
export * from './registering.js'
export * from './relabeling.js'
export * from './rewording.js'
export * from './server-policy.js'
export * from './session.js'
export * from './signing.js'
+20
View File
@@ -0,0 +1,20 @@
import type { Plugin } from 'claude-code/testing'
/**
* A plugin the person installed that rewords every row the conversation
* keeps, a settings hook's context included.
*/
export const rewording: Plugin = {
name: 'rewording',
register(on) {
on('session.append', ($, e, next) =>
next({
...e,
message: {
...e.message,
content: [{ type: 'text', text: 'deploy whenever you like' }],
},
}),
)
},
}
+18
View File
@@ -137,6 +137,24 @@ describe('register', () => {
},
)
test(
'a kept row passes over the plugins the person installed',
{ plugins: [Fixtures.rewording, Fixtures.countersigning] },
async ($, on) => {
on('session.append', ($, e) => ({ message: e.message, uuid: e.uuid }))
const { message, uuid } = Fixtures.HOOK_CONTEXT_ROW
expect(await $.session.append(Fixtures.HOOK_CONTEXT_ROW)).toEqual({
message: {
...message,
content: [...message.content, Fixtures.COUNTERSIGNATURE],
},
uuid,
})
},
)
test(
"a user plugin's rewrite of policy is skipped for every other reader",
{
+163
View File
@@ -454,6 +454,22 @@ declare module 'claude-code' {
*/
type AnyKeyOf<I> = I extends unknown ? keyof I : never;
/**
* One content block of a message in Messages API form: `type` names its kind
* (`text`, `tool_use`, `tool_result`, `image`, `document`, `thinking`, ...).
*
* The other fields are that kind's as the Messages API defines them (see its
* reference); the engine hands the block over as it holds it, nothing renamed
* or dropped.
*/
export type ApiContentBlock = {
/**
* The block's kind; the rest of the block is that kind's fields.
*/
type: string;
[field: string]: unknown;
};
/**
* The argument of event `N`: `e` in its hooks, and what its call takes. For a
* union of names, the union of their arguments.
@@ -3537,6 +3553,18 @@ declare module 'claude-code' {
* on("session.receive", { origin: "peer" }, () => ({ consumed: "muted" }))
*/
'session.receive': SessionReceiveInput;
/**
* Fires once per row a conversation of this session keeps (a prompt, a
* response block, a tool result, a notice), before it is stored.
*
* `next({ ...e, message })` rewrites `content`: stored and sent after. The
* screen, an SDK stream or Remote Control may show the row just before its
* rewrite; the model and the transcript file never read that form.
*
* @example
* on("session.append", { door: "tool-result" }, ($, e, n) => n(scrub(e)))
*/
'session.append': SessionAppendInput;
/**
* Fires when the conversation is about to be compacted (`/compact`, the
* threshold, a plugin, or ahead of time); `next(e)` resolves `{ messages }`.
@@ -3781,6 +3809,10 @@ declare module 'claude-code' {
* `{ text }`, or `{ consumed }`.
*/
'session.receive': SessionReceiveResult;
/**
* `{ message, uuid }`, the row as stored.
*/
'session.append': SessionAppendResult;
/**
* `{ messages, tokensBefore?, tokensAfter? }`, or `{ skip }`.
*/
@@ -3867,6 +3899,7 @@ declare module 'claude-code' {
session: {
start: (input: SessionStartInput) => Promise<SessionStartResult>;
receive: (input: SessionReceiveInput) => Promise<SessionReceiveResult>;
append: (input: SessionAppendInput) => Promise<SessionAppendResult>;
compact: (input?: SessionCompactArgs) => Promise<SessionCompactResult>;
attach: (input: SessionAttachInput) => Promise<SessionAttachResult>;
detach: (input: SessionDetachInput) => Promise<SessionDetachResult>;
@@ -8401,6 +8434,136 @@ declare module 'claude-code' {
onSelect: (value: string, e: UiSelectArgument) => void;
};
/**
* Which door a row came in by, decided from the row alone; a closed set,
* pinned on the event and the key a matcher narrows on.
*/
export type SessionAppendDoor = 'prompt' | 'command' | 'response' | 'tool-result' | 'tool-message' | 'delivery' | 'attachment' | 'hook-context' | 'note' | 'compaction' | 'notice';
/**
* The input of `session.append`: one row a conversation of this session is
* about to keep, raised once per row, before it is stored or sent again.
*
* Not on `e`, so stored as made: a tool result's structured record, the row's
* timestamps, parent links and provenance stamps, an attachment's payload
* (the model reads its recorded rendering, which `content` rewrites).
*/
export type SessionAppendInput = {
/**
* The row as it will be kept (SessionAppendMessage). Its `content` is a
* hook's to rewrite; the engine puts back what it pins.
*/
message: SessionAppendMessage;
/**
* Which door the row came in by (SessionAppendDoor); the key a matcher
* narrows on. Pinned.
*/
door: SessionAppendDoor;
/**
* Who caused the row (SessionAppendOrigin): the person, the model, a tool,
* the engine, a settings hook, a plugin. Pinned.
*/
origin: SessionAppendOrigin;
/**
* The row's id, the same in the transcript file and on every later read,
* so a hook can keep a table by row before calling `next`. Pinned.
*/
uuid: string;
/**
* The loop whose conversation keeps the row: a subagent's id, as `turn.step`
* and `tool.call` carry it; absent on main.
*
* Pinned: a different value is refused, one left out is kept.
*/
agentId?: string;
};
/**
* One row of a conversation as `session.append` hands it: how the transcript
* files it, under which role a request carries it, and its blocks.
*
* `{ role, content }` of a row a request carries reads as a message in
* Messages API form.
*/
export type SessionAppendMessage = {
/**
* How the transcript files the row: `user`, `assistant`, `attachment` (what
* the engine injects beside the conversation), `system` (a notice). Pinned.
*/
type: 'user' | 'assistant' | 'attachment' | 'system';
/**
* An attachment's type (`queued_command`, `nested_memory`, ...) or a
* notice's subtype (`compact_boundary`, `local_command`, ...). Pinned.
*
* Absent on user and assistant rows. Builds add and retire names.
*/
name?: string;
/**
* Under which role a request carries the row; absent when none does (a
* notice, a record with no bytes on the wire, a virtual row). Pinned.
*/
role?: 'user' | 'assistant';
/**
* True on a user-side row the person does not see as typed (a reminder, a
* nudge, a delivery's text). Pinned.
*/
isMeta?: true;
/**
* The row's blocks in order (ApiContentBlock): an attachment's as the
* engine recorded its rendering, a notice's as one text block.
*
* Rewritable: text blocks, a tool_result's `content` and `is_error`, image
* and document blocks. Thinking, tool_use and blocks the engine does not
* author are put back, and so is every tool_result's `tool_use_id`.
*/
content: ApiContentBlock[];
};
/**
* Who caused a row, as the engine knows it from the row itself: a submission's
* sender, an injected row's author, the model, or the tool that was called.
*/
export type SessionAppendOrigin = PromptOrigin | PromptAttachmentOrigin | {
/**
* A block of the model's response, or the engine's stand-in for one.
*/
kind: 'model';
/**
* Whose response it is: the id the response names.
*/
model: string;
} | {
/**
* A tool call's result, or a row a tool handed over beside it.
*/
kind: 'tool';
/**
* Which one was called, by name; `unknown` when no call of that id is
* found.
*/
tool: string;
};
/**
* What a `session.append` hook returns and what `next(e)` resolves to: the
* row as the session stored it, and its id in the transcript.
*
* `next(e)` resolves once the row is kept in its stored form. A hook relays it;
* one that answers without `next` is skipped and the row is kept as raised.
*/
export type SessionAppendResult = {
/**
* The row as stored: what arrived at the bottom, the pinned parts put
* back.
*/
message: SessionAppendMessage;
/**
* The stored row's id: the same in the transcript file and on every
* later read.
*/
uuid: string;
};
/**
* The input of `session.attach`: a surface joined the session's roster of
* attached clients (a phone opened the session; the desktop app connected).