iit-mcp-autotask 0.14.0 → 0.26.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.
Files changed (96) hide show
  1. package/.env.example +9 -5
  2. package/README.md +58 -76
  3. package/capabilities.json +2 -4
  4. package/dist/access/gate.d.ts +29 -0
  5. package/dist/access/gate.js +75 -0
  6. package/dist/access/write-env.d.ts +27 -0
  7. package/dist/access/write-env.js +161 -0
  8. package/dist/auth.d.ts +13 -0
  9. package/dist/auth.js +19 -0
  10. package/dist/config/constants.d.ts +2 -2
  11. package/dist/config/constants.js +7 -2
  12. package/dist/config/load.js +1 -1
  13. package/dist/config/parse.d.ts +1 -1
  14. package/dist/config/parse.js +1 -1
  15. package/dist/config/schema.d.ts +8 -4
  16. package/dist/config/schema.js +7 -23
  17. package/dist/config.d.ts +0 -1
  18. package/dist/config.js +0 -1
  19. package/dist/doctor.d.ts +29 -1
  20. package/dist/doctor.js +6 -29
  21. package/dist/http.d.ts +1 -1
  22. package/dist/http.js +15 -4
  23. package/dist/main.js +10 -11
  24. package/dist/mocks/harness.js +1 -1
  25. package/dist/moves.d.ts +1 -1
  26. package/dist/moves.js +3 -3
  27. package/dist/policy.d.ts +1 -1
  28. package/dist/policy.js +2 -2
  29. package/dist/tools/capabilities-manifest.js +3 -3
  30. package/dist/tools/catalog-data.d.ts +1 -1
  31. package/dist/tools/catalog-data.js +1 -1
  32. package/dist/tools/context/derive.js +1 -1
  33. package/dist/tools/context/types.d.ts +6 -2
  34. package/dist/tools/dispatch.d.ts +3 -2
  35. package/dist/tools/dispatch.js +31 -13
  36. package/dist/tools/meta.js +9 -33
  37. package/dist/tools/reasons.d.ts +3 -3
  38. package/dist/tools/reasons.js +3 -12
  39. package/dist/tools/register.js +10 -4
  40. package/dist/tools/schemas.d.ts +20 -27
  41. package/dist/tools/schemas.js +32 -19
  42. package/dist/tools/server-version.d.ts +1 -1
  43. package/dist/tools/server-version.js +1 -1
  44. package/dist/tools/server.d.ts +4 -4
  45. package/dist/tools/server.js +25 -14
  46. package/dist/tools/surface.d.ts +6 -2
  47. package/dist/tools/surface.js +45 -0
  48. package/dist/tools/write/apply-phases.d.ts +18 -13
  49. package/dist/tools/write/apply-phases.js +43 -75
  50. package/dist/tools/write/apply.js +32 -9
  51. package/dist/tools/write/facts.d.ts +7 -21
  52. package/dist/tools/write/facts.js +7 -30
  53. package/dist/tools/write/outcome.d.ts +3 -8
  54. package/dist/tools/write/outcome.js +1 -19
  55. package/dist/tools/write/payload.d.ts +1 -1
  56. package/dist/tools/write/payload.js +1 -1
  57. package/dist/tools/write/preview.js +26 -53
  58. package/dist/tools/write/snapshot.d.ts +0 -1
  59. package/dist/tools/write/snapshot.js +2 -3
  60. package/dist/tools/write.d.ts +1 -1
  61. package/dist/tools/write.js +1 -1
  62. package/dist/tty.d.ts +13 -0
  63. package/dist/tty.js +44 -0
  64. package/dist/write/canonical.d.ts +6 -0
  65. package/dist/write/canonical.js +4 -0
  66. package/dist/write/evaluate.d.ts +13 -37
  67. package/dist/write/evaluate.js +21 -89
  68. package/dist/write/hard-rules.d.ts +12 -14
  69. package/dist/write/hard-rules.js +2 -4
  70. package/dist/write/runtime.d.ts +9 -6
  71. package/dist/write/runtime.js +22 -14
  72. package/dist/write/store/constants.d.ts +1 -1
  73. package/dist/write/store/constants.js +1 -1
  74. package/dist/write/store/ledger.d.ts +1 -1
  75. package/dist/write/store/ledger.js +1 -14
  76. package/dist/write/store/plan-store.d.ts +3 -6
  77. package/dist/write/store/plan-store.js +3 -31
  78. package/dist/write/store/plans.d.ts +1 -1
  79. package/dist/write/store/plans.js +1 -1
  80. package/dist/write/store/types.d.ts +5 -34
  81. package/dist/write/store.d.ts +1 -1
  82. package/dist/write/store.js +1 -3
  83. package/examples/agentgateway.write-gate.yaml +29 -0
  84. package/package.json +3 -3
  85. package/server.json +4 -6
  86. package/skills/filter-semantics.md +2 -4
  87. package/skills/log-time-entry.md +5 -5
  88. package/dist/approvals.d.ts +0 -112
  89. package/dist/approvals.js +0 -440
  90. package/dist/write/grant.d.ts +0 -52
  91. package/dist/write/grant.js +0 -159
  92. package/dist/write/manual.d.ts +0 -28
  93. package/dist/write/manual.js +0 -54
  94. package/dist/write/policy-file.d.ts +0 -136
  95. package/dist/write/policy-file.js +0 -231
  96. package/examples/policy.time-entries.json +0 -47
package/.env.example CHANGED
@@ -50,14 +50,18 @@ MCP_TRANSPORT=stdio
50
50
 
51
51
  # --- Profile and toolsets (0.6.0) ---
52
52
  # readonly (default): reads only, no state. operator: adds autotask_write (preview) and
53
- # autotask_apply (execute the previewed plan), governed by a policy file; needs the four below.
53
+ # autotask_apply (execute the previewed plan), governed by AUTOTASK_WRITE; needs the state dir and run scope below.
54
54
  AUTOTASK_PROFILE=readonly
55
- # operator: the write policy (closed JSON schema, default deny; see examples/policy.time-entries.json).
56
- # AUTOTASK_POLICY_FILE=/etc/autotask-mcp/policy.json
55
+ # operator: which writes are served: allow, gate (a person confirms) or deny per operation; unlisted is read-only.
56
+ # Grammar, dedup, limits and trusted host prompts: docs/write-gate.md.
57
+ # AUTOTASK_WRITE=*:gate,tickets.*:allow,time_entries.create:allow,companies.update:deny
58
+ # AUTOTASK_DEDUP=companies.create:companyName
59
+ # AUTOTASK_WRITE_LIMITS=50/h,200/d
60
+ # AUTOTASK_TRUSTED_HOST_PROMPT=stdio
57
61
  # operator: the state dir (mode 0700; plans, ledger, audit log, lock; one process per dir).
58
62
  # AUTOTASK_STATE_DIR=/var/lib/autotask-mcp
59
- # operator on stdio: the run scope (required), {"companyIds":[...]} or {"all":true} (= the policy's
60
- # own scope). It only narrows the policy. On HTTP set runScope per token instead.
63
+ # operator on stdio: the run scope (required), {"companyIds":[...]} or {"all":true} (any
64
+ # company). On HTTP set runScope per token instead.
61
65
  # AUTOTASK_RUN_SCOPE={"companyIds":[12345]}
62
66
  # operator on stdio: the principal plans and the audit log are bound to (default stdio).
63
67
  # AUTOTASK_PRINCIPAL=nightly-time-entries
package/README.md CHANGED
@@ -40,17 +40,18 @@ its contract, `autotask_read` runs it.
40
40
  `include_api_users: true`; `by: "email"` narrows to the email field; a numeric identifier is looked up
41
41
  as a Resource id); compact rows carry `apiUser` and the licence label when the tenant's labels tell it, never an email. Every match carries `ref`, its human reference. A sweep returns compact matches (`resource`, `ref`, `id`, the human fields) unless `expand: true` (the full record under `record`); `limit` caps the TOTAL matches across resources (resource order, then the SDK's order) and a contains sweep reports `truncated` and per-resource `counts {returned, found}`.
42
42
  - `autotask_query` is the bounded filtered list over `resource`: `tickets`, `time_entries`,
43
- `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500).
43
+ `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500). The served filter
44
+ schema declares each condition as `{field, op, value}` strings, so a client that builds one type
45
+ per array column writes `"29"`, `"true"`, `"2026-10-22"`; numbers, `in` arrays and and/or groups are
46
+ still accepted and passed through unchanged.
44
47
  - `autotask_read` runs any read by exact key, or up to 10 at once (`items`). Unbilled work
45
48
  (`companies.unbilledWork`), contract profitability (`contracts.profitability`), aggregates
46
49
  (`operations.aggregate`) and SLA health (`tickets.slaHealthCheck`) run here; `autotask_guide`
47
- holds their recipes. The two time-entry window keys (`time_entries.dateWorkedWindow`,
48
- `time_entries.postedWithin`) also take flat named inputs instead of `args`, for clients that cannot
49
- send a mixed-type array (Copilot Studio): `{operation, from, to, posted?, limit?, ticketID?,
50
- resourceID?}`, `from`/`to` as inclusive `YYYY-MM-DD` dateWorked days (never UTC-converted),
51
- `ticketID`/`resourceID` as numbers, no `tz` (it does not shift dateWorked). The SDK's
52
- `foldFlattenedOpts` maps them onto its named invoke form, so the result and `truncated` flag are
53
- the positional call's. `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
50
+ holds their recipes. Its inputs are generic (`operation`, `args`, `items`): no operation-specific
51
+ fields, because form-filling clients (Copilot Studio) fill every optional input they are shown. A
52
+ client that cannot send a mixed-type array asks for a time entry window over a resource with
53
+ `autotask_query` `time_entries` (`resourceID` eq, `dateWorked` gte/lte, every value a string: the
54
+ served filter schema types `value` as a string). `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
54
55
  breaking): `data = { results: Slot[], ok, failed, budget?, budgetWarning? }`, an array in input
55
56
  order even for a single read (`data.results[0].result`). A slot is `{ ok: true, operation, result,
56
57
  windowNote? }` or `{ ok: false, operation, error }`; fetch_many slots also echo `resource` and `id`
@@ -85,7 +86,7 @@ part miss never fails the call (typed `absent`); only a missing root record fail
85
86
  **Every result is one envelope** (`structuredContent`, mirrored as JSON in the first text block):
86
87
 
87
88
  ```
88
- { status: ok|needs_input|ambiguous|needs_approval|denied|error,
89
+ { status: ok|needs_input|ambiguous|denied|error,
89
90
  data, next: proceed|supply_input|choose_candidate|...|retry|stop,
90
91
  reasons: [{ code, field?, detail }], retryable, correlationId, summary }
91
92
  ```
@@ -119,7 +120,7 @@ server metadata: [`server.json`](server.json) · per-workflow mapping:
119
120
 
120
121
  | Variable | Default | Effect |
121
122
  |----------|---------|--------|
122
- | `AUTOTASK_PROFILE` | `readonly` | `readonly`: reads only (approval mode `off`), no state. `operator`: adds `autotask_write` and `autotask_apply` (approval mode `policy`); see [Operator profile](#operator-profile-writes). On HTTP it requires `AUTOTASK_HTTP_AUTH=tokens`. |
123
+ | `AUTOTASK_PROFILE` | `readonly` | `readonly`: reads only, no state. `operator`: adds `autotask_write` and `autotask_apply`; see [Operator profile](#operator-profile-writes). On HTTP it requires `AUTOTASK_HTTP_AUTH=tokens` or `gateway`. |
123
124
  | `AUTOTASK_TOOLSETS` | — (core only) | comma list of `service`, `records`, `finance`, or `all`; an unknown name fails boot |
124
125
 
125
126
  Every write and delete is refused on the read path (`autotask_read`) before any request
@@ -142,65 +143,44 @@ An operator deployment needs:
142
143
  | Variable | Meaning |
143
144
  |----------|---------|
144
145
  | `AUTOTASK_PROFILE=operator` (or an operator token) | the profile |
145
- | `AUTOTASK_POLICY_FILE` | the write policy: a closed JSON schema, default deny ([example](examples/policy.time-entries.json)) |
146
+ | `AUTOTASK_WRITE` | which writes are served and how: `allow`, `gate` or `deny` per operation, unlisted is read-only (grammar in [docs/write-gate.md](docs/write-gate.md)) |
146
147
  | `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log, the primary lock and the holder registry (many processes may share one dir — see below) |
147
- | `AUTOTASK_RUN_SCOPE` | stdio: `{"companyIds":[...]}` or `{"all":true}` (the policy's own scope); it can only narrow the policy. On HTTP each operator token carries `runScope` instead |
148
+ | `AUTOTASK_RUN_SCOPE` | `{"companyIds":[...]}` or `{"all":true}`: the run scope on stdio and in gateway mode (one scope shared by every gateway principal). In tokens mode each operator token carries `runScope` instead |
148
149
  | `AUTOTASK_PRINCIPAL` | stdio: the name plans and the audit log are bound to (default `stdio`) |
149
150
  | `AUTOTASK_KILL_FILE` | optional: while this file exists every preview and apply is denied (default `<state dir>/KILL`) |
150
151
 
151
- What the policy can and cannot do:
152
-
153
- - An operation the policy does not list is `OP_NOT_ALLOWED`. Per operation: `approval`
154
- (`none`/`required`), `maxPerHour`, `maxPerDay`, `maxDistinctTargetsPerDay`, a `fields` allowlist,
155
- `forbidRemovals`, `requireReason`, UTC `windows`, `duplicateCheck` (required for creates) and
156
- `allowUnattended`. Globally: `write.scope.companyIds`, `createdHereWithinDays`, and
157
- `limits.writesPerHour`/`writesPerDay`.
158
- - Moving a time entry or ticket charge to another ticket (#107): list `time_entries.moveToTicket` or
159
- `ticket_charges.moveToTicket` with an `owner` block, e.g. `{"approval": "none", "allowUnattended": true,
160
- "owner": {"mode": "createdHere", "days": 7}}` (`createdHere`: this server created the source for this
161
- principal within 1-14 days; or `{"mode": "resourceIDs", "ids": [..]}` for time entries, whose `resourceID`
162
- must be listed; a charge has no owning resource). Preview `autotask_write {operation, args: [sourceId,
163
- toTicketId]}`, then apply. The server creates the copy on the target ticket, verifies it and deletes the
164
- original (the vendor ignores an update of `ticketID`): the new record gets a new id, create date and
165
- creator, and notifications may fire. A move into another company or contract always needs a signed
166
- approval grant; an unfinished earlier move of the same source blocks another until its ledger entry ages
167
- out (14 days) or a person clears it: in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set,
168
- `autotask-mcp moves list` shows the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you
169
- checked both tickets in Autotask; you type the source id, and the copy's id when the ledger names one) closes the
170
- ledger lines as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
171
- (its engine may still be running) and, while the engine may still run, when the sending server is still registered or alive. A plan previewed
172
- before the resolve cannot apply after it. It is not an
173
- MCP tool; like the approvals CLI it is a guardrail, not a boundary against an agent with a same-user shell. Billed, attached, approved, closed-target and older-than-14-days sources are refused.
174
- - Hard rules no policy overrides: `authenticate.*` is never served; operations the SDK flags
175
- `requiresApproval`, and non-read sensitive ones (webhooks, `resources.transferOwnership`), always
176
- need approval; destructive and account-wide operations need it, and `allowUnattended` cannot waive
177
- that for an operation the hard rules cover. In approval mode `policy` no approver
178
- exists: an approval-required write answers `needs_approval` with `next: manual_action` (no plan
179
- handle) and is never executed. Approval mode `external` (`approvalMode: "external"` plus an `approvals`
180
- block listing ed25519 approver keys, each scoped to operations) is being added in stages (#106): a gated
181
- preview stores a pending plan (`next: await_approval`, handle, hash, expiry of grant window + 5 minutes) that
182
- executes only with a valid ed25519 grant file `approvals/grants/<handle>.json` signed by a listed approver
183
- (apply verifies it; deny rules still win; one use per plan). A human decides with the CLI, run in their own
184
- terminal as the server's OS user with `AUTOTASK_STATE_DIR` and `AUTOTASK_POLICY_FILE` set as the server has them:
185
- `autotask-mcp keygen <approver-id> --ops <op,op|*>` (creates a passphrase-sealed ed25519 key, mode 0600, default
186
- `~/.config/iit-mcp-autotask/approver.key` or `$AUTOTASK_APPROVER_KEY_FILE`, and prints the entry for
187
- `approvals.approvers`), then `autotask-mcp approvals list|show|approve|reject <handle>`. `show` and `approve`
188
- recompute the plan hash and snapshot hash from the stored plan and refuse on a mismatch or a changed policy; they
189
- print only verified fields, and every agent-supplied string (arguments, the agent's reason) is JSON-encoded behind
190
- `| ` inside a labelled untrusted block with control and bidi characters stripped. `approve` and `reject` need you to
191
- type the first 8 characters of the plan hash and the key passphrase; the passphrase is read from `/dev/tty` only
192
- (never the environment, arguments, a pipe or a file; there is no `--yes`), so the command refuses without a
193
- terminal and on Windows. `approve` and `reject` refuse while any shown value was cut for display (it would be signed in full): review with `show <handle> --full`, then pass `--full`. The first decision per plan is final. This stops accidents, not an agent that can drive a
194
- pty, and on stdio the sealed key file can be attacked offline by anything running as the same user. On HTTP run it
195
- where the state dir lives (for example `docker exec -it` as the server's user). On stdio this is a
196
- guardrail against a mistaken agent, not a boundary against an adversarial agent running as the same OS
197
- user; it is a real boundary only on HTTP with the server, its key material and the policy file isolated
198
- from the agent. Any edit to the policy file (including `approvals`) changes the policy hash and voids
199
- every outstanding plan. `data.manual` carries a fingerprint of the record (ids, ticket,
200
- start time, hours, resource — numbers and instants only, never vendor text) and, when the pinned
201
- SDK provides `operations.uiLink`, a clickable Autotask deep link; a person finishes the job in
202
- Autotask. A policy that sets `approval: "none"` on such an operation changes nothing: boot logs a
203
- warning and `doctor` prints it. There is no `autotask_delete` (D-12): deletes preview through `autotask_write`.
152
+ What the write access can and cannot do (full reference: [docs/write-gate.md](docs/write-gate.md)):
153
+
154
+ - `AUTOTASK_WRITE` lists operations, families (`tickets.*`) or `*`, each `allow` (applies without a
155
+ person), `gate` (a person confirms the apply) or `deny`. An operation not listed is `OP_NOT_ALLOWED`.
156
+ `AUTOTASK_DEDUP` names the duplicate-check fields for a create, `AUTOTASK_WRITE_LIMITS` caps writes
157
+ (`50/h,200/d`) and `AUTOTASK_TRUSTED_HOST_PROMPT` names the clients whose own permission prompt counts
158
+ as the confirmation. A bad entry fails boot.
159
+ - A gated apply is confirmed by a trusted host's own prompt, else by one confirm form (MCP elicitation);
160
+ a client that can do neither gets `GATE_UNSUPPORTED`, and a decline or dismissal `GATE_DECLINED`.
161
+ Nothing is sent in either case.
162
+ - Moving a time entry or ticket charge to another ticket (#107): `time_entries.moveToTicket` and
163
+ `ticket_charges.moveToTicket`. Preview `autotask_write {operation, args: [sourceId, toTicketId]}`, then
164
+ apply. The server creates the copy on the target ticket, verifies it and deletes the original (the
165
+ vendor ignores an update of `ticketID`): the new record gets a new id, create date and creator, and
166
+ notifications may fire. A move into another company or contract is always gated. An unfinished earlier
167
+ move of the same source blocks another until its ledger entry ages out (14 days) or a person clears it:
168
+ in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set, `autotask-mcp moves list` shows
169
+ the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you checked both tickets in
170
+ Autotask; you type the source id, and the copy's id when the ledger names one) closes the ledger lines
171
+ as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
172
+ and, while the engine may still run, when the sending server is still registered or alive. A plan
173
+ previewed before the resolve cannot apply after it. It is not an MCP tool, and it is a guardrail, not a
174
+ boundary against an agent with a same-user shell. Billed, attached, approved, closed-target and
175
+ older-than-14-days sources are refused.
176
+ - Hard rules no setting overrides: `authenticate.*` is never served; operations the SDK flags
177
+ `requiresApproval` or sensitive (webhooks, `resources.transferOwnership`, `contacts.create`) can only
178
+ be `gate` or `deny`. An exact `allow` entry for one is a boot error; a family or `*` entry resolves it
179
+ to `gate`. Destructive and move operations can be `allow`ed (see the trust model in the doc).
180
+ - When a person must finish a job by hand (a move whose source delete the vendor refused), the result is
181
+ `next: manual_action` and `data.move.manualAction` carries what to do and, when the pinned SDK
182
+ provides `operations.uiLink`, a clickable Autotask deep link. There is no `autotask_delete` (D-12):
183
+ deletes preview through `autotask_write`.
204
184
  - The company scope is read from the live records the write touches (the target, its parent, the
205
185
  ticket, project, task or contract it references, and any `companyID` it sets), never from names
206
186
  or titles. A reference that cannot be read is refused.
@@ -214,7 +194,7 @@ precomposed form after NFC (for example a lone U+0301) is refused `INPUT_INVALID
214
194
  precomposed character.
215
195
 
216
196
  Outcomes the caller acts on: `ok`/`proceed`; `needs_input`/`supply_input` (`missing` names the
217
- field); `needs_approval`/`await_approval`; `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
197
+ field); `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
218
198
  `repreview`; a failed write: `WRITE_FAILED` with `stop` (a deterministic rejection; it replays,
219
199
  fix the input and use a new key) or `RATE_LIMITED` with `retry` (nothing was written: apply the
220
200
  same plan again after the wait); a vendor that accepted an update but kept the old value (`FIELD_NOT_PERSISTED` with `stop`: the apply reads the stored record back and compares every field you sent; the write itself is `succeeded`, `data.record` shows what is stored and `data.notPersisted` names the fields — do not repeat the update, the vendor does not support changing them; a replay answers the same); an unknown outcome (`WRITE_OUTCOME_UNKNOWN`,
@@ -472,7 +452,7 @@ No aliases: old names fail as unknown tools.
472
452
  | `quote_get`, `contact_full_profile`, `contract_full_profile`, `project_get_context`, `resource_workload` | same names (toolsets) |
473
453
  | `configurationItem_profile`, `serviceCall_full_profile` | `configuration_item_profile`, `service_call_full_profile` |
474
454
  | `write`, `delete`, `timeEntry_create` | `write {operation, args, idempotency_key?}` then `apply {plan}` (operator profile) |
475
- | env `READ_ONLY` / `WRITE_POLICY` / `WRITE_ALLOW` / `SECRET_READS` | `AUTOTASK_PROFILE` + `AUTOTASK_POLICY_FILE` + `AUTOTASK_STATE_DIR` + `AUTOTASK_RUN_SCOPE`; boot fails if an old variable is set |
455
+ | env `READ_ONLY` / `WRITE_POLICY` / `WRITE_ALLOW` / `SECRET_READS` | `AUTOTASK_PROFILE` + `AUTOTASK_WRITE` + `AUTOTASK_STATE_DIR` + `AUTOTASK_RUN_SCOPE`; boot fails if an old variable is set |
476
456
  | CLI default stdio, bind on all interfaces | http on `127.0.0.1` by default; local clients pass `--transport stdio` |
477
457
  | results: per-tool shapes, errors as a text JSON payload | one envelope; the old payload is the envelope `data` |
478
458
 
@@ -487,12 +467,11 @@ No aliases: old names fail as unknown tools.
487
467
  `tokens`); a server-held credential on HTTP needs `tokens`.
488
468
  4. Read-only use needs nothing else. Pick toolsets with `AUTOTASK_TOOLSETS` if the agent used the
489
469
  service, records or finance tools.
490
- 5. Writes (the operator profile): write a policy file (start from
491
- `examples/policy.time-entries.json`: a time entry there needs `startDateTime` for its duplicate
470
+ 5. Writes (the operator profile): set `AUTOTASK_WRITE` (see [docs/write-gate.md](docs/write-gate.md)) and `AUTOTASK_DEDUP` (a time entry needs `startDateTime` for its duplicate
492
471
  check, Service tickets need `startDateTime` and `endDateTime`, and `billingCodeID` must be an active
493
472
  general allocation code), create the state dir's parent, and set
494
- `AUTOTASK_PROFILE=operator`, `AUTOTASK_POLICY_FILE`, `AUTOTASK_STATE_DIR` and a run scope
495
- (`AUTOTASK_RUN_SCOPE` on stdio, `runScope` per token on HTTP). The first boot creates the state
473
+ `AUTOTASK_PROFILE=operator`, `AUTOTASK_WRITE`, `AUTOTASK_STATE_DIR` and a run scope
474
+ (`AUTOTASK_RUN_SCOPE` on stdio and in gateway mode, shared by every principal; `runScope` per token in tokens mode). The first boot creates the state
496
475
  dir (0700), its key and its lock. There is nothing to migrate: 0.5.x kept no write state.
497
476
  6. Update agent prompts and playbooks to the new tool names (table above) and to the envelope's
498
477
  `status`/`next`.
@@ -512,7 +491,7 @@ preview/apply path; a plan previewed under 0.6.0 cannot be applied after a rollb
512
491
  | `GET /health` | process alive — pre-auth, **no vendor call** (container healthcheck) | none |
513
492
  | `GET /ready` | config valid, SDK plan-hash pin checked, server initialised, client factory ready — **no live vendor call** (credentials are request-scoped). In an operator deployment the payload also carries `lock` (the state lock's age, holder id, lease freshness and the holder count — see "The state lock: recovery") | none |
514
493
  | `GET /doctor` | zero-wire credential/config preflight: per credential field `present`/`len`/`prefix2`/`shapeOk` (`AUTOTASK_SECRET`'s prefix is masked to `[REDACTED]` — no character of a secret is ever emitted; identifiers keep their format prefix), the base-url + allow-list resolution (`default_only` / `allow_list` / `default_and_list` / `any`), the SDK plan-hash gate status and the host-policy mode — **no vendor call, by construction** | Host/Origin validated; a bearer token in `tokens` mode |
515
- | `GET /metadata` | server identity + the MCP protocol **era pair** (baseline `2026-07-28` + legacy fallback `2025-11-25`) + capability-manifest pointer + the `deployment` block (profile, toolsets, approval mode, caller auth, rate limiting) — the served payload is the repo's `server.json` + build stamp | Host/Origin validated; a bearer token in `tokens` mode |
494
+ | `GET /metadata` | server identity + the MCP protocol **era pair** (baseline `2026-07-28` + legacy fallback `2025-11-25`) + capability-manifest pointer + the `deployment` block (profile, toolsets, caller auth, rate limiting) — the served payload is the repo's `server.json` + build stamp | Host/Origin validated; a bearer token in `tokens` mode |
516
495
 
517
496
  ## Configuration
518
497
 
@@ -523,9 +502,12 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
523
502
  |----------|----------|---------|---------|
524
503
  | `MCP_TRANSPORT` | no | `http` | `stdio` or `http`; the CLI `--transport` flag overrides |
525
504
  | `AUTOTASK_PROFILE` | no | `readonly` | `readonly` or `operator` (see [Profiles and writes](#profiles-and-writes)) |
526
- | `AUTOTASK_POLICY_FILE` | operator | — | the write policy file (see [Operator profile](#operator-profile-writes)) |
505
+ | `AUTOTASK_WRITE` | operator | — | per-operation `allow`/`gate`/`deny` (see [docs/write-gate.md](docs/write-gate.md)) |
506
+ | `AUTOTASK_DEDUP` | no | — | create duplicate-check fields, e.g. `companies.create:companyName` |
507
+ | `AUTOTASK_WRITE_LIMITS` | no | — | write caps, e.g. `50/h,200/d` |
508
+ | `AUTOTASK_TRUSTED_HOST_PROMPT` | no | — | `stdio` and client ids whose own prompt confirms a gated apply |
527
509
  | `AUTOTASK_STATE_DIR` | operator | — | the operator state dir (0700) |
528
- | `AUTOTASK_RUN_SCOPE` | operator on stdio | — | `{"companyIds":[...]}` or `{"all":true}` |
510
+ | `AUTOTASK_RUN_SCOPE` | operator on stdio or gateway | — | `{"companyIds":[...]}` or `{"all":true}` |
529
511
  | `AUTOTASK_PRINCIPAL` | no | `stdio` | the stdio operator principal |
530
512
  | `AUTOTASK_KILL_FILE` | no | `<state dir>/KILL` | the kill switch file |
531
513
  | `AUTOTASK_USER_TIMEZONE` | no | `UTC` | IANA time zone of the people asking (their local "today"), not the Autotask tenant's or the server's zone; region/city names only (e.g. `Australia/Melbourne`), kept as written with only the case of a listed name fixed (`Europe/Kyiv` stays `Europe/Kyiv`); `utc`, `Etc/UTC` and `GMT` mean `UTC`; fixed offsets (`+10:00`, `Etc/GMT-10`) and names without a region (`EST`, `Japan`) fail boot. Autotask stores UTC; the zone is told to clients (`autotask_tenant_status` `time` part: zone, local time, UTC time, offset; one line in the server instructions when not UTC) so "today" and "yesterday" become UTC bounds in the right zone for moment fields such as ticket creation and due times. Time entries are calendar days: `dateWorked` and `time_entries.dateWorkedWindow` take plain `YYYY-MM-DD`, not UTC bounds. Filter values are never converted. |
@@ -548,7 +530,7 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
548
530
  | `MCP_HOST` | no | `127.0.0.1` | http bind address (`--host` overrides it). Off loopback, `AUTOTASK_HTTP_ALLOWED_HOSTS` and `AUTOTASK_HTTP_AUTH` are required |
549
531
  | `AUTOTASK_HTTP_ALLOWED_HOSTS` | off loopback | — | `Host` names `/mcp` accepts (DNS-rebinding defence) |
550
532
  | `AUTOTASK_HTTP_ALLOWED_ORIGINS` | no | — | browser `Origin` hostnames `/mcp` accepts; a request without an `Origin` passes |
551
- | `AUTOTASK_HTTP_AUTH` | off loopback; `tokens` for operator or a server-held tuple on http | — | `gateway` or `tokens` |
533
+ | `AUTOTASK_HTTP_AUTH` | off loopback; `tokens` or `gateway` for operator, `tokens` for a server-held tuple on http | — | `gateway` or `tokens` |
552
534
  | `AUTOTASK_HTTP_TOKENS_FILE` | with `tokens` | — | the tokens file (see [Gateway (http, container)](#gateway-http-container)) |
553
535
  | `AUTOTASK_HTTP_RATE_PER_MIN` | no | `60` | requests a minute per principal in `tokens` mode |
554
536
  | `PORT` | no | `8080` | http listener port (`--port` overrides it) |
@@ -854,7 +836,7 @@ malformed).
854
836
  ## Gateway assumptions
855
837
 
856
838
  With `AUTOTASK_HTTP_AUTH=gateway` the gateway is the control plane. It owns: client authentication,
857
- per-tenant tool filtering/exposure, **credential injection** into the `X-Autotask-*` headers and rate
839
+ per-tenant tool filtering/exposure, **credential injection** into the `X-Autotask-*` headers, the per-request identity headers `x-autotask-client-id` and `x-autotask-principal` (see [docs/write-gate.md](docs/write-gate.md#gateway-identity); the gateway must strip client-supplied copies) and rate
858
840
  limiting (`/metadata` states the delegation). The server still validates `Host`/`Origin` and caps
859
841
  bodies at 1 MiB. With `AUTOTASK_HTTP_AUTH=tokens` this process authenticates callers and
860
842
  rate-limits per principal itself. Either way the vendor-side budget (10,000 req/hour, ≤10 concurrent
package/capabilities.json CHANGED
@@ -10,11 +10,9 @@
10
10
  },
11
11
  "profiles": {
12
12
  "readonly": {
13
- "approvalMode": "off",
14
13
  "note": "Reads only. The default."
15
14
  },
16
15
  "operator": {
17
- "approvalMode": "policy",
18
16
  "note": "Adds the operator toolset (preview with autotask_write, execute with autotask_apply)."
19
17
  }
20
18
  },
@@ -348,7 +346,7 @@
348
346
  "type": "write",
349
347
  "toolset": "operator",
350
348
  "profile": "operator",
351
- "backingOperation": "node-autotask/operations invokeWrite / invokeDelete dry run (the keys the policy file lists)",
349
+ "backingOperation": "node-autotask/operations invokeWrite / invokeDelete dry run (the keys AUTOTASK_WRITE lists)",
352
350
  "annotations": {
353
351
  "readOnly": true,
354
352
  "idempotent": true,
@@ -380,7 +378,7 @@
380
378
  "autotask_describe",
381
379
  "autotask_read"
382
380
  ],
383
- "backingRegistryOperations": 1217,
381
+ "backingRegistryOperations": 1225,
384
382
  "note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
385
383
  },
386
384
  "schemas": {}
@@ -0,0 +1,29 @@
1
+ import { type ClientCapabilities, type InputRequiredResult } from '@modelcontextprotocol/server';
2
+ import { type ToolReturn } from '../tools/results.js';
3
+ import type { Confirmation } from '../tools/write/apply-phases.js';
4
+ import type { PlanRecord } from '../write/store.js';
5
+ /** The key the confirm form's answer rides under in `inputResponses`. */
6
+ export declare const GATE_KEY = "confirm";
7
+ export type Caller = {
8
+ transport: 'stdio';
9
+ } | {
10
+ transport: 'http';
11
+ clientId?: string;
12
+ };
13
+ export type GateOutcome = {
14
+ ok: Confirmation;
15
+ } | {
16
+ result: ToolReturn | InputRequiredResult;
17
+ };
18
+ /** What the person reads: the op, its targets and the plan handle, then the data fenced as untrusted and capped. */
19
+ export declare function gateMessage(plan: PlanRecord): string;
20
+ /** Whether the calling client can show a form-mode elicitation. An empty `elicitation: {}` declares form mode. */
21
+ export declare function formElicitation(extra: unknown, caller: Caller, legacyCaps: ClientCapabilities | undefined): boolean;
22
+ /** Decide one gated plan's apply: a confirmation, the confirm form, or a typed deny. Zero vendor calls on every path. */
23
+ export declare function gateApply(a: {
24
+ plan: PlanRecord;
25
+ caller: Caller;
26
+ trusted: ReadonlySet<string>;
27
+ extra: unknown;
28
+ legacyCaps: ClientCapabilities | undefined;
29
+ }): GateOutcome;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * access/gate.ts — the apply-time human gate for a gated plan (#178).
3
+ *
4
+ * A trusted caller's own host prompt counts as the confirmation (AUTOTASK_TRUSTED_HOST_PROMPT). Any
5
+ * other caller whose client shows form elicitation gets one plain confirm form through the SDK's
6
+ * multi-round-trip `input_required` result, and the client retries the same call with the answer.
7
+ * The plan is already held on the server, so there is no code, key or `requestState` to carry.
8
+ * Everything else is a typed `GATE_UNSUPPORTED` deny: an untrusted 2025-era client over HTTP
9
+ * included, because the legacy shim cannot push a request from a stateless per-request instance.
10
+ */
11
+ import * as z from 'zod';
12
+ import { inputRequired } from '@modelcontextprotocol/server';
13
+ import { cleanUntrustedText } from '../tools/results.js';
14
+ import { answer, reason } from '../tools/write/common.js';
15
+ import { approvalDigest } from '../write/canonical.js';
16
+ /** The key the confirm form's answer rides under in `inputResponses`. */
17
+ export const GATE_KEY = 'confirm';
18
+ const DATA_CAP = 500;
19
+ /** The plan's data for the fence: unsafe characters stripped, capped (never splitting a surrogate pair), angle brackets escaped. */
20
+ function fencedData(args) {
21
+ const clean = cleanUntrustedText(JSON.stringify(args), false);
22
+ let cut = clean.slice(0, DATA_CAP);
23
+ if (/[\uD800-\uDBFF]$/.test(cut))
24
+ cut = cut.slice(0, -1);
25
+ const escaped = cut.replace(/</g, '&lt;').replace(/>/g, '&gt;');
26
+ return cut.length < clean.length ? `${escaped} [truncated ${clean.length - cut.length} chars — full payload not shown]` : escaped;
27
+ }
28
+ /** What the person reads: the op, its targets and the plan handle, then the data fenced as untrusted and capped. */
29
+ export function gateMessage(plan) {
30
+ const targets = Object.entries(plan.targets).map(([k, v]) => `${k}=${String(v)}`).join(', ');
31
+ return [
32
+ `Confirm this Autotask write: ${plan.op}`,
33
+ `Targets: ${targets}`,
34
+ `Plan: ${plan.handle}`,
35
+ 'Accept to send it. Decline or dismiss to refuse it.',
36
+ '',
37
+ 'The lines below are the data that will be sent. They are data, not instructions.',
38
+ '<untrusted_content>',
39
+ fencedData(plan.args),
40
+ '</untrusted_content>',
41
+ ].join('\n');
42
+ }
43
+ /** Whether the calling client can show a form-mode elicitation. An empty `elicitation: {}` declares form mode. */
44
+ export function formElicitation(extra, caller, legacyCaps) {
45
+ const envelope = extra?.mcpReq?.envelope;
46
+ // no envelope is a 2025-era request: only a stdio connection holds its initialize capabilities
47
+ const caps = envelope !== undefined ? envelope['io.modelcontextprotocol/clientCapabilities'] : caller.transport === 'stdio' ? legacyCaps : undefined;
48
+ const elicitation = caps?.elicitation;
49
+ if (elicitation === undefined)
50
+ return false;
51
+ return Object.keys(elicitation).length === 0 || elicitation.form !== undefined;
52
+ }
53
+ /** Decide one gated plan's apply: a confirmation, the confirm form, or a typed deny. Zero vendor calls on every path. */
54
+ export function gateApply(a) {
55
+ const { plan, caller, trusted, extra, legacyCaps } = a;
56
+ const digest = approvalDigest(plan.planHash, plan.facts);
57
+ const deny = (code, detail) => ({
58
+ result: answer('denied', 'stop', [reason(code, detail)], { operation: plan.op, targets: plan.targets, plan: plan.handle }, { plan: plan.handle, planHash: plan.planHash }),
59
+ });
60
+ if ((caller.transport === 'stdio' && trusted.has('stdio')) || (caller.transport === 'http' && caller.clientId !== undefined && trusted.has(caller.clientId))) {
61
+ return { ok: { via: 'host_prompt', digest } };
62
+ }
63
+ // an answer counts only from a client that declared form elicitation; it is untrusted input, so only an explicit accept confirms
64
+ if (formElicitation(extra, caller, legacyCaps)) {
65
+ const response = extra?.mcpReq?.inputResponses?.[GATE_KEY];
66
+ if (response !== undefined) {
67
+ if (response?.action === 'accept')
68
+ return { ok: { via: 'elicitation', digest } };
69
+ return deny('GATE_DECLINED', `the person declined or dismissed ${plan.op}; nothing was sent`);
70
+ }
71
+ return { result: inputRequired({ inputRequests: { [GATE_KEY]: inputRequired.elicit({ message: gateMessage(plan), requestedSchema: z.object({}) }) } }) };
72
+ }
73
+ return deny('GATE_UNSUPPORTED', `${plan.op} needs a person to confirm it, and this client cannot show a confirm form (and is not a trusted host); nothing was sent`);
74
+ }
75
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1,27 @@
1
+ import { type WriteShape } from '../write/hard-rules.js';
2
+ export type WriteMode = 'allow' | 'gate' | 'deny';
3
+ export interface ResolvedOp {
4
+ mode: WriteMode;
5
+ rule: string;
6
+ shape: WriteShape;
7
+ upgraded?: true;
8
+ dedup?: readonly string[];
9
+ }
10
+ export interface WriteLimits {
11
+ perHour?: number;
12
+ perDay?: number;
13
+ }
14
+ export interface LoadedPolicy {
15
+ table: ReadonlyMap<string, ResolvedOp>;
16
+ limits?: WriteLimits;
17
+ policyHash: string;
18
+ }
19
+ export declare function resolveWritePolicy(env: {
20
+ write: string;
21
+ dedup?: string;
22
+ limits?: string;
23
+ }): LoadedPolicy;
24
+ /** The trusted host-prompt ids (`stdio` or a connection id), comma separated. */
25
+ export declare function parseTrustedHosts(raw: string | undefined): ReadonlySet<string>;
26
+ /** A `gate` row, or a non-deny move row (a move can escalate to a gate). */
27
+ export declare const anyGate: (loaded: LoadedPolicy) => boolean;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * access/write-env.ts — parse and resolve the env write grammar: `AUTOTASK_WRITE` (per-operation
3
+ * allow/gate/deny), `AUTOTASK_DEDUP`, `AUTOTASK_WRITE_LIMITS` and `AUTOTASK_TRUSTED_HOST_PROMPT`.
4
+ * Pure: resolution needs only the capability registry. Every problem is collected into one ConfigError.
5
+ */
6
+ import { ConfigError } from '../config/error.js';
7
+ import { hashOf } from '../write/canonical.js';
8
+ import { hardRuleFor, MOVE_OPS, writeShape } from '../write/hard-rules.js';
9
+ import { RECORDS, recordById } from '../tools/catalog-data.js';
10
+ const FAMILY = /^[a-z][a-z0-9_]*\.\*$/;
11
+ const EXACT = /^[A-Za-z][A-Za-z0-9_]*\.[A-Za-z][A-Za-z0-9]*$/;
12
+ const FIELD = /^[A-Za-z][A-Za-z0-9_]*$/;
13
+ const LIMIT = /^([1-9]\d{0,5})\/(h|d)$/;
14
+ const TRUSTED = /^[A-Za-z0-9._:-]{1,200}$/;
15
+ /** Operations the preview/apply path can serve: real writes with a snapshot-able shape. */
16
+ const servable = () => {
17
+ const out = new Map();
18
+ for (const r of RECORDS) {
19
+ const shape = writeShape(r);
20
+ if (hardRuleFor(r.id, r).never === undefined && shape !== undefined)
21
+ out.set(r.id, shape);
22
+ }
23
+ return out;
24
+ };
25
+ const fail = (name, problems) => {
26
+ if (problems.length > 0)
27
+ throw new ConfigError(`${name} is invalid:\n ${problems.join('\n ')}`);
28
+ };
29
+ /** `*:gate,tickets.*:allow,...` -> pattern -> mode, rejecting malformed entries. */
30
+ function parseWrite(raw, ops, problems) {
31
+ const rules = new Map();
32
+ for (const entry of raw.split(',').map((e) => e.trim())) {
33
+ if (entry === '') {
34
+ problems.push('empty entry');
35
+ continue;
36
+ }
37
+ const cut = entry.lastIndexOf(':');
38
+ const pattern = (cut < 0 ? entry : entry.slice(0, cut)).trim();
39
+ const mode = cut < 0 ? '' : entry.slice(cut + 1).trim();
40
+ if (mode !== 'allow' && mode !== 'gate' && mode !== 'deny') {
41
+ problems.push(`${entry}: mode must be allow, gate or deny`);
42
+ continue;
43
+ }
44
+ const exact = pattern !== '*' && !FAMILY.test(pattern) && EXACT.test(pattern);
45
+ if (pattern !== '*' && !FAMILY.test(pattern) && !exact) {
46
+ problems.push(`${pattern}: not *, family.* or an operation key`);
47
+ continue;
48
+ }
49
+ if (rules.has(pattern)) {
50
+ problems.push(`${pattern}: listed twice`);
51
+ continue;
52
+ }
53
+ if (FAMILY.test(pattern) && ![...ops.keys()].some((k) => k.startsWith(pattern.slice(0, -1)))) {
54
+ problems.push(`${pattern}: matches no servable write`);
55
+ continue;
56
+ }
57
+ if (exact && !ops.has(pattern)) {
58
+ const rec = recordById(pattern);
59
+ const never = hardRuleFor(pattern, rec).never;
60
+ problems.push(`${pattern}: ${never ?? 'the argument shape is not supported by preview/apply'}`);
61
+ continue;
62
+ }
63
+ if (exact && mode === 'allow' && hardRuleFor(pattern, recordById(pattern)).approval === 'always') {
64
+ problems.push(`${pattern}: always needs a person; use gate`);
65
+ continue;
66
+ }
67
+ rules.set(pattern, mode);
68
+ }
69
+ return rules;
70
+ }
71
+ function parseLimits(raw, problems) {
72
+ if (raw === undefined)
73
+ return undefined;
74
+ const limits = {};
75
+ for (const entry of raw.split(',').map((e) => e.trim())) {
76
+ const m = LIMIT.exec(entry);
77
+ if (!m) {
78
+ problems.push(`${entry === '' ? 'empty entry' : entry}: use <n>/h or <n>/d, n from 1 to 999999`);
79
+ continue;
80
+ }
81
+ const key = m[2] === 'h' ? 'perHour' : 'perDay';
82
+ if (limits[key] !== undefined)
83
+ problems.push(`${entry}: ${m[2]} listed twice`);
84
+ limits[key] = Number(m[1]);
85
+ }
86
+ return limits;
87
+ }
88
+ export function resolveWritePolicy(env) {
89
+ const ops = servable();
90
+ const problems = [];
91
+ const rules = parseWrite(env.write, ops, problems);
92
+ fail('AUTOTASK_WRITE', problems);
93
+ const table = new Map();
94
+ for (const [key, shape] of [...ops].sort(([a], [b]) => (a < b ? -1 : 1))) {
95
+ const family = `${key.slice(0, key.indexOf('.'))}.*`;
96
+ const rule = [key, family, '*'].find((p) => rules.has(p));
97
+ if (rule === undefined)
98
+ continue;
99
+ let mode = rules.get(rule);
100
+ const row = { mode, rule, shape };
101
+ if (mode === 'allow' && hardRuleFor(key, recordById(key)).approval === 'always') {
102
+ mode = 'gate';
103
+ row.mode = mode;
104
+ row.upgraded = true;
105
+ }
106
+ table.set(key, row);
107
+ }
108
+ const dedupProblems = [];
109
+ if (env.dedup !== undefined) {
110
+ const seen = new Set();
111
+ for (const entry of env.dedup.split(',').map((e) => e.trim())) {
112
+ if (entry === '') {
113
+ dedupProblems.push('empty entry');
114
+ continue;
115
+ }
116
+ const cut = entry.indexOf(':');
117
+ const op = (cut < 0 ? entry : entry.slice(0, cut)).trim();
118
+ const fields = cut < 0 ? [''] : entry.slice(cut + 1).trim().split('+');
119
+ if (seen.has(op)) {
120
+ dedupProblems.push(`${op}: listed twice`);
121
+ continue;
122
+ }
123
+ seen.add(op);
124
+ const row = table.get(op);
125
+ if (row === undefined || row.mode === 'deny') {
126
+ dedupProblems.push(`${op}: not served (absent or denied in AUTOTASK_WRITE)`);
127
+ continue;
128
+ }
129
+ if (row.shape !== 'create' && row.shape !== 'create_child') {
130
+ dedupProblems.push(`${op}: dedup applies to creates only`);
131
+ continue;
132
+ }
133
+ if (!fields.every((f) => FIELD.test(f))) {
134
+ dedupProblems.push(`${op}: each field must be a plain field name joined by +`);
135
+ continue;
136
+ }
137
+ table.set(op, { ...row, dedup: fields });
138
+ }
139
+ }
140
+ fail('AUTOTASK_DEDUP', dedupProblems);
141
+ const limitProblems = [];
142
+ const limits = parseLimits(env.limits, limitProblems);
143
+ fail('AUTOTASK_WRITE_LIMITS', limitProblems);
144
+ const policyHash = hashOf({
145
+ ops: Object.fromEntries([...table].map(([k, v]) => [k, { mode: v.mode, dedup: v.dedup ?? null }])),
146
+ limits: limits ?? null,
147
+ });
148
+ return { table, ...(limits ? { limits } : {}), policyHash };
149
+ }
150
+ /** The trusted host-prompt ids (`stdio` or a connection id), comma separated. */
151
+ export function parseTrustedHosts(raw) {
152
+ if (raw === undefined)
153
+ return new Set();
154
+ const bad = raw.split(',').map((e) => e.trim()).filter((e) => !TRUSTED.test(e));
155
+ if (bad.length > 0)
156
+ throw new ConfigError(`AUTOTASK_TRUSTED_HOST_PROMPT is invalid: ${bad.map((b) => JSON.stringify(b)).join(', ')} (letters, digits and . _ : - only)`);
157
+ return new Set(raw.split(',').map((e) => e.trim()));
158
+ }
159
+ /** A `gate` row, or a non-deny move row (a move can escalate to a gate). */
160
+ export const anyGate = (loaded) => [...loaded.table].some(([k, v]) => v.mode === 'gate' || (v.mode !== 'deny' && MOVE_OPS.includes(k)));
161
+ //# sourceMappingURL=write-env.js.map
package/dist/auth.d.ts CHANGED
@@ -32,6 +32,10 @@ export declare const AUTOTASK_SECRET_HEADER = "x-autotask-secret";
32
32
  export declare const AUTOTASK_IMPERSONATION_HEADER = "x-autotask-impersonation-resource-id";
33
33
  /** The header carrying the caller's zone base URL (optional; allow-listed). */
34
34
  export declare const AUTOTASK_BASE_URL_HEADER = "x-autotask-base-url";
35
+ /** The gateway-validated client id (`AUTOTASK_HTTP_AUTH=gateway` only; matched against AUTOTASK_TRUSTED_HOST_PROMPT). */
36
+ export declare const GATEWAY_CLIENT_ID_HEADER = "x-autotask-client-id";
37
+ /** The gateway-validated per-user principal (`AUTOTASK_HTTP_AUTH=gateway` only; the Entra oid). */
38
+ export declare const GATEWAY_PRINCIPAL_HEADER = "x-autotask-principal";
35
39
  /** The headers that carry per-request scope, and so must never be read from a duplicated pair. */
36
40
  export declare const CREDENTIAL_HEADERS: readonly string[];
37
41
  /** The caller's per-request identity, exactly as the transport supplied it. */
@@ -44,6 +48,15 @@ export interface RequestScope {
44
48
  }
45
49
  /** Read the caller's per-request scope from request headers (all-or-nothing values, see module doc). */
46
50
  export declare function readRequestScope(headers: Record<string, string | string[] | undefined>): RequestScope;
51
+ /**
52
+ * Read the identity the gateway stamped on the request. Callers must read it ONLY in gateway
53
+ * mode: anywhere else these headers are caller-controlled and mean nothing. Duplicated or
54
+ * malformed values are absent.
55
+ */
56
+ export declare function readGatewayIdentity(headers: Record<string, string | string[] | undefined>): {
57
+ clientId?: string;
58
+ principal?: string;
59
+ };
47
60
  /**
48
61
  * How many of the three identity members this scope presents.
49
62
  *