healthcloud-cli 3.6.0__tar.gz → 3.7.1__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.6.0 → healthcloud_cli-3.7.1}/.gitignore +2 -0
  2. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/PKG-INFO +84 -21
  3. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/README.md +83 -20
  4. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/departments/operations.py +74 -5
  5. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/output.py +56 -0
  6. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/policies/commands.py +29 -5
  7. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/policies/operations.py +110 -6
  8. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/results.py +53 -0
  9. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/sessions/commands.py +30 -4
  10. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/sessions/operations.py +32 -7
  11. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/skills/operations.py +58 -4
  12. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/teams/operations.py +58 -4
  13. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/contracts/mcp-tools.manifest.json +207 -3
  14. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/pyproject.toml +1 -1
  15. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/hatch_build.py +0 -0
  16. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/__init__.py +0 -0
  17. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/admins/__init__.py +0 -0
  18. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/admins/commands.py +0 -0
  19. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/admins/group.py +0 -0
  20. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/admins/operations.py +0 -0
  21. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/__init__.py +0 -0
  22. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/agents/__init__.py +0 -0
  23. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/agents/commands.py +0 -0
  24. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/agents/group.py +0 -0
  25. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/agents/operations.py +0 -0
  26. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/agents/projections.py +0 -0
  27. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/auth.py +0 -0
  28. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/commands.py +0 -0
  29. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/compatibility.py +0 -0
  30. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/context.py +0 -0
  31. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/data_sources/__init__.py +0 -0
  32. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/data_sources/commands.py +0 -0
  33. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/data_sources/group.py +0 -0
  34. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/data_sources/operations.py +0 -0
  35. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/data_sources/projections.py +0 -0
  36. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/departments/__init__.py +0 -0
  37. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/departments/commands.py +0 -0
  38. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/departments/group.py +0 -0
  39. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/departments/projections.py +0 -0
  40. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/domain_errors.py +0 -0
  41. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/errors.py +0 -0
  42. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/group.py +0 -0
  43. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/guardrails/__init__.py +0 -0
  44. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/guardrails/commands.py +0 -0
  45. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/guardrails/group.py +0 -0
  46. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/guardrails/operations.py +0 -0
  47. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/guardrails/projections.py +0 -0
  48. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/organization/__init__.py +0 -0
  49. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/organization/commands.py +0 -0
  50. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/organization/group.py +0 -0
  51. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/organization/operations.py +0 -0
  52. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/organization/projections.py +0 -0
  53. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/policies/__init__.py +0 -0
  54. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/policies/group.py +0 -0
  55. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/policies/projections.py +0 -0
  56. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/pretty.py +0 -0
  57. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/registry.py +0 -0
  58. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/session.py +0 -0
  59. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/sessions/__init__.py +0 -0
  60. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/sessions/group.py +0 -0
  61. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/sessions/projections.py +0 -0
  62. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/skills/__init__.py +0 -0
  63. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/skills/commands.py +0 -0
  64. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/skills/group.py +0 -0
  65. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/skills/projections.py +0 -0
  66. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/target.py +0 -0
  67. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/teams/__init__.py +0 -0
  68. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/teams/commands.py +0 -0
  69. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/teams/group.py +0 -0
  70. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/teams/projections.py +0 -0
  71. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tools/__init__.py +0 -0
  72. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tools/commands.py +0 -0
  73. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tools/group.py +0 -0
  74. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tools/operations.py +0 -0
  75. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tools/projections.py +0 -0
  76. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/tuning.py +0 -0
  77. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/ui.py +0 -0
  78. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/ams/windows_files.py +0 -0
  79. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/cli.py +0 -0
  80. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/context.py +0 -0
  81. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/contracts/__init__.py +0 -0
  82. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/main.py +0 -0
  83. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/manifest.py +0 -0
  84. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/output.py +0 -0
  85. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/params.py +0 -0
  86. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/tenants/__init__.py +0 -0
  87. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/tenants/commands.py +0 -0
  88. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/tenants/group.py +0 -0
  89. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/tenants/operations.py +0 -0
  90. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/healthcloud_cli/util.py +0 -0
  91. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/scripts/__init__.py +0 -0
  92. {healthcloud_cli-3.6.0 → healthcloud_cli-3.7.1}/scripts/bundle_manifest.py +0 -0
@@ -260,3 +260,5 @@ integration-tests/shared/test-assets/
260
260
  .ralph/
261
261
  /PROMPT.md
262
262
  .blind-peer-review/
263
+ # Terraform CI runner-local diagnostics, inputs and sensitive plan bundles.
264
+ /.kb-ci/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: healthcloud-cli
3
- Version: 3.6.0
3
+ Version: 3.7.1
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
@@ -751,10 +751,14 @@ uv run --frozen hc ams departments delete --environment dev --tenant-id TENANT_I
751
751
  reports a `validation` refusal that names the department, the tenant that
752
752
  received it, and tells you to run `hc ams departments get` — it never claims
753
753
  a landed write was a stopped no-op.
754
- - If the transport times out or the connection drops mid-write, the write's
755
- outcome is unknown: the CLI reports one `transport` error whose message says
756
- the outcome is unknown and to check `hc ams departments get` before
757
- retrying. It never re-sends the write on its own.
754
+ - If the transport times out, the connection drops mid-write, or the server
755
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
756
+ unknown: the CLI exits 1 with an `unknown` envelope
757
+ (`verification.saved_state: unknown`, `verification.side_effects:
758
+ unverified`, `data.mutation_replayed: false`, and
759
+ `data.reconciliation.status: not_read`) whose message says the outcome is
760
+ unknown and to check `hc ams departments get` before retrying. It never
761
+ re-sends the write and never re-reads on its own.
758
762
 
759
763
  Read-only team listing and inspection are also complete and unpaged. The bounded
760
764
  projection omits descriptions and creator identities; it includes evaluation-suite
@@ -797,10 +801,14 @@ uv run --frozen hc ams teams delete --environment dev --tenant-id TENANT_ID --
797
801
  `validation` refusal that reports the landed write truthfully: it names the
798
802
  team and the tenant that actually received it and points to `hc ams teams
799
803
  get` — it never claims a landed write was stopped.
800
- - If the transport times out or the connection drops mid-write, the write's
801
- outcome is unknown: the CLI reports one `transport` error whose message says
802
- the outcome is unknown and to check `hc ams teams get` before retrying. It
803
- never re-sends the write on its own.
804
+ - If the transport times out, the connection drops mid-write, or the server
805
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
806
+ unknown: the CLI exits 1 with an `unknown` envelope
807
+ (`verification.saved_state: unknown`, `verification.side_effects:
808
+ unverified`, `data.mutation_replayed: false`, and
809
+ `data.reconciliation.status: not_read`) whose message says the outcome is
810
+ unknown and to check `hc ams teams get` before retrying. It never re-sends
811
+ the write and never re-reads on its own.
804
812
 
805
813
  The organization hierarchy is the backend-built department/team/agent tree. The
806
814
  CLI preserves its order and nesting without issuing entity reads, joining records,
@@ -862,10 +870,14 @@ uv run --frozen hc ams skills delete --environment dev --tenant-id TENANT_ID --s
862
870
  landed write truthfully: it names the skill and the tenant that actually
863
871
  received it and points to `hc ams skills get` — it never claims a landed
864
872
  write was stopped.
865
- - If the transport times out or the connection drops mid-write, the write's
866
- outcome is unknown: the CLI reports one `transport` error whose message says
867
- the outcome is unknown and to check `hc ams skills get` before retrying. It
868
- never re-sends the write on its own.
873
+ - If the transport times out, the connection drops mid-write, or the server
874
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
875
+ unknown: the CLI exits 1 with an `unknown` envelope
876
+ (`verification.saved_state: unknown`, `verification.side_effects:
877
+ unverified`, `data.mutation_replayed: false`, and
878
+ `data.reconciliation.status: not_read`) whose message says the outcome is
879
+ unknown and to check `hc ams skills get` before retrying. It never re-sends
880
+ the write and never re-reads on its own.
869
881
 
870
882
  Read-only policy catalog listing and inspection are complete and unpaged. The
871
883
  bounded projection includes only policy IDs, names, department IDs, guardrail
@@ -897,8 +909,8 @@ Policies have an `active` lifecycle flag, so `activate` and `deactivate`
897
909
  commands exist (they PUT the `active` field):
898
910
 
899
911
  ```bash
900
- uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID [--description TEXT] [--content TEXT] [--guardrail-ids GUARDRAIL_ID ...]
901
- uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID [--name NAME] [--description TEXT] [--content TEXT] [--guardrail-ids GUARDRAIL_ID ... | --clear-guardrail-ids]
912
+ uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ...]
913
+ uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID [--name NAME] [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ... | --clear-guardrail-ids]
902
914
  uv run --frozen hc ams policies activate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
903
915
  uv run --frozen hc ams policies deactivate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
904
916
  uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
@@ -911,6 +923,29 @@ uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID -
911
923
  server does not validate that `--guardrail-ids` reference real guardrails
912
924
  (blank guardrail IDs are rejected client-side). `--content` is write-only:
913
925
  it is sent but never echoed back.
926
+ - The protected policy content can also arrive from an owner-only file, which
927
+ keeps it out of the command line entirely (argv, the process table under
928
+ `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):
929
+
930
+ ```bash
931
+ uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID --content-file PATH
932
+ uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID --content-file PATH
933
+ ```
934
+
935
+ The file is read locally, before the login check and any request, by the same
936
+ reader the sessions commands use: it must be a readable owner-only regular
937
+ text file (no symlinks, no directories, not group- or world-readable) and
938
+ must hold 1-20000 characters. Its content is never printed, and an invalid,
939
+ empty or oversized file is refused locally with a message that names the
940
+ discipline, never the text. The output is identical to the inline form:
941
+ `policies.create` / `policies.update` with `data.policy`.
942
+ - Exactly one content source is accepted per write: passing `--content` with
943
+ `--content-file` is a usage error (exit 2) that sends nothing. The inline
944
+ `--content` flag remains supported for convenience and remains exposed in
945
+ argv, the process table and shell history — use the file channel for
946
+ anything sensitive. An empty `--content ""` on `update` is still the
947
+ documented clear; an empty `--content-file` is a `validation` refusal, so a
948
+ truncated or misdirected file can never clear protected content by accident.
914
949
  - `update` sends only the options you pass (an empty `--description ""` or
915
950
  `--content ""` clears it; `--clear-guardrail-ids` sends an explicit empty
916
951
  list to clear the policy's guardrail bindings); it refuses with exit 1
@@ -931,10 +966,14 @@ uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID -
931
966
  landed write truthfully: it names the policy and the tenant that actually
932
967
  received it and points to `hc ams policies get` — it never claims a landed
933
968
  write was stopped.
934
- - If the transport times out or the connection drops mid-write, the write's
935
- outcome is unknown: the CLI reports one `transport` error whose message says
936
- the outcome is unknown and to check `hc ams policies get` before retrying.
937
- It never re-sends the write on its own.
969
+ - If the transport times out, the connection drops mid-write, or the server
970
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
971
+ unknown: the CLI exits 1 with an `unknown` envelope
972
+ (`verification.saved_state: unknown`, `verification.side_effects:
973
+ unverified`, `data.mutation_replayed: false`, and
974
+ `data.reconciliation.status: not_read`) whose message says the outcome is
975
+ unknown and to check `hc ams policies get` before retrying. It never
976
+ re-sends the write and never re-reads on its own.
938
977
 
939
978
  Governed authoring sessions and their messages are first-class commands over the
940
979
  VibeCoder session routes. Creating a session and appending a message make no model
@@ -947,9 +986,9 @@ is refused locally:
947
986
  ```bash
948
987
  uv run --frozen hc ams sessions list --environment dev --tenant-id TENANT_ID [--limit N]
949
988
  uv run --frozen hc ams sessions get --environment dev --tenant-id TENANT_ID --session-id SESSION_ID
950
- uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID [--agent-id AGENT_ID] [--mode plan|implement] [--initial-prompt TEXT]
989
+ uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID [--agent-id AGENT_ID] [--mode plan|implement] [--initial-prompt TEXT | --initial-prompt-file PATH]
951
990
  uv run --frozen hc ams sessions messages list --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--limit N]
952
- uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --content TEXT [--mode plan|implement] [--idempotency-key KEY]
991
+ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--content TEXT | --content-file PATH] [--mode plan|implement] [--idempotency-key KEY]
953
992
  ```
954
993
 
955
994
  - `list` emits `operation: "sessions.list"`, `data.sessions` (bounded projections
@@ -966,6 +1005,30 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
966
1005
  mode, plan ID, model, timestamps) plus `data.idempotency_key` when a key was
967
1006
  supplied. Protected free text — `--initial-prompt` and `--content` — is sent to
968
1007
  the server but never appears in the envelope, a refusal, a diagnostic or a log.
1008
+ - The protected free text can also arrive from an owner-only file, which keeps it
1009
+ out of the command line entirely (argv, the process table under
1010
+ `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):
1011
+
1012
+ ```bash
1013
+ uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID --initial-prompt-file PATH
1014
+ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --content-file PATH
1015
+ ```
1016
+
1017
+ The file is read locally, before the login check and any request, by the same
1018
+ reader `hc ams sessions plans generate --prompt-file` uses: it must be a
1019
+ readable owner-only regular text file (no symlinks, no directories, not
1020
+ group- or world-readable), and must hold 1-20000 characters — the same bound
1021
+ the inline flag is now held to, so neither channel can carry an empty or
1022
+ over-long value past the local check. Its content is never printed, and an
1023
+ invalid, empty or oversized file is refused locally with a message that
1024
+ names the discipline, never the text. The output is identical to the inline form: `sessions.create`
1025
+ with `data.session`, `sessions.messages.add` with `data.message`.
1026
+ - Exactly one source is accepted per field. Passing `--initial-prompt` with
1027
+ `--initial-prompt-file`, or `--content` with `--content-file`, is a usage
1028
+ error (exit 2) that sends nothing; `messages add` with neither is the same
1029
+ usage error. The inline `--initial-prompt` and `--content` flags remain
1030
+ supported for convenience, and remain exposed in argv, the process table and
1031
+ shell history — use the file channel for anything sensitive.
969
1032
  - `--initial-prompt` becomes the session's first `user` message in the same
970
1033
  server transaction; `hc ams sessions messages list` shows it afterwards.
971
1034
  - `--mode implement` is not available from the server yet: `create` reports one
@@ -733,10 +733,14 @@ uv run --frozen hc ams departments delete --environment dev --tenant-id TENANT_I
733
733
  reports a `validation` refusal that names the department, the tenant that
734
734
  received it, and tells you to run `hc ams departments get` — it never claims
735
735
  a landed write was a stopped no-op.
736
- - If the transport times out or the connection drops mid-write, the write's
737
- outcome is unknown: the CLI reports one `transport` error whose message says
738
- the outcome is unknown and to check `hc ams departments get` before
739
- retrying. It never re-sends the write on its own.
736
+ - If the transport times out, the connection drops mid-write, or the server
737
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
738
+ unknown: the CLI exits 1 with an `unknown` envelope
739
+ (`verification.saved_state: unknown`, `verification.side_effects:
740
+ unverified`, `data.mutation_replayed: false`, and
741
+ `data.reconciliation.status: not_read`) whose message says the outcome is
742
+ unknown and to check `hc ams departments get` before retrying. It never
743
+ re-sends the write and never re-reads on its own.
740
744
 
741
745
  Read-only team listing and inspection are also complete and unpaged. The bounded
742
746
  projection omits descriptions and creator identities; it includes evaluation-suite
@@ -779,10 +783,14 @@ uv run --frozen hc ams teams delete --environment dev --tenant-id TENANT_ID --
779
783
  `validation` refusal that reports the landed write truthfully: it names the
780
784
  team and the tenant that actually received it and points to `hc ams teams
781
785
  get` — it never claims a landed write was stopped.
782
- - If the transport times out or the connection drops mid-write, the write's
783
- outcome is unknown: the CLI reports one `transport` error whose message says
784
- the outcome is unknown and to check `hc ams teams get` before retrying. It
785
- never re-sends the write on its own.
786
+ - If the transport times out, the connection drops mid-write, or the server
787
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
788
+ unknown: the CLI exits 1 with an `unknown` envelope
789
+ (`verification.saved_state: unknown`, `verification.side_effects:
790
+ unverified`, `data.mutation_replayed: false`, and
791
+ `data.reconciliation.status: not_read`) whose message says the outcome is
792
+ unknown and to check `hc ams teams get` before retrying. It never re-sends
793
+ the write and never re-reads on its own.
786
794
 
787
795
  The organization hierarchy is the backend-built department/team/agent tree. The
788
796
  CLI preserves its order and nesting without issuing entity reads, joining records,
@@ -844,10 +852,14 @@ uv run --frozen hc ams skills delete --environment dev --tenant-id TENANT_ID --s
844
852
  landed write truthfully: it names the skill and the tenant that actually
845
853
  received it and points to `hc ams skills get` — it never claims a landed
846
854
  write was stopped.
847
- - If the transport times out or the connection drops mid-write, the write's
848
- outcome is unknown: the CLI reports one `transport` error whose message says
849
- the outcome is unknown and to check `hc ams skills get` before retrying. It
850
- never re-sends the write on its own.
855
+ - If the transport times out, the connection drops mid-write, or the server
856
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
857
+ unknown: the CLI exits 1 with an `unknown` envelope
858
+ (`verification.saved_state: unknown`, `verification.side_effects:
859
+ unverified`, `data.mutation_replayed: false`, and
860
+ `data.reconciliation.status: not_read`) whose message says the outcome is
861
+ unknown and to check `hc ams skills get` before retrying. It never re-sends
862
+ the write and never re-reads on its own.
851
863
 
852
864
  Read-only policy catalog listing and inspection are complete and unpaged. The
853
865
  bounded projection includes only policy IDs, names, department IDs, guardrail
@@ -879,8 +891,8 @@ Policies have an `active` lifecycle flag, so `activate` and `deactivate`
879
891
  commands exist (they PUT the `active` field):
880
892
 
881
893
  ```bash
882
- uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID [--description TEXT] [--content TEXT] [--guardrail-ids GUARDRAIL_ID ...]
883
- uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID [--name NAME] [--description TEXT] [--content TEXT] [--guardrail-ids GUARDRAIL_ID ... | --clear-guardrail-ids]
894
+ uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ...]
895
+ uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID [--name NAME] [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ... | --clear-guardrail-ids]
884
896
  uv run --frozen hc ams policies activate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
885
897
  uv run --frozen hc ams policies deactivate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
886
898
  uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
@@ -893,6 +905,29 @@ uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID -
893
905
  server does not validate that `--guardrail-ids` reference real guardrails
894
906
  (blank guardrail IDs are rejected client-side). `--content` is write-only:
895
907
  it is sent but never echoed back.
908
+ - The protected policy content can also arrive from an owner-only file, which
909
+ keeps it out of the command line entirely (argv, the process table under
910
+ `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):
911
+
912
+ ```bash
913
+ uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID --content-file PATH
914
+ uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID --content-file PATH
915
+ ```
916
+
917
+ The file is read locally, before the login check and any request, by the same
918
+ reader the sessions commands use: it must be a readable owner-only regular
919
+ text file (no symlinks, no directories, not group- or world-readable) and
920
+ must hold 1-20000 characters. Its content is never printed, and an invalid,
921
+ empty or oversized file is refused locally with a message that names the
922
+ discipline, never the text. The output is identical to the inline form:
923
+ `policies.create` / `policies.update` with `data.policy`.
924
+ - Exactly one content source is accepted per write: passing `--content` with
925
+ `--content-file` is a usage error (exit 2) that sends nothing. The inline
926
+ `--content` flag remains supported for convenience and remains exposed in
927
+ argv, the process table and shell history — use the file channel for
928
+ anything sensitive. An empty `--content ""` on `update` is still the
929
+ documented clear; an empty `--content-file` is a `validation` refusal, so a
930
+ truncated or misdirected file can never clear protected content by accident.
896
931
  - `update` sends only the options you pass (an empty `--description ""` or
897
932
  `--content ""` clears it; `--clear-guardrail-ids` sends an explicit empty
898
933
  list to clear the policy's guardrail bindings); it refuses with exit 1
@@ -913,10 +948,14 @@ uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID -
913
948
  landed write truthfully: it names the policy and the tenant that actually
914
949
  received it and points to `hc ams policies get` — it never claims a landed
915
950
  write was stopped.
916
- - If the transport times out or the connection drops mid-write, the write's
917
- outcome is unknown: the CLI reports one `transport` error whose message says
918
- the outcome is unknown and to check `hc ams policies get` before retrying.
919
- It never re-sends the write on its own.
951
+ - If the transport times out, the connection drops mid-write, or the server
952
+ answers an HTTP 5xx/408 before a readable result, the write's outcome is
953
+ unknown: the CLI exits 1 with an `unknown` envelope
954
+ (`verification.saved_state: unknown`, `verification.side_effects:
955
+ unverified`, `data.mutation_replayed: false`, and
956
+ `data.reconciliation.status: not_read`) whose message says the outcome is
957
+ unknown and to check `hc ams policies get` before retrying. It never
958
+ re-sends the write and never re-reads on its own.
920
959
 
921
960
  Governed authoring sessions and their messages are first-class commands over the
922
961
  VibeCoder session routes. Creating a session and appending a message make no model
@@ -929,9 +968,9 @@ is refused locally:
929
968
  ```bash
930
969
  uv run --frozen hc ams sessions list --environment dev --tenant-id TENANT_ID [--limit N]
931
970
  uv run --frozen hc ams sessions get --environment dev --tenant-id TENANT_ID --session-id SESSION_ID
932
- uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID [--agent-id AGENT_ID] [--mode plan|implement] [--initial-prompt TEXT]
971
+ uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID [--agent-id AGENT_ID] [--mode plan|implement] [--initial-prompt TEXT | --initial-prompt-file PATH]
933
972
  uv run --frozen hc ams sessions messages list --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--limit N]
934
- uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --content TEXT [--mode plan|implement] [--idempotency-key KEY]
973
+ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--content TEXT | --content-file PATH] [--mode plan|implement] [--idempotency-key KEY]
935
974
  ```
936
975
 
937
976
  - `list` emits `operation: "sessions.list"`, `data.sessions` (bounded projections
@@ -948,6 +987,30 @@ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENAN
948
987
  mode, plan ID, model, timestamps) plus `data.idempotency_key` when a key was
949
988
  supplied. Protected free text — `--initial-prompt` and `--content` — is sent to
950
989
  the server but never appears in the envelope, a refusal, a diagnostic or a log.
990
+ - The protected free text can also arrive from an owner-only file, which keeps it
991
+ out of the command line entirely (argv, the process table under
992
+ `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):
993
+
994
+ ```bash
995
+ uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID --initial-prompt-file PATH
996
+ uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --content-file PATH
997
+ ```
998
+
999
+ The file is read locally, before the login check and any request, by the same
1000
+ reader `hc ams sessions plans generate --prompt-file` uses: it must be a
1001
+ readable owner-only regular text file (no symlinks, no directories, not
1002
+ group- or world-readable), and must hold 1-20000 characters — the same bound
1003
+ the inline flag is now held to, so neither channel can carry an empty or
1004
+ over-long value past the local check. Its content is never printed, and an
1005
+ invalid, empty or oversized file is refused locally with a message that
1006
+ names the discipline, never the text. The output is identical to the inline form: `sessions.create`
1007
+ with `data.session`, `sessions.messages.add` with `data.message`.
1008
+ - Exactly one source is accepted per field. Passing `--initial-prompt` with
1009
+ `--initial-prompt-file`, or `--content` with `--content-file`, is a usage
1010
+ error (exit 2) that sends nothing; `messages add` with neither is the same
1011
+ usage error. The inline `--initial-prompt` and `--content` flags remain
1012
+ supported for convenience, and remain exposed in argv, the process table and
1013
+ shell history — use the file channel for anything sensitive.
951
1014
  - `--initial-prompt` becomes the session's first `user` message in the same
952
1015
  server transaction; `hc ams sessions messages list` shows it afterwards.
953
1016
  - `--mode implement` is not available from the server yet: `create` reports one
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from ..context import AmsDependencies
6
6
  from ..domain_errors import DomainReadRefused
7
- from ..results import succeeded
7
+ from ..results import is_unknown_write_status, succeeded, unknown_write
8
8
  from ..target import TenantTarget
9
9
  from .projections import department_projection
10
10
 
@@ -27,6 +27,18 @@ UNKNOWN_OUTCOME = (
27
27
  "department's current server state before deciding whether to retry."
28
28
  )
29
29
 
30
+ UNKNOWN_HTTP_OUTCOME = (
31
+ "The department write was sent once and its outcome is now unknown: the server answered with an "
32
+ "error status before a readable result, so the request may have been processed. Run "
33
+ "`hc ams departments get` to check the department's current server state before deciding whether "
34
+ "to retry."
35
+ )
36
+
37
+ UNKNOWN_RECONCILIATION = (
38
+ "This command did not re-read the department after the uncertain write; run "
39
+ "`hc ams departments list` before doing anything else."
40
+ )
41
+
30
42
 
31
43
  def _department_id(value: str) -> str:
32
44
  if not isinstance(value, str) or not value.strip():
@@ -140,11 +152,26 @@ def create_department(
140
152
  )
141
153
  except HealthCloudNetworkError:
142
154
  # Sent once: the request may have landed; the outcome is unknown, never retried here.
143
- raise DepartmentWriteRefused("transport", UNKNOWN_OUTCOME) from None
155
+ unknown_write(
156
+ "departments.create",
157
+ _target(target),
158
+ UNKNOWN_OUTCOME,
159
+ {"department": None},
160
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
161
+ )
144
162
  except HealthCloudHTTPError as error:
145
163
  refusal = _create_not_found(error, parent_department_id)
146
164
  if refusal is not None:
147
165
  raise refusal from error
166
+ if is_unknown_write_status(getattr(error, "status_code", None)):
167
+ # A 5xx/408 may have committed server-side: report it as unknown, never a retry shape.
168
+ unknown_write(
169
+ "departments.create",
170
+ _target(target),
171
+ UNKNOWN_HTTP_OUTCOME,
172
+ {"department": None},
173
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
174
+ )
148
175
  raise
149
176
  # The write has already landed server-side; a mismatching readback must never be
150
177
  # reported as a stopped no-op. Name the tenant that actually received it.
@@ -174,11 +201,25 @@ def update_department(
174
201
  try:
175
202
  department = resource.update(department_id, **fields)
176
203
  except HealthCloudNetworkError:
177
- raise DepartmentWriteRefused("transport", UNKNOWN_OUTCOME) from None
204
+ unknown_write(
205
+ "departments.update",
206
+ _target(target, department_id),
207
+ UNKNOWN_OUTCOME,
208
+ {"department": None},
209
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
210
+ )
178
211
  except HealthCloudHTTPError as error:
179
212
  refusal = _not_found(error, department_id)
180
213
  if refusal is not None:
181
214
  raise refusal from error
215
+ if is_unknown_write_status(getattr(error, "status_code", None)):
216
+ unknown_write(
217
+ "departments.update",
218
+ _target(target, department_id),
219
+ UNKNOWN_HTTP_OUTCOME,
220
+ {"department": None},
221
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
222
+ )
182
223
  raise
183
224
  _identity(department, target, department_id)
184
225
  return _write_result("departments.update", target, department_id, {"department": department_projection(department)})
@@ -193,11 +234,25 @@ def _lifecycle(dependencies: AmsDependencies, target: TenantTarget, department_i
193
234
  try:
194
235
  department = getattr(resource, method)(department_id)
195
236
  except HealthCloudNetworkError:
196
- raise DepartmentWriteRefused("transport", UNKNOWN_OUTCOME) from None
237
+ unknown_write(
238
+ f"departments.{method}",
239
+ _target(target, department_id),
240
+ UNKNOWN_OUTCOME,
241
+ {"department": None},
242
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
243
+ )
197
244
  except HealthCloudHTTPError as error:
198
245
  refusal = _not_found(error, department_id)
199
246
  if refusal is not None:
200
247
  raise refusal from error
248
+ if is_unknown_write_status(getattr(error, "status_code", None)):
249
+ unknown_write(
250
+ f"departments.{method}",
251
+ _target(target, department_id),
252
+ UNKNOWN_HTTP_OUTCOME,
253
+ {"department": None},
254
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
255
+ )
201
256
  raise
202
257
  _identity(department, target, department_id)
203
258
  return _write_result(
@@ -225,10 +280,24 @@ def delete_department(dependencies: AmsDependencies, target: TenantTarget, depar
225
280
  try:
226
281
  resource.delete(department_id)
227
282
  except HealthCloudNetworkError:
228
- raise DepartmentWriteRefused("transport", UNKNOWN_OUTCOME) from None
283
+ unknown_write(
284
+ "departments.delete",
285
+ _target(target, department_id),
286
+ UNKNOWN_OUTCOME,
287
+ {"deleted": None},
288
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
289
+ )
229
290
  except HealthCloudHTTPError as error:
230
291
  refusal = _not_found(error, department_id)
231
292
  if refusal is not None:
232
293
  raise refusal from error
294
+ if is_unknown_write_status(getattr(error, "status_code", None)):
295
+ unknown_write(
296
+ "departments.delete",
297
+ _target(target, department_id),
298
+ UNKNOWN_HTTP_OUTCOME,
299
+ {"deleted": None},
300
+ reconciliation_detail=UNKNOWN_RECONCILIATION,
301
+ )
233
302
  raise
234
303
  return _write_result("departments.delete", target, department_id, {"deleted": True})
@@ -6,11 +6,16 @@ import hashlib
6
6
  import json
7
7
  import os
8
8
  import stat
9
+ from collections.abc import Callable
9
10
  from contextlib import contextmanager
10
11
  from pathlib import Path
11
12
 
12
13
  import click
13
14
 
15
+ # One bound for every protected free-text field the CLI accepts, whichever
16
+ # channel it arrives on (#834): the same value the server enforces.
17
+ PROTECTED_TEXT_MAX_CHARS = 20000
18
+
14
19
 
15
20
  def canonical(value: object) -> str:
16
21
  return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
@@ -91,6 +96,57 @@ def read_private_text(path: Path, *, maximum: int) -> str:
91
96
  ) from None
92
97
 
93
98
 
99
+ def protected_text(
100
+ inline: str | None,
101
+ path: str | None,
102
+ *,
103
+ flag: str,
104
+ file_flag: str,
105
+ refuse: Callable[[str, str], BaseException],
106
+ required: bool = False,
107
+ allow_empty_inline: bool = False,
108
+ ) -> str | None:
109
+ """Resolve one protected free-text field from exactly one input channel.
110
+
111
+ ``--<field>`` keeps working for convenience but lands in argv, the process
112
+ table and shell history; ``--<field>-file`` reads the same text from one
113
+ owner-only file instead. Exactly one source is allowed, and both channels
114
+ are bounded identically, so neither can carry an over-long value past the
115
+ local check the other applies.
116
+
117
+ ``required`` makes "no source at all" a refusal here, in the Click-free
118
+ layer, so the invariant does not depend on the adapter. Empty text is
119
+ refused on both channels unless the caller documents an empty inline value
120
+ as a clear (``hc ams policies update --content ""``); an empty file is
121
+ always refused, so a truncated or misdirected file can never clear a
122
+ protected field by accident. ``refuse`` builds the caller's own categorized
123
+ refusal, and no refusal quotes the text itself.
124
+ """
125
+
126
+ if inline is not None and path is not None:
127
+ raise refuse("validation", f"Pass {flag} or {file_flag}, not both; nothing was sent.")
128
+ if inline is None and path is None:
129
+ if not required:
130
+ return None
131
+ raise refuse("validation", f"Pass {flag} or {file_flag}; nothing was sent.")
132
+ if path is not None:
133
+ if not isinstance(path, str) or not path.strip():
134
+ raise refuse("validation", f"Explicit {file_flag} file path required.")
135
+ text = read_private_text(Path(path), maximum=PROTECTED_TEXT_MAX_CHARS)
136
+ if not text:
137
+ raise refuse("validation", f"The file given to {file_flag} is empty; nothing was sent.")
138
+ return text
139
+ if len(inline) > PROTECTED_TEXT_MAX_CHARS:
140
+ raise refuse(
141
+ "validation",
142
+ f"{flag} is longer than {PROTECTED_TEXT_MAX_CHARS} characters; nothing was sent. "
143
+ f"Pass a shorter value, or the same text with {file_flag}.",
144
+ )
145
+ if not inline and not allow_empty_inline:
146
+ raise refuse("validation", f"{flag} is empty; nothing was sent. Pass the text, or {file_flag} holding it.")
147
+ return inline
148
+
149
+
94
150
  @contextmanager
95
151
  def _private_stream(path: Path, newline: str | None = None):
96
152
  """Reserve output before network calls, never overwrite, clean failed work."""
@@ -88,7 +88,15 @@ def create_create_command(dependencies: AmsDependencies) -> click.Command:
88
88
  @click.option(
89
89
  "--content",
90
90
  default=None,
91
- help="Policy content (write-only; never echoed in command output).",
91
+ help="Policy content (write-only; never echoed in command output). Exposed in argv, the process "
92
+ "table and shell history: prefer --content-file.",
93
+ )
94
+ @click.option(
95
+ "--content-file",
96
+ default=None,
97
+ type=click.Path(dir_okay=False),
98
+ help="Owner-only text file holding the policy content, so the text never enters argv or shell "
99
+ "history (1-20000 characters). Cannot be combined with --content.",
92
100
  )
93
101
  @click.option(
94
102
  "--guardrail-ids",
@@ -106,7 +114,10 @@ def create_create_command(dependencies: AmsDependencies) -> click.Command:
106
114
  description: str | None,
107
115
  content: str | None,
108
116
  guardrail_ids: tuple[str, ...],
117
+ content_file: str | None,
109
118
  ) -> None:
119
+ if content is not None and content_file is not None:
120
+ raise click.UsageError("Pass --content or --content-file, not both; nothing was sent.")
110
121
  target_data = {"environment": environment, "tenant_id": tenant_id, "name": name}
111
122
 
112
123
  def execute():
@@ -123,6 +134,7 @@ def create_create_command(dependencies: AmsDependencies) -> click.Command:
123
134
  description=description,
124
135
  content=content,
125
136
  guardrail_ids=list(guardrail_ids) or None,
137
+ content_file=content_file,
126
138
  )
127
139
 
128
140
  run_domain("policies.create", target_data, execute, read_only=False)
@@ -140,7 +152,16 @@ def create_update_command(dependencies: AmsDependencies) -> click.Command:
140
152
  @click.option(
141
153
  "--content",
142
154
  default=None,
143
- help="New policy content; an empty value clears it (write-only; never echoed).",
155
+ help="New policy content; an empty value clears it (write-only; never echoed). Exposed in argv, "
156
+ "the process table and shell history: prefer --content-file.",
157
+ )
158
+ @click.option(
159
+ "--content-file",
160
+ default=None,
161
+ type=click.Path(dir_okay=False),
162
+ help="Owner-only text file holding the new policy content, so the text never enters argv or "
163
+ "shell history (1-20000 characters). An empty file is refused. Cannot be combined with "
164
+ "--content.",
144
165
  )
145
166
  @click.option(
146
167
  "--guardrail-ids",
@@ -163,7 +184,10 @@ def create_update_command(dependencies: AmsDependencies) -> click.Command:
163
184
  content: str | None,
164
185
  guardrail_ids: tuple[str, ...],
165
186
  clear_guardrail_ids: bool,
187
+ content_file: str | None,
166
188
  ) -> None:
189
+ if content is not None and content_file is not None:
190
+ raise click.UsageError("Pass --content or --content-file, not both; nothing was sent.")
167
191
  target_data = {
168
192
  "environment": environment,
169
193
  "tenant_id": tenant_id,
@@ -181,14 +205,14 @@ def create_update_command(dependencies: AmsDependencies) -> click.Command:
181
205
  if description is not None:
182
206
  fields["description"] = description or None
183
207
  if content is not None:
184
- fields["content"] = content or None
208
+ fields["content"] = content
185
209
  if guardrail_ids:
186
210
  fields["guardrail_ids"] = list(guardrail_ids)
187
211
  elif clear_guardrail_ids:
188
212
  fields["guardrail_ids"] = []
189
- if not fields:
213
+ if not fields and content_file is None:
190
214
  raise click.UsageError("Nothing to update; pass at least one field option.")
191
- return update_policy(dependencies, target, policy_id, fields=fields)
215
+ return update_policy(dependencies, target, policy_id, fields=fields, content_file=content_file)
192
216
 
193
217
  run_domain("policies.update", target_data, execute, read_only=False)
194
218