unbound-cli 1.3.2 → 1.4.0

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,477 @@
1
+ # WEB-4887 — AI-Assisted Tool Policy Creation in `unbound` CLI
2
+
3
+ **Linear:** https://linear.app/unboundsec/issue/WEB-4887
4
+ **Status:** Spec / implementation plan
5
+ **Owner:** Dinesh Veluswamy
6
+ **Last updated:** 2026-06-18
7
+
8
+ ---
9
+
10
+ ## 1. Background
11
+
12
+ The web UI's tool-policy creation flow has an AI-assist input that hits a server-side endpoint tuned with our policy schema and is materially better than Claude hand-authoring policy JSON. Today, when a Claude Code user asks `unbound` to create a tool policy, Claude reaches for the flag-based `unbound policy tool create-terminal` / `create-mcp` commands and constructs the arguments itself. That works but underperforms the in-product AI-assist for the same reason the ticket describes — Claude does not have our system prompt, our command-family inventory, or our MCP catalog in context.
13
+
14
+ This plan exposes the existing AI-assist endpoints to the CLI and ships a Claude Code skill that steers Claude to use them.
15
+
16
+ Endpoints already in production (see `ai-gateway-data/webapp/api/v1/command_policy_handlers.py`):
17
+
18
+ | Policy kind | Endpoint | Model | Backend fallback if LLM fails |
19
+ |---|---|---|---|
20
+ | Terminal command | `POST /api/v1/command-policies/assist/` | Cerebras → Groq via ai-gateway classifier | Deterministic family/field extraction |
21
+ | MCP tool | `POST /api/v1/command-policies/assist-mcp/` | Claude Haiku 4.5 (`temp=0.1`, `max_tokens=900`) | None — returns `success: false` |
22
+
23
+ Both are admin-only (`@require_admin`), enforce a hard 2000-character input cap (HTTP 200 + `success: false`), and collapse runs of newlines/strip non-printable control chars as prompt-injection defense.
24
+
25
+ ---
26
+
27
+ ## 2. Goals
28
+
29
+ 1. Add an AI-assist code path to `unbound policy tool create-terminal` and `create-mcp` so Claude (and humans) can create policies from a natural-language description.
30
+ 2. Ship a Claude Code skill bundled with the CLI install so Claude reliably picks the AI-assist path over hand-authored arguments.
31
+ 3. Detect and clearly reject prompts that ask for fields the AI-assist endpoint cannot fill (env scoping, exception clauses, user-group conditions, etc.).
32
+ 4. Surface backend errors — especially the 2000-char rejection and the LLM-could-not-determine cases — with actionable CLI messages.
33
+ 5. Provide an eval harness that measures the acceptance criterion: "Claude Code should always pick our AI-assist endpoint over creating policies on its own."
34
+
35
+ ## 3. Non-goals
36
+
37
+ - **LLM quality improvements.** The Cerebras terminal classifier and Haiku MCP classifier are out of scope. The user has explicitly flagged this as non-functional for this ticket. We will work around flakiness via UX, not by changing the model.
38
+ - **Cost / Model / Security policy types.** Only tool policies have AI-assist endpoints today. Adding AI-assist to the other three is a separate ticket.
39
+ - **Multi-turn refinement loops** with the user inside the CLI. The endpoints are one-shot; we keep that contract.
40
+ - **Authoring new backend fields.** The CLI must work strictly within the existing form schema and AI-assist response schema.
41
+
42
+ ---
43
+
44
+ ## 4. Field whitelist — what AI-assist actually fills
45
+
46
+ This is the boundary the skill must teach Claude to respect. Fields outside this set must be passed via existing flags, or the prompt must be rejected with a clear message.
47
+
48
+ ### 4.1 Terminal command AI-assist (`assist/`)
49
+
50
+ **Fills** (from `form_updates` in the response — `command_policy_handlers.py:1340-1393`):
51
+
52
+ | Field | Type | Notes |
53
+ |---|---|---|
54
+ | `command_family` | string | Must match a known family (e.g. `filesystem`, `git`, `package_manager`). |
55
+ | `selected_field` | string | One of the family's fields, or the literal `ANY` wildcard. |
56
+ | `field_value` | string | Pattern — auto-detected as EXACT / GLOB / REGEX. `*` for "match everything". |
57
+ | `action` | enum | `audit` \| `block` \| `warn` \| `require_slack_approval`. Omitted if user didn't specify. |
58
+ | `name` | string ≤ 200 | Suggested policy name. Optional. |
59
+ | `description` | string ≤ 500 | Suggested description. Optional. |
60
+
61
+ **Does NOT fill** (form fields the user / Claude must set via flags or accept defaults):
62
+
63
+ | Form field | Source | CLI handling |
64
+ |---|---|---|
65
+ | `enabled` | `useCreatePolicyForm.ts:21` | Default `true`. Expose `--disabled` flag. |
66
+ | `custom_message` | `useCreatePolicyForm.ts:18` | Only relevant for `BLOCK`/`WARN`. Expose `--custom-message` flag; do not parse from prompt. |
67
+ | `scope_user_group_ids` | `useCreatePolicyForm.ts:39` | Expose `--group` / `--scope-group` flag; do not parse group names from prompt. |
68
+ | `suggestion_id` | UI-only, coverage-gap link | Not relevant to CLI. |
69
+
70
+ ### 4.2 MCP tool AI-assist (`assist-mcp/`)
71
+
72
+ **Fills** (from `mcp_policy_assist_service.py:36-86`):
73
+
74
+ | Field | Type | Notes |
75
+ |---|---|---|
76
+ | `mcp_canonical_group_id` | int | Must match an entry in the org's MCP catalog. |
77
+ | `mcp_tools` | string[] | Tool names, post-validated against the group's actual tool list — invented names are dropped. |
78
+ | `name` | string ≤ 80 | Short policy name. |
79
+ | `description` | string | One sentence. |
80
+ | `action` | enum | `AUDIT` \| `BLOCK` \| `WARN` \| `REQUIRE_SLACK_APPROVAL`. Defaults to `AUDIT` if not in prompt. |
81
+ | `custom_message` | string | Only when action is `BLOCK`/`WARN`. |
82
+
83
+ **Does NOT fill** (same boundary as terminal):
84
+
85
+ - `enabled` — default true, `--disabled` flag.
86
+ - `scope_user_group_ids` — `--group` flag.
87
+ - `suggested_tools` — UI-only, captured from a separate `/resolve-tools/` call.
88
+
89
+ ### 4.3 Out-of-scope constructs (the "env, archived projects, nuance" cases)
90
+
91
+ These are things users will naturally try to express. None of them are representable in the current form schema, and the LLM will either silently drop them or hallucinate. The CLI must detect them in the prompt and warn before sending.
92
+
93
+ | Construct | Example phrasing | Why it can't work | CLI response |
94
+ |---|---|---|---|
95
+ | Environment scope | "in staging", "on prod", "only in dev" | No env field on policies. | Warn + ask user to remove or proceed knowing it's ignored. |
96
+ | Repo / project filter | "for the web repo", "except archived projects" | No project field. | Warn. |
97
+ | User-role conditions | "for non-admins", "when the user is on call" | Closest is `scope_user_group_ids`, settable via `--group` only. | Suggest `--group <name>`. |
98
+ | Time conditions | "after 6pm", "outside business hours" | Not representable. | Reject. |
99
+ | Exception clauses | "block all writes EXCEPT to docs" | Single-policy schema is allow-or-deny on one pattern. | Suggest creating two policies. |
100
+ | Compound asks | "block X and audit Y" | One policy per call. | Suggest splitting into N invocations. |
101
+
102
+ ---
103
+
104
+ ## 5. Architecture decision
105
+
106
+ ### 5.1 Add `--prompt` to existing subcommands rather than introduce parallel `-ai` variants
107
+
108
+ The existing CLI surface is:
109
+
110
+ ```
111
+ unbound policy tool create-terminal --name "..." --command-family ... --field K=V --action ... [flags]
112
+ unbound policy tool create-mcp --name "..." --mcp-server ... --mcp-action-type ... --action ... [flags]
113
+ ```
114
+
115
+ We add a single `--prompt "<natural language>"` flag to each. When `--prompt` is present:
116
+
117
+ 1. The CLI calls the corresponding AI-assist endpoint.
118
+ 2. The response's `form_updates` are merged with any flags the user also passed (flags take precedence — they're explicit overrides).
119
+ 3. The CLI shows the resolved policy to the user (or to Claude, who will surface it to the user) and asks for confirmation before calling the real create endpoint.
120
+
121
+ Rejected alternatives:
122
+
123
+ - **Parallel `create-terminal-ai` / `create-mcp-ai` subcommands.** Doubles the command surface; gives Claude two near-identical commands to pick between; complicates the skill.
124
+ - **Unified `unbound policy tool assist` that infers terminal vs MCP.** Inference itself is another LLM call. Cleaner UX, but adds a third moving part. Defer to a later ticket.
125
+
126
+ ### 5.2 Two-phase delivery
127
+
128
+ | Phase | Scope | Why this order |
129
+ |---|---|---|
130
+ | **Phase 1** | `create-terminal --prompt` + skill v1 (terminal only) | Terminal endpoint has a deterministic fallback when the LLM fails. Lower-risk path to validate the surface. |
131
+ | **Phase 2** | `create-mcp --prompt` + skill v2 (covers both) | MCP endpoint has no fallback. Ship after Phase 1 has shaken out the error-handling patterns. |
132
+
133
+ In Phase 1, the skill explicitly tells Claude: "For MCP policies, use the existing flag-based `create-mcp` — AI-assist for MCP is coming in Phase 2."
134
+
135
+ ---
136
+
137
+ ## 6. Phase 1 — Terminal command AI-assist
138
+
139
+ ### 6.1 CLI surface
140
+
141
+ ```bash
142
+ unbound policy tool create-terminal \
143
+ --prompt "block rm -rf in the filesystem family" \
144
+ [--group <name>] \
145
+ [--disabled] \
146
+ [--custom-message "..."] \
147
+ [--yes] \
148
+ [--json]
149
+ ```
150
+
151
+ Behavior:
152
+
153
+ - `--prompt` is mutually exclusive with the existing field-specifying flags (`--command-family`, `--field`, `--action`, `--name`, `--description`). If both are passed, error out: "Pass `--prompt` for AI-assist or the field flags for explicit creation, not both."
154
+ - `--group`, `--disabled`, `--custom-message` remain valid alongside `--prompt` because they're out-of-AI-scope (see §4.1).
155
+ - `--yes` skips the confirmation prompt (needed for Claude's non-interactive flows).
156
+ - `--json` emits the resolved policy and the create response as JSON, no TTY formatting.
157
+
158
+ ### 6.2 Pre-flight checks (CLI side, before any network call)
159
+
160
+ In order, fail fast on the first hit:
161
+
162
+ 1. **Auth present.** Refuse if `unbound login` has not been run.
163
+ 2. **Admin role.** Hit `whoami` cache or pre-flight `/me`; if not admin, error: "Tool policy creation requires admin role; current role: <role>."
164
+ 3. **Prompt length.** If trimmed length > 1800 characters, reject locally with the same wording the backend uses: "Input is too long (max 2000 characters)." Reject at 1800 to leave headroom for backend normalization.
165
+ 4. **Out-of-scope keyword scan.** Lower-cased substring match against this list (kept small and explicit, not a regex puzzle):
166
+ - `staging`, `production`, `prod`, `dev`, `qa`, `testing`
167
+ - `archived`, `archive`
168
+ - `business hour`, `after hour`, `outside hour`, `weekend`
169
+ - `except`, `unless`, `but not`
170
+ - `private repo`, `public repo`, `private project`, `public project`
171
+ On match, warn and confirm: "Your prompt mentions `<token>`. Tool policies cannot scope by environment / project / time / exception clauses. Continue anyway? The endpoint will ignore those parts. [y/N]". Under `--yes`, log the warning but proceed.
172
+ 5. **Newline normalization.** Collapse runs of ≥2 newlines to one. (Mirror backend defense — gives us identical sanitization regardless of which side rejects.)
173
+
174
+ ### 6.3 Network call
175
+
176
+ ```
177
+ POST {API_BASE}/api/v1/command-policies/assist/
178
+ Authorization: Bearer <token>
179
+ Content-Type: application/json
180
+
181
+ {
182
+ "user_input": "<sanitized prompt>",
183
+ "current_form_state": {
184
+ "command_family": "",
185
+ "selected_field": "",
186
+ "field_value": "",
187
+ "action": "",
188
+ "name": "",
189
+ "description": ""
190
+ }
191
+ }
192
+ ```
193
+
194
+ Timeout: 20 seconds (backend timeout to the gateway is 15s; we add 5s for routing).
195
+
196
+ ### 6.4 Response handling
197
+
198
+ | HTTP | Body | CLI behavior |
199
+ |---|---|---|
200
+ | 200 | `success: true`, `form_updates: {...}`, `explanation` | Merge `form_updates` ← user-passed flags, render preview, confirm. |
201
+ | 200 | `success: false`, `error: "Input is too long..."` | Print verbatim error + suggestion: "Try shortening to under 1800 characters." Exit 2. |
202
+ | 200 | `success: false`, `error: "Could not determine command family..."` | Print verbatim error + suggestion: "Try naming the command type explicitly (e.g. `block git pushes`, `audit npm installs`). Or use `unbound policy tool families` to see available families and pass `--command-family` directly." Exit 2. |
203
+ | 200 | `success: false`, `error: <other>` | Print verbatim error. Exit 2. |
204
+ | 401 / 403 | any | "Authentication failed / not authorized. Tool policies require admin. Run `unbound whoami` to check role." Exit 3. |
205
+ | 400 / 422 | any | "Request validation failed: `<body>`." Exit 2. |
206
+ | 5xx | any | "Server error. Try again, or fall back to flag-based creation: `unbound policy tool create-terminal --command-family ... --field ... --action ...`." Exit 4. |
207
+ | timeout / network | — | "Network error reaching `<host>`. Check connectivity. Falling back to flag-based creation is not blocked." Exit 4. |
208
+
209
+ **No automatic retries.** Re-sending the same prompt to the same flaky LLM endpoint won't change the answer in any meaningful way, and silent retries hide latency. If the user explicitly asks Claude to retry, that's a separate invocation.
210
+
211
+ ### 6.5 Merging AI output with user-passed flags
212
+
213
+ Order of precedence (highest first):
214
+
215
+ 1. Explicit flags on the command line (`--group`, `--custom-message`, `--disabled`).
216
+ 2. AI response `form_updates` fields.
217
+ 3. Defaults (`enabled: true`, `action: audit` if AI omitted it).
218
+
219
+ The merge happens before the confirmation step so the user sees the final shape.
220
+
221
+ ### 6.6 Confirmation step
222
+
223
+ Default behavior — show the resolved policy, ask `Create policy? [Y/n]`. The
224
+ preview reuses the CLI's standard `output.keyValue` rendering (dimmed keys,
225
+ auto-padded columns) for visual consistency with `displayToolPolicy` and the
226
+ other policy-display surfaces.
227
+
228
+ Concept-order of rows (rows MAY be skipped when their value is empty — e.g.
229
+ Description and Custom message are dropped rather than printed as `(none)`):
230
+
231
+ - Name, Description, Type, Command family, Field, Pattern, Action,
232
+ Custom message, Scope (groups), Enabled.
233
+
234
+ Action is colored — `BLOCK` red, `WARN`/`REQUIRE_SLACK_APPROVAL` yellow,
235
+ `AUDIT` dimmed (low-emphasis default). The AI explanation prints below the
236
+ table in dim text and is omitted entirely when the backend returns an empty
237
+ explanation.
238
+
239
+ Exact column widths and labels are not pinned — tests assert on meaningful
240
+ tokens (Name value, Action enum, explanation body), not on column-aligned
241
+ strings.
242
+
243
+ Under `--yes`, skip the prompt but always print the resolved policy (Claude will surface it to the user in chat).
244
+
245
+ ### 6.7 Create call
246
+
247
+ On confirmation, call the existing create endpoint exactly as the flag-based path does today (`POST /api/v1/command-policies/`) with the merged payload. No new backend code on the create side.
248
+
249
+ ### 6.8 Backend change (optional, recommended)
250
+
251
+ Add a `source` field to both AI-assist endpoints (`"web"` | `"cli"`, defaulting to `"web"` for backward compat). Lets us measure CLI adoption and the AC pick-rate without parsing user-agent headers. One-line wire change, two-line server change, no behavioral impact.
252
+
253
+ ### 6.9 Tests
254
+
255
+ - **Unit (CLI):**
256
+ - Prompt length pre-flight at 1799 / 1800 / 1801 chars.
257
+ - Out-of-scope keyword detection for each token in §6.2.
258
+ - Flag/prompt mutual exclusion error path.
259
+ - Merge precedence: flag wins over AI value.
260
+ - Each HTTP status path in §6.4 routes to the right CLI message.
261
+ - **Integration (against staging):**
262
+ - 10 representative single-intent prompts → expect `success: true` and a valid policy.
263
+ - 3 oversize prompts → expect length error, no policy created.
264
+ - 3 nonsense prompts → expect "could not determine" error.
265
+ - **Eval harness (see §8):**
266
+ - Pick-rate test: spawn Claude Code with the skill installed, give it 10 natural-language asks ("create a policy that…"), measure whether it invokes `create-terminal --prompt` vs hand-rolls `--command-family ...`.
267
+
268
+ ---
269
+
270
+ ## 7. Phase 2 — MCP tool AI-assist — **DELIVERED**
271
+
272
+ Phase 2 ships on the same branch (`web-4887-cli-ai-assist-policy`, PR #54) as
273
+ Phase 1. New exports in `src/lib/policy-ai-assist.js`: `runMcpPromptCreate`,
274
+ `mergeAiAndFlagsMcp`, `renderMcpPreview`. The `create-mcp` subcommand now
275
+ accepts `--prompt <text>` as a first-class alternative to the flag-based path.
276
+
277
+ ### 7.1 CLI surface
278
+
279
+ ```bash
280
+ unbound policy tool create-mcp \
281
+ --prompt "audit all Linear writes" \
282
+ [--group <name>] \
283
+ [--disabled] \
284
+ [--custom-message "..."] \
285
+ [--yes] \
286
+ [--json]
287
+ ```
288
+
289
+ Same flag rules as Phase 1.
290
+
291
+ ### 7.2 Pre-flight additions on top of §6.2
292
+
293
+ - **Catalog awareness.** If the prompt names a service that isn't in the org's MCP catalog (fetched via the existing `unbound policy tool mcp-servers` call, cached for the session), warn before sending: "Your prompt mentions `<service>`, which isn't in this org's MCP catalog. The endpoint will likely return an error. Available services: `<list>`. Continue? [y/N]". Optional optimization; skip in v1 if scope creeps.
294
+
295
+ ### 7.3 Network call
296
+
297
+ `POST {API_BASE}/api/v1/command-policies/assist-mcp/` — identical envelope shape to Phase 1, with the MCP-specific `current_form_state` keys.
298
+
299
+ ### 7.4 Response handling — same matrix as §6.4, with one addition
300
+
301
+ | HTTP | Body | CLI behavior |
302
+ |---|---|---|
303
+ | 200 | `success: true`, `mcp_tools: []` (empty array after backend post-validation drops hallucinated names) | Treat as a soft failure: "AI assist could not match any tools in `<group>` for your description. Try naming the tools more directly, or use `unbound policy tool create-mcp --mcp-server ... --mcp-tools tool1,tool2`." Do NOT auto-create a policy with no tools. Exit 2. |
304
+
305
+ The MCP endpoint has no backend deterministic fallback, so flakiness surfaces as either `success: false` or `mcp_tools: []`. The CLI handles both as "couldn't figure it out; here's the manual escape hatch."
306
+
307
+ Note: the `assist-mcp/` response has no `explanation` field (asymmetric with the
308
+ terminal `assist/` endpoint). `renderMcpPreview` is passed `undefined` for the
309
+ explanation and skips the trailing dim line entirely. The render code is
310
+ forward-compatible: if the backend later adds `explanation`, the existing
311
+ `if (explanation)` guard picks it up with zero CLI change.
312
+
313
+ ### 7.5 Tests — extend §6.9 with MCP-specific cases
314
+
315
+ - Empty `mcp_tools` response routes to the "couldn't match any tools" message.
316
+ - Out-of-catalog service mention warns before sending.
317
+ - Integration: 10 single-service prompts + 3 multi-service prompts (latter should warn or split).
318
+
319
+ ---
320
+
321
+ ## 8. Claude Code skill — distribution and content
322
+
323
+ The CLI cannot edit Claude's system prompt, but it can drop a skill file. Skills are the closest analog to a CLAUDE.md hook that we can install programmatically.
324
+
325
+ ### 8.1 Where it lives
326
+
327
+ `~/.claude/skills/unbound-tool-policy/SKILL.md` at runtime.
328
+
329
+ Source-of-truth lives in the **`setup` repo** at `claude-code/skills/unbound-tool-policy/SKILL.md`, NOT in unbound-cli. The `claude-code/hooks/setup.py` and `claude-code/gateway/setup.py` scripts (which `unbound setup claude-code` and `unbound onboard` download and run) copy it from the setup repo's main branch on every install. This co-locates the skill with the rest of the claude-code per-tool install artifacts (`unbound.py`, `anthropic_key.sh`, etc.) and lets skill content updates ship the moment they merge to setup-repo main — no unbound-cli release required.
330
+
331
+ Installed by `unbound setup claude-code` (both subscription and gateway modes) and by `unbound onboard`. Idempotent: overwritten on each install so content updates propagate.
332
+
333
+ ### 8.2 Phase 1 skill content (terminal only)
334
+
335
+ The SKILL.md frontmatter is what Claude reads to decide whether to invoke. Keep the description imperative and specific:
336
+
337
+ ```markdown
338
+ ---
339
+ name: unbound-tool-policy
340
+ description: |
341
+ Use when the user asks to create a terminal command tool policy in Unbound
342
+ (e.g. "block rm -rf", "audit git pushes to main"). ALWAYS prefer
343
+ `unbound policy tool create-terminal --prompt "<NL>"` over hand-authoring
344
+ the --command-family / --field / --action flags. The CLI calls a
345
+ server-side AI-assist endpoint tuned on Unbound's policy schema; it
346
+ outperforms hand-authoring.
347
+
348
+ For MCP tool policies (Linear, GitHub, etc.), AI-assist is not available
349
+ yet — fall back to flag-based `unbound policy tool create-mcp` for those.
350
+ ---
351
+
352
+ # Creating an Unbound terminal command policy
353
+
354
+ When the user asks for a tool policy targeting terminal commands, invoke:
355
+
356
+ unbound policy tool create-terminal --prompt "<natural language>" [--group <name>] [--yes]
357
+
358
+ ## Rules for the --prompt string
359
+
360
+ 1. **One policy per invocation.** If the user wants two things, run the
361
+ command twice.
362
+ 2. **Single-intent, imperative.** Phrase it as "block git pushes to main",
363
+ "audit npm installs", "warn on rm -rf". Avoid multi-paragraph briefs.
364
+ 3. **Stay under 1500 characters.** The endpoint caps at 2000; the CLI
365
+ pre-trims at 1800. Long prompts get rejected.
366
+ 4. **Do NOT include** these — the endpoint cannot represent them and the
367
+ CLI will warn about them:
368
+ - environment scope (staging, prod, dev)
369
+ - project / repo filters
370
+ - time-based conditions
371
+ - exception clauses ("except for X")
372
+ - user-role conditions
373
+ 5. **Group scoping** goes on the `--group` flag, not in the prompt.
374
+ 6. **Custom block messages** go on `--custom-message`, not in the prompt.
375
+
376
+ ## When AI-assist fails
377
+
378
+ If the CLI returns "Could not determine command family", try one of:
379
+
380
+ - Re-phrase the user's intent to name the command type explicitly.
381
+ - Fall back to flag-based: `unbound policy tool families` to list families,
382
+ then `unbound policy tool create-terminal --command-family ... --field ...`.
383
+
384
+ ## What success looks like
385
+
386
+ The CLI prints a resolved policy preview and asks for confirmation.
387
+ Pass `--yes` to skip the confirm; the preview still prints so the user
388
+ can sanity-check.
389
+
390
+ ## MCP policies (not this skill, for now)
391
+
392
+ For MCP tool policies, use the flag-based form:
393
+
394
+ unbound policy tool create-mcp --name "..." --mcp-server <server> \
395
+ --mcp-action-type <read|write|destructive> --action AUDIT|BLOCK|WARN
396
+ ```
397
+
398
+ ### 8.3 Phase 2 skill content updates
399
+
400
+ When Phase 2 ships, replace the "MCP policies (not this skill, for now)" section with the MCP variant of the same rules, and update the frontmatter description to drop the "MCP not available yet" caveat.
401
+
402
+ ### 8.4 Belt-and-suspenders: README and `--help`
403
+
404
+ - Update `unbound policy tool create-terminal --help` so the imperative language is also in the help output. Claude reads `--help` when uncertain.
405
+ - Update `README.md` "Tool policy examples" section so the first example uses `--prompt`. Manual flag examples remain below as a fallback.
406
+
407
+ ---
408
+
409
+ ## 9. Acceptance criterion — testable definition
410
+
411
+ The Linear ticket says "Claude code should always pick our AI assist endpoint over creating policies on its own." Rewritten as testable:
412
+
413
+ 1. **Pick-rate ≥ 90%** on the eval set below.
414
+ 2. **Success-rate ≥ 80%** of in-scope eval prompts that reach the endpoint return `success: true`.
415
+ 3. **Zero hand-authored YAML / JSON files** in the eval transcript. Claude should never write a policy file to disk and then upload — the CLI is the only path.
416
+
417
+ ### 9.1 Eval set
418
+
419
+ `tests/eval/policy-prompts.json` in this repo. ~20 prompts across:
420
+
421
+ - 6 single-intent in-scope ("block rm -rf", "audit git pushes to main", …)
422
+ - 4 detailed in-scope (300–800 chars, single intent, more context)
423
+ - 4 out-of-scope (env, exception, time, compound) — expected outcome: skill steers Claude to warn the user or split
424
+ - 3 oversize (> 2000 chars) — expected outcome: pre-flight rejects
425
+ - 3 nonsense — expected outcome: `success: false`, surfaced
426
+
427
+ Eval harness: a script that spawns Claude Code in a clean workspace with the skill installed, feeds each prompt as a user message, and records: which command Claude invoked, whether `--prompt` was used, the endpoint response, the final state. Aggregate into a pick-rate / success-rate report.
428
+
429
+ This eval is the artifact that closes the ticket. Without it, "always picks" is unverifiable.
430
+
431
+ ---
432
+
433
+ ## 10. Implementation order
434
+
435
+ ### Phase 1 (1–2 dev-days)
436
+
437
+ 1. `src/commands/policy.js` — add `--prompt` flag and AI path to `create-terminal`. Branch on `--prompt` presence; preserve all existing flag paths.
438
+ 2. New module `src/lib/policy-ai-assist.js` — HTTP client, pre-flight checks, response routing, preview rendering.
439
+ 3. Skill file at `skills/unbound-tool-policy/SKILL.md` in the repo; build step copies to `~/.claude/skills/` during `setup claude-code` and `onboard`.
440
+ 4. Update `--help` text and `README.md` policy examples.
441
+ 5. Unit tests (§6.9).
442
+ 6. Eval harness skeleton + the in-scope prompts (§9.1).
443
+ 7. Integration sanity check against staging.
444
+
445
+ ### Phase 2 (1 dev-day, after Phase 1 lands)
446
+
447
+ 1. Extend `create-mcp` with `--prompt`.
448
+ 2. Reuse `src/lib/policy-ai-assist.js`; add MCP-specific pre-flight and response routing.
449
+ 3. Update skill file and `--help`.
450
+ 4. Extend eval harness with MCP prompts.
451
+
452
+ ### Backend (optional, parallel to Phase 1)
453
+
454
+ Add the `source: "cli" | "web"` field to both AI-assist request handlers and log it. PR in `ai-gateway-data`.
455
+
456
+ ---
457
+
458
+ ## 11. Open questions / risks
459
+
460
+ 1. **Catalog awareness in MCP pre-flight.** The `mcp-servers` list is org-specific and changes. Caching policy: per-session in memory, or persist to `~/.unbound/cache/`? Defer to Phase 2 review.
461
+ 2. **Admin-key blast radius.** AI-assist is admin-only. End-user Claude Code sessions running under a non-admin key will get 403s. The skill should detect that on first use and tell Claude to advise the user to ask their admin, instead of looping on retries.
462
+ 3. **Eval cost.** Running the eval against real Claude Code instances burns tokens. Cap the eval to one run per CI build, gated on changes to `src/commands/policy.js` or the skill file.
463
+
464
+ ## 12. Deferred to follow-up tickets
465
+
466
+ These are intentionally out of scope for WEB-4887 to keep the surface narrow. Spin off tickets when these become live:
467
+
468
+ 1. **MCP catalog cache for pre-flight.** Phase 2 pre-flight could check the prompted service against the org's MCP catalog before sending. Cache location (`~/.unbound/cache/`?) and invalidation policy need design. Defer until we see whether the bare endpoint surfaces "no matching service" errors well enough on its own.
469
+ 2. **`source: "cli" | "web"` request field on both AI-assist endpoints.** Lets us distinguish CLI usage from web usage in production logs, which is the only way to measure the AC pick-rate at scale without parsing user-agents. Punt to a post-Phase-2 ticket so backend work doesn't gate the CLI rollout.
470
+ 3. **AI-assist for Cost / Model / Security policy types.** Today only tool policies have AI-assist endpoints. If pick-rate data shows users want NL creation for the other three, design endpoints and extend the skill.
471
+ ~~4. **Per-user skill install for MDM deployments.**~~ — DELIVERED in setup PR #163. Both MDM setup scripts now enumerate device users via `get_all_user_homes()` and install the skill into each user's `~/.claude/skills/` under `_run_as_user(username, _do)` (the existing security-critical privilege-drop primitive). Best-effort per user; one locked-down home does not abort the device rollout. See `claude-code/{hooks,gateway}/mdm/setup.py:setup_tool_policy_skill_for_user` and the README updates in each `mdm/` directory.
472
+
473
+ ## 13. Skill reinstall policy
474
+
475
+ Each `unbound setup claude-code` and `unbound onboard` run **overwrites** `~/.claude/skills/unbound-tool-policy/SKILL.md` with the version fetched from the `setup` repo's main branch at install time. No checksum gating, no diff-and-prompt. Rationale: the skill is a derived artifact of whatever's in the setup repo's main; treating it as an install-time output (not user state) keeps the mental model simple and ensures Claude is always steered with the current rules.
476
+
477
+ (The skill source-of-truth was originally bundled in unbound-cli during initial implementation; relocated to the setup repo to follow the existing per-tool-artifact convention. See companion PR https://github.com/websentry-ai/setup/pull/163.)
package/PLAN.md ADDED
@@ -0,0 +1,117 @@
1
+ # Implementation Plan: WEB-4887 Phase 2 — AI-assisted MCP tool policy creation in `unbound` CLI
2
+
3
+ > Generated by /implementation-plan on 2026-06-18. Source: principal-architect.
4
+ >
5
+ > Phase 1 (terminal command AI-assist) is **DELIVERED** on the same branch
6
+ > (`web-4887-cli-ai-assist-policy`, commits `a90713e`..`f4508dc`, PR #54).
7
+ > Phase 1 acceptance criteria still hold — see git history. This document
8
+ > contracts Phase 2 (MCP tool AI-assist) only; downstream engineers should
9
+ > read Phase 1's exports in `src/lib/policy-ai-assist.js` to understand the
10
+ > infrastructure being extended (preflight, admin probe, error matrix,
11
+ > confirmation, BLOCK/WARN guard, flag-precedence merge).
12
+
13
+ ## Goal
14
+ Add `--prompt <text>` to `unbound policy tool create-mcp` so an admin can describe an MCP tool policy in natural language; the CLI routes the prompt through `POST /api/v1/command-policies/assist-mcp/`, merges the AI response with user-supplied override flags, previews the resolved policy, confirms, and creates it via the existing create endpoint.
15
+
16
+ ## Established Context (do not override)
17
+ - Single PR — Phase 2 ships on existing branch `web-4887-cli-ai-assist-policy`, same PR #54 as Phase 1. Phase 1 infrastructure is shared.
18
+ - Same `--prompt` flag pattern — add to existing `unbound policy tool create-mcp` subcommand (NOT a parallel `-ai` subcommand). Mutex with MCP classification flags (`--mcp-server`, `--mcp-tool` — singular, what the CLI actually exposes today; `--mcp-action-type`; `--config`). Compatible with `--name`, `--description`, `--action`, `--custom-message`, `--group`, `--disabled`, `--yes`, `--json` as overrides.
19
+ - Endpoint: `POST {API_BASE}/api/v1/command-policies/assist-mcp/`. Admin-only. Request body `{user_input: string, current_form_state: {mcp_canonical_group_id, mcp_tools, name, description, action}}`. Success response: `{success:true, canonical_group_id:int, mcp_tools:string[], name:string, description:string, action:enum, custom_message:string}`. Failure response: `{success:false, error:string}`. No `explanation` field in the response (asymmetric with the terminal endpoint).
20
+ - Field whitelist (spec §4.2). AI-fills: `mcp_canonical_group_id`, `mcp_tools[]`, `name` (≤80 chars), `description`, `action`, `custom_message`. User-via-flags overrides: `--name`, `--description`, `--action`, `--custom-message`, `--group`, `--disabled`. Out-of-scope (mutex with `--prompt`): `--mcp-server`, `--mcp-tool`, `--mcp-action-type`, `--config`.
21
+ - No backend fallback (Haiku endpoint). Two extra failure modes vs Phase 1: `success:true` + `mcp_tools:[]` → soft fail; `success:true` + missing `canonical_group_id` → soft fail. Surface manual escape-hatch and exit 2; do NOT create the policy.
22
+ - Reuse Phase 1 helpers from `src/lib/policy-ai-assist.js`: `validatePromptPreflight`, `loadPrivileges` + module-level `_privilegesCache`, `confirmCreate`, `confirmContinue`, `promptYesNo`, `routeBackendError`, `validateActionOverride`, `OUT_OF_SCOPE_KEYWORDS`, `MAX_PROMPT_LEN`, `TOOL_ACTIONS`, `colorAction`.
23
+ - New helpers added to the SAME file (`src/lib/policy-ai-assist.js`): `runMcpPromptCreate`, `mergeAiAndFlagsMcp`, `renderMcpPreview`, `routeMcpSuccessFalse`. Module grows from ~340 LOC to ~600 LOC — within "wieldy".
24
+ - BLOCK/WARN custom-message guard same shape as Phase 1: post-merge, if action ∈ {BLOCK, WARN} and no `custom_message`, exit 2 with user-vs-AI attributed wording.
25
+ - Out of Scope this ticket: MCP catalog cache pre-flight (spec §7.2), backend `source:"cli"|"web"` telemetry (spec §12.2), AI-assist for Cost/Model/Security (spec §3 / §12.3), MDM-side MCP catalog awareness.
26
+ - Skill update needed in setup PR #163 — separate cross-repo deliverable (see "Sequencing" step 7).
27
+
28
+ ## Out of Scope
29
+ - MCP catalog cache pre-flight (spec §7.2). The "warn before sending if service not in org's catalog" optimization is deferred.
30
+ - Backend `source: "cli" | "web"` telemetry field on the assist-mcp endpoint (spec §12.2).
31
+ - AI-assist for Cost / Model / Security policy types (spec §3 / §12.3).
32
+ - MDM-side MCP catalog awareness (catalogs are per-org).
33
+ - Reverse-resolving `canonical_group_id` → service display name in the preview "Service" row. v1 shows the integer id; post-create `displayToolPolicy` shows the resolved name from the backend's full serialization. Polish follow-up.
34
+ - Migrating the existing flag-based `create-mcp` body shape (`mcp_server` string + `mcp_tool` singular + `mcp_tool_action_type`) to the AI-assist shape (`mcp_canonical_group_id` int + `mcp_tools[]` array). The flag path stays exactly as today; only the AI-assist branch sends the new shape. Backend accepts both.
35
+ - Renaming `--mcp-tool` (singular, current) to `--mcp-tools` (plural, as referenced in spec §7). Separate flag-rename ticket if wanted.
36
+ - Updating `claude-code/skills/unbound-tool-policy/SKILL.md` in the `setup` repo (PR #163). Cross-repo follow-up commit, NOT bundled with the unbound-cli PR. Sequencing in step 7.
37
+ - Running the eval harness in CI. The terminal eval set at `test/eval/policy-prompts.json` is manual-only; the MCP extension (10 single-service + 3 multi-service prompts per spec §7.5) is also manual-only and noted in `test/eval/README.md` as a follow-up entry — not a Phase 2 file change.
38
+
39
+ ## Files to Change
40
+ | Path | Change | Acceptance criterion |
41
+ |------|--------|----------------------|
42
+ | `src/commands/policy.js` | (a) Downgrade three `.requiredOption(...)` calls on `create-mcp` (lines ~1586, ~1587, ~1590 — `--name`, `--mcp-server`, `--action`) to `.option(...)`. (b) Add `.option('--prompt <text>', ...)`, `.option('--yes', ...)`. (Confirm `--json` is present; add if absent.) (c) Update the descriptions of `--name`, `--description`, `--action`, `--custom-message` to note "Override AI suggestion when used with --prompt". (d) In the `create-mcp` action handler, branch on `opts.prompt` BEFORE the existing flag validation. Branch body: empty-prompt check (exit 1, message "--prompt cannot be empty."); mutex against `--mcp-server`, `--mcp-tool`, `--mcp-action-type`, `--config` with verbatim wording "Pass --prompt for AI-assist or the field flags for explicit creation, not both." (exit 1); resolve `--group` via `loadFormData` and `resolveByName` to populate `opts.scopeUserGroupIds`; call `runMcpPromptCreate(opts)`; handle `err.exitCode === 0` as user-abort (warn + return); on success, `api.post('/api/v1/command-policies/', { body: result.body })`; success message `MCP policy${body.name ? ` "${body.name}"` : ''} created.`; `displayToolPolicy(unwrapToolPolicy(data))`. (e) Preserve the flag-based path verbatim; add `(or use --prompt for AI assist)` suffix to the three required-flag in-handler errors so non-`--prompt` users see equivalent guidance. (f) Update `addHelpText('after', ...)` so the first example uses `--prompt`. | M2, M3, M5, M6, M7, M9, M10, M11 |
43
+ | `src/lib/policy-ai-assist.js` | Add four new symbols. (a) `routeMcpSuccessFalse(error)` — same shape as the existing `routeSuccessFalse`, but MCP-specific manual-escape-hatch wording (mentions `--mcp-server`/`--mcp-action-type`, not `--command-family`/`unbound policy tool families`). (b) `mergeAiAndFlagsMcp(aiResponse, opts)` — accepts the full AI response (`canonical_group_id`, `mcp_tools`, `name`, `description`, `action`, `custom_message`), returns body `{policy_type:'MCP_TOOL', mcp_canonical_group_id, mcp_tools, name, description, action, enabled, custom_message?, scope_user_group_ids?}`. Flag-precedence rules: trimmed `--name`/`--description`/`--action` win over AI; whitespace-only falls back to AI silently (mirrors Phase 1 regression fix); `--custom-message` always wins; `--disabled` always wins; default action `AUDIT` if neither AI nor flag sets it. (c) `renderMcpPreview(body, explanation)` — same `console.log` + `output.keyValue` shape as `renderTerminalPreview`. Rows in order: Name, Description (skip if empty), Type (`MCP_TOOL`), Service (`canonical_group_id: <int>` — see Out of Scope for why no reverse-resolve), Tools (`body.mcp_tools.join(', ')`), Action (colored via existing `colorAction`), Custom message (skip if empty), Scope (groups, `(org-wide)` if none), Enabled. Header "ℹ Resolved MCP policy (from AI assist):" in cyan. Explanation block guarded by `if (explanation)` — Phase 2 callers pass `undefined` because assist-mcp returns no explanation; forward-compatible if backend later adds it. (d) `runMcpPromptCreate(opts)` — orchestrator mirroring `runTerminalPromptCreate`. Sequence: `config.isLoggedIn()` check (exit 3); `validateActionOverride(opts.action)` early; `validatePromptPreflight(opts)` and warn loop over `warnings`; if warnings present and not `--yes`/`--json`, `confirmContinue()` gate (abort → throw with `exitCode:0`); `loadPrivileges()` + non-admin gate (exit 3 with role-naming message); `api.post('/api/v1/command-policies/assist-mcp/', {body: {user_input: sanitizedPrompt, current_form_state: {mcp_canonical_group_id:'', mcp_tools:[], name:'', description:'', action:''}}})` wrapped in try/catch that routes through `routeBackendError`; on `success === false`, route through `routeMcpSuccessFalse` and throw; **soft-fail check**: if `!response.canonical_group_id` OR `!Array.isArray(response.mcp_tools)` OR `response.mcp_tools.length === 0`, throw `Error` with `exitCode:2` and message `"AI assist could not match any tools for your description. Try naming the service and tools more directly, or use \`unbound policy tool create-mcp --name "..." --mcp-server <server> --mcp-action-type <read\|write\|destructive> --action <action>\`."`; `body = mergeAiAndFlagsMcp(response, opts)`; BLOCK/WARN guard reusing Phase 1's user-vs-AI attribution pattern (check `typeof opts.action === 'string' && opts.action.trim().length > 0` to decide attribution); `renderMcpPreview(body, undefined)` unless `opts.json`; `confirmed = opts.yes || opts.json ? true : await confirmCreate()`; return `{body, confirmed, explanation: undefined}`. (e) Append `runMcpPromptCreate`, `mergeAiAndFlagsMcp`, `renderMcpPreview` to `module.exports` (keep all existing exports verbatim). | M1, M2, M3, M4, M5, M6, M7, M8, M11 |
44
+ | `README.md` | Under "Tool policy examples", in the MCP section, lead the first example with `unbound policy tool create-mcp --prompt "audit all Linear writes"`. Keep the flag-based example below as the documented fallback. Symmetric to Phase 1's terminal section update. | M12 |
45
+ | `PLAN-web-4887.md` | §7: replace "Phase 2 spec, not yet implemented" language with "Phase 2 DELIVERED" markers. At the end of §7.4, add a note that the assist-mcp response has no `explanation` field (preview's explanation block is unconditionally skipped). §11.1 (catalog cache), §12.2 (`source:cli` telemetry), §12.3 (Cost/Model/Security AI-assist) remain in the deferred list — Phase 2 does NOT mark them delivered. | M13 |
46
+
47
+ ## Files to Create
48
+ | Path | Purpose | Acceptance criterion |
49
+ |------|---------|----------------------|
50
+ | `test/policy-ai-assist-mcp.test.js` | NEW integration test file at the CLI command layer. Uses `node:test` + `commander.parseAsync` exactly like Phase 1's `test/policy-ai-assist.test.js`. Extends the harness pattern: `buildHarness` in this file accepts `assistMcpResponse` / `assistMcpError` options, and the `api.post` stub routes on `path === '/api/v1/command-policies/assist-mcp/'` to either return `assistMcpResponse` or throw `assistMcpError` (otherwise routes to `/api/v1/command-policies/` for the create POST). Defines a local `runCreateMcp(argv)` helper wrapping `program.parseAsync(['node','unbound','policy','tool','create-mcp', ...argv])`. ~50 LOC of harness duplication accepted vs shared-helper plumbing. Defines 12 distinct tests (mutex tests parametrized over a `MUTEX_FLAGS` array; routing tests parametrized over a `ROUTING_CASES` array). Total ~13–15 `test(...)` calls. | M2, M3, M4, M5, M6, M7, M8, M9, M11 |
51
+
52
+ ## Test Surface
53
+ - **Layer:** CLI command. Tests invoke `unbound policy tool create-mcp` via `commander.parseAsync(['node','unbound','policy','tool','create-mcp', ...argv])` against a fresh `new Command()` with the policy module re-registered per test (`loadFreshModules()` invalidates `require.cache` so module-level `_privilegesCache` and `_formDataCache` are clean). `src/api.js` `.post`/`.get` and `src/config.js` `isLoggedIn`/`getApiKey`/`getBaseUrl` are monkey-patched. NO internal helper unit tests — preflight is already covered by the policy-type-agnostic `test/policy-ai-assist-preflight.test.js` from Phase 1 and is NOT re-covered.
54
+ - **Tests to add or extend:**
55
+ - `test/policy-ai-assist-mcp.test.js` — covers acceptance criteria M2, M3, M4, M5, M6, M7, M8, M9, M11. Test cases:
56
+ 1. Mutex `--prompt + --mcp-server` → M2.
57
+ 2. Mutex `--prompt + --mcp-tool` → M2.
58
+ 3. Mutex `--prompt + --mcp-action-type` → M2.
59
+ 4. Mutex `--prompt + --config` → M2.
60
+ 5. Empty `--prompt` → M2.
61
+ 6. Happy-path body shape (assist-mcp returns full success response) → M3.
62
+ 7. Merge precedence with all override flags + AI fields preserved where flags absent → M4.
63
+ 8. Whitespace-only `--name` falls back to AI name → M4.
64
+ 9. Soft-fail `mcp_tools:[]` → M8.
65
+ 10. Soft-fail missing `canonical_group_id` → M8.
66
+ 11. AI-attributed BLOCK without `--custom-message` → M11.
67
+ 12. User-attributed `--action BLOCK` without `--custom-message` → M11.
68
+ 13. 7 HTTP routing cases parametrized (200 success, 200 success_false length, 200 success_false generic, 401, 422, 5xx, network/timeout) → exit codes 0/2/2/3/2/4/4 → M5.
69
+ 14. `--yes` prints preview + skips confirm + issues create POST; assert "Resolved MCP policy" header on stdout, Name/Tools/Action rows present, create POST fires → M6, M7.
70
+ 15. `--json` suppresses preview but still POSTs create → M6.
71
+ 16. No `--yes`, readline-stubbed `'n'` → preview prints, no create POST → M6.
72
+ 17. Out-of-scope keyword `staging` + `--yes` → warning logged AND assist-mcp call fires → M9.
73
+ 18. Non-admin probe → exit 3 before assist-mcp call (defensive smoke test against `_privilegesCache` bleed across tests).
74
+ - M1, M10, M12, M13 are verified by inspection / build smoke (M1: `require('../src/lib/policy-ai-assist')` exposes new symbols AND all Phase 1 symbols; M10: existing `test/policy-conditions.test.js` + flag-path tests if any continue to pass — running `npm test` validates this; M12: `README.md` diff; M13: `PLAN-web-4887.md` diff).
75
+
76
+ ## Acceptance Criteria
77
+ 1. **M1.** Given `require('../src/lib/policy-ai-assist')`, when destructured, then the module exports `runMcpPromptCreate`, `mergeAiAndFlagsMcp`, `renderMcpPreview` AND retains every Phase 1 export (`MAX_PROMPT_LEN`, `OUT_OF_SCOPE_KEYWORDS`, `validatePromptPreflight`, `mergeAiAndFlags`, `renderTerminalPreview`, `routeBackendError`, `runTerminalPromptCreate`) verbatim.
78
+ 2. **M2.** Given `unbound policy tool create-mcp --prompt "X" --mcp-server linear` (and the three other mutex pairings `--mcp-tool`, `--mcp-action-type`, `--config`), when the command runs, then `process.exitCode === 1` AND `output.error` is called with exactly `Pass --prompt for AI-assist or the field flags for explicit creation, not both.` AND no network calls fire. Given `unbound policy tool create-mcp --prompt ""`, then `process.exitCode === 1` AND `output.error` is called with a message containing `--prompt cannot be empty.` AND no network calls fire.
79
+ 3. **M3.** Given `unbound policy tool create-mcp --prompt "audit all linear writes" --yes` and a stubbed `api.post('/api/v1/command-policies/assist-mcp/')` returning `{success:true, canonical_group_id:12, mcp_tools:['create_issue','update_issue'], name:'Audit Linear writes', description:'…', action:'AUDIT'}`, when the command runs, then the body POSTed to `/api/v1/command-policies/` equals `{policy_type:'MCP_TOOL', mcp_canonical_group_id:12, mcp_tools:['create_issue','update_issue'], name:'Audit Linear writes', description:'…', action:'AUDIT', enabled:true}` (custom_message and scope_user_group_ids absent because not supplied).
80
+ 4. **M4.** Given a stubbed assist-mcp response `{success:true, canonical_group_id:12, mcp_tools:['t1'], name:'AI Name', description:'AI desc', action:'BLOCK', custom_message:'AI msg'}` and the CLI invoked with `--prompt "..." --name "Custom" --custom-message "Override" --action AUDIT --group GroupX --disabled --yes` and `loadFormData` stubbed to return `{user_groups:[{id:7,name:'GroupX'}]}`, when the command runs, then the create-POST body has `name:'Custom'`, `action:'AUDIT'`, `custom_message:'Override'`, `enabled:false`, `scope_user_group_ids:[7]`, AND retains `mcp_canonical_group_id:12` and `mcp_tools:['t1']` from the AI response. Given the same setup but `--name " "` (whitespace-only), then the body has `name:'AI Name'` (AI value preserved, whitespace silently ignored).
81
+ 5. **M5.** Given each of 7 backend response shapes returned by the assist-mcp stub — (a) 200 + `{success:true, canonical_group_id, mcp_tools, ...}`, (b) 200 + `{success:false, error:"Input is too long (max 2000 characters)."}`, (c) 200 + `{success:false, error:"<generic>"}`, (d) `throw new FakeApiError(401, ...)`, (e) `throw new FakeApiError(422, ...)`, (f) `throw new FakeApiError(503, ...)`, (g) `throw new Error("connect ECONNREFUSED ...")` — when the command runs with `--yes`, then `process.exitCode` is `0 / 2 / 2 / 3 / 2 / 4 / 4` respectively AND `output.error` contains the verbatim wording from Phase 1's spec §6.4 matrix for that row.
82
+ 6. **M6.** Given `unbound policy tool create-mcp --prompt "audit linear writes"` without `--yes` and a stubbed successful assist-mcp response, when the command runs against a stubbed `readline.createInterface().question(_, cb)` that calls `cb('n')`, then `console.log` captures emit the header `Resolved MCP policy (from AI assist):` AND key rows for Name, Type (`MCP_TOOL`), Service, Tools (comma-joined), Action, Enabled BEFORE the prompt; AND no POST to `/api/v1/command-policies/` fires (user declined). Description and Custom message rows are omitted when their values are empty. Given `--json`, then NO "Resolved MCP policy" string appears on stdout or in `output.info`, but the create POST still fires.
83
+ 7. **M7.** Given `unbound policy tool create-mcp --prompt "audit linear writes" --yes` and a stubbed successful assist-mcp response, when the command runs, then the preview header AND key rows still print on stdout AND no readline prompt is invoked AND a POST to `/api/v1/command-policies/` fires with the merged body.
84
+ 8. **M8.** Given a stubbed assist-mcp response `{success:true, canonical_group_id:12, mcp_tools:[], name:'X', action:'AUDIT'}`, when the command runs with `--yes`, then `process.exitCode === 2` AND `output.error` contains the substring `could not match any tools` AND `unbound policy tool create-mcp --name` AND no POST to `/api/v1/command-policies/` fires. Given a stubbed assist-mcp response `{success:true, mcp_tools:['t'], name:'X', action:'AUDIT'}` (no `canonical_group_id`), the same exit code and message wording apply.
85
+ 9. **M9.** Given `unbound policy tool create-mcp --prompt "audit staging linear writes" --yes`, when the command runs, then `output.warn` is called with a message containing the substring `` `staging` `` AND a POST to `/api/v1/command-policies/assist-mcp/` is fired (does NOT exit early).
86
+ 10. **M10.** Given `unbound policy tool create-mcp --name X --mcp-server linear --mcp-tool create_issue --action AUDIT` (no `--prompt`), when the command runs against a stubbed `api.post('/api/v1/command-policies/')`, then the body POSTed equals `{name:'X', description:'', policy_type:'MCP_TOOL', mcp_server:'linear', mcp_tool:'create_issue', action:'AUDIT', enabled:true, config:{}}` — identical to today's flag-path body shape. Given `unbound policy tool create-mcp` with `--name` omitted, then the in-handler error `--name is required (or use --prompt for AI assist).` fires (replacing commander's auto-emitted `required option not specified` message).
87
+ 11. **M11.** Given the merged body has `action ∈ {BLOCK, WARN}` and no `custom_message`, when the command runs with `--yes`, then `process.exitCode === 2` AND no create POST fires AND `output.error` wording is user-attributed (`--action BLOCK requires --custom-message. ...`) when `opts.action` was passed by the user, or AI-attributed (`AI assist set --action to BLOCK ...`) when only the AI returned the action.
88
+ 12. **M12.** Given `README.md`, when read, the "Tool policy examples" MCP section's first example uses `unbound policy tool create-mcp --prompt "..."` AND a flag-based example appears below it as a documented fallback.
89
+ 13. **M13.** Given `PLAN-web-4887.md`, when read, §7 is marked "Phase 2 DELIVERED" with a cross-link to the Phase 2 commit AND §7.4 includes a note that assist-mcp has no `explanation` field AND §11.1, §12.2, §12.3 remain in the deferred list.
90
+
91
+ ## Risks & Mitigations
92
+ - **Risk:** Downgrading three `.requiredOption(...)` calls on `create-mcp` to `.option(...)` changes commander's auto-emitted error wording for users who omit `--name` / `--mcp-server` / `--action` on the flag-based path. Existing scripts that grep stderr will see different text.
93
+ - **Mitigation:** In-handler validation throws functionally equivalent messages (`"--name is required (or use --prompt for AI assist)."`, etc.) — same flag name, same imperative, additional `--prompt` hint. Document the wording change in the PR description. Phase 1 made the exact same change to `create-terminal`.
94
+ - **Risk:** The assist-mcp response has no `explanation` field (asymmetric with the terminal endpoint). A naive `renderMcpPreview` that always prints "AI assist explanation:" would emit an empty trailing line.
95
+ - **Mitigation:** Reuse Phase 1's `if (explanation) { ... }` guard pattern verbatim. Phase 2 callers pass `explanation: undefined`; the block is skipped. Forward-compatible: if backend later adds `explanation`, the same branch picks it up with zero CLI change.
96
+ - **Risk:** Soft-fail on `mcp_tools:[]` could mask a legitimate "match all tools on this service" intent. The Haiku endpoint's post-validation drops invented names; a legitimate "all writes on Linear" might survive validation as `mcp_tools:[]` if Linear has no write tools.
97
+ - **Mitigation:** Established Context #5 makes this an explicit product decision: do NOT auto-create with no tools. If backend later wants wildcard semantics, it can return `mcp_tools:['*']` or a sentinel; the CLI passes through unchanged. Surface message points users at the manual flag path for the unambiguous "all destructive tools on Linear" intent.
98
+ - **Risk:** Preview shows `Service: canonical_group_id: 12` (integer) instead of "Linear". Users seeing only the preview won't know what service they're authorizing.
99
+ - **Mitigation:** v1 limitation, explicit in Out of Scope. Post-create `displayToolPolicy(unwrapToolPolicy(data))` shows the backend's resolved `mcp_server` name in the success output, so the integer-only window is just the pre-confirmation preview. Polish follow-up: have `renderMcpPreview` accept an optional name-resolution map fetched from `/api/v1/command-policies/metadata/mcp-servers/` (one extra round trip; defer unless user feedback demands it).
100
+ - **Risk:** `runMcpPromptCreate` and `runTerminalPromptCreate` diverge subtly over time. The BLOCK/WARN guard wording is duplicated text, not a shared helper.
101
+ - **Mitigation:** Both functions explicitly call shared `validatePromptPreflight`, `loadPrivileges`, `confirmCreate`, `confirmContinue`, `routeBackendError`, `validateActionOverride`. The duplicated guard wording is ~10 lines — extract into a `assertCustomMessageForBlockWarn(body, opts)` helper if a third policy type ships with AI-assist (per spec §3, deferred).
102
+ - **Risk:** `buildHarness` in `test/policy-ai-assist-mcp.test.js` duplicates ~50 LOC of the Phase 1 harness scaffolding (module-cache invalidation, output-capture, ApiError shape).
103
+ - **Mitigation:** Duplication is intentional. A shared `test/_helpers/harness.js` would couple Phase 1 and Phase 2 test files; any harness change risks breaking Phase 1's stable test suite. Accepted trade-off: per-file harnesses, ~50 LOC duplication, full Phase 1 / Phase 2 test isolation.
104
+ - **Risk:** Cross-repo SKILL.md update on setup PR #163 lands BEFORE unbound-cli PR #54 — the skill would advertise an MCP `--prompt` path that doesn't exist yet on installed CLIs.
105
+ - **Mitigation:** Sequencing in step 7 explicitly orders PR #54 to merge first, THEN push the skill update commit to PR #163. CLI versioning ensures users who upgrade get the binary before the skill steers them to the new flag.
106
+
107
+ ## Sequencing
108
+ 1. Extend `src/lib/policy-ai-assist.js`: add `routeMcpSuccessFalse`, `mergeAiAndFlagsMcp`, `renderMcpPreview`, `runMcpPromptCreate`. Append to `module.exports`. ~250 LOC added; module reaches ~600 LOC.
109
+ 2. Modify `src/commands/policy.js` `create-mcp` registration: downgrade three `.requiredOption(...)` to `.option(...)`, add `--prompt`/`--yes` (and `--json` if absent), update flag descriptions, branch in the action handler on `opts.prompt`, update `addHelpText` to lead with `--prompt`. ~80 LOC added/modified.
110
+ 3. Write `test/policy-ai-assist-mcp.test.js` with the ~13–15 tests enumerated in Test Surface. Run `npm test` — both the new file and the existing Phase 1 suite (`test/policy-ai-assist.test.js`, `test/policy-ai-assist-preflight.test.js`, all other existing tests) must pass.
111
+ 4. Update `README.md` "Tool policy examples" MCP section to lead with `--prompt`.
112
+ 5. Update `PLAN-web-4887.md` §7 to mark Phase 2 DELIVERED with commit cross-links; append the no-`explanation`-field note at the end of §7.4. Leave §11.1 / §12.2 / §12.3 in the deferred list.
113
+ 6. Commit on `web-4887-cli-ai-assist-policy`; PR #54 updates with the Phase 2 changes.
114
+ 7. (Cross-repo, AFTER step 6 merges) Push a follow-up commit to setup PR #163: update `claude-code/skills/unbound-tool-policy/SKILL.md` — drop the "MCP not available yet" footer, update the frontmatter description to cover both terminal AND MCP policy creation via `--prompt`, add a "Creating an Unbound MCP tool policy" section mirroring the terminal one (rules: one policy per service per invocation, tool names belong in the prompt, `--group` for scoping, `--custom-message` for BLOCK/WARN, fall back to flag-based on failure). Merge PR #163 after PR #54 is in main so the skill never advertises a flag the installed CLI doesn't have.
115
+
116
+ ## Open Questions
117
+ None — all decisions are locked in Established Context. The setup PR #163 SKILL.md update is sequencing, not a question.