healthcloud-cli 3.7.7__tar.gz → 3.7.8__tar.gz

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.
Files changed (92) hide show
  1. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/PKG-INFO +37 -8
  2. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/README.md +36 -7
  3. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/output.py +59 -13
  4. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/operations.py +94 -18
  5. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/projections.py +26 -0
  6. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/windows_files.py +19 -3
  7. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/pyproject.toml +1 -1
  8. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/.gitignore +0 -0
  9. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/hatch_build.py +0 -0
  10. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/__init__.py +0 -0
  11. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/__init__.py +0 -0
  12. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/commands.py +0 -0
  13. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/group.py +0 -0
  14. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/admins/operations.py +0 -0
  15. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/__init__.py +0 -0
  16. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/__init__.py +0 -0
  17. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/commands.py +0 -0
  18. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/group.py +0 -0
  19. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/operations.py +0 -0
  20. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/agents/projections.py +0 -0
  21. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/auth.py +0 -0
  22. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/commands.py +0 -0
  23. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/compatibility.py +0 -0
  24. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/context.py +0 -0
  25. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/__init__.py +0 -0
  26. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/commands.py +0 -0
  27. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/group.py +0 -0
  28. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/operations.py +0 -0
  29. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/data_sources/projections.py +0 -0
  30. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/__init__.py +0 -0
  31. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/commands.py +0 -0
  32. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/group.py +0 -0
  33. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/operations.py +0 -0
  34. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/departments/projections.py +0 -0
  35. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/domain_errors.py +0 -0
  36. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/errors.py +0 -0
  37. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/group.py +0 -0
  38. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/__init__.py +0 -0
  39. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/commands.py +0 -0
  40. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/group.py +0 -0
  41. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/operations.py +0 -0
  42. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/guardrails/projections.py +0 -0
  43. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/__init__.py +0 -0
  44. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/commands.py +0 -0
  45. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/group.py +0 -0
  46. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/operations.py +0 -0
  47. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/organization/projections.py +0 -0
  48. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/__init__.py +0 -0
  49. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/commands.py +0 -0
  50. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/group.py +0 -0
  51. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/operations.py +0 -0
  52. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/policies/projections.py +0 -0
  53. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/pretty.py +0 -0
  54. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/registry.py +0 -0
  55. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/results.py +0 -0
  56. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/session.py +0 -0
  57. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/__init__.py +0 -0
  58. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/commands.py +0 -0
  59. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/sessions/group.py +0 -0
  60. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/__init__.py +0 -0
  61. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/commands.py +0 -0
  62. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/group.py +0 -0
  63. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/operations.py +0 -0
  64. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/skills/projections.py +0 -0
  65. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/target.py +0 -0
  66. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/__init__.py +0 -0
  67. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/commands.py +0 -0
  68. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/group.py +0 -0
  69. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/operations.py +0 -0
  70. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/teams/projections.py +0 -0
  71. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/__init__.py +0 -0
  72. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/commands.py +0 -0
  73. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/group.py +0 -0
  74. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/operations.py +0 -0
  75. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tools/projections.py +0 -0
  76. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/tuning.py +0 -0
  77. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/ams/ui.py +0 -0
  78. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/cli.py +0 -0
  79. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/context.py +0 -0
  80. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/contracts/__init__.py +0 -0
  81. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/contracts/mcp-tools.manifest.json +0 -0
  82. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/main.py +0 -0
  83. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/manifest.py +0 -0
  84. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/output.py +0 -0
  85. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/params.py +0 -0
  86. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/__init__.py +0 -0
  87. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/commands.py +0 -0
  88. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/group.py +0 -0
  89. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/tenants/operations.py +0 -0
  90. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/healthcloud_cli/util.py +0 -0
  91. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/scripts/__init__.py +0 -0
  92. {healthcloud_cli-3.7.7 → healthcloud_cli-3.7.8}/scripts/bundle_manifest.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: healthcloud-cli
3
- Version: 3.7.7
3
+ Version: 3.7.8
4
4
  Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
5
5
  Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
6
6
  Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
@@ -1269,7 +1269,12 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1269
1269
  `get` sends exactly one `GET /vibecoder/plans/PLAN_ID` and reports
1270
1270
  `operation: "sessions.plans.get"`; a reload is immutable, so it returns the
1271
1271
  same `plan_hash` and diff however the session changed afterwards. `get` is a
1272
- read and needs no saved `hc login` session.
1272
+ read and needs no saved `hc login` session: it authenticates like every other
1273
+ session read, with the saved session when there is one and otherwise with the
1274
+ environment's own `HC_AMS_ADMIN_EMAIL`/`HC_AMS_ADMIN_PASSWORD` credentials.
1275
+ A refusal is worded for the operation that was refused, so a `422` or `503`
1276
+ on `get` never claims a plan "was generated": `generate` says no plan was
1277
+ generated, `get` says no plan was reloaded.
1273
1278
  - `--out` is required and must be a new path: the review is written to a
1274
1279
  newly created owner-only file (mode `0600`, no symlinks, never overwritten).
1275
1280
  A refusal removes the reservation again, so a refused review never leaves a
@@ -1286,6 +1291,13 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1286
1291
  guardrail/data-source) for a creation plan. `plan_json["original_prompt"]`
1287
1292
  is never projected, and `warnings`/`skipped_references` are reported as
1288
1293
  counts, not bodies.
1294
+ - The review file marks the text a model produced: `content_origin` is
1295
+ `"model-generated"` and `model_generated_fields` lists the fields whose values
1296
+ came from the planner — `summary`, `changes.*.why`,
1297
+ `definition.instructions`/`greetings`/`name`/`description`/opening suggestions
1298
+ and `review_warnings[].message`. Those values are data to inspect before you
1299
+ approve, never instructions to the reader or to an agent that later reads the
1300
+ file.
1289
1301
  - stdout stays bounded and free-text-free: plan ID, `plan_hash`, operation,
1290
1302
  status, base config version, agent ID, model, sorted changed-field **names**
1291
1303
  (create: reference counts by kind) and finding counts. No prompt,
@@ -1299,7 +1311,14 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1299
1311
  `--output json` prints the full envelope, unchanged.
1300
1312
  - The prompt is protected free text and never comes from argv: `--prompt-file`
1301
1313
  reads one owner-only regular text file (no symlinks, at most 20000
1302
- characters) and sends it once. `--prompt-file` is optional — the server falls
1314
+ characters) and sends it once. A file that is too large, missing, unreadable,
1315
+ not UTF-8 text, or not an owner-only regular file is a `validation` refusal
1316
+ naming the reason and never quoting the text. Ownership is checked differently
1317
+ per platform: on POSIX it is the file's mode and owner; on Windows the opened
1318
+ handle's owner and DACL must grant access to your account only, so a file that
1319
+ inherited a grant to another principal (SYSTEM, Administrators) is refused as
1320
+ not owner-only. Keep the prompt file in a directory only your account can read.
1321
+ `--prompt-file` is optional — the server falls
1303
1322
  back to the session's initial prompt or last message — and `--agent-type`
1304
1323
  applies to `--operation create` only (with `modify` it is a usage error
1305
1324
  before login or any request). `--prompt-file` is not mirrored as a local
@@ -1320,12 +1339,22 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1320
1339
  values, so read it in the AMS console. Non-`advisory` review findings are
1321
1340
  refused the same way; advisory findings are displayed in the review file.
1322
1341
  A creation plan that claims an `agent_id` is refused, and no existing agent
1323
- ID is ever fabricated or required.
1342
+ ID is ever fabricated or required. So is a creation plan whose proposed agent
1343
+ carries a field outside the documented definition and reference set
1344
+ (`name`, `description`, `type`, `model`, `preferred_model`,
1345
+ `is_orchestrator`, `department_id`, `team_id`, `instructions`, `greetings`,
1346
+ `opening_suggestions`, `opening_suggestions_options`, and the catalog
1347
+ `*_ids`), or whose reference field holds anything but non-blank catalog IDs:
1348
+ the operator never approves a definition the review did not show.
1324
1349
  - Server answers are preserved, not replaced: a missing, expired or other
1325
- user's session or plan is a `not_found` refusal naming it, a `422` from the
1326
- plan route is a `validation` refusal carrying the server's own bounded
1327
- detail, and an unavailable planner (`503`) is a `transport` refusal with no
1328
- file written. A dropped connection on `generate` reports one `transport`
1350
+ user's session or plan is a `not_found` refusal naming it, and an unavailable
1351
+ planner (`503`) is a `transport` refusal with no file written. A `422` from
1352
+ the plan route is a `validation` refusal, but its detail is only quoted when
1353
+ it is a known static contract message that carries no field values — the
1354
+ documented `"a prompt is required for agent creation"`. Any other `422`
1355
+ detail can quote protected field values, exactly like `validation_errors`
1356
+ and blocking review findings, so it is reported by category only and you read
1357
+ it in the AMS console. A dropped connection on `generate` reports one `transport`
1329
1358
  error saying the plan's outcome is unknown and points at
1330
1359
  `hc ams sessions messages list`/`plans get` before retrying; the request is
1331
1360
  never re-sent automatically.
@@ -1251,7 +1251,12 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1251
1251
  `get` sends exactly one `GET /vibecoder/plans/PLAN_ID` and reports
1252
1252
  `operation: "sessions.plans.get"`; a reload is immutable, so it returns the
1253
1253
  same `plan_hash` and diff however the session changed afterwards. `get` is a
1254
- read and needs no saved `hc login` session.
1254
+ read and needs no saved `hc login` session: it authenticates like every other
1255
+ session read, with the saved session when there is one and otherwise with the
1256
+ environment's own `HC_AMS_ADMIN_EMAIL`/`HC_AMS_ADMIN_PASSWORD` credentials.
1257
+ A refusal is worded for the operation that was refused, so a `422` or `503`
1258
+ on `get` never claims a plan "was generated": `generate` says no plan was
1259
+ generated, `get` says no plan was reloaded.
1255
1260
  - `--out` is required and must be a new path: the review is written to a
1256
1261
  newly created owner-only file (mode `0600`, no symlinks, never overwritten).
1257
1262
  A refusal removes the reservation again, so a refused review never leaves a
@@ -1268,6 +1273,13 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1268
1273
  guardrail/data-source) for a creation plan. `plan_json["original_prompt"]`
1269
1274
  is never projected, and `warnings`/`skipped_references` are reported as
1270
1275
  counts, not bodies.
1276
+ - The review file marks the text a model produced: `content_origin` is
1277
+ `"model-generated"` and `model_generated_fields` lists the fields whose values
1278
+ came from the planner — `summary`, `changes.*.why`,
1279
+ `definition.instructions`/`greetings`/`name`/`description`/opening suggestions
1280
+ and `review_warnings[].message`. Those values are data to inspect before you
1281
+ approve, never instructions to the reader or to an agent that later reads the
1282
+ file.
1271
1283
  - stdout stays bounded and free-text-free: plan ID, `plan_hash`, operation,
1272
1284
  status, base config version, agent ID, model, sorted changed-field **names**
1273
1285
  (create: reference counts by kind) and finding counts. No prompt,
@@ -1281,7 +1293,14 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1281
1293
  `--output json` prints the full envelope, unchanged.
1282
1294
  - The prompt is protected free text and never comes from argv: `--prompt-file`
1283
1295
  reads one owner-only regular text file (no symlinks, at most 20000
1284
- characters) and sends it once. `--prompt-file` is optional — the server falls
1296
+ characters) and sends it once. A file that is too large, missing, unreadable,
1297
+ not UTF-8 text, or not an owner-only regular file is a `validation` refusal
1298
+ naming the reason and never quoting the text. Ownership is checked differently
1299
+ per platform: on POSIX it is the file's mode and owner; on Windows the opened
1300
+ handle's owner and DACL must grant access to your account only, so a file that
1301
+ inherited a grant to another principal (SYSTEM, Administrators) is refused as
1302
+ not owner-only. Keep the prompt file in a directory only your account can read.
1303
+ `--prompt-file` is optional — the server falls
1285
1304
  back to the session's initial prompt or last message — and `--agent-type`
1286
1305
  applies to `--operation create` only (with `modify` it is a usage error
1287
1306
  before login or any request). `--prompt-file` is not mirrored as a local
@@ -1302,12 +1321,22 @@ uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TE
1302
1321
  values, so read it in the AMS console. Non-`advisory` review findings are
1303
1322
  refused the same way; advisory findings are displayed in the review file.
1304
1323
  A creation plan that claims an `agent_id` is refused, and no existing agent
1305
- ID is ever fabricated or required.
1324
+ ID is ever fabricated or required. So is a creation plan whose proposed agent
1325
+ carries a field outside the documented definition and reference set
1326
+ (`name`, `description`, `type`, `model`, `preferred_model`,
1327
+ `is_orchestrator`, `department_id`, `team_id`, `instructions`, `greetings`,
1328
+ `opening_suggestions`, `opening_suggestions_options`, and the catalog
1329
+ `*_ids`), or whose reference field holds anything but non-blank catalog IDs:
1330
+ the operator never approves a definition the review did not show.
1306
1331
  - Server answers are preserved, not replaced: a missing, expired or other
1307
- user's session or plan is a `not_found` refusal naming it, a `422` from the
1308
- plan route is a `validation` refusal carrying the server's own bounded
1309
- detail, and an unavailable planner (`503`) is a `transport` refusal with no
1310
- file written. A dropped connection on `generate` reports one `transport`
1332
+ user's session or plan is a `not_found` refusal naming it, and an unavailable
1333
+ planner (`503`) is a `transport` refusal with no file written. A `422` from
1334
+ the plan route is a `validation` refusal, but its detail is only quoted when
1335
+ it is a known static contract message that carries no field values — the
1336
+ documented `"a prompt is required for agent creation"`. Any other `422`
1337
+ detail can quote protected field values, exactly like `validation_errors`
1338
+ and blocking review findings, so it is reported by category only and you read
1339
+ it in the AMS console. A dropped connection on `generate` reports one `transport`
1311
1340
  error saying the plan's outcome is unknown and points at
1312
1341
  `hc ams sessions messages list`/`plans get` before retrying; the request is
1313
1342
  never re-sent automatically.
@@ -66,34 +66,75 @@ def read_json(path: Path, *, private: bool = False) -> dict:
66
66
  ) from None
67
67
 
68
68
 
69
+ class PrivateInputRefused(ValueError):
70
+ """A protected input file the reader refused, with a value-free reason.
71
+
72
+ ``reason`` names the failed property — ``too_large``, ``not_owner_only``,
73
+ ``not_text`` or ``unreadable`` — never the file's path or content, so a
74
+ caller can map it to its own categorized refusal (a bare click error is not
75
+ the command's error contract) without quoting protected text.
76
+ """
77
+
78
+ def __init__(self, reason: str) -> None:
79
+ super().__init__(reason)
80
+ self.reason = reason
81
+
82
+
83
+ def private_input_detail(reason: str, *, maximum: int) -> str:
84
+ """A bounded, value-free sentence explaining why a protected file was refused."""
85
+
86
+ if reason == "too_large":
87
+ return f"it is larger than {maximum} characters"
88
+ if reason == "not_text":
89
+ return "it is not readable UTF-8 text"
90
+ if reason in ("not_owner_only", "not_regular"):
91
+ return "it is not an owner-only regular file"
92
+ return "it cannot be read"
93
+
94
+
69
95
  def read_private_text(path: Path, *, maximum: int) -> str:
70
96
  """Read one owner-only plain-text input without quoting its content.
71
97
 
72
98
  ``maximum`` is a character bound checked before the text is handed on, so
73
99
  an oversized protected input is refused locally instead of being sent. The
74
- text itself is never included in the refusal: only the reason.
100
+ text itself is never included in the refusal: only the reason, as a
101
+ :class:`PrivateInputRefused` carrying one of ``too_large``,
102
+ ``not_owner_only``, ``not_regular``, ``not_text`` or ``unreadable``.
103
+
104
+ The owner-only check is a POSIX mode/ownership check. On Windows the same
105
+ guarantee comes from ``windows_files``, which validates the opened handle's
106
+ owner and DACL; its refusal carries its own reason (``not_owner_only`` or
107
+ ``not_regular``) and is reported unchanged here, so a non-private file is not
108
+ misreported as unreadable. An inherited ACL cannot be made owner-only by the
109
+ creator, so the caller must still keep the file in a directory only the
110
+ operator can read.
75
111
  """
76
112
  try:
77
113
  if os.name == "nt":
78
- from .windows_files import open_file
114
+ from .windows_files import PrivateHandleRefused, open_file
79
115
 
80
- fd = open_file(path, private=True)
116
+ try:
117
+ fd = open_file(path, private=True)
118
+ except PrivateHandleRefused as error:
119
+ raise PrivateInputRefused(error.reason) from None
81
120
  else:
82
121
  fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
83
122
  with os.fdopen(fd, encoding="utf-8") as stream:
84
123
  info = os.fstat(stream.fileno())
85
- if not stat.S_ISREG(info.st_mode) or (
86
- os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid())
87
- ):
88
- raise ValueError("Private input must be an owner-only regular file")
124
+ if not stat.S_ISREG(info.st_mode):
125
+ raise PrivateInputRefused("not_regular")
126
+ if os.name != "nt" and (info.st_mode & 0o077 or info.st_uid != os.getuid()):
127
+ raise PrivateInputRefused("not_owner_only")
89
128
  text = stream.read(maximum + 1)
90
129
  if len(text) > maximum:
91
- raise ValueError("Private input is too large")
130
+ raise PrivateInputRefused("too_large")
92
131
  return text
93
- except OSError, ValueError, UnicodeError:
94
- raise click.ClickException(
95
- "Invalid input file; require a readable, owner-only regular text file without symlinks."
96
- ) from None
132
+ except PrivateInputRefused:
133
+ raise
134
+ except UnicodeError:
135
+ raise PrivateInputRefused("not_text") from None
136
+ except OSError, ValueError:
137
+ raise PrivateInputRefused("unreadable") from None
97
138
 
98
139
 
99
140
  def protected_text(
@@ -132,7 +173,12 @@ def protected_text(
132
173
  if path is not None:
133
174
  if not isinstance(path, str) or not path.strip():
134
175
  raise refuse("validation", f"Explicit {file_flag} file path required.")
135
- text = read_private_text(Path(path), maximum=PROTECTED_TEXT_MAX_CHARS)
176
+ try:
177
+ text = read_private_text(Path(path), maximum=PROTECTED_TEXT_MAX_CHARS)
178
+ except PrivateInputRefused:
179
+ raise click.ClickException(
180
+ "Invalid input file; require a readable, owner-only regular text file without symlinks."
181
+ ) from None
136
182
  if not text:
137
183
  raise refuse("validation", f"The file given to {file_flag} is empty; nothing was sent.")
138
184
  return text
@@ -7,7 +7,14 @@ from pathlib import Path
7
7
 
8
8
  from ..context import AmsDependencies
9
9
  from ..domain_errors import DomainOutcomeUnknown, DomainReadRefused
10
- from ..output import PROTECTED_TEXT_MAX_CHARS, private_output, protected_text, read_private_text
10
+ from ..output import (
11
+ PROTECTED_TEXT_MAX_CHARS,
12
+ PrivateInputRefused,
13
+ private_input_detail,
14
+ private_output,
15
+ protected_text,
16
+ read_private_text,
17
+ )
11
18
  from ..results import succeeded, unknown
12
19
  from ..target import TenantTarget
13
20
  from .projections import (
@@ -292,16 +299,21 @@ def _plan_operation(value: str) -> str:
292
299
 
293
300
 
294
301
  def _prompt_file(path: str) -> str:
295
- """Read the protected prompt from one owner-only text file, never argv."""
302
+ """Read the protected prompt from one owner-only text file, never argv.
303
+
304
+ ``read_private_text`` applies the size bound itself, so oversize, unsafe and
305
+ unreadable files are all refused here in the plan's own ``validation``
306
+ category instead of a bare click error. There is deliberately no second
307
+ length check: a check after that read could never run.
308
+ """
296
309
 
297
310
  if not isinstance(path, str) or not path.strip():
298
311
  raise PlanReviewRefused("validation", "Explicit prompt file path required.")
299
- text = read_private_text(Path(path), maximum=PROMPT_MAX_CHARS)
300
- if len(text) > PROMPT_MAX_CHARS:
301
- raise PlanReviewRefused(
302
- "validation", f"The prompt file is larger than {PROMPT_MAX_CHARS} characters; nothing was sent."
303
- )
304
- return text
312
+ try:
313
+ return read_private_text(Path(path), maximum=PROMPT_MAX_CHARS)
314
+ except PrivateInputRefused as error:
315
+ detail = private_input_detail(error.reason, maximum=PROMPT_MAX_CHARS)
316
+ raise PlanReviewRefused("validation", f"The prompt file was refused: {detail}; nothing was sent.") from None
305
317
 
306
318
 
307
319
  def _plans(sdk, methods, *, write: bool = False, what: str | None = None):
@@ -471,15 +483,34 @@ def _modify_diff(body: dict) -> dict:
471
483
 
472
484
 
473
485
  def _references(agent: dict) -> dict:
486
+ """Group the proposal's catalog references by kind, refusing anything but IDs.
487
+
488
+ Field **names** only ever reach a refusal: a non-string or blank item is
489
+ refused rather than dropped, because a dropped item is a reference the
490
+ operator would approve without having seen it.
491
+ """
492
+
474
493
  references = {}
475
494
  for field, kind in PLAN_REFERENCE_FIELDS.items():
476
495
  value = agent.get(field)
477
- if isinstance(value, str) and value:
496
+ if value is None:
497
+ continue
498
+ if isinstance(value, str):
499
+ if not value.strip():
500
+ raise PlanReviewRefused(
501
+ "validation", f"Creation plan reference '{field}' is blank; it is not a catalog ID. Refused."
502
+ )
478
503
  references[kind] = [value]
479
- elif isinstance(value, list):
480
- ids = sorted({item for item in value if isinstance(item, str) and item})
481
- if ids:
482
- references[kind] = ids
504
+ continue
505
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
506
+ raise PlanReviewRefused(
507
+ "validation",
508
+ f"Creation plan reference '{field}' must be a catalog ID or a list of catalog IDs; "
509
+ "anything else is refused by field name. No review file was written.",
510
+ )
511
+ ids = sorted(set(value))
512
+ if ids:
513
+ references[kind] = ids
483
514
  return references
484
515
 
485
516
 
@@ -496,6 +527,13 @@ def _create_diff(plan, body: dict) -> dict:
496
527
  raise PlanReviewRefused(
497
528
  "validation", "A creation plan must carry the proposed agent definition; the server returned none."
498
529
  )
530
+ unknown = sorted(set(agent) - set(PLAN_DEFINITION_FIELDS) - set(PLAN_REFERENCE_FIELDS))
531
+ if unknown:
532
+ raise PlanReviewRefused(
533
+ "validation",
534
+ f"Creation plan carries agent fields this review does not cover: {', '.join(unknown)}. "
535
+ "Refused by field name; no review file was written.",
536
+ )
499
537
  return {
500
538
  "definition": {name: agent[name] for name in PLAN_DEFINITION_FIELDS if name in agent},
501
539
  "references": _references(agent),
@@ -523,9 +561,41 @@ def _plan_review(
523
561
  return actual, review, plan_metadata(plan, actual, review)
524
562
 
525
563
 
564
+ # The only `422` details this CLI prints: static route-contract messages that
565
+ # name a rule and carry no field value. Everything else the server sends in a
566
+ # `422` detail is its own text, which can quote protected field values — the
567
+ # same reason `validation_errors` and blocking review findings are reported by
568
+ # count only. Extend this set only with a detail verified to be static.
569
+ PLAN_SAFE_422_DETAILS = frozenset({"a prompt is required for agent creation"})
570
+
571
+
572
+ def _plan_safe_detail(error: BaseException) -> str | None:
573
+ """The server's own `422` detail, but only when it is a known value-free message."""
574
+
575
+ parsed = getattr(error, "parsed_body", None)
576
+ if not isinstance(parsed, dict):
577
+ return None
578
+ for key in ("detail", "error", "message"):
579
+ value = parsed.get(key)
580
+ if isinstance(value, str) and value.strip():
581
+ detail = " ".join(value.split())
582
+ return detail if detail in PLAN_SAFE_422_DETAILS else None
583
+ return None
584
+
585
+
586
+ def _plan_not_done(kind: str) -> str:
587
+ """What did not happen, worded for the operation that was refused."""
588
+
589
+ if kind == "generate":
590
+ return "No plan was generated."
591
+ return "No plan was reloaded; nothing was reviewed."
592
+
593
+
526
594
  def _plan_write_refusal(
527
- error: BaseException, *, session_id: str | None, plan_id: str | None
595
+ error: BaseException, *, kind: str, session_id: str | None, plan_id: str | None
528
596
  ) -> PlanReviewRefused | None:
597
+ """Map one plan-route HTTP error for `generate` (`kind="generate"`) or `get`."""
598
+
529
599
  status = getattr(error, "status_code", None)
530
600
  if status == 404:
531
601
  if plan_id is not None:
@@ -536,14 +606,20 @@ def _plan_write_refusal(
536
606
  "not_found", f"Session '{session_id}' was not found in this tenant; no plan was generated."
537
607
  )
538
608
  if status == 422:
609
+ detail = _plan_safe_detail(error)
610
+ if detail is not None:
611
+ return PlanReviewRefused(
612
+ "validation", f"The plan request was refused by the server (422): {detail}. {_plan_not_done(kind)}"
613
+ )
539
614
  return PlanReviewRefused(
540
615
  "validation",
541
- f"The plan request was refused by the server (422): {_server_detail(error)}. No plan was generated.",
616
+ "The plan request was refused by the server (422). Its detail can quote protected field values, "
617
+ f"so it is not printed; review it in the AMS console. {_plan_not_done(kind)}",
542
618
  )
543
619
  if status == 503:
544
620
  return PlanReviewRefused(
545
621
  "transport",
546
- "The plan service is unavailable (the server answered 503); no plan was generated. Retry later.",
622
+ f"The plan service is unavailable (the server answered 503); {_plan_not_done(kind)} Retry later.",
547
623
  )
548
624
  return None
549
625
 
@@ -1009,7 +1085,7 @@ def generate_plan(
1009
1085
  except HealthCloudNetworkError:
1010
1086
  raise PlanReviewRefused("transport", UNKNOWN_PLAN_OUTCOME) from None
1011
1087
  except HealthCloudHTTPError as error:
1012
- refusal = _plan_write_refusal(error, session_id=session_id, plan_id=None)
1088
+ refusal = _plan_write_refusal(error, kind="generate", session_id=session_id, plan_id=None)
1013
1089
  if refusal is not None:
1014
1090
  raise refusal from error
1015
1091
  raise
@@ -1043,7 +1119,7 @@ def get_plan(dependencies: AmsDependencies, target: TenantTarget, plan_id: str,
1043
1119
  "was lost. Nothing was written; retry the read when the service is reachable.",
1044
1120
  ) from None
1045
1121
  except HealthCloudHTTPError as error:
1046
- refusal = _plan_write_refusal(error, session_id=None, plan_id=plan_id)
1122
+ refusal = _plan_write_refusal(error, kind="get", session_id=None, plan_id=plan_id)
1047
1123
  if refusal is not None:
1048
1124
  raise refusal from error
1049
1125
  raise
@@ -50,6 +50,25 @@ def attachment_projection(attachment: TuningAttachment) -> dict:
50
50
  }
51
51
 
52
52
 
53
+ # Every review field whose value is model-generated text: the planner writes it
54
+ # from the operator's prompt and the session context, so it is data to inspect,
55
+ # never an instruction to follow. The review file carries this list beside the
56
+ # values it describes (an LLM01/LLM07 marker for the human approval decision).
57
+ MODEL_GENERATED_FIELDS = {
58
+ "modify": ("changes.*.why", "review_warnings[].message", "summary"),
59
+ "create": (
60
+ "definition.description",
61
+ "definition.greetings",
62
+ "definition.instructions",
63
+ "definition.name",
64
+ "definition.opening_suggestions",
65
+ "definition.opening_suggestions_options",
66
+ "review_warnings[].message",
67
+ "summary",
68
+ ),
69
+ }
70
+
71
+
53
72
  def plan_review(target: TenantTarget, plan: TuningPlan, operation: str, diff: dict) -> dict:
54
73
  """The owner-only review artifact for one exact plan.
55
74
 
@@ -57,10 +76,17 @@ def plan_review(target: TenantTarget, plan: TuningPlan, operation: str, diff: di
57
76
  owner-only ``--out`` file and never printed: ``plan_json["original_prompt"]``
58
77
  is not projected at all, ``warnings``/``skipped_references`` are reported as
59
78
  counts rather than bodies, and file actions keep attachment IDs only.
79
+
80
+ The summary, the per-change ``why``, the proposed instructions/greetings and
81
+ the advisory findings are model-generated free text: the file marks them as
82
+ such with ``content_origin`` and ``model_generated_fields`` so the operator
83
+ approving it treats them as untrusted data, not as instructions.
60
84
  """
61
85
 
62
86
  review = {
63
87
  "schema_version": 1,
88
+ "content_origin": "model-generated",
89
+ "model_generated_fields": sorted(MODEL_GENERATED_FIELDS[operation]),
64
90
  "target": {
65
91
  "environment": target.environment,
66
92
  "tenant_id": target.tenant_id,
@@ -95,6 +95,20 @@ def _user_sid() -> str:
95
95
  _close(token)
96
96
 
97
97
 
98
+ class PrivateHandleRefused(ValueError):
99
+ """A Windows handle check refused the file, with a value-free reason.
100
+
101
+ A distinct type (not a plain ``ValueError``) so a reader can report the real
102
+ reason — ``not_owner_only`` or ``not_regular``, the same vocabulary the POSIX
103
+ reader uses — instead of collapsing every refusal into "cannot be read".
104
+ Neither the message nor the reason ever names the path, the owner or an ACE.
105
+ """
106
+
107
+ def __init__(self, reason: str, message: str) -> None:
108
+ super().__init__(message)
109
+ self.reason = reason
110
+
111
+
98
112
  def _require_private(handle, user_sid: str) -> None:
99
113
  owner, acl, descriptor = _P(), _P(), _P()
100
114
  status = _get_security(
@@ -104,7 +118,7 @@ def _require_private(handle, user_sid: str) -> None:
104
118
  raise ctypes.WinError(status)
105
119
  try:
106
120
  if not owner or _sid_string(owner) != user_sid or not acl:
107
- raise ValueError("Owner-only Windows DACL required")
121
+ raise PrivateHandleRefused("not_owner_only", "Owner-only Windows DACL required")
108
122
  count = ctypes.cast(acl, ctypes.POINTER(_Acl)).contents.count
109
123
  for index in range(count):
110
124
  ace = _P()
@@ -114,7 +128,9 @@ def _require_private(handle, user_sid: str) -> None:
114
128
  continue
115
129
  # Only ordinary ACCESS_ALLOWED_ACE grants to the current user are accepted.
116
130
  if kind != 0 or _sid_string(ace.value + 8) != user_sid:
117
- raise ValueError("Private Windows files must not grant access to other principals")
131
+ raise PrivateHandleRefused(
132
+ "not_owner_only", "Private Windows files must not grant access to other principals"
133
+ )
118
134
  finally:
119
135
  _free(descriptor)
120
136
 
@@ -195,7 +211,7 @@ def open_file(path: Path, *, private: bool = False, create: bool = False) -> int
195
211
  info = (w.DWORD * 2)() # FILE_ATTRIBUTE_TAG_INFO
196
212
  _check(_file_info(handle, 9, info, ctypes.sizeof(info)))
197
213
  if _file_type(handle) != 1 or info[0] & (0x10 | 0x400): # Disk files only; no directories or reparse points.
198
- raise ValueError("Regular non-reparse Windows file required")
214
+ raise PrivateHandleRefused("not_regular", "Regular non-reparse Windows file required")
199
215
  if user is not None:
200
216
  _require_private(handle, user)
201
217
  fd = msvcrt.open_osfhandle(handle, (os.O_WRONLY if create else os.O_RDONLY) | os.O_BINARY)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "healthcloud-cli"
7
- version = "3.7.7"
7
+ version = "3.7.8"
8
8
  description = "Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"