hilos-agent 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ // Claude Code runtime-permission adapter (0777).
2
+ //
3
+ // THE SEAM, live-verified against claude 2.1.233 (never docs-trusted, per 0521):
4
+ // `--permission-prompt-tool mcp__<server>__<tool>` names an MCP tool the CLI
5
+ // calls INSTEAD of showing its own interactive prompt. The flag is undocumented
6
+ // in `claude --help` (a bogus flag errors with "unknown option"; this one is
7
+ // accepted), so it was proven by running it:
8
+ //
9
+ // • the tool receives `{ tool_name, input, tool_use_id }` — e.g.
10
+ // `{tool_name:"Bash", input:{command:"touch created.txt", description:…}}`;
11
+ // • it must answer with ONE text content block holding JSON:
12
+ // `{"behavior":"allow","updatedInput":{…}}` or
13
+ // `{"behavior":"deny","message":"…"}`;
14
+ // • the CLI BLOCKS on that call — a deliberately slow answer held a real run
15
+ // for 150s and it still executed the tool afterwards, so a card can wait on
16
+ // a human for minutes, not seconds;
17
+ // • a deny really stops the tool (the file was never created) and the message
18
+ // is handed to the model verbatim.
19
+ //
20
+ // This means claude needs NO new transport: the existing argv path (runCli,
21
+ // resume, streaming, activity feed) is untouched, and permissions ride an MCP
22
+ // server the daemon serves on loopback for the life of the run. Also verified:
23
+ // claude accepts an `http`-type server in `--mcp-config` with an Authorization
24
+ // header, so the daemon serves the tool IN-PROCESS — no bridge subprocess, and
25
+ // the run's hilos callbacks are directly in scope.
26
+ //
27
+ // `--mcp-config` ADDS to the user's own servers (only `--strict-mcp-config`
28
+ // would replace them), so gating a run never costs the operator their own MCP
29
+ // setup.
30
+
31
+ import http from "node:http";
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { randomBytes } from "node:crypto";
36
+ import {
37
+ DEFAULT_PERMISSION_POLL_MS,
38
+ DEFAULT_PERMISSION_TIMEOUT_MS,
39
+ resolveHilosPermissionReply,
40
+ } from "./permission-gate.mjs";
41
+
42
+ /** The MCP server key and tool name the daemon serves. Together they form the
43
+ * `mcp__<server>__<tool>` identifier claude's flag takes. */
44
+ export const CLAUDE_PERMISSION_SERVER = "hilos_permissions";
45
+ export const CLAUDE_PERMISSION_TOOL = "ask";
46
+ export const CLAUDE_PERMISSION_TOOL_ID = `mcp__${CLAUDE_PERMISSION_SERVER}__${CLAUDE_PERMISSION_TOOL}`;
47
+
48
+ const PROTOCOL_VERSION = "2025-06-18";
49
+
50
+ /** How long an ask waits for claude's real session id before standing in the
51
+ * run's own. Claude's init frame lands in well under a second in practice. */
52
+ const DEFAULT_SESSION_ID_WAIT_MS = 10_000;
53
+
54
+ /** Tool names whose input names a file rather than a command. */
55
+ const FILE_INPUT_KEYS = ["file_path", "path", "notebook_path"];
56
+
57
+ function isObject(value) {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ /**
62
+ * Shape claude's permission-prompt payload into the SAME vendor-neutral request
63
+ * the opencode/ACP card flow already produces, so the card, its audit row, and
64
+ * the decision vocabulary are identical across vendors.
65
+ */
66
+ export function normalizeClaudePermissionRequest(args, { sessionId = "", fallbackId = "0" } = {}) {
67
+ const toolName = typeof args?.tool_name === "string" ? args.tool_name : "tool";
68
+ const input = isObject(args?.input) ? args.input : {};
69
+ const vendorRequestId =
70
+ typeof args?.tool_use_id === "string" && args.tool_use_id
71
+ ? args.tool_use_id
72
+ : `claude_${fallbackId}`;
73
+ const command = typeof input.command === "string" ? input.command : null;
74
+ const filePath = FILE_INPUT_KEYS.map((key) => input[key]).find(
75
+ (value) => typeof value === "string" && value,
76
+ );
77
+ const resources = command ? [command] : filePath ? [filePath] : [];
78
+ // The card's one-line title: the command or file is what a human is actually
79
+ // deciding about, so it leads; the tool name is the fallback.
80
+ const title = command || filePath || toolName;
81
+ return {
82
+ vendor: "claude_code",
83
+ vendorRequestId,
84
+ sessionId: String(sessionId || ""),
85
+ action: toolName,
86
+ resources,
87
+ suggestedSave: undefined,
88
+ metadata: {
89
+ title,
90
+ ...(command ? { command } : {}),
91
+ ...(filePath ? { path: filePath } : {}),
92
+ transport: "mcp-permission-prompt",
93
+ },
94
+ source: { type: "tool", name: toolName },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Turn a hilos reply into claude's permission-prompt result.
100
+ *
101
+ * "once" and "always" both allow THIS call — the durable card already records
102
+ * which one a human chose, and claude's own allowlist is deliberately not
103
+ * widened from here: persisting an always-allow into the CLI's config would put
104
+ * an approval outside the audited substrate.
105
+ */
106
+ /**
107
+ * @param {string} reply
108
+ * @param {unknown} input
109
+ * @param {{ outcome?: string, byPolicy?: boolean, policyReason?: string | null }} [opts]
110
+ */
111
+ export function claudePermissionResult(
112
+ reply,
113
+ input,
114
+ { outcome = "decided", byPolicy = false, policyReason = null } = {},
115
+ ) {
116
+ if (reply === "once" || reply === "always") {
117
+ return { behavior: "allow", updatedInput: isObject(input) ? input : {} };
118
+ }
119
+ // 0813 — a workspace RULE refused this, not a person. Saying "a person
120
+ // declined" would be false, and it would also waste the one thing a standing
121
+ // rule can give the model that a one-off rejection cannot: the reason, which
122
+ // is the difference between a wall and an instruction. Only claude's seam
123
+ // carries a message at all; ACP and codex get their own bare rejection, so
124
+ // nothing here may imply the model was told when it was not.
125
+ //
126
+ // Provenance and reason are separate inputs because a rule need not carry a
127
+ // reason: deriving "policy" from "a reason exists" would send a reasonless
128
+ // rule's refusal back as a person's, which is the precise lie this avoids.
129
+ const reason = typeof policyReason === "string" ? policyReason.trim() : "";
130
+ const message =
131
+ outcome === "timeout"
132
+ ? "hilos: nobody answered this permission request in time, so it was declined. Don't retry it — say what you needed it for and stop."
133
+ : outcome === "aborted"
134
+ ? "hilos: the run was cancelled, so this was declined. Stop here."
135
+ : byPolicy
136
+ ? `hilos: your workspace's permission rules do not allow this.${reason ? ` ${reason}` : ""} Don't retry it — work within that rule, or say what you need.`
137
+ : "hilos: a person in the room declined this. Don't retry it — continue with what you can do without it, or explain what you need.";
138
+ return { behavior: "deny", message };
139
+ }
140
+
141
+ /**
142
+ * Does this run get the claude permission gate?
143
+ *
144
+ * The same rule the other vendors use: the workspace must have granted runtime
145
+ * permissions, and a run explicitly configured to never ask (the `skip` tier's
146
+ * bypass flags) has nothing to gate — hilos must be the one answering asks, so
147
+ * a run that answers its own is left exactly as the operator configured it.
148
+ *
149
+ * @param {{ vendor?: string, runtimePermissions?: boolean, codeArgs?: string[] }} o
150
+ */
151
+ export function shouldGateClaudePermissions({ vendor, runtimePermissions, codeArgs = [] }) {
152
+ if (vendor !== "claude_code" || runtimePermissions !== true) return false;
153
+ const args = Array.isArray(codeArgs) ? codeArgs : [];
154
+ if (args.includes("--dangerously-skip-permissions")) return false;
155
+ // `--permission-mode bypassPermissions` is the same escape hatch by another
156
+ // spelling, and an operator who already named their own prompt tool owns it.
157
+ const modeIdx = args.indexOf("--permission-mode");
158
+ if (modeIdx >= 0 && args[modeIdx + 1] === "bypassPermissions") return false;
159
+ if (args.includes("--permission-prompt-tool")) return false;
160
+ return true;
161
+ }
162
+
163
+ /** The argv the gate appends to a claude code run. */
164
+ export function claudePermissionArgs({ configPath, toolId = CLAUDE_PERMISSION_TOOL_ID }) {
165
+ return ["--mcp-config", configPath, "--permission-prompt-tool", toolId];
166
+ }
167
+
168
+ /** The flags to strip when a CLI turns out not to know them (compat retry). */
169
+ export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-tool"];
170
+
171
+ /**
172
+ * Serve the permission tool on loopback for the life of one run.
173
+ *
174
+ * Bound to 127.0.0.1 on an ephemeral port with a random bearer token, and the
175
+ * generated config file is written 0600 and deleted on close — the token never
176
+ * appears in argv (where any local `ps` would read it).
177
+ *
178
+ * @param {{
179
+ * requestPermission: (request: object, context: object) => Promise<unknown>,
180
+ * getPermissionDecision: (handle: unknown, context: object) => Promise<unknown>,
181
+ * sessionId?: string,
182
+ * resolveSessionId?: () => string | null | undefined,
183
+ * sessionIdWaitMs?: number,
184
+ * timeoutMs?: number,
185
+ * pollIntervalMs?: number,
186
+ * signal?: AbortSignal,
187
+ * log?: { error?: (message: string) => void },
188
+ * dir?: string,
189
+ * }} options
190
+ * @returns {Promise<{ configPath: string, toolId: string, url: string, port: number,
191
+ * args: string[], setSessionId: (value: string) => void, pending: () => number,
192
+ * close: () => Promise<void> }>}
193
+ */
194
+ export async function startClaudePermissionServer({
195
+ requestPermission,
196
+ getPermissionDecision,
197
+ sessionId = "",
198
+ resolveSessionId,
199
+ sessionIdWaitMs = DEFAULT_SESSION_ID_WAIT_MS,
200
+ timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS,
201
+ pollIntervalMs = DEFAULT_PERMISSION_POLL_MS,
202
+ signal,
203
+ log,
204
+ dir = os.tmpdir(),
205
+ }) {
206
+ if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
207
+ throw new Error("claude permission server requires requestPermission and getPermissionDecision");
208
+ }
209
+ const token = randomBytes(24).toString("hex");
210
+ let seq = 0;
211
+ let inflight = 0;
212
+ let currentSessionId = String(sessionId || "");
213
+ // hilos's server REJECTS a permission request with an empty vendorSessionId,
214
+ // so a fresh run (no session to resume) must not ask with one — every card
215
+ // would fail before it existed. This is the run's own correlation id, used
216
+ // only if claude never tells us its real one.
217
+ const fallbackSessionId = `hilos-run-${randomBytes(8).toString("hex")}`;
218
+
219
+ /**
220
+ * The session id to stamp on an ask.
221
+ *
222
+ * On a fresh run claude only reveals its session id in the init frame of its
223
+ * stream, which normally lands well before the first tool call — but "well
224
+ * before" is luck, not a guarantee, so an ask that arrives first WAITS a
225
+ * bounded moment for the real id rather than racing it. If it never comes
226
+ * (a server without post_progress runs the CLI unstreamed, so there is no
227
+ * init frame at all), the run's own id stands in: a card a human can decide
228
+ * beats an ask that dies as an invalid request.
229
+ */
230
+ async function sessionIdForAsk() {
231
+ const pull = () => {
232
+ if (currentSessionId) return currentSessionId;
233
+ try {
234
+ const value = resolveSessionId?.();
235
+ if (typeof value === "string" && value) {
236
+ currentSessionId = value;
237
+ return value;
238
+ }
239
+ } catch {
240
+ /* a session-id lookup must never break the gate */
241
+ }
242
+ return "";
243
+ };
244
+ const found = pull();
245
+ if (found) return found;
246
+ // Nothing could arrive later (an unstreamed run supplies no resolver at
247
+ // all), so waiting would only delay a card nobody is going to improve.
248
+ if (typeof resolveSessionId !== "function") return fallbackSessionId;
249
+ const deadline = Date.now() + Math.max(0, sessionIdWaitMs);
250
+ while (Date.now() < deadline) {
251
+ if (signal?.aborted) break;
252
+ await new Promise((resolve) => setTimeout(resolve, 100));
253
+ const later = pull();
254
+ if (later) return later;
255
+ }
256
+ log?.error?.(
257
+ `claude permission: no CLI session id yet; asking as ${fallbackSessionId} so the card still reaches the room`,
258
+ );
259
+ return fallbackSessionId;
260
+ }
261
+
262
+ const server = http.createServer((req, res) => {
263
+ if (req.headers.authorization !== `Bearer ${token}`) {
264
+ res.writeHead(401).end();
265
+ return;
266
+ }
267
+ if (req.method !== "POST") {
268
+ // No SSE lane: claude probes GET once, accepts the refusal, and uses POST.
269
+ res.writeHead(405).end();
270
+ return;
271
+ }
272
+ let body = "";
273
+ let tooBig = false;
274
+ req.on("data", (chunk) => {
275
+ body += chunk;
276
+ if (body.length > 1_000_000) {
277
+ tooBig = true;
278
+ req.destroy();
279
+ }
280
+ });
281
+ req.on("end", () => {
282
+ if (tooBig) return;
283
+ void handle(body, res);
284
+ });
285
+ });
286
+
287
+ async function handle(body, res) {
288
+ let msg;
289
+ try {
290
+ msg = JSON.parse(body);
291
+ } catch {
292
+ res.writeHead(400).end();
293
+ return;
294
+ }
295
+ const reply = (result) => {
296
+ if (res.writableEnded) return;
297
+ res.writeHead(200, { "content-type": "application/json" });
298
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }));
299
+ };
300
+ // Notifications carry no id and expect no body.
301
+ if (msg?.id === undefined) {
302
+ res.writeHead(202).end();
303
+ return;
304
+ }
305
+ if (msg.method === "initialize") {
306
+ const requested =
307
+ typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : null;
308
+ reply({
309
+ protocolVersion: requested || PROTOCOL_VERSION,
310
+ capabilities: { tools: {} },
311
+ serverInfo: { name: "hilos-permissions", version: "1.0.0" },
312
+ });
313
+ return;
314
+ }
315
+ if (msg.method === "tools/list") {
316
+ reply({
317
+ tools: [
318
+ {
319
+ name: CLAUDE_PERMISSION_TOOL,
320
+ description:
321
+ "Ask the people in this hilos room to approve a tool call. Blocks until someone decides.",
322
+ inputSchema: {
323
+ type: "object",
324
+ properties: {
325
+ tool_name: { type: "string" },
326
+ input: { type: "object" },
327
+ tool_use_id: { type: "string" },
328
+ },
329
+ required: ["tool_name", "input"],
330
+ },
331
+ },
332
+ ],
333
+ });
334
+ return;
335
+ }
336
+ if (msg.method === "tools/call") {
337
+ if (msg.params?.name !== CLAUDE_PERMISSION_TOOL) {
338
+ reply({
339
+ content: [{ type: "text", text: JSON.stringify({ behavior: "deny", message: "hilos: unknown tool" }) }],
340
+ isError: true,
341
+ });
342
+ return;
343
+ }
344
+ const args = isObject(msg.params?.arguments) ? msg.params.arguments : {};
345
+ const request = normalizeClaudePermissionRequest(args, {
346
+ sessionId: await sessionIdForAsk(),
347
+ fallbackId: String(++seq),
348
+ });
349
+ inflight++;
350
+ let outcome = "transport-error";
351
+ // Set only when a workspace RULE refused this ask (0813); a person's
352
+ // rejection leaves these alone and keeps the human wording. Provenance is
353
+ // tracked apart from the reason because a rule may carry no reason.
354
+ let byPolicy = false;
355
+ let policyReason = null;
356
+ let decisionReply = "reject";
357
+ try {
358
+ const resolved = await resolveHilosPermissionReply({
359
+ request,
360
+ requestPermission,
361
+ getPermissionDecision,
362
+ signal,
363
+ timeoutMs,
364
+ pollIntervalMs,
365
+ log,
366
+ label: "claude permission",
367
+ });
368
+ decisionReply = resolved.reply;
369
+ outcome = resolved.outcome;
370
+ byPolicy = resolved.byPolicy === true;
371
+ policyReason = resolved.policyReason ?? null;
372
+ } finally {
373
+ inflight--;
374
+ }
375
+ // A rejection is never an MCP error: an isError result makes claude retry
376
+ // or improvise around the block. A plain deny result is the refusal the
377
+ // CLI is built to respect.
378
+ reply({
379
+ content: [
380
+ { type: "text", text: JSON.stringify(
381
+ claudePermissionResult(decisionReply, args.input, {
382
+ outcome,
383
+ byPolicy,
384
+ policyReason,
385
+ }),
386
+ ) },
387
+ ],
388
+ });
389
+ return;
390
+ }
391
+ reply({});
392
+ }
393
+
394
+ await new Promise((resolve, reject) => {
395
+ server.once("error", reject);
396
+ server.listen(0, "127.0.0.1", () => {
397
+ server.removeListener("error", reject);
398
+ resolve(undefined);
399
+ });
400
+ });
401
+ const { port } = /** @type {{ port: number }} */ (server.address());
402
+ const url = `http://127.0.0.1:${port}/mcp`;
403
+ const configPath = path.join(
404
+ dir,
405
+ `hilos-permission-${process.pid}-${randomBytes(6).toString("hex")}.json`,
406
+ );
407
+ fs.writeFileSync(
408
+ configPath,
409
+ JSON.stringify({
410
+ mcpServers: {
411
+ [CLAUDE_PERMISSION_SERVER]: { type: "http", url, headers: { Authorization: `Bearer ${token}` } },
412
+ },
413
+ }),
414
+ { mode: 0o600 },
415
+ );
416
+
417
+ return {
418
+ configPath,
419
+ toolId: CLAUDE_PERMISSION_TOOL_ID,
420
+ url,
421
+ port,
422
+ args: claudePermissionArgs({ configPath }),
423
+ /** Let the caller stamp the CLI's real session id onto later cards. */
424
+ setSessionId(value) {
425
+ if (typeof value === "string" && value) currentSessionId = value;
426
+ },
427
+ pending: () => inflight,
428
+ async close() {
429
+ try {
430
+ fs.unlinkSync(configPath);
431
+ } catch {
432
+ /* a leftover temp config must never fail a run */
433
+ }
434
+ // Drop keep-alive sockets first: server.close() waits for open
435
+ // connections, and a CLI that exited without closing its MCP socket
436
+ // would otherwise hang the run's teardown forever.
437
+ try {
438
+ server.closeAllConnections?.();
439
+ } catch {
440
+ /* older runtimes simply do not have it */
441
+ }
442
+ await new Promise((resolve) => server.close(() => resolve(undefined)));
443
+ },
444
+ };
445
+ }
package/src/cli.mjs CHANGED
@@ -175,6 +175,10 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
175
175
  * hilos-owned var (HILOS_*) is stripped from it regardless, and `PWD` is pinned
176
176
  * to `cwd` when one is set (0615). Omit to inherit the daemon's environment
177
177
  * minus HILOS_* (the safe default).
178
+ * @property {() => (void | Promise<void>)} [onPermissionGateDropped] - awaited
179
+ * BEFORE the ungated compat retry is spawned when a CLI rejects the 0777
180
+ * permission flags (0785), so the caller can warn its room while the run can
181
+ * still be stopped. A throw here never fails the run.
178
182
  */
179
183
 
180
184
  /**
@@ -328,6 +332,37 @@ function runCliOnce(opts) {
328
332
  // inside runCli (not a wrapper) so every call site, present and future, gets it.
329
333
  const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
330
334
 
335
+ // Compat retry (0777): the permission gate adds `--permission-prompt-tool` (and
336
+ // the `--mcp-config` that serves it) to claude runs. The flag is real but
337
+ // undocumented on 2.1.233, so a Claude Code old enough not to know it would
338
+ // reject the whole command. Rather than fail a run over a gate the CLI cannot
339
+ // honor, strip BOTH flags (and their values) and retry once — the run then
340
+ // behaves exactly as it did before 0777. It is logged as a lost gate, not
341
+ // silently swallowed: the caller sees the first attempt's stderr in the run.
342
+ //
343
+ // 0785: a daemon console line is not the room. Before the ungated retry is
344
+ // SPAWNED, `onPermissionGateDropped` gives the caller — which owns the channel
345
+ // and the thread — its chance to tell the people watching, so the warning
346
+ // arrives while the run can still be stopped instead of after every ungated
347
+ // command has already executed. The result is also stamped
348
+ // `permissionGateDropped:true` as a backstop for callers with no hook.
349
+ const UNKNOWN_PERMISSION_FLAG_RE =
350
+ /unknown option '(--permission-prompt-tool|--mcp-config)'/;
351
+ const PERMISSION_GATE_FLAGS = new Set(["--permission-prompt-tool", "--mcp-config"]);
352
+
353
+ /** Drop each flag AND the value that follows it. */
354
+ function stripFlagPairs(args, flags) {
355
+ const out = [];
356
+ for (let i = 0; i < args.length; i++) {
357
+ if (flags.has(args[i])) {
358
+ i++; // skip its value
359
+ continue;
360
+ }
361
+ out.push(args[i]);
362
+ }
363
+ return out;
364
+ }
365
+
331
366
  /** @param {RunCliOptions} opts */
332
367
  export async function runCli(opts) {
333
368
  const first = await runCliOnce(opts);
@@ -340,5 +375,26 @@ export async function runCli(opts) {
340
375
  ) {
341
376
  return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
342
377
  }
378
+ if (
379
+ first.status !== 0 &&
380
+ !first.aborted &&
381
+ args.includes("--permission-prompt-tool") &&
382
+ UNKNOWN_PERMISSION_FLAG_RE.test(first.stderr || "")
383
+ ) {
384
+ console.log(
385
+ " code → this CLI doesn't support --permission-prompt-tool; retrying WITHOUT the hilos permission gate",
386
+ );
387
+ // Warn FIRST, then run. The hook is bounded by its owner; a failure here is
388
+ // not a reason to fail a run that is about to happen anyway.
389
+ if (typeof opts?.onPermissionGateDropped === "function") {
390
+ try {
391
+ await opts.onPermissionGateDropped();
392
+ } catch {
393
+ /* telling the room must never break the run */
394
+ }
395
+ }
396
+ const retried = await runCliOnce({ ...opts, args: stripFlagPairs(args, PERMISSION_GATE_FLAGS) });
397
+ return { ...retried, permissionGateDropped: true };
398
+ }
343
399
  return first;
344
400
  }