@prohost/cli 0.8.1 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.8.2
8
+
9
+ A ProhostAI back-office agent is told its cross-account reads are allowed.
10
+
11
+ - **Back-office read tools change the credential sentence.** Before each run
12
+ whose ProhostAI tools are wired, the harness asks the MCP server's own
13
+ `tools/list` (cached 10 minutes; a failure reads as "absent" and is retried
14
+ after a minute). When it lists both `backoffice_describe_readonly_schema` and
15
+ `backoffice_query_readonly` — shown only to an agent-bound key on ProhostAI's
16
+ internal account holding `backoffice:read` — and a read-only call to the
17
+ schema tool goes through (its rollout flag is enforced only at call time),
18
+ the prompt replaces "your
19
+ credential is deliberately narrow" with: read-only cross-account
20
+ investigation is pre-authorized, look before answering (schema, then a query
21
+ filtered to the customer's account), never ask a human before a read-only
22
+ query, writes and customer-facing sends still follow approval discipline,
23
+ and summarize rather than paste rows. Ungated runs are also told that
24
+ read-only tools on the machine (operator-added MCP servers, CLIs) serve the
25
+ same purpose. Every other agent's prompt is byte-for-byte unchanged.
26
+
7
27
  ## 0.8.1
8
28
 
9
29
  A long message no longer reaches a paired agent as a fragment it can't recover.
package/README.md CHANGED
@@ -172,6 +172,12 @@ reservations or listings — those tools return a permission error. The prompt
172
172
  tells the agent to say what it couldn't reach rather than answer as if it had,
173
173
  and refused tools are printed in the run log.
174
174
 
175
+ The one exception is a ProhostAI back-office agent. Before a run, the CLI asks
176
+ the MCP server's `tools/list` (cached for ten minutes). If the list includes the
177
+ back-office read tools, which only an internal key with `backoffice:read` sees,
178
+ and a read-only call to the schema tool is allowed, the prompt tells the agent that read-only cross-account investigation is
179
+ pre-authorized. Writes and customer-facing sends still go through approval.
180
+
175
181
  **It reads the reply out of JSON.** The run is invoked with
176
182
  `--output-format json`, and the `result` field is what gets posted. If that
177
183
  output isn't JSON — a login prompt, a crash banner — the run is reported failed
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What the paired credential can reach, as far as the prompt needs to know.
3
+ *
4
+ * Today that is one question: does this agent hold ProhostAI's back-office
5
+ * read tools (`backoffice_describe_readonly_schema` + `backoffice_query_readonly`)?
6
+ * The answer changes what the prompt says — an agent told its credential is
7
+ * "deliberately narrow" refuses or hedges on exactly the cross-account
8
+ * investigations those tools exist for.
9
+ *
10
+ * Nothing the CLI stores can answer it. The pairing response carries no scopes,
11
+ * and the gate is not a scope alone: the server shows the tools only to an
12
+ * agent-bound key on ProhostAI's internal account, holding the literal
13
+ * `backoffice:read` scope, with a flag on — any of which can change after
14
+ * pairing. So the server is asked: `tools/list` first, and — because its
15
+ * visibility check leaves out the rollout flag, which is enforced only when a
16
+ * tool is called — then the read-only, side-effect-free
17
+ * `backoffice_describe_readonly_schema`, which runs the whole gate. Two small
18
+ * requests, cached, and every failure reads as "absent": the prompt then says
19
+ * what it has always said.
20
+ */
21
+ /** Both have to be listed — the schema call is what makes the query usable. */
22
+ export declare const BACKOFFICE_READ_TOOLS: readonly ["backoffice_describe_readonly_schema", "backoffice_query_readonly"];
23
+ export interface RunCapabilities {
24
+ /** The back-office read tools are in this credential's `tools/list`. */
25
+ backofficeRead: boolean;
26
+ }
27
+ export declare const NO_CAPABILITIES: RunCapabilities;
28
+ /** Short: a run waits on this before its agent starts. */
29
+ export declare const CAPABILITY_PROBE_TIMEOUT_MS = 5000;
30
+ /** How long an answer is reused. A grant or revoke shows up within this. */
31
+ export declare const CAPABILITY_CACHE_MS: number;
32
+ /** How long a failed probe is reused, so an outage costs one timeout a minute, not one per run. */
33
+ export declare const CAPABILITY_FAILURE_CACHE_MS = 60000;
34
+ export interface ToolListOptions {
35
+ mcpUrl: string;
36
+ apiKey: string;
37
+ fetchImpl?: typeof fetch;
38
+ timeoutMs?: number;
39
+ }
40
+ /** The tool names the server lists for this key, or `null` when it could not be asked. */
41
+ export declare function listMcpToolNames(options: ToolListOptions): Promise<string[] | null>;
42
+ /**
43
+ * Whether a back-office read actually goes through for this key: `true` when
44
+ * the schema call answers with its schema, `false` when the server refuses it
45
+ * (`backoffice_forbidden` — e.g. the rollout flag is off), `null` when it could
46
+ * not be asked.
47
+ */
48
+ export declare function backofficeReadAllowed(options: ToolListOptions): Promise<boolean | null>;
49
+ export declare function capabilitiesFromToolNames(names: readonly string[]): RunCapabilities;
50
+ /**
51
+ * A cached `tools/list` probe for one daemon's credential.
52
+ *
53
+ * A daemon is long-lived, so the answer is re-asked every
54
+ * {@link CAPABILITY_CACHE_MS} rather than fixed at start-up: a scope granted or
55
+ * revoked on the server reaches the prompt without a restart.
56
+ */
57
+ export declare function capabilityProbe(options: ToolListOptions & {
58
+ now?: () => number;
59
+ }): () => Promise<RunCapabilities>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * What the paired credential can reach, as far as the prompt needs to know.
3
+ *
4
+ * Today that is one question: does this agent hold ProhostAI's back-office
5
+ * read tools (`backoffice_describe_readonly_schema` + `backoffice_query_readonly`)?
6
+ * The answer changes what the prompt says — an agent told its credential is
7
+ * "deliberately narrow" refuses or hedges on exactly the cross-account
8
+ * investigations those tools exist for.
9
+ *
10
+ * Nothing the CLI stores can answer it. The pairing response carries no scopes,
11
+ * and the gate is not a scope alone: the server shows the tools only to an
12
+ * agent-bound key on ProhostAI's internal account, holding the literal
13
+ * `backoffice:read` scope, with a flag on — any of which can change after
14
+ * pairing. So the server is asked: `tools/list` first, and — because its
15
+ * visibility check leaves out the rollout flag, which is enforced only when a
16
+ * tool is called — then the read-only, side-effect-free
17
+ * `backoffice_describe_readonly_schema`, which runs the whole gate. Two small
18
+ * requests, cached, and every failure reads as "absent": the prompt then says
19
+ * what it has always said.
20
+ */
21
+ import { USER_AGENT } from '../version.js';
22
+ /** Both have to be listed — the schema call is what makes the query usable. */
23
+ export const BACKOFFICE_READ_TOOLS = ['backoffice_describe_readonly_schema', 'backoffice_query_readonly'];
24
+ export const NO_CAPABILITIES = { backofficeRead: false };
25
+ /** Short: a run waits on this before its agent starts. */
26
+ export const CAPABILITY_PROBE_TIMEOUT_MS = 5_000;
27
+ /** How long an answer is reused. A grant or revoke shows up within this. */
28
+ export const CAPABILITY_CACHE_MS = 10 * 60_000;
29
+ /** How long a failed probe is reused, so an outage costs one timeout a minute, not one per run. */
30
+ export const CAPABILITY_FAILURE_CACHE_MS = 60_000;
31
+ /** Cap on the body read — a full catalog is well under this. */
32
+ const MAX_BODY_CHARS = 2_000_000;
33
+ /**
34
+ * Pull the JSON-RPC result out of a response that may be plain JSON or a
35
+ * streamable-HTTP SSE stream (`data: {...}` lines). `undefined` when neither
36
+ * shape holds a result.
37
+ */
38
+ function jsonRpcResult(body) {
39
+ const candidates = body.trimStart().startsWith('{')
40
+ ? [body]
41
+ : body
42
+ .split(/\r?\n/)
43
+ .filter((line) => line.startsWith('data:'))
44
+ .map((line) => line.slice('data:'.length).trim());
45
+ for (const candidate of candidates) {
46
+ try {
47
+ const parsed = JSON.parse(candidate);
48
+ if (parsed && typeof parsed.result === 'object' && parsed.result !== null) {
49
+ return parsed.result;
50
+ }
51
+ }
52
+ catch {
53
+ // Not JSON — keep looking.
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * One JSON-RPC request to the ProhostAI MCP server; its `result`, or `null`
60
+ * when it could not be asked (network, timeout, non-2xx, unparseable, error).
61
+ *
62
+ * Authenticates exactly as the brain's own MCP config does (`X-API-Key`), so
63
+ * the answer is what the agent itself will see. The server runs stateless
64
+ * HTTP, so no `initialize` round-trip is needed first.
65
+ */
66
+ async function mcpRequest(options, method, params) {
67
+ const fetchImpl = options.fetchImpl ?? fetch;
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? CAPABILITY_PROBE_TIMEOUT_MS);
70
+ try {
71
+ const response = await fetchImpl(options.mcpUrl, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ Accept: 'application/json, text/event-stream',
76
+ 'X-API-Key': options.apiKey,
77
+ 'User-Agent': USER_AGENT,
78
+ },
79
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
80
+ signal: controller.signal,
81
+ });
82
+ if (response.status < 200 || response.status >= 300)
83
+ return null;
84
+ return jsonRpcResult((await response.text()).slice(0, MAX_BODY_CHARS)) ?? null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ /** The tool names the server lists for this key, or `null` when it could not be asked. */
94
+ export async function listMcpToolNames(options) {
95
+ const tools = (await mcpRequest(options, 'tools/list', {}))?.tools;
96
+ if (!Array.isArray(tools))
97
+ return null;
98
+ return tools
99
+ .map((tool) => (tool && typeof tool === 'object' ? tool.name : undefined))
100
+ .filter((name) => typeof name === 'string');
101
+ }
102
+ /**
103
+ * Whether a back-office read actually goes through for this key: `true` when
104
+ * the schema call answers with its schema, `false` when the server refuses it
105
+ * (`backoffice_forbidden` — e.g. the rollout flag is off), `null` when it could
106
+ * not be asked.
107
+ */
108
+ export async function backofficeReadAllowed(options) {
109
+ const result = await mcpRequest(options, 'tools/call', {
110
+ name: 'backoffice_describe_readonly_schema',
111
+ arguments: {},
112
+ });
113
+ if (!result)
114
+ return null;
115
+ if (result.isError === true)
116
+ return false;
117
+ const content = Array.isArray(result.content) ? result.content : [];
118
+ const text = content
119
+ .map((part) => (part && typeof part === 'object' ? part.text : undefined))
120
+ .filter((part) => typeof part === 'string')
121
+ .join('');
122
+ try {
123
+ const parsed = JSON.parse(text);
124
+ return parsed.error === undefined && parsed.tables !== undefined;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ export function capabilitiesFromToolNames(names) {
131
+ return { backofficeRead: BACKOFFICE_READ_TOOLS.every((tool) => names.includes(tool)) };
132
+ }
133
+ /**
134
+ * A cached `tools/list` probe for one daemon's credential.
135
+ *
136
+ * A daemon is long-lived, so the answer is re-asked every
137
+ * {@link CAPABILITY_CACHE_MS} rather than fixed at start-up: a scope granted or
138
+ * revoked on the server reaches the prompt without a restart.
139
+ */
140
+ export function capabilityProbe(options) {
141
+ const now = options.now ?? Date.now;
142
+ let cached;
143
+ return async () => {
144
+ if (cached && now() < cached.until)
145
+ return cached.value;
146
+ const names = await listMcpToolNames(options);
147
+ let value = names ? capabilitiesFromToolNames(names) : NO_CAPABILITIES;
148
+ let answered = names !== null;
149
+ if (value.backofficeRead) {
150
+ // Listed is not the same as allowed: confirm the gate end to end.
151
+ const allowed = await backofficeReadAllowed(options);
152
+ answered = allowed !== null;
153
+ value = { backofficeRead: allowed === true };
154
+ }
155
+ cached = { value, until: now() + (answered ? CAPABILITY_CACHE_MS : CAPABILITY_FAILURE_CACHE_MS) };
156
+ return value;
157
+ };
158
+ }
@@ -30,6 +30,17 @@ export interface PromptContext {
30
30
  * to guess at it.
31
31
  */
32
32
  mcpToolPattern?: string;
33
+ /**
34
+ * The ProhostAI tools include the back-office read pair
35
+ * (`backoffice_describe_readonly_schema` → `backoffice_query_readonly`).
36
+ *
37
+ * Taken from the server's own `tools/list` for this key (see
38
+ * `capabilities.ts`), never assumed. It changes what the prompt says about
39
+ * the credential: an agent told its access is "deliberately narrow" refused
40
+ * or asked permission for exactly the cross-account reads these tools
41
+ * pre-authorize. Unset or false, the prompt is byte-for-byte what it was.
42
+ */
43
+ backofficeRead?: boolean;
33
44
  /**
34
45
  * Tools are behind permission prompts this run (`--safe-tools`).
35
46
  *
@@ -328,10 +328,13 @@ export function buildPrompt(run, context) {
328
328
  context.mcpToolPattern
329
329
  ? ` (${context.mcpToolPattern})`
330
330
  : `, from the MCP server registered as "${context.mcpServerName}"`}. They are the sanctioned ` +
331
- 'way to look up workspace data, so use them rather than guessing. Your credential is ' +
332
- 'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
333
- 'a permission error, that is a real limit rather than a glitch — say what you could not ' +
334
- 'look up instead of retrying it or answering as if you had.'
331
+ 'way to look up workspace data, so use them rather than guessing. ' +
332
+ (context.backofficeRead
333
+ ? backofficeGuidance(Boolean(context.toolsGated))
334
+ : 'Your credential is ' +
335
+ 'deliberately narrow and does not reach every part of the workspace: if a tool returns ' +
336
+ 'a permission error, that is a real limit rather than a glitch — say what you could not ' +
337
+ 'look up instead of retrying it or answering as if you had.')
335
338
  : '- You have no ProhostAI tools in this run. Everything you know about this ' +
336
339
  'workspace is what is written above.', '');
337
340
  // Postability is a separate question from what the run is about: a task run
@@ -351,6 +354,29 @@ export function buildPrompt(run, context) {
351
354
  : 'stdout is recorded but not posted anywhere — act through your own tools.');
352
355
  return `${lines.join('\n')}\n`;
353
356
  }
357
+ /**
358
+ * What replaces the "deliberately narrow" sentence for a back-office agent.
359
+ *
360
+ * Field-observed 2026-10-01: an internal support agent holding these tools
361
+ * answered "I couldn't read the workflow" and asked a human before a read-only
362
+ * query, because the only thing the prompt said about its access was that it
363
+ * was narrow. The read is pre-authorized, so the prompt says so — and keeps
364
+ * writes, customer-facing sends and personal data exactly as guarded as before.
365
+ */
366
+ function backofficeGuidance(toolsGated) {
367
+ return ('You are a ProhostAI back-office agent: read-only cross-account investigation is ' +
368
+ 'pre-authorized and expected. When a support or engineering question is about a ' +
369
+ 'specific customer account, look before you answer — backoffice_describe_readonly_schema ' +
370
+ 'for the tables, then backoffice_query_readonly filtered to that customer\'s account. ' +
371
+ 'Never ask a human for permission to run a read-only query. ' +
372
+ (toolsGated
373
+ ? ''
374
+ : 'Read-only investigation tools on this machine — MCP servers your operator added, ' +
375
+ 'CLIs such as `gh` — are usable for the same purpose. ') +
376
+ 'Writes and anything sent to a customer still follow approval discipline. Keep personal ' +
377
+ 'data and secrets out of your reply: summarize what you found, do not paste rows. If a ' +
378
+ 'tool returns a permission error, that is a real limit — say what you could not look up.');
379
+ }
354
380
  /**
355
381
  * Normalize an agent's stdout into a postable message.
356
382
  *
@@ -26,6 +26,7 @@ import type { spawn } from 'node:child_process';
26
26
  import WebSocket from 'ws';
27
27
  import type { CommandRunner } from './accounts.js';
28
28
  import type { AgentCredentials } from './credentials.js';
29
+ import type { RunCapabilities } from './capabilities.js';
29
30
  import type { QuotaProvider } from './runtime_status.js';
30
31
  /**
31
32
  * Default ceiling on one agent invocation: none.
@@ -144,6 +145,12 @@ export interface AgentRunOptions {
144
145
  * `codex login status`). Tests inject a fake so no real CLI is asked.
145
146
  */
146
147
  accountCommandRunner?: CommandRunner;
148
+ /**
149
+ * Asks what the paired credential can reach (see `capabilities.ts`).
150
+ * Defaults to a cached `tools/list` against the pairing's MCP endpoint; tests
151
+ * inject a fake so no extra request lands in their recorded calls.
152
+ */
153
+ capabilityProbe?: () => Promise<RunCapabilities>;
147
154
  }
148
155
  export interface AgentRunMetrics {
149
156
  connections: number;
package/dist/agent/run.js CHANGED
@@ -38,6 +38,7 @@ import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
38
38
  import { execAgent } from './exec.js';
39
39
  import { HeartbeatLoop } from './heartbeat.js';
40
40
  import { buildPrompt, replyFromStdout } from './prompt.js';
41
+ import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
41
42
  import { RunStore } from './runstore.js';
42
43
  import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
43
44
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
@@ -255,6 +256,7 @@ function buildRuntime(options, log) {
255
256
  brain,
256
257
  skipPermissions,
257
258
  prepareMcp: () => ({ mcpWired: false }),
259
+ capabilities: async () => NO_CAPABILITIES,
258
260
  runtimeName,
259
261
  observe,
260
262
  model,
@@ -336,6 +338,10 @@ function buildRuntime(options, log) {
336
338
  sessions,
337
339
  brain,
338
340
  prepareMcp: prepareWithAccount,
341
+ capabilities: options.capabilityProbe ??
342
+ (mcpUrl
343
+ ? capabilityProbe({ mcpUrl, apiKey: options.credentials.api_key, fetchImpl: options.fetchImpl })
344
+ : async () => NO_CAPABILITIES),
339
345
  skipPermissions,
340
346
  runtimeName,
341
347
  observe,
@@ -841,6 +847,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
841
847
  const surface = run.conversation_id ?? run.kind ?? 'no conversation';
842
848
  log(`${timestamp()} ▶ ${label} started (${surface})`);
843
849
  const mcp = runtime.prepareMcp();
850
+ // Only worth asking when the tools it describes are wired into this run.
851
+ const capabilities = mcp.mcpWired ? await runtime.capabilities() : NO_CAPABILITIES;
844
852
  const prompt = buildPrompt(run, {
845
853
  agentName: options.credentials.agent?.name ?? 'your agent',
846
854
  // Named only when the tools are actually reachable from this invocation.
@@ -852,6 +860,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
852
860
  // `plain` command is still told the server name only: we have no idea what
853
861
  // it is. See PromptContext.mcpToolPattern.
854
862
  mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
863
+ backofficeRead: capabilities.backofficeRead,
855
864
  toolsGated: !runtime.skipPermissions,
856
865
  // The real numbers, not a vague "there is a limit" — see PromptContext.
857
866
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.8.1";
10
+ export declare const CLI_VERSION = "0.8.2";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.8.1";
12
+ export declare const USER_AGENT = "prohost-cli/0.8.2";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.8.1';
10
+ export const CLI_VERSION = '0.8.2';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",