Palantir Foundry for Claude Code: Orthanc tools, an approval ladder for Foundry mutations, scoped Foundry context, one slash command per Foundry resource and a…

A Claude Code mod that lets Claude work in Palantir Foundry through the Orthanc CLI, with the operator in control of every change. Orthanc is the only path into Foundry: when it can't do something, the mod says so instead of falling back to REST.
orthanc_help discovers Orthanc's command surface; orthanc_cli runs a command in agent mode and returns its foundry-agent-v1 envelope. Output over 20,000 chars is cut for the model, with row names, counts and the page token kept above the cut and the whole envelope written to a file.ask-always, allow-edits, auto (a Haiku guard judges each mutation) and yolo. Destructive changes always ask; protected branches ask unless yolo; branch-scoped work needs an agent branch; calls naming the Compass root are denied; a call repeated three times is blocked.--branch <global branch RID>; dataset, stream and build writes must name a work branch; code goes to a work branch through orthanc repository push and a pull request. Branchless writes, writes to main/master, proposal merges and approvals, release tags, default-branch pushes, raw git push to a Foundry repository and Foundry writes outside Orthanc are blocked. Orthanc writes run only through orthanc_cli: Bash may read through Orthanc but not write, however the shell wraps the call./fde on, or /fde <goal>): binds the session to one agent global branch. Ontology writes are pinned to it, each --apply needs a dry run of the same call first, and /fde end hands the branch back to the operator, still open./foundry, a live view of what is in context, like /context for Foundry.foundry-scout, foundry-builder, foundry-reviewer, foundry-oracle) and skills for the Foundry delivery lifecycle.The mod does all of its Foundry work through the orthanc binary. Install the latest release and set up a profile for your stack:
curl -fsSL https://github.com/zaycruz/orthanc/releases/latest/download/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
orthanc --version
orthanc onboarding # skipped by the installer if a profile already exists
The mod runs orthanc from PATH (or ORTHANC_BIN). Add more stacks with orthanc configure profile create. Tokens stay in the macOS Keychain and never pass through Claude: /auth prints the command for you to run in your own terminal.
/plugin marketplace add zaycruz/claude-foundry
/plugin install foundry@claude-foundry
Or load a checkout for one session: claude --plugin-dir ./claude-foundry. Function hooks must be enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). /onboard checks that Orthanc, the profile and the credentials all work.
| Command | Does | |||
|---|---|---|---|---|
/foundry | The Foundry resources in context: counts, items, clear buttons, prompt size | |||
/open | Stack, then ontology, then projects | |||
/onboard, /mcp-status | Check orthanc, profiles, stack, credentials, context | |||
/auth [profile] | How to store credentials, and whether they work | |||
/profile, /ontology, /project | Choose the stack, ontology, projects | |||
/object-type, /action-type | Ontology types in scope | |||
/folder /dataset /media-set /repository /contour /source /developer-console /aip-chatbot | Compass resources in scope | |||
/media-item, /objects, /function, /language-model, /permission, /search | Lookups added to context | |||
| `/scope [show\ | clear\ | ontology], /approval [mode\ | rules], /recap` | Context, approval mode and guard rules, a two-paragraph recap |
/fde on, /fde <goal>, /fde goal <text>, /fde --branch <rid> [goal], /fde, /fde resume, /fde end | FDE mode: start on a new agent branch (with or without a goal) or adopt an open one, set the goal, show status, rebind after a restart, hand off | |||
/trace | Trace export status, and whether the last Foundry trace reached Langfuse |
Every command has one name; there are no aliases. Every list command takes a filter, and clear. /link-type, /interface, /egress-policy, /certificate and /automation wait on Orthanc list commands and say so; the mod never falls back to Foundry REST.
/fde on puts the session in FDE mode: it creates agent/fde-<timestamp> as a global branch on the selected ontology and binds the session, subagents included, to it. /fde <goal> does the same with a goal, which names the branch (agent/fde-<goal>-<timestamp>) and heads the prompt and the handoff; /fde goal <text> sets or changes it later. /fde --branch <rid> [goal] adopts an open agent branch instead. While bound:
--profile or an abbreviated branch flag is refused. Dataset, stream and build writes keep the branch rule (a work branch, never main) but are not pinned to the global branch.global-branch add-repo runs on the bound branch only.--apply runs only after a clean dry run of the same call. The plan pins every file the call reads (--*-file values, @file, and any path-like argument that reads as a file) by SHA-256, checked again just before the call runs, and a file naming another global branch or ontology is refused. Each dry run licenses one apply: the apply takes it before running, so two identical applies in parallel cannot share it, and a refused apply, or one Orthanc rejects as a usage error, gives it back; any other failure keeps it spent, since Foundry may have changed. Directory arguments are not hashed, and a file over 20 MB is refused./fde end prints a handoff (branch, applied writes, how to close it) and leaves the branch open: merging or closing it is the operator's call. If a session ends while bound, the next one offers /fde resume. The mode only adds checks. The branch rule, the approval ladder and the destructive rule judge every bound call as before, in every approval mode.
In auto, a Haiku guard judges each Foundry write. Give it your own rules in ~/.claude/foundry/rules.md, one per line. A project can add .claude/foundry-rules.md; since a cloned repository can carry that file, its rules may only make the guard stricter. Claude cannot edit either file, nor read ~/.claude/foundry/; /approval rules shows the rules. For example:
Approve writes in the Sandbox project on agent/ branches.
Ask before any change to the Person or Organization object types.
/approval rules shows what the guard reads. The rules steer the guard only: the branch rule and the destructive rule run first and cannot be loosened.
Claude Code's own auto-mode classifier judges tool calls before the mod sees them, and by default it can refuse Foundry writes as changes to shared resources. To let branch-scoped Foundry work run in auto mode, add an allow rule under autoMode.allow in ~/.claude/settings.json (keep the defaults from claude auto-mode defaults, since a custom list replaces them):
Foundry Branch Work: Palantir Foundry writes made through the foundry Claude Code mod, and the
mod's foundry-* subagents doing them, when every write targets an agent work branch (an agent/
global branch or its ri.branch. RID, an agent- dataset branch, a non-default code branch, or a
resource this session created). The mod denies writes on main/master, merges and
default-branch pushes itself. Does NOT cover writes on main, master or the production
ontology, merges or approvals, permission changes, or deleting resources the session did
not create.
Run claude auto-mode critique after editing.
Each turn that touches Foundry (Orthanc tools, Orthanc in Bash, a gated call, a Palantir MCP tool) becomes one OTLP trace: the prompt and answer, a span per tool call with the gate's outcome and reason, and a generation span with the model and token usage. Only Foundry calls carry their input and output; other tools in the turn are named, not shown. Turns in one Claude Code session share a Langfuse session. Nothing is exported until you configure an endpoint, in the environment or in ~/.claude/foundry/otel.env. The mod reads that file once per session as KEY=value lines (it never runs it), and Claude can neither read nor edit it. A value can be a 1Password reference, resolved with op read, so the file holds no keys:
# Langfuse
LANGFUSE_BASE_URL=https://cloud.langfuse.com
LANGFUSE_PUBLIC_KEY="op://Vault/Langfuse/public_key"
LANGFUSE_SECRET_KEY="op://Vault/Langfuse/secret_key"
# or any OTLP/HTTP collector
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector.example.com/v1/traces
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20...
Prefer the file to exported variables: keys in the session's environment are visible to any shell command Claude runs. FOUNDRY_OTEL=off turns tracing off, FOUNDRY_OTEL=all exports every turn, FOUNDRY_OTEL_TOOL_IO=off drops tool input and output, and OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES set the resource. Before export, values under sensitive keys (tokens, RIDs, hosts, names), inside Orthanc envelopes too, are replaced, and tokens, credentials, RIDs, emails, phone numbers, private IPs and internal hostnames are redacted; add your own domains with FOUNDRY_OTEL_REDACT_DOMAINS=.... /trace shows the collector's host, never a header. Export is fire-and-forget and never changes what a tool call does.
claude plugin validate .
claude plugin test .
hooks/*.test.ts cover the ladder, parity, the context view and trace export. For an E2E run against your own stack, record real Orthanc output and replay it:
FOUNDRY_E2E_PROFILE=<profile> FOUNDRY_E2E_PROJECT=<project rid> \
FOUNDRY_E2E_ONTOLOGY=<ontology rid> ./scripts/record.sh
That writes hooks/fixtures.local.ts, which hooks/e2e.local.test.ts replays. Both stay out of git: they hold your stack's data.
MIT
hooks/register.tsx 1595 lines1// foundry: a governed Palantir Foundry lane as a Claude Code mod.
2// Orthanc tools, the approval ladder, Foundry context in the system prompt,
3// one slash command per Foundry resource, and /foundry, the visual view of what is in context.
4// Orthanc is the only path to Foundry: what it cannot do waits on an orthanc bead.
5
6import { atom, read, update } from 'claude-code'
7import type { Args, EngineInterface, EventResult, Next, Register } from 'claude-code'
8
9import type { FdeSession, FoundryPicker, FoundryPickerKind, FoundryPickItem, FoundryResource, FoundryResourceKind, FoundryScope } from '../types'
10import {
11 applyClear,
12 blockedText,
13 type ClearTarget,
14 COMMAND_TABLE,
15 type CommandEntry,
16 COMPASS_KINDS,
17 type CompassKind,
18 contextLines,
19 emptyScope,
20 foundryTree,
21 hasFocus,
22 mergeSelections,
23 normalizeScope,
24 OBJECT_QUERY_BEAD,
25 objectsQuery,
26 ontologyChanged,
27 RESOURCE_LABELS,
28 resourceArgs,
29 resourceRows,
30} from './catalog'
31import {
32 adoptable,
33 branchCreateArgs,
34 foreignRids,
35 goalLabel,
36 takePlan,
37 branchRidFrom,
38 contentHash,
39 fdeBind,
40 fdeBranchName,
41 fdeContext,
42 fdeHandoff,
43 fdeStatus,
44 fileCandidates,
45 hasApply,
46 parseFdeArgs,
47 planGate,
48 planSignature,
49 recordApply,
50 recordPlan,
51} from './fde'
52import {
53 type Envelope,
54 failure,
55 guideEnvelope,
56 isCredentialCommand,
57 isErrorEnvelope,
58 isOversized,
59 isUsageError,
60 listItems,
61 manifestFrom,
62 normalizeCommand,
63 parseEnvelope,
64 renderCommand,
65 renderListing,
66 rowsOf,
67 serialize,
68} from './orthanc'
69import {
70 APPROVAL_MODES,
71 type ApprovalMode,
72 type ManifestCommand,
73 classifyCall,
74 classifyOrthancCommand,
75 compassRootReason,
76 DEFAULT_APPROVAL_MODE,
77 type GateClassification,
78 type GuardVerdict,
79 FOUNDRY_GIT_PUSH_REASON,
80 gitPushDirectories,
81 gitPushNamesFoundry,
82 ladderDecision,
83 loopStep,
84 modeGuidance,
85 ORTHANC_CLI,
86 ORTHANC_HELP,
87 parseApprovalMode,
88 resolveOrthancCommand,
89 rulesWriteReason,
90} from './policy'
91import {
92 domainPatterns,
93 langfuseApiBase,
94 otlpBody,
95 parseEnvFile,
96 randomHex,
97 redactString,
98 resourceAttributes,
99 serializePayload,
100 type SpanRecord,
101 type TraceConfig,
102 type TraceEnv,
103 traceConfig,
104} from './telemetry'
105
106const SCOPE = { plugin: 'foundry', key: 'scope' } as const
107const PICKER = { plugin: 'foundry', key: 'picker' } as const
108const BRANCH = { plugin: 'foundry', key: 'workingBranch' } as const
109const FDE = { plugin: 'foundry', key: 'fde' } as const
110const PANE = 'foundry-picker'
111const CONTEXT_PANE = 'foundry-context'
112const pickerAtom = atom(PICKER, null)
113const scopeAtom = atom(SCOPE, emptyScope('unknown', ''))
114const fdeAtom = atom(FDE, null)
115const GUARD_MODEL = 'claude-haiku-5-5'
116const VISIBLE_ROWS = 12
117const TREE_ROWS = 8
118const ROOT_CONCURRENCY = 6
119const CLI_TIMEOUT_MS = 300_000
120
121let loop: { identity: string; streak: number } | null = null
122let manifestCache: readonly ManifestCommand[] | undefined
123const helpCache = new Map<string, string>()
124const refusedCommands: string[] = []
125
126const asString = (value: unknown) => (typeof value === 'string' ? value : '')
127const errorsOf = (envelope: Envelope) => JSON.stringify(envelope.errors).slice(0, 400)
128
129async function orthancBin($: EngineInterface): Promise<string> {
130 return (await $.env.get('ORTHANC_BIN'))?.trim() || 'orthanc'
131}
132
133// orthanc in agent mode, argv only, no shell; any failure comes back as an
134// envelope so callers handle one shape.
135async function runOrthanc(
136 $: EngineInterface,
137 argv: readonly string[],
138 profile?: string,
139 timeoutMs = CLI_TIMEOUT_MS,
140): Promise<Envelope> {
141 const full = ['--agent', '--non-interactive', ...(profile ? ['--profile', profile] : []), ...argv]
142 try {
143 const ran = await $.process.run([await orthancBin($), ...full], { timeoutMs })
144 return (
145 parseEnvelope(ran.stdout) ??
146 failure('orthanc-launch-error', `orthanc exited ${ran.exitCode} with no foundry-agent-v1 envelope`, argv.join(' '), {
147 exit_code: ran.exitCode,
148 stderr: ran.stderr.slice(0, 4000),
149 })
150 )
151 } catch (error) {
152 return failure('orthanc-launch-error', error instanceof Error ? error.message : String(error), argv.join(' '))
153 }
154}
155
156async function loadManifest($: EngineInterface): Promise<readonly ManifestCommand[]> {
157 if (manifestCache !== undefined) return manifestCache
158 const loaded = manifestFrom(await runOrthanc($, ['agent-manifest'], undefined, 30_000))
159 if (loaded.length === 0) throw new Error('orthanc agent-manifest returned no commands')
160 manifestCache = loaded
161 return loaded
162}
163
164async function helpText($: EngineInterface, path: string): Promise<string | null> {
165 const cached = helpCache.get(path)
166 if (cached !== undefined) return cached
167 try {
168 const ran = await $.process.run([await orthancBin($), ...path.split(' '), '--help'], { timeoutMs: 15_000 })
169 const text = `${ran.stdout}${ran.stderr}`.trim()
170 if (text === '') return null
171 helpCache.set(path, text)
172 return text
173 } catch {
174 return null
175 }
176}
177
178async function renderHelp($: EngineInterface, input: { group?: string; command?: string; query?: string }): Promise<string> {
179 const commands = await loadManifest($)
180 if (!input.command) return renderListing(commands, input)
181 const path = normalizeCommand(input.command)
182 const command = commands.find(entry => entry.path === path)
183 if (command === undefined) return `Unknown Orthanc command '${path}'. Use orthanc_help with query to search commands.`
184 return renderCommand(command, await helpText($, command.path))
185}
186
187async function executeCli($: EngineInterface, input: { command: string; args: string[]; profile?: string }): Promise<Envelope> {
188 const commands = await loadManifest($)
189 // The same resolution the guard classified, so what runs is what was judged.
190 const resolved = resolveOrthancCommand(input.command, input.args, commands)
191 const command = commands.find(entry => entry.path === resolved.path)
192 if (command === undefined) {
193 return failure('unknown-command', `Unknown Orthanc command '${input.command}'. Call orthanc_help first.`, input.command)
194 }
195 if (isCredentialCommand(command)) {
196 return failure(
197 'credential-command-denied',
198 `Orthanc command '${command.path}' requires credential input and is not available through orthanc_cli. The operator runs it in a terminal.`,
199 command.path,
200 )
201 }
202 const envelope = await runOrthanc($, [...command.path.split(' '), ...resolved.args], input.profile)
203 return guideEnvelope(envelope, command, isUsageError(envelope) ? await helpText($, command.path) : null)
204}
205
206async function storedScopes($: EngineInterface): Promise<Record<string, unknown>> {
207 const value = await $.store.get('scopes')
208 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
209}
210
211async function saveScope($: EngineInterface, scope: FoundryScope): Promise<void> {
212 const next = { ...scope, updatedAt: new Date().toISOString() }
213 await $.state.set(SCOPE, next)
214 await $.store.set('scopes', { ...(await storedScopes($)), [next.profile]: next })
215}
216
217async function approvalMode($: EngineInterface): Promise<ApprovalMode> {
218 return parseApprovalMode(asString(await $.store.get('approvalMode'))) ?? DEFAULT_APPROVAL_MODE
219}
220
221type Profile = { name: string; host: string; isDefault: boolean; status: string }
222
223async function listProfiles($: EngineInterface): Promise<Profile[]> {
224 const envelope = await runOrthanc($, ['configure', 'list'], undefined, 30_000)
225 return listItems(envelope, 'profiles').map(entry => ({
226 name: asString(entry.name),
227 host: asString(entry.host),
228 isDefault: entry.is_default === true,
229 status: asString(entry.status),
230 }))
231}
232
233async function activateProfile($: EngineInterface, profile: Profile): Promise<FoundryScope> {
234 const next = normalizeScope((await storedScopes($))[profile.name], profile.name, profile.host)
235 await $.env.set('FOUNDRY_PROFILE', profile.name)
236 await $.store.set('defaultProfile', profile.name)
237 await $.state.set(BRANCH, null)
238 await saveScope($, { ...next, host: profile.host || next.host })
239 return next
240}
241
242async function startupScope($: EngineInterface): Promise<void> {
243 const profiles = await listProfiles($)
244 const wanted = (await $.env.get('FOUNDRY_PROFILE')) || asString(await $.store.get('defaultProfile'))
245 const chosen = profiles.find(profile => profile.name === wanted) ?? profiles.find(profile => profile.isDefault)
246 if (chosen === undefined) {
247 await $.state.set(SCOPE, emptyScope(wanted || 'unknown', ''))
248 return
249 }
250 const scope = normalizeScope((await storedScopes($))[chosen.name], chosen.name, chosen.host)
251 await $.env.set('FOUNDRY_PROFILE', chosen.name)
252 await $.state.set(SCOPE, { ...scope, host: chosen.host || scope.host })
253}
254
255async function currentScope($: EngineInterface): Promise<FoundryScope> {
256 return normalizeScope((await $.state.get(SCOPE)).value, 'unknown', '')
257}
258
259const profileArg = (scope: FoundryScope) => (scope.profile === 'unknown' ? undefined : scope.profile)
260
261function contextText(scope: FoundryScope, mode: ApprovalMode): string {
262 const lines = [
263 '# Palantir Foundry',
264 'Tools: `mcp__foundry__orthanc_help` (orthanc_help in the skills) discovers the Orthanc command surface; ' +
265 '`mcp__foundry__orthanc_cli` (orthanc_cli) runs Orthanc in agent mode. Prefer them over raw `orthanc` in Bash ' +
266 'and over any Palantir MCP server, which is a read-only fallback for documentation.',
267 `Foundry stack: ${scope.profile}${scope.host ? ` (${scope.host})` : ''}. The operator switches it with /profile; do not pass --profile.`,
268 ...contextLines(scope),
269 ]
270 if (hasFocus(scope)) {
271 lines.push(
272 'Start Foundry resource discovery from the selected ontology, projects and resources.',
273 'For a bounded change: inspect the target, make the smallest reversible change, then run focused verification.',
274 'Read documentation only for a concrete unknown, a tool error, or a high-risk irreversible operation.',
275 )
276 }
277 lines.push(modeGuidance(mode))
278 return lines.join('\n')
279}
280
281const RULES_LIMIT = 4000
282
283// Operator rules for the Haiku guard: ~/.claude/foundry/rules.md (the
284// operator's), then the project's .claude/foundry-rules.md, which a cloned
285// repository can carry and so may only make the guard stricter. They steer the
286// guard only; the branch rule and the destructive rule run before it.
287type OperatorRule = { path: string; text: string; isProject: boolean }
288// Read once per session: an edit made mid-session (by anyone) waits for the next.
289let rulesSnapshot: OperatorRule[] | undefined
290
291async function operatorRules($: EngineInterface): Promise<OperatorRule[]> {
292 if (rulesSnapshot !== undefined) return rulesSnapshot
293 const home = (await $.env.get('HOME'))?.trim()
294 const paths = [...(home ? [{ path: `${home}/.claude/foundry/rules.md`, isProject: false }] : []), { path: '.claude/foundry-rules.md', isProject: true }]
295 const found: { path: string; text: string; isProject: boolean }[] = []
296 for (const { path, isProject } of paths) {
297 const text = await $.fs.read(path).then(String).catch(() => '')
298 if (text.trim() !== '') found.push({ path, text: text.trim().slice(0, RULES_LIMIT), isProject })
299 }
300 rulesSnapshot = found
301 return found
302}
303
304// The auto tier's classifier: a Haiku call.
305// Anything but a clean JSON verdict is null, which the ladder turns into ask.
306// Project rules can come with a cloned repository, so they only tighten: the
307// guard runs with and without them and the stricter verdict stands.
308async function guardVerdict(
309 $: EngineInterface,
310 tool: string,
311 input: unknown,
312 classification: GateClassification,
313): Promise<GuardVerdict | null> {
314 const rules = await operatorRules($)
315 const operator = rules.filter(rule => !rule.isProject).map(rule => rule.text).join('\n\n')
316 const project = rules.filter(rule => rule.isProject).map(rule => rule.text).join('\n\n')
317 const base = await guardOnce($, tool, input, classification, operator, '')
318 if (project === '' || base === null || base.action === 'deny') return base
319 const withProject = await guardOnce($, tool, input, classification, operator, project)
320 if (withProject === null) return null
321 return GUARD_RANK[withProject.action] > GUARD_RANK[base.action] ? withProject : base
322}
323
324const GUARD_RANK = { approve: 0, ask: 1, deny: 2 } as const
325
326async function guardOnce(
327 $: EngineInterface,
328 tool: string,
329 input: unknown,
330 classification: GateClassification,
331 operator: string,
332 project: string,
333): Promise<GuardVerdict | null> {
334 const answer = await $.model.complete({
335 model: GUARD_MODEL,
336 maxTokens: 200,
337 timeoutMs: 15_000,
338 system:
339 'You review one Palantir Foundry mutation an agent wants to run. Answer only JSON: ' +
340 '{"action":"approve"|"ask"|"deny","reason":"<one sentence>"}. approve: routine, reversible, ' +
341 'scoped work on an agent branch. ask: unclear blast radius, shared or production resources, ' +
342 'permissions, schema or ontology changes. deny: clearly destructive or targeting main/master or production.' +
343 (operator === '' ? '' : `\nThe operator's rules, which take precedence over the defaults above:\n${operator}`) +
344 (project === ''
345 ? ''
346 : `\nThe project's rules, which may only make you more cautious:\n${project}`),
347 prompt: JSON.stringify({ tool, input, classification }).slice(0, 8000),
348 })
349 if (!answer.isAnswered) return null
350 try {
351 const parsed: unknown = JSON.parse(answer.text.replace(/^```(json)?|```$/g, '').trim())
352 if (typeof parsed !== 'object' || parsed === null) return null
353 const { action, reason } = parsed as Record<string, unknown>
354 if (action !== 'approve' && action !== 'ask' && action !== 'deny') return null
355 return { action, reason: `guard: ${asString(reason) || action}` }
356 } catch {
357 return null
358 }
359}
360
361// The session's directory; '/' where the host has none (the test kit).
362async function sessionCwd($: EngineInterface): Promise<string> {
363 try {
364 return await $.session.cwd()
365 } catch {
366 return '/'
367 }
368}
369
370async function homeDir($: EngineInterface): Promise<string> {
371 try {
372 return (await $.env.get('HOME'))?.trim() ?? ''
373 } catch {
374 return ''
375 }
376}
377
378// Tracing: one OTLP trace per turn that touched Foundry, sent to Langfuse or
379// any OTLP/HTTP collector. Config comes from the environment, else from an env
380// file (~/.claude/foundry/otel.env, which Claude cannot write) sourced in a
381// subprocess; keys and headers stay in the mod's memory and are never shown.
382// Tracing never changes a gate outcome and never blocks a turn.
383type Tracing = { config: TraceConfig; resource: Record<string, string>; redact: RegExp[]; everyTurn: boolean; source: string }
384type TurnTrace = { traceId: string; rootSpanId: string; startMs: number; prompt: string; spans: SpanRecord[]; touched: boolean; dropped: number }
385type GateNote = { outcome?: string; reason?: string; command?: string; foundry: boolean }
386let tracing: Tracing | null = null
387let literalNote = ''
388let tracingNote = 'off: no OTLP endpoint or Langfuse keys configured'
389let lastExport: { traceId: string; status: string; atMs: number } | null = null
390const turnTraces = new Map<string, TurnTrace>()
391const ENV_FILE_TIMEOUT_MS = 10_000
392const MAX_SPANS = 200
393const STALE_TURN_MS = 3_600_000
394
395// Read as data (parseEnvFile), never sourced. 1Password references resolve
396// through `op read` by argv, in-process; the values never reach the model.
397async function traceEnvFromFile($: EngineInterface, path: string): Promise<TraceEnv | null> {
398 const text = await $.fs.read(path).then(String).catch(() => null)
399 if (text === null) return null
400 const { env, refs, literalSecrets } = parseEnvFile(text)
401 if (literalSecrets.length > 0) literalNote = ` (ignored ${literalSecrets.join(', ')}: write them as op:// references)`
402 // Together, so a hung `op` costs one timeout, not one per reference.
403 await Promise.all(
404 Object.entries(refs).map(async ([key, ref]) => {
405 const ran = await $.process.run(['op', 'read', '--no-newline', ref], { timeoutMs: ENV_FILE_TIMEOUT_MS }).catch(() => null)
406 if (ran && ran.exitCode === 0 && ran.stdout.trim() !== '') env[key] = ran.stdout.trim()
407 }),
408 )
409 return env
410}
411
412async function loadTracing($: EngineInterface, profile: string | null): Promise<void> {
413 tracing = null
414 literalNote = ''
415 // $.env.get takes literal names, so the variables the mod reads stay listable.
416 const read = (value: string | undefined) => value || undefined
417 const env: TraceEnv = {
418 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: read(await $.env.get('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')),
419 OTEL_EXPORTER_OTLP_ENDPOINT: read(await $.env.get('OTEL_EXPORTER_OTLP_ENDPOINT')),
420 OTEL_EXPORTER_OTLP_HEADERS: read(await $.env.get('OTEL_EXPORTER_OTLP_HEADERS')),
421 LANGFUSE_BASE_URL: read(await $.env.get('LANGFUSE_BASE_URL')),
422 LANGFUSE_HOST: read(await $.env.get('LANGFUSE_HOST')),
423 LANGFUSE_PUBLIC_KEY: read(await $.env.get('LANGFUSE_PUBLIC_KEY')),
424 LANGFUSE_SECRET_KEY: read(await $.env.get('LANGFUSE_SECRET_KEY')),
425 FOUNDRY_OTEL: read(await $.env.get('FOUNDRY_OTEL')),
426 FOUNDRY_OTEL_TOOL_IO: read(await $.env.get('FOUNDRY_OTEL_TOOL_IO')),
427 PALANTIR_AGENT_OTEL_TOOL_IO: read(await $.env.get('PALANTIR_AGENT_OTEL_TOOL_IO')),
428 OTEL_SERVICE_NAME: read(await $.env.get('OTEL_SERVICE_NAME')),
429 OTEL_RESOURCE_ATTRIBUTES: read(await $.env.get('OTEL_RESOURCE_ATTRIBUTES')),
430 FOUNDRY_OTEL_REDACT_DOMAINS: read(await $.env.get('FOUNDRY_OTEL_REDACT_DOMAINS')),
431 }
432 for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]
433 let source = 'environment'
434 if ((env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off') {
435 tracingNote = 'off: FOUNDRY_OTEL=off'
436 return
437 }
438 if (traceConfig(env) === null) {
439 const home = await homeDir($)
440 // Only the operator's own file: a path from the environment could come with a project.
441 if (home === '') return
442 const path = `${home}/.claude/foundry/otel.env`
443 const fromFile = await traceEnvFromFile($, path).catch(() => null)
444 if (fromFile === null) return
445 Object.assign(env, fromFile)
446 source = '~/.claude/foundry/otel.env'
447 }
448 const config = traceConfig(env)
449 if (config === null) {
450 tracingNote = (env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off' ? 'off: FOUNDRY_OTEL=off' : `off: ${source} has no OTLP endpoint or Langfuse keys (or a key reference did not resolve)`
451 return
452 }
453 tracing = {
454 config,
455 resource: resourceAttributes(env, profile),
456 redact: domainPatterns(env.FOUNDRY_OTEL_REDACT_DOMAINS),
457 everyTurn: (env.FOUNDRY_OTEL ?? '').toLowerCase() === 'all',
458 source,
459 }
460 $.ui.log(`foundry: tracing to ${config.host}`, { to: 'debug' })
461 tracingNote = `on: ${config.host} (from ${source}${config.toolIo ? '' : ', tool input and output off'}${tracing.everyTurn ? ', every turn' : ', Foundry turns'})`
462}
463
464function startTurn(key: string, prompt: string, nowMs: number): TurnTrace {
465 for (const [stale, turn] of turnTraces) if (stale !== 'main' && nowMs - turn.startMs > STALE_TURN_MS) turnTraces.delete(stale)
466 const turn = { traceId: randomHex(32), rootSpanId: randomHex(16), startMs: nowMs, prompt, spans: [], touched: false, dropped: 0 }
467 turnTraces.set(key, turn)
468 return turn
469}
470
471function recordTool(key: string, tool: string, input: unknown, ran: unknown, note: GateNote, startMs: number, endMs: number): void {
472 if (tracing === null) return
473 const turn = turnTraces.get(key) ?? startTurn(key, '', startMs)
474 turn.touched ||= note.foundry
475 if (turn.spans.length >= MAX_SPANS) {
476 turn.dropped += 1
477 return
478 }
479 const result = (ran ?? {}) as Record<string, unknown>
480 const denied = typeof result.deny === 'string' ? result.deny : undefined
481 const output = denied ?? result.text ?? result.result
482 // Only Foundry calls carry their input and output; other tools in the turn are named, not shown.
483 const io = tracing.config.toolIo && note.foundry
484 const attributes: SpanRecord['attributes'] = {
485 'langfuse.observation.type': 'tool',
486 'gen_ai.tool.name': tool,
487 ...(note.command ? { 'foundry.command': redactString(note.command, tracing.redact) } : {}),
488 ...(note.outcome ? { 'foundry.gate.outcome': note.outcome } : {}),
489 ...(note.reason ? { 'foundry.gate.reason': serializePayload(note.reason, tracing.redact) ?? '' } : {}),
490 }
491 const inputJson = io ? serializePayload(input, tracing.redact) : undefined
492 const outputJson = io ? serializePayload(output, tracing.redact) : undefined
493 if (inputJson) attributes['langfuse.observation.input'] = inputJson
494 if (outputJson) attributes['langfuse.observation.output'] = outputJson
495 turn.spans.push({
496 spanId: randomHex(16),
497 parentSpanId: turn.rootSpanId,
498 name: `tool ${tool}${note.command ? ` ${redactString(note.command, tracing.redact)}` : ''}`,
499 startMs,
500 endMs,
501 attributes,
502 ...(denied ? { error: `denied: ${denied}` } : result.isError === true ? { error: 'tool error' } : {}),
503 })
504}
505
506async function exportTurn($: EngineInterface, key: string, answer: string, usage: unknown, reason: string, endMs: number): Promise<void> {
507 const turn = turnTraces.get(key)
508 turnTraces.delete(key)
509 // In FDE mode every turn is part of the engagement, so every turn is exported.
510 const fde = await fdeState($).catch(() => null)
511 if (tracing === null || turn === undefined || !(turn.touched || tracing.everyTurn || fde !== null)) return
512 const sessionId = await $.session.id().catch(() => 'unknown')
513 const redact = tracing.redact
514 const root: SpanRecord = {
515 spanId: turn.rootSpanId,
516 name: key === 'main' ? 'claude-foundry turn' : 'claude-foundry subagent turn',
517 startMs: turn.startMs,
518 endMs,
519 attributes: {
520 'langfuse.observation.type': 'agent',
521 'langfuse.trace.name': key === 'main' ? 'claude-foundry turn' : 'claude-foundry subagent turn',
522 'langfuse.session.id': sessionId,
523 'session.id': sessionId,
524 'foundry.turn.reason': reason,
525 'foundry.tool.count': turn.spans.length,
526 ...(turn.dropped > 0 ? { 'foundry.spans.dropped': turn.dropped } : {}),
527 ...(key === 'main' ? {} : { 'foundry.agent.id': key }),
528 ...(fde ? { 'foundry.fde.branch': redactString(fde.branchRid, redact), 'foundry.fde.goal': redactString(fde.goal, redact) } : {}),
529 },
530 ...(reason === 'error' || reason === 'refusal' ? { error: reason } : {}),
531 }
532 const prompt = serializePayload(turn.prompt || undefined, redact)
533 const output = serializePayload(answer || undefined, redact)
534 if (prompt) root.attributes['langfuse.observation.input'] = prompt
535 if (output) root.attributes['langfuse.observation.output'] = output
536 const spans = [root, ...turn.spans]
537 const counted = usage as { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; model?: string } | undefined
538 if (counted?.model) {
539 spans.push({
540 spanId: randomHex(16),
541 parentSpanId: turn.rootSpanId,
542 name: 'model',
543 startMs: turn.startMs,
544 endMs,
545 attributes: {
546 'langfuse.observation.type': 'generation',
547 'gen_ai.request.model': counted.model,
548 'langfuse.observation.model.name': counted.model,
549 'langfuse.observation.usage_details': JSON.stringify({
550 input: counted.input_tokens ?? 0,
551 output: counted.output_tokens ?? 0,
552 cache_read_input_tokens: counted.cache_read_input_tokens ?? 0,
553 cache_creation_input_tokens: counted.cache_creation_input_tokens ?? 0,
554 }),
555 },
556 })
557 }
558 const body = otlpBody(turn.traceId, tracing.resource, spans)
559 const sent = { traceId: turn.traceId, status: 'sending', atMs: endMs }
560 lastExport = sent
561 try {
562 const response = await $.http.fetch(tracing.config.url, { method: 'POST', headers: tracing.config.headers, body })
563 sent.status = `HTTP ${response.status}`
564 } catch (error) {
565 sent.status = `failed: ${redactString(error instanceof Error ? error.message : 'fetch error').slice(0, 120)}`
566 }
567}
568
569function touchesFoundry(tool: string, input: Record<string, unknown>, classified: boolean): boolean {
570 if (classified || tool === ORTHANC_CLI || tool === ORTHANC_HELP) return true
571 if (tool === 'Bash') return /\borthanc\b/.test(asString(input.command))
572 return /^mcp__.*(palantir|foundry)/i.test(tool)
573}
574
575async function traceText($: EngineInterface): Promise<string> {
576 const lines = [`Tracing ${tracingNote}${literalNote}.`]
577 if (tracing === null) {
578 lines.push('Set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS, or LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY, in the environment or in ~/.claude/foundry/otel.env (KEY=value lines; keys as op:// references; read once per session).')
579 return lines.join('\n')
580 }
581 if (lastExport === null) {
582 lines.push('No Foundry turn exported yet this session.')
583 return lines.join('\n')
584 }
585 lines.push(`Last trace ${lastExport.traceId}: ${lastExport.status}.`)
586 const api = langfuseApiBase(tracing.config)
587 if (api === null || !lastExport.status.startsWith('HTTP 2')) return lines.join('\n')
588 const headers = Object.fromEntries(Object.entries(tracing.config.headers).filter(([name]) => name.toLowerCase() !== 'content-type'))
589 const found = await $.http.fetch(`${api}/traces/${lastExport.traceId}`, { headers }).catch(() => null)
590 if (found?.ok) {
591 let count = '?'
592 try {
593 const parsed = JSON.parse(found.text) as { observations?: unknown[] }
594 count = String(parsed.observations?.length ?? '?')
595 } catch {}
596 lines.push(`Langfuse has it: ${count} observations.`)
597 } else {
598 lines.push(`Langfuse lookup: ${found ? `HTTP ${found.status}` : 'failed'} (ingestion can take a few seconds; run /trace again).`)
599 }
600 return lines.join('\n')
601}
602
603async function askOperator($: EngineInterface, reason: string): Promise<boolean> {
604 try {
605 const answer = await $.ui.ask(`Approve Foundry mutation? ${reason}`, {
606 header: 'Foundry',
607 options: ['Approve', 'Decline'],
608 })
609 return answer === 'Approve'
610 } catch {
611 return false
612 }
613}
614
615// `notRun` is a failure Orthanc refused before reaching Foundry (a usage error);
616// any other failure may have changed something.
617async function serveTool($: EngineInterface, tool: string, input: Record<string, unknown>): Promise<{ out: EventResult<'tool.call'>; failed: boolean; notRun: boolean }> {
618 if (tool === ORTHANC_HELP) {
619 return { failed: false, notRun: false, out: {
620 result: await renderHelp($, {
621 group: asString(input.group).trim() || undefined,
622 command: asString(input.command).trim() || undefined,
623 query: asString(input.query).trim() || undefined,
624 }),
625 } }
626 }
627 const scope = await currentScope($)
628 const envelope = await executeCli($, {
629 command: normalizeCommand(asString(input.command)),
630 args: Array.isArray(input.args) ? input.args.map(String) : [],
631 profile: profileArg(scope),
632 })
633 const failed = isErrorEnvelope(envelope)
634 return { failed, notRun: failed && isUsageError(envelope), out: { result: serialize(envelope, await spill($, input.command, envelope)), ...(failed ? { context: ['The Orthanc call returned errors; read them before retrying.'] } : {}) } }
635}
636
637// Writes an oversized envelope where the model can read it whole; undefined when
638// it fits inline or the write fails (the cut text still carries the page token).
639async function spill($: EngineInterface, command: unknown, envelope: Envelope): Promise<string | undefined> {
640 if (!isOversized(envelope)) return undefined
641 const base = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/$/, '')
642 const path = `${base}/foundry-orthanc/${Date.now()}-${asString(command).trim().replace(/\W+/g, '-') || 'output'}.json`
643 try {
644 await $.fs.write(path, JSON.stringify(envelope, null, 2))
645 return path
646 } catch {
647 return undefined
648 }
649}
650
651// ---------- /fde ----------
652
653async function fdeState($: EngineInterface): Promise<FdeSession | null> {
654 return (await $.state.get(FDE)).value ?? null
655}
656
657// The session's binding, mirrored to the store so the next session can offer /fde resume.
658async function setFde($: EngineInterface, fde: FdeSession | null): Promise<void> {
659 await $.state.set(FDE, fde)
660 await $.state.set(BRANCH, fde?.branchRid ?? null)
661 await $.store.set('fdeOpen', fde)
662}
663
664async function fdeFromEarlier($: EngineInterface): Promise<FdeSession | null> {
665 const stored = await $.store.get('fdeOpen').catch(() => null)
666 return typeof stored === 'object' && stored !== null && typeof (stored as FdeSession).branchRid === 'string' ? (stored as FdeSession) : null
667}
668
669/**
670 * The global branch's name when it is an OPEN agent/ branch of the ontology.
671 * Otherwise a reason, with `transient` set when the lookup failed rather than
672 * the branch being unfit, so a resume can keep the binding and try again.
673 */
674async function openBranch($: EngineInterface, rid: string, profile: string, ontologyRid: string): Promise<{ name: string } | { reason: string; transient: boolean }> {
675 const envelope = await runOrthanc($, ['global-branch', 'get', rid], profile, 60_000)
676 if (isErrorEnvelope(envelope)) return { reason: `global-branch get ${rid} failed: ${errorsOf(envelope)}`, transient: true }
677 const fit = adoptable(envelope.data, ontologyRid)
678 return typeof fit === 'string' ? { reason: fit, transient: false } : fit
679}
680
681// FDE mode: pin the call to the session's branch and hold each --apply to its
682// dry run. It only adds checks; the branch rule and the ladder still judge the
683// bound call. Returns the bound input and its plan, or why it is refused.
684const MAX_PINNED_FILE = 20_000_000
685type FdePlan = { signature: string; isApply: boolean; command: string; files: string[] }
686
687async function filesSignature(
688 $: EngineInterface,
689 fde: FdeSession,
690 command: string,
691 args: string[],
692 files: { required: string[]; optional: string[] },
693): Promise<string | { deny: string }> {
694 const hashes: Record<string, string> = {}
695 for (const file of [...files.required, ...files.optional]) {
696 // Only regular files are read, and only up to a size, so a device or FIFO
697 // argument cannot hang the gate.
698 const stat = await $.fs.stat(file).catch(() => null)
699 if (stat !== null && stat.kind === 'file' && stat.size > MAX_PINNED_FILE) {
700 return { deny: `FDE mode: ${file} is over ${MAX_PINNED_FILE / 1_000_000} MB, too large to check against its dry run, so the call was not run.` }
701 }
702 const text = stat?.kind === 'file' ? await $.fs.read(file).then(String).catch(() => null) : null
703 if (text === null && files.required.includes(file)) return { deny: `FDE mode: could not read ${file} to check it against its dry run, so the call was not run.` }
704 if (text === null) continue
705 const foreign = foreignRids(fde, text)
706 if (foreign) return { deny: `FDE mode: ${foreign}` }
707 hashes[file] = await contentHash(text)
708 }
709 return planSignature(command, args, hashes)
710}
711
712async function fdeGate(
713 $: EngineInterface,
714 fde: FdeSession,
715 input: Record<string, unknown>,
716 manifest: readonly ManifestCommand[],
717): Promise<{ deny: string } | { input: Record<string, unknown>; plan: FdePlan | null }> {
718 const resolved = resolveOrthancCommand(asString(input.command), Array.isArray(input.args) ? input.args.map(String) : [], manifest)
719 const isMutation = resolved.path !== '' && resolved.path !== 'repository clone' && classifyOrthancCommand(resolved.path, manifest) === 'mutation'
720 const bound = fdeBind(fde, resolved.path, resolved.args, (await currentScope($)).profile, isMutation)
721 if ('deny' in bound) return bound
722 const next = { ...input, command: resolved.path, args: bound.args }
723 if (!isMutation) return { input: next, plan: null }
724 const files = fileCandidates(bound.args, await sessionCwd($), await homeDir($))
725 const signature = await filesSignature($, fde, resolved.path, bound.args, files)
726 if (typeof signature !== 'string') return signature
727 const isApply = hasApply(bound.args)
728 const gated = planGate(fde, signature, isApply)
729 if (gated) return { deny: gated }
730 return { input: next, plan: { signature, isApply, command: resolved.path, files: [...files.required, ...files.optional] } }
731}
732
733async function fdeCommand($: EngineInterface, args: string): Promise<string> {
734 const parsed = parseFdeArgs(args)
735 const active = await fdeState($)
736 const earlier = active ? null : await fdeFromEarlier($)
737 if (parsed.kind === 'error') return parsed.reason
738 if (parsed.kind === 'status') {
739 if (active) return fdeStatus(active)
740 return [
741 'FDE mode is off. /fde on (or /fde <goal>) creates an agent global branch on the selected ontology and binds this session to it; /fde --branch <rid> [goal] adopts an open one.',
742 ...(earlier ? [`An earlier session left ${earlier.branchName} (${goalLabel(earlier.goal)}) bound: /fde resume rebinds it, /fde end hands it off.`] : []),
743 ].join('\n')
744 }
745 if (parsed.kind === 'end') {
746 const ending = active ?? earlier
747 if (ending === null) return 'FDE mode is off.'
748 await setFde($, null)
749 const history = await $.store.get('fdeHandoffs').catch(() => null)
750 await $.store.set('fdeHandoffs', [...(Array.isArray(history) ? history : []), { ...ending, endedAt: new Date().toISOString() }].slice(-10))
751 return fdeHandoff(ending)
752 }
753 if (parsed.kind === 'goal') {
754 if (!active) return 'FDE mode is off, so there is no goal to set. /fde on starts it.'
755 const next = { ...active, goal: parsed.goal }
756 await setFde($, next)
757 return `Goal set.\n${fdeStatus(next)}`
758 }
759 if (parsed.kind === 'resume') {
760 if (active) return fdeStatus(active)
761 if (earlier === null) return 'No FDE branch to resume. /fde on starts one.'
762 const open = await openBranch($, earlier.branchRid, earlier.profile, earlier.ontologyRid)
763 if ('reason' in open) {
764 if (open.transient) return `${open.reason} Not resumed; the earlier binding is kept, so /fde resume can try again.`
765 await $.store.set('fdeOpen', null)
766 return `${open.reason} Not resumed; the earlier binding is forgotten.`
767 }
768 await setFde($, earlier)
769 return `Resumed.\n${fdeStatus(earlier)}`
770 }
771 if (active) return `Already in FDE mode:\n${fdeStatus(active)}\n/fde end first.`
772 const scope = await currentScope($)
773 if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
774 if (scope.ontology === null) return 'Choose the ontology first (/ontology): the branch is made on it.'
775 let branchRid: string
776 let branchName: string
777 if (parsed.kind === 'start') {
778 branchName = fdeBranchName(parsed.goal, new Date())
779 const envelope = await runOrthanc($, branchCreateArgs(branchName, scope.ontology.rid, parsed.goal), scope.profile, 120_000)
780 const rid = branchRidFrom(envelope)
781 if (rid === null) return `global-branch create ${branchName} failed, so FDE mode stays off: ${errorsOf(envelope)}`
782 branchRid = rid
783 } else {
784 const open = await openBranch($, parsed.rid, scope.profile, scope.ontology.rid)
785 if ('reason' in open) return `${open.reason} FDE mode stays off.`
786 branchRid = parsed.rid
787 branchName = open.name
788 }
789 const fde: FdeSession = {
790 goal: parsed.goal,
791 profile: scope.profile,
792 ontologyRid: scope.ontology.rid,
793 branchRid,
794 branchName,
795 startedAt: new Date().toISOString(),
796 plans: [],
797 applied: [],
798 }
799 await setFde($, fde)
800 return `${fdeStatus(fde)}\nOntology writes in this session now go to this branch only, and each --apply needs its dry run first.`
801}
802
803// ---------- pickers ----------
804
805type Source = { title: string; isMulti: boolean; items: FoundryPickItem[]; resourceKind?: FoundryResourceKind }
806type PickerOptions = { resourceKind?: FoundryResourceKind; chain?: FoundryPickerKind[] }
807
808const toItems = (resources: readonly FoundryResource[]): FoundryPickItem[] =>
809 resources.map(entry => ({ key: entry.rid, label: entry.name, hint: entry.hint }))
810
811async function inBatches<T, R>(values: readonly T[], size: number, run: (value: T) => Promise<R>): Promise<R[]> {
812 const out: R[] = []
813 for (let index = 0; index < values.length; index += size) out.push(...(await Promise.all(values.slice(index, index + size).map(run))))
814 return out
815}
816
817/** Compass resources of one kind under the selected projects, or every visible project. */
818async function compassSource($: EngineInterface, scope: FoundryScope, kind: CompassKind, query: string): Promise<Source | string> {
819 const def = COMPASS_KINDS[kind]
820 let roots = scope.projects.map(project => project.rid)
821 if (roots.length === 0) {
822 const projects = await runOrthanc($, ['project', 'list'], scope.profile, 120_000)
823 if (isErrorEnvelope(projects)) return `project list failed: ${errorsOf(projects)}`
824 roots = listItems(projects, 'projects').map(entry => asString(entry.rid)).filter(Boolean)
825 }
826 if (roots.length === 0) return 'No projects are visible on this stack. No selection was saved.'
827 const search = kind === 'folders' && !query.trim() ? 'ri.compass.main.folder.' : query
828 const envelopes = await inBatches(roots, ROOT_CONCURRENCY, root =>
829 runOrthanc($, resourceArgs(search, def.typeId, root), scope.profile, 180_000),
830 )
831 const failed = envelopes.filter(isErrorEnvelope)
832 if (failed.length === envelopes.length && failed[0]) return `resource listing failed: ${errorsOf(failed[0])}`
833 const rows = resourceRows(envelopes.flatMap(envelope => rowsOf(envelope, ['resources'])))
834 return {
835 title: `${def.label}${scope.projects.length > 0 ? ' in selected projects' : ''}`,
836 isMulti: true,
837 items: toItems(rows),
838 resourceKind: kind,
839 }
840}
841
842async function pickerSource(
843 $: EngineInterface,
844 kind: FoundryPickerKind,
845 scope: FoundryScope,
846 query: string,
847 resourceKind?: FoundryResourceKind,
848): Promise<Source | string> {
849 if (kind === 'profile') {
850 const profiles = await listProfiles($)
851 return {
852 title: 'Foundry stack',
853 isMulti: false,
854 items: profiles.map(profile => ({
855 key: profile.name,
856 label: profile.name,
857 hint: [profile.host, profile.status, profile.name === scope.profile ? 'active' : ''].filter(Boolean).join(' · '),
858 })),
859 }
860 }
861 if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
862 if (kind === 'resource') {
863 if (resourceKind && resourceKind in COMPASS_KINDS) return compassSource($, scope, resourceKind as CompassKind, query)
864 return 'This resource kind has no listing; use its lookup command.'
865 }
866 if (kind === 'ontology') {
867 const envelope = await runOrthanc($, ['ontology', 'list'], scope.profile, 60_000)
868 if (isErrorEnvelope(envelope)) return `ontology list failed: ${errorsOf(envelope)}`
869 return {
870 title: 'Ontology',
871 isMulti: false,
872 items: listItems(envelope, 'data').map(entry => ({
873 key: asString(entry.rid),
874 label: asString(entry.display_name) || asString(entry.api_name),
875 hint: asString(entry.rid),
876 })),
877 }
878 }
879 if (kind === 'project') {
880 const envelope = await runOrthanc($, ['project', 'list'], scope.profile, 120_000)
881 if (isErrorEnvelope(envelope)) return `project list failed: ${errorsOf(envelope)}`
882 return {
883 title: 'Projects',
884 isMulti: true,
885 items: listItems(envelope, 'projects').map(entry => ({
886 key: asString(entry.rid),
887 label: asString(entry.display_name) || asString(entry.name),
888 hint: asString(entry.path),
889 })),
890 }
891 }
892 if (scope.ontology === null) return 'No ontology is selected. Run /ontology first.'
893 const isAction = kind === 'action-type'
894 const envelope = await runOrthanc(
895 $,
896 ['ontology', isAction ? 'action-type-list' : 'object-type-list', scope.ontology.rid],
897 scope.profile,
898 120_000,
899 )
900 if (isErrorEnvelope(envelope)) return `${isAction ? 'action' : 'object'}-type-list failed: ${errorsOf(envelope)}`
901 return {
902 title: isAction ? 'Action types' : 'Object types',
903 isMulti: true,
904 items: listItems(envelope, isAction ? 'action_types' : 'object_types').map(entry => ({
905 key: asString(entry.api_name),
906 label: asString(entry.display_name) || asString(entry.api_name),
907 hint: asString(entry.api_name),
908 })),
909 }
910}
911
912async function applyPick($: EngineInterface, picker: FoundryPicker, keys: readonly string[]): Promise<string> {
913 const scope = await currentScope($)
914 const chosen = picker.items.filter(item => keys.includes(item.key))
915 if (picker.kind === 'profile') {
916 const profile = (await listProfiles($)).find(entry => entry.name === chosen[0]?.key)
917 if (profile === undefined) return 'No stack selected.'
918 const next = await activateProfile($, profile)
919 return `Foundry stack: ${next.profile}${next.ontology ? `, ontology ${next.ontology.name}` : ''}.`
920 }
921 if (picker.kind === 'ontology') {
922 const pick = chosen[0]
923 if (pick === undefined) return 'No ontology selected.'
924 await saveScope($, ontologyChanged(scope, { rid: pick.key, name: pick.label }))
925 return `Ontology: ${pick.label}. Projects, types, functions and object instances were cleared.`
926 }
927 if (picker.kind === 'project') {
928 await saveScope($, { ...scope, projects: chosen.map(item => ({ rid: item.key, name: item.label })) })
929 return `Projects: ${chosen.map(item => item.label).join(', ') || 'none'}.`
930 }
931 if (picker.kind === 'resource' && picker.resourceKind) {
932 const kind = picker.resourceKind
933 const asResource = (item: FoundryPickItem): FoundryResource => ({ rid: item.key, name: item.label, hint: item.hint })
934 const merged = mergeSelections(scope.resources[kind] ?? [], chosen.map(asResource), picker.items.map(asResource))
935 await saveScope($, { ...scope, resources: { ...scope.resources, [kind]: merged } })
936 return `${RESOURCE_LABELS[kind]}: ${merged.map(entry => entry.name).join(', ') || 'none'}.`
937 }
938 const key = picker.kind === 'action-type' ? 'actionTypes' : 'objectTypes'
939 await saveScope($, { ...scope, [key]: chosen.map(item => ({ apiName: item.key, displayName: item.label })) })
940 return `${picker.title}: ${chosen.map(item => item.label).join(', ') || 'none'}.`
941}
942
943const matches = (item: FoundryPickItem, filter: string) => {
944 const haystack = `${item.label} ${item.hint} ${item.key}`.toLowerCase()
945 return filter.toLowerCase().split(/\s+/).filter(Boolean).every(term => haystack.includes(term))
946}
947
948function preselected(scope: FoundryScope, kind: FoundryPickerKind, resourceKind?: FoundryResourceKind): string[] {
949 if (kind === 'project') return scope.projects.map(project => project.rid)
950 if (kind === 'object-type') return scope.objectTypes.map(type => type.apiName)
951 if (kind === 'action-type') return scope.actionTypes.map(type => type.apiName)
952 if (kind === 'resource' && resourceKind) return (scope.resources[resourceKind] ?? []).map(entry => entry.rid)
953 return []
954}
955
956/** Opens the picker pane on `source`, or answers in text where no pane can be placed. */
957async function openPicker($: EngineInterface, kind: FoundryPickerKind, source: Source, query: string, chain: FoundryPickerKind[]): Promise<string> {
958 const scope = await currentScope($)
959 if (source.items.length === 0) return `No ${source.title.toLowerCase()} found. No selection was saved.`
960 const picker: FoundryPicker = {
961 kind,
962 ...(source.resourceKind ? { resourceKind: source.resourceKind } : {}),
963 title: source.title,
964 isMulti: source.isMulti,
965 items: source.items,
966 chosen: preselected(scope, kind, source.resourceKind),
967 filter: kind === 'resource' ? '' : query,
968 chain,
969 }
970 const filtered = source.items.filter(item => matches(item, picker.filter))
971 const only = filtered.length === 1 ? filtered[0] : undefined
972 if (query !== '' && only) return applyPick($, picker, [...new Set([...picker.chosen, only.key])])
973 await $.state.set(PICKER, picker)
974 const opened = await $.ui.open({ id: PANE, title: source.title, focus: true, closeOnEscape: true })
975 if (!opened.isPlaced) {
976 await $.state.set(PICKER, null)
977 const lines = filtered.slice(0, 20).map(item => `- ${item.label} (${item.hint || item.key})`)
978 return [`${filtered.length} ${source.title.toLowerCase()}; widen the terminal for the picker, or narrow the command with a filter.`, ...lines].join('\n')
979 }
980 return `Choose ${source.title.toLowerCase()} in the picker (type to filter, Enter or a click selects, Esc cancels).`
981}
982
983async function runPicker($: EngineInterface, kind: FoundryPickerKind, args: string, options: PickerOptions = {}): Promise<string> {
984 const scope = await currentScope($)
985 const query = args.trim()
986 if (query === 'clear' && kind !== 'profile') {
987 if (kind === 'ontology') await saveScope($, ontologyChanged(scope, null))
988 if (kind === 'project') await saveScope($, { ...scope, projects: [] })
989 if (kind === 'object-type') await saveScope($, { ...scope, objectTypes: [] })
990 if (kind === 'action-type') await saveScope($, { ...scope, actionTypes: [] })
991 if (kind === 'resource' && options.resourceKind) {
992 await saveScope($, { ...scope, resources: { ...scope.resources, [options.resourceKind]: [] } })
993 }
994 return `Cleared ${options.resourceKind ? RESOURCE_LABELS[options.resourceKind].toLowerCase() : kind} selection.`
995 }
996 const source = await pickerSource($, kind, scope, query, options.resourceKind)
997 if (typeof source === 'string') return source
998 return openPicker($, kind, source, query, options.chain ?? [])
999}
1000
1001// ---------- lookups ----------
1002
1003async function lookupSource($: EngineInterface, entry: CommandEntry, args: string, scope: FoundryScope): Promise<Source | string> {
1004 if (entry.action.type !== 'lookup') return 'Not a lookup.'
1005 const { lookup, kind } = entry.action
1006 const query = args.trim()
1007 const usage = `Usage: /${entry.name} ${entry.argumentHint ?? ''}`.trim()
1008 const source = (title: string, rows: FoundryResource[]): Source => ({ title, isMulti: true, items: toItems(rows), resourceKind: kind })
1009 if (lookup === 'objects') {
1010 const parsed = objectsQuery(query)
1011 if (parsed === null) {
1012 return query
1013 ? `Free-text object search needs 'ontology object-search --query' in orthanc (bead ${OBJECT_QUERY_BEAD}). ` +
1014 `For now name the property: /${entry.name} <property>=<text>.`
1015 : usage
1016 }
1017 if (scope.ontology === null || scope.objectTypes.length === 0) return 'Choose an ontology and at least one object type first (/ontology, /object-type).'
1018 const where = JSON.stringify({ type: 'containsAllTerms', field: parsed.property, value: parsed.text })
1019 const ontology = scope.ontology
1020 const envelopes = await Promise.all(
1021 scope.objectTypes.map(type =>
1022 runOrthanc($, ['ontology', 'object-search', '--where-json', where, '--select', parsed.property, ontology.rid, type.apiName], scope.profile, 120_000).then(
1023 envelope => ({ type, envelope }),
1024 ),
1025 ),
1026 )
1027 const failed = envelopes.find(({ envelope }) => isErrorEnvelope(envelope))
1028 const rows = envelopes.flatMap(({ type, envelope }) =>
1029 rowsOf(envelope, ['data']).map(row => {
1030 const key = asString(row.__rid) || `${type.apiName}:${asString(row.__primaryKey) || String(row[parsed.property] ?? '')}`
1031 return { rid: key, name: String(row.__title ?? row[parsed.property] ?? key), hint: type.displayName }
1032 }),
1033 )
1034 if (rows.length === 0 && failed) return `object-search failed: ${errorsOf(failed.envelope)}`
1035 return source(`Object instances matching ${parsed.property}=${parsed.text}`, resourceRows(rows.map(row => ({ rid: row.rid, name: row.name, path: row.hint }))))
1036 }
1037 if (lookup === 'language-model') {
1038 const envelope = await runOrthanc($, ['language-models', 'list'], scope.profile, 60_000)
1039 if (isErrorEnvelope(envelope)) return `language-models list failed: ${errorsOf(envelope)}`
1040 return source('Language models', resourceRows(rowsOf(envelope, ['models', 'language_models', 'data']), ['provider', 'type']))
1041 }
1042 if (!query) return usage
1043 if (lookup === 'function') {
1044 const envelope = await runOrthanc($, ['functions', 'search', query, '--limit', '100'], scope.profile, 60_000)
1045 if (isErrorEnvelope(envelope)) return `functions search failed: ${errorsOf(envelope)}`
1046 return source(`Functions matching '${query}'`, resourceRows(rowsOf(envelope, ['results']), ['api_name', 'version', 'type']))
1047 }
1048 if (lookup === 'search') {
1049 const envelope = await runOrthanc($, ['search', query, '--limit', '100'], scope.profile, 60_000)
1050 if (isErrorEnvelope(envelope)) return `search failed: ${errorsOf(envelope)}`
1051 return source(`Resources matching '${query}'`, resourceRows(rowsOf(envelope, ['results'])))
1052 }
1053 if (lookup === 'permission') {
1054 const envelope = await runOrthanc($, ['resource-role', 'list', query], scope.profile, 60_000)
1055 if (isErrorEnvelope(envelope)) return `resource-role list failed: ${errorsOf(envelope)}`
1056 const rows = rowsOf(envelope, ['roles']).map(row => ({
1057 rid: `${query}#${asString(row.principal_id)}:${asString(row.role_name)}`,
1058 name: `${asString(row.principal_type).toLowerCase()} ${asString(row.principal_id)} → ${asString(row.role_name)}`,
1059 path: query,
1060 }))
1061 return source(`Role grants on ${query}`, resourceRows(rows))
1062 }
1063 // media-item: one item, added directly.
1064 const [setRid, ...rest] = query.split(/\s+/)
1065 const item = rest.join(' ')
1066 if (!setRid || !item) return usage
1067 const envelope = await runOrthanc(
1068 $,
1069 /^ri\./i.test(item) ? ['media-sets', 'get', setRid, item] : ['media-sets', 'get-by-path', setRid, item],
1070 scope.profile,
1071 60_000,
1072 )
1073 if (isErrorEnvelope(envelope)) return `media item lookup failed: ${errorsOf(envelope)}`
1074 const data = typeof envelope.data === 'object' && envelope.data !== null && !Array.isArray(envelope.data) ? (envelope.data as Record<string, unknown>) : {}
1075 const rid = asString(data.rid) || asString(data.media_item_rid) || asString(data.mediaItemRid) || (/^ri\./i.test(item) ? item : '')
1076 if (!rid) return 'The media item response did not include an RID. No selection was saved.'
1077 const selected = { rid, name: asString(data.path) || item, hint: setRid }
1078 const next = mergeSelections(scope.resources.mediaItems ?? [], [selected], [selected])
1079 await saveScope($, { ...scope, resources: { ...scope.resources, mediaItems: next } })
1080 return `Media items: ${next.map(entry => entry.name).join(', ')}.`
1081}
1082
1083async function runLookup($: EngineInterface, entry: CommandEntry, args: string): Promise<string> {
1084 if (entry.action.type !== 'lookup') return 'Not a lookup.'
1085 const kind = entry.action.kind
1086 if (args.trim() === 'clear') return runPicker($, 'resource', 'clear', { resourceKind: kind })
1087 // Parse-only answers (usage, waits-on-bead) come before the stack check.
1088 if (entry.action.lookup === 'objects' && objectsQuery(args) === null) return (await lookupSource($, entry, args, emptyScope('unknown', ''))) as string
1089 const scope = await currentScope($)
1090 if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
1091 const source = await lookupSource($, entry, args, scope)
1092 if (typeof source === 'string') return source
1093 return openPicker($, 'resource', source, '', [])
1094}
1095
1096// ---------- status, onboarding, auth ----------
1097
1098type Check = { label: string; ok: boolean; detail: string; fix?: string }
1099
1100async function setupChecks($: EngineInterface): Promise<{ checks: Check[]; scope: FoundryScope }> {
1101 const checks: Check[] = []
1102 const version = await runOrthanc($, ['version'], undefined, 15_000)
1103 const versionText = JSON.stringify(version.data ?? '').replace(/[{}"]/g, '').slice(0, 120)
1104 checks.push({
1105 label: 'orthanc',
1106 ok: !isErrorEnvelope(version),
1107 detail: isErrorEnvelope(version) ? errorsOf(version) : `${await orthancBin($)} ${versionText}`,
1108 fix: 'install Orthanc in your terminal, then run /onboard again:\n curl -fsSL https://github.com/zaycruz/orthanc/releases/latest/download/install.sh | sh\n orthanc onboarding\n(or set ORTHANC_BIN to an orthanc you already have)',
1109 })
1110 const profiles = await listProfiles($)
1111 checks.push({ label: 'profiles', ok: profiles.length > 0, detail: profiles.map(profile => `${profile.name} (${profile.status || '?'})`).join(', ') || 'none', fix: '/auth <name> to create one.' })
1112 const scope = await currentScope($)
1113 const active = profiles.find(profile => profile.name === scope.profile)
1114 checks.push({ label: 'stack', ok: active !== undefined, detail: active ? `${active.name} · ${active.host}` : 'none active', fix: '/profile' })
1115 if (active) {
1116 const probe = await runOrthanc($, ['ontology', 'list'], active.name, 60_000)
1117 checks.push({
1118 label: 'credentials',
1119 ok: !isErrorEnvelope(probe),
1120 detail: isErrorEnvelope(probe) ? errorsOf(probe) : 'ontology list works',
1121 fix: `/auth ${active.name}`,
1122 })
1123 }
1124 checks.push({ label: 'context', ok: hasFocus(scope), detail: hasFocus(scope) ? 'ontology or projects selected' : 'nothing selected', fix: '/open' })
1125 return { checks, scope }
1126}
1127
1128const renderChecks = (checks: readonly Check[]) =>
1129 checks.map(check => `${check.ok ? '✓' : '✗'} ${check.label}: ${check.detail}${check.ok || !check.fix ? '' : ` → ${check.fix}`}`).join('\n')
1130
1131async function authText($: EngineInterface, args: string): Promise<string> {
1132 const scope = await currentScope($)
1133 const target = args.trim() || scope.profile
1134 const profile = (await listProfiles($)).find(entry => entry.name === target)
1135 const host = profile?.host || '<https://your-stack.palantirfoundry.com>'
1136 const lines = [
1137 `Credentials never pass through Claude. Run one of these in your own terminal:`,
1138 profile
1139 ? ` orthanc auth store --profile ${target} --host ${host} # paste the token on stdin`
1140 : ` orthanc configure profile create # new profile '${target}': host and token`,
1141 `Then run /onboard (or /profile ${target}) here to verify.`,
1142 ]
1143 if (profile) {
1144 const probe = await runOrthanc($, ['ontology', 'list'], target, 60_000)
1145 lines.unshift(isErrorEnvelope(probe) ? `${target}: credentials do not work (${errorsOf(probe).slice(0, 160)}).` : `${target}: credentials already work.`)
1146 }
1147 return lines.join('\n')
1148}
1149
1150// ---------- /foundry ----------
1151
1152async function treeText($: EngineInterface): Promise<string> {
1153 const scope = await currentScope($)
1154 const nodes = foundryTree(scope)
1155 const lines = [`Foundry context · ${scope.profile}${scope.host ? ` (${scope.host})` : ''} · ~${Math.ceil(contextText(scope, await approvalMode($)).length / 4)} tokens`]
1156 if (nodes.length === 0) lines.push(' nothing selected: /open sets the stack, ontology and projects')
1157 nodes.forEach((node, index) => {
1158 const last = index === nodes.length - 1
1159 lines.push(`${last ? '└─' : '├─'} ${node.label}${node.detail ? `: ${node.detail}` : ` (${node.count})`}`)
1160 node.children.slice(0, TREE_ROWS).forEach((child, childIndex) => {
1161 lines.push(`${last ? ' ' : '│ '}${childIndex === node.children.length - 1 ? '└─' : '├─'} ${child.label} ${child.detail}`)
1162 })
1163 if (node.children.length > TREE_ROWS) lines.push(`${last ? ' ' : '│ '} … ${node.children.length - TREE_ROWS} more`)
1164 })
1165 return lines.join('\n')
1166}
1167
1168async function clearFromTree($: EngineInterface, target: ClearTarget): Promise<void> {
1169 await saveScope($, applyClear(await currentScope($), target))
1170}
1171
1172// ---------- registration ----------
1173
1174async function runEntry($: EngineInterface, entry: CommandEntry, args: string): Promise<string> {
1175 const action = entry.action
1176 switch (action.type) {
1177 case 'picker':
1178 return runPicker($, action.kind, args)
1179 case 'resource':
1180 return runPicker($, 'resource', args, { resourceKind: action.kind })
1181 case 'blocked':
1182 return blockedText(action.what, action.bead)
1183 case 'lookup':
1184 return runLookup($, entry, args)
1185 case 'open': {
1186 const scope = await currentScope($)
1187 if (scope.profile === 'unknown') return runPicker($, 'profile', '', { chain: ['ontology', 'project'] })
1188 return runPicker($, 'ontology', '', { chain: ['project'] })
1189 }
1190 case 'onboard': {
1191 const { checks } = await setupChecks($)
1192 const failing = checks.filter(check => !check.ok)
1193 return `${renderChecks(checks)}\n${failing.length === 0 ? 'Setup is complete.' : `Next: ${failing[0]?.fix ?? ''}`}`
1194 }
1195 case 'auth':
1196 return authText($, args)
1197 case 'mcp-status': {
1198 const { checks } = await setupChecks($)
1199 const manifest = await loadManifest($).catch(() => [])
1200 return [hooks/catalog.ts 359 lines1// Pure: the slash-command table (one name per resource), scope shape and edits,
2// resource rows, the prompt block and the /foundry context tree.
3// Everything that runs orthanc stays in register.tsx, where `$` lives.
4
5import type {
6 FoundryNamed,
7 FoundryObjectType,
8 FoundryPickerKind,
9 FoundryResource,
10 FoundryResourceKind,
11 FoundryScope,
12} from '../types'
13import { isRecord } from './orthanc'
14
15/** Compass resource kinds orthanc lists with `resource list|search --type`. */
16export const COMPASS_KINDS = {
17 folders: { label: 'Folders', typeId: 'COMPASS_FOLDER' },
18 datasets: { label: 'Datasets', typeId: 'FOUNDRY_DATASET' },
19 mediaSets: { label: 'Media sets', typeId: 'MIO_MEDIA_SET' },
20 repositories: { label: 'Repositories', typeId: 'STEMMA_REPOSITORY' },
21 contourAnalyses: { label: 'Contour analyses', typeId: 'CONTOUR_ANALYSIS' },
22 sources: { label: 'Sources', typeId: 'MAGRITTE_SOURCE' },
23 developerConsoles: { label: 'Developer Console', typeId: 'THIRD_PARTY_APPLICATIONS_APPLICATION' },
24 aipChatbots: { label: 'AIP chatbots', typeId: 'AIP_AGENTS_AGENT' },
25} as const
26export type CompassKind = keyof typeof COMPASS_KINDS
27
28export const RESOURCE_LABELS: Record<FoundryResourceKind, string> = {
29 ...Object.fromEntries(Object.entries(COMPASS_KINDS).map(([kind, def]) => [kind, def.label])),
30 mediaItems: 'Media items',
31 objectInstances: 'Object instances',
32 functions: 'Functions',
33 languageModels: 'Language models',
34 permissions: 'Permissions',
35 searchResults: 'Search results',
36} as Record<FoundryResourceKind, string>
37
38/** Resource kinds that belong to the ontology and go when it changes. */
39const ONTOLOGY_KINDS: readonly FoundryResourceKind[] = ['objectInstances', 'functions']
40
41export type Lookup = 'media-item' | 'objects' | 'function' | 'language-model' | 'permission' | 'search'
42
43export type CommandAction =
44 | { type: 'picker'; kind: Exclude<FoundryPickerKind, 'resource'> }
45 | { type: 'resource'; kind: CompassKind }
46 | { type: 'lookup'; lookup: Lookup; kind: FoundryResourceKind }
47 | { type: 'blocked'; bead: string; what: string }
48 | { type: 'open' | 'onboard' | 'auth' | 'scope' | 'approval' | 'mcp-status' | 'foundry' | 'trace' | 'fde' }
49
50export type CommandEntry = { name: string; description: string; argumentHint?: string; action: CommandAction }
51
52const CLEARABLE = '[filter | clear]'
53const LINK_BEAD = 'orthanc-ra6'
54const LISTING_BEAD = 'orthanc-qm5'
55export const OBJECT_QUERY_BEAD = 'orthanc-4f7'
56
57const picker = (name: string, kind: Exclude<FoundryPickerKind, 'resource'>, description: string): CommandEntry => ({
58 name,
59 description,
60 argumentHint: kind === 'profile' ? '[name]' : CLEARABLE,
61 action: { type: 'picker', kind },
62})
63const resource = (name: string, kind: CompassKind): CommandEntry => ({
64 name,
65 description: `Choose ${COMPASS_KINDS[kind].label.toLowerCase()} in scope`,
66 argumentHint: CLEARABLE,
67 action: { type: 'resource', kind },
68})
69const blocked = (name: string, what: string, bead: string): CommandEntry =>
70 ({ name, description: `Choose ${what} (waits on orthanc ${bead})`, action: { type: 'blocked', bead, what } })
71
72export const COMMAND_TABLE: readonly CommandEntry[] = [
73 { name: 'foundry', description: 'Show the Foundry resources in context (like /context, for Palantir)', action: { type: 'foundry' } },
74 { name: 'open', description: 'Set working context: stack, then ontology and projects', action: { type: 'open' } },
75 { name: 'onboard', description: 'Check Foundry setup: orthanc, stack, credentials, context', action: { type: 'onboard' } },
76 { name: 'auth', description: 'Show how to authenticate a Foundry stack', argumentHint: '[profile]', action: { type: 'auth' } },
77 picker('profile', 'profile', 'Choose the Foundry stack (orthanc profile)'),
78 picker('ontology', 'ontology', 'Choose the active Foundry ontology'),
79 picker('project', 'project', 'Choose the Foundry projects in scope'),
80 picker('object-type', 'object-type', 'Choose the ontology object types in scope'),
81 picker('action-type', 'action-type', 'Choose the ontology action types in scope'),
82 blocked('link-type', 'ontology link types', LINK_BEAD),
83 blocked('interface', 'ontology interfaces', LINK_BEAD),
84 resource('folder', 'folders'),
85 resource('dataset', 'datasets'),
86 resource('media-set', 'mediaSets'),
87 resource('repository', 'repositories'),
88 resource('contour', 'contourAnalyses'),
89 resource('source', 'sources'),
90 resource('developer-console', 'developerConsoles'),
91 resource('aip-chatbot', 'aipChatbots'),
92 blocked('egress-policy', 'egress policies', LISTING_BEAD),
93 blocked('certificate', 'certificates', LISTING_BEAD),
94 blocked('automation', 'automations', LISTING_BEAD),
95 {
96 name: 'media-item',
97 description: 'Add a media item by media-set RID and item path or RID',
98 argumentHint: '<media-set-rid> <path|rid> | clear',
99 action: { type: 'lookup', lookup: 'media-item', kind: 'mediaItems' },
100 },
101 {
102 name: 'objects',
103 description: 'Search object instances of the selected object types',
104 argumentHint: '<property>=<text> | clear',
105 action: { type: 'lookup', lookup: 'objects', kind: 'objectInstances' },
106 },
107 {
108 name: 'function',
109 description: 'Search Foundry functions and add them to context',
110 argumentHint: '<query> | clear',
111 action: { type: 'lookup', lookup: 'function', kind: 'functions' },
112 },
113 {
114 name: 'language-model',
115 description: 'Choose the stack language models in context',
116 argumentHint: CLEARABLE,
117 action: { type: 'lookup', lookup: 'language-model', kind: 'languageModels' },
118 },
119 {
120 name: 'permission',
121 description: 'Inspect role grants on a resource and add them to context',
122 argumentHint: '<resource-rid> | clear',
123 action: { type: 'lookup', lookup: 'permission', kind: 'permissions' },
124 },
125 {
126 name: 'search',
127 description: 'Search Foundry resource titles across the stack',
128 argumentHint: '<text> | clear',
129 action: { type: 'lookup', lookup: 'search', kind: 'searchResults' },
130 },
131 { name: 'scope', description: 'Show the Foundry context; `clear` resets it, `ontology` re-picks it', argumentHint: '[show | clear | ontology]', action: { type: 'scope' } },
132 { name: 'approval', description: 'Show or set the Foundry approval mode, or show the guard rules', argumentHint: '[mode|rules]', action: { type: 'approval' } },
133 {
134 name: 'fde',
135 description: 'FDE mode: bind this session to one agent branch; writes plan before they apply',
136 argumentHint: 'on | <goal> | goal <text> | --branch <rid> [goal] | resume | end',
137 action: { type: 'fde' },
138 },
139 { name: 'trace', description: 'Show trace export status (Langfuse/OTLP) and check the last Foundry trace landed', action: { type: 'trace' } },
140 { name: 'mcp-status', description: 'Show Orthanc and Foundry stack status', action: { type: 'mcp-status' } },
141]
142
143export const blockedText = (what: string, bead: string) =>
144 `Orthanc cannot list ${what} yet, so this command is waiting on bead ${bead} ` +
145 `in the orthanc tracker. It does not fall back to Foundry REST.`
146
147// ---------- scope ----------
148
149const asString = (value: unknown) => (typeof value === 'string' ? value : '')
150
151const named = (value: unknown): FoundryNamed | null =>
152 isRecord(value) && typeof value.rid === 'string' ? { rid: value.rid, name: asString(value.name) || value.rid } : null
153
154const types = (value: unknown): FoundryObjectType[] =>
155 Array.isArray(value)
156 ? value.filter(isRecord).flatMap(entry =>
157 typeof entry.apiName === 'string'
158 ? [{ apiName: entry.apiName, displayName: asString(entry.displayName) || entry.apiName, ...(typeof entry.rid === 'string' ? { rid: entry.rid } : {}) }]
159 : [],
160 )
161 : []
162
163const resourcesOf = (value: unknown): FoundryResource[] =>
164 Array.isArray(value)
165 ? value.filter(isRecord).flatMap(entry =>
166 typeof entry.rid === 'string' ? [{ rid: entry.rid, name: asString(entry.name) || entry.rid, hint: asString(entry.hint) }] : [],
167 )
168 : []
169
170export const emptyScope = (profile: string, host: string): FoundryScope => ({
171 profile,
172 host,
173 ontology: null,
174 projects: [],
175 objectTypes: [],
176 actionTypes: [],
177 resources: {},
178 updatedAt: new Date().toISOString(),
179})
180
181/** Any stored value (v0.1 scopes included) as a whole FoundryScope. */
182export function normalizeScope(value: unknown, profile: string, host: string): FoundryScope {
183 if (!isRecord(value)) return emptyScope(profile, host)
184 const resources: FoundryScope['resources'] = {}
185 if (isRecord(value.resources)) {
186 for (const kind of Object.keys(RESOURCE_LABELS) as FoundryResourceKind[]) {
187 const list = resourcesOf(value.resources[kind])
188 if (list.length > 0) resources[kind] = list
189 }
190 }
191 return {
192 profile: asString(value.profile) || profile,
193 host: asString(value.host) || host,
194 ontology: named(value.ontology),
195 projects: Array.isArray(value.projects) ? value.projects.map(named).filter((entry): entry is FoundryNamed => entry !== null) : [],
196 objectTypes: types(value.objectTypes),
197 actionTypes: types(value.actionTypes),
198 resources,
199 updatedAt: asString(value.updatedAt) || new Date().toISOString(),
200 }
201}
202
203export function ontologyChanged(scope: FoundryScope, ontology: FoundryNamed | null): FoundryScope {
204 const resources = { ...scope.resources }
205 for (const kind of ONTOLOGY_KINDS) resources[kind] = []
206 return { ...scope, ontology, projects: [], objectTypes: [], actionTypes: [], resources }
207}
208
209// ---------- orthanc rows ----------
210
211export function resourceArgs(query: string, typeId: string, root: string): string[] {
212 const trimmed = query.trim()
213 return [...(trimmed ? ['resource', 'search', trimmed] : ['resource', 'list']), '--type', typeId, '--folder-rid', root]
214}
215
216/** orthanc rows as resources: RID required, first RID wins. */
217export function resourceRows(rows: readonly Record<string, unknown>[], hintKeys: readonly string[] = ['path', 'type']): FoundryResource[] {
218 const seen = new Set<string>()
219 return rows.flatMap(row => {
220 const rid = asString(row.rid) || asString(row.__rid) || asString(row.api_name) || asString(row.apiName)
221 if (!rid || seen.has(rid)) return []
222 seen.add(rid)
223 const name =
224 asString(row.display_name) || asString(row.displayName) || asString(row.name) || asString(row.__title) || asString(row.api_name) || rid
225 const hint = hintKeys.map(key => row[key]).map(value => (isRecord(value) ? asString(value.name) : asString(value))).find(Boolean) ?? ''
226 return [{ rid, name, hint }]
227 })
228}
229
230/** The new selection, plus earlier selections the listing did not show. */
231export function mergeSelections(
232 existing: readonly FoundryResource[],
233 selected: readonly FoundryResource[],
234 visible: readonly FoundryResource[],
235): FoundryResource[] {
236 const shown = new Set(visible.map(entry => entry.rid))
237 return [...existing.filter(entry => !shown.has(entry.rid)), ...selected]
238}
239
240export function objectsQuery(args: string): { property: string; text: string } | null {
241 const match = /^\s*([A-Za-z_][\w.]*)\s*=\s*(.+?)\s*$/.exec(args)
242 return match?.[1] && match[2] ? { property: match[1], text: match[2] } : null
243}
244
245// ---------- prompt block ----------
246
247const list = (entries: readonly FoundryResource[]) => entries.map(entry => `${entry.name} (${entry.rid})`).join(', ')
248
249export function contextLines(scope: FoundryScope): string[] {
250 const lines: string[] = []
251 if (scope.ontology) lines.push(`Ontology: ${scope.ontology.name} (${scope.ontology.rid}).`)
252 if (scope.projects.length > 0) {
253 lines.push(`Projects: ${scope.projects.map(project => `${project.name} (${project.rid})`).join(', ')}.`)
254 lines.push('Keep project discovery within the selected project subtrees.')
255 }
256 if (scope.objectTypes.length > 0) {
257 lines.push(`Object types: ${scope.objectTypes.map(type => `${type.displayName} (${type.apiName})`).join(', ')}.`)
258 }
259 if (scope.actionTypes.length > 0) {
260 lines.push(`Action types: ${scope.actionTypes.map(type => `${type.displayName} (${type.apiName})`).join(', ')}.`)
261 }
262 for (const [kind, label] of Object.entries(RESOURCE_LABELS) as [FoundryResourceKind, string][]) {
263 const entries = scope.resources[kind] ?? []
264 if (entries.length > 0) lines.push(`${label}: ${list(entries)}.`)
265 }
266 return lines
267}
268
269export const hasFocus = (scope: FoundryScope) =>
270 scope.ontology !== null || scope.projects.length > 0 || Object.values(scope.resources).some(entries => (entries?.length ?? 0) > 0)
271
272// ---------- /foundry tree ----------
273
274export type ClearTarget =
275 | { type: 'ontology' }
276 | { type: 'projects' | 'objectTypes' | 'actionTypes' }
277 | { type: 'resource'; kind: FoundryResourceKind }
278 | { type: 'item'; section: 'projects' | 'objectTypes' | 'actionTypes'; key: string }
279 | { type: 'resource-item'; kind: FoundryResourceKind; rid: string }
280
281export type TreeNode = { id: string; label: string; detail: string; count: number; command: string; clear?: ClearTarget; children: TreeNode[] }
282
283const leaf = (id: string, label: string, detail: string, clear: ClearTarget): TreeNode => ({ id, label, detail, count: 0, command: '', clear, children: [] })
284
285const COMMAND_FOR: Record<FoundryResourceKind, string> = {
286 folders: '/folder',
287 datasets: '/dataset',
288 mediaSets: '/media-set',
289 mediaItems: '/media-item',
290 repositories: '/repository',
291 contourAnalyses: '/contour',
292 sources: '/source',
293 developerConsoles: '/developer-console',
294 aipChatbots: '/aip-chatbot',
295 objectInstances: '/objects',
296 functions: '/function',
297 languageModels: '/language-model',
298 permissions: '/permission',
299 searchResults: '/search',
300}
301
302/** The sections that hold something, top-level, in reading order. */
303export function foundryTree(scope: FoundryScope): TreeNode[] {
304 const nodes: TreeNode[] = []
305 if (scope.ontology) {
306 nodes.push({ id: 'ontology', label: 'Ontology', detail: scope.ontology.name, count: 1, command: '/ontology', clear: { type: 'ontology' }, children: [] })
307 }
308 const typed = (id: 'projects' | 'objectTypes' | 'actionTypes', label: string, command: string, entries: { key: string; label: string; detail: string }[]) => {
309 if (entries.length === 0) return
310 nodes.push({
311 id,
312 label,
313 detail: '',
314 count: entries.length,
315 command,
316 clear: { type: id },
317 children: entries.map(entry => leaf(`${id}:${entry.key}`, entry.label, entry.detail, { type: 'item', section: id, key: entry.key })),
318 })
319 }
320 typed('projects', 'Projects', '/project', scope.projects.map(project => ({ key: project.rid, label: project.name, detail: project.rid })))
321 typed('objectTypes', 'Object types', '/object-type', scope.objectTypes.map(type => ({ key: type.apiName, label: type.displayName, detail: type.apiName })))
322 typed('actionTypes', 'Action types', '/action-type', scope.actionTypes.map(type => ({ key: type.apiName, label: type.displayName, detail: type.apiName })))
323 for (const [kind, label] of Object.entries(RESOURCE_LABELS) as [FoundryResourceKind, string][]) {
324 const entries = scope.resources[kind] ?? []
325 if (entries.length === 0) continue
326 nodes.push({
327 id: `resource:${kind}`,
328 label,
329 detail: '',
330 count: entries.length,
331 command: COMMAND_FOR[kind],
332 clear: { type: 'resource', kind },
333 children: entries.map(entry => leaf(`${kind}:${entry.rid}`, entry.name, entry.hint || entry.rid, { type: 'resource-item', kind, rid: entry.rid })),
334 })
335 }
336 return nodes
337}
338
339export function applyClear(scope: FoundryScope, target: ClearTarget): FoundryScope {
340 switch (target.type) {
341 case 'ontology':
342 return ontologyChanged(scope, null)
343 case 'projects':
344 case 'objectTypes':
345 case 'actionTypes':
346 return { ...scope, [target.type]: [] }
347 case 'resource':
348 return { ...scope, resources: { ...scope.resources, [target.kind]: [] } }
349 case 'resource-item':
350 return {
351 ...scope,
352 resources: { ...scope.resources, [target.kind]: (scope.resources[target.kind] ?? []).filter(entry => entry.rid !== target.rid) },
353 }
354 case 'item':
355 if (target.section === 'projects') return { ...scope, projects: scope.projects.filter(entry => entry.rid !== target.key) }
356 return { ...scope, [target.section]: scope[target.section].filter(entry => entry.apiName !== target.key) }
357 }
358}
359hooks/fde.ts 277 lines1// FDE mode (/fde): one session, one goal, one agent global branch. Pure
2// functions, shared by the hooks and the tests. The mode only adds checks: the
3// branch rule, the ladder and the destructive rule run as they do without it.
4//
5// While bound:
6// - every ontology write is pinned to the session's global branch (injected
7// when the call names none, refused when it names another);
8// - no global-branch or proposal changes (but add-repo on the bound branch):
9// closing or merging is the operator's;
10// - --apply runs only after a clean dry run of the same call, with every file
11// it reads pinned by SHA-256; each dry run licenses one apply.
12
13import type { FdeSession } from '../types'
14import { BRANCH_FLAGS, flagValues, requiresBranch, unquote, validateBranchName } from './policy'
15
16const PREFIX = 'agent/fde-'
17const NAME_MAX = 64
18const GOAL_MAX = 200
19const RECORD_MAX = 50
20const GLOBAL_BRANCH = /^ri\.branch\.\.branch\.[A-Za-z0-9-]+$/
21const GLOBAL_BRANCH_ANY = /ri\.branch\.\.branch\.[A-Za-z0-9-]+/g
22const ONTOLOGY_RID = /ri\.ontology\.main\.ontology\.[A-Za-z0-9-]+/g
23// Flags that could be read as a branch flag by a lenient parser: a --br… prefix, or -b inside a bundle.
24const AMBIGUOUS_BRANCH_FLAG = /^(--br(?!anch(=|$))[a-z-]*(=.*)?|-[a-z]*b[a-z]+|-[a-z]+b)$/i
25const PROFILE_FLAG = /^--profile(=|$)/
26// While bound, only this branch command runs, and only on the bound branch.
27const GLOBAL_BRANCH_ALLOWED = new Set(['global-branch add-repo'])
28
29/** One line, no control characters, at most 200 characters. */
30const cleanGoal = (text: string) =>
31 text
32 .replace(/[\u0000-\u001f\u007f]+/g, ' ')
33 .replace(/\s+/g, ' ')
34 .trim()
35 .slice(0, GOAL_MAX)
36
37export type FdeArgs =
38 | { kind: 'status' }
39 | { kind: 'end' }
40 | { kind: 'resume' }
41 | { kind: 'start'; goal: string }
42 | { kind: 'adopt'; rid: string; goal: string }
43 | { kind: 'goal'; goal: string }
44 | { kind: 'error'; reason: string }
45
46/** The goal as shown; a session started with /fde on has none until /fde goal sets one. */
47export const goalLabel = (goal: string) => (goal === '' ? 'no goal set' : goal)
48
49export function parseFdeArgs(text: string): FdeArgs {
50 const trimmed = text.trim()
51 if (trimmed === '' || trimmed === 'status') return { kind: 'status' }
52 if (trimmed === 'end') return { kind: 'end' }
53 if (trimmed === 'resume') return { kind: 'resume' }
54 if (trimmed.toLowerCase() === 'on') return { kind: 'start', goal: '' }
55 const setGoal = /^goal(?:\s+([\s\S]*))?$/.exec(trimmed)
56 if (setGoal) {
57 const goal = cleanGoal(setGoal[1] ?? '')
58 return goal === '' ? { kind: 'error', reason: 'Name the goal: /fde goal <text>.' } : { kind: 'goal', goal }
59 }
60 const adopt = /^--branch(?:=|\s+)(\S+)\s*([\s\S]*)$/.exec(trimmed)
61 if (adopt) {
62 const rid = adopt[1]!
63 const goal = cleanGoal(adopt[2] ?? '')
64 if (!GLOBAL_BRANCH.test(rid)) return { kind: 'error', reason: `'${rid}' is not a global branch RID (ri.branch..branch.…).` }
65 return { kind: 'adopt', rid, goal }
66 }
67 return { kind: 'start', goal: cleanGoal(trimmed) }
68}
69
70const stamp = (now: Date) => now.toISOString().replace(/\D/g, '').slice(0, 12)
71
72/** agent/fde-<goal slug>-<yyyymmddhhmm>, at most 64 characters, valid for global-branch create. */
73export function fdeBranchName(goal: string, now: Date): string {
74 const suffix = `-${stamp(now)}`
75 const room = NAME_MAX - PREFIX.length - suffix.length
76 let slug = goal
77 .normalize('NFKD')
78 .toLowerCase()
79 .replace(/[^a-z0-9]+/g, '-')
80 .replace(/^-+|-+$/g, '')
81 if (slug.length > room) slug = slug.slice(0, room).replace(/-[^-]*$/, '') || slug.slice(0, room)
82 slug = slug.replace(/-+$/, '')
83 const name = slug === '' ? `${PREFIX}${stamp(now)}` : `${PREFIX}${slug}${suffix}`
84 return validateBranchName('global-branch create', name).ok ? name : `${PREFIX}work${suffix}`
85}
86
87export function branchCreateArgs(name: string, ontologyRid: string, goal: string): string[] {
88 return ['global-branch', 'create', name, '--ontology-rid', ontologyRid, '--description', (goal === '' ? 'claude-foundry FDE session' : `claude-foundry FDE: ${goal}`).slice(0, GOAL_MAX), '--apply']
89}
90
91/** The global branch RID in a clean orthanc envelope; null on errors or none. */
92export function branchRidFrom(envelope: { data?: unknown; errors?: unknown }): string | null {
93 if (Array.isArray(envelope.errors) && envelope.errors.length > 0) return null
94 const found = /ri\.branch\.\.branch\.[A-Za-z0-9-]+/.exec(JSON.stringify(envelope.data ?? null))
95 return found ? found[0] : null
96}
97
98export const hasApply = (args: readonly string[]) => args.some(arg => /^--apply(=.*)?$/.test(unquote(arg)))
99
100const FILE_FLAG = /^--(?:[a-z0-9-]+-)?file$/
101const absolute = (path: string, cwd: string, home: string) => {
102 const expanded = home !== '' ? path.replace(/^~(?=\/|$)/, home) : path
103 return expanded.startsWith('/') ? expanded : `${cwd.replace(/\/$/, '')}/${expanded}`
104}
105// Any word that could name a file: not a flag, RID, URL or number. A word that
106// is not a file simply does not read, and is left out of the plan.
107const looksLikePath = (value: string) =>
108 value !== '' && !value.startsWith('-') && !/^ri\./.test(value) && !/^[a-z]+:\/\//i.test(value) && !/^[0-9.:]+$/.test(value) && !/[\n{}]/.test(value)
109
110/** Files a call may read, absolute against `cwd`: `required` (values of --file
111 * and --*-file flags, and @name references) must be readable; `optional`
112 * (every other word that could be a path) is hashed when it reads. Directories
113 * do not read, so their contents are not pinned. */
114export function fileCandidates(args: readonly string[], cwd: string, home = ''): { required: string[]; optional: string[] } {
115 const required: string[] = []
116 const optional: string[] = []
117 for (let index = 0; index < args.length; index += 1) {
118 const arg = unquote(args[index] ?? '')
119 if (FILE_FLAG.test(arg) && args[index + 1] !== undefined) {
120 required.push(absolute(unquote(args[index + 1]!).replace(/^@/, ''), cwd, home))
121 index += 1
122 continue
123 }
124 const joined = /^(--[a-z0-9-]+)=(.+)$/i.exec(arg)
125 const raw = joined ? unquote(joined[2]!) : arg
126 const value = raw.replace(/^@/, '')
127 if ((joined && FILE_FLAG.test(joined[1]!)) || (raw.startsWith('@') && value !== '')) required.push(absolute(value, cwd, home))
128 else if (looksLikePath(value)) optional.push(absolute(value, cwd, home))
129 }
130 return { required, optional }
131}
132
133/** SHA-256 of the text, with its length: what a plan pins a file to. */
134export async function contentHash(text: string): Promise<string> {
135 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
136 return `${text.length}:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}`
137}
138
139/** One call, --apply removed, with the hash of every file it reads. */
140export function planSignature(path: string, args: readonly string[], fileHashes: Record<string, string>): string {
141 const kept = args.filter(arg => !/^--apply(=.*)?$/.test(unquote(arg))).map(unquote)
142 const files = Object.keys(fileHashes)
143 .sort()
144 .map(file => [file, fileHashes[file]])
145 return JSON.stringify([path, kept, files])
146}
147
148export function planGate(fde: FdeSession, signature: string, isApply: boolean): string | null {
149 if (!isApply || fde.plans.includes(signature)) return null
150 return (
151 'FDE mode: --apply runs only after a clean dry run of the same call. Run it without --apply first, ' +
152 'read the plan, then apply with identical arguments. Each dry run licenses one apply, and editing a file the call reads voids it.'
153 )
154}
155
156export const recordPlan = (fde: FdeSession, signature: string): FdeSession =>
157 fde.plans.includes(signature) ? fde : { ...fde, plans: [...fde.plans, signature].slice(-RECORD_MAX) }
158
159/** Takes the plan for one apply, so a second identical apply (even in parallel) finds it gone. */
160export function takePlan(fde: FdeSession, signature: string): { fde: FdeSession; taken: boolean } {
161 if (!fde.plans.includes(signature)) return { fde, taken: false }
162 return { fde: { ...fde, plans: fde.plans.filter(plan => plan !== signature) }, taken: true }
163}
164
165/** Why a file the call reads is refused: it names another global branch or ontology. */
166export function foreignRids(fde: FdeSession, text: string): string | null {
167 const branches = new Set(text.match(GLOBAL_BRANCH_ANY) ?? [])
168 branches.delete(fde.branchRid)
169 const ontologies = new Set(text.match(ONTOLOGY_RID) ?? [])
170 ontologies.delete(fde.ontologyRid)
171 const found = [...branches, ...ontologies]
172 return found.length === 0 ? null : `a file this call reads names ${found.join(', ')}; the session works on ${fde.branchRid} in ${fde.ontologyRid} only.`
173}
174
175/** An apply consumes its dry run: the next apply needs a new one. */
176export const recordApply = (fde: FdeSession, command: string, signature: string, now: Date): FdeSession => ({
177 ...fde,
178 plans: fde.plans.filter(plan => plan !== signature),
179 applied: [...fde.applied, { command, at: now.toISOString() }].slice(-RECORD_MAX),
180})
181
182/** Binds one orthanc_cli call to the session: the (possibly rewritten) args, or why it is refused. */
183export function fdeBind(
184 fde: FdeSession,
185 path: string,
186 args: readonly string[],
187 profile: string,
188 isMutation: boolean,
189): { args: string[] } | { deny: string } {
190 const tag = `FDE mode (${fde.branchName})`
191 const words = args.map(unquote)
192 // The stack is the session's: no call may switch it, read or write.
193 if (words.some(word => PROFILE_FLAG.test(word))) return { deny: `${tag}: --profile is not allowed while bound; the session runs on stack '${fde.profile}'.` }
194 if (!isMutation) return { args: [...args] }
195 if (profile !== fde.profile) {
196 return { deny: `${tag}: the session's branch is on stack '${fde.profile}', but the active stack is '${profile}'. Switch back with /profile ${fde.profile}, or /fde end.` }
197 }
198 if (path === 'global-branch create') {
199 return { deny: `${tag}: this session works on ${fde.branchRid} only. Do not create another global branch; the operator ends the mode with /fde end.` }
200 }
201 if ((path.startsWith('global-branch ') && !GLOBAL_BRANCH_ALLOWED.has(path)) || path.startsWith('proposal ')) {
202 return { deny: `${tag}: '${path}' changes branches or proposals, which is the operator's decision at handoff. Report that the work is ready instead.` }
203 }
204 const foreign = new Set(words.flatMap(word => word.match(GLOBAL_BRANCH_ANY) ?? []))
205 foreign.delete(fde.branchRid)
206 if (foreign.size > 0) return { deny: `${tag}: this call names ${[...foreign].join(', ')}; the session works on ${fde.branchRid} only.` }
207 if (path.startsWith('global-branch ') && !words.includes(fde.branchRid)) {
208 return { deny: `${tag}: '${path}' must name the session's branch ${fde.branchRid}.` }
209 }
210 const ontologies = new Set(words.flatMap(word => word.match(ONTOLOGY_RID) ?? []))
211 ontologies.delete(fde.ontologyRid)
212 if (ontologies.size > 0) {
213 return { deny: `${tag}: the branch belongs to ontology ${fde.ontologyRid}; this call names ${[...ontologies].join(', ')}.` }
214 }
215 if (!path.startsWith('ontology ')) return { args: [...args] }
216 if (words.some(word => AMBIGUOUS_BRANCH_FLAG.test(word))) {
217 return { deny: `${tag}: spell the branch flag as --branch (or leave it out and the mode supplies it).` }
218 }
219 const branches = flagValues(args, BRANCH_FLAGS)
220 if (branches.length === 0) return { args: requiresBranch(path) ? [...args, '--branch', fde.branchRid] : [...args] }
221 if (branches.length === 1 && branches[0] === fde.branchRid) return { args: [...args] }
222 const named = branches.filter(Boolean).join(', ') || '(no value)'
223 return { deny: `${tag}: ontology writes go to ${fde.branchRid}; this call names --branch ${named}. Drop --branch and the mode supplies it.` }
224}
225
226/** The branch's name when `data` (global-branch get) is an OPEN agent/ branch of `ontologyRid`; why not otherwise. */
227export function adoptable(data: unknown, ontologyRid: string): { name: string } | string {
228 const record = (data as { branch_record?: Record<string, unknown> } | null)?.branch_record
229 if (typeof record !== 'object' || record === null) return 'global-branch get returned no branch record.'
230 const name = typeof record.display_name === 'string' ? cleanGoal(record.display_name) : ''
231 if (record.branch_status !== 'OPEN') return `The branch is ${String(record.branch_status ?? 'unknown')}, not OPEN.`
232 if (record.ontology_rid !== ontologyRid) return `The branch belongs to ontology ${String(record.ontology_rid ?? 'unknown')}, not the selected ${ontologyRid}.`
233 if (!name.startsWith('agent/') || !validateBranchName('global-branch create', name).ok) return `The branch '${name.slice(0, 80) || '?'}' is not a valid agent/ branch name.`
234 return { name }
235}
236
237const appliedLines = (fde: FdeSession) =>
238 fde.applied.length === 0 ? ['- nothing applied'] : fde.applied.map(entry => `- ${entry.at} ${entry.command}`)
239
240export function fdeContext(fde: FdeSession): string {
241 return [
242 '# FDE mode',
243 `Goal: ${goalLabel(fde.goal)}${fde.goal === '' ? ' (the operator works with you turn by turn; /fde goal sets one)' : ''}`,
244 `This session works on one global branch: ${fde.branchName} (${fde.branchRid}) on ontology ${fde.ontologyRid}, stack ${fde.profile}.`,
245 'Ontology writes are pinned to it: omit --branch and the mode adds it; any other branch, --profile or a new branch is refused. ' +
246 'Pass --branch with that RID on reads you mean to see the branch (`ontology definition --object-type X --branch <rid>`); reads without it see main.',
247 'Plan, then apply: run each write as a dry run (no --apply), read the plan, then repeat it with --apply and identical arguments. ' +
248 'Each dry run licenses one apply, and editing a file the call reads voids it. Keep request files outside ~/.claude.',
249 'Work as an FDE: scout what exists, make the smallest change on the branch, verify it on the branch, then report. ' +
250 'Never create, close or merge branches: the operator ends the mode with /fde end and decides what lands.',
251 ].join('\n')
252}
253
254export function fdeStatus(fde: FdeSession): string {
255 return [
256 `FDE mode on · ${goalLabel(fde.goal)}`,
257 `branch ${fde.branchName} (${fde.branchRid})`,
258 `stack ${fde.profile} · ontology ${fde.ontologyRid} · since ${fde.startedAt}`,
259 `${fde.applied.length} applied · ${fde.plans.length} planned`,
260 '/fde end hands off and leaves the branch open.',
261 ].join('\n')
262}
263
264export function fdeHandoff(fde: FdeSession): string {
265 return [
266 `# FDE handoff: ${goalLabel(fde.goal)}`,
267 '',
268 `- Branch: ${fde.branchName} (${fde.branchRid}), still OPEN and not merged.`,
269 `- Stack ${fde.profile}, ontology ${fde.ontologyRid}, started ${fde.startedAt}.`,
270 `- Applied writes (${fde.applied.length}):`,
271 ...appliedLines(fde).map(line => ` ${line}`),
272 '',
273 'The operator reviews the branch, then merges it through a proposal or closes it: ' +
274 `orthanc global-branch close ${fde.branchRid} --apply --yes.`,
275 ].join('\n')
276}
277hooks/orthanc.ts 196 lines1// The orthanc_help and orthanc_cli tools' pure half.
2// Pure: the envelope, the manifest and the help text. Running orthanc stays
3// in register.tsx, where `$` lives.
4
5import { DENIED_COMMANDS, type ManifestCommand } from './policy'
6
7export type Envelope = {
8 schema_version: string
9 data?: unknown
10 errors?: unknown[]
11 meta?: Record<string, unknown>
12 warnings?: unknown[]
13 [key: string]: unknown
14}
15
16const INLINE_LIMIT = 20_000
17const ROW_NAMES_LIMIT = 8_000
18
19/** A row's identity for a cut listing: api name, else display name, else RID. */
20const rowName = (row: Record<string, unknown>) =>
21 [row.api_name, row.apiName, row.display_name, row.name, row.rid].find((value): value is string => typeof value === 'string' && value !== '') ?? ''
22const USAGE_ERRORS = ['required arguments were not provided', 'unexpected argument', 'usage: orthanc', 'invalid value']
23
24export const isRecord = (value: unknown): value is Record<string, unknown> =>
25 typeof value === 'object' && value !== null && !Array.isArray(value)
26
27export function parseEnvelope(stdout: string): Envelope | null {
28 try {
29 const parsed: unknown = JSON.parse(stdout)
30 return isRecord(parsed) && parsed.schema_version === 'foundry-agent-v1' ? (parsed as Envelope) : null
31 } catch {
32 return null
33 }
34}
35
36export const failure = (type: string, message: string, operation: string, extra: Record<string, unknown> = {}): Envelope => ({
37 schema_version: 'foundry-agent-v1',
38 data: null,
39 errors: [{ type, message, ...extra }],
40 meta: { operation },
41 warnings: [],
42})
43
44export function manifestFrom(envelope: Envelope): ManifestCommand[] {
45 const commands = isRecord(envelope.data) && Array.isArray(envelope.data.commands) ? envelope.data.commands : []
46 return commands.filter(isRecord).flatMap(entry =>
47 typeof entry.path === 'string'
48 ? [{
49 path: entry.path,
50 kind: typeof entry.kind === 'string' ? entry.kind : undefined,
51 auth: typeof entry.auth === 'string' ? entry.auth : undefined,
52 input: typeof entry.input === 'string' ? entry.input : undefined,
53 }]
54 : [],
55 )
56}
57
58export const normalizeCommand = (command: string) => command.trim().replace(/\s+/g, ' ')
59
60const groupOf = (path: string) => path.split(' ')[0] ?? path
61
62function commandLine(command: ManifestCommand): string {
63 const traits = [command.kind, command.auth && `auth: ${command.auth}`, command.input && `input: ${command.input}`]
64 .filter(Boolean)
65 .join(', ')
66 return `${command.path}${traits ? ` — ${traits}` : ''}`
67}
68
69export const isCredentialCommand = (command: ManifestCommand) =>
70 DENIED_COMMANDS.has(command.path) || command.input === 'token-on-stdin'
71
72export function renderCommand(command: ManifestCommand, help: string | null): string {
73 const lines = [commandLine(command)]
74 if (help) {
75 lines.push('', help.slice(0, 4000), '')
76 lines.push(
77 'For orthanc_cli, put positionals first in args, then exact flags. Do not invent flags. ' +
78 'Agent/non-interactive flags and the profile are supplied by the tool.',
79 )
80 }
81 if (isCredentialCommand(command)) {
82 lines.push('This credential-input command is not executable through orthanc_cli; the operator runs it in a terminal.')
83 }
84 return lines.join('\n')
85}
86
87export function renderListing(
88 commands: readonly ManifestCommand[],
89 input: { group?: string; query?: string },
90): string {
91 if (input.query) {
92 const terms = input.query.toLowerCase().split(/\s+/).filter(Boolean)
93 const hits = commands
94 .map(command => ({ command, score: terms.filter(term => command.path.toLowerCase().includes(term)).length }))
95 .filter(hit => hit.score > 0)
96 .sort((left, right) => right.score - left.score || left.command.path.localeCompare(right.command.path))
97 .slice(0, 20)
98 if (hits.length === 0) return `No Orthanc commands matched '${input.query}'.`
99 return [`Orthanc search '${input.query}': ${hits.length} hit${hits.length === 1 ? '' : 's'}.`, '', ...hits.map(hit => commandLine(hit.command))].join('\n')
100 }
101 if (input.group) {
102 const matches = commands.filter(command => groupOf(command.path) === input.group)
103 if (matches.length === 0) {
104 return `Unknown Orthanc group '${input.group}'. Groups: ${[...new Set(commands.map(command => groupOf(command.path)))].sort().join(', ')}.`
105 }
106 return [`Orthanc group '${input.group}': ${matches.length} command${matches.length === 1 ? '' : 's'}.`, '', ...matches.map(commandLine)].join('\n')
107 }
108 const counts = new Map<string, number>()
109 for (const command of commands) counts.set(groupOf(command.path), (counts.get(groupOf(command.path)) ?? 0) + 1)
110 return [
111 `Orthanc command surface: ${commands.length} commands in ${counts.size} groups.`,
112 'Call orthanc_help with a group to list commands, with a command path to inspect it, or with query to search.',
113 '',
114 ...[...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([group, count]) => `${group} (${count})`),
115 ].join('\n')
116}
117
118function verificationHint(path: string): string {
119 if (path.startsWith('global-branch create')) return "verify with 'global-branch get <branch_rid>' before reporting success"
120 if (path.startsWith('dataset branches create')) return "verify with 'dataset branches list <dataset_rid>' before reporting success"
121 if (path.startsWith('dataset create')) return "verify with 'dataset get <dataset_rid>' before reporting success"
122 if (path.startsWith('ontology ')) return 'verify with the matching ontology read for the changed type before reporting success'
123 return 'verify the change with the matching read operation before reporting success'
124}
125
126export const isErrorEnvelope = (envelope: Envelope) => Array.isArray(envelope.errors) && envelope.errors.length > 0
127
128export function isUsageError(envelope: Envelope): boolean {
129 const reason = JSON.stringify(envelope.errors ?? []).toLowerCase()
130 return USAGE_ERRORS.some(pattern => reason.includes(pattern))
131}
132
133/** Usage hint on usage errors; verification hint or uncertain-mutation flag on mutations. */
134export function guideEnvelope(envelope: Envelope, command: ManifestCommand, help: string | null): Envelope {
135 const usage = isUsageError(envelope)
136 let guided = usage && help ? { ...envelope, usage_hint: help.slice(0, 4000) } : envelope
137 if (command.kind !== 'mutation') return guided
138 if (!isErrorEnvelope(guided)) return { ...guided, verification_hint: verificationHint(command.path) }
139 if (!usage && JSON.stringify(guided.errors).includes('orthanc-launch-error')) {
140 guided = { ...guided, uncertain_mutation: true, guidance: 'reconcile observable Foundry state before any retry' }
141 }
142 return guided
143}
144
145/**
146 * The envelope as text for the model. Over the inline limit it is cut, with the
147 * page token and list sizes hoisted above the cut (they sit in `data`, which the
148 * cut can hide) and, when given, the path the whole envelope was written to.
149 */
150export function serialize(value: unknown, spillPath?: string): string {
151 const text = JSON.stringify(value, null, 2) ?? 'null'
152 if (text.length <= INLINE_LIMIT) return text
153 const header = [`orthanc output is ${text.length} chars; showing the first ${INLINE_LIMIT}.`]
154 if (spillPath) header.push(`The whole envelope is in ${spillPath}: read it with the Read tool (offset/limit) or jq.`)
155 const data = isRecord(value) ? value.data : undefined
156 if (isRecord(data)) {
157 const token = data.next_page_token ?? data.nextPageToken
158 if (typeof token === 'string' && token) header.push(`next_page_token: ${token} (pass --page-token to read the next page).`)
159 const sizes = Object.entries(data).filter(([, entry]) => Array.isArray(entry)).map(([key, entry]) => `${key}: ${(entry as unknown[]).length}`)
160 if (sizes.length > 0) header.push(`rows on this page: ${sizes.join(', ')}.`)
161 for (const [key, entry] of Object.entries(data)) {
162 if (!Array.isArray(entry)) continue
163 const names = entry.filter(isRecord).map(rowName).filter(Boolean)
164 if (names.length === 0) continue
165 const joined = names.join(', ')
166 header.push(`${key}: ${joined.length > ROW_NAMES_LIMIT ? `${joined.slice(0, ROW_NAMES_LIMIT)} …` : joined}`)
167 }
168 }
169 if (!spillPath) header.push('Narrow the call (--page-size, a get command) for less output.')
170 return `${header.join('\n')}\n\n${text.slice(0, INLINE_LIMIT)}\n... truncated`
171}
172
173export const isOversized = (value: unknown) => (JSON.stringify(value, null, 2)?.length ?? 0) > INLINE_LIMIT
174
175/** The list inside an orthanc list envelope, wherever the envelope keeps it. */
176export function listItems(envelope: Envelope, key: string): Record<string, unknown>[] {
177 const data = envelope.data
178 if (Array.isArray(data)) return data.filter(isRecord)
179 if (!isRecord(data)) return []
180 const list = data[key] ?? data.data
181 return Array.isArray(list) ? list.filter(isRecord) : []
182}
183
184/** The rows of an envelope whose list key varies by command: the named keys, then the first list in data. */
185export function rowsOf(envelope: Envelope, keys: readonly string[]): Record<string, unknown>[] {
186 const data = envelope.data
187 if (Array.isArray(data)) return data.filter(isRecord)
188 if (!isRecord(data)) return []
189 for (const key of [...keys, 'data']) {
190 const list = data[key]
191 if (Array.isArray(list)) return list.filter(isRecord)
192 }
193 const first = Object.values(data).find(Array.isArray)
194 return Array.isArray(first) ? first.filter(isRecord) : []
195}
196hooks/policy.ts 729 lines1// The approval ladder, the branch gate and the approval modes. Pure functions:
2// no `$`, so the test kit and the hooks share them. Read/mutation comes from
3// orthanc's own agent-manifest `kind`.
4
5export type ManifestCommand = { path: string; kind?: string; auth?: string; input?: string }
6
7export const APPROVAL_MODES = ['ask-always', 'allow-edits', 'auto', 'yolo'] as const
8export type ApprovalMode = (typeof APPROVAL_MODES)[number]
9export const DEFAULT_APPROVAL_MODE: ApprovalMode = 'auto'
10
11export function parseApprovalMode(value: string): ApprovalMode | null {
12 const normalized = value.trim().toLowerCase()
13 return APPROVAL_MODES.find(mode => mode === normalized) ?? null
14}
15
16export const ORTHANC_CLI = 'mcp__foundry__orthanc_cli'
17export const ORTHANC_HELP = 'mcp__foundry__orthanc_help'
18
19const PROTECTED_BRANCHES = ['main', 'master']
20const BRANCH_CREATION_PATHS = ['global-branch create', 'dataset branches create']
21// The branch rule: agent work happens on branches, never on a production main.
22// Each of these writes must name a non-protected --branch; ontology writes must
23// name a global branch RID, the one branch shape that is provably not main.
24const BRANCH_REQUIRED_PATHS = new Set([
25 'compute manage',
26 'compute execute',
27 'dataset files upload',
28 'dataset files delete',
29 'dataset schema set',
30 'dataset schema apply',
31 'dataset schema update',
32 'dataset transactions start',
33 'ontology action-apply',
34 'ontology object-type-create',
35 'ontology object-type-upsert',
36 'ontology object-type-guarded-upsert',
37 'ontology object-type-add-property',
38 'ontology object-type-delete',
39 'ontology object-type-guarded-delete',
40 'ontology link-type-create',
41 'ontology link-type-upsert',
42 'ontology link-type-delete',
43 'ontology action-type-upsert',
44 'ontology action-type-update',
45 'ontology action-type-set-function-version',
46 'ontology action-type-delete',
47 'streams dataset create',
48 'streams stream create',
49 'streams stream publish',
50 'streams stream publish-batch',
51 'streams stream reset',
52 'orchestration builds create',
53 'cp',
54])
55export const requiresBranch = (path: string) => BRANCH_REQUIRED_PATHS.has(path)
56// Ontology writes Orthanc cannot branch: they change live data on main.
57const MAIN_ONLY_WRITES = new Set(['ontology object-upsert', 'ontology object-upsert-batch', 'ontology action-apply-with-overrides'])
58// Promotion into main is the operator's decision, not the agent's.
59// A tag publishes a release from a commit, so it is promotion too.
60const PROMOTIONS = new Set(['proposal merge', 'proposal accept', 'proposal approve', 'repository tag create'])
61export const BRANCH_FLAGS = ['--branch', '-b']
62const BRANCH_VALUE = /^[A-Za-z0-9._/-]+$/
63// Orthanc flags that take no value, so the word after one is a positional.
64const BOOLEAN_FLAGS = new Set([
65 '--agent', '--apply', '--allow-default-branch', '--non-interactive', '--dry-run', '--force',
66 '--recursive', '-r', '--schema', '--fail-fast', '--debug', '--help', '-h',
67])
68const GLOBAL_BRANCH_RID = 'ri.branch.'
69const GLOBAL_BRANCH_PREFIX = 'agent/'
70const DATASET_BRANCH_PREFIX = 'agent-'
71const BRANCH_NAME_MAX_LEN = 64
72const READ_VERBS = new Set([
73 'get', 'list', 'search', 'describe', 'check', 'health', 'inspect',
74 'preview', 'query', 'read', 'view', 'help', 'export', 'status', 'download',
75])
76export const DENIED_COMMANDS = new Set(['auth store', 'onboarding'])
77// Orthanc `local` commands that only read local state.
78const LOCAL_READS = new Set([
79 'version', 'hello', 'agent-manifest', 'capabilities', 'completion show',
80 'configure list', 'alias list', 'alias show', 'alias resolve', 'alias export',
81])
82
83export type GateClassification = {
84 command: string | null
85 kind: 'mutation' | 'branch-creation'
86 isBranchScoped: boolean
87 hasWorkingBranch: boolean
88 targetsProtected: boolean
89 isDestructive: boolean
90 proposedName?: string
91 error?: string
92}
93
94export type LadderDecision = { outcome: 'run' | 'ask' | 'block'; reason: string }
95
96export type GuardVerdict = { action: 'approve' | 'ask' | 'deny'; reason: string }
97
98const lastWord = (text: string) => text.split(/\s+/).at(-1) ?? ''
99
100export function classifyOrthancCommand(
101 command: string,
102 manifest: readonly ManifestCommand[],
103): 'read' | 'mutation' {
104 const normalized = command.trim().toLowerCase()
105 const declared = manifest.find(entry => entry.path === normalized)
106 if (declared?.kind === 'read-only') return 'read'
107 if (declared?.kind === 'local' && LOCAL_READS.has(normalized)) return 'read'
108 if (declared === undefined && READ_VERBS.has(lastWord(normalized))) return 'read'
109 return 'mutation'
110}
111
112function isDestructiveCommand(command: string): boolean {
113 return lastWord(command.trim().toLowerCase()).split('-').at(-1) === 'delete'
114}
115
116function argsHaveForce(args: readonly string[]): boolean {
117 return args.some(arg => {
118 const flag = arg.trim().toLowerCase().replace(/=.*$/, '')
119 return flag === '--force' || flag === '-f'
120 })
121}
122
123// `main`, `refs/heads/main`, `+refs/heads/main`, `:main`, `./main/` all name main.
124const branchCore = (value: string) =>
125 value
126 .trim()
127 .toLowerCase()
128 .replace(/^[+:]+|:+$/g, '')
129 .replace(/\/{2,}/g, '/')
130 .replace(/^(\.\/)+/, '')
131 .replace(/^(refs\/)?heads\//, '')
132 .replace(/[/.]+$/, '')
133const isProtectedValue = (value: string) => PROTECTED_BRANCHES.includes(branchCore(value)) || branchCore(value) === 'head'
134
135export function branchIsWorking(branch: string | null | undefined): boolean {
136 return typeof branch === 'string' && branch.trim() !== '' && !isProtectedValue(branch)
137}
138
139export function validateBranchName(
140 path: string,
141 name: string,
142): { ok: true; name: string } | { ok: false; reason: string } {
143 const trimmed = name.trim()
144 if (trimmed.length > BRANCH_NAME_MAX_LEN) {
145 return { ok: false, reason: `branch name exceeds ${BRANCH_NAME_MAX_LEN} characters` }
146 }
147 const prefix = path === 'global-branch create' ? GLOBAL_BRANCH_PREFIX : DATASET_BRANCH_PREFIX
148 if (!trimmed.startsWith(prefix)) {
149 const kind = prefix === GLOBAL_BRANCH_PREFIX ? 'global' : 'dataset'
150 return {
151 ok: false,
152 reason: `${kind} branch name must start with '${prefix}', e.g. '${prefix}foundry-fix-12'`,
153 }
154 }
155 const body = trimmed.slice(prefix.length)
156 const isKebab = body !== '' && body.split('-').every(part => /^[a-z0-9]+$/.test(part))
157 if (!isKebab) {
158 return {
159 ok: false,
160 reason: 'branch name body must be lowercase alphanumeric segments separated by single hyphens',
161 }
162 }
163 return { ok: true, name: trimmed }
164}
165
166export function proposedBranchName(args: readonly string[]): string | null {
167 const candidate = args.find(item => {
168 const trimmed = item.trim()
169 return trimmed !== '' && !trimmed.startsWith('--') && !trimmed.startsWith('ri.')
170 })
171 return candidate ? candidate.trim() : null
172}
173
174const isBranchFlag = (value: unknown) =>
175 typeof value === 'string' && value.replace(/^-+/, '').toLowerCase() === 'branch'
176
177function targetsProtected(input: unknown): boolean {
178 if (typeof input === 'string') return false
179 if (Array.isArray(input)) {
180 const flagPair = input.some((flag, index) => {
181 const value = input[index + 1]
182 return isBranchFlag(flag) && typeof value === 'string' && isProtectedValue(value)
183 })
184 return flagPair || input.some(item => targetsProtected(item))
185 }
186 if (typeof input !== 'object' || input === null) return false
187 return Object.entries(input).some(([key, value]) => {
188 const field = key.replace(/^-+/, '').toLowerCase()
189 const isBranchField = field === 'branch' || field === 'branchname' || field === 'branch_name'
190 return (
191 (isBranchField && typeof value === 'string' && isProtectedValue(value)) ||
192 targetsProtected(value)
193 )
194 })
195}
196
197export const unquote = (value: string) => value.trim().replace(/^(['"])(.*)\1$/, '$2')
198
199/** Every value of the named flags (`--name value`, `--name=value`, `-bvalue`), unquoted; '' for a flag with no value. */
200export function flagValues(args: readonly string[], names: readonly string[]): string[] {
201 const found: string[] = []
202 for (let index = 0; index < args.length; index += 1) {
203 const arg = unquote(args[index] ?? '')
204 for (const name of names) {
205 if (arg === name) {
206 const value = unquote(args[index + 1] ?? '')
207 if (value === '' || value.startsWith('-')) found.push('')
208 else {
209 found.push(value)
210 index += 1
211 }
212 break
213 }
214 if (arg.startsWith(`${name}=`)) {
215 found.push(unquote(arg.slice(name.length + 1)))
216 break
217 }
218 if (/^-[a-z]$/.test(name) && arg.startsWith(name) && arg.length > 2) {
219 found.push(unquote(arg.slice(2)))
220 break
221 }
222 }
223 }
224 return found
225}
226
227/** The first value of `--name value` or `--name=value`, unquoted; '' when the flag has no value. */
228export function flagValue(args: readonly string[], name: string): string | null {
229 return flagValues(args, [name])[0] ?? null
230}
231
232const positionals = (args: readonly string[]) => {
233 const found: string[] = []
234 for (let index = 0; index < args.length; index += 1) {
235 const arg = unquote(args[index] ?? '')
236 if (arg.startsWith('-')) {
237 const takesValue = !arg.includes('=') && !BOOLEAN_FLAGS.has(arg)
238 if (takesValue && args[index + 1] !== undefined && !unquote(args[index + 1] ?? '').startsWith('-')) index += 1
239 continue
240 }
241 found.push(arg)
242 }
243 return found
244}
245
246/** Why the branch rule refuses this Orthanc write, or null. Applies in every approval mode. */
247export function branchRuleReason(command: string, args: readonly string[]): string | null {
248 if (MAIN_ONLY_WRITES.has(command)) {
249 return `'${command}' writes live data on the main ontology branch and cannot take --branch. Agents do not run it: use 'ontology action-apply --branch <global branch RID>' or hand it to the operator.`
250 }
251 if (PROMOTIONS.has(command)) {
252 return `'${command}' promotes work (a merge, an approval or a release). The operator does it; the agent does not.`
253 }
254 if (command === 'repository push') {
255 if (args.some(arg => unquote(arg).replace(/=.*$/, '').toLowerCase() === '--allow-default-branch')) {
256 return "'repository push --allow-default-branch' pushes to the default branch. Push to a work branch and open a pull request."
257 }
258 const found = positionals(args)
259 if (found.length !== 3) {
260 return `'repository push' takes exactly <repository rid> <local ref> <destination ref>; found ${found.length} positionals, so the destination cannot be checked.`
261 }
262 const destination = found[2]!
263 if (isProtectedValue(destination)) {
264 return `'repository push' to ${destination} targets the default branch. Push to a work branch and open a pull request.`
265 }
266 return null
267 }
268 if (!BRANCH_REQUIRED_PATHS.has(command)) return null
269 const branches = flagValues(args, BRANCH_FLAGS)
270 if (branches.length > 1) return `'${command}' names --branch ${branches.length} times. Name one work branch.`
271 const branch = branches[0] ?? null
272 if (branch === null || branch === '') {
273 return command.startsWith('ontology ')
274 ? `'${command}' names no --branch, so it would change the main ontology. Pass --branch <global branch RID> (ri.branch..branch.…); create one with 'global-branch create ${GLOBAL_BRANCH_PREFIX}<name>'.`
275 : `'${command}' names no --branch, so it would write the default branch (master). Pass --branch <work branch>; create one with 'dataset branches create <dataset> ${DATASET_BRANCH_PREFIX}<name>' or use a global branch.`
276 }
277 if (!BRANCH_VALUE.test(branch)) return `'${command}' branch '${branch}' has characters no branch name uses.`
278 if (isProtectedValue(branch)) return `'${command}' targets ${branch}. Agent work happens on branches, never on main or master.`
279 if (command.startsWith('ontology ') && !branch.startsWith(GLOBAL_BRANCH_RID)) {
280 return `'${command}' must target a global branch RID (ri.branch..branch.…), not '${branch}': only a global branch is provably not the main ontology branch.`
281 }
282 return null
283}
284
285function orthancClassification(
286 command: string,
287 args: readonly string[],
288 rawInput: unknown,
289 workingBranch: string | null,
290): GateClassification {
291 const base = {
292 command,
293 hasWorkingBranch: branchIsWorking(workingBranch),
294 targetsProtected: targetsProtected(rawInput),
295 }
296 if (!BRANCH_CREATION_PATHS.includes(command)) {
297 const refused = branchRuleReason(command, args)
298 return {
299 ...base,
300 kind: 'mutation',
301 isBranchScoped: BRANCH_REQUIRED_PATHS.has(command),
302 isDestructive: isDestructiveCommand(command) || argsHaveForce(args),
303 ...(refused ? { error: refused } : {}),
304 }
305 }
306 const creation = { ...base, kind: 'branch-creation' as const, isBranchScoped: false, isDestructive: false }
307 const proposed = proposedBranchName(args)
308 if (proposed === null) {
309 return {
310 ...creation,
311 error: `branch creation blocked: the call proposes no branch name; name branches '${GLOBAL_BRANCH_PREFIX}<kebab>' for global branches`,
312 }
313 }
314 const validated = validateBranchName(command, proposed)
315 return validated.ok
316 ? { ...creation, targetsProtected: false, proposedName: proposed }
317 : { ...creation, proposedName: proposed, error: `branch name '${proposed}' rejected: ${validated.reason}` }
318}
319
320/** An orthanc_cli command as the manifest path it names (lowercased, longest
321 * prefix) and its args; words past the path lead the args. Classifier and
322 * executor both resolve through here, so they see the same command. */
323export function resolveOrthancCommand(
324 command: string,
325 args: readonly string[],
326 manifest: readonly ManifestCommand[],
327): { path: string; args: string[] } {
328 const words = command.trim().split(/\s+/).filter(word => word !== '')
329 const paths = new Set(manifest.map(entry => entry.path))
330 for (let width = words.length; width >= 1; width -= 1) {
331 const path = words.slice(0, width).join(' ').toLowerCase()
332 if (paths.has(path)) return { path, args: [...words.slice(width), ...args] }
333 }
334 return { path: words.join(' ').toLowerCase(), args: [...args] }
335}
336
337const SHELL_OPERATORS = ';&|()`<>'
338
339/** A shell command as segments of words: quotes kept together, split at
340 * ; & | ( ) ` < > $( and newlines. A quoted word that holds a command
341 * (`bash -c "…"`) is split again and its segments added. */
342export function shellSegments(command: string, depth = 0): string[][] {
343 const segments: string[][] = []
344 let words: string[] = []
345 let word = ''
346 let quote: string | null = null
347 let quoted = false
348 const endWord = () => {
349 if (word !== '' || quoted) words.push(word)
350 word = ''
351 quoted = false
352 }
353 const endSegment = () => {
354 endWord()
355 if (words.length > 0) segments.push(words)
356 words = []
357 }
358 for (let index = 0; index < command.length; index += 1) {
359 const char = command[index]!
360 if (quote !== null) {
361 if (char === quote) quote = null
362 else if (char === '\\' && quote === '"' && index + 1 < command.length) word += command[++index]
363 else word += char
364 } else if (char === "'" || char === '"') {
365 quote = char
366 quoted = true
367 } else if (char === '\\' && index + 1 < command.length) word += command[++index]
368 else if (char === '\n') endSegment()
369 else if (/\s/.test(char)) endWord()
370 else if (char === '$' && command[index + 1] === '(') {
371 endSegment()
372 index += 1
373 } else if (SHELL_OPERATORS.includes(char)) endSegment()
374 else word += char
375 }
376 endSegment()
377 if (depth >= 3) return segments
378 const nested = segments.flatMap(found =>
379 found.filter(item => /[\s;&|()`<>]|\$\(/.test(item)).flatMap(item => shellSegments(item, depth + 1)),
380 )
381 return [...segments, ...nested]
382}
383
384const basename = (word: string | undefined) => (word ?? '').split('/').at(-1)?.replace(/^!/, '') ?? ''
385const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
386const MENTIONS_ORTHANC = /(^|[^\w/.-])orthanc([^\w.-]|$)|ORTHANC_BIN/i
387const isOrthancWord = (word: string) => basename(word).toLowerCase() === 'orthanc' || /\$\{?ORTHANC_BIN\}?/.test(word)
388const WRAPPERS = new Set([
389 'env', 'command', 'exec', 'time', 'nohup', 'sudo', 'doas', 'xargs', 'nice', 'timeout', 'caffeinate',
390 'builtin', 'stdbuf', 'then', 'do', 'else', '!', '{',
391])
392const INDIRECT = new Set(['eval', 'source', '.'])
393const CODE_RUNNERS = /^(python[0-9.]*|node|deno|bun|ruby|perl|osascript|awk)$/
394
395// Heads that name orthanc without running it (`grep orthanc`, `cd orthanc`).
396const NON_EXECUTING = new Set([
397 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'cat', 'bat', 'less', 'more', 'head', 'tail', 'wc', 'ls', 'cd', 'pushd',
398 'man', 'which', 'whereis', 'type', 'echo', 'printf', 'git', 'make', 'file', 'stat', 'open', 'code', 'vim',
399 'nano', 'brew', 'mkdir', 'rm', 'cp', 'mv', 'touch', 'tree', 'du', 'diff', 'cargo', 'sed', 'awk',
400])
401
402/** Where Orthanc starts in a segment: the command word, or the first orthanc
403 * word after any head that can run another program (env, sudo, xargs, find
404 * -exec, npx, ssh, `!`, …). -1 when the segment does not run Orthanc. */
405function orthancStart(words: readonly string[]): number {
406 let index = 0
407 while (index < words.length && ASSIGNMENT.test(words[index]!)) index += 1
408 if (index >= words.length) return -1
409 if (isOrthancWord(words[index]!)) return index
410 if (NON_EXECUTING.has(basename(words[index]))) return -1
411 return words.findIndex((word, position) => position > index && isOrthancWord(word))
412}
413
414/** True when a segment runs Orthanc in a form the parser cannot read: a
415 * variable or eval as the command, code passed to an interpreter, or an
416 * Orthanc command stored in a variable. `command` is the whole shell line. */
417function hidesOrthanc(words: readonly string[], command: string): boolean {
418 if (!MENTIONS_ORTHANC.test(command)) return false
419 let index = 0
420 while (index < words.length && (ASSIGNMENT.test(words[index]!) || WRAPPERS.has(basename(words[index])) || words[index]!.startsWith('-'))) {
421 if (ASSIGNMENT.test(words[index]!) && MENTIONS_ORTHANC.test(words[index]!.replace(ASSIGNMENT, ''))) return true
422 index += 1
423 }
424 const head = words[index]
425 if (head === undefined) return false
426 if (head.startsWith('$')) return true
427 const rest = words.slice(index + 1)
428 if (INDIRECT.has(head)) return rest.some(word => MENTIONS_ORTHANC.test(word))
429 // Interpreter code inline (`python3 -c "…orthanc…"`): a word holding code, or the one after -c/-e.
430 return CODE_RUNNERS.test(basename(head)) &&
431 rest.some((word, position) => MENTIONS_ORTHANC.test(word) && (/[\s()'"\[\];]/.test(word) || /^-(c|e|-eval)$/.test(rest[position - 1] ?? '')))
432}
433
434/** The Orthanc command a Bash segment runs: global flags skipped, then the longest manifest path. */
435function orthancShellCall(
436 words: readonly string[],
437 start: number,
438 manifest: readonly ManifestCommand[],
439): { path: string; known: boolean; help: boolean } {
440 const rest = words.slice(start + 1)
441 let index = 0
442 while (index < rest.length && rest[index]!.startsWith('-')) index += rest[index] === '--profile' ? 2 : 1
443 const tail = rest.slice(index)
444 const ended = rest.indexOf('--')
445 const flags = ended < 0 ? rest : rest.slice(0, ended)
446 const help = flags.some(word => ['-h', '--help', '-V', '--version'].includes(word)) || tail[0] === 'help'
447 const leading: string[] = []
448 for (const word of tail) {
449 if (word.startsWith('-')) break
450 leading.push(word)
451 }
452 const paths = new Set(manifest.map(entry => entry.path))
453 for (let width = leading.length; width >= 1; width -= 1) {
454 const path = leading.slice(0, width).join(' ').toLowerCase()
455 if (paths.has(path)) return { path, known: true, help }
456 }
457 return { path: leading.join(' ').toLowerCase(), known: false, help }
458}
459
460// Bash may read through Orthanc; every Orthanc write goes through orthanc_cli,
461// where the branch rule and the ladder see its arguments as data, not shell.
462function shellClassification(
463 command: string,
464 manifest: readonly ManifestCommand[],
465 workingBranch: string | null,
466): GateClassification | null {
467 const refuse = (path: string | null, error: string): GateClassification => ({
468 command: path,
469 kind: 'mutation',
470 isBranchScoped: false,
471 hasWorkingBranch: branchIsWorking(workingBranch),
472 targetsProtected: false,
473 isDestructive: false,
474 error,
475 })
476 for (const words of shellSegments(command)) {
477 if (hidesOrthanc(words, command)) {
478 return refuse(null, 'This shell command runs Orthanc in a form the Foundry guard cannot read (a variable, eval or interpreter code). Run Orthanc reads as plain `orthanc …` and writes with orthanc_cli.')
479 }
480 const start = orthancStart(words)
481 if (start < 0) continue
482 const call = orthancShellCall(words, start, manifest)
483 if (call.path === '' && !call.help && /\$|`/.test(command)) {
484 return refuse(null, 'This shell command builds an Orthanc command from a substitution or variable, which the Foundry guard cannot read. Run Orthanc reads as plain `orthanc …` and writes with orthanc_cli.')
485 }
486 if (call.help || call.path === '' || call.path === 'repository clone') continue
487 if (!call.known) {
488 return refuse(call.path, `Bash runs 'orthanc ${call.path}', which is not a command in Orthanc's agent manifest. Look it up with orthanc_help, then run it with orthanc_cli.`)
489 }
490 if (classifyOrthancCommand(call.path, manifest) === 'read') continue
491 return refuse(call.path, `Orthanc writes run through orthanc_cli, where the branch rule and approval ladder apply, not Bash. Run '${call.path}' with orthanc_cli.`)
492 }
493 return null
494}
495
496const PALANTIR_MCP = /^mcp__palantir(-mcp)?__/
497const WRITE_WORDS = new Set(['add', 'apply', 'build', 'create', 'delete', 'deploy', 'edit', 'install', 'post', 'publish', 'put', 'remove', 'rename', 'set', 'submit', 'update', 'write'])
498const AMBIGUOUS_WORDS = new Set(['call', 'execute', 'invoke', 'run', 'trigger'])
499const READ_WORDS = new Set(['check', 'describe', 'get', 'health', 'inspect', 'list', 'preview', 'query', 'read', 'search', 'view'])
500
501export function classifyMcpIntent(inner: string): 'read' | 'write' | 'unknown' {
502 const words = inner.toLowerCase().split(/[^a-z]+/)
503 if (words.some(word => WRITE_WORDS.has(word))) return 'write'
504 if (words.some(word => AMBIGUOUS_WORDS.has(word))) return 'unknown'
505 if (words.some(word => READ_WORDS.has(word))) return 'read'
506 return 'unknown'
507}
508
509/** Null: a read, or a tool outside the Foundry lane. Never reaches the ladder. */
510export function classifyCall(
511 tool: string,
512 input: Record<string, unknown>,
513 manifest: readonly ManifestCommand[],
514 workingBranch: string | null,
515): GateClassification | null {
516 if (tool === ORTHANC_CLI) {
517 const raw = typeof input.command === 'string' ? input.command : ''
518 const resolved = resolveOrthancCommand(raw, Array.isArray(input.args) ? input.args.map(String) : [], manifest)
519 if (resolved.path === '' || resolved.path === 'repository clone') return null
520 if (classifyOrthancCommand(resolved.path, manifest) === 'read') return null
521 return orthancClassification(resolved.path, resolved.args, { ...input, args: resolved.args }, workingBranch)
522 }
523 if (tool === 'Bash' && typeof input.command === 'string') return shellClassification(input.command, manifest, workingBranch)
524 if (PALANTIR_MCP.test(tool)) {
525 const inner = tool.replace(PALANTIR_MCP, '')
526 if (classifyMcpIntent(inner) === 'read') return null
527 return {
528 command: null,
529 kind: 'mutation',
530 isBranchScoped: false,
531 hasWorkingBranch: branchIsWorking(workingBranch),
532 targetsProtected: targetsProtected(input),
533 isDestructive: inner.toLowerCase().split(/[^a-z]+/).includes('delete'),
534 error: `${tool} writes to Foundry outside Orthanc, with no branch. Orthanc is the only write path: use orthanc_cli with --branch.`,
535 }
536 }
537 return null
538}
539
540export function ladderDecision(
541 mode: ApprovalMode,
542 classification: GateClassification,
543 verdict?: GuardVerdict | null,
544): LadderDecision {
545 if (classification.error) return { outcome: 'block', reason: classification.error }
546 if (classification.kind === 'branch-creation') {
547 if (mode === 'ask-always') {
548 return {
549 outcome: 'ask',
550 reason: `ask-always mode: branch creation '${classification.proposedName ?? '?'}' waits for approval`,
551 }
552 }
553 return { outcome: 'run', reason: 'branch creation is pre-authorized and reversible' }
554 }
555 const command = classification.command ?? '(foundry mcp write)'
556 const note = verdict ? ` ${verdict.reason}` : ''
557 if (classification.isDestructive) {
558 return { outcome: 'ask', reason: `destructive mutation '${command}' asks in every mode${note}` }
559 }
560 if (classification.targetsProtected && mode !== 'yolo') {
561 return {
562 outcome: 'ask',
563 reason: `'${command}' targets a protected baseline (main/master branch or the production ontology)${note}`,
564 }
565 }
566 if (mode === 'yolo') return { outcome: 'run', reason: 'yolo mode: no guardrails outside destructive paths' }
567 if (mode === 'ask-always') return { outcome: 'ask', reason: `ask-always mode: mutation '${command}' waits for approval` }
568 if (mode === 'allow-edits') return { outcome: 'run', reason: 'allow-edits mode: non-protected mutations run' }
569 if (!verdict) return { outcome: 'ask', reason: `'${command}': guard unavailable; failing closed to a human decision` }
570 if (verdict.action === 'approve') return { outcome: 'run', reason: verdict.reason }
571 return { outcome: 'ask', reason: `'${command}': ${verdict.reason}` }
572}
573
574export function modeGuidance(mode: ApprovalMode): string {
575 return (
576 'Branch rule: agent work happens on branches, never on a production main. Every ontology write ' +
577 'passes --branch <global branch RID> (ri.branch..branch.…; create one with global-branch create agent/<name>); ' +
578 'dataset, stream and build writes pass --branch <work branch>; code goes to a work branch and a pull request. ' +
579 'Writes without a branch, merges and default-branch pushes are blocked in every mode. ' +
580 `Foundry approval mode: ${mode}. Foundry mutations may surface an approval dialog for the ` +
581 'operator: the call continues once approved; if the operator declines or no one can be asked, ' +
582 'the call is blocked — report it, do not retry blindly.'
583 )
584}
585
586const COMPASS_ROOT = 'ri.compass.main.folder.0'
587
588function containsCompassRoot(value: unknown): boolean {
589 if (typeof value === 'string') return value.trim().toLowerCase() === COMPASS_ROOT
590 if (Array.isArray(value)) return value.some(containsCompassRoot)
591 if (typeof value === 'object' && value !== null) return Object.values(value).some(containsCompassRoot)
592 return false
593}
594
595export function compassRootReason(tool: string, input: unknown): string | null {
596 if (!containsCompassRoot(input)) return null
597 return `Compass root folder ${COMPASS_ROOT} is not a valid scope for ${tool}. Provide a verified child folder RID; root resource search is not supported.`
598}
599
600function stable(value: unknown): unknown {
601 if (Array.isArray(value)) return value.map(stable)
602 if (typeof value !== 'object' || value === null) return value
603 return Object.fromEntries(
604 Object.entries(value).sort(([left], [right]) => left.localeCompare(right)),
605 )
606}
607
608/** Third consecutive identical call (tool + top-level-sorted input) is blocked. */
609export function loopStep(
610 previous: { identity: string; streak: number } | null,
611 tool: string,
612 input: unknown,
613): { next: { identity: string; streak: number }; reason: string | null } {
614 const identity = JSON.stringify({ input: stable(input), tool })
615 const streak = previous?.identity === identity ? previous.streak + 1 : 1
616 const reason =
617 streak > 2
618 ? 'Loop guard blocks a third identical tool call. Use existing evidence or change the experiment.'
619 : null
620 return { next: { identity, streak }, reason }
621}
622
623// Raw `git push` from a Foundry clone skips Orthanc's default-branch refusal.
624// Every directory a `git … push` in a shell command could run in: '' (the
625// session's), each cd/pushd target, each -C, --git-dir and --work-tree value.
626// Over-counting only costs a remote check. Null when nothing pushes.
627export function gitPushDirectories(command: string): string[] | null {
628 const directories = new Set<string>([''])
629 let pushes = false
630 for (const words of shellSegments(command)) {
631 words.forEach((word, index) => {
632 if (word === 'cd' || word === 'pushd') {
633 const target = words.slice(index + 1).find(next => !next.startsWith('-'))
634 if (target !== undefined) directories.add(target)
635 }
636 const env = /^(?:GIT_DIR|GIT_WORK_TREE)=(.+)$/.exec(word)
637 if (env) directories.add(env[1]!)
638 })
639 const git = words.findIndex(word => basename(word) === 'git')
640 if (git < 0) continue
641 const rest = words.slice(git + 1)
642 for (let index = 0; index < rest.length; index += 1) {
643 const word = rest[index]!
644 if (word === '-C' || word === '--git-dir' || word === '--work-tree') {
645 if (rest[index + 1] !== undefined) directories.add(rest[index + 1]!)
646 index += 1
647 continue
648 }
649 if (word === '-c' || word === '--namespace' || word === '--config-env') {
650 // An alias set on the command line can be push under another name.
651 if (/^alias\./i.test(rest[index + 1] ?? '')) pushes = true
652 index += 1
653 continue
654 }
655 const joined = /^--(git-dir|work-tree)=(.+)$/.exec(word)
656 if (joined) directories.add(joined[2]!)
657 if (word.startsWith('-')) continue
658 if (word === 'push') pushes = true
659 break
660 }
661 }
662 return pushes ? [...directories] : null
663}
664
665/** A push that names a Foundry repository URL outright, from any directory. */
666export function gitPushNamesFoundry(command: string): boolean {
667 return gitPushDirectories(command) !== null && command.includes('ri.stemma.')
668}
669
670export const FOUNDRY_GIT_PUSH_REASON =
671 "Raw 'git push' to a Foundry code repository bypasses Orthanc. Push with " +
672 "'orthanc repository push <repository rid> refs/heads/<work branch> refs/heads/<work branch>' and open a pull request; it refuses the default branch."
673
674// The operator's files steer the write guard and the trace export, so only the
675// operator edits them: ~/.claude/foundry/ (rules.md, otel.env) and a project's
676// .claude/foundry-rules.md. otel.env can name a collector and key references,
677// so Claude does not read the directory either. The mod reads them once at
678// session start, so a change made mid-session waits for the next one.
679const FILE_WRITERS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
680const FILE_READERS = new Set(['Read', 'Grep', 'Glob', 'LS'])
681const PROTECTED_FILE = /(^|\/)\.claude\/(foundry-rules\.md|foundry(\/|$))/
682const SECRET_DIR = /(^|\/)\.claude\/foundry(\/|$)/
683// Any shell mention of these is denied, read or write.
684const SHELL_PROTECTED = /\.claude\/+(\.\/+)*foundry\b|foundry-rules|otel\.env|\.claude\/*\*/i
685// A loose mention (a relative foundry/…md path) is denied with a writer.
686const SHELL_MENTION = /\bfound\S*\/\S*\.md/i
687const SHELL_WRITES =
688 /(>|\btee\b|\bsed\b|\bmv\b|\bcp\b|\brm\b|\bln\b|\btruncate\b|\binstall\b|\bdd\b|\btouch\b|\bperl\b|\bpython[0-9.]*\b|\bnode\b|\bruby\b|\bed\b|\bex\b|\bvim?\b|\bnvim\b|\bcurl\b|\bwget\b|\bchmod\b|\bunlink\b|\brsync\b|\bbun\b|\bdeno\b|\bphp\b|\bawk\b|\blua\b|\bswift\b|\bosascript\b|\btar\b|\bunzip\b|\bcpio\b|\bditto\b|\bpatch\b|\bgit\b|\b(ba|z|da|k|c|tc|fi)?sh\b)/
689// Removing or moving ~/.claude itself removes the operator's rules with it.
690const CLAUDE_DIR_REMOVAL = /\b(rm|mv|rsync|unlink)\b[^;&|]*\.claude\/?(?=["'\s;&|)]|$)/
691export const RULES_WRITE_REASON =
692 'Foundry operator files (~/.claude/foundry/: rules.md, otel.env; .claude/foundry-rules.md) steer the write guard and tracing, so only the operator reads and edits them. Suggest the change and let the operator make it.'
693
694/** A file path as the filesystem sees it: absolute against `cwd`, ~ expanded, . and // collapsed, lowercased (macOS). */
695export function normalizedPath(path: string, cwd: string, home: string): string {
696 const expanded = path.trim().replace(/^~(?=\/|$)/, home)
697 const absolute = expanded.startsWith('/') ? expanded : `${cwd.replace(/\/$/, '')}/${expanded}`
698 const parts: string[] = []
699 for (const part of absolute.split('/')) {
700 if (part === '' || part === '.') continue
701 if (part === '..') parts.pop()
702 else parts.push(part)
703 }
704 return `/${parts.join('/')}`.toLowerCase()
705}
706
707export function rulesWriteReason(tool: string, input: Record<string, unknown>, cwd = '/', home = ''): string | null {
708 const paths = [input.file_path, input.notebook_path, input.path].filter((value): value is string => typeof value === 'string')
709 if (FILE_WRITERS.has(tool) && paths.some(path => PROTECTED_FILE.test(normalizedPath(path, cwd, home)))) return RULES_WRITE_REASON
710 if (FILE_READERS.has(tool)) {
711 const base = paths[0] ?? cwd
712 const targets = [base, ...(typeof input.pattern === 'string' && tool === 'Glob' ? [`${base.replace(/\/$/, '')}/${input.pattern}`] : [])]
713 const NAMES_OPERATOR_FILES = /otel|foundry-rules|\.claude/i
714 if (typeof input.glob === 'string' && NAMES_OPERATOR_FILES.test(input.glob)) return RULES_WRITE_REASON
715 if (tool === 'Glob' && typeof input.pattern === 'string' && NAMES_OPERATOR_FILES.test(input.pattern)) return RULES_WRITE_REASON
716 // A search rooted at ~/.claude reaches the directory; one rooted at ~ is left
717 // alone (it would stop every home-directory session), and otel.env holds only references.
718 if (tool !== 'Read' && home !== '' && normalizedPath(base, cwd, home) === normalizedPath('~/.claude', '/', home)) return RULES_WRITE_REASON
719 if (targets.some(target => SECRET_DIR.test(normalizedPath(target, cwd, home)))) return RULES_WRITE_REASON
720 }
721 if (tool === 'Bash' && typeof input.command === 'string') {
722 const command = input.command
723 if (SHELL_PROTECTED.test(command) || CLAUDE_DIR_REMOVAL.test(command)) return RULES_WRITE_REASON
724 if (SHELL_MENTION.test(command) && SHELL_WRITES.test(command)) return RULES_WRITE_REASON
725 if (PROTECTED_FILE.test(normalizedPath(cwd, '/', home))) return RULES_WRITE_REASON
726 }
727 return null
728}
729hooks/telemetry.ts 306 lines1// Trace export: one OTLP trace per Foundry turn, sent to any OTLP/HTTP
2// collector (Langfuse's /api/public/otel among them). Pure functions: the
3// hooks hold the state and do the fetch. Every exported value is redacted at
4// source; a value that cannot be serialized is dropped, never sent raw.
5
6export type TraceConfig = { url: string; headers: Record<string, string>; host: string; toolIo: boolean }
7
8export type TraceEnv = Partial<Record<string, string>>
9
10export const TRACE_ENV_KEYS: readonly string[] = [
11 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
12 'OTEL_EXPORTER_OTLP_ENDPOINT',
13 'OTEL_EXPORTER_OTLP_HEADERS',
14 'LANGFUSE_BASE_URL',
15 'LANGFUSE_HOST',
16 'LANGFUSE_PUBLIC_KEY',
17 'LANGFUSE_SECRET_KEY',
18 'FOUNDRY_OTEL',
19 'FOUNDRY_OTEL_TOOL_IO',
20 'PALANTIR_AGENT_OTEL_TOOL_IO',
21 'OTEL_SERVICE_NAME',
22 'OTEL_RESOURCE_ATTRIBUTES',
23 'FOUNDRY_OTEL_REDACT_DOMAINS',
24]
25
26/**
27 * An env file read as data, never run: `[export ]KEY=VALUE` lines for the
28 * TRACE_ENV_KEYS only, quotes stripped. A value that is a 1Password reference
29 * (op://vault/item/field) is returned in `refs` for the caller to resolve
30 * through `op read` by argv; anything that looks like shell ($, `) is dropped,
31 * and a credential written literally is ignored and named in `literalSecrets`.
32 */
33// Keys that carry credentials: in the file they must be op:// references, so
34// the file itself holds nothing secret even if something reads it.
35const SECRET_ENV_KEYS = new Set(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'OTEL_EXPORTER_OTLP_HEADERS'])
36// A URL with userinfo or a query string can carry a credential too.
37const URL_ENV_KEYS = new Set(['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', 'OTEL_EXPORTER_OTLP_ENDPOINT', 'LANGFUSE_BASE_URL', 'LANGFUSE_HOST'])
38
39export function parseEnvFile(text: string): { env: TraceEnv; refs: Record<string, string>; literalSecrets: string[] } {
40 const env: TraceEnv = {}
41 const refs: Record<string, string> = {}
42 const literalSecrets: string[] = []
43 for (const raw of text.split(/\r?\n/)) {
44 const match = /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$/.exec(raw)
45 if (!match || !TRACE_ENV_KEYS.includes(match[1]!)) continue
46 let value = match[2]!.trim()
47 const quoted = /^(["'])(.*)\1$/.exec(value)
48 if (quoted) value = quoted[2]!
49 else value = value.replace(/\s+#.*$/, '')
50 if (value === '' || /[`$]/.test(value)) continue
51 if (/^op:\/\/.+/.test(value)) refs[match[1]!] = value
52 else if (SECRET_ENV_KEYS.has(match[1]!)) literalSecrets.push(match[1]!)
53 else if (URL_ENV_KEYS.has(match[1]!) && /^[a-z]+:\/\/[^/?#]*@|\?/i.test(value)) literalSecrets.push(match[1]!)
54 else env[match[1]!] = value
55 }
56 return { env, refs, literalSecrets }
57}
58
59const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
60
61function utf8(text: string): number[] {
62 const out: number[] = []
63 for (const char of text) {
64 const code = char.codePointAt(0)!
65 if (code < 0x80) out.push(code)
66 else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 63))
67 else if (code < 0x10000) out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63))
68 else out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63))
69 }
70 return out
71}
72
73export function base64(text: string): string {
74 const bytes = utf8(text)
75 let out = ''
76 for (let index = 0; index < bytes.length; index += 3) {
77 const [a, b, c] = [bytes[index]!, bytes[index + 1], bytes[index + 2]]
78 out += BASE64[a >> 2]! + BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
79 out += b === undefined ? '=' : BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
80 out += c === undefined ? '=' : BASE64[c & 63]!
81 }
82 return out
83}
84
85/** `k=v,k2=v2`, values percent-decoded, as the OTel spec writes OTEL_EXPORTER_OTLP_HEADERS. */
86export function parseOtlpHeaders(raw: string | undefined): Record<string, string> {
87 const headers: Record<string, string> = {}
88 for (const pair of (raw ?? '').split(',')) {
89 const at = pair.indexOf('=')
90 if (at <= 0) continue
91 try {
92 headers[pair.slice(0, at).trim()] = decodeURIComponent(pair.slice(at + 1).trim())
93 } catch {
94 headers[pair.slice(0, at).trim()] = pair.slice(at + 1).trim()
95 }
96 }
97 return headers
98}
99
100/** Where traces go, or null when tracing is off or unconfigured. OTEL_* first, then LANGFUSE_*. */
101export function traceConfig(env: TraceEnv): TraceConfig | null {
102 if ((env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off') return null
103 const toolIo = ![env.FOUNDRY_OTEL_TOOL_IO, env.PALANTIR_AGENT_OTEL_TOOL_IO].some(value => (value ?? '').toLowerCase() === 'off')
104 const exact = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim()
105 const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
106 let url = exact || (base ? `${base.replace(/\/$/, '')}/v1/traces` : '')
107 let headers = parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS)
108 const langfuse = (env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST)?.trim()
109 if (url === '' && langfuse && env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY) {
110 url = `${langfuse.replace(/\/$/, '')}/api/public/otel/v1/traces`
111 headers = { Authorization: `Basic ${base64(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`)}` }
112 }
113 if (!/^https?:\/\//.test(url)) return null
114 const host = /^https?:\/\/([^/?#]+)/.exec(url)?.[1]?.replace(/^[^@]*@/, '') ?? ''
115 return { url, headers: { ...headers, 'Content-Type': 'application/json' }, host, toolIo }
116}
117
118/** The OTel resource: service name and OTEL_RESOURCE_ATTRIBUTES (k=v,…), plus the mod's own. */
119export function resourceAttributes(env: TraceEnv, profile: string | null): Record<string, string> {
120 return {
121 'service.name': env.OTEL_SERVICE_NAME?.trim() || 'claude-foundry',
122 ...parseOtlpHeaders(env.OTEL_RESOURCE_ATTRIBUTES),
123 'agent.harness': 'claude-code',
124 ...(profile ? { 'palantir.agent.profile': profile } : {}),
125 }
126}
127
128/** The Langfuse public API base for a trace URL that ends in /api/public/otel/v1/traces; null for other collectors. */
129export function langfuseApiBase(config: TraceConfig): string | null {
130 const match = /^(.*\/api\/public)\/otel\/v1\/traces\/?$/.exec(config.url)
131 return match ? match[1]! : null
132}
133
134// Redaction: field names, then patterns, then URLs, before the 64 KiB cap,
135// so a secret can never straddle the cut.
136const FIELD_MAX_CHARS = 64 * 1024
137const REDACTED = '[REDACTED]'
138const SUBSTRING_KEY_TERMS = [
139 'token', 'secret', 'password', 'apikey', 'api_key', 'api-key', 'authorization', 'bearer', 'cookie',
140 'credential', 'privatekey', 'private_key', 'private-key', 'ssn', 'cardnumber', 'card_number', 'card-number',
141 'rid', 'org', 'tenant', 'space', 'host', 'cluster', 'node', 'vmid', 'ctid', 'amount', 'balance', 'price',
142]
143const EXACT_KEY_TERMS = new Set(['name', 'address', 'street', 'city'])
144const KEY_REDACTION_EXEMPT = new Set(['userid', 'user_id', 'sessionid', 'session_id'])
145
146export function isSensitiveKey(key: string): boolean {
147 const lowered = key.toLowerCase()
148 if (KEY_REDACTION_EXEMPT.has(lowered)) return false
149 return EXACT_KEY_TERMS.has(lowered) || SUBSTRING_KEY_TERMS.some(term => lowered.includes(term))
150}
151
152function redactUrls(value: string): string {
153 return value.replace(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,30}:\/\/)([^\s"')\]]+)/gi, (_whole, scheme: string, rest: string) => {
154 let out = rest.replace(/^[^:@/?#]+(:[^@/?#]*)?@/, `${REDACTED}@`)
155 out = out.replace(/\?[^#\s]*/, `?${REDACTED}`)
156 out = out.replace(/(hooks\.slack\.com\/services\/)[^?\s]*/i, `$1${REDACTED}`)
157 out = out.replace(/(\/)[A-Za-z0-9_-]{20,}(?=\/|$)/g, `$1${REDACTED}`)
158 return scheme + out
159 })
160}
161
162// Token prefixes match without a leading boundary: a token glued to other text still goes.
163const REDACTION_PATTERNS: RegExp[] = [
164 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
165 /sk-(proj|ant|svcacct)-[A-Za-z0-9_-]{20,}/g,
166 /[sr]k_live_[A-Za-z0-9]{20,}\b/g,
167 /github_pat_[A-Za-z0-9_]{22,}\b/g,
168 /gh[pousr]_[A-Za-z0-9]{36,}\b/g,
169 /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g,
170 /A[KS]IA[0-9A-Z]{16}\b/g,
171 /xox[baprs]-[A-Za-z0-9-]{10,}\b/g,
172 /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
173 /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}/g,
174 /\b\d{3}-\d{2}-\d{4}\b/g,
175 /\b[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}\b/g,
176 /\b(?:10|100)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
177 /\b(?:172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b/g,
178 /(?<![A-Za-z0-9.-])[A-Za-z0-9][A-Za-z0-9.-]{0,252}\.(?:local|internal|lan|corp)\b/gi,
179 /\+?[0-9][0-9 .-]{7,}[0-9]/g,
180 /\bBasic\s+[A-Za-z0-9+/=]{8,}\b/gi,
181 /npm_[A-Za-z0-9]{30,}\b/g,
182 /\bsk-[A-Za-z0-9_-]{16,}\b/g,
183 /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{0,40}(password|passwd|pwd|token|secret|api[-_]?key|private[-_]?key|credential)[A-Za-z0-9_-]{0,40}["']?\s*[=:]\s*["']?[^\s&"',}]{4,}/gi,
184 /\b(pk|sk)-lf-[A-Za-z0-9-]{6,}/g,
185 /Authorization["']?\s*[=:]\s*["']?(Basic|Bearer)?(\s|%20)*[A-Za-z0-9._~+/=%-]{6,}/gi,
186 /Basic%20[A-Za-z0-9+/=%]{8,}/gi,
187 /(\s-u\s+|--user[=\s]+)\S+:\S+/g,
188 /\bri\.[a-z0-9-]+\.[a-z0-9-]*\.[A-Za-z0-9._-]+/g,
189]
190
191const SECRET_FLAG = /^--?(password|passwd|token|secret|api-?key|key|auth|credential|private-key)$/i
192
193/** An args array with the value after a secret-bearing flag (and --flag=value) redacted. */
194export function redactArgs(args: readonly unknown[]): unknown[] {
195 return args.map((arg, index) => {
196 if (typeof arg !== 'string') return arg
197 const joined = /^(--?[A-Za-z-]+)=/.exec(arg)
198 if (joined && SECRET_FLAG.test(joined[1]!)) return `${joined[1]}=${REDACTED}`
199 const previous = args[index - 1]
200 return typeof previous === 'string' && SECRET_FLAG.test(previous) ? REDACTED : arg
201 })
202}
203
204/** Extra hostname suffixes to redact (FOUNDRY_OTEL_REDACT_DOMAINS, comma-separated). */
205export function domainPatterns(raw: string | undefined): RegExp[] {
206 return (raw ?? '')
207 .split(',')
208 .map(domain => domain.trim().replace(/^\.+/, ''))
209 .filter(domain => /^[A-Za-z0-9.-]+$/.test(domain))
210 .map(domain => new RegExp(`(?<![A-Za-z0-9.-])[A-Za-z0-9][A-Za-z0-9.-]{0,252}\\.${domain.replace(/\./g, '\\.')}\\b`, 'gi'))
211}
212
213export function redactString(value: string, extra: readonly RegExp[] = []): string {
214 let out = redactUrls(value)
215 for (const pattern of [...REDACTION_PATTERNS, ...extra]) out = out.replace(pattern, REDACTED)
216 return out
217}
218
219const cap = (value: string) => (value.length > FIELD_MAX_CHARS ? `${value.slice(0, FIELD_MAX_CHARS)}[TRUNCATED]` : value)
220
221/** Redacted, capped JSON for one attribute; undefined when the value cannot be serialized. */
222export function serializePayload(value: unknown, extra: readonly RegExp[] = []): string | undefined {
223 if (value === undefined || value === null) return undefined
224 try {
225 const json = JSON.stringify(value, (key, item: unknown) => {
226 if (key && isSensitiveKey(key)) return REDACTED
227 if (Array.isArray(item)) return redactArgs(item)
228 if (typeof item !== 'string') return item
229 // A JSON document carried as a string (an Orthanc envelope) is redacted by key too.
230 if (/^\s*[[{]/.test(item)) {
231 let parsed: unknown
232 try {
233 parsed = JSON.parse(item)
234 } catch {}
235 if (typeof parsed === 'object' && parsed !== null) {
236 const nested = serializePayload(parsed, extra)
237 try {
238 return JSON.parse(nested ?? 'null')
239 } catch {
240 return nested
241 }
242 }
243 }
244 return redactString(item, extra)
245 })
246 return json === undefined ? undefined : cap(json)
247 } catch {
248 return undefined
249 }
250}
251
252export type SpanRecord = {
253 spanId: string
254 parentSpanId?: string
255 name: string
256 startMs: number
257 endMs: number
258 attributes: Record<string, string | number | boolean | string[]>
259 error?: string
260}
261
262export function randomHex(length: number): string {
263 let out = ''
264 while (out.length < length) out += Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')
265 return out.slice(0, length)
266}
267
268function attributeValue(value: string | number | boolean | string[]) {
269 if (Array.isArray(value)) return { arrayValue: { values: value.map(item => ({ stringValue: item })) } }
270 if (typeof value === 'number') return Number.isInteger(value) ? { intValue: value } : { doubleValue: value }
271 if (typeof value === 'boolean') return { boolValue: value }
272 return { stringValue: value }
273}
274
275const attributes = (record: Record<string, string | number | boolean | string[]>) =>
276 Object.entries(record).map(([key, value]) => ({ key, value: attributeValue(value) }))
277
278const nanos = (ms: number) => `${Math.round(ms)}000000`
279
280/** One trace as an OTLP/JSON ExportTraceServiceRequest body. */
281export function otlpBody(traceId: string, resource: Record<string, string>, spans: readonly SpanRecord[]): string {
282 return JSON.stringify({
283 resourceSpans: [
284 {
285 resource: { attributes: attributes(resource) },
286 scopeSpans: [
287 {
288 scope: { name: 'claude-foundry' },
289 spans: spans.map(span => ({
290 traceId,
291 spanId: span.spanId,
292 ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
293 name: span.name,
294 kind: 1,
295 startTimeUnixNano: nanos(span.startMs),
296 endTimeUnixNano: nanos(Math.max(span.endMs, span.startMs)),
297 attributes: attributes(span.attributes),
298 status: span.error ? { code: 2, message: span.error.slice(0, 500) } : { code: 1 },
299 })),
300 },
301 ],
302 },
303 ],
304 })
305}
306types/index.d.ts 76 lines1export type FoundryNamed = { rid: string; name: string }
2
3export type FoundryObjectType = { apiName: string; displayName: string; rid?: string }
4
5/** The scope keys that hold selected Foundry resources. */
6export type FoundryResourceKind =
7 | 'folders'
8 | 'datasets'
9 | 'mediaSets'
10 | 'mediaItems'
11 | 'repositories'
12 | 'contourAnalyses'
13 | 'sources'
14 | 'developerConsoles'
15 | 'aipChatbots'
16 | 'objectInstances'
17 | 'functions'
18 | 'languageModels'
19 | 'permissions'
20 | 'searchResults'
21
22/** One selected resource: its RID (or key), display name and a path or type hint. */
23export type FoundryResource = { rid: string; name: string; hint: string }
24
25export type FoundryScope = {
26 profile: string
27 host: string
28 ontology: FoundryNamed | null
29 projects: FoundryNamed[]
30 objectTypes: FoundryObjectType[]
31 actionTypes: FoundryObjectType[]
32 resources: Partial<Record<FoundryResourceKind, FoundryResource[]>>
33 updatedAt: string
34}
35
36export type FoundryPickItem = { key: string; label: string; hint: string }
37
38export type FoundryPickerKind = 'profile' | 'ontology' | 'project' | 'object-type' | 'action-type' | 'resource'
39
40export type FoundryPicker = {
41 kind: FoundryPickerKind
42 /** The scope key a 'resource' picker writes. */
43 resourceKind?: FoundryResourceKind
44 title: string
45 isMulti: boolean
46 items: FoundryPickItem[]
47 chosen: string[]
48 filter: string
49 /** Pickers to open after this one, in order (the /open guided flow). */
50 chain: FoundryPickerKind[]
51}
52
53/** /fde: a session bound to one agent global branch. */
54export type FdeSession = {
55 goal: string
56 profile: string
57 ontologyRid: string
58 branchRid: string
59 branchName: string
60 startedAt: string
61 /** Signatures of clean dry runs; an --apply must match one. */
62 plans: string[]
63 applied: { command: string; at: string }[]
64}
65
66declare module 'claude-code' {
67 interface PluginState {
68 foundry: {
69 scope: FoundryScope
70 picker: FoundryPicker | null
71 workingBranch: string | null
72 fde: FdeSession | null
73 }
74 }
75}
76